Files
DocsGPT/frontend/src/conversation/AnswerFlow.tsx
T
arc53-machine 1c9cf7b693 Open the sources list from a citation that has no card
Only the first three sources are shown as cards, so a pill for [4] or
later scrolled to nothing; it now opens the full list.
2026-09-28 13:30:55 +01:00

373 lines
12 KiB
TypeScript

import { ChevronDown, Cloud } from 'lucide-react';
import { Fragment, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import SchedulerToolCallCard from '../agents/schedules/SchedulerToolCallCard';
import CopyButton from '../components/CopyButton';
import ToolIcon from '../components/ToolIcon';
import { Button } from '../components/ui/button';
import { usePacedText } from '../hooks';
import {
getToolChipLabel,
isToolCallRunning,
} from '../utils/streamingStatusUtils';
import { AnswerSegment, getAnswerSegments } from './answerSegments';
import MarkdownAnswer from './MarkdownAnswer';
import { type SandboxArtifact } from './sandboxLinks';
import StreamingStatusLine from './StreamingStatusLine';
import { ToolCallsType } from './types';
import { isWikiWriteCall } from './wikiToolCall';
import { cn } from '@/lib/utils';
type AnswerFlowProps = {
message?: string;
thought?: string;
toolCalls?: ToolCallsType[];
// Absent on reload, where the order is synthesized from the flat fields.
segments?: AnswerSegment[];
isStreaming?: boolean;
/** How many sources the answer can cite; see `MarkdownAnswer`. */
sourceCount?: number;
onOpenSources?: () => void;
agentId?: string;
/** Set when the bubble already carries its own progress UI (a research run). */
suppressStatusLine?: boolean;
/** Every artifact in the conversation, so ``sandbox:`` links can reach them. */
artifacts?: SandboxArtifact[];
/** This turn's own artifacts, which win when a link names a bare filename. */
turnArtifacts?: SandboxArtifact[];
onOpenArtifact?: (artifact: { id: string; toolName: string }) => void;
renderApproval: (toolCall: ToolCallsType) => React.ReactNode;
renderWikiWrite: (
toolCall: ToolCallsType,
isLive: boolean,
) => 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,
sourceCount,
onOpenSources,
agentId,
suppressStatusLine,
artifacts,
turnArtifacts,
onOpenArtifact,
renderApproval,
renderWikiWrite,
}: AnswerFlowProps) {
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) => {
if (step.kind === 'thought') {
return (
<InlineThoughtChip
key={`thought-${index}`}
thought={step.text}
isActive={liveSteps[index]}
/>
);
}
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, liveSteps[index])}
</Fragment>
);
if (call.tool_name === 'scheduler')
return (
<div key={`scheduler-${call.call_id}`} className="my-2 mr-5 ml-6">
<SchedulerToolCallCard
result={call.result}
actionName={call.action_name}
status={call.status}
agentId={agentId}
/>
</div>
);
return (
<InlineToolCallChip
key={`tool-${call.call_id}`}
toolCall={call}
isLive={liveSteps[index]}
/>
);
})}
{message && (
<div className="flex w-full min-w-0 flex-col">
{/* ``ml-6`` is the answer's text column: step labels sit at the same
offset, with their icons in the gutter to its left. Stretched,
not ``self-start max-w-full``: a shrink-to-fit box sizes to its
longest code line, and ``max-w-full`` caps it at 100% before the
margins land on top, so the chat scrolled sideways on a phone. */}
<div className="animate-in fade-in slide-in-from-bottom-1.5 my-2 mr-5 ml-6 flex min-w-0 flex-col duration-260 ease-out motion-reduce:animate-none">
<MarkdownAnswer
content={message}
isStreaming={isStreaming}
sourceCount={sourceCount}
onOpenSources={onOpenSources}
artifacts={artifacts}
turnArtifacts={turnArtifacts}
onOpenArtifact={onOpenArtifact}
/>
</div>
</div>
)}
{isStreaming && !hasLiveStep && !suppressStatusLine && (
<StreamingStatusLine
hasAnswerText={Boolean(message)}
className="my-2 ml-6"
/>
)}
</>
);
}
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}
size="sm"
// ml-3.5 plus size sm's own has-[>svg]:px-2.5 (the chevron is a direct
// svg child) puts the icon on the answer's ml-6 text column.
className="ml-3.5 w-fit max-w-full justify-start"
>
<Cloud aria-hidden />
<span
className={cn(
'min-w-0 truncate text-left',
isActive ? 'shimmer-text' : 'text-muted-foreground',
)}
>
{t('conversation.reasoning')}
</span>
<ChevronDown
aria-hidden
className={cn(
'text-muted-foreground shrink-0 transition-transform duration-200',
isOpen ? 'rotate-180' : '',
)}
/>
</Button>
{showLiveWindow && (
<div
ref={liveRef}
className="text-muted-foreground mt-1 ml-6 h-24 overflow-hidden scroll-smooth mask-t-from-60% 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-6 truncate text-sm">
{thought}
</p>
)}
{isOpen && (
<p className="animate-in fade-in text-muted-foreground mt-0.5 ml-6 text-sm leading-normal wrap-break-word whitespace-pre-wrap duration-160 ease-out motion-reduce:animate-none">
{thought}
</p>
)}
</div>
);
}
function InlineToolCallChip({
toolCall,
isLive,
}: {
toolCall: ToolCallsType;
isLive?: boolean;
}) {
const { t } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
// 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 (
<div className="my-2 flex w-full flex-col">
<Button
type="button"
variant="ghost"
onClick={() => setIsOpen(!isOpen)}
aria-expanded={isOpen}
size="sm"
// ml-3.5 plus size sm's own has-[>svg]:px-2.5 (the chevron is a direct
// svg child) puts the icon on the answer's ml-6 text column.
className="ml-3.5 w-fit max-w-full justify-start"
>
{/* 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={cn(
'flex size-4 shrink-0 items-center justify-center',
isLive ? 'animate-pulse' : '',
)}
>
<ToolIcon
name={toolCall.tool_name}
className="text-muted-foreground size-4"
/>
<span className="bg-muted-foreground/50 hidden size-1.5 rounded-full only:block" />
</span>
<span
className={cn(
'min-w-0 truncate text-left',
isLive ? '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={cn(
'text-muted-foreground shrink-0 transition-transform duration-200',
isOpen ? 'rotate-180' : '',
)}
/>
</Button>
{isOpen && (
<div className="animate-in fade-in mt-2 mr-5 ml-6 flex flex-col gap-2 duration-160 ease-out motion-reduce:animate-none">
<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)
}
>
{isRunning && (
<p
className={cn(
'text-xs',
isLive ? 'shimmer-text' : 'text-muted-foreground',
)}
>
{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>
)}
{!isRunning &&
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>
);
}
export function ToolCallPanel({
title,
copyText,
children,
}: {
title: string;
copyText: string;
children: React.ReactNode;
}) {
return (
<div className="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>
);
}