From 94766c93cbe2f57d4a94b4405f279c3e10fddd53 Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 10 Aug 2026 19:45:57 +0100 Subject: [PATCH] fix: minor edits, cleanup, light changes --- frontend/src/agents/SharedAgent.tsx | 2 +- frontend/src/conversation/AnswerFlow.test.tsx | 79 +++++++++ frontend/src/conversation/AnswerFlow.tsx | 106 +++++++---- frontend/src/conversation/Conversation.tsx | 6 +- .../src/conversation/ConversationBubble.tsx | 166 ++++++------------ .../src/conversation/ConversationMessages.tsx | 23 +-- .../src/conversation/SharedConversation.tsx | 4 +- .../src/conversation/StreamingStatusLine.tsx | 5 + .../WikiWriteToolCallCard.test.tsx | 44 ++++- .../answerFlowIntegration.test.tsx | 53 ++++++ .../src/conversation/answerSegments.test.ts | 68 ++++++- frontend/src/conversation/answerSegments.ts | 30 +++- .../src/conversation/conversationSlice.ts | 8 +- frontend/src/index.css | 6 + frontend/src/locale/de.json | 10 +- frontend/src/locale/en.json | 10 +- frontend/src/locale/es.json | 10 +- frontend/src/locale/jp.json | 10 +- frontend/src/locale/ru.json | 10 +- frontend/src/locale/zh-TW.json | 10 +- frontend/src/locale/zh.json | 10 +- frontend/src/utils/streamingStatusUtils.ts | 18 +- 22 files changed, 500 insertions(+), 188 deletions(-) diff --git a/frontend/src/agents/SharedAgent.tsx b/frontend/src/agents/SharedAgent.tsx index f4d57500..e6b76867 100644 --- a/frontend/src/agents/SharedAgent.tsx +++ b/frontend/src/agents/SharedAgent.tsx @@ -176,7 +176,7 @@ export default function SharedAgent() { } /> -
+
handleQuestionSubmission(text)} diff --git a/frontend/src/conversation/AnswerFlow.test.tsx b/frontend/src/conversation/AnswerFlow.test.tsx index 70114c1b..741db333 100644 --- a/frontend/src/conversation/AnswerFlow.test.tsx +++ b/frontend/src/conversation/AnswerFlow.test.tsx @@ -33,6 +33,7 @@ const render = (props: { toolCalls?: ToolCallsType[]; segments?: AnswerSegment[]; isStreaming?: boolean; + suppressStatusLine?: boolean; }): string => renderToStaticMarkup( @@ -141,3 +142,81 @@ describe('AnswerFlow', () => { expect(html).not.toContain('fade-in-bubble'); }); }); + +describe('AnswerFlow activity indicator', () => { + const thoughtThenCall: AnswerSegment[] = [ + { kind: 'thought', text: 'reasoning here' }, + { kind: 'tool', call_id: 'c1' }, + ]; + + it('shows the status line once every step has settled', () => { + const html = render({ + thought: 'reasoning here', + toolCalls: [search()], + segments: thoughtThenCall, + isStreaming: true, + }); + expect(html).toContain('Thinking…'); + }); + + it('leaves the status line out while a call is still running', () => { + const html = render({ + thought: 'reasoning here', + toolCalls: [search({ status: 'pending' })], + segments: thoughtThenCall, + isStreaming: true, + }); + expect(html).not.toContain('Thinking…'); + expect(html).toContain('Searching the web'); + }); + + it('leaves the status line out while reasoning is the live step', () => { + const html = render({ + thought: 'reasoning here', + segments: [{ kind: 'thought', text: 'reasoning here' }], + isStreaming: true, + }); + expect(html).not.toContain('Thinking…'); + expect(html).toContain('shimmer-text'); + }); + + it('says it is generating once answer text has started', () => { + const html = render({ + message: 'the answer', + thought: 'reasoning here', + toolCalls: [search()], + segments: thoughtThenCall, + isStreaming: true, + }); + expect(html).toContain('Generating…'); + }); + + it('stays quiet when the answer is no longer streaming', () => { + const html = render({ + thought: 'reasoning here', + toolCalls: [search()], + segments: thoughtThenCall, + }); + expect(html).not.toContain('Thinking…'); + }); + + it('defers to a run that already shows its own progress', () => { + const html = render({ + toolCalls: [search()], + segments: [{ kind: 'tool', call_id: 'c1' }], + isStreaming: true, + suppressStatusLine: true, + }); + expect(html).not.toContain('Thinking…'); + }); + + it('treats a call handed to the client as still running', () => { + const html = render({ + toolCalls: [search({ status: 'requires_client_execution' })], + segments: [{ kind: 'tool', call_id: 'c1' }], + isStreaming: true, + }); + expect(html).not.toContain('Thinking…'); + expect(html).toContain('Searching the web'); + }); +}); diff --git a/frontend/src/conversation/AnswerFlow.tsx b/frontend/src/conversation/AnswerFlow.tsx index 321f5991..0da445a4 100644 --- a/frontend/src/conversation/AnswerFlow.tsx +++ b/frontend/src/conversation/AnswerFlow.tsx @@ -4,15 +4,17 @@ import { useTranslation } from 'react-i18next'; import SchedulerToolCallCard from '../agents/schedules/SchedulerToolCallCard'; import ChevronDown from '../assets/chevron-down.svg?react'; import Cloud from '../assets/cloud.svg'; -import DocsGPT3 from '../assets/cute_docsgpt3.svg'; import CopyButton from '../components/CopyButton'; import ToolIcon from '../components/ToolIcon'; -import { Avatar } from '../components/ui/avatar'; import { Button } from '../components/ui/button'; import { usePacedText } from '../hooks'; -import { getToolChipLabel } from '../utils/streamingStatusUtils'; +import { + getToolChipLabel, + isToolCallRunning, +} from '../utils/streamingStatusUtils'; import { AnswerSegment, getAnswerSegments } from './answerSegments'; import MarkdownAnswer from './MarkdownAnswer'; +import StreamingStatusLine from './StreamingStatusLine'; import { ToolCallsType } from './types'; import { isWikiWriteCall } from './wikiToolCall'; @@ -24,8 +26,13 @@ type AnswerFlowProps = { segments?: AnswerSegment[]; isStreaming?: boolean; agentId?: string; + /** Set when the bubble already carries its own progress UI (a research run). */ + suppressStatusLine?: boolean; renderApproval: (toolCall: ToolCallsType) => React.ReactNode; - renderWikiWrite: (toolCall: ToolCallsType) => React.ReactNode; + renderWikiWrite: ( + toolCall: ToolCallsType, + isLive: boolean, + ) => React.ReactNode; }; /** @@ -39,14 +46,26 @@ export default function AnswerFlow({ segments, isStreaming, agentId, + suppressStatusLine, renderApproval, renderWikiWrite, }: AnswerFlowProps) { - const { t } = useTranslation(); const steps = getAnswerSegments({ thought, tool_calls: toolCalls, segments }); const callById = new Map((toolCalls ?? []).map((c) => [c.call_id, c])); const lastIndex = steps.length - 1; + // One derivation of "something here is already announcing activity": every + // chip shimmers off this, and the status line below fills only the gaps it + // leaves. Deriving it a second time from the flat fields let the two disagree, + // which showed up as no indicator at all between a settled step and the answer. + const liveSteps = steps.map((step, index) => { + if (!isStreaming) return false; + if (step.kind === 'thought') return index === lastIndex && !message; + const call = callById.get(step.call_id); + return Boolean(call && isToolCallRunning(call)); + }); + const hasLiveStep = liveSteps.some(Boolean); + return ( <> {steps.map((step, index) => { @@ -55,7 +74,7 @@ export default function AnswerFlow({ ); } @@ -73,13 +92,13 @@ export default function AnswerFlow({ if (isWikiWriteCall(call)) return ( - {renderWikiWrite(call)} + {renderWikiWrite(call, liveSteps[index])} ); if (call.tool_name === 'scheduler') return ( -
+
+ ); })} {message && (
-
- -

- {t('conversation.answer')} -

-
-
+ {/* ``ml-6`` is the answer's text column: step labels sit at the same + offset, with their icons in the gutter to its left. */} +
)} + {isStreaming && !hasLiveStep && !suppressStatusLine && ( + + )} ); } @@ -145,7 +165,11 @@ function InlineThoughtChip({ variant="ghost" onClick={() => setIsOpen(!isOpen)} aria-expanded={isOpen} - className="hover:bg-muted/60 flex h-auto w-fit max-w-full items-center justify-start gap-2 rounded-lg bg-transparent px-2 py-1.5 text-sm font-normal" + // ml-4 plus the button's own px-2 puts the icon on the answer's ml-6 + // text column. has-[>svg]:px-2 restates that padding under the same + // variant the button's own has-[>svg]:px-3 uses; a plain px-2 does not + // override it, and the chevron makes it match. + className="hover:bg-muted/60 ml-4 flex h-auto w-fit max-w-full items-center justify-start gap-2 rounded-lg bg-transparent px-2 py-1.5 text-sm font-normal has-[>svg]:px-2" >
{pacedThought} @@ -173,12 +197,12 @@ function InlineThoughtChip({
)} {!showLiveWindow && !isOpen && ( -

+

{thought}

)} {isOpen && ( -

+

{thought}

)} @@ -186,10 +210,18 @@ function InlineThoughtChip({ ); } -function InlineToolCallChip({ toolCall }: { toolCall: ToolCallsType }) { +function InlineToolCallChip({ + toolCall, + isLive, +}: { + toolCall: ToolCallsType; + isLive?: boolean; +}) { const { t } = useTranslation(); const [isOpen, setIsOpen] = useState(false); - const isPending = toolCall.status === 'pending'; + // Liveness is what animates; running is what the call's own status means, so + // a call left pending by a dropped stream still reads in the present tense. + const isRunning = isToolCallRunning(toolCall); const label = getToolChipLabel(toolCall, t); return ( @@ -199,13 +231,17 @@ function InlineToolCallChip({ toolCall }: { toolCall: ToolCallsType }) { variant="ghost" onClick={() => setIsOpen(!isOpen)} aria-expanded={isOpen} - className="hover:bg-muted/60 flex h-auto w-fit max-w-full items-center justify-start gap-2 rounded-lg bg-transparent px-2 py-1.5 text-sm font-normal" + // ml-4 plus the button's own px-2 puts the icon on the answer's ml-6 + // text column. has-[>svg]:px-2 restates that padding under the same + // variant the button's own has-[>svg]:px-3 uses; a plain px-2 does not + // override it, and the chevron makes it match. + className="hover:bg-muted/60 ml-4 flex h-auto w-fit max-w-full items-center justify-start gap-2 rounded-lg bg-transparent px-2 py-1.5 text-sm font-normal has-[>svg]:px-2" > {/* ToolIcon renders nothing for a tool with no bundled icon, so the dot below stands in via ``only:block`` to keep the row aligned. */} {label} @@ -234,7 +270,7 @@ function InlineToolCallChip({ toolCall }: { toolCall: ToolCallsType }) { /> {isOpen && ( -
+
- {isPending && ( -

+ {isRunning && ( +

{t('conversation.inlineSteps.running')}

)} @@ -266,7 +304,7 @@ function InlineToolCallChip({ toolCall }: { toolCall: ToolCallsType }) { {t('conversation.inlineSteps.denied')}

)} - {!isPending && + {!isRunning && toolCall.status !== 'error' && toolCall.status !== 'denied' && (

diff --git a/frontend/src/conversation/Conversation.tsx b/frontend/src/conversation/Conversation.tsx index a699251b..1a532eca 100644 --- a/frontend/src/conversation/Conversation.tsx +++ b/frontend/src/conversation/Conversation.tsx @@ -377,11 +377,13 @@ export default function Conversation() { />

+ {/* One notch narrower than the message column above it, which keeps its + own width. */}
diff --git a/frontend/src/conversation/ConversationBubble.tsx b/frontend/src/conversation/ConversationBubble.tsx index 4027d005..9435f74a 100644 --- a/frontend/src/conversation/ConversationBubble.tsx +++ b/frontend/src/conversation/ConversationBubble.tsx @@ -1,12 +1,12 @@ import 'katex/dist/katex.min.css'; +import { Pencil } from 'lucide-react'; import { forwardRef, Fragment, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useSelector } from 'react-redux'; import WorkflowRunArtifacts from '../agents/workflow/WorkflowRunArtifacts'; import ChevronDown from '../assets/chevron-down.svg'; -import DocsGPT3 from '../assets/cute_docsgpt3.svg'; import Dislike from '../assets/dislike.svg?react'; import Document from '../assets/document.svg'; import DocumentationDark from '../assets/documentation-dark.svg'; @@ -14,7 +14,6 @@ import Edit from '../assets/edit.svg'; import Like from '../assets/like.svg?react'; import Link from '../assets/link.svg'; import Sources from '../assets/sources.svg'; -import UserIcon from '../assets/user.svg'; import CopyButton from '../components/CopyButton'; import { Avatar } from '../components/ui/avatar'; @@ -22,18 +21,18 @@ import { Button } from '../components/ui/button'; import { Input } from '../components/ui/input'; import { Sheet, SheetContent } from '../components/ui/sheet'; import SpeakButton from '../components/TextToSpeechButton'; -import { useDarkTheme, useOutsideAlerter } from '../hooks'; +import { useOutsideAlerter } from '../hooks'; import { selectChunks, selectSelectedDocs, selectToken, } from '../preferences/preferenceSlice'; +import { isToolCallRunning } from '../utils/streamingStatusUtils'; import AnswerFlow from './AnswerFlow'; import { AnswerSegment } from './answerSegments'; import { FEEDBACK, MESSAGE_TYPE, ResearchState } from './conversationModels'; import MarkdownAnswer from './MarkdownAnswer'; import ResearchProgress from './ResearchProgress'; -import StreamingStatusLine from './StreamingStatusLine'; import { ToolCallsType } from './types'; import { wikiWriteActionKey, wikiWritePath } from './wikiToolCall'; @@ -99,7 +98,6 @@ const ConversationBubble = forwardRef< ref, ) { const { t } = useTranslation(); - const [isDarkTheme] = useDarkTheme(); // const bubbleRef = useRef(null); const chunks = useSelector(selectChunks); const selectedDocs = useSelector(selectSelectedDocs); @@ -113,11 +111,6 @@ const ConversationBubble = forwardRef< const editableQueryRef = useRef(null); const [isQuestionCollapsed, setIsQuestionCollapsed] = useState(true); - // These already shimmer in place above; the status line would say it twice. - const hasLiveInlineStep = - (toolCalls ?? []).some((call) => call.status === 'pending') || - Boolean(thought && !message); - const formatToolName = (toolName: string | undefined): string => { if (!toolName) return ''; // Display-name overrides for tools whose label differs from the formatted key. @@ -173,7 +166,7 @@ const ConversationBubble = forwardRef<
{filesAttached && filesAttached.length > 0 && ( -
+
{filesAttached.map((file, index) => (
- - - {!isEditClicked && ( <> -
+ {/* ``mr-3`` plus the pill's own ``mr-2`` puts the question's + right edge on the answer's ``mr-5`` gutter. */} +
); } else { - const preprocessLaTeX = (content: string) => { - // Replace block-level LaTeX delimiters \[ \] with $$ $$ - const blockProcessedContent = content.replace( - /\\\[(.*?)\\\]/gs, - (_, equation) => `$$${equation}$$`, - ); - - // Replace inline LaTeX delimiters \( \) with $ $ - const inlineProcessedContent = blockProcessedContent.replace( - /\\\((.*?)\\\)/gs, - (_, equation) => `$${equation}$`, - ); - - return inlineProcessedContent; - }; - const processMarkdownContent = (content: string) => { - let processedContent = preprocessLaTeX(content); - - // Convert citation references [N] into markdown links [N](#cite-N) - // so ReactMarkdown renders them as tags we can style. - // Avoid matching inside code blocks or existing links. - processedContent = processedContent.replace( - /(? `[${num}](#cite-${num})`, - ); - - const contentSegments: Array<{ - type: 'text' | 'mermaid'; - content: string; - }> = []; - - let lastIndex = 0; - const regex = /```mermaid\n([\s\S]*?)```/g; - let match; - - while ((match = regex.exec(processedContent)) !== null) { - const textBefore = processedContent.substring(lastIndex, match.index); - if (textBefore) { - contentSegments.push({ type: 'text', content: textBefore }); - } - - contentSegments.push({ type: 'mermaid', content: match[1].trim() }); - - lastIndex = match.index + match[0].length; - } - - const textAfter = processedContent.substring(lastIndex); - if (textAfter) { - contentSegments.push({ type: 'text', content: textAfter }); - } - - return contentSegments; - }; - bubble = (
source.link === 'None') ? null : sources && ( -
-
+ // Stretched, not shrink-to-fit: the grid below sizes off this box, + // so a fit-content parent would leave its width to the cards. +
+
-
+ {/* Width comes from the stretched parent minus these margins; + w-full here would be the column width plus them. */} +
{sources?.slice(0, 3)?.map((source, index) => (
} {!message && onOpenArtifact && completedArtifacts.length > 0 && ( -
+
{completedArtifacts.map((artifact, artifactIndex) => (