Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions api/server/routes/__tests__/config.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ describe('GET /api/config', () => {
expect(response.body).not.toHaveProperty('sharePointPickerSharePointScope');
expect(response.body).not.toHaveProperty('conversationImportMaxFileSize');
expect(response.body).not.toHaveProperty('insightsEnabled');
expect(response.body).not.toHaveProperty('pullRequestsEnabled');
expect(response.body).not.toHaveProperty('mcpApps');
});

Expand Down Expand Up @@ -669,6 +670,28 @@ describe('GET /api/config', () => {
expect(response.body.insightsEnabled).toBe(true);
});

it.each([
['unset', undefined, false],
['disabled', { enabled: false }, false],
['enabled', { enabled: true }, true],
])(
'should advertise pull requests only when they are enabled (%s)',
async (_label, pullRequests, expected) => {
mockGetAppConfig.mockResolvedValue({
...baseAppConfig,
endpoints: { agents: pullRequests ? { pullRequests } : {} },
});
const response = await request(createApp(mockUser)).get('/api/config');
expect(response.body.pullRequestsEnabled).toBe(expected);
},
);

it('should not advertise pull requests for a config with no endpoints', async () => {
mockGetAppConfig.mockResolvedValue(baseAppConfig);
const response = await request(createApp(mockUser)).get('/api/config');
expect(response.body.pullRequestsEnabled).toBe(false);
});

