diff --git a/api/server/routes/__tests__/config.spec.js b/api/server/routes/__tests__/config.spec.js
index 5042cff638f..07f4de42348 100644
--- a/api/server/routes/__tests__/config.spec.js
+++ b/api/server/routes/__tests__/config.spec.js
@@ -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');
});
@@ -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);
diff --git a/api/server/routes/config.js b/api/server/routes/config.js
index fd0adb57ae9..3336a94d4c7 100644
--- a/api/server/routes/config.js
+++ b/api/server/routes/config.js
@@ -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(
diff --git a/client/src/components/Chat/Header.tsx b/client/src/components/Chat/Header.tsx
index 45fd682b1a4..8d50f6f122b 100644
--- a/client/src/components/Chat/Header.tsx
+++ b/client/src/components/Chat/Header.tsx
@@ -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';
@@ -112,6 +113,11 @@ function Header({
)}
+ {/* 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 && (
+
+ )}
@@ -128,6 +134,9 @@ function Header({
startupConfig={startupConfig}
trace={trace}
readOnly={readOnly}
+ pullRequestConversationId={
+ isNewChat || parentConversationId != null ? undefined : routeConversationId
+ }
className={isSmallScreen ? undefined : 'hidden'}
/>
diff --git a/client/src/components/Chat/Menus/HeaderMenu.tsx b/client/src/components/Chat/Menus/HeaderMenu.tsx
index 1a7b37d0a89..4e6a481b6a5 100644
--- a/client/src/components/Chat/Menus/HeaderMenu.tsx
+++ b/client/src/components/Chat/Menus/HeaderMenu.tsx
@@ -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';
@@ -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();
@@ -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 });
@@ -106,6 +111,10 @@ export default function HeaderMenu({
});
}
+ if (pullRequest.item != null) {
+ pushGroup(pullRequest.item);
+ }
+
if (exportShare.show) {
pushGroup(...exportShare.items);
}
@@ -182,6 +191,7 @@ export default function HeaderMenu({
/>
{showBookmarks && bookmarks.dialog}
{exportShare.dialogs}
+ {pullRequest.dialog}
);
}
diff --git a/client/src/components/Chat/Menus/__tests__/HeaderMenu.spec.tsx b/client/src/components/Chat/Menus/__tests__/HeaderMenu.spec.tsx
index 52b2326ca6a..399a9453fc0 100644
--- a/client/src/components/Chat/Menus/__tests__/HeaderMenu.spec.tsx
+++ b/client/src/components/Chat/Menus/__tests__/HeaderMenu.spec.tsx
@@ -3,7 +3,12 @@ import type { MenuItemProps } from '~/common';
const mockAccess: Record
= {};
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: {
@@ -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,
@@ -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];
}
@@ -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: ,
+ };
+
+ render();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ expect(labels()).toEqual(['PR #7']);
+ expect(rows()[0]).toHaveAttribute('data-kind', 'item');
+ });
+
it('keeps every action reachable when groups are divided', () => {
render();
diff --git a/client/src/components/Chat/PullRequest/Card.tsx b/client/src/components/Chat/PullRequest/Card.tsx
new file mode 100644
index 00000000000..dcb5e1d9faa
--- /dev/null
+++ b/client/src/components/Chat/PullRequest/Card.tsx
@@ -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 (
+
+
+
+
+ {localize('com_ui_pr_label', { 0: pullRequest.number })}
+
+
+
+ +{pullRequest.additions}
+
+
+ -{pullRequest.deletions}
+
+
+
+
+
+ }
+ />
+
+
{pullRequest.title}
+
+
+ {localize('com_ui_pr_state', { 0: localize(view.stateKey) })}
+
+
+ {localize('com_ui_pr_checks', { 0: localize(view.checksKey) })}
+
+
+ {refreshFailed && (
+
+ {localize('com_ui_pr_refresh_failed')}
+ {onRetry != null && (
+
+ )}
+
+ )}
+
+ );
+}
+
+export default memo(PullRequestCard);
diff --git a/client/src/components/Chat/PullRequest/Chip.tsx b/client/src/components/Chat/PullRequest/Chip.tsx
new file mode 100644
index 00000000000..18c3bde4c15
--- /dev/null
+++ b/client/src/components/Chat/PullRequest/Chip.tsx
@@ -0,0 +1,130 @@
+import { memo } from 'react';
+import * as Ariakit from '@ariakit/react';
+import { useConversationPullRequestQuery } from '~/data-provider';
+import { useAgentsMapContext, useChatContext } from '~/Providers';
+import { TONE_DOT_CLASS, presentPullRequest } from './status';
+import { URLIcon } from '~/components/Endpoints/URLIcon';
+import { summarizePullRequest } from './summary';
+import { useLocalize } from '~/hooks';
+import PullRequestIcon from './Icon';
+import PullRequestCard from './Card';
+import { cn } from '~/utils';
+
+/** Slides in from the chip's side, left to right, and fades; reduced motion only fades. */
+const cardClass = cn(
+ 'border-border-light bg-surface-secondary text-text-primary z-[200] w-80 max-w-[calc(100vw-2rem)] rounded-xl border shadow-lg focus:outline-none',
+ 'origin-left -translate-x-3 opacity-0 transition duration-200 ease-out',
+ 'data-[enter]:translate-x-0 data-[enter]:opacity-100',
+ 'data-[leave]:-translate-x-3 data-[leave]:opacity-0',
+ 'motion-reduce:translate-x-0 motion-reduce:transition-opacity',
+);
+
+function CiDot({ dotClass }: { dotClass: string }) {
+ return (
+
+ );
+}
+
+/** The agent's picture with the CI dot on its corner. */
+function AgentAvatar({
+ avatar,
+ name,
+ dotClass,
+}: {
+ avatar: string;
+ name?: string | null;
+ dotClass: string | null;
+}) {
+ return (
+
+
+ {dotClass != null && }
+
+ );
+}
+
+/**
+ * Desktop header control for the pull request a code conversation opened. It renders nothing
+ * until a pull request is known: a conversation without one, a disabled feature and a failed
+ * first lookup all leave the header exactly as it was, so the row never shifts. The details
+ * open to the right of the chip on hover or keyboard focus and slide in from the left. The card
+ * holds a link, so it is an Ariakit hovercard: it stays open while the pointer or focus is
+ * inside it, and Tab moves from the chip into it.
+ */
+function PullRequestChip({ conversationId }: { conversationId: string }) {
+ const localize = useLocalize();
+ const { conversation } = useChatContext();
+ const agentsMap = useAgentsMapContext();
+ const hovercard = Ariakit.useHovercardStore({
+ placement: 'right-start',
+ showTimeout: 100,
+ hideTimeout: 150,
+ });
+ const open = Ariakit.useStoreState(hovercard, 'open');
+ const cardElement = Ariakit.useStoreState(hovercard, 'contentElement');
+ const { data, isError, refetch } = useConversationPullRequestQuery(conversationId);
+ const pullRequest = data?.pullRequest;
+
+ if (pullRequest == null) return null;
+
+ const view = presentPullRequest(pullRequest);
+ const label = summarizePullRequest(pullRequest, localize);
+ const dotClass = view.dotTone == null ? null : TONE_DOT_CLASS[view.dotTone];
+ /** The chat state can lag the route by a render; only its own conversation's agent applies. */
+ const agentId =
+ conversation?.conversationId === conversationId ? conversation.agent_id : undefined;
+ const agent = agentId == null ? undefined : agentsMap?.[agentId];
+ const avatar = agent?.avatar?.filepath ?? '';
+ const hasAvatar = avatar !== '';
+
+ return (
+
+ hovercard.show()}
+ className="border-border-light bg-presentation text-text-primary hover:bg-surface-tertiary aria-expanded:bg-surface-tertiary inline-flex h-9 max-w-[14rem] min-w-0 flex-shrink items-center gap-1.5 rounded-xl border px-2 text-sm transition-all ease-in-out"
+ >
+ {hasAvatar && }
+
+
+ {!hasAvatar && dotClass != null && }
+
+ {pullRequest.title}
+
+ }
+ />
+
+ void refetch()}
+ portalElement={cardElement}
+ />
+
+
+ );
+}
+
+export default memo(PullRequestChip);
diff --git a/client/src/components/Chat/PullRequest/Icon.tsx b/client/src/components/Chat/PullRequest/Icon.tsx
new file mode 100644
index 00000000000..9373e31a7b1
--- /dev/null
+++ b/client/src/components/Chat/PullRequest/Icon.tsx
@@ -0,0 +1,25 @@
+import { GitMerge, GitPullRequest, GitPullRequestClosed, GitPullRequestDraft } from 'lucide-react';
+import type { PullRequestIcon as IconName, PullRequestTone } from './status';
+import { TONE_ICON_CLASS } from './status';
+import { cn } from '~/utils';
+
+const ICONS = {
+ open: GitPullRequest,
+ draft: GitPullRequestDraft,
+ merged: GitMerge,
+ closed: GitPullRequestClosed,
+} as const;
+
+/** Decorative: the surrounding label already says what state the pull request is in. */
+export default function PullRequestIcon({
+ icon,
+ tone,
+ className,
+}: {
+ icon: IconName;
+ tone: PullRequestTone;
+ className?: string;
+}) {
+ const Icon = ICONS[icon];
+ return ;
+}
diff --git a/client/src/components/Chat/PullRequest/__tests__/Card.test.tsx b/client/src/components/Chat/PullRequest/__tests__/Card.test.tsx
new file mode 100644
index 00000000000..84b90506f0a
--- /dev/null
+++ b/client/src/components/Chat/PullRequest/__tests__/Card.test.tsx
@@ -0,0 +1,80 @@
+import React from 'react';
+import '@testing-library/jest-dom';
+import userEvent from '@testing-library/user-event';
+import { render, screen, within } from '@testing-library/react';
+import type { TConversationPullRequest } from 'librechat-data-provider';
+import PullRequestCard from '../Card';
+
+jest.mock('~/hooks', () => ({
+ useLocalize: () => (key: string, values?: Record) =>
+ values == null ? key : `${key}:${Object.values(values).join(',')}`,
+}));
+
+const pr: TConversationPullRequest = {
+ number: 7,
+ title: 'Simplify Single Tool Execution Path',
+ url: 'https://github.com/o/r/pull/7',
+ additions: 12,
+ deletions: 3,
+ state: 'open',
+ isDraft: false,
+ mergeable: 'conflicting',
+ checks: 'failing',
+};
+
+describe('PullRequestCard', () => {
+ it('shows conflicts and failing checks as error badges', () => {
+ render();
+ const state = screen.getByTestId('pull-request-state-badge');
+ const checks = screen.getByTestId('pull-request-checks-badge');
+ expect(state).toHaveTextContent('com_ui_pr_state:com_ui_pr_state_conflicts');
+ expect(checks).toHaveTextContent('com_ui_pr_checks:com_ui_pr_checks_failing');
+ expect(state).toHaveClass('text-status-error');
+ expect(checks).toHaveClass('text-status-error');
+ });
+
+ it('renders the badges with the shared Chip, so they follow its sizing and theming', () => {
+ render();
+ for (const id of ['pull-request-state-badge', 'pull-request-checks-badge']) {
+ const badge = screen.getByTestId(id);
+ expect(badge).toHaveClass('inline-flex', 'rounded-theme-control', 'min-h-6');
+ }
+ });
+
+ it('puts the GitHub tooltip in the layer the card lives in, not behind it', async () => {
+ const layer = document.createElement('div');
+ document.body.appendChild(layer);
+ render();
+ await userEvent.tab();
+ expect(screen.getByTestId('pull-request-github-link')).toHaveFocus();
+ expect(await within(layer).findByRole('tooltip')).toHaveTextContent('com_ui_pr_open_in_github');
+ layer.remove();
+ });
+
+ it('colors added lines and removed lines with their own roles and names them for a screen reader', () => {
+ render();
+ expect(screen.getByText('+12')).toHaveClass('text-status-success');
+ expect(screen.getByText('-3')).toHaveClass('text-status-error');
+ expect(screen.getByLabelText('com_ui_pr_additions:12')).toBeInTheDocument();
+ expect(screen.getByLabelText('com_ui_pr_deletions:3')).toBeInTheDocument();
+ });
+
+ it('does not show a refresh warning by default', () => {
+ render();
+ expect(screen.queryByText('com_ui_pr_refresh_failed')).not.toBeInTheDocument();
+ });
+
+ it('announces a failed refresh and retries on request', async () => {
+ const onRetry = jest.fn();
+ render();
+ expect(screen.getByRole('status')).toHaveTextContent('com_ui_pr_refresh_failed');
+ await userEvent.click(screen.getByRole('button', { name: 'com_ui_retry' }));
+ expect(onRetry).toHaveBeenCalledTimes(1);
+ });
+
+ it('shows a failed refresh without a retry button when no handler is given', () => {
+ render();
+ expect(screen.getByRole('status')).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'com_ui_retry' })).not.toBeInTheDocument();
+ });
+});
diff --git a/client/src/components/Chat/PullRequest/__tests__/Chip.test.tsx b/client/src/components/Chat/PullRequest/__tests__/Chip.test.tsx
new file mode 100644
index 00000000000..dc3db2d59a3
--- /dev/null
+++ b/client/src/components/Chat/PullRequest/__tests__/Chip.test.tsx
@@ -0,0 +1,262 @@
+import React from 'react';
+import '@testing-library/jest-dom';
+import userEvent from '@testing-library/user-event';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { render, screen, waitFor, within, fireEvent, act } from '@testing-library/react';
+import type { TConversationPullRequest } from 'librechat-data-provider';
+import PullRequestChip from '../Chip';
+
+const mockGet = jest.fn();
+
+jest.mock('~/data-provider/Endpoints', () => ({
+ useGetStartupConfig: () => ({ data: { pullRequestsEnabled: true } }),
+}));
+
+jest.mock('librechat-data-provider', () => {
+ const actual = jest.requireActual('librechat-data-provider');
+ return {
+ ...actual,
+ dataService: {
+ ...actual.dataService,
+ getConversationPullRequest: (...args: unknown[]) => mockGet(...args),
+ },
+ };
+});
+
+jest.mock('~/hooks', () => ({
+ useLocalize: () => (key: string, values?: Record) =>
+ values == null ? key : `${key}:${Object.values(values).join(',')}`,
+}));
+const mockAgents: { current: Record } = {
+ current: { 'agent-1': { name: 'Coder' } },
+};
+const mockChat: { current: { conversationId?: string; agent_id?: string } } = {
+ current: { conversationId: 'convo-1', agent_id: 'agent-1' },
+};
+jest.mock('~/Providers', () => ({
+ useAgentsMapContext: () => mockAgents.current,
+ useChatContext: () => ({ conversation: mockChat.current }),
+}));
+
+/**
+ * Ariakit opens a hovercard only for a pointer that is really moving, and only accepts a bare
+ * synthetic hover when NODE_ENV is "test". CI runs the suite with NODE_ENV=development, so the
+ * tests move the pointer the way a browser reports it.
+ */
+let pointerX = 100;
+const movePointerOver = (element: HTMLElement) => {
+ pointerX += 7;
+ fireEvent.mouseMove(element, {
+ screenX: pointerX,
+ screenY: pointerX,
+ movementX: 7,
+ movementY: 7,
+ });
+};
+const movePointerAway = (element: HTMLElement) => {
+ fireEvent.mouseLeave(element);
+ fireEvent.mouseMove(document.body, { screenX: 900, screenY: 900, movementX: 7, movementY: 7 });
+};
+
+const pr: TConversationPullRequest = {
+ number: 1234,
+ title: 'Simplify Single Tool Execution Path',
+ url: 'https://github.com/LibreChat-AI/LibreChat/pull/1234',
+ additions: 1234,
+ deletions: 56,
+ state: 'open',
+ isDraft: false,
+ mergeable: 'clean',
+ checks: 'passing',
+};
+
+const renderChip = () => {
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ return render(
+
+
+ ,
+ );
+};
+
+describe('PullRequestChip', () => {
+ beforeEach(() => {
+ mockGet.mockReset();
+ mockAgents.current = { 'agent-1': { name: 'Coder' } };
+ mockChat.current = { conversationId: 'convo-1', agent_id: 'agent-1' };
+ });
+
+ it('renders nothing while loading, so the header does not shift', () => {
+ mockGet.mockReturnValue(new Promise(() => undefined));
+ const { container } = renderChip();
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it('renders nothing for a conversation without a pull request', async () => {
+ mockGet.mockResolvedValue({ pullRequest: null });
+ const { container } = renderChip();
+ await waitFor(() => expect(mockGet).toHaveBeenCalled());
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it('renders nothing when the first lookup fails', async () => {
+ mockGet.mockRejectedValue(new Error('503'));
+ const { container } = renderChip();
+ await waitFor(() => expect(mockGet).toHaveBeenCalled());
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it('shows the title, a CI dot and a label that states everything the colors say', async () => {
+ mockGet.mockResolvedValue({ pullRequest: pr });
+ renderChip();
+ const button = await screen.findByTestId('header-pull-request-button');
+ expect(button).toHaveTextContent(pr.title);
+ expect(screen.getByTestId('pull-request-ci-dot')).toHaveClass('bg-status-success');
+ const label = button.getAttribute('aria-label') ?? '';
+ expect(label).toContain(pr.title);
+ expect(label).toContain('com_ui_pr_state:com_ui_pr_state_open');
+ expect(label).toContain('com_ui_pr_checks:com_ui_pr_checks_passing');
+ });
+
+ it.each([
+ ['failing', 'bg-status-error'],
+ ['running', 'bg-status-warning'],
+ ] as const)('colors the dot for %s checks', async (checks, dotClass) => {
+ mockGet.mockResolvedValue({ pullRequest: { ...pr, checks } });
+ renderChip();
+ expect(await screen.findByTestId('pull-request-ci-dot')).toHaveClass(dotClass);
+ });
+
+ it('puts the dot on the agent picture when the agent has one, and not on the icon', async () => {
+ mockAgents.current = {
+ 'agent-1': { name: 'Coder', avatar: { filepath: '/images/coder.png' } },
+ };
+ mockGet.mockResolvedValue({ pullRequest: pr });
+ renderChip();
+ const dot = await screen.findByTestId('pull-request-ci-dot');
+ expect(screen.getAllByTestId('pull-request-ci-dot')).toHaveLength(1);
+ expect(dot.parentElement?.querySelector('img')).not.toBeNull();
+ });
+
+ it('does not borrow the agent of the previous conversation while the chat state catches up', async () => {
+ mockAgents.current = {
+ 'agent-1': { name: 'Coder', avatar: { filepath: '/images/coder.png' } },
+ };
+ mockChat.current = { conversationId: 'previous-convo', agent_id: 'agent-1' };
+ mockGet.mockResolvedValue({ pullRequest: pr });
+ renderChip();
+ const dot = await screen.findByTestId('pull-request-ci-dot');
+ expect(dot.parentElement?.querySelector('img')).toBeNull();
+ expect(dot.parentElement?.querySelector('svg')).not.toBeNull();
+ });
+
+ it('keeps the dot on the pull request icon when the agent has no picture', async () => {
+ mockGet.mockResolvedValue({ pullRequest: pr });
+ renderChip();
+ const dot = await screen.findByTestId('pull-request-ci-dot');
+ expect(screen.getAllByTestId('pull-request-ci-dot')).toHaveLength(1);
+ expect(dot.parentElement?.querySelector('svg')).not.toBeNull();
+ expect(dot.parentElement?.querySelector('img')).toBeNull();
+ });
+
+ it('shows no dot when there are no checks', async () => {
+ mockGet.mockResolvedValue({ pullRequest: { ...pr, checks: 'none' } });
+ renderChip();
+ await screen.findByTestId('header-pull-request-button');
+ expect(screen.queryByTestId('pull-request-ci-dot')).not.toBeInTheDocument();
+ });
+
+ it('keeps the card closed until the chip is hovered or focused', async () => {
+ mockGet.mockResolvedValue({ pullRequest: pr });
+ renderChip();
+ const button = await screen.findByTestId('header-pull-request-button');
+ expect(screen.queryByTestId('pull-request-card')).not.toBeInTheDocument();
+ expect(button).toHaveAttribute('aria-expanded', 'false');
+ });
+
+ it('opens the card on hover and says so on the chip', async () => {
+ mockGet.mockResolvedValue({ pullRequest: pr });
+ renderChip();
+ const button = await screen.findByTestId('header-pull-request-button');
+ movePointerOver(button);
+ const card = await screen.findByTestId('pull-request-card');
+ expect(card.closest('[role="dialog"]')).toHaveAccessibleName('com_ui_pull_request');
+ expect(button).toHaveAttribute('aria-expanded', 'true');
+ });
+
+ it('closes the card when the pointer leaves', async () => {
+ mockGet.mockResolvedValue({ pullRequest: pr });
+ renderChip();
+ const button = await screen.findByTestId('header-pull-request-button');
+ movePointerOver(button);
+ await screen.findByTestId('pull-request-card');
+ movePointerAway(button);
+ await waitFor(() => expect(screen.queryByTestId('pull-request-card')).not.toBeInTheDocument());
+ expect(button).toHaveAttribute('aria-expanded', 'false');
+ });
+
+ it('opens the card for keyboard focus', async () => {
+ mockGet.mockResolvedValue({ pullRequest: pr });
+ renderChip();
+ await screen.findByTestId('header-pull-request-button');
+ await userEvent.tab();
+ expect(screen.getByTestId('header-pull-request-button')).toHaveFocus();
+ expect(await screen.findByTestId('pull-request-card')).toBeInTheDocument();
+ });
+
+ it('shows the GitHub tooltip inside the open card, so it is not painted behind it', async () => {
+ mockGet.mockResolvedValue({ pullRequest: pr });
+ renderChip();
+ movePointerOver(await screen.findByTestId('header-pull-request-button'));
+ const card = await screen.findByTestId('pull-request-card');
+ const layer = card.closest('[role="dialog"]') as HTMLElement;
+ await userEvent.tab();
+ act(() => screen.getByTestId('pull-request-github-link').focus());
+ expect(await within(layer).findByRole('tooltip')).toHaveTextContent('com_ui_pr_open_in_github');
+ });
+
+ it('opens a card with the number, line counts, badges and a safe GitHub link', async () => {
+ mockGet.mockResolvedValue({ pullRequest: pr });
+ renderChip();
+ await userEvent.click(await screen.findByTestId('header-pull-request-button'));
+ const card = await screen.findByTestId('pull-request-card');
+ expect(within(card).getByText('com_ui_pr_label:1234')).toBeInTheDocument();
+ expect(within(card).getByText('+1234')).toBeInTheDocument();
+ expect(within(card).getByText('-56')).toBeInTheDocument();
+ expect(within(card).getByText(pr.title)).toBeInTheDocument();
+ expect(within(card).getByText('com_ui_pr_state:com_ui_pr_state_open')).toBeInTheDocument();
+ expect(within(card).getByText('com_ui_pr_checks:com_ui_pr_checks_passing')).toBeInTheDocument();
+ const link = within(card).getByTestId('pull-request-github-link');
+ expect(link).toHaveAttribute('href', pr.url);
+ expect(link).toHaveAttribute('target', '_blank');
+ expect(link).toHaveAttribute('rel', 'noopener noreferrer');
+ expect(link).toHaveAccessibleName('com_ui_pr_open_in_github');
+ });
+
+ it('keeps the last known pull request when a later refresh fails', async () => {
+ mockGet.mockResolvedValueOnce({ pullRequest: pr });
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ render(
+
+
+ ,
+ );
+ await userEvent.click(await screen.findByTestId('header-pull-request-button'));
+ await screen.findByTestId('pull-request-card');
+ expect(screen.queryByText('com_ui_pr_refresh_failed')).not.toBeInTheDocument();
+
+ mockGet.mockRejectedValue(new Error('503'));
+ await client.refetchQueries();
+
+ expect(await screen.findByText('com_ui_pr_refresh_failed')).toBeInTheDocument();
+ expect(screen.getByTestId('pull-request-card')).toHaveTextContent(pr.title);
+ });
+
+ it('labels a merged pull request and drops its CI dot', async () => {
+ mockGet.mockResolvedValue({ pullRequest: { ...pr, state: 'merged' } });
+ renderChip();
+ const button = await screen.findByTestId('header-pull-request-button');
+ expect(button.getAttribute('aria-label')).toContain('com_ui_pr_state_merged');
+ expect(screen.queryByTestId('pull-request-ci-dot')).not.toBeInTheDocument();
+ });
+});
diff --git a/client/src/components/Chat/PullRequest/__tests__/menu.test.tsx b/client/src/components/Chat/PullRequest/__tests__/menu.test.tsx
new file mode 100644
index 00000000000..74fa2d9bff0
--- /dev/null
+++ b/client/src/components/Chat/PullRequest/__tests__/menu.test.tsx
@@ -0,0 +1,163 @@
+import React from 'react';
+import '@testing-library/jest-dom';
+import userEvent from '@testing-library/user-event';
+import { render, screen, waitFor } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import type { TConversationPullRequest } from 'librechat-data-provider';
+import usePullRequestMenu from '../menu';
+
+const mockGet = jest.fn();
+
+jest.mock('~/data-provider/Endpoints', () => ({
+ useGetStartupConfig: () => ({ data: { pullRequestsEnabled: true } }),
+}));
+
+jest.mock('librechat-data-provider', () => {
+ const actual = jest.requireActual('librechat-data-provider');
+ return {
+ ...actual,
+ dataService: {
+ ...actual.dataService,
+ getConversationPullRequest: (...args: unknown[]) => mockGet(...args),
+ },
+ };
+});
+
+jest.mock('~/hooks', () => ({
+ useLocalize: () => (key: string, values?: Record) =>
+ values == null ? key : `${key}:${Object.values(values).join(',')}`,
+}));
+
+const pr: TConversationPullRequest = {
+ number: 1234,
+ title: 'Simplify Single Tool Execution Path',
+ url: 'https://github.com/LibreChat-AI/LibreChat/pull/1234',
+ additions: 1234,
+ deletions: 56,
+ state: 'open',
+ isDraft: false,
+ mergeable: 'clean',
+ checks: 'passing',
+};
+
+/** Stands in for the overflow menu: the entry is a button the menu would render. */
+function Harness({ conversationId }: { conversationId: string }) {
+ const { item, dialog } = usePullRequestMenu(conversationId);
+ return (
+ <>
+ {item != null && (
+
+ )}
+ {dialog}
+ >
+ );
+}
+
+const renderHarness = (conversationId = 'convo-1') => {
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ const view = render(
+
+
+ ,
+ );
+ return {
+ ...view,
+ goTo: (next: string) =>
+ view.rerender(
+
+
+ ,
+ ),
+ };
+};
+
+describe('usePullRequestMenu', () => {
+ beforeEach(() => mockGet.mockReset());
+
+ it.each([
+ ['while loading', () => new Promise(() => undefined)],
+ ['without a pull request', () => Promise.resolve({ pullRequest: null })],
+ ['when the lookup fails', () => Promise.reject(new Error('503'))],
+ ])('offers no entry %s, so the menu is unchanged', async (_label, respond) => {
+ mockGet.mockImplementation(respond);
+ renderHarness();
+ await waitFor(() => expect(mockGet).toHaveBeenCalled());
+ expect(screen.queryByTestId('menu-entry')).not.toBeInTheDocument();
+ });
+
+ it('does not ask for a pull request without a conversation', () => {
+ renderHarness('');
+ expect(mockGet).not.toHaveBeenCalled();
+ });
+
+ it('offers an entry named by number whose label states what the colors say', async () => {
+ mockGet.mockResolvedValue({ pullRequest: pr });
+ renderHarness();
+ const entry = await screen.findByTestId('menu-entry');
+ expect(entry).toHaveTextContent('com_ui_pr_label:1234');
+ expect(entry.getAttribute('aria-label')).toContain(pr.title);
+ expect(entry.getAttribute('aria-label')).toContain('com_ui_pr_state:com_ui_pr_state_open');
+ });
+
+ it('keeps the menu open on click, as every dialog-opening entry must', async () => {
+ mockGet.mockResolvedValue({ pullRequest: pr });
+ renderHarness();
+ expect(await screen.findByTestId('menu-entry')).toHaveAttribute('data-hide-on-click', 'false');
+ });
+
+ it('tells assistive technology it opens a dialog, and which one', async () => {
+ mockGet.mockResolvedValue({ pullRequest: pr });
+ renderHarness();
+ const entry = await screen.findByTestId('menu-entry');
+ expect(entry).toHaveAttribute('aria-haspopup', 'dialog');
+ await userEvent.click(entry);
+ const dialog = await screen.findByRole('dialog');
+ expect(entry.getAttribute('aria-controls')).toBe(dialog.id);
+ expect(dialog.id).not.toBe('');
+ });
+
+ it('closes the dialog when the route moves to another conversation, and does not reopen it', async () => {
+ mockGet.mockImplementation((id: string) =>
+ Promise.resolve({ pullRequest: { ...pr, number: id === 'convo-1' ? 1 : 2 } }),
+ );
+ const { goTo } = renderHarness('convo-1');
+ await userEvent.click(await screen.findByTestId('menu-entry'));
+ await screen.findByTestId('pull-request-card');
+
+ goTo('convo-2');
+ await waitFor(() => expect(screen.queryByTestId('pull-request-card')).not.toBeInTheDocument());
+ await screen.findByText('com_ui_pr_label:2');
+ expect(screen.queryByTestId('pull-request-card')).not.toBeInTheDocument();
+
+ goTo('convo-1');
+ await screen.findByText('com_ui_pr_label:1');
+ expect(screen.queryByTestId('pull-request-card')).not.toBeInTheDocument();
+ });
+
+ it('opens the same card in a dialog and returns focus to the entry on close', async () => {
+ mockGet.mockResolvedValue({ pullRequest: pr });
+ renderHarness();
+ const entry = await screen.findByTestId('menu-entry');
+ expect(screen.queryByTestId('pull-request-card')).not.toBeInTheDocument();
+
+ await userEvent.click(entry);
+ const card = await screen.findByTestId('pull-request-card');
+ expect(card).toHaveTextContent(pr.title);
+ expect(screen.getByTestId('pull-request-github-link')).toHaveAttribute('href', pr.url);
+
+ await userEvent.keyboard('{Escape}');
+ await waitFor(() => expect(screen.queryByTestId('pull-request-card')).not.toBeInTheDocument());
+ await waitFor(() => expect(entry).toHaveFocus());
+ });
+});
diff --git a/client/src/components/Chat/PullRequest/__tests__/status.test.ts b/client/src/components/Chat/PullRequest/__tests__/status.test.ts
new file mode 100644
index 00000000000..ea613cf5ee4
--- /dev/null
+++ b/client/src/components/Chat/PullRequest/__tests__/status.test.ts
@@ -0,0 +1,89 @@
+import type { TConversationPullRequest } from 'librechat-data-provider';
+import { presentPullRequest } from '../status';
+
+const base: TConversationPullRequest = {
+ number: 1,
+ title: 'Simplify single tool',
+ url: 'https://github.com/o/r/pull/1',
+ additions: 1234,
+ deletions: 56,
+ state: 'open',
+ isDraft: false,
+ mergeable: 'clean',
+ checks: 'passing',
+};
+const make = (overrides: Partial) => ({ ...base, ...overrides });
+
+describe('presentPullRequest', () => {
+ it('shows an open pull request with no conflicts as ready to merge', () => {
+ expect(presentPullRequest(base)).toMatchObject({
+ icon: 'open',
+ iconTone: 'success',
+ dotTone: 'success',
+ stateKey: 'com_ui_pr_state_open',
+ stateTone: 'success',
+ checksKey: 'com_ui_pr_checks_passing',
+ checksTone: 'success',
+ });
+ });
+
+ it('shows merge conflicts as an error on the icon and the state badge', () => {
+ expect(presentPullRequest(make({ mergeable: 'conflicting' }))).toMatchObject({
+ icon: 'open',
+ iconTone: 'error',
+ stateKey: 'com_ui_pr_state_conflicts',
+ stateTone: 'error',
+ });
+ });
+
+ it('shows a draft with the draft icon and no signal color, whatever its conflicts', () => {
+ for (const mergeable of ['clean', 'conflicting', 'unknown'] as const) {
+ expect(presentPullRequest(make({ isDraft: true, mergeable }))).toMatchObject({
+ icon: 'draft',
+ iconTone: 'neutral',
+ stateKey: 'com_ui_pr_state_draft',
+ });
+ }
+ });
+
+ it('does not claim readiness while GitHub is still computing mergeability', () => {
+ expect(presentPullRequest(make({ mergeable: 'unknown' }))).toMatchObject({
+ icon: 'open',
+ iconTone: 'neutral',
+ stateKey: 'com_ui_pr_state_open',
+ });
+ });
+
+ it.each([
+ ['merged', 'merged', 'com_ui_pr_state_merged'],
+ ['closed', 'closed', 'com_ui_pr_state_closed'],
+ ] as const)('shows a %s pull request with its own icon', (state, icon, stateKey) => {
+ expect(presentPullRequest(make({ state, mergeable: 'unknown' }))).toMatchObject({
+ icon,
+ iconTone: 'neutral',
+ stateKey,
+ stateTone: 'neutral',
+ });
+ });
+
+ it.each([
+ ['passing', 'success', 'success'],
+ ['failing', 'error', 'error'],
+ ['running', 'warning', 'warning'],
+ ] as const)('maps %s checks to the %s dot', (checks, dotTone, checksTone) => {
+ expect(presentPullRequest(make({ checks }))).toMatchObject({ dotTone, checksTone });
+ });
+
+ it('shows no dot when the repository reports no checks', () => {
+ expect(presentPullRequest(make({ checks: 'none' }))).toMatchObject({
+ dotTone: null,
+ checksKey: 'com_ui_pr_checks_none',
+ checksTone: 'neutral',
+ });
+ });
+
+ it('drops the CI dot once the pull request is no longer open', () => {
+ expect(presentPullRequest(make({ state: 'merged', checks: 'passing' })).dotTone).toBeNull();
+ expect(presentPullRequest(make({ state: 'closed', checks: 'failing' })).dotTone).toBeNull();
+ });
+});
diff --git a/client/src/components/Chat/PullRequest/index.ts b/client/src/components/Chat/PullRequest/index.ts
new file mode 100644
index 00000000000..505e79db72d
--- /dev/null
+++ b/client/src/components/Chat/PullRequest/index.ts
@@ -0,0 +1,2 @@
+export { default as PullRequestChip } from './Chip';
+export { default as usePullRequestMenu } from './menu';
diff --git a/client/src/components/Chat/PullRequest/menu.tsx b/client/src/components/Chat/PullRequest/menu.tsx
new file mode 100644
index 00000000000..079b2bf2a25
--- /dev/null
+++ b/client/src/components/Chat/PullRequest/menu.tsx
@@ -0,0 +1,77 @@
+import { useEffect, useRef, useState } from 'react';
+import { OGDialog, OGDialogContent, OGDialogHeader, OGDialogTitle } from '@librechat/client';
+import type { ReactNode } from 'react';
+import type * as t from '~/common';
+import { useConversationPullRequestQuery } from '~/data-provider';
+import { summarizePullRequest } from './summary';
+import { presentPullRequest } from './status';
+import { useLocalize } from '~/hooks';
+import PullRequestIcon from './Icon';
+import PullRequestCard from './Card';
+
+const DIALOG_ID = 'pull-request-dialog';
+
+export type PullRequestMenu = {
+ /** Absent until a pull request is known, so the menu is unchanged for every other chat. */
+ item?: t.MenuItemProps;
+ /** Rendered by the surface that owns the menu, next to its trigger. */
+ dialog: ReactNode;
+};
+
+/**
+ * The pull request as an overflow-menu entry for the small-screen header, where there is no
+ * room for the chip. The entry opens the same card in a dialog, so both surfaces read from one
+ * query and one presentation. An empty `conversationId` leaves the query disabled.
+ */
+export default function usePullRequestMenu(conversationId: string): PullRequestMenu {
+ const localize = useLocalize();
+ /** Which conversation the dialog was opened for, so a route change closes it instead of
+ * carrying it over to the next conversation's pull request. */
+ const [openFor, setOpenFor] = useState(null);
+ const open = openFor != null && openFor === conversationId;
+
+ /** Leaving the conversation forgets the request, so coming back does not reopen the dialog. */
+ useEffect(() => {
+ setOpenFor((current) => (current === conversationId ? current : null));
+ }, [conversationId]);
+ const triggerRef = useRef(null);
+ const { data, isError, refetch } = useConversationPullRequestQuery(conversationId);
+ const pullRequest = data?.pullRequest;
+
+ if (pullRequest == null) return { dialog: null };
+
+ const view = presentPullRequest(pullRequest);
+ return {
+ item: {
+ id: 'header-pull-request',
+ label: localize('com_ui_pr_label', { 0: pullRequest.number }),
+ ariaLabel: summarizePullRequest(pullRequest, localize),
+ icon: ,
+ onClick: () => setOpenFor(conversationId),
+ ariaHasPopup: 'dialog',
+ ariaControls: DIALOG_ID,
+ /** NOTE: THE FOLLOWING PROPS ARE REQUIRED FOR MENU ITEMS THAT OPEN DIALOGS */
+ hideOnClick: false,
+ ref: triggerRef,
+ render: (props) => ,
+ },
+ dialog: (
+ setOpenFor(next ? conversationId : null)}
+ triggerRef={triggerRef}
+ >
+
+
+ {localize('com_ui_pull_request')}
+
+ void refetch()}
+ />
+
+
+ ),
+ };
+}
diff --git a/client/src/components/Chat/PullRequest/status.ts b/client/src/components/Chat/PullRequest/status.ts
new file mode 100644
index 00000000000..c8eccb1fcc9
--- /dev/null
+++ b/client/src/components/Chat/PullRequest/status.ts
@@ -0,0 +1,96 @@
+import type { TConversationPullRequest } from 'librechat-data-provider';
+import type { TranslationKeys } from '~/hooks/useLocalize';
+
+export type PullRequestTone = 'success' | 'error' | 'warning' | 'neutral';
+
+export type PullRequestIcon = 'open' | 'draft' | 'merged' | 'closed';
+
+export type PullRequestPresentation = {
+ icon: PullRequestIcon;
+ /** Colors the pull request icon: ready, conflicting, or no signal (draft, closed). */
+ iconTone: PullRequestTone;
+ /** Colors the CI dot on the avatar; null when the repository reports no checks. */
+ dotTone: PullRequestTone | null;
+ stateKey: TranslationKeys;
+ stateTone: PullRequestTone;
+ checksKey: TranslationKeys;
+ checksTone: PullRequestTone;
+};
+
+const CHECKS: Record<
+ TConversationPullRequest['checks'],
+ { key: TranslationKeys; tone: PullRequestTone; dot: PullRequestTone | null }
+> = {
+ passing: { key: 'com_ui_pr_checks_passing', tone: 'success', dot: 'success' },
+ failing: { key: 'com_ui_pr_checks_failing', tone: 'error', dot: 'error' },
+ running: { key: 'com_ui_pr_checks_running', tone: 'warning', dot: 'warning' },
+ none: { key: 'com_ui_pr_checks_none', tone: 'neutral', dot: null },
+};
+
+function stateOf(
+ pr: TConversationPullRequest,
+): Pick {
+ if (pr.state === 'merged') {
+ return {
+ icon: 'merged',
+ iconTone: 'neutral',
+ stateKey: 'com_ui_pr_state_merged',
+ stateTone: 'neutral',
+ };
+ }
+ if (pr.state === 'closed') {
+ return {
+ icon: 'closed',
+ iconTone: 'neutral',
+ stateKey: 'com_ui_pr_state_closed',
+ stateTone: 'neutral',
+ };
+ }
+ if (pr.isDraft) {
+ return {
+ icon: 'draft',
+ iconTone: 'neutral',
+ stateKey: 'com_ui_pr_state_draft',
+ stateTone: 'neutral',
+ };
+ }
+ if (pr.mergeable === 'conflicting') {
+ return {
+ icon: 'open',
+ iconTone: 'error',
+ stateKey: 'com_ui_pr_state_conflicts',
+ stateTone: 'error',
+ };
+ }
+ return {
+ icon: 'open',
+ iconTone: pr.mergeable === 'clean' ? 'success' : 'neutral',
+ stateKey: 'com_ui_pr_state_open',
+ stateTone: 'success',
+ };
+}
+
+/** The one place a pull request becomes icon, dot and badges, so the chip and card never disagree. */
+export function presentPullRequest(pr: TConversationPullRequest): PullRequestPresentation {
+ const checks = CHECKS[pr.checks];
+ return {
+ ...stateOf(pr),
+ dotTone: pr.state === 'open' ? checks.dot : null,
+ checksKey: checks.key,
+ checksTone: checks.tone,
+ };
+}
+
+export const TONE_ICON_CLASS: Record = {
+ success: 'text-status-success',
+ error: 'text-status-error',
+ warning: 'text-status-warning',
+ neutral: 'text-text-secondary',
+};
+
+export const TONE_DOT_CLASS: Record = {
+ success: 'bg-status-success',
+ error: 'bg-status-error',
+ warning: 'bg-status-warning',
+ neutral: 'bg-status-neutral',
+};
diff --git a/client/src/components/Chat/PullRequest/summary.ts b/client/src/components/Chat/PullRequest/summary.ts
new file mode 100644
index 00000000000..4fc3769144f
--- /dev/null
+++ b/client/src/components/Chat/PullRequest/summary.ts
@@ -0,0 +1,16 @@
+import type { TConversationPullRequest } from 'librechat-data-provider';
+import type { useLocalize } from '~/hooks';
+import { presentPullRequest } from './status';
+
+/** One sentence that says everything the icon, dot and badges say in color. */
+export function summarizePullRequest(
+ pr: TConversationPullRequest,
+ localize: ReturnType,
+): string {
+ const view = presentPullRequest(pr);
+ return [
+ `${localize('com_ui_pull_request')} ${localize('com_ui_pr_label', { 0: pr.number })}: ${pr.title}`,
+ localize('com_ui_pr_state', { 0: localize(view.stateKey) }),
+ localize('com_ui_pr_checks', { 0: localize(view.checksKey) }),
+ ].join(', ');
+}
diff --git a/client/src/components/Chat/__tests__/Header.stacking.spec.tsx b/client/src/components/Chat/__tests__/Header.stacking.spec.tsx
index 3908754bd69..af374994854 100644
--- a/client/src/components/Chat/__tests__/Header.stacking.spec.tsx
+++ b/client/src/components/Chat/__tests__/Header.stacking.spec.tsx
@@ -4,6 +4,7 @@ import { render, screen } from '@testing-library/react';
import Header from '../Header';
const mockEndpoint = { current: 'agents' };
+const mockSmallScreen = { current: false };
jest.mock('react-router-dom', () => ({
useParams: () => ({ conversationId: 'convo-1' }),
@@ -23,7 +24,7 @@ jest.mock('librechat-data-provider', () => ({
}));
jest.mock('~/data-provider', () => ({ useGetStartupConfig: () => ({ data: undefined }) }));
jest.mock('~/hooks', () => ({ useHasAccess: () => false }));
-jest.mock('~/hooks/Nav/useDrawerViewport', () => () => false);
+jest.mock('~/hooks/Nav/useDrawerViewport', () => () => mockSmallScreen.current);
jest.mock('~/store', () => ({
__esModule: true,
default: {
@@ -39,7 +40,7 @@ jest.mock('../Menus', () => ({
OpenSidebar: () => null,
PresetsMenu: () => null,
NewChat: () => null,
- HeaderMenu: () => null,
+ HeaderMenu: jest.fn(() => null),
}));
jest.mock('../TemporaryChat', () => ({
TemporaryChat: () => null,
@@ -49,6 +50,9 @@ jest.mock('../Trace', () => ({ useTraceControl: () => ({ show: false }) }));
jest.mock('../BackgroundTasks', () => ({
BackgroundTasksButton: jest.fn(() => ),
}));
+jest.mock('../PullRequest', () => ({
+ PullRequestChip: jest.fn(() => ),
+}));
jest.mock('../Menus/Endpoints/ModelSelector', () => () => null);
jest.mock('../ExportAndShareMenu', () => () => null);
jest.mock('../SubagentThreadLink', () => () => null);
@@ -57,10 +61,16 @@ jest.mock('../AddMultiConvo', () => () => null);
describe('Header stacking', () => {
const backgroundTasks = jest.requireMock('../BackgroundTasks').BackgroundTasksButton as jest.Mock;
+ const pullRequestChip = jest.requireMock('../PullRequest').PullRequestChip as jest.Mock;
+
+ const headerMenu = jest.requireMock('../Menus').HeaderMenu as jest.Mock;
beforeEach(() => {
backgroundTasks.mockClear();
+ pullRequestChip.mockClear();
+ headerMenu.mockClear();
mockEndpoint.current = 'agents';
+ mockSmallScreen.current = false;
});
test('keeps header controls above the z-10 composer approval review', () => {
@@ -81,4 +91,34 @@ describe('Header stacking', () => {
render();
expect(backgroundTasks).not.toHaveBeenCalled();
});
+
+ test('mounts the pull request chip for the routed conversation', () => {
+ render();
+ expect(pullRequestChip.mock.calls[0][0]).toEqual({ conversationId: 'convo-1' });
+ });
+
+ test('does not mount the pull request chip on child threads', () => {
+ render();
+ expect(pullRequestChip).not.toHaveBeenCalled();
+ });
+
+ test('places the pull request chip with the left controls, not beside the right ones', () => {
+ render();
+ const chip = screen.getByTestId('conversation-pull-request');
+ const tasks = screen.getByTestId('conversation-tasks');
+ expect(chip.parentElement).not.toBe(tasks.parentElement);
+ });
+
+ test('leaves the chip to the overflow menu on small screens', () => {
+ mockSmallScreen.current = true;
+ render();
+ expect(pullRequestChip).not.toHaveBeenCalled();
+ expect(headerMenu.mock.calls[0][0]).toMatchObject({ pullRequestConversationId: 'convo-1' });
+ });
+
+ test('offers the overflow menu no pull request for a child thread', () => {
+ mockSmallScreen.current = true;
+ render();
+ expect(headerMenu.mock.calls[0][0].pullRequestConversationId).toBeUndefined();
+ });
});
diff --git a/client/src/data-provider/PullRequest/index.ts b/client/src/data-provider/PullRequest/index.ts
new file mode 100644
index 00000000000..3cf1ef310b4
--- /dev/null
+++ b/client/src/data-provider/PullRequest/index.ts
@@ -0,0 +1 @@
+export * from './queries';
diff --git a/client/src/data-provider/PullRequest/queries.test.tsx b/client/src/data-provider/PullRequest/queries.test.tsx
new file mode 100644
index 00000000000..fe7448d7c9a
--- /dev/null
+++ b/client/src/data-provider/PullRequest/queries.test.tsx
@@ -0,0 +1,118 @@
+import React from 'react';
+import { renderHook, waitFor } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import type { TConversationPullRequest } from 'librechat-data-provider';
+import { pullRequestRefetchInterval, useConversationPullRequestQuery } from './queries';
+
+const mockGet = jest.fn();
+const mockStartup: { current: { pullRequestsEnabled?: boolean } | undefined } = {
+ current: { pullRequestsEnabled: true },
+};
+
+jest.mock('../Endpoints', () => ({
+ useGetStartupConfig: () => ({ data: mockStartup.current }),
+}));
+
+jest.mock('librechat-data-provider', () => {
+ const actual = jest.requireActual('librechat-data-provider');
+ return {
+ ...actual,
+ dataService: {
+ ...actual.dataService,
+ getConversationPullRequest: (...args: unknown[]) => mockGet(...args),
+ },
+ };
+});
+
+const pr: TConversationPullRequest = {
+ number: 1,
+ title: 't',
+ url: 'https://github.com/o/r/pull/1',
+ additions: 1,
+ deletions: 0,
+ state: 'open',
+ isDraft: false,
+ mergeable: 'clean',
+ checks: 'passing',
+};
+
+const wrap = () => {
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ return ({ children }: { children: React.ReactNode }) => (
+ {children}
+ );
+};
+
+describe('pullRequestRefetchInterval', () => {
+ it('polls quickly only while checks or mergeability can still change', () => {
+ expect(pullRequestRefetchInterval({ pullRequest: { ...pr, checks: 'running' } })).toBe(20_000);
+ expect(pullRequestRefetchInterval({ pullRequest: { ...pr, mergeable: 'unknown' } })).toBe(
+ 20_000,
+ );
+ });
+
+ it('polls a settled open pull request slowly and a missing one slowly enough to notice a new one', () => {
+ expect(pullRequestRefetchInterval({ pullRequest: pr })).toBe(60_000);
+ expect(pullRequestRefetchInterval({ pullRequest: null })).toBe(60_000);
+ expect(pullRequestRefetchInterval(undefined)).toBe(60_000);
+ });
+
+ it('stops polling a merged or closed pull request once its checks are not running', () => {
+ for (const checks of ['passing', 'failing', 'none'] as const) {
+ expect(pullRequestRefetchInterval({ pullRequest: { ...pr, state: 'merged', checks } })).toBe(
+ false,
+ );
+ expect(pullRequestRefetchInterval({ pullRequest: { ...pr, state: 'closed', checks } })).toBe(
+ false,
+ );
+ }
+ });
+
+ it('keeps polling a merged or closed pull request while its checks are still running', () => {
+ expect(
+ pullRequestRefetchInterval({ pullRequest: { ...pr, state: 'merged', checks: 'running' } }),
+ ).toBe(20_000);
+ expect(
+ pullRequestRefetchInterval({ pullRequest: { ...pr, state: 'closed', checks: 'running' } }),
+ ).toBe(20_000);
+ });
+});
+
+describe('useConversationPullRequestQuery', () => {
+ beforeEach(() => {
+ mockGet.mockReset().mockResolvedValue({ pullRequest: pr });
+ mockStartup.current = { pullRequestsEnabled: true };
+ });
+
+ it.each([
+ ['advertised off', { pullRequestsEnabled: false }],
+ ['not advertised', {}],
+ ['startup config not loaded', undefined],
+ ])('does not call the endpoint when the feature is %s', (_label, startup) => {
+ mockStartup.current = startup;
+ renderHook(() => useConversationPullRequestQuery('convo-1'), { wrapper: wrap() });
+ expect(mockGet).not.toHaveBeenCalled();
+ });
+
+ it('fetches the pull request of a saved conversation', async () => {
+ const { result } = renderHook(() => useConversationPullRequestQuery('convo-1'), {
+ wrapper: wrap(),
+ });
+ await waitFor(() => expect(result.current.data).toEqual({ pullRequest: pr }));
+ expect(mockGet).toHaveBeenCalledWith('convo-1');
+ });
+
+ it.each(['', 'new', 'PENDING'])('does not fetch for the placeholder conversation %p', (id) => {
+ renderHook(() => useConversationPullRequestQuery(id), { wrapper: wrap() });
+ expect(mockGet).not.toHaveBeenCalled();
+ });
+
+ it('surfaces a failure without retrying', async () => {
+ mockGet.mockReset().mockRejectedValue(new Error('503'));
+ const { result } = renderHook(() => useConversationPullRequestQuery('convo-1'), {
+ wrapper: wrap(),
+ });
+ await waitFor(() => expect(result.current.isError).toBe(true));
+ expect(mockGet).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/client/src/data-provider/PullRequest/queries.ts b/client/src/data-provider/PullRequest/queries.ts
new file mode 100644
index 00000000000..21294add9e4
--- /dev/null
+++ b/client/src/data-provider/PullRequest/queries.ts
@@ -0,0 +1,53 @@
+import { useQuery } from '@tanstack/react-query';
+import { Constants, QueryKeys, dataService } from 'librechat-data-provider';
+import type { TConversationPullRequestResponse } from 'librechat-data-provider';
+import type { UseQueryOptions } from '@tanstack/react-query';
+import { useGetStartupConfig } from '../Endpoints';
+
+const SETTLED_REFRESH_MS = 60_000;
+/** Checks and conflict state change while a pull request is being worked on. */
+const ACTIVE_REFRESH_MS = 20_000;
+
+/**
+ * Poll faster only while something can still change. A closed or merged pull request has no
+ * mergeability left to settle, but its checks can still be running, so those keep the active
+ * interval until they finish; only then does polling stop.
+ */
+export const pullRequestRefetchInterval = (
+ response: TConversationPullRequestResponse | undefined,
+): number | false => {
+ const pr = response?.pullRequest;
+ if (pr == null) return SETTLED_REFRESH_MS;
+ if (pr.checks === 'running') return ACTIVE_REFRESH_MS;
+ if (pr.state !== 'open') return false;
+ return pr.mergeable === 'unknown' ? ACTIVE_REFRESH_MS : SETTLED_REFRESH_MS;
+};
+
+/**
+ * Asks only when the deployment advertises the feature and the conversation is a saved one, so a
+ * default installation never calls the endpoint. The server answers no pull request for a chat
+ * that has no code lane, which keeps a non-code chat to one cheap, slow poll.
+ */
+export const useConversationPullRequestQuery = (
+ conversationId: string,
+ config?: UseQueryOptions,
+) => {
+ const { data: startupConfig } = useGetStartupConfig();
+ return useQuery(
+ [QueryKeys.conversationPullRequest, conversationId],
+ () => dataService.getConversationPullRequest(conversationId),
+ {
+ enabled:
+ startupConfig?.pullRequestsEnabled === true &&
+ conversationId !== '' &&
+ conversationId !== Constants.NEW_CONVO &&
+ conversationId !== Constants.PENDING_CONVO,
+ staleTime: 15_000,
+ retry: false,
+ refetchOnWindowFocus: true,
+ refetchInterval: pullRequestRefetchInterval,
+ refetchIntervalInBackground: false,
+ ...config,
+ },
+ );
+};
diff --git a/client/src/data-provider/index.ts b/client/src/data-provider/index.ts
index b554ae0de50..09041e6e16d 100644
--- a/client/src/data-provider/index.ts
+++ b/client/src/data-provider/index.ts
@@ -14,6 +14,7 @@ export * from './Projects';
export * from './Schedules';
export * from './Subagents';
export * from './BackgroundTasks';
+export * from './PullRequest';
export * from './Tools';
export * from './Traces';
export * from './connection';
diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json
index 170e595199e..535353199ec 100644
--- a/client/src/locales/en/translation.json
+++ b/client/src/locales/en/translation.json
@@ -1,4 +1,21 @@
{
+ "com_ui_pr_additions": "{{0}} lines added",
+ "com_ui_pr_checks": "Checks: {{0}}",
+ "com_ui_pr_checks_failing": "Failing",
+ "com_ui_pr_checks_none": "None",
+ "com_ui_pr_checks_passing": "Passing",
+ "com_ui_pr_checks_running": "Running",
+ "com_ui_pr_deletions": "{{0}} lines removed",
+ "com_ui_pr_label": "PR #{{0}}",
+ "com_ui_pr_open_in_github": "Open in GitHub",
+ "com_ui_pr_refresh_failed": "Couldn't refresh the pull request",
+ "com_ui_pr_state": "State: {{0}}",
+ "com_ui_pr_state_closed": "Closed",
+ "com_ui_pr_state_conflicts": "Conflicts",
+ "com_ui_pr_state_draft": "Draft",
+ "com_ui_pr_state_merged": "Merged",
+ "com_ui_pr_state_open": "Open",
+ "com_ui_pull_request": "Pull request",
"com_ui_private_text_hidden": "Private details hidden from the model",
"com_ui_private_text_loading": "Loading your original messageā¦",
"com_ui_private_text_retry": "Retry loading original",
diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts
index 05d522b8cd5..9a86d953651 100644
--- a/packages/data-provider/src/config.ts
+++ b/packages/data-provider/src/config.ts
@@ -3135,6 +3135,8 @@ export type TStartupConfig = {
langfuseFanoutEnabled?: boolean;
langfuseConnectionAccess?: boolean;
insightsEnabled?: boolean;
+ /** `endpoints.agents.pullRequests.enabled`; the header does not ask for a pull request without it. */
+ pullRequestsEnabled?: boolean;
/** Manual context compaction, gated by the same `summarization.enabled`
* switch that governs the automatic detour. */
compactionEnabled?: boolean;