fix: minor edits, cleanup, light changes

This commit is contained in:
Alex committed 2026-08-10 19:45:57 +01:00
1 parent e2f85bf19b
commit 94766c93cb
22 files changed
+500 -188

No files matched your search

+1 -1
View File
@@ -176,7 +176,7 @@ export default function SharedAgent() {
}
/>
</div>
<div className="flex w-[95%] max-w-[1500px] flex-col items-center pb-2 md:w-9/12 lg:w-8/12 xl:w-8/12 2xl:w-6/12">
<div className="flex w-[95%] max-w-290 flex-col items-center pb-2 md:w-10/12 lg:w-9/12 xl:w-8/12 2xl:w-7/12">
<div className="w-full px-2">
<MessageInput
onSubmit={(text) => handleQuestionSubmission(text)}
@@ -33,6 +33,7 @@ const render = (props: {
toolCalls?: ToolCallsType[];
segments?: AnswerSegment[];
isStreaming?: boolean;
suppressStatusLine?: boolean;
}): string =>
renderToStaticMarkup(
<I18nextProvider i18n={testI18n}>
@@ -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');
});
});
+72 -34
View File
@@ -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({
<InlineThoughtChip
key={`thought-${index}`}
thought={step.text}
isActive={isStreaming && index === lastIndex && !message}
isActive={liveSteps[index]}
/>
);
}
@@ -73,13 +92,13 @@ export default function AnswerFlow({
if (isWikiWriteCall(call))
return (
<Fragment key={`wiki-${call.call_id}`}>
{renderWikiWrite(call)}
{renderWikiWrite(call, liveSteps[index])}
</Fragment>
);
if (call.tool_name === 'scheduler')
return (
<div key={`scheduler-${call.call_id}`} className="my-2 w-full">
<div key={`scheduler-${call.call_id}`} className="my-2 mr-5 ml-6">
<SchedulerToolCallCard
result={call.result}
actionName={call.action_name}
@@ -90,27 +109,28 @@ export default function AnswerFlow({
);
return (
<InlineToolCallChip key={`tool-${call.call_id}`} toolCall={call} />
<InlineToolCallChip
key={`tool-${call.call_id}`}
toolCall={call}
isLive={liveSteps[index]}
/>
);
})}
{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">
{/* ``ml-6`` is the answer's text column: step labels sit at the same
offset, with their icons in the gutter to its left. */}
<div className="fade-in-bubble my-2 mr-5 ml-6 flex max-w-full flex-col">
<MarkdownAnswer content={message} isStreaming={isStreaming} />
</div>
</div>
)}
{isStreaming && !hasLiveStep && !suppressStatusLine && (
<StreamingStatusLine
hasAnswerText={Boolean(message)}
className="my-2 ml-6"
/>
)}
</>
);
}
@@ -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"
>
<img src={Cloud} alt="" aria-hidden className="h-4 w-4 shrink-0" />
<span
@@ -165,7 +189,7 @@ function InlineThoughtChip({
{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"
className="text-muted-foreground mt-1 ml-6 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}
@@ -173,12 +197,12 @@ function InlineThoughtChip({
</div>
)}
{!showLiveWindow && !isOpen && (
<p className="text-muted-foreground mt-0.5 ml-3 truncate text-sm">
<p className="text-muted-foreground mt-0.5 ml-6 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">
<p className="fade-in text-muted-foreground mt-0.5 ml-6 text-sm leading-normal wrap-break-word whitespace-pre-wrap">
{thought}
</p>
)}
@@ -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. */}
<span
className={`flex h-4 w-4 shrink-0 items-center justify-center ${
isPending ? 'animate-pulse' : ''
isLive ? 'animate-pulse' : ''
}`}
>
<ToolIcon
@@ -216,7 +252,7 @@ function InlineToolCallChip({ toolCall }: { toolCall: ToolCallsType }) {
</span>
<span
className={`min-w-0 truncate text-left ${
isPending ? 'shimmer-text' : 'text-muted-foreground'
isLive ? 'shimmer-text' : 'text-muted-foreground'
}`}
>
{label}
@@ -234,7 +270,7 @@ function InlineToolCallChip({ toolCall }: { toolCall: ToolCallsType }) {
/>
</Button>
{isOpen && (
<div className="fade-in mt-2 flex flex-col gap-2">
<div className="fade-in mt-2 mr-5 ml-6 flex flex-col gap-2">
<ToolCallPanel
title={t('conversation.inlineSteps.arguments')}
copyText={JSON.stringify(toolCall.arguments ?? {}, null, 2)}
@@ -251,8 +287,10 @@ function InlineToolCallChip({ toolCall }: { toolCall: ToolCallsType }) {
: JSON.stringify(toolCall.result ?? {}, null, 2)
}
>
{isPending && (
<p className="shimmer-text text-xs">
{isRunning && (
<p
className={`text-xs ${isLive ? 'shimmer-text' : 'text-muted-foreground'}`}
>
{t('conversation.inlineSteps.running')}
</p>
)}
@@ -266,7 +304,7 @@ function InlineToolCallChip({ toolCall }: { toolCall: ToolCallsType }) {
{t('conversation.inlineSteps.denied')}
</p>
)}
{!isPending &&
{!isRunning &&
toolCall.status !== 'error' &&
toolCall.status !== 'denied' && (
<p className="max-h-80 overflow-y-auto font-mono text-xs whitespace-pre-wrap">
+4 -2
View File
@@ -377,11 +377,13 @@ export default function Conversation() {
/>
</div>
{/* One notch narrower than the message column above it, which keeps its
own width. */}
<div
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-11/12 lg:w-10/12 xl:w-9/12 2xl:w-8/12'
? 'max-w-290'
: 'max-w-290 md:w-10/12 lg:w-9/12 xl:w-8/12 2xl:w-7/12'
}`}
>
<div className="flex w-full items-center rounded-full px-2">
+50 -116
View File
@@ -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<HTMLDivElement | null>(null);
const chunks = useSelector(selectChunks);
const selectedDocs = useSelector(selectSelectedDocs);
@@ -113,11 +111,6 @@ 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.
@@ -173,7 +166,7 @@ const ConversationBubble = forwardRef<
<div className={`group ${className}`}>
<div className="flex flex-col items-end">
{filesAttached && filesAttached.length > 0 && (
<div className="mr-12 mb-4 flex flex-wrap justify-end gap-2">
<div className="mr-5 mb-4 flex flex-wrap justify-end gap-2">
{filesAttached.map((file, index) => (
<div
key={index}
@@ -198,12 +191,11 @@ const ConversationBubble = forwardRef<
ref={ref}
className={`flex flex-row-reverse justify-items-start`}
>
<Avatar className="mt-2 shrink-0 text-2xl">
<img className="mr-1 rounded-full" width={30} src={UserIcon} />
</Avatar>
{!isEditClicked && (
<>
<div className="relative mr-2 flex w-full min-w-0 flex-col">
{/* ``mr-3`` plus the pill's own ``mr-2`` puts the question's
right edge on the answer's ``mr-5`` gutter. */}
<div className="relative mr-3 flex w-full min-w-0 flex-col">
<div className="mr-2 ml-2 flex max-w-full min-w-0 items-start gap-2 rounded-3xl bg-linear-to-b from-violet-500 to-violet-600 px-5 py-4 text-sm leading-normal wrap-anywhere whitespace-pre-wrap text-white sm:text-base">
<div
ref={messageRef}
@@ -295,60 +287,6 @@ const ConversationBubble = forwardRef<
</div>
);
} 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 <a> tags we can style.
// Avoid matching inside code blocks or existing links.
processedContent = processedContent.replace(
/(?<!\[)\[(\d+)\](?!\()/g,
(_, num) => `[${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 = (
<div
ref={ref}
@@ -360,8 +298,10 @@ const ConversationBubble = forwardRef<
sources?.some((source) => source.link === 'None')
? null
: sources && (
<div className="mb-4 flex flex-col flex-wrap items-start self-start lg:flex-nowrap">
<div className="my-2 flex flex-row items-center justify-center gap-3">
// Stretched, not shrink-to-fit: the grid below sizes off this box,
// so a fit-content parent would leave its width to the cards.
<div className="mb-4 flex w-full flex-col flex-wrap items-start self-stretch lg:flex-nowrap">
<div className="my-2 ml-6 flex flex-row items-center justify-center gap-3">
<Avatar
src={Sources}
alt={t('conversation.sources.title')}
@@ -372,7 +312,9 @@ const ConversationBubble = forwardRef<
{t('conversation.sources.title')}
</p>
</div>
<div className="fade-in mr-5 ml-3 w-full">
{/* Width comes from the stretched parent minus these margins;
w-full here would be the column width plus them. */}
<div className="fade-in mr-5 ml-6">
<div className="grid grid-cols-2 gap-2 lg:grid-cols-4">
{sources?.slice(0, 3)?.map((source, index) => (
<div
@@ -454,7 +396,7 @@ const ConversationBubble = forwardRef<
)}
{research && <ResearchProgress research={research} />}
{!message && onOpenArtifact && completedArtifacts.length > 0 && (
<div className="my-2 ml-2 flex flex-wrap justify-start gap-2">
<div className="my-2 ml-6 flex flex-wrap justify-start gap-2">
{completedArtifacts.map((artifact, artifactIndex) => (
<Button
key={artifact.id ?? `${artifact.callId}-${artifactIndex}`}
@@ -494,7 +436,7 @@ const ConversationBubble = forwardRef<
</div>
)}
{workflowRunId && (
<div className="my-2 mr-5 ml-2">
<div className="my-2 mr-5 ml-6">
<WorkflowRunArtifacts
workflowRunId={workflowRunId}
inProgress={isStreaming}
@@ -504,17 +446,6 @@ const ConversationBubble = forwardRef<
{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>
@@ -528,32 +459,26 @@ const ConversationBubble = forwardRef<
segments={segments}
isStreaming={isStreaming}
agentId={agentId}
// A research run already narrates itself above; the status line
// would be a second live indicator away from the point of action.
suppressStatusLine={Boolean(research)}
renderApproval={(toolCall: ToolCallsType) => (
<div className="fade-in mt-4 w-full">
<div className="fade-in mt-4 mr-5 ml-6">
<ToolCallApprovalBar
toolCall={toolCall}
onToolAction={onToolAction}
/>
</div>
)}
renderWikiWrite={(toolCall: ToolCallsType) => (
<div className="fade-in mt-4 w-full">
<WikiWriteToolCallCard toolCall={toolCall} />
</div>
renderWikiWrite={(toolCall: ToolCallsType, isLive: boolean) => (
<WikiWriteToolCallCard toolCall={toolCall} isLive={isLive} />
)}
/>
)}
{/* 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">
// ml-4 plus each button's own p-2 puts the first glyph on the answer's
// ml-6 text column.
<div className="my-2 ml-4 flex flex-wrap justify-start gap-2">
{type === 'ERROR' ? (
<div className="relative block items-center justify-center">
<div>{retryBtn}</div>
@@ -937,39 +862,48 @@ function ToolCallApprovalBar({
);
}
/** The wiki-write step, in the same inline chip language as the other steps. */
export function WikiWriteToolCallCard({
toolCall,
isLive,
}: {
toolCall: ToolCallsType;
isLive?: boolean;
}) {
const { t } = useTranslation();
const path = wikiWritePath(toolCall);
const actionKey = wikiWriteActionKey(toolCall.action_name);
const isError = toolCall.status === 'error';
const namespace = isToolCallRunning(toolCall)
? 'wikiWrite.active'
: 'wikiWrite';
const label = t(`conversation.${namespace}.${actionKey}`, {
defaultValue: t(`conversation.${namespace}.edited`),
});
return (
<div
className={`flex items-center gap-2.5 rounded-2xl border px-4 py-2.5 text-sm ${
isError
? 'border-destructive/40 bg-destructive/5'
: 'border-primary/30 bg-primary/5 dark:bg-primary/10'
}`}
>
<span aria-hidden="true" className="text-base leading-none">
✏️
</span>
<span className="text-foreground font-medium">
{t(`conversation.wikiWrite.${actionKey}`, {
defaultValue: t('conversation.wikiWrite.edited'),
})}
<div className="my-2 mr-5 ml-6 flex min-w-0 items-center gap-2 py-1.5 text-sm">
<Pencil
aria-hidden
className={`text-muted-foreground h-4 w-4 shrink-0 ${
isLive ? 'animate-pulse' : ''
}`}
/>
<span
className={`shrink-0 ${isLive ? 'shimmer-text' : 'text-muted-foreground'}`}
>
{label}
</span>
{path && (
<code className="dark:bg-card min-w-0 truncate rounded-md bg-black/10 px-1.5 py-0.5 font-mono text-xs">
<code
className="text-muted-foreground bg-muted dark:bg-answer-bubble min-w-0 truncate rounded-md px-1.5 py-0.5 font-mono text-xs"
title={path}
>
{path}
</code>
)}
{isError && (
<span className="text-destructive text-xs">
<span className="text-destructive shrink-0 text-xs">
{t('conversation.wikiWrite.failed')}
</span>
)}
@@ -8,7 +8,6 @@ import {
} from 'react';
import { useTranslation } from 'react-i18next';
import DocsGPT3 from '../assets/cute_docsgpt3.svg';
import Retry from '../assets/retry.svg?react';
import { Button } from '../components/ui/button';
import {
@@ -131,6 +130,12 @@ export default function ConversationMessages({
? 'w-full max-w-325 px-2'
: 'w-full max-w-325 px-2 md:w-11/12 lg:w-10/12 xl:w-9/12 2xl:w-8/12';
// The empty state sits directly on top of the composer with nothing between
// them, so it takes the composer's width rather than the wider message column.
const emptyStateColumnClass = isSplitView
? 'w-full max-w-290 px-2'
: 'w-full max-w-290 px-2 md:w-10/12 lg:w-9/12 xl:w-8/12 2xl:w-7/12';
const renderResponseView = (query: Query, index: number) => {
const isLastMessage = index === queries.length - 1;
const bubbleMargin = DEFAULT_BUBBLE_MARGIN;
@@ -228,19 +233,7 @@ export default function ConversationMessages({
className={`fade-in-bubble group dark:text-foreground flex flex-col flex-wrap self-start ${bubbleMargin}`}
>
<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">
<div className="flex h-8.5 w-8.5 items-center justify-center overflow-hidden rounded-full">
<img
src={DocsGPT3}
alt={t('conversation.answer')}
className="h-full w-full object-cover"
/>
</div>
<p className="text-base font-semibold">
{t('conversation.answer')}
</p>
</div>
<StreamingStatusLine className="my-2 ml-3" />
<StreamingStatusLine className="my-2 ml-6" />
</div>
</div>
);
@@ -252,7 +245,7 @@ export default function ConversationMessages({
if (queries.length === 0) {
return (
<div className="flex h-full w-full justify-center overflow-y-auto will-change-scroll sm:pt-6 lg:pt-12">
<div className={columnClass}>
<div className={emptyStateColumnClass}>
{headerContent}
{showHeroOnEmpty ? <Hero handleQuestion={handleQuestion} /> : null}
</div>
@@ -140,7 +140,7 @@ export const SharedConversation = () => {
twitterDescription="Shared conversations with DocsGPT"
/>
<div className="bg-background flex h-full flex-col items-center justify-between gap-2 overflow-y-hidden">
<div className="border-b-border w-full max-w-[1200px] border-b p-2 md:w-9/12 lg:w-8/12 xl:w-8/12 2xl:w-6/12">
<div className="border-b-border w-full max-w-325 border-b p-2 md:w-11/12 lg:w-10/12 xl:w-9/12 2xl:w-8/12">
<h1 className="text-foreground dark:text-foreground text-4xl font-semibold">
{title}
</h1>
@@ -160,7 +160,7 @@ export const SharedConversation = () => {
queries={queries}
status={status}
/>
<div className="flex w-full max-w-[1200px] flex-col items-center gap-4 pb-2 md:w-9/12 lg:w-8/12 xl:w-8/12 2xl:w-6/12">
<div className="flex w-full max-w-290 flex-col items-center gap-4 pb-2 md:w-10/12 lg:w-9/12 xl:w-8/12 2xl:w-7/12">
{apiKey ? (
<div className="w-full px-2">
<MessageInput
@@ -23,6 +23,11 @@ export default function StreamingStatusLine({
aria-live="polite"
className={cn('flex h-6 min-w-0 items-center gap-2', className)}
>
{/* Stands in for a step chip's icon so the live text keeps the same
offset whether a chip or this line is carrying it. */}
<span className="flex h-4 w-4 shrink-0 items-center justify-center">
<span className="bg-muted-foreground/50 h-1.5 w-1.5 animate-pulse rounded-full" />
</span>
<span className="shimmer-text max-w-[70vw] truncate text-sm lg:max-w-md">
{hasAnswerText
? t('conversation.streamingStatus.generating')
@@ -18,10 +18,10 @@ beforeAll(async () => {
});
});
const render = (toolCall: ToolCallsType): string =>
const render = (toolCall: ToolCallsType, isLive?: boolean): string =>
renderToStaticMarkup(
<I18nextProvider i18n={testI18n}>
<WikiWriteToolCallCard toolCall={toolCall} />
<WikiWriteToolCallCard toolCall={toolCall} isLive={isLive} />
</I18nextProvider>,
);
@@ -34,11 +34,49 @@ describe('WikiWriteToolCallCard', () => {
arguments: { path: '/policy.md', old_str: 'a', new_str: 'b' },
status: 'completed',
});
expect(html).toContain('✏️');
expect(html).toContain('Edited wiki page');
expect(html).toContain('/policy.md');
});
it('reads in the present tense while the write is still running', () => {
const html = render(
{
tool_name: 'wiki',
action_name: 'wiki_create',
call_id: 'c1',
arguments: { path: '/policy.md' },
status: 'pending',
},
true,
);
expect(html).toContain('Creating wiki page…');
expect(html).toContain('shimmer-text');
});
it('drops the shimmer once the write has settled', () => {
const html = render({
tool_name: 'wiki',
action_name: 'wiki_create',
call_id: 'c1',
arguments: { path: '/policy.md' },
status: 'completed',
});
expect(html).not.toContain('shimmer-text');
});
it('marks a failed write', () => {
const html = render({
tool_name: 'wiki',
action_name: 'wiki_delete',
call_id: 'c1',
arguments: { path: '/policy.md' },
status: 'error',
error: 'boom',
});
expect(html).toContain('Deleted wiki page');
expect(html).toContain('failed');
});
it('uses the new path for a rename', () => {
const html = render({
tool_name: 'wiki',
@@ -7,6 +7,7 @@ import en from '../locale/en.json';
import AnswerFlow from './AnswerFlow';
import reducer, {
addQuery,
applyMessageTail,
setStatus,
updateStreamingQuery,
updateThought,
@@ -92,4 +93,56 @@ describe('stream reducers feeding the answer flow', () => {
html.indexOf('DocsGPT rocks.'),
);
});
// Reopening the streaming conversation polls /tail alongside the live SSE, so
// the flat snapshot lands between two deltas.
it('keeps every step visible when a tail snapshot interleaves with the stream', () => {
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.' } }),
);
state = reducer(
state,
updateToolCall({ ...at, tool_call: searchCall('completed') }),
);
state = reducer(
state,
applyMessageTail({
index: 0,
tail: {
status: 'pending',
thought: 'I should search.',
tool_calls: [searchCall('completed')],
},
}),
);
// A second call arrives after the tail; it must not become the whole order.
state = reducer(
state,
updateToolCall({
...at,
tool_call: { ...searchCall('completed'), call_id: 'c2' },
}),
);
const query = state.queries[0];
const html = renderToStaticMarkup(
<I18nextProvider i18n={testI18n}>
<AnswerFlow
thought={query.thought}
toolCalls={query.tool_calls}
segments={query.segments}
isStreaming
renderApproval={() => null}
renderWikiWrite={() => null}
/>
</I18nextProvider>,
);
expect(html).toContain('I should search.');
expect(html.split('Searched the web').length - 1).toBe(2);
});
});
@@ -87,8 +87,17 @@ describe('synthesizeSegments / getAnswerSegments', () => {
});
it('prefers live steps when present', () => {
const live: AnswerSegment[] = [{ kind: 'thought', text: 'live' }];
expect(getAnswerSegments({ thought: 'flat', segments: live })).toBe(live);
const live: AnswerSegment[] = [
{ kind: 'thought', text: 'live' },
{ kind: 'tool', call_id: 'c1' },
];
expect(
getAnswerSegments({
thought: 'live',
tool_calls: [call({ call_id: 'c1' })],
segments: live,
}),
).toBe(live);
});
it('falls back when steps were cleared by a tail snapshot', () => {
@@ -96,4 +105,59 @@ describe('synthesizeSegments / getAnswerSegments', () => {
{ kind: 'thought', text: 'flat' },
]);
});
it('falls back when the order misses a call the answer holds', () => {
expect(
getAnswerSegments({
thought: 'reasoning',
tool_calls: [call({ call_id: 'c1' }), call({ call_id: 'c2' })],
segments: [{ kind: 'tool', call_id: 'c2' }],
}),
).toEqual([
{ kind: 'thought', text: 'reasoning' },
{ kind: 'tool', call_id: 'c1' },
{ kind: 'tool', call_id: 'c2' },
]);
});
it('falls back when the order misses reasoning the answer holds', () => {
expect(
getAnswerSegments({
thought: 'full reasoning',
tool_calls: [call({ call_id: 'c1' })],
segments: [
{ kind: 'tool', call_id: 'c1' },
{ kind: 'thought', text: 'reasoning' },
],
}),
).toEqual([
{ kind: 'thought', text: 'full reasoning' },
{ kind: 'tool', call_id: 'c1' },
]);
});
it('keeps the live order when reasoning arrived in several pieces', () => {
const live: AnswerSegment[] = [
{ kind: 'thought', text: 'plan' },
{ kind: 'tool', call_id: 'c1' },
{ kind: 'thought', text: 'verify' },
];
expect(
getAnswerSegments({
thought: 'planverify',
tool_calls: [call({ call_id: 'c1' })],
segments: live,
}),
).toBe(live);
});
it('does not fall back for a call the backend left without an id', () => {
const live: AnswerSegment[] = [{ kind: 'tool', call_id: 'c1' }];
expect(
getAnswerSegments({
tool_calls: [call({ call_id: 'c1' }), call({ call_id: '' })],
segments: live,
}),
).toBe(live);
});
});
+29 -1
View File
@@ -45,10 +45,38 @@ export function synthesizeSegments(query: {
return segments;
}
/**
* Whether the recorded order still accounts for every part of the answer. A
* call with no id can never be recorded, so it never counts against the order.
*/
function coversAnswer(
query: { thought?: string; tool_calls?: ToolCallsType[] },
segments: AnswerSegment[],
): boolean {
const ordered = new Set(
segments.flatMap((segment) =>
segment.kind === 'tool' ? [segment.call_id] : [],
),
);
const everyCallOrdered = (query.tool_calls ?? []).every(
(call) => !call.call_id || ordered.has(call.call_id),
);
const orderedThought = segments
.flatMap((segment) => (segment.kind === 'thought' ? [segment.text] : []))
.join('');
return everyCallOrdered && orderedThought === (query.thought ?? '');
}
export function getAnswerSegments(query: {
thought?: string;
tool_calls?: ToolCallsType[];
segments?: AnswerSegment[];
}): AnswerSegment[] {
return query.segments?.length ? query.segments : synthesizeSegments(query);
// The order only arranges what ``thought``/``tool_calls`` already hold, so one
// that no longer accounts for them (a tail snapshot landed mid-stream, a
// reconnect replayed part of it) loses to synthesis rather than hiding the
// steps it never recorded.
if (query.segments?.length && coversAnswer(query, query.segments))
return query.segments;
return synthesizeSegments(query);
}
@@ -1013,9 +1013,11 @@ 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;
// A tail is a flat snapshot with no ordering. The live order is left
// alone: dropping it here left the stream reducers rebuilding a partial
// one from the next delta, which hid every step that came before. It is
// rendering that decides, per answer, whether the recorded order still
// accounts for the snapshot or synthesis has to take over.
const status = tail?.status as MessageStatus | undefined;
query.messageStatus = status;
query.lastHeartbeatAt = tail?.last_heartbeat_at ?? query.lastHeartbeatAt;
+6
View File
@@ -752,6 +752,12 @@ Avoid over-scrolling in mobile browsers
color: inherit;
background: none;
}
/* Same specificity as the dark gradient above, which would otherwise
outrank the reset and paint the sweep as a solid bar behind the text. */
.dark .shimmer-text {
background-image: none;
}
}
@keyframes fadeInUp {
+9 -1
View File
@@ -1100,7 +1100,15 @@
"insert": "Wiki-Seite bearbeitet",
"delete": "Wiki-Seite gelöscht",
"rename": "Wiki-Seite umbenannt",
"failed": "fehlgeschlagen"
"failed": "fehlgeschlagen",
"active": {
"edited": "Bearbeite Wiki…",
"create": "Erstelle Wiki-Seite…",
"str_replace": "Bearbeite Wiki-Seite…",
"insert": "Bearbeite Wiki-Seite…",
"delete": "Lösche Wiki-Seite…",
"rename": "Benenne Wiki-Seite um…"
}
},
"streamingStatus": {
"thinking": "Denke nach…",
+9 -1
View File
@@ -1101,7 +1101,15 @@
"insert": "Edited wiki page",
"delete": "Deleted wiki page",
"rename": "Renamed wiki page",
"failed": "failed"
"failed": "failed",
"active": {
"edited": "Editing wiki…",
"create": "Creating wiki page…",
"str_replace": "Editing wiki page…",
"insert": "Editing wiki page…",
"delete": "Deleting wiki page…",
"rename": "Renaming wiki page…"
}
},
"streamingStatus": {
"thinking": "Thinking…",
+9 -1
View File
@@ -1100,7 +1100,15 @@
"insert": "Página de wiki editada",
"delete": "Página de wiki eliminada",
"rename": "Página de wiki renombrada",
"failed": "falló"
"failed": "falló",
"active": {
"edited": "Editando wiki…",
"create": "Creando página de wiki…",
"str_replace": "Editando página de wiki…",
"insert": "Editando página de wiki…",
"delete": "Eliminando página de wiki…",
"rename": "Renombrando página de wiki…"
}
},
"streamingStatus": {
"thinking": "Pensando…",
+9 -1
View File
@@ -1100,7 +1100,15 @@
"insert": "Wikiページを編集",
"delete": "Wikiページを削除",
"rename": "Wikiページの名前を変更",
"failed": "失敗"
"failed": "失敗",
"active": {
"edited": "Wikiを編集中…",
"create": "Wikiページを作成中…",
"str_replace": "Wikiページを編集中…",
"insert": "Wikiページを編集中…",
"delete": "Wikiページを削除中…",
"rename": "Wikiページの名前を変更中…"
}
},
"streamingStatus": {
"thinking": "考え中…",
+9 -1
View File
@@ -1100,7 +1100,15 @@
"insert": "Отредактирована страница вики",
"delete": "Удалена страница вики",
"rename": "Переименована страница вики",
"failed": "не удалось"
"failed": "не удалось",
"active": {
"edited": "Редактирую вики…",
"create": "Создаю страницу вики…",
"str_replace": "Редактирую страницу вики…",
"insert": "Редактирую страницу вики…",
"delete": "Удаляю страницу вики…",
"rename": "Переименовываю страницу вики…"
}
},
"streamingStatus": {
"thinking": "Думаю…",
+9 -1
View File
@@ -1100,7 +1100,15 @@
"insert": "已編輯 Wiki 頁面",
"delete": "已刪除 Wiki 頁面",
"rename": "已重新命名 Wiki 頁面",
"failed": "失敗"
"failed": "失敗",
"active": {
"edited": "正在編輯 Wiki…",
"create": "正在建立 Wiki 頁面…",
"str_replace": "正在編輯 Wiki 頁面…",
"insert": "正在編輯 Wiki 頁面…",
"delete": "正在刪除 Wiki 頁面…",
"rename": "正在重新命名 Wiki 頁面…"
}
},
"streamingStatus": {
"thinking": "思考中…",
+9 -1
View File
@@ -1100,7 +1100,15 @@
"insert": "已编辑 Wiki 页面",
"delete": "已删除 Wiki 页面",
"rename": "已重命名 Wiki 页面",
"failed": "失败"
"failed": "失败",
"active": {
"edited": "正在编辑 Wiki…",
"create": "正在创建 Wiki 页面…",
"str_replace": "正在编辑 Wiki 页面…",
"insert": "正在编辑 Wiki 页面…",
"delete": "正在删除 Wiki 页面…",
"rename": "正在重命名 Wiki 页面…"
}
},
"streamingStatus": {
"thinking": "思考中…",
+16 -2
View File
@@ -91,12 +91,26 @@ function activityLabel(
);
}
/**
* Whether the call has been handed off and not come back yet. A call the client
* still has to execute counts: it has no result either.
*/
export function isToolCallRunning(toolCall: {
status?: ToolCallsType['status'];
}): boolean {
return (
toolCall.status === 'pending' ||
toolCall.status === 'requires_client_execution'
);
}
export function getToolChipLabel(
toolCall: ToolCallsType,
t: TFunction,
): string {
const activity = describeToolCall(toolCall);
const namespace =
toolCall.status === 'pending' ? 'streamingStatus' : 'toolChip';
const namespace = isToolCallRunning(toolCall)
? 'streamingStatus'
: 'toolChip';
return activityLabel(activity, namespace, t);
}