it('should advertise Langfuse fanout only when the toggle and collector URL are configured', async () => {
mockGetAppConfig.mockResolvedValue(baseAppConfig);
mockHasCapability.mockResolvedValue(true);
Expand Down
3 changes: 3 additions & 0 deletions api/server/routes/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,9 @@ router.get('/', async function (req, res) {
langfuseFanoutEnabled,
langfuseConnectionAccess,
insightsEnabled: isEnabled(process.env.ENABLE_INSIGHTS),
/** Lets the client skip the pull request lookup entirely when the feature is off. */
pullRequestsEnabled:
appConfig?.endpoints?.[EModelEndpoint.agents]?.pullRequests?.enabled === true,
compactionEnabled: appConfig?.summarization?.enabled !== false,
...(codeEnvironmentDecisionVersion != null ? { codeEnvironmentDecisionVersion } : {}),
mcpApps: resolveMCPAppsPolicy(
Expand Down
9 changes: 9 additions & 0 deletions client/src/components/Chat/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { TraceButton, useTraceControl } from './Trace';
import { useGetStartupConfig } from '~/data-provider';
import ExportAndShareMenu from './ExportAndShareMenu';
import SubagentThreadLink from './SubagentThreadLink';
import { PullRequestChip } from './PullRequest';
import BookmarkMenu from './Menus/BookmarkMenu';
import AddMultiConvo from './AddMultiConvo';
import { useHasAccess } from '~/hooks';
Expand Down Expand Up @@ -112,6 +113,11 @@ function Header({
<AddMultiConvo />
</div>
)}
{/* Desktop only: its details open to the right, into the room this cluster leaves free.
Small screens reach the same card from the overflow menu. */}
{!isSmallScreen && !isNewChat && parentConversationId == null && (
<PullRequestChip key={`pr-${routeConversationId}`} conversationId={routeConversationId} />
)}
</div>

<div className={cn('flex shrink-0 items-center gap-2', hiddenBehindNav)}>
Expand All @@ -128,6 +134,9 @@ function Header({
startupConfig={startupConfig}
trace={trace}
readOnly={readOnly}
pullRequestConversationId={
isNewChat || parentConversationId != null ? undefined : routeConversationId
}
className={isSmallScreen ? undefined : 'hidden'}
/>
<div className={cn('items-center gap-2', isSmallScreen ? 'hidden' : 'flex')}>
Expand Down
10 changes: 10 additions & 0 deletions client/src/components/Chat/Menus/HeaderMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { Ellipsis, PlusCircle, HatGlasses, Check, ChartNoAxesGantt } from 'lucid
import type { TStartupConfig } from 'librechat-data-provider';
import type { TraceControl } from '~/components/Chat/Trace';
import type * as t from '~/common';
import { usePullRequestMenu } from '~/components/Chat/PullRequest';
import { BookmarkContext } from '~/Providers/BookmarkContext';
import useBookmarkItems from '~/hooks/Chat/useBookmarkItems';
import useTemporaryChat from '~/hooks/Chat/useTemporaryChat';
Expand All @@ -26,12 +27,15 @@ export default function HeaderMenu({
trace,
className,
readOnly = false,
pullRequestConversationId,
}: {
startupConfig?: TStartupConfig;
/** A read-only subagent thread offers share and export but no edits to the chat. */
readOnly?: boolean;
/** Owned by the header, which also renders the desktop trace button from it. */
trace?: TraceControl;
/** The saved conversation whose pull request this menu offers; none for a new chat or a child thread. */
pullRequestConversationId?: string;
className?: string;
}) {
const localize = useLocalize();
Expand All @@ -51,6 +55,7 @@ export default function HeaderMenu({
permission: Permissions.USE,
});

const pullRequest = usePullRequestMenu(pullRequestConversationId ?? '');
const multiConvo = useMultiConvo();
const temporary = useTemporaryChat();
const bookmarks = useBookmarkItems({ enabled: hasAccessToBookmarks === true });
Expand Down Expand Up @@ -106,6 +111,10 @@ export default function HeaderMenu({
});
}

if (pullRequest.item != null) {
pushGroup(pullRequest.item);
}

if (exportShare.show) {
pushGroup(...exportShare.items);
}
Expand Down Expand Up @@ -182,6 +191,7 @@ export default function HeaderMenu({
/>
{showBookmarks && bookmarks.dialog}
{exportShare.dialogs}
{pullRequest.dialog}
</BookmarkContext.Provider>
);
}
59 changes: 59 additions & 0 deletions client/src/components/Chat/Menus/__tests__/HeaderMenu.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@ import type { MenuItemProps } from '~/common';

const mockAccess: Record<string, boolean> = {};
const mockBookmarkArgs: { enabled?: boolean }[] = [];
const mockPullRequestArgs: string[] = [];
const mockHookState = {
pullRequest: {
item: undefined as MenuItemProps | undefined,
dialog: null as React.ReactNode,
},
multiConvo: { show: true, addConversation: jest.fn() },
temporary: { show: true, isTemporary: false, toggle: jest.fn() },
bookmarks: {
Expand All @@ -29,6 +34,12 @@ jest.mock('~/hooks', () => ({
mockAccess[permissionType] ?? true,
}));

jest.mock('~/components/Chat/PullRequest', () => ({
usePullRequestMenu: (conversationId: string) => {
mockPullRequestArgs.push(conversationId);
return mockHookState.pullRequest;
},
}));
jest.mock('~/hooks/Chat/useMultiConvo', () => ({
__esModule: true,
default: () => mockHookState.multiConvo,
Expand Down Expand Up @@ -101,6 +112,8 @@ const labels = () =>
describe('HeaderMenu', () => {
beforeEach(() => {
mockBookmarkArgs.length = 0;
mockPullRequestArgs.length = 0;
mockHookState.pullRequest = { item: undefined, dialog: null };
for (const key of Object.keys(mockAccess)) {
delete mockAccess[key];
}
Expand All @@ -125,6 +138,52 @@ describe('HeaderMenu', () => {
]);
});

it('offers the pull request in its own group, ahead of share and export', () => {
mockHookState.pullRequest = {
item: { label: 'PR #7' },
dialog: <div data-testid="pull-request-dialog" />,
};

render(<HeaderMenu pullRequestConversationId="convo-1" />);

expect(labels()).toEqual([
'com_ui_bookmarks',
'com_ui_add_multi_conversation',
'PR #7',
'share',
'export',
'com_ui_temporary',
]);
expect(screen.getByTestId('pull-request-dialog')).toBeInTheDocument();
expect(mockPullRequestArgs).toContain('convo-1');
});

it('leaves the menu unchanged when the chat has no pull request', () => {
render(<HeaderMenu pullRequestConversationId="convo-1" />);

expect(labels()).not.toContain('PR #7');
expect(screen.queryByTestId('pull-request-dialog')).not.toBeInTheDocument();
});

it('asks for no pull request when the header names no conversation', () => {
render(<HeaderMenu />);

expect(mockPullRequestArgs.every((id) => id === '')).toBe(true);
});

it('shows the menu for a pull request alone', () => {
mockHookState.multiConvo.show = false;
mockHookState.temporary.show = false;
mockHookState.bookmarks.show = false;
mockHookState.exportShare.show = false;
mockHookState.pullRequest = { item: { label: 'PR #7' }, dialog: null };

render(<HeaderMenu pullRequestConversationId="convo-1" />);

expect(labels()).toEqual(['PR #7']);
expect(rows()[0]).toHaveAttribute('data-kind', 'item');
});

it('keeps every action reachable when groups are divided', () => {
render(<HeaderMenu />);

Expand Down
88 changes: 88 additions & 0 deletions client/src/components/Chat/PullRequest/Card.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { memo } from 'react';
import { Button, Chip, GithubIcon, TooltipAnchor } from '@librechat/client';
import type { TConversationPullRequest } from 'librechat-data-provider';
import { presentPullRequest } from './status';
import { useLocalize } from '~/hooks';
import PullRequestIcon from './Icon';

type CardProps = {
pullRequest: TConversationPullRequest;
/** A refresh failed; the last known pull request stays visible with a way to retry. */
refreshFailed?: boolean;
onRetry?: () => void;
/** The layer the card lives in, so its tooltip is not painted behind it. */
portalElement?: HTMLElement | null;
};

function PullRequestCard({
pullRequest,
refreshFailed = false,
onRetry,
portalElement,
}: CardProps) {
const localize = useLocalize();
const view = presentPullRequest(pullRequest);
const openLabel = localize('com_ui_pr_open_in_github');

return (
<div className="flex flex-col gap-3 p-3" data-testid="pull-request-card">
<div className="flex items-center gap-2">
<PullRequestIcon icon={view.icon} tone={view.iconTone} className="size-4 shrink-0" />
<span className="text-text-primary text-sm font-medium">
{localize('com_ui_pr_label', { 0: pullRequest.number })}
</span>
<span className="ml-auto flex items-center gap-1.5 text-xs font-medium">
<span
className="text-status-success"
aria-label={localize('com_ui_pr_additions', { 0: pullRequest.additions })}
>
+{pullRequest.additions}
</span>
<span
className="text-status-error"
aria-label={localize('com_ui_pr_deletions', { 0: pullRequest.deletions })}
>
-{pullRequest.deletions}
</span>
</span>
<TooltipAnchor
description={openLabel}
Comment thread
berry-13 marked this conversation as resolved.
portalElement={portalElement}
render={
<a
href={pullRequest.url}
target="_blank"
rel="noopener noreferrer"
aria-label={openLabel}
data-testid="pull-request-github-link"
className="text-text-secondary hover:bg-surface-hover hover:text-text-primary focus-visible:ring-text-primary flex size-7 shrink-0 items-center justify-center rounded-md outline-hidden focus-visible:ring-2"
>
<GithubIcon />
</a>
}
/>
</div>
<p className="text-text-primary line-clamp-3 text-sm break-words">{pullRequest.title}</p>
<div className="flex flex-wrap gap-2">
<Chip tone={view.stateTone} shape="theme" data-testid="pull-request-state-badge">
{localize('com_ui_pr_state', { 0: localize(view.stateKey) })}
</Chip>
<Chip tone={view.checksTone} shape="theme" data-testid="pull-request-checks-badge">
{localize('com_ui_pr_checks', { 0: localize(view.checksKey) })}
</Chip>
</div>
{refreshFailed && (
<div role="status" className="text-status-warning flex items-center gap-2 text-xs">
<span>{localize('com_ui_pr_refresh_failed')}</span>
{onRetry != null && (
<Button type="button" variant="outline" size="sm" onClick={onRetry}>
{localize('com_ui_retry')}
</Button>
)}
</div>
)}
</div>
);
}

export default memo(PullRequestCard);
Loading
Loading