+ );
+}
+
+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..55dcc8a85ec
--- /dev/null
+++ b/client/src/components/Chat/PullRequest/Chip.tsx
@@ -0,0 +1,125 @@
+import { memo, useRef } from 'react';
+import * as Ariakit from '@ariakit/react';
+import { TooltipAnchor } from '@librechat/client';
+import type { TConversationPullRequest } from 'librechat-data-provider';
+import { useConversationPullRequestQuery } from '~/data-provider';
+import { useAgentsMapContext, useChatContext } from '~/Providers';
+import { TONE_DOT_CLASS, presentPullRequest } from './status';
+import { URLIcon } from '~/components/Endpoints/URLIcon';
+import { useLocalize } from '~/hooks';
+import PullRequestIcon from './Icon';
+import PullRequestCard from './Card';
+import { cn } from '~/utils';
+
+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 && }
+
+ );
+}
+
+function summaryLabel(
+ 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(', ');
+}
+
+/**
+ * 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.
+ */
+function PullRequestChip({ conversationId }: { conversationId: string }) {
+ const localize = useLocalize();
+ const { conversation } = useChatContext();
+ const agentsMap = useAgentsMapContext();
+ const popover = Ariakit.usePopoverStore({ placement: 'bottom-end' });
+ const disclosureRef = useRef(null);
+ const { data, isError, refetch } = useConversationPullRequestQuery(conversationId);
+ const pullRequest = data?.pullRequest;
+
+ if (pullRequest == null) return null;
+
+ const view = presentPullRequest(pullRequest);
+ const label = summaryLabel(pullRequest, localize);
+ const dotClass = view.dotTone == null ? null : TONE_DOT_CLASS[view.dotTone];
+ const agentId = conversation?.agent_id;
+ const agent = agentId == null ? undefined : agentsMap?.[agentId];
+ const avatar = agent?.avatar?.filepath ?? '';
+ const hasAvatar = avatar !== '';
+
+ return (
+ <>
+
+ {hasAvatar && }
+
+
+ {!hasAvatar && dotClass != null && }
+
+ {pullRequest.title}
+
+ }
+ />
+
+ void refetch()}
+ />
+
+ >
+ );
+}
+
+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..cabf3ce490a
--- /dev/null
+++ b/client/src/components/Chat/PullRequest/__tests__/Card.test.tsx
@@ -0,0 +1,60 @@
+import React from 'react';
+import '@testing-library/jest-dom';
+import userEvent from '@testing-library/user-event';
+import { render, screen } 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.getByText('com_ui_pr_state:com_ui_pr_state_conflicts');
+ const checks = screen.getByText('com_ui_pr_checks:com_ui_pr_checks_failing');
+ expect(state).toHaveClass('text-status-error');
+ expect(checks).toHaveClass('text-status-error');
+ });
+
+ 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..d5ae57deca6
--- /dev/null
+++ b/client/src/components/Chat/PullRequest/__tests__/Chip.test.tsx
@@ -0,0 +1,173 @@
+import React from 'react';
+import '@testing-library/jest-dom';
+import userEvent from '@testing-library/user-event';
+import { render, screen, waitFor, within } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import type { TConversationPullRequest } from 'librechat-data-provider';
+import PullRequestChip from '../Chip';
+
+const mockGet = jest.fn();
+
+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' } },
+};
+jest.mock('~/Providers', () => ({
+ useAgentsMapContext: () => mockAgents.current,
+ useChatContext: () => ({ conversation: { agent_id: 'agent-1' } }),
+}));
+
+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' } };
+ });
+
+ 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('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('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__/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..50e78c1952c
--- /dev/null
+++ b/client/src/components/Chat/PullRequest/index.ts
@@ -0,0 +1 @@
+export { default as PullRequestChip } from './Chip';
diff --git a/client/src/components/Chat/PullRequest/status.ts b/client/src/components/Chat/PullRequest/status.ts
new file mode 100644
index 00000000000..334cbd8c981
--- /dev/null
+++ b/client/src/components/Chat/PullRequest/status.ts
@@ -0,0 +1,104 @@
+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,
+ };
+}
+
+/** Semantic status roles only; there is no raw palette or `dark:` twin to maintain. */
+export const TONE_BADGE_CLASS: Record = {
+ success: 'bg-status-success-subtle text-status-success border-status-success-border',
+ error: 'bg-status-error-subtle text-status-error border-status-error-border',
+ warning: 'bg-status-warning-subtle text-status-warning border-status-warning-border',
+ neutral: 'bg-status-neutral-subtle text-status-neutral border-status-neutral-border',
+};
+
+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/__tests__/Header.stacking.spec.tsx b/client/src/components/Chat/__tests__/Header.stacking.spec.tsx
index 3908754bd69..93bd193fb31 100644
--- a/client/src/components/Chat/__tests__/Header.stacking.spec.tsx
+++ b/client/src/components/Chat/__tests__/Header.stacking.spec.tsx
@@ -49,6 +49,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,9 +60,11 @@ 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;
beforeEach(() => {
backgroundTasks.mockClear();
+ pullRequestChip.mockClear();
mockEndpoint.current = 'agents';
});
@@ -81,4 +86,14 @@ 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();
+ });
});
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..abd92d27052
--- /dev/null
+++ b/client/src/data-provider/PullRequest/queries.test.tsx
@@ -0,0 +1,83 @@
+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();
+
+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', () => {
+ expect(pullRequestRefetchInterval({ pullRequest: { ...pr, state: 'merged' } })).toBe(false);
+ expect(pullRequestRefetchInterval({ pullRequest: { ...pr, state: 'closed' } })).toBe(false);
+ });
+});
+
+describe('useConversationPullRequestQuery', () => {
+ beforeEach(() => mockGet.mockReset().mockResolvedValue({ pullRequest: pr }));
+
+ 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..c01bf08741b
--- /dev/null
+++ b/client/src/data-provider/PullRequest/queries.ts
@@ -0,0 +1,41 @@
+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';
+
+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 finished pull request needs no polling. */
+export const pullRequestRefetchInterval = (
+ response: TConversationPullRequestResponse | undefined,
+): number | false => {
+ const pr = response?.pullRequest;
+ if (pr == null) return SETTLED_REFRESH_MS;
+ if (pr.state !== 'open') return false;
+ return pr.checks === 'running' || pr.mergeable === 'unknown'
+ ? ACTIVE_REFRESH_MS
+ : SETTLED_REFRESH_MS;
+};
+
+export const useConversationPullRequestQuery = (
+ conversationId: string,
+ config?: UseQueryOptions,
+) =>
+ useQuery(
+ [QueryKeys.conversationPullRequest, conversationId],
+ () => dataService.getConversationPullRequest(conversationId),
+ {
+ enabled:
+ 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",
From 0d7b80a204a5b9ce620cb34f3adf8371cbb33a75 Mon Sep 17 00:00:00 2001
From: Marco Beretta <81851188+berry-13@users.noreply.github.com>
Date: Tue, 6 Oct 2026 10:42:44 +0200
Subject: [PATCH 2/4] feat: Place the Pull Request Chip Beside the Model
Controls and Move It to the Overflow Menu on Small Screens
On desktop the chip sits with the left header controls and its card opens to the right as an Ariakit hovercard that slides in from the left, on hover or keyboard focus. Small screens offer the same card from the header overflow menu through a dialog, following the dialog-item contract.
---
client/src/components/Chat/Header.tsx | 11 +-
.../src/components/Chat/Menus/HeaderMenu.tsx | 10 ++
.../Chat/Menus/__tests__/HeaderMenu.spec.tsx | 59 +++++++++
.../src/components/Chat/PullRequest/Chip.tsx | 73 +++++------
.../Chat/PullRequest/__tests__/Chip.test.tsx | 38 ++++++
.../Chat/PullRequest/__tests__/menu.test.tsx | 119 ++++++++++++++++++
.../src/components/Chat/PullRequest/index.ts | 1 +
.../src/components/Chat/PullRequest/menu.tsx | 61 +++++++++
.../components/Chat/PullRequest/summary.ts | 16 +++
.../Chat/__tests__/Header.stacking.spec.tsx | 29 ++++-
10 files changed, 376 insertions(+), 41 deletions(-)
create mode 100644 client/src/components/Chat/PullRequest/__tests__/menu.test.tsx
create mode 100644 client/src/components/Chat/PullRequest/menu.tsx
create mode 100644 client/src/components/Chat/PullRequest/summary.ts
diff --git a/client/src/components/Chat/Header.tsx b/client/src/components/Chat/Header.tsx
index 8f56752b4db..8d50f6f122b 100644
--- a/client/src/components/Chat/Header.tsx
+++ b/client/src/components/Chat/Header.tsx
@@ -113,14 +113,16 @@ 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 && (
+
+ )}