diff --git a/frontend/src/conversation/AnswerFlow.tsx b/frontend/src/conversation/AnswerFlow.tsx index a7fb4ce4..9e134617 100644 --- a/frontend/src/conversation/AnswerFlow.tsx +++ b/frontend/src/conversation/AnswerFlow.tsx @@ -28,6 +28,7 @@ type AnswerFlowProps = { isStreaming?: boolean; /** How many sources the answer can cite; see `MarkdownAnswer`. */ sourceCount?: number; + onOpenSources?: () => void; agentId?: string; /** Set when the bubble already carries its own progress UI (a research run). */ suppressStatusLine?: boolean; @@ -54,6 +55,7 @@ export default function AnswerFlow({ segments, isStreaming, sourceCount, + onOpenSources, agentId, suppressStatusLine, artifacts, @@ -140,6 +142,7 @@ export default function AnswerFlow({ content={message} isStreaming={isStreaming} sourceCount={sourceCount} + onOpenSources={onOpenSources} artifacts={artifacts} turnArtifacts={turnArtifacts} onOpenArtifact={onOpenArtifact} diff --git a/frontend/src/conversation/ConversationBubble.tsx b/frontend/src/conversation/ConversationBubble.tsx index cd5234d5..fba3e819 100644 --- a/frontend/src/conversation/ConversationBubble.tsx +++ b/frontend/src/conversation/ConversationBubble.tsx @@ -16,7 +16,14 @@ import { ThumbsUp, ExternalLink, } from 'lucide-react'; -import { forwardRef, Fragment, useEffect, useRef, useState } from 'react'; +import { + forwardRef, + Fragment, + useCallback, + useEffect, + useRef, + useState, +} from 'react'; import { useTranslation } from 'react-i18next'; import { useSelector } from 'react-redux'; @@ -126,6 +133,7 @@ const ConversationBubble = forwardRef< const [shouldShowToggle, setShouldShowToggle] = useState(false); const [isSidebarOpen, setIsSidebarOpen] = useState(false); + const openSources = useCallback(() => setIsSidebarOpen(true), []); const editableQueryRef = useRef(null); const [isQuestionCollapsed, setIsQuestionCollapsed] = useState(true); @@ -452,6 +460,7 @@ const ConversationBubble = forwardRef< // A citation pill jumps to a source card, so an answer whose // sources are not shown has nothing to cite. sourceCount={showSources ? (sources?.length ?? 0) : 0} + onOpenSources={openSources} agentId={agentId} // A research run already narrates itself above; the status line // would be a second live indicator away from the point of action. diff --git a/frontend/src/conversation/MarkdownAnswer.math.test.tsx b/frontend/src/conversation/MarkdownAnswer.math.test.tsx index c52cf656..63d3d689 100644 --- a/frontend/src/conversation/MarkdownAnswer.math.test.tsx +++ b/frontend/src/conversation/MarkdownAnswer.math.test.tsx @@ -51,7 +51,11 @@ afterEach(() => { container.remove(); }); -type RenderOptions = { isStreaming?: boolean; sourceCount?: number }; +type RenderOptions = { + isStreaming?: boolean; + sourceCount?: number; + onOpenSources?: () => void; +}; function render(content: string, options: RenderOptions = {}) { act(() => root.render()); @@ -418,6 +422,39 @@ describe('citations', () => { }); }); +describe('citation pills', () => { + function click(label: string) { + const pill = Array.from(container.querySelectorAll('button')).find( + (node) => node.textContent === label, + ); + act(() => pill?.click()); + } + + it('scrolls to the source card when it is shown', () => { + const card = document.createElement('div'); + card.id = 'source-1'; + card.scrollIntoView = vi.fn(); + document.body.appendChild(card); + const onOpenSources = vi.fn(); + try { + render('Per [2].', { sourceCount: 5, onOpenSources }); + click('2'); + expect(card.scrollIntoView).toHaveBeenCalled(); + expect(onOpenSources).not.toHaveBeenCalled(); + } finally { + card.remove(); + } + }); + + it('opens the sources list for a source with no card', () => { + // Only the first three sources get a card; `[5]` has none to scroll to. + const onOpenSources = vi.fn(); + render('Per [5].', { sourceCount: 5, onOpenSources }); + click('5'); + expect(onOpenSources).toHaveBeenCalledTimes(1); + }); +}); + describe('mermaid', () => { it('renders a closed mermaid fence as a diagram, source untouched', () => { render( diff --git a/frontend/src/conversation/MarkdownAnswer.tsx b/frontend/src/conversation/MarkdownAnswer.tsx index 39d4bedb..a1cfa469 100644 --- a/frontend/src/conversation/MarkdownAnswer.tsx +++ b/frontend/src/conversation/MarkdownAnswer.tsx @@ -83,6 +83,7 @@ export default function MarkdownAnswer({ artifacts: artifactsProp, turnArtifacts: turnArtifactsProp, onOpenArtifact, + onOpenSources, }: { content: string; isStreaming?: boolean; @@ -100,6 +101,8 @@ export default function MarkdownAnswer({ /** This turn's own artifacts; the filename fallback prefers them. */ turnArtifacts?: SandboxArtifact[]; onOpenArtifact?: (artifact: { id: string; toolName: string }) => void; + /** Opens the full sources list, for a citation whose card is not shown. */ + onOpenSources?: () => void; }) { const { t } = useTranslation(); const [isDarkTheme] = useDarkTheme(); @@ -194,6 +197,9 @@ export default function MarkdownAnswer({ () => el.classList.remove('ring-3', 'ring-primary'), 2000, ); + } else { + // Only the first few sources get a card to scroll to. + onOpenSources?.(); } }} className="mx-0.5 h-5 min-w-5" @@ -321,7 +327,7 @@ export default function MarkdownAnswer({ return {children}; }, }; - }, [t, isDarkTheme, artifacts, turnArtifacts, onOpenArtifact]); + }, [t, isDarkTheme, artifacts, turnArtifacts, onOpenArtifact, onOpenSources]); return ( <>