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 ( + + ); +} + +/** + * 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