Merge pull request #2636 from ManishMadan2882/main

(feat)ui/chat: inline streaming steps for reasoning and tool calls
This commit is contained in:
Alex authored and GitHub committed 2026-08-10 19:49:21 +01:00
commit f7b3d3eacd
25 files changed
+1571 -602

No files matched your search

+10
View File
@@ -1,5 +1,9 @@
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
import {
appendThoughtText,
recordToolCall,
} from '../conversation/answerSegments';
import {
handleFetchAnswer,
handleFetchAnswerSteaming,
@@ -206,6 +210,7 @@ export const agentPreviewSlice = createSlice({
delete state.queries[index].thought;
delete state.queries[index].sources;
delete state.queries[index].tool_calls;
delete state.queries[index].segments;
delete state.queries[index].error;
delete state.queries[index].structured;
delete state.queries[index].schema;
@@ -245,6 +250,8 @@ export const agentPreviewSlice = createSlice({
if (query.thought != undefined) {
state.queries[index].thought =
(state.queries[index].thought || '') + query.thought;
if (!state.queries[index].segments) state.queries[index].segments = [];
appendThoughtText(state.queries[index].segments, query.thought);
}
},
updateStreamingSource(
@@ -279,6 +286,9 @@ export const agentPreviewSlice = createSlice({
...tool_call,
};
} else state.queries[index].tool_calls.push(tool_call);
if (!state.queries[index].segments) state.queries[index].segments = [];
recordToolCall(state.queries[index].segments, tool_call.call_id);
},
updateQuery(
state,
@@ -0,0 +1,143 @@
import i18n from 'i18next';
import { renderToStaticMarkup } from 'react-dom/server';
import { I18nextProvider, initReactI18next } from 'react-i18next';
import { beforeAll, describe, expect, it } from 'vitest';
import en from '../locale/en.json';
import { AnswerSegment } from './answerSegments';
import AnswerFlow from './AnswerFlow';
import { ToolCallsType } from './types';
const testI18n = i18n.createInstance();
beforeAll(async () => {
await testI18n.use(initReactI18next).init({
lng: 'en',
fallbackLng: 'en',
resources: { en: { translation: en } },
});
});
const search = (overrides: Partial<ToolCallsType> = {}): ToolCallsType => ({
tool_name: 'brave',
action_name: 'brave_web_search',
call_id: 'c1',
arguments: { query: 'docsgpt setup' },
status: 'completed',
...overrides,
});
const render = (props: {
message?: string;
thought?: string;
toolCalls?: ToolCallsType[];
segments?: AnswerSegment[];
isStreaming?: boolean;
}): string =>
renderToStaticMarkup(
<I18nextProvider i18n={testI18n}>
<AnswerFlow
{...props}
renderApproval={() => <div>APPROVAL_BAR</div>}
renderWikiWrite={() => <div>WIKI_CARD</div>}
/>
</I18nextProvider>,
);
describe('AnswerFlow', () => {
it('renders the same markup whether the steps arrived live or were fetched', () => {
const live = render({
message: 'part one part two',
thought: 'reasoning here',
toolCalls: [search()],
// Live ordering interleaved the tool call with the answer text.
segments: [
{ kind: 'thought', text: 'reasoning here' },
{ kind: 'tool', call_id: 'c1' },
],
});
const fetched = render({
message: 'part one part two',
thought: 'reasoning here',
toolCalls: [search()],
});
expect(live).toBe(fetched);
});
it('keeps the answer in a single bubble with the steps above it', () => {
const html = render({
message: 'part one part two',
toolCalls: [search()],
segments: [{ kind: 'tool', call_id: 'c1' }],
});
expect(html.split('fade-in-bubble').length - 1).toBe(1);
expect(html.indexOf('Searched the web')).toBeLessThan(
html.indexOf('part one part two'),
);
});
it('labels a running call in present tense and a finished one in past tense', () => {
expect(
render({
toolCalls: [search({ status: 'pending' })],
segments: [{ kind: 'tool', call_id: 'c1' }],
isStreaming: true,
}),
).toContain('Searching the web');
expect(
render({
toolCalls: [search()],
segments: [{ kind: 'tool', call_id: 'c1' }],
}),
).toContain('Searched the web');
});
it('keeps tool arguments and results hidden until expanded', () => {
const html = render({
toolCalls: [search({ result: { secret: 'RESULT_BODY' } })],
segments: [{ kind: 'tool', call_id: 'c1' }],
});
expect(html).not.toContain('RESULT_BODY');
});
it('routes a call awaiting approval to the approval bar, not a chip', () => {
const html = render({
toolCalls: [search({ status: 'awaiting_approval' })],
segments: [{ kind: 'tool', call_id: 'c1' }],
});
expect(html).toContain('APPROVAL_BAR');
expect(html).not.toContain('Searched the web');
});
it('puts reasoning before tool calls for a reloaded conversation', () => {
const html = render({
message: 'the answer',
thought: 'my reasoning',
toolCalls: [search()],
});
expect(html.indexOf('my reasoning')).toBeLessThan(
html.indexOf('Searched the web'),
);
expect(html.indexOf('Searched the web')).toBeLessThan(
html.indexOf('the answer'),
);
});
it('ignores a step whose call is missing from tool_calls', () => {
const html = render({
toolCalls: [],
segments: [{ kind: 'tool', call_id: 'gone' }],
});
expect(html).not.toContain('Searched');
});
it('renders steps with no answer text yet', () => {
const html = render({
toolCalls: [search({ status: 'pending' })],
segments: [{ kind: 'tool', call_id: 'c1' }],
isStreaming: true,
});
expect(html).toContain('Searching the web');
expect(html).not.toContain('fade-in-bubble');
});
});
+303
View File
@@ -0,0 +1,303 @@
import { Fragment, useEffect, useRef, useState } from 'react';
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 { AnswerSegment, getAnswerSegments } from './answerSegments';
import MarkdownAnswer from './MarkdownAnswer';
import { ToolCallsType } from './types';
import { isWikiWriteCall } from './wikiToolCall';
type AnswerFlowProps = {
message?: string;
thought?: string;
toolCalls?: ToolCallsType[];
// Absent on reload, where the order is synthesized from the flat fields.
segments?: AnswerSegment[];
isStreaming?: boolean;
agentId?: string;
renderApproval: (toolCall: ToolCallsType) => React.ReactNode;
renderWikiWrite: (toolCall: ToolCallsType) => React.ReactNode;
};
/**
* Never splits the answer with a step, which is what keeps a streaming answer and
* the same answer fetched back rendering identically.
*/
export default function AnswerFlow({
message,
thought,
toolCalls,
segments,
isStreaming,
agentId,
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;
return (
<>
{steps.map((step, index) => {
if (step.kind === 'thought') {
return (
<InlineThoughtChip
key={`thought-${index}`}
thought={step.text}
isActive={isStreaming && index === lastIndex && !message}
/>
);
}
const call = callById.get(step.call_id);
if (!call) return null;
if (call.status === 'awaiting_approval')
return (
<Fragment key={`approval-${call.call_id}`}>
{renderApproval(call)}
</Fragment>
);
if (isWikiWriteCall(call))
return (
<Fragment key={`wiki-${call.call_id}`}>
{renderWikiWrite(call)}
</Fragment>
);
if (call.tool_name === 'scheduler')
return (
<div key={`scheduler-${call.call_id}`} className="my-2 w-full">
<SchedulerToolCallCard
result={call.result}
actionName={call.action_name}
status={call.status}
agentId={agentId}
/>
</div>
);
return (
<InlineToolCallChip key={`tool-${call.call_id}`} toolCall={call} />
);
})}
{message && (
<div className="flex max-w-full flex-col flex-wrap items-start self-start lg:flex-nowrap">
<div className="my-2 flex flex-row items-center justify-center gap-3">
<Avatar
src={DocsGPT3}
alt={t('conversation.answer')}
className="h-8.5 w-8.5 text-2xl"
imgClassName="h-full w-full object-cover"
/>
<p className="text-base font-semibold">
{t('conversation.answer')}
</p>
</div>
<div className="fade-in-bubble mr-5 flex max-w-full flex-col rounded-3xl py-4.5">
<MarkdownAnswer content={message} isStreaming={isStreaming} />
</div>
</div>
)}
</>
);
}
function InlineThoughtChip({
thought,
isActive,
}: {
thought: string;
isActive?: boolean;
}) {
const { t } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
const liveRef = useRef<HTMLDivElement>(null);
// Expanding means the user wants it all now, so pace the live window only.
const pacedThought = usePacedText(thought, Boolean(isActive) && !isOpen);
const showLiveWindow = Boolean(isActive) && !isOpen;
// The window keeps a fixed height so deltas never resize the page and
// retrigger the conversation scroll pin; scrolling it instead (with CSS
// scroll-smooth) glides the text up rather than snapping a line at a time.
useEffect(() => {
const el = liveRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [pacedThought, isOpen]);
return (
<div className="my-2 flex w-full flex-col">
<Button
type="button"
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"
>
<img src={Cloud} alt="" aria-hidden className="h-4 w-4 shrink-0" />
<span
className={`min-w-0 truncate text-left ${
isActive ? 'shimmer-text' : 'text-muted-foreground'
}`}
>
{t('conversation.reasoning')}
</span>
<ChevronDown
aria-hidden
className={`text-muted-foreground h-4 w-4 shrink-0 transform transition-transform duration-200 ${
isOpen ? 'rotate-180' : ''
}`}
/>
</Button>
{showLiveWindow && (
<div
ref={liveRef}
className="text-muted-foreground mt-1 ml-3 h-24 overflow-hidden scroll-smooth mask-[linear-gradient(to_bottom,transparent,black_40%)] text-sm leading-normal motion-reduce:scroll-auto"
>
<div className="flex min-h-full flex-col justify-end wrap-break-word whitespace-pre-wrap">
{pacedThought}
</div>
</div>
)}
{!showLiveWindow && !isOpen && (
<p className="text-muted-foreground mt-0.5 ml-3 truncate text-sm">
{thought}
</p>
)}
{isOpen && (
<p className="fade-in text-muted-foreground mt-0.5 ml-3 text-sm leading-normal wrap-break-word whitespace-pre-wrap">
{thought}
</p>
)}
</div>
);
}
function InlineToolCallChip({ toolCall }: { toolCall: ToolCallsType }) {
const { t } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
const isPending = toolCall.status === 'pending';
const label = getToolChipLabel(toolCall, t);
return (
<div className="my-2 flex w-full flex-col">
<Button
type="button"
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"
>
{/* ToolIcon renders nothing for a tool with no bundled icon, so the
dot below stands in via ``only:block`` to keep the row aligned. */}
<span
className={`flex h-4 w-4 shrink-0 items-center justify-center ${
isPending ? 'animate-pulse' : ''
}`}
>
<ToolIcon
name={toolCall.tool_name}
className="text-muted-foreground h-4 w-4"
/>
<span className="bg-muted-foreground/50 hidden h-1.5 w-1.5 rounded-full only:block" />
</span>
<span
className={`min-w-0 truncate text-left ${
isPending ? 'shimmer-text' : 'text-muted-foreground'
}`}
>
{label}
</span>
{toolCall.status === 'error' && (
<span className="text-destructive shrink-0 text-xs">
{t('conversation.inlineSteps.failed')}
</span>
)}
<ChevronDown
aria-hidden
className={`text-muted-foreground h-4 w-4 shrink-0 transform transition-transform duration-200 ${
isOpen ? 'rotate-180' : ''
}`}
/>
</Button>
{isOpen && (
<div className="fade-in mt-2 flex flex-col gap-2">
<ToolCallPanel
title={t('conversation.inlineSteps.arguments')}
copyText={JSON.stringify(toolCall.arguments ?? {}, null, 2)}
>
<p className="max-h-80 overflow-y-auto font-mono text-xs whitespace-pre-wrap">
{JSON.stringify(toolCall.arguments ?? {}, null, 2)}
</p>
</ToolCallPanel>
<ToolCallPanel
title={t('conversation.inlineSteps.response')}
copyText={
toolCall.status === 'error'
? (toolCall.error ?? '')
: JSON.stringify(toolCall.result ?? {}, null, 2)
}
>
{isPending && (
<p className="shimmer-text text-xs">
{t('conversation.inlineSteps.running')}
</p>
)}
{toolCall.status === 'error' && (
<p className="text-destructive font-mono text-xs whitespace-pre-wrap">
{toolCall.error}
</p>
)}
{toolCall.status === 'denied' && (
<p className="text-muted-foreground text-xs">
{t('conversation.inlineSteps.denied')}
</p>
)}
{!isPending &&
toolCall.status !== 'error' &&
toolCall.status !== 'denied' && (
<p className="max-h-80 overflow-y-auto font-mono text-xs whitespace-pre-wrap">
{JSON.stringify(toolCall.result ?? {}, null, 2)}
</p>
)}
</ToolCallPanel>
</div>
)}
</div>
);
}
function ToolCallPanel({
title,
copyText,
children,
}: {
title: string;
copyText: string;
children: React.ReactNode;
}) {
return (
<div className="bg-muted/50 dark:bg-answer-bubble overflow-hidden rounded-xl">
<div className="flex items-center justify-between px-3 py-1.5">
<span className="text-muted-foreground text-xs font-medium">
{title}
</span>
<CopyButton textToCopy={copyText} />
</div>
<div className="px-3 pb-2">{children}</div>
</div>
);
}
+2 -2
View File
@@ -372,7 +372,7 @@ export default function Conversation() {
className={`from-background pointer-events-none absolute bottom-0 left-1/2 h-6 w-full -translate-x-1/2 rounded-t-2xl bg-linear-to-t to-transparent bg-clip-content px-2 ${
isSplitArtifactOpen
? 'max-w-325'
: 'max-w-325 md:w-9/12 lg:w-8/12 xl:w-8/12 2xl:w-6/12'
: 'max-w-325 md:w-11/12 lg:w-10/12 xl:w-9/12 2xl:w-8/12'
}`}
/>
</div>
@@ -381,7 +381,7 @@ export default function Conversation() {
className={`bg-opacity-0 z-3 flex h-auto w-full flex-col items-end self-center rounded-2xl py-1 ${
isSplitArtifactOpen
? 'max-w-325'
: 'max-w-325 md:w-9/12 lg:w-8/12 xl:w-8/12 2xl:w-6/12'
: 'max-w-325 md:w-11/12 lg:w-10/12 xl:w-9/12 2xl:w-8/12'
}`}
>
<div className="flex w-full items-center rounded-full px-2">
+64 -550
View File
@@ -2,21 +2,10 @@ import 'katex/dist/katex.min.css';
import { forwardRef, Fragment, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import ReactMarkdown from 'react-markdown';
import { useSelector } from 'react-redux';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import {
oneLight,
vscDarkPlus,
} from 'react-syntax-highlighter/dist/cjs/styles/prism';
import rehypeKatex from 'rehype-katex';
import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
import SchedulerToolCallCard from '../agents/schedules/SchedulerToolCallCard';
import WorkflowRunArtifacts from '../agents/workflow/WorkflowRunArtifacts';
import ChevronDown from '../assets/chevron-down.svg';
import Cloud from '../assets/cloud.svg';
import DocsGPT3 from '../assets/cute_docsgpt3.svg';
import Dislike from '../assets/dislike.svg?react';
import Document from '../assets/document.svg';
@@ -27,17 +16,10 @@ import Link from '../assets/link.svg';
import Sources from '../assets/sources.svg';
import UserIcon from '../assets/user.svg';
import CopyButton from '../components/CopyButton';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '../components/ui/accordion';
import { Avatar } from '../components/ui/avatar';
import { Button } from '../components/ui/button';
import { Input } from '../components/ui/input';
import MermaidRenderer from '../components/MermaidRenderer';
import Spinner from '../components/Spinner';
import { Sheet, SheetContent } from '../components/ui/sheet';
import SpeakButton from '../components/TextToSpeechButton';
import { useDarkTheme, useOutsideAlerter } from '../hooks';
@@ -46,15 +28,14 @@ import {
selectSelectedDocs,
selectToken,
} from '../preferences/preferenceSlice';
import classes from './ConversationBubble.module.css';
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 {
isWikiWriteCall,
wikiWriteActionKey,
wikiWritePath,
} from './wikiToolCall';
import { wikiWriteActionKey, wikiWritePath } from './wikiToolCall';
const DisableSourceFE = import.meta.env.VITE_DISABLE_SOURCE_FE || false;
@@ -69,6 +50,8 @@ const ConversationBubble = forwardRef<
thought?: string;
sources?: { title: string; text: string; link: string }[];
toolCalls?: ToolCallsType[];
/** Arrival order of the answer's parts; drives inline rendering. */
segments?: AnswerSegment[];
/** Set when this answer came from a workflow agent run; renders the
* run's produced artifacts as click-through chips/previews. */
workflowRunId?: string;
@@ -101,6 +84,7 @@ const ConversationBubble = forwardRef<
thought,
sources,
toolCalls,
segments,
workflowRunId,
research,
retryBtn,
@@ -129,6 +113,11 @@ const ConversationBubble = forwardRef<
const editableQueryRef = useRef<HTMLDivElement>(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.
@@ -383,7 +372,7 @@ const ConversationBubble = forwardRef<
{t('conversation.sources.title')}
</p>
</div>
<div className="fade-in mr-5 ml-3 max-w-[90vw] md:max-w-[70vw] lg:max-w-[50vw]">
<div className="fade-in mr-5 ml-3 w-full">
<div className="grid grid-cols-2 gap-2 lg:grid-cols-4">
{sources?.slice(0, 3)?.map((source, index) => (
<div
@@ -464,13 +453,6 @@ const ConversationBubble = forwardRef<
</div>
)}
{research && <ResearchProgress research={research} />}
{toolCalls && toolCalls.length > 0 && (
<ToolCalls
toolCalls={toolCalls}
onToolAction={onToolAction}
agentId={agentId}
/>
)}
{!message && onOpenArtifact && completedArtifacts.length > 0 && (
<div className="my-2 ml-2 flex flex-wrap justify-start gap-2">
{completedArtifacts.map((artifact, artifactIndex) => (
@@ -519,212 +501,56 @@ const ConversationBubble = forwardRef<
/>
</div>
)}
{thought && (
<Thought thought={thought} preprocessLaTeX={preprocessLaTeX} />
{type === 'ERROR' ? (
message && (
<div className="flex max-w-full flex-col flex-wrap items-start self-start lg:flex-nowrap">
<div className="my-2 flex flex-row items-center justify-center gap-3">
<Avatar
src={DocsGPT3}
alt={t('conversation.answer')}
className="h-8.5 w-8.5 text-2xl"
imgClassName="h-full w-full object-cover"
/>
<p className="text-base font-semibold">
{t('conversation.answer')}
</p>
</div>
<div className="fade-in-bubble text-destructive/80 dark:border-destructive dark:bg-destructive/15 relative mr-5 flex max-w-full flex-row items-center rounded-full border border-transparent bg-[#FFE7E7] p-2 px-6 py-5 text-sm font-normal dark:text-white">
<MarkdownAnswer content={message} isStreaming={isStreaming} />
</div>
</div>
)
) : (
<AnswerFlow
message={message}
thought={thought}
toolCalls={toolCalls}
segments={segments}
isStreaming={isStreaming}
agentId={agentId}
renderApproval={(toolCall: ToolCallsType) => (
<div className="fade-in mt-4 w-full">
<ToolCallApprovalBar
toolCall={toolCall}
onToolAction={onToolAction}
/>
</div>
)}
renderWikiWrite={(toolCall: ToolCallsType) => (
<div className="fade-in mt-4 w-full">
<WikiWriteToolCallCard toolCall={toolCall} />
</div>
)}
/>
)}
{message && (
<div className="flex max-w-full flex-col flex-wrap items-start self-start lg:flex-nowrap">
<div className="my-2 flex flex-row items-center justify-center gap-3">
<Avatar
src={DocsGPT3}
alt={t('conversation.answer')}
className="h-8.5 w-8.5 text-2xl"
imgClassName="h-full w-full object-cover"
/>
<p className="text-base font-semibold">
{t('conversation.answer')}
</p>
</div>
<div
className={`fade-in-bubble bg-answer-bubble mr-5 flex max-w-full rounded-2xl px-6 py-4.5 ${
type === 'ERROR'
? 'text-destructive/80 dark:border-destructive dark:bg-destructive/15 relative flex-row items-center rounded-full border border-transparent bg-[#FFE7E7] p-2 py-5 text-sm font-normal dark:text-white'
: 'flex-col rounded-3xl'
}`}
>
{(() => {
const contentSegments = processMarkdownContent(message);
return (
<>
{contentSegments.map((segment, index) => (
<Fragment key={index}>
{segment.type === 'text' ? (
<ReactMarkdown
className="fade-in flex flex-col gap-3 leading-normal wrap-break-word whitespace-pre-wrap"
remarkPlugins={[
remarkGfm,
[remarkMath, { singleDollarTextMath: false }],
]}
rehypePlugins={[rehypeKatex]}
components={{
a({ href, children }) {
if (href?.startsWith('#cite-')) {
const num = href.replace('#cite-', '');
const sourceIdx = parseInt(num, 10) - 1;
return (
<Button
type="button"
onClick={() => {
const el = document.getElementById(
`source-${sourceIdx}`,
);
if (el) {
el.scrollIntoView({
behavior: 'smooth',
block: 'center',
});
el.classList.add(
'ring-2',
'ring-purple-500',
);
setTimeout(
() =>
el.classList.remove(
'ring-2',
'ring-purple-500',
),
2000,
);
}
}}
className="mx-0.5 h-5 min-w-5 rounded-full bg-purple-100 px-1.5 text-xs font-semibold text-purple-700 hover:bg-purple-200 dark:bg-purple-900/40 dark:text-purple-300 dark:hover:bg-purple-900/60"
title={`Jump to source ${num}`}
>
{num}
</Button>
);
}
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
>
{children}
</a>
);
},
code(props) {
const {
children,
className,
node,
ref,
...rest
} = props;
const match = /language-(\w+)/.exec(
className || '',
);
const language = match ? match[1] : '';
return match ? (
<div className="group border-border relative overflow-hidden rounded-xl border">
<div className="bg-muted flex items-center justify-between px-2 py-1">
<span className="text-foreground dark:text-foreground text-xs font-medium">
{language}
</span>
<CopyButton
textToCopy={String(children).replace(
/\n$/,
'',
)}
/>
</div>
<SyntaxHighlighter
{...rest}
PreTag="div"
language={language}
style={
isDarkTheme ? vscDarkPlus : oneLight
}
className="mt-0!"
customStyle={{
margin: 0,
borderRadius: 0,
}}
>
{String(children).replace(/\n$/, '')}
</SyntaxHighlighter>
</div>
) : (
<code className="dark:bg-accent dark:text-foreground rounded-md bg-gray-200 px-2 py-1 text-xs font-normal whitespace-pre-line">
{children}
</code>
);
},
ul({ children }) {
return (
<ul
className={`list-inside list-disc pl-4 whitespace-normal ${classes.list}`}
>
{children}
</ul>
);
},
ol({ children }) {
return (
<ol
className={`list-inside list-decimal pl-4 whitespace-normal ${classes.list}`}
>
{children}
</ol>
);
},
table({ children }) {
return (
<div className="border-border relative overflow-x-auto rounded-lg border">
<table className="dark:text-foreground w-full text-left text-gray-700">
{children}
</table>
</div>
);
},
thead({ children }) {
return (
<thead className="bg-muted text-foreground text-xs uppercase">
{children}
</thead>
);
},
tr({ children }) {
return (
<tr className="border-border odd:bg-card even:bg-muted border-b">
{children}
</tr>
);
},
th({ children }) {
return (
<th className="px-6 py-3">{children}</th>
);
},
td({ children }) {
return (
<td className="px-6 py-3">{children}</td>
);
},
}}
>
{segment.content}
</ReactMarkdown>
) : (
<div
className="my-4 w-full"
style={{ minWidth: '100%' }}
>
<MermaidRenderer
code={segment.content}
isLoading={isStreaming}
/>
</div>
)}
</Fragment>
))}
</>
);
})()}
</div>
</div>
{/* Only when nothing inline is already showing the activity: a live
reasoning or tool segment carries its own shimmer, so this would be
a second indicator away from the point of action. */}
{isStreaming && !hasLiveInlineStep && (
<StreamingStatusLine
hasAnswerText={Boolean(message)}
className="my-2 ml-3"
/>
)}
{message && (
<div className="my-2 ml-2 flex flex-wrap justify-start gap-2">
@@ -1150,315 +976,3 @@ export function WikiWriteToolCallCard({
</div>
);
}
export function ToolCalls({
toolCalls,
onToolAction,
agentId,
}: {
toolCalls: ToolCallsType[];
onToolAction?: (
callId: string,
decision: 'approved' | 'denied',
comment?: string,
) => void;
agentId?: string;
}) {
const [isToolCallsOpen, setIsToolCallsOpen] = useState(false);
const awaitingCalls = toolCalls.filter(
(tc) => tc.status === 'awaiting_approval',
);
const wikiWriteCalls = toolCalls.filter(
(tc) => tc.status !== 'awaiting_approval' && isWikiWriteCall(tc),
);
const resolvedCalls = toolCalls.filter(
(tc) => tc.status !== 'awaiting_approval' && !isWikiWriteCall(tc),
);
return (
<div className="relative mb-4 flex w-full flex-col flex-wrap items-start self-start lg:flex-nowrap">
{/* Approval bars — always visible, compact inline */}
{awaitingCalls.length > 0 && (
<div className="fade-in mt-4 ml-3 w-[90vw] md:w-[70vw] lg:w-full">
{awaitingCalls.map((tc) => (
<ToolCallApprovalBar
key={`approval-${tc.call_id}`}
toolCall={tc}
onToolAction={onToolAction}
/>
))}
</div>
)}
{/* Wiki write cards — always visible (D24: visibility is the control) */}
{wikiWriteCalls.length > 0 && (
<div className="fade-in mt-4 mr-5 ml-3 grid w-[90vw] grid-cols-1 gap-2 md:w-[70vw] lg:w-full">
{wikiWriteCalls.map((toolCall, index) => (
<WikiWriteToolCallCard
key={`wiki-write-${toolCall.call_id ?? index}`}
toolCall={toolCall}
/>
))}
</div>
)}
{/* Regular tool calls accordion */}
{resolvedCalls.length > 0 && (
<>
<div className="my-2 flex flex-row items-center justify-center gap-3">
<Avatar
src={Sources}
alt={'ToolCalls'}
className="h-6.5 w-7.5 text-xl"
imgClassName="h-full w-full object-fill"
/>
<Button
type="button"
variant="ghost"
size="sm"
className="h-auto bg-transparent px-0 py-0 font-normal hover:bg-transparent dark:hover:bg-transparent"
onClick={() => setIsToolCallsOpen(!isToolCallsOpen)}
>
<p className="text-base font-semibold">Tool Calls</p>
<img
src={ChevronDown}
alt="ChevronDown"
className={`h-4 w-4 transform transition-transform duration-200 dark:invert ${isToolCallsOpen ? 'rotate-180' : ''}`}
/>
</Button>
</div>
{isToolCallsOpen && (
<div className="fade-in mr-5 ml-3 w-[90vw] md:w-[70vw] lg:w-full">
<div className="grid grid-cols-1 gap-2">
{resolvedCalls.map((toolCall, index) => {
if (toolCall.tool_name === 'scheduler') {
return (
<SchedulerToolCallCard
key={`scheduler-${toolCall.call_id ?? index}`}
result={toolCall.result}
actionName={toolCall.action_name}
status={toolCall.status}
agentId={agentId}
/>
);
}
return (
<Accordion
key={`tool-call-${index}`}
type="single"
collapsible
className="bg-muted dark:bg-answer-bubble w-full rounded-4xl"
>
<AccordionItem value="tool-call">
<AccordionTrigger className="px-6 py-2 text-sm font-semibold">
{`${toolCall.tool_name} - ${toolCall.action_name.substring(0, toolCall.action_name.lastIndexOf('_'))}`}
</AccordionTrigger>
<AccordionContent>
<div className="flex flex-col gap-1">
<div className="border-border flex flex-col rounded-2xl border">
<p className="dark:bg-background flex flex-row items-center justify-between rounded-t-2xl bg-black/10 px-2 py-1 font-mono text-sm font-semibold wrap-break-word">
<span>Arguments</span>{' '}
<CopyButton
textToCopy={JSON.stringify(
toolCall.arguments,
null,
2,
)}
/>
</p>
<p className="dark:bg-card max-h-80 overflow-y-auto rounded-b-2xl p-2 font-mono text-sm wrap-break-word">
<span className="dark:text-muted-foreground leading-5.75 text-black">
{JSON.stringify(toolCall.arguments, null, 2)}
</span>
</p>
</div>
<div className="border-border flex flex-col rounded-2xl border">
<p className="dark:bg-background flex flex-row items-center justify-between rounded-t-2xl bg-black/10 px-2 py-1 font-mono text-sm font-semibold wrap-break-word">
<span>Response</span>{' '}
<CopyButton
textToCopy={
toolCall.status === 'error'
? toolCall.error || 'Unknown error'
: JSON.stringify(toolCall.result, null, 2)
}
/>
</p>
{toolCall.status === 'pending' && (
<span className="dark:bg-card flex w-full items-center justify-center rounded-b-2xl p-2">
<Spinner size="small" />
</span>
)}
{toolCall.status === 'completed' && (
<p className="dark:bg-card max-h-80 overflow-y-auto rounded-b-2xl p-2 font-mono text-sm wrap-break-word">
<span className="dark:text-muted-foreground leading-5.75 text-black">
{JSON.stringify(toolCall.result, null, 2)}
</span>
</p>
)}
{toolCall.status === 'error' && (
<p className="dark:bg-card max-h-80 overflow-y-auto rounded-b-2xl p-2 font-mono text-sm wrap-break-word">
<span className="text-destructive leading-5.75">
{toolCall.error}
</span>
</p>
)}
{toolCall.status === 'denied' && (
<p className="dark:bg-card rounded-b-2xl p-2 font-mono text-sm wrap-break-word">
<span className="text-muted-foreground leading-5.75">
Denied by user
</span>
</p>
)}
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
);
})}
</div>
</div>
)}
</>
)}
</div>
);
}
function Thought({
thought,
preprocessLaTeX,
}: {
thought: string;
preprocessLaTeX: (content: string) => string;
}) {
const { t } = useTranslation();
const [isDarkTheme] = useDarkTheme();
const [isThoughtOpen, setIsThoughtOpen] = useState(false);
return (
<div className="mb-4 flex w-full flex-col flex-wrap items-start self-start lg:flex-nowrap">
<div className="my-2 flex flex-row items-center justify-center gap-3">
<Avatar
src={Cloud}
alt={'Thought'}
className="h-6.5 w-7.5 text-xl"
imgClassName="h-full w-full object-fill"
/>
<Button
type="button"
variant="ghost"
size="sm"
className="h-auto bg-transparent px-0 py-0 font-normal hover:bg-transparent dark:hover:bg-transparent"
onClick={() => setIsThoughtOpen(!isThoughtOpen)}
>
<p className="text-base font-semibold">
{t('conversation.reasoning')}
</p>
<img
src={ChevronDown}
alt="ChevronDown"
className={`h-4 w-4 transform transition-transform duration-200 dark:invert ${isThoughtOpen ? 'rotate-180' : ''}`}
/>
</Button>
</div>
{isThoughtOpen && (
<div className="fade-in mr-5 ml-2 max-w-[90vw] md:max-w-[70vw] lg:max-w-[50vw]">
<div className="bg-muted dark:bg-answer-bubble rounded-3xl px-7 py-4.5">
<ReactMarkdown
className="fade-in leading-normal wrap-break-word whitespace-pre-wrap"
remarkPlugins={[
remarkGfm,
[remarkMath, { singleDollarTextMath: false }],
]}
rehypePlugins={[rehypeKatex]}
components={{
code(props) {
const { children, className, node, ref, ...rest } = props;
const match = /language-(\w+)/.exec(className || '');
const language = match ? match[1] : '';
return match ? (
<div className="group border-border relative overflow-hidden rounded-xl border">
<div className="bg-muted flex items-center justify-between px-2 py-1">
<span className="text-foreground dark:text-foreground text-xs font-medium">
{language}
</span>
<CopyButton
textToCopy={String(children).replace(/\n$/, '')}
/>
</div>
<SyntaxHighlighter
{...rest}
PreTag="div"
language={language}
style={isDarkTheme ? vscDarkPlus : oneLight}
className="mt-0!"
customStyle={{
margin: 0,
borderRadius: 0,
}}
>
{String(children).replace(/\n$/, '')}
</SyntaxHighlighter>
</div>
) : (
<code className="dark:bg-accent dark:text-foreground rounded-md bg-gray-200 px-2 py-1 text-xs font-normal whitespace-pre-line">
{children}
</code>
);
},
ul({ children }) {
return (
<ul className="list-inside list-disc pl-4 whitespace-normal">
{children}
</ul>
);
},
ol({ children }) {
return (
<ol className="list-inside list-decimal pl-4 whitespace-normal">
{children}
</ol>
);
},
table({ children }) {
return (
<div className="border-border relative overflow-x-auto rounded-lg border">
<table className="dark:text-foreground w-full text-left text-gray-700">
{children}
</table>
</div>
);
},
thead({ children }) {
return (
<thead className="bg-muted text-foreground text-xs uppercase">
{children}
</thead>
);
},
tr({ children }) {
return (
<tr className="border-border odd:bg-card even:bg-muted border-b">
{children}
</tr>
);
},
th({ children }) {
return <th className="px-6 py-3">{children}</th>;
},
td({ children }) {
return <td className="px-6 py-3">{children}</td>;
},
}}
>
{preprocessLaTeX(thought ?? '')}
</ReactMarkdown>
</div>
</div>
)}
</div>
);
}
@@ -22,6 +22,7 @@ import {
import Hero from '../Hero';
import ConversationBubble from './ConversationBubble';
import { FEEDBACK, Query, Status } from './conversationModels';
import StreamingStatusLine from './StreamingStatusLine';
type ConversationMessagesProps = {
handleQuestion: (params: {
@@ -55,6 +56,8 @@ const STICK_THRESHOLD_PX = 48;
const DEFAULT_BUBBLE_MARGIN = 'mb-7';
const FIRST_QUESTION_BUBBLE_MARGIN_TOP = 'mt-5';
// Below DEFAULT_BUBBLE_MARGIN so a question groups with its own answer.
const QUESTION_BUBBLE_MARGIN_BOTTOM = 'mb-3';
export default function ConversationMessages({
handleQuestion,
@@ -126,7 +129,7 @@ export default function ConversationMessages({
const columnClass = isSplitView
? 'w-full max-w-325 px-2'
: 'w-full max-w-325 px-2 md:w-9/12 lg:w-8/12 xl:w-8/12 2xl:w-6/12';
: 'w-full max-w-325 px-2 md:w-11/12 lg:w-10/12 xl:w-9/12 2xl:w-8/12';
const renderResponseView = (query: Query, index: number) => {
const isLastMessage = index === queries.length - 1;
@@ -201,6 +204,7 @@ export default function ConversationMessages({
thought={query.thought}
sources={query.sources}
toolCalls={query.tool_calls}
segments={query.segments}
workflowRunId={query.workflow_run_id}
research={query.research}
onOpenArtifact={onOpenArtifact}
@@ -236,13 +240,7 @@ export default function ConversationMessages({
{t('conversation.answer')}
</p>
</div>
<div className="bg-muted mr-5 flex rounded-3xl px-6 py-5">
<div className="thinking-dots">
<span></span>
<span></span>
<span></span>
</div>
</div>
<StreamingStatusLine className="my-2 ml-3" />
</div>
</div>
);
@@ -279,9 +277,9 @@ export default function ConversationMessages({
<Fragment key={`${index}-query-fragment`}>
<MessageScrollerItem messageId={`q-${index}`} scrollAnchor>
<ConversationBubble
className={
className={`${QUESTION_BUBBLE_MARGIN_BOTTOM} ${
index === 0 ? FIRST_QUESTION_BUBBLE_MARGIN_TOP : ''
}
}`}
message={query.prompt}
type="QUESTION"
handleUpdatedQuestionSubmission={handleQuestionSubmission}
@@ -0,0 +1,223 @@
import 'katex/dist/katex.min.css';
import { Fragment, useMemo } from 'react';
import ReactMarkdown from 'react-markdown';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import {
oneLight,
vscDarkPlus,
} from 'react-syntax-highlighter/dist/cjs/styles/prism';
import rehypeKatex from 'rehype-katex';
import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
import CopyButton from '../components/CopyButton';
import MermaidRenderer from '../components/MermaidRenderer';
import { Button } from '../components/ui/button';
import { useDarkTheme } from '../hooks';
import classes from './ConversationBubble.module.css';
// Replaces block-level ``\[ \]`` and inline ``\( \)`` LaTeX delimiters with the
// ``$$``/``$`` forms remark-math understands.
export function preprocessLaTeX(content: string): string {
const blockProcessedContent = content.replace(
/\\\[(.*?)\\\]/gs,
(_, equation) => `$$${equation}$$`,
);
return blockProcessedContent.replace(
/\\\((.*?)\\\)/gs,
(_, equation) => `$${equation}$`,
);
}
type ContentSegment = { type: 'text' | 'mermaid'; content: string };
export function processMarkdownContent(content: string): ContentSegment[] {
let processedContent = preprocessLaTeX(content);
// Convert citation references [N] into markdown links [N](#cite-N)
// so ReactMarkdown renders them as <a> tags we can style.
// Avoid matching inside code blocks or existing links.
processedContent = processedContent.replace(
/(?<!\[)\[(\d+)\](?!\()/g,
(_, num) => `[${num}](#cite-${num})`,
);
const contentSegments: ContentSegment[] = [];
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;
}
export default function MarkdownAnswer({
content,
isStreaming,
}: {
content: string;
isStreaming?: boolean;
}) {
const [isDarkTheme] = useDarkTheme();
// Re-runs on every streamed token otherwise.
const contentSegments = useMemo(
() => processMarkdownContent(content),
[content],
);
return (
<>
{contentSegments.map((segment, index) => (
<Fragment key={index}>
{segment.type === 'text' ? (
<ReactMarkdown
className="fade-in flex flex-col gap-3 leading-normal wrap-break-word whitespace-pre-wrap"
remarkPlugins={[
remarkGfm,
[remarkMath, { singleDollarTextMath: false }],
]}
rehypePlugins={[rehypeKatex]}
components={{
a({ href, children }) {
if (href?.startsWith('#cite-')) {
const num = href.replace('#cite-', '');
const sourceIdx = parseInt(num, 10) - 1;
return (
<Button
type="button"
onClick={() => {
const el = document.getElementById(
`source-${sourceIdx}`,
);
if (el) {
el.scrollIntoView({
behavior: 'smooth',
block: 'center',
});
el.classList.add('ring-2', 'ring-purple-500');
setTimeout(
() =>
el.classList.remove(
'ring-2',
'ring-purple-500',
),
2000,
);
}
}}
className="mx-0.5 h-5 min-w-5 rounded-full bg-purple-100 px-1.5 text-xs font-semibold text-purple-700 hover:bg-purple-200 dark:bg-purple-900/40 dark:text-purple-300 dark:hover:bg-purple-900/60"
title={`Jump to source ${num}`}
>
{num}
</Button>
);
}
return (
<a href={href} target="_blank" rel="noopener noreferrer">
{children}
</a>
);
},
code(props) {
const { children, className, node, ref, ...rest } = props;
const match = /language-(\w+)/.exec(className || '');
const language = match ? match[1] : '';
return match ? (
<div className="group border-border relative overflow-hidden rounded-xl border">
<div className="bg-muted flex items-center justify-between px-2 py-1">
<span className="text-foreground dark:text-foreground text-xs font-medium">
{language}
</span>
<CopyButton
textToCopy={String(children).replace(/\n$/, '')}
/>
</div>
<SyntaxHighlighter
{...rest}
PreTag="div"
language={language}
style={isDarkTheme ? vscDarkPlus : oneLight}
className="mt-0!"
customStyle={{ margin: 0, borderRadius: 0 }}
>
{String(children).replace(/\n$/, '')}
</SyntaxHighlighter>
</div>
) : (
<code className="dark:bg-accent dark:text-foreground rounded-md bg-gray-200 px-2 py-1 text-xs font-normal whitespace-pre-line">
{children}
</code>
);
},
ul({ children }) {
return (
<ul
className={`list-inside list-disc pl-4 whitespace-normal ${classes.list}`}
>
{children}
</ul>
);
},
ol({ children }) {
return (
<ol
className={`list-inside list-decimal pl-4 whitespace-normal ${classes.list}`}
>
{children}
</ol>
);
},
table({ children }) {
return (
<div className="border-border relative overflow-x-auto rounded-lg border">
<table className="dark:text-foreground w-full text-left text-gray-700">
{children}
</table>
</div>
);
},
thead({ children }) {
return (
<thead className="bg-muted text-foreground text-xs uppercase">
{children}
</thead>
);
},
tr({ children }) {
return (
<tr className="border-border odd:bg-card even:bg-muted border-b">
{children}
</tr>
);
},
th({ children }) {
return <th className="px-6 py-3">{children}</th>;
},
td({ children }) {
return <td className="px-6 py-3">{children}</td>;
},
}}
>
{segment.content}
</ReactMarkdown>
) : (
<div className="my-4 w-full" style={{ minWidth: '100%' }}>
<MermaidRenderer code={segment.content} isLoading={isStreaming} />
</div>
)}
</Fragment>
))}
</>
);
}
@@ -0,0 +1,33 @@
import { useTranslation } from 'react-i18next';
import { cn } from '../lib/utils';
type StreamingStatusLineProps = {
hasAnswerText?: boolean;
className?: string;
};
/**
* Only renders while no step chip is live above it, since chips announce their
* own activity. That leaves the two states nothing else covers.
*/
export default function StreamingStatusLine({
hasAnswerText = false,
className,
}: StreamingStatusLineProps) {
const { t } = useTranslation();
return (
<div
role="status"
aria-live="polite"
className={cn('flex h-6 min-w-0 items-center gap-2', className)}
>
<span className="shimmer-text max-w-[70vw] truncate text-sm lg:max-w-md">
{hasAnswerText
? t('conversation.streamingStatus.generating')
: t('conversation.streamingStatus.thinking')}
</span>
</div>
);
}
@@ -4,7 +4,8 @@ import { I18nextProvider, initReactI18next } from 'react-i18next';
import { beforeAll, describe, expect, it } from 'vitest';
import en from '../locale/en.json';
import { ToolCalls, WikiWriteToolCallCard } from './ConversationBubble';
import AnswerFlow from './AnswerFlow';
import { WikiWriteToolCallCard } from './ConversationBubble';
import { ToolCallsType } from './types';
const testI18n = i18n.createInstance();
@@ -63,11 +64,17 @@ describe('WikiWriteToolCallCard', () => {
});
});
describe('ToolCalls placement', () => {
describe('tool call placement in the answer flow', () => {
const renderToolCalls = (toolCalls: ToolCallsType[]): string =>
renderToStaticMarkup(
<I18nextProvider i18n={testI18n}>
<ToolCalls toolCalls={toolCalls} />
<AnswerFlow
toolCalls={toolCalls}
renderApproval={() => null}
renderWikiWrite={(toolCall) => (
<WikiWriteToolCallCard toolCall={toolCall} />
)}
/>
</I18nextProvider>,
);
@@ -87,14 +94,14 @@ describe('ToolCalls placement', () => {
status: 'completed',
};
it('renders wiki write cards outside the collapsed accordion (default closed)', () => {
it('shows wiki write cards inline and keeps other call arguments collapsed', () => {
const html = renderToolCalls([wikiWrite, otherCall]);
expect(html).toContain('Edited wiki page');
expect(html).toContain('/policy.md');
expect(html).not.toContain('secret-arg');
});
it('does not double-render wiki write cards inside the accordion', () => {
it('renders each wiki write card exactly once', () => {
const html = renderToolCalls([wikiWrite]);
const occurrences = html.split('Edited wiki page').length - 1;
expect(occurrences).toBe(1);
@@ -0,0 +1,95 @@
import i18n from 'i18next';
import { renderToStaticMarkup } from 'react-dom/server';
import { I18nextProvider, initReactI18next } from 'react-i18next';
import { beforeAll, describe, expect, it } from 'vitest';
import en from '../locale/en.json';
import AnswerFlow from './AnswerFlow';
import reducer, {
addQuery,
setStatus,
updateStreamingQuery,
updateThought,
updateToolCall,
} from './conversationSlice';
import { ToolCallsType } from './types';
const testI18n = i18n.createInstance();
beforeAll(async () => {
await testI18n.use(initReactI18next).init({
lng: 'en',
fallbackLng: 'en',
resources: { en: { translation: en } },
});
});
const searchCall = (status: ToolCallsType['status']): ToolCallsType => ({
tool_name: 'brave',
action_name: 'brave_web_search',
call_id: 'c1',
arguments: { query: 'docsgpt' },
status,
...(status === 'completed' ? { result: { hits: 1 } } : {}),
});
// Covers the seam the unit tests leave open: that the stream reducers produce
// exactly the shape AnswerFlow renders from.
describe('stream reducers feeding the answer flow', () => {
it('records steps in arrival order and renders them above one answer bubble', () => {
const at = { index: 0, conversationId: null };
let state = reducer(undefined, addQuery({ prompt: 'hi' }));
state = reducer(state, setStatus('loading'));
state = reducer(
state,
updateThought({ ...at, query: { thought: 'I should search.' } }),
);
// The same call arrives twice, pending then completed.
state = reducer(
state,
updateToolCall({ ...at, tool_call: searchCall('pending') }),
);
state = reducer(
state,
updateToolCall({ ...at, tool_call: searchCall('completed') }),
);
state = reducer(
state,
updateStreamingQuery({ ...at, query: { response: 'Docs' } }),
);
state = reducer(
state,
updateStreamingQuery({ ...at, query: { response: 'GPT rocks.' } }),
);
const query = state.queries[0];
expect(query.segments).toEqual([
{ kind: 'thought', text: 'I should search.' },
{ kind: 'tool', call_id: 'c1' },
]);
expect(query.response).toBe('DocsGPT rocks.');
expect(query.tool_calls).toHaveLength(1);
expect(query.tool_calls?.[0].status).toBe('completed');
const html = renderToStaticMarkup(
<I18nextProvider i18n={testI18n}>
<AnswerFlow
message={query.response}
thought={query.thought}
toolCalls={query.tool_calls}
segments={query.segments}
renderApproval={() => null}
renderWikiWrite={() => null}
/>
</I18nextProvider>,
);
expect(html.split('fade-in-bubble').length - 1).toBe(1);
expect(html.indexOf('I should search.')).toBeLessThan(
html.indexOf('Searched the web'),
);
expect(html.indexOf('Searched the web')).toBeLessThan(
html.indexOf('DocsGPT rocks.'),
);
});
});
@@ -0,0 +1,99 @@
import { describe, expect, it } from 'vitest';
import {
AnswerSegment,
appendThoughtText,
getAnswerSegments,
recordToolCall,
synthesizeSegments,
} from './answerSegments';
import { ToolCallsType } from './types';
const call = (overrides: Partial<ToolCallsType> = {}): ToolCallsType => ({
tool_name: 'brave',
action_name: 'brave_web_search',
call_id: 'c1',
arguments: {},
status: 'completed',
...overrides,
});
describe('appendThoughtText', () => {
it('coalesces consecutive reasoning deltas', () => {
const segments: AnswerSegment[] = [];
appendThoughtText(segments, 'I should ');
appendThoughtText(segments, 'search');
expect(segments).toEqual([{ kind: 'thought', text: 'I should search' }]);
});
it('starts a new reasoning step after a tool call', () => {
const segments: AnswerSegment[] = [];
appendThoughtText(segments, 'plan');
recordToolCall(segments, 'c1');
appendThoughtText(segments, 'now verify');
expect(segments).toEqual([
{ kind: 'thought', text: 'plan' },
{ kind: 'tool', call_id: 'c1' },
{ kind: 'thought', text: 'now verify' },
]);
});
});
describe('recordToolCall', () => {
it('records a call once even though pending and completed both fire', () => {
const segments: AnswerSegment[] = [];
recordToolCall(segments, 'c1');
recordToolCall(segments, 'c1');
expect(segments).toEqual([{ kind: 'tool', call_id: 'c1' }]);
});
it('keeps arrival order across several calls', () => {
const segments: AnswerSegment[] = [];
recordToolCall(segments, 'c1');
recordToolCall(segments, 'c2');
recordToolCall(segments, 'c1');
expect(segments).toEqual([
{ kind: 'tool', call_id: 'c1' },
{ kind: 'tool', call_id: 'c2' },
]);
});
it('ignores an empty call id', () => {
const segments: AnswerSegment[] = [];
recordToolCall(segments, '');
expect(segments).toEqual([]);
});
});
describe('synthesizeSegments / getAnswerSegments', () => {
it('orders a reloaded answer as reasoning then tool calls', () => {
expect(
synthesizeSegments({
thought: 'reasoning',
tool_calls: [call({ call_id: 'c1' }), call({ call_id: 'c2' })],
}),
).toEqual([
{ kind: 'thought', text: 'reasoning' },
{ kind: 'tool', call_id: 'c1' },
{ kind: 'tool', call_id: 'c2' },
]);
});
it('omits fields that are absent', () => {
expect(synthesizeSegments({ thought: 'only reasoning' })).toEqual([
{ kind: 'thought', text: 'only reasoning' },
]);
expect(synthesizeSegments({})).toEqual([]);
});
it('prefers live steps when present', () => {
const live: AnswerSegment[] = [{ kind: 'thought', text: 'live' }];
expect(getAnswerSegments({ thought: 'flat', segments: live })).toBe(live);
});
it('falls back when steps were cleared by a tail snapshot', () => {
expect(getAnswerSegments({ thought: 'flat', segments: [] })).toEqual([
{ kind: 'thought', text: 'flat' },
]);
});
});
@@ -0,0 +1,54 @@
import { ToolCallsType } from './types';
/**
* Ordering layer only: ``response``/``thought``/``tool_calls`` keep accumulating
* as before and stay the source of truth for copy, feedback, history and resume.
*/
export type AnswerSegment =
| { kind: 'thought'; text: string }
// Id only. The call is upserted in place in ``query.tool_calls``, so a chip
// always reads its current status rather than a frozen copy.
| { kind: 'tool'; call_id: string };
export function appendThoughtText(
segments: AnswerSegment[],
delta: string,
): void {
const last = segments[segments.length - 1];
if (last?.kind === 'thought') last.text += delta;
else segments.push({ kind: 'thought', text: delta });
}
export function recordToolCall(
segments: AnswerSegment[],
callId: string,
): void {
if (!callId) return;
// Each call arrives twice (pending, then completed); only the first fixes
// its position.
const seen = segments.some(
(segment) => segment.kind === 'tool' && segment.call_id === callId,
);
if (!seen) segments.push({ kind: 'tool', call_id: callId });
}
/** Fallback order for answers with no recorded arrival order (reloads, shares). */
export function synthesizeSegments(query: {
thought?: string;
tool_calls?: ToolCallsType[];
}): AnswerSegment[] {
const segments: AnswerSegment[] = [];
if (query.thought) segments.push({ kind: 'thought', text: query.thought });
query.tool_calls?.forEach((call) => {
if (call.call_id) segments.push({ kind: 'tool', call_id: call.call_id });
});
return segments;
}
export function getAnswerSegments(query: {
thought?: string;
tool_calls?: ToolCallsType[];
segments?: AnswerSegment[];
}): AnswerSegment[] {
return query.segments?.length ? query.segments : synthesizeSegments(query);
}
@@ -1,3 +1,4 @@
import { AnswerSegment } from './answerSegments';
import { ToolCallsType } from './types';
export type MESSAGE_TYPE = 'QUESTION' | 'ANSWER' | 'ERROR';
@@ -62,6 +63,10 @@ export interface Query {
thought?: string;
sources?: { title: string; text: string; link: string }[];
tool_calls?: ToolCallsType[];
// Arrival-ordered layout of the fields above, so reasoning and tool calls
// render where they happened. Live-stream only; absent on reload, where
// ``getAnswerSegments`` synthesizes an order instead.
segments?: AnswerSegment[];
// Set when this answer came from a workflow agent run; lets the chat render
// the run's produced artifacts via WorkflowRunArtifacts.
workflow_run_id?: string;
@@ -18,6 +18,7 @@ import {
selectCompletedAttachments,
} from '../upload/uploadSlice';
import { newIdempotencyKey } from '../utils/idempotency';
import { appendThoughtText, recordToolCall } from './answerSegments';
import {
handleFetchAnswer,
handleFetchAnswerSteaming,
@@ -785,6 +786,7 @@ export const conversationSlice = createSlice({
delete state.queries[index].thought;
delete state.queries[index].sources;
delete state.queries[index].tool_calls;
delete state.queries[index].segments;
delete state.queries[index].error;
delete state.queries[index].structured;
delete state.queries[index].schema;
@@ -852,6 +854,8 @@ export const conversationSlice = createSlice({
if (query.thought != undefined) {
state.queries[index].thought =
(state.queries[index].thought || '') + query.thought;
if (!state.queries[index].segments) state.queries[index].segments = [];
appendThoughtText(state.queries[index].segments, query.thought);
}
},
updateStreamingSource(
@@ -884,6 +888,9 @@ export const conversationSlice = createSlice({
...tool_call,
};
} else state.queries[index].tool_calls.push(tool_call);
if (!state.queries[index].segments) state.queries[index].segments = [];
recordToolCall(state.queries[index].segments, tool_call.call_id);
},
// Records the workflow run id so the answer bubble can render the run's
// produced artifacts (WorkflowRunArtifacts fetches by this id).
@@ -1006,6 +1013,9 @@ export const conversationSlice = createSlice({
const { index, tail } = action.payload;
const query = state.queries[index];
if (!query) return;
// A tail is a flat snapshot with no ordering, so any live segments it
// overwrites would be stale; drop them and let rendering synthesize.
delete query.segments;
const status = tail?.status as MessageStatus | undefined;
query.messageStatus = status;
query.lastHeartbeatAt = tail?.last_heartbeat_at ?? query.lastHeartbeatAt;
+30
View File
@@ -65,6 +65,36 @@ export function useMediaQuery() {
return { isMobile, isTablet, isDesktop };
}
/**
* Reveals ``text`` gradually while ``enabled``, catching up faster the further
* the display falls behind (lag stays bounded at roughly half a second).
* When ``enabled`` is false the full text is returned immediately.
*/
export function usePacedText(text: string, enabled: boolean): string {
const textRef = useRef(text);
textRef.current = text;
const [visibleLength, setVisibleLength] = useState(() =>
enabled ? 0 : text.length,
);
useEffect(() => {
if (!enabled) {
setVisibleLength(textRef.current.length);
return;
}
const id = window.setInterval(() => {
setVisibleLength((len) => {
const backlog = textRef.current.length - len;
if (backlog <= 0) return len;
return len + Math.max(2, Math.ceil(backlog * 0.08));
});
}, 50);
return () => window.clearInterval(id);
}, [enabled]);
return enabled ? text.slice(0, Math.min(visibleLength, text.length)) : text;
}
export function useDarkTheme() {
const getSystemThemePreference = () => {
return (
+33 -35
View File
@@ -713,46 +713,44 @@ Avoid over-scrolling in mobile browsers
animation: fadeInUp 0.5s forwards;
}
.thinking-dots {
display: inline-flex;
gap: 4px;
align-items: center;
.shimmer-text {
background: linear-gradient(
90deg,
#6b7280 0%,
#6b7280 40%,
#d1d5db 50%,
#6b7280 60%,
#6b7280 100%
);
background-size: 200% 100%;
-webkit-background-clip: text;
background-clip: text;
color: transparent;
animation: shimmer-sweep 2s linear infinite;
}
.thinking-dots span {
width: 6px;
height: 6px;
border-radius: 50%;
background-color: #7d54d1;
animation: thinkingBounce 1.4s infinite ease-in-out both;
.dark .shimmer-text {
background-image: linear-gradient(
90deg,
#9ca3af 0%,
#9ca3af 40%,
#f9fafb 50%,
#9ca3af 60%,
#9ca3af 100%
);
}
.dark .thinking-dots span {
background-color: #976af3;
}
.thinking-dots span:nth-child(1) {
animation-delay: -0.32s;
}
.thinking-dots span:nth-child(2) {
animation-delay: -0.16s;
}
.thinking-dots span:nth-child(3) {
animation-delay: 0s;
}
@keyframes thinkingBounce {
0%,
80%,
100% {
transform: scale(0.6);
opacity: 0.4;
@keyframes shimmer-sweep {
to {
background-position: -200% 0;
}
40% {
transform: scale(1);
opacity: 1;
}
@media (prefers-reduced-motion: reduce) {
.shimmer-text {
animation: none;
color: inherit;
background: none;
}
}
+31
View File
@@ -1101,6 +1101,37 @@
"delete": "Wiki-Seite gelöscht",
"rename": "Wiki-Seite umbenannt",
"failed": "fehlgeschlagen"
},
"streamingStatus": {
"thinking": "Denke nach…",
"generating": "Generiere…",
"searchingWeb": "Suche im Web nach „{{query}}“…",
"searchingWebGeneric": "Suche im Web…",
"searchingImages": "Suche Bilder zu „{{query}}“…",
"readingPage": "Lese {{target}}…",
"searchingKnowledge": "Durchsuche Wissensdatenbank…",
"accessingMemory": "Greife auf das Gedächtnis zu…",
"runningCode": "Führe Code aus…",
"creatingArtifact": "Erstelle ein Artefakt…",
"usingTool": "Verwende {{tool}}…"
},
"toolChip": {
"searchingWeb": "Im Web gesucht · „{{query}}“",
"searchedWebGeneric": "Im Web gesucht",
"searchingImages": "Bilder gesucht · „{{query}}“",
"readingPage": "{{target}} gelesen",
"searchingKnowledge": "Wissensdatenbank durchsucht",
"accessingMemory": "Auf Gedächtnis zugegriffen",
"runningCode": "Code ausgeführt",
"creatingArtifact": "Artefakt erstellt",
"usingTool": "{{tool}} verwendet"
},
"inlineSteps": {
"arguments": "Argumente",
"response": "Antwort",
"running": "Läuft…",
"denied": "Vom Benutzer abgelehnt",
"failed": "fehlgeschlagen"
}
},
"agents": {
+31
View File
@@ -1102,6 +1102,37 @@
"delete": "Deleted wiki page",
"rename": "Renamed wiki page",
"failed": "failed"
},
"streamingStatus": {
"thinking": "Thinking…",
"generating": "Generating…",
"searchingWeb": "Searching the web for “{{query}}”…",
"searchingWebGeneric": "Searching the web…",
"searchingImages": "Searching images for “{{query}}”…",
"readingPage": "Reading {{target}}…",
"searchingKnowledge": "Searching knowledge base…",
"accessingMemory": "Accessing memory…",
"runningCode": "Running code…",
"creatingArtifact": "Creating an artifact…",
"usingTool": "Using {{tool}}…"
},
"toolChip": {
"searchingWeb": "Searched the web · “{{query}}”",
"searchedWebGeneric": "Searched the web",
"searchingImages": "Searched images · “{{query}}”",
"readingPage": "Read {{target}}",
"searchingKnowledge": "Searched knowledge base",
"accessingMemory": "Accessed memory",
"runningCode": "Ran code",
"creatingArtifact": "Created an artifact",
"usingTool": "Used {{tool}}"
},
"inlineSteps": {
"arguments": "Arguments",
"response": "Response",
"running": "Running…",
"denied": "Denied by user",
"failed": "failed"
}
},
"agents": {
+31
View File
@@ -1101,6 +1101,37 @@
"delete": "Página de wiki eliminada",
"rename": "Página de wiki renombrada",
"failed": "falló"
},
"streamingStatus": {
"thinking": "Pensando…",
"generating": "Generando…",
"searchingWeb": "Buscando en la web “{{query}}”…",
"searchingWebGeneric": "Buscando en la web…",
"searchingImages": "Buscando imágenes de “{{query}}”…",
"readingPage": "Leyendo {{target}}…",
"searchingKnowledge": "Buscando en la base de conocimientos…",
"accessingMemory": "Accediendo a la memoria…",
"runningCode": "Ejecutando código…",
"creatingArtifact": "Creando un artefacto…",
"usingTool": "Usando {{tool}}…"
},
"toolChip": {
"searchingWeb": "Buscado en la web · “{{query}}”",
"searchedWebGeneric": "Buscado en la web",
"searchingImages": "Buscadas imágenes · “{{query}}”",
"readingPage": "Leído {{target}}",
"searchingKnowledge": "Buscado en la base de conocimientos",
"accessingMemory": "Memoria consultada",
"runningCode": "Código ejecutado",
"creatingArtifact": "Artefacto creado",
"usingTool": "Usado {{tool}}"
},
"inlineSteps": {
"arguments": "Argumentos",
"response": "Respuesta",
"running": "Ejecutando…",
"denied": "Denegado por el usuario",
"failed": "falló"
}
},
"agents": {
+31
View File
@@ -1101,6 +1101,37 @@
"delete": "Wikiページを削除",
"rename": "Wikiページの名前を変更",
"failed": "失敗"
},
"streamingStatus": {
"thinking": "考え中…",
"generating": "生成中…",
"searchingWeb": "「{{query}}」をウェブ検索中…",
"searchingWebGeneric": "ウェブ検索中…",
"searchingImages": "「{{query}}」の画像を検索中…",
"readingPage": "{{target}} を読み込み中…",
"searchingKnowledge": "ナレッジベースを検索中…",
"accessingMemory": "メモリにアクセス中…",
"runningCode": "コードを実行中…",
"creatingArtifact": "アーティファクトを作成中…",
"usingTool": "{{tool}} を使用中…"
},
"toolChip": {
"searchingWeb": "ウェブ検索済み · 「{{query}}」",
"searchedWebGeneric": "ウェブ検索済み",
"searchingImages": "画像検索済み · 「{{query}}」",
"readingPage": "{{target}} を読み込み済み",
"searchingKnowledge": "ナレッジベース検索済み",
"accessingMemory": "メモリにアクセス済み",
"runningCode": "コード実行済み",
"creatingArtifact": "アーティファクト作成済み",
"usingTool": "{{tool}} を使用済み"
},
"inlineSteps": {
"arguments": "引数",
"response": "レスポンス",
"running": "実行中…",
"denied": "ユーザーによって拒否されました",
"failed": "失敗"
}
},
"agents": {
+31
View File
@@ -1101,6 +1101,37 @@
"delete": "Удалена страница вики",
"rename": "Переименована страница вики",
"failed": "не удалось"
},
"streamingStatus": {
"thinking": "Думаю…",
"generating": "Генерирую…",
"searchingWeb": "Ищу в интернете «{{query}}»…",
"searchingWebGeneric": "Ищу в интернете…",
"searchingImages": "Ищу изображения «{{query}}»…",
"readingPage": "Читаю {{target}}…",
"searchingKnowledge": "Ищу в базе знаний…",
"accessingMemory": "Обращаюсь к памяти…",
"runningCode": "Выполняю код…",
"creatingArtifact": "Создаю артефакт…",
"usingTool": "Использую {{tool}}…"
},
"toolChip": {
"searchingWeb": "Поиск в интернете · «{{query}}»",
"searchedWebGeneric": "Выполнен поиск в интернете",
"searchingImages": "Поиск изображений · «{{query}}»",
"readingPage": "Прочитано {{target}}",
"searchingKnowledge": "Выполнен поиск в базе знаний",
"accessingMemory": "Обращение к памяти выполнено",
"runningCode": "Код выполнен",
"creatingArtifact": "Артефакт создан",
"usingTool": "Использован {{tool}}"
},
"inlineSteps": {
"arguments": "Аргументы",
"response": "Ответ",
"running": "Выполняется…",
"denied": "Отклонено пользователем",
"failed": "не удалось"
}
},
"agents": {
+31
View File
@@ -1101,6 +1101,37 @@
"delete": "已刪除 Wiki 頁面",
"rename": "已重新命名 Wiki 頁面",
"failed": "失敗"
},
"streamingStatus": {
"thinking": "思考中…",
"generating": "生成中…",
"searchingWeb": "正在網路搜尋「{{query}}」…",
"searchingWebGeneric": "正在搜尋網路…",
"searchingImages": "正在搜尋「{{query}}」的圖片…",
"readingPage": "正在閱讀 {{target}}…",
"searchingKnowledge": "正在搜尋知識庫…",
"accessingMemory": "正在存取記憶…",
"runningCode": "正在執行程式碼…",
"creatingArtifact": "正在建立 Artifact…",
"usingTool": "正在使用 {{tool}}…"
},
"toolChip": {
"searchingWeb": "已搜尋網路 · 「{{query}}」",
"searchedWebGeneric": "已搜尋網路",
"searchingImages": "已搜尋圖片 · 「{{query}}」",
"readingPage": "已閱讀 {{target}}",
"searchingKnowledge": "已搜尋知識庫",
"accessingMemory": "已存取記憶",
"runningCode": "已執行程式碼",
"creatingArtifact": "已建立 Artifact",
"usingTool": "已使用 {{tool}}"
},
"inlineSteps": {
"arguments": "參數",
"response": "回應",
"running": "執行中…",
"denied": "已被使用者拒絕",
"failed": "失敗"
}
},
"agents": {
+31
View File
@@ -1101,6 +1101,37 @@
"delete": "已删除 Wiki 页面",
"rename": "已重命名 Wiki 页面",
"failed": "失败"
},
"streamingStatus": {
"thinking": "思考中…",
"generating": "生成中…",
"searchingWeb": "正在网上搜索“{{query}}”…",
"searchingWebGeneric": "正在搜索网络…",
"searchingImages": "正在搜索“{{query}}”的图片…",
"readingPage": "正在阅读 {{target}}…",
"searchingKnowledge": "正在搜索知识库…",
"accessingMemory": "正在访问记忆…",
"runningCode": "正在运行代码…",
"creatingArtifact": "正在创建工件…",
"usingTool": "正在使用 {{tool}}…"
},
"toolChip": {
"searchingWeb": "已搜索网络 · “{{query}}”",
"searchedWebGeneric": "已搜索网络",
"searchingImages": "已搜索图片 · “{{query}}”",
"readingPage": "已阅读 {{target}}",
"searchingKnowledge": "已搜索知识库",
"accessingMemory": "已访问记忆",
"runningCode": "已运行代码",
"creatingArtifact": "已创建工件",
"usingTool": "已使用 {{tool}}"
},
"inlineSteps": {
"arguments": "参数",
"response": "响应",
"running": "正在运行…",
"denied": "已被用户拒绝",
"failed": "失败"
}
},
"agents": {
@@ -0,0 +1,128 @@
import type { TFunction } from 'i18next';
import { describe, expect, it } from 'vitest';
import type { ToolCallsType } from '../conversation/types';
import { getToolChipLabel } from './streamingStatusUtils';
// Stub that renders "key" or "key|value,value" so assertions can check both
// the selected key and the interpolated values.
const t = ((key: string, opts?: Record<string, unknown>) => {
const values = opts
? Object.entries(opts)
.filter(([k]) => k !== 'interpolation')
.map(([, v]) => String(v))
: [];
return values.length ? `${key}|${values.join(',')}` : key;
}) as unknown as TFunction;
const call = (overrides: Partial<ToolCallsType>): ToolCallsType => ({
tool_name: 'brave',
action_name: 'brave_web_search',
call_id: 'c1',
arguments: {},
status: 'completed',
...overrides,
});
describe('getToolChipLabel', () => {
it('uses present tense while running and past tense once settled', () => {
const args = { query: 'docsgpt' };
expect(
getToolChipLabel(call({ arguments: args, status: 'pending' }), t),
).toBe('conversation.streamingStatus.searchingWeb|docsgpt');
expect(getToolChipLabel(call({ arguments: args }), t)).toBe(
'conversation.toolChip.searchingWeb|docsgpt',
);
});
it('falls back to the generic search label without a query, per namespace', () => {
expect(getToolChipLabel(call({ status: 'pending' }), t)).toBe(
'conversation.streamingStatus.searchingWebGeneric',
);
expect(getToolChipLabel(call({}), t)).toBe(
'conversation.toolChip.searchedWebGeneric',
);
});
it('treats a status-less call as settled, matching the non-shimmer row', () => {
expect(
getToolChipLabel(
call({ arguments: { query: 'docsgpt' }, status: undefined }),
t,
),
).toBe('conversation.toolChip.searchingWeb|docsgpt');
expect(getToolChipLabel(call({ status: undefined }), t)).toBe(
'conversation.toolChip.searchedWebGeneric',
);
});
it('labels read_webpage with the hostname', () => {
expect(
getToolChipLabel(
call({
tool_name: 'read_webpage',
action_name: 'read_webpage',
arguments: { url: 'https://docs.docsgpt.cloud/quickstart' },
}),
t,
),
).toBe('conversation.toolChip.readingPage|docs.docsgpt.cloud');
});
it('keeps the raw value when the url does not parse', () => {
expect(
getToolChipLabel(
call({ action_name: 'read_webpage', arguments: { url: 'not a url' } }),
t,
),
).toBe('conversation.toolChip.readingPage|not a url');
});
it('maps image search, knowledge, memory, code and artifact tools', () => {
expect(
getToolChipLabel(
call({
action_name: 'ddg_image_search',
arguments: { query: 'cats' },
}),
t,
),
).toBe('conversation.toolChip.searchingImages|cats');
expect(
getToolChipLabel(
call({ tool_name: 'internal_search', action_name: 'search' }),
t,
),
).toBe('conversation.toolChip.searchingKnowledge');
expect(
getToolChipLabel(
call({ tool_name: 'memory', action_name: 'memory_view' }),
t,
),
).toBe('conversation.toolChip.accessingMemory');
expect(
getToolChipLabel(
call({ tool_name: 'code_executor', action_name: 'run_code' }),
t,
),
).toBe('conversation.toolChip.runningCode');
expect(
getToolChipLabel(
call({
tool_name: 'artifact_generator',
action_name: 'create_artifact',
}),
t,
),
).toBe('conversation.toolChip.creatingArtifact');
});
it('falls back to a formatted tool name for unknown tools', () => {
expect(
getToolChipLabel(
call({ tool_name: 'mcp_tool', action_name: 'some_dynamic_action' }),
t,
),
).toBe('conversation.toolChip.usingTool|Mcp Tool');
});
});
+102
View File
@@ -0,0 +1,102 @@
import type { TFunction } from 'i18next';
import type { ToolCallsType } from '../conversation/types';
const WEB_SEARCH_ACTIONS = new Set([
'brave_web_search',
'ddg_web_search',
'ddg_news_search',
]);
const IMAGE_SEARCH_ACTIONS = new Set([
'brave_image_search',
'ddg_image_search',
]);
const ARTIFACT_ACTIONS = new Set([
'create_artifact',
'edit_artifact',
'rewrite_artifact',
]);
// Display-name overrides for tools whose label differs from the formatted key.
const TOOL_LABEL_OVERRIDES: Record<string, string> = {
artifact_generator: 'Artifact',
};
function formatToolLabel(toolName: string | undefined): string {
if (!toolName) return '';
if (TOOL_LABEL_OVERRIDES[toolName]) return TOOL_LABEL_OVERRIDES[toolName];
return toolName
.split('_')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(' ');
}
// i18n.ts leaves escapeValue at its default (true); React escapes on render,
// so disable it here to keep user queries like O'Brien readable.
const NO_ESCAPE = { interpolation: { escapeValue: false } } as const;
/**
* An activity as an i18n key suffix plus values, so the present- and past-tense
* phrasings stay two lookups of one mapping instead of two mappings.
*/
export type ToolActivity = { key: string; values?: Record<string, string> };
export function describeToolCall(toolCall: ToolCallsType): ToolActivity {
const { tool_name, action_name, arguments: args } = toolCall;
const query = typeof args?.query === 'string' ? args.query : undefined;
if (WEB_SEARCH_ACTIONS.has(action_name))
return query ? { key: 'searchingWeb', values: { query } } : { key: 'web' };
if (IMAGE_SEARCH_ACTIONS.has(action_name))
return query
? { key: 'searchingImages', values: { query } }
: { key: 'web' };
if (action_name === 'read_webpage') {
const url = typeof args?.url === 'string' ? args.url : undefined;
if (url) {
let target = url;
try {
target = new URL(url).hostname;
} catch {
// not a parseable url; show it as given
}
return { key: 'readingPage', values: { target } };
}
}
if (action_name === 'run_code') return { key: 'runningCode' };
if (ARTIFACT_ACTIONS.has(action_name)) return { key: 'creatingArtifact' };
if (tool_name === 'internal_search') return { key: 'searchingKnowledge' };
if (tool_name === 'memory' || tool_name === 'notes')
return { key: 'accessingMemory' };
return { key: 'usingTool', values: { tool: formatToolLabel(tool_name) } };
}
// The generic-search fallback key differs between the two namespaces.
const GENERIC_SEARCH_KEY: Record<string, string> = {
streamingStatus: 'searchingWebGeneric',
toolChip: 'searchedWebGeneric',
};
function activityLabel(
activity: ToolActivity,
namespace: 'streamingStatus' | 'toolChip',
t: TFunction,
): string {
const key =
activity.key === 'web' ? GENERIC_SEARCH_KEY[namespace] : activity.key;
return t(
`conversation.${namespace}.${key}`,
activity.values ? { ...activity.values, ...NO_ESCAPE } : undefined,
);
}
export function getToolChipLabel(
toolCall: ToolCallsType,
t: TFunction,
): string {
const activity = describeToolCall(toolCall);
const namespace =
toolCall.status === 'pending' ? 'streamingStatus' : 'toolChip';
return activityLabel(activity, namespace, t);
}