1539 lines
56 KiB
TypeScript
1539 lines
56 KiB
TypeScript
import {
|
||
memo,
|
||
type ReactNode,
|
||
useCallback,
|
||
useEffect,
|
||
useLayoutEffect,
|
||
useMemo,
|
||
useRef,
|
||
useState,
|
||
} from 'react';
|
||
import ReactMarkdown from 'react-markdown';
|
||
import remarkGfm from 'remark-gfm';
|
||
import {
|
||
Bot,
|
||
BookOpen,
|
||
Check,
|
||
CheckCircle2,
|
||
ChevronDown,
|
||
CircleAlert,
|
||
Copy,
|
||
ExternalLink,
|
||
GitFork,
|
||
Globe2,
|
||
Hammer,
|
||
Layers3,
|
||
ListChecks,
|
||
LoaderCircle,
|
||
Pencil,
|
||
RotateCcw,
|
||
Search,
|
||
ShieldCheck,
|
||
Terminal,
|
||
Workflow,
|
||
Wrench,
|
||
XCircle,
|
||
} from 'lucide-react';
|
||
import { toast } from 'sonner';
|
||
import { Button } from '@/components/ui/button';
|
||
import {
|
||
openConversationExternalUrl,
|
||
openConversationLocalWebFile,
|
||
openableConversationLocalWebPath,
|
||
resolveConversationMarkdownWebPath,
|
||
safeConversationExternalUrl,
|
||
showConversationLinkContextMenu,
|
||
} from '@/lib/conversation-links';
|
||
import { cn } from '@/lib/utils';
|
||
import {
|
||
selectCodingConversationSnapshot,
|
||
useCodingConversationStore,
|
||
type CodingConversationStoreState,
|
||
} from '@/stores/coding-conversations';
|
||
import type {
|
||
ConversationBoundaryNode,
|
||
ConversationCompactionNode,
|
||
ConversationContentBlock,
|
||
ConversationMessageNode,
|
||
ConversationNode,
|
||
ConversationNoticeNode,
|
||
ConversationRunState,
|
||
ConversationSubagentNode,
|
||
ConversationToolNode,
|
||
KnownToolDetails,
|
||
SubagentDetailsV1,
|
||
} from '../../../shared/coding-conversation-contracts';
|
||
import { CodingAttachmentPreview } from './CodingAttachmentPreview';
|
||
|
||
const EMPTY_NODES: ConversationNode[] = [];
|
||
const IDLE_RUN: ConversationRunState = { status: 'idle' };
|
||
const INITIAL_WINDOW = 120;
|
||
const WINDOW_STEP = 100;
|
||
const LONG_OUTPUT_CHARS = 4_000;
|
||
const STREAMING_THINKING_WINDOW_CHARS = 16_000;
|
||
const PROGRESS_PREVIEW_MAX_CHARS = 360;
|
||
const PROGRESS_ROLL_CHAR_STEP = 6;
|
||
|
||
const NODE_STATUS_LABELS: Record<string, string> = {
|
||
declared: '已声明',
|
||
waiting: '等待中',
|
||
running: '执行中',
|
||
complete: '已完成',
|
||
error: '失败',
|
||
aborted: '已中止',
|
||
queued: '排队中',
|
||
skipped: '已跳过',
|
||
};
|
||
|
||
type TextualBlock = Exclude<ConversationContentBlock, { kind: 'image' }>;
|
||
type ThinkingBlock = Extract<ConversationContentBlock, { kind: 'thinking' }>;
|
||
|
||
type ProcessItem =
|
||
| { kind: 'thinking'; id: string; block: ThinkingBlock }
|
||
| { kind: 'assistant-commentary'; id: string; node: ConversationMessageNode; blocks: ConversationContentBlock[] }
|
||
| { kind: 'tool'; id: string; node: ConversationToolNode }
|
||
| { kind: 'compaction'; id: string; node: ConversationCompactionNode }
|
||
| { kind: 'retry'; id: string; node: ConversationBoundaryNode }
|
||
| { kind: 'subagent'; id: string; node: ConversationSubagentNode }
|
||
| { kind: 'notice'; id: string; node: ConversationNoticeNode };
|
||
|
||
interface TimelineTurn {
|
||
id: string;
|
||
user?: ConversationMessageNode;
|
||
process: ProcessItem[];
|
||
final?: ConversationMessageNode;
|
||
}
|
||
|
||
function statusLabel(status: string): string {
|
||
return NODE_STATUS_LABELS[status] ?? status;
|
||
}
|
||
|
||
const MarkdownText = memo(function MarkdownText({
|
||
text,
|
||
compact = false,
|
||
muted = false,
|
||
}: {
|
||
text: string;
|
||
compact?: boolean;
|
||
muted?: boolean;
|
||
}) {
|
||
const openExternal = useCallback((href: string) => {
|
||
void openConversationExternalUrl(href)
|
||
.catch(() => toast.error('链接无法打开'));
|
||
}, []);
|
||
const openLocalWebFile = useCallback((path: string) => {
|
||
void openConversationLocalWebFile(path)
|
||
.catch(() => toast.error('本地网页无法打开'));
|
||
}, []);
|
||
const showLinkContextMenu = useCallback((
|
||
request: Parameters<typeof showConversationLinkContextMenu>[0],
|
||
) => {
|
||
void showConversationLinkContextMenu(request)
|
||
.catch(() => toast.error('链接菜单无法打开'));
|
||
}, []);
|
||
|
||
return (
|
||
<div
|
||
className={cn(
|
||
'chat-assistant-message-markdown min-w-0 break-words',
|
||
muted ? 'text-muted-foreground' : 'text-foreground',
|
||
compact && 'text-[14px]',
|
||
)}
|
||
data-testid="assistant-markdown"
|
||
>
|
||
<ReactMarkdown
|
||
remarkPlugins={[remarkGfm]}
|
||
skipHtml
|
||
components={{
|
||
h1: ({ children }) => (
|
||
<h1 className={cn(
|
||
'mb-2.5 mt-6 font-semibold tracking-tight first:mt-0',
|
||
compact ? 'text-base leading-6' : 'text-xl leading-7',
|
||
)}>
|
||
{children}
|
||
</h1>
|
||
),
|
||
h2: ({ children }) => (
|
||
<h2 className={cn(
|
||
'mb-2 mt-5 font-semibold tracking-tight first:mt-0',
|
||
compact ? 'text-[15px] leading-6' : 'text-lg leading-7',
|
||
)}>
|
||
{children}
|
||
</h2>
|
||
),
|
||
h3: ({ children }) => (
|
||
<h3 className={cn(
|
||
'mb-1.5 mt-4 font-semibold first:mt-0',
|
||
compact ? 'text-sm leading-6' : 'text-base leading-6',
|
||
)}>
|
||
{children}
|
||
</h3>
|
||
),
|
||
h4: ({ children }) => (
|
||
<h4 className="mb-1.5 mt-3.5 text-[15px] font-semibold leading-6 first:mt-0">
|
||
{children}
|
||
</h4>
|
||
),
|
||
p: ({ children }) => (
|
||
<p className={cn(
|
||
'my-2 text-pretty first:mt-0 last:mb-0',
|
||
compact ? 'leading-[1.65]' : 'leading-7',
|
||
)}>
|
||
{children}
|
||
</p>
|
||
),
|
||
a: ({ href, children }) => {
|
||
const safeHref = safeConversationExternalUrl(href);
|
||
if (!safeHref) return <span>{children}</span>;
|
||
return (
|
||
<a
|
||
href={safeHref}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
title="左键用默认浏览器打开;右键查看更多操作"
|
||
className="inline-flex max-w-full items-baseline gap-0.5 break-all font-medium text-brand underline decoration-brand/35 underline-offset-[3px] hover:decoration-brand focus-visible:rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/35"
|
||
onClick={(event) => {
|
||
event.preventDefault();
|
||
openExternal(safeHref);
|
||
}}
|
||
onContextMenu={(event) => {
|
||
event.preventDefault();
|
||
showLinkContextMenu({ kind: 'external', target: safeHref });
|
||
}}
|
||
>
|
||
<span>{children}</span>
|
||
<ExternalLink className="inline h-3 w-3 shrink-0 self-center" aria-hidden="true" />
|
||
</a>
|
||
);
|
||
},
|
||
strong: ({ children }) => (
|
||
<strong className={cn(
|
||
'font-semibold',
|
||
muted ? 'text-muted-foreground' : 'text-foreground',
|
||
)}>
|
||
{children}
|
||
</strong>
|
||
),
|
||
ul: ({ children }) => (
|
||
<ul className="my-2.5 list-disc space-y-1 pl-5 marker:text-muted-foreground">
|
||
{children}
|
||
</ul>
|
||
),
|
||
ol: ({ children }) => (
|
||
<ol className="my-2.5 list-decimal space-y-1 pl-5 marker:font-medium marker:text-muted-foreground">
|
||
{children}
|
||
</ol>
|
||
),
|
||
li: ({ children }) => <li className="pl-1 leading-6">{children}</li>,
|
||
blockquote: ({ children }) => (
|
||
<blockquote className={cn(
|
||
'my-3 rounded-r-lg border-l-2 border-brand/45 bg-brand-soft/35 py-2 pl-3.5 pr-3',
|
||
muted ? 'text-muted-foreground' : 'text-foreground/80',
|
||
)}>
|
||
{children}
|
||
</blockquote>
|
||
),
|
||
hr: () => <hr className="my-5 border-border/80" />,
|
||
code: ({ children, className }) => {
|
||
const rawCode = String(children);
|
||
const codeText = rawCode.replace(/\n$/, '');
|
||
const blockCode = rawCode.endsWith('\n') || Boolean(className?.startsWith('language-'));
|
||
const inlineUrl = blockCode ? null : safeConversationExternalUrl(codeText);
|
||
const localWebPath = blockCode ? null : openableConversationLocalWebPath(codeText);
|
||
const resolvedWebPath = blockCode || localWebPath
|
||
? null
|
||
: resolveConversationMarkdownWebPath(codeText, text);
|
||
if (inlineUrl) {
|
||
return (
|
||
<a
|
||
href={inlineUrl}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
title="左键用默认浏览器打开;右键查看更多操作"
|
||
className="inline-flex max-w-full items-center gap-1 align-baseline font-mono text-[0.92em] text-brand underline decoration-brand/35 underline-offset-2 hover:decoration-brand focus-visible:rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/35"
|
||
onClick={(event) => {
|
||
event.preventDefault();
|
||
openExternal(inlineUrl);
|
||
}}
|
||
onContextMenu={(event) => {
|
||
event.preventDefault();
|
||
showLinkContextMenu({ kind: 'external', target: inlineUrl });
|
||
}}
|
||
>
|
||
<code className="min-w-0 break-all">{codeText}</code>
|
||
<ExternalLink className="h-3 w-3 shrink-0" aria-hidden="true" />
|
||
</a>
|
||
);
|
||
}
|
||
if (resolvedWebPath) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
className="group/path inline-flex max-w-full items-center gap-1 align-baseline font-mono text-[0.92em] text-brand underline decoration-brand/35 underline-offset-2 hover:decoration-brand focus-visible:rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/35"
|
||
aria-label={`用默认浏览器打开 ${codeText}`}
|
||
title="左键用默认浏览器打开;右键查看更多操作"
|
||
onClick={() => openLocalWebFile(resolvedWebPath)}
|
||
onContextMenu={(event) => {
|
||
event.preventDefault();
|
||
showLinkContextMenu({ kind: 'local-web', target: resolvedWebPath });
|
||
}}
|
||
>
|
||
<code className="min-w-0 break-all">{codeText}</code>
|
||
<ExternalLink className="h-3 w-3 shrink-0 opacity-60 group-hover/path:opacity-100" aria-hidden="true" />
|
||
</button>
|
||
);
|
||
}
|
||
if (localWebPath) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
className="group/path inline-flex max-w-full items-center gap-1 align-baseline font-mono text-[0.92em] text-brand underline decoration-brand/35 underline-offset-2 hover:decoration-brand focus-visible:rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/35"
|
||
aria-label={`用默认浏览器打开 ${localWebPath}`}
|
||
title="左键用默认浏览器打开;右键查看更多操作"
|
||
onClick={() => openLocalWebFile(localWebPath)}
|
||
onContextMenu={(event) => {
|
||
event.preventDefault();
|
||
showLinkContextMenu({ kind: 'local-web', target: localWebPath });
|
||
}}
|
||
>
|
||
<code className="min-w-0 break-all">{codeText}</code>
|
||
<ExternalLink className="h-3 w-3 shrink-0 opacity-60 group-hover/path:opacity-100" aria-hidden="true" />
|
||
</button>
|
||
);
|
||
}
|
||
return (
|
||
<code
|
||
className={cn(
|
||
blockCode
|
||
? 'font-mono text-[0.92em]'
|
||
: 'rounded bg-foreground/[0.055] px-1 py-0.5 font-mono text-[0.92em]',
|
||
className,
|
||
)}
|
||
>
|
||
{children}
|
||
</code>
|
||
);
|
||
},
|
||
pre: ({ children }) => (
|
||
<pre className="my-3 max-w-full overflow-x-auto rounded-xl border border-border/70 bg-surface-subtle p-3.5 font-mono text-xs leading-5">
|
||
{children}
|
||
</pre>
|
||
),
|
||
table: ({ children }) => (
|
||
<div className="my-4 max-w-full overflow-x-auto rounded-xl border border-border/80">
|
||
<table className="w-full min-w-[32rem] border-collapse text-left text-[13px] leading-5">
|
||
{children}
|
||
</table>
|
||
</div>
|
||
),
|
||
thead: ({ children }) => <thead className="bg-surface-subtle">{children}</thead>,
|
||
tbody: ({ children }) => <tbody className="divide-y divide-border/70">{children}</tbody>,
|
||
th: ({ children }) => (
|
||
<th className={cn(
|
||
'px-3 py-2.5 align-top font-semibold',
|
||
muted ? 'text-muted-foreground' : 'text-foreground',
|
||
)}>
|
||
{children}
|
||
</th>
|
||
),
|
||
td: ({ children }) => (
|
||
<td className={cn(
|
||
'px-3 py-2.5 align-top',
|
||
muted ? 'text-muted-foreground' : 'text-foreground/85',
|
||
)}>
|
||
{children}
|
||
</td>
|
||
),
|
||
img: ({ src, alt }) => typeof src === 'string' ? (
|
||
<img
|
||
src={src}
|
||
alt={alt || '回复中的图片'}
|
||
loading="lazy"
|
||
className="my-4 max-h-[32rem] max-w-full rounded-xl border border-border/70 object-contain"
|
||
/>
|
||
) : null,
|
||
}}
|
||
>
|
||
{text}
|
||
</ReactMarkdown>
|
||
</div>
|
||
);
|
||
});
|
||
|
||
const ContentBlock = memo(function ContentBlock({
|
||
block,
|
||
markdown,
|
||
compactMarkdown = false,
|
||
muted = false,
|
||
}: {
|
||
block: ConversationContentBlock;
|
||
markdown: boolean;
|
||
compactMarkdown?: boolean;
|
||
muted?: boolean;
|
||
}) {
|
||
if (block.kind === 'image') {
|
||
return <CodingAttachmentPreview attachmentId={block.attachmentId} mime={block.mime} />;
|
||
}
|
||
if (!markdown) {
|
||
return (
|
||
<p className={cn(
|
||
'whitespace-pre-wrap break-words text-pretty leading-6',
|
||
muted && 'text-muted-foreground',
|
||
)}>
|
||
{block.text}
|
||
{block.status === 'streaming' && (
|
||
<span
|
||
className={cn(
|
||
'ml-0.5 inline-block h-3.5 w-1 animate-pulse align-middle',
|
||
muted ? 'bg-muted-foreground/35' : 'bg-foreground/35',
|
||
)}
|
||
aria-label="正在生成回答"
|
||
/>
|
||
)}
|
||
</p>
|
||
);
|
||
}
|
||
return (
|
||
<div className="min-w-0">
|
||
<MarkdownText text={block.text} compact={compactMarkdown} muted={muted} />
|
||
{block.status === 'streaming' && (
|
||
<span
|
||
className={cn(
|
||
'mt-1 inline-block h-4 w-1 animate-pulse align-middle',
|
||
muted ? 'bg-muted-foreground/35' : 'bg-foreground/35',
|
||
)}
|
||
aria-label="正在生成回答"
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
});
|
||
|
||
function answerBlocks(node: ConversationMessageNode): ConversationContentBlock[] {
|
||
return node.blocks.filter((block) => block.kind !== 'thinking');
|
||
}
|
||
|
||
function hasVisibleAnswer(node: ConversationMessageNode): boolean {
|
||
return answerBlocks(node).some((block) => (
|
||
block.kind === 'image' || block.text.trim().length > 0 || block.status === 'streaming'
|
||
));
|
||
}
|
||
|
||
const MessageNode = memo(function MessageNode({
|
||
node,
|
||
onFork,
|
||
assistantName,
|
||
}: {
|
||
node: ConversationMessageNode;
|
||
onFork?(sourceEntryId: string): void;
|
||
assistantName?: string;
|
||
}) {
|
||
const user = node.role === 'user';
|
||
const blocks = user ? node.blocks : answerBlocks(node);
|
||
const answerText = blocks
|
||
.filter((block): block is TextualBlock => block.kind === 'text')
|
||
.map((block) => block.text)
|
||
.join('\n\n')
|
||
.trim();
|
||
const [copied, setCopied] = useState(false);
|
||
const copyAnswer = useCallback(() => {
|
||
if (!answerText || !navigator.clipboard) return;
|
||
void navigator.clipboard.writeText(answerText).then(() => {
|
||
setCopied(true);
|
||
window.setTimeout(() => setCopied(false), 1_600);
|
||
}).catch(() => undefined);
|
||
}, [answerText]);
|
||
|
||
return (
|
||
<article
|
||
data-node-id={node.id}
|
||
data-node-kind="message"
|
||
aria-label={user ? '你的消息' : assistantName ? `${assistantName} 的回复` : '助手回复'}
|
||
className={cn('group/message flex items-start', user && 'justify-end')}
|
||
>
|
||
<div
|
||
className={cn(
|
||
'min-w-0 text-[15px] text-foreground',
|
||
user ? 'max-w-[82%]' : 'flex-1',
|
||
)}
|
||
>
|
||
<div
|
||
className={cn(
|
||
'space-y-2.5',
|
||
user
|
||
? 'chat-user-message-surface rounded-[18px] rounded-tr-md px-3.5 py-2.5'
|
||
: 'chat-assistant-message-surface w-full py-0.5',
|
||
node.status === 'error' && user && 'bg-destructive/[0.07]',
|
||
)}
|
||
>
|
||
{blocks.map((block) => (
|
||
<ContentBlock key={block.id} block={block} markdown={!user} />
|
||
))}
|
||
{node.status === 'optimistic' && <p className="text-xs text-muted-foreground">正在提交…</p>}
|
||
{node.status === 'error' && (
|
||
<p className="text-xs font-medium text-destructive">
|
||
{user ? '本次提交未被接受,内容已保留在输入框。' : '本次回复失败。'}
|
||
</p>
|
||
)}
|
||
{node.status === 'aborted' && <p className="text-xs text-muted-foreground">本次处理已中止。</p>}
|
||
{user && node.sourceEntryId && onFork && node.status !== 'optimistic' && (
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
className="h-auto min-h-0 rounded-md px-1.5 py-1 text-[11px] text-muted-foreground"
|
||
onClick={() => onFork(node.sourceEntryId!)}
|
||
>
|
||
<GitFork className="mr-1 h-3 w-3" aria-hidden="true" />
|
||
从这里创建新对话分支
|
||
</Button>
|
||
)}
|
||
</div>
|
||
{!user && answerText && (
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="icon"
|
||
className="mt-1 h-7 w-7 rounded-md text-muted-foreground opacity-0 transition-opacity group-hover/message:opacity-100 focus-visible:opacity-100"
|
||
aria-label={copied ? '已复制回复' : '复制回复'}
|
||
onClick={copyAnswer}
|
||
>
|
||
{copied
|
||
? <Check className="h-3.5 w-3.5 text-emerald-600" aria-hidden="true" />
|
||
: <Copy className="h-3.5 w-3.5" aria-hidden="true" />}
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</article>
|
||
);
|
||
});
|
||
|
||
function subagentModeLabel(mode: SubagentDetailsV1['mode']): string {
|
||
if (mode === 'parallel') return '并行子任务';
|
||
if (mode === 'chain') return '串行子任务';
|
||
return '单个子任务';
|
||
}
|
||
|
||
function subagentStatus(details: SubagentDetailsV1): 'running' | 'error' | 'complete' {
|
||
if (details.tasks.some((task) => task.status === 'queued' || task.status === 'running')) return 'running';
|
||
if (details.tasks.some((task) => task.status === 'error')) return 'error';
|
||
return 'complete';
|
||
}
|
||
|
||
const SubagentGraph = memo(function SubagentGraph({ details }: { details: SubagentDetailsV1 }) {
|
||
return (
|
||
<div className="space-y-2.5 text-xs" data-subagent-mode={details.mode}>
|
||
<div className="flex items-center gap-2 text-muted-foreground">
|
||
<span>{subagentModeLabel(details.mode)}</span>
|
||
<span className="ml-auto truncate font-mono text-[10px]">{details.dispatchId}</span>
|
||
</div>
|
||
<div className="space-y-1.5">
|
||
{details.tasks.map((task) => (
|
||
<div key={task.taskId} className="flex items-start gap-2 py-0.5">
|
||
<span className={cn(
|
||
'mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full',
|
||
task.status === 'error'
|
||
? 'bg-destructive'
|
||
: task.status === 'complete'
|
||
? 'bg-emerald-500'
|
||
: 'bg-brand',
|
||
)} />
|
||
<div className="min-w-0 flex-1">
|
||
<div className="flex items-center gap-2">
|
||
<p className="min-w-0 flex-1 truncate font-medium text-foreground">{task.agentId}</p>
|
||
<span className="shrink-0 text-muted-foreground">{statusLabel(task.status)}</span>
|
||
</div>
|
||
<p className="mt-0.5 text-muted-foreground">{task.toolProfile === 'coding' ? '可写代码' : '只读'}</p>
|
||
{task.summary && <p className="mt-1 text-pretty leading-5 text-foreground/80">{task.summary}</p>}
|
||
{task.errorCode && <p className="mt-1 font-mono text-destructive">{task.errorCode}</p>}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
});
|
||
|
||
const ToolDetails = memo(function ToolDetails({ details }: { details: KnownToolDetails }) {
|
||
if (details.schema === 'changed-file.v1') {
|
||
return (
|
||
<div className="text-xs">
|
||
<p className="font-medium text-foreground">本轮变更文件</p>
|
||
{details.paths.map((path) => <p key={path} className="mt-1 truncate font-mono text-muted-foreground">{path}</p>)}
|
||
</div>
|
||
);
|
||
}
|
||
if (details.schema === 'task-state.v1') {
|
||
return (
|
||
<div className="text-xs">
|
||
<p className="flex items-center gap-2 font-medium text-foreground"><ListChecks className="h-3.5 w-3.5" aria-hidden="true" />任务状态</p>
|
||
{details.tasks.map((task) => (
|
||
<p key={task.id} className="mt-1.5 flex gap-2"><span className="min-w-0 flex-1 truncate">{task.title}</span><span className="text-muted-foreground">{statusLabel(task.status)}</span></p>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
if (details.schema === 'write-lease.v1') {
|
||
return <p className="flex items-center gap-2 text-xs"><ShieldCheck className="h-3.5 w-3.5" aria-hidden="true" />写入权限:{statusLabel(details.status)}</p>;
|
||
}
|
||
if (details.schema === 'agent-browser.v1') {
|
||
return (
|
||
<div className="space-y-2 text-xs">
|
||
<p className="flex items-center gap-2 font-medium"><Globe2 className="h-3.5 w-3.5" aria-hidden="true" />浏览器操作 · {details.action}</p>
|
||
{details.attachmentId && details.mime && (
|
||
<CodingAttachmentPreview attachmentId={details.attachmentId} mime={details.mime} caption="浏览器附件" />
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
if (details.schema === 'game-assets.v1') {
|
||
return (
|
||
<div className="text-xs">
|
||
<p className="font-medium">素材确认 · {details.status === 'pending' ? '等待处理' : '已完成'}</p>
|
||
<p className="mt-1 text-muted-foreground">待确认 {details.pendingAssetIds.length} · 已采用 {details.approvedAssetIds.length} · 已丢弃 {details.discardedAssetIds.length}</p>
|
||
</div>
|
||
);
|
||
}
|
||
if (details.schema === 'runtime-context.v1') {
|
||
return (
|
||
<div className="grid gap-3 text-xs sm:grid-cols-2">
|
||
<div><p className="font-medium">技能</p>{details.skills.map((skill) => <p key={skill.id} className="mt-1 truncate text-muted-foreground">{skill.selected ? '已启用 · ' : ''}{skill.name}</p>)}</div>
|
||
<div><p className="font-medium">命令</p>{details.commands.map((command) => <p key={`${command.source}:${command.name}`} className="mt-1 truncate font-mono text-muted-foreground">/{command.name}</p>)}</div>
|
||
</div>
|
||
);
|
||
}
|
||
return <SubagentGraph details={details} />;
|
||
});
|
||
|
||
function toolStatusIsActive(status: ConversationToolNode['status']): boolean {
|
||
return status === 'declared' || status === 'waiting' || status === 'running';
|
||
}
|
||
|
||
function toolStatusIcon(node: ConversationToolNode, active: boolean) {
|
||
if (active) {
|
||
return <LoaderCircle className="h-3.5 w-3.5 shrink-0 animate-spin text-brand" aria-hidden="true" />;
|
||
}
|
||
if (node.status === 'error') {
|
||
return <XCircle className="h-3.5 w-3.5 shrink-0 text-destructive" aria-hidden="true" />;
|
||
}
|
||
return toolActivityIcon(node);
|
||
}
|
||
|
||
function toolActivityIcon(node: ConversationToolNode) {
|
||
const name = node.toolName.toLowerCase();
|
||
const title = node.title.toLowerCase();
|
||
const className = cn(
|
||
'h-3.5 w-3.5 shrink-0',
|
||
node.status === 'error'
|
||
? 'text-destructive'
|
||
: 'text-muted-foreground',
|
||
);
|
||
if (title.includes('加载')) return <Wrench className={className} aria-hidden="true" />;
|
||
if (name.includes('read')) return <BookOpen className={className} aria-hidden="true" />;
|
||
if (name.includes('search') || name.includes('grep') || name.includes('find')) {
|
||
return <Search className={className} aria-hidden="true" />;
|
||
}
|
||
if (name.includes('edit') || name.includes('write') || name.includes('patch') || name.includes('change')) {
|
||
return <Pencil className={className} aria-hidden="true" />;
|
||
}
|
||
if (name.includes('bash') || name.includes('exec') || name.includes('command') || name.includes('terminal')) {
|
||
return <Terminal className={className} aria-hidden="true" />;
|
||
}
|
||
if (name.includes('browser')) return <Globe2 className={className} aria-hidden="true" />;
|
||
return <Hammer className={className} aria-hidden="true" />;
|
||
}
|
||
|
||
function latestProgressLine(value: string): string | null {
|
||
const lines = value
|
||
.split(/\r?\n/)
|
||
.map((line) => line.trim())
|
||
.filter(Boolean);
|
||
const line = lines
|
||
.at(-1)
|
||
?.replace(/\s+/g, ' ')
|
||
.replace(/^#{1,6}\s+/, '')
|
||
.replace(/^(?:[-*+]|\d+[.)])\s+/, '');
|
||
if (!line) return null;
|
||
return line.length > PROGRESS_PREVIEW_MAX_CHARS
|
||
? `…${line.slice(-(PROGRESS_PREVIEW_MAX_CHARS - 1))}`
|
||
: line;
|
||
}
|
||
|
||
function progressInlineParts(text: string): Array<{ code: boolean; text: string }> {
|
||
return text
|
||
.split(/(`[^`\n]+`)/g)
|
||
.filter(Boolean)
|
||
.map((part) => (
|
||
part.startsWith('`') && part.endsWith('`')
|
||
? { code: true, text: part.slice(1, -1) }
|
||
: { code: false, text: part }
|
||
));
|
||
}
|
||
|
||
const RollingProgressPreview = memo(function RollingProgressPreview({
|
||
text,
|
||
active,
|
||
shimmer = true,
|
||
rollUpdates = true,
|
||
contentVersion,
|
||
testId,
|
||
className,
|
||
codeClassName = 'text-muted-foreground',
|
||
}: {
|
||
text: string;
|
||
active: boolean;
|
||
shimmer?: boolean;
|
||
rollUpdates?: boolean;
|
||
contentVersion: number;
|
||
testId?: string;
|
||
className?: string;
|
||
codeClassName?: string;
|
||
}) {
|
||
const viewportRef = useRef<HTMLSpanElement | null>(null);
|
||
const revision = `${active ? 'active' : 'settled'}:${Math.floor(
|
||
contentVersion / PROGRESS_ROLL_CHAR_STEP,
|
||
)}`;
|
||
const parts = progressInlineParts(text);
|
||
|
||
useLayoutEffect(() => {
|
||
const viewport = viewportRef.current;
|
||
if (!viewport) return;
|
||
viewport.scrollLeft = viewport.scrollWidth;
|
||
}, [contentVersion, text]);
|
||
|
||
useEffect(() => {
|
||
const viewport = viewportRef.current;
|
||
if (!viewport || typeof ResizeObserver === 'undefined') return;
|
||
const observer = new ResizeObserver(() => {
|
||
viewport.scrollLeft = viewport.scrollWidth;
|
||
});
|
||
observer.observe(viewport);
|
||
return () => observer.disconnect();
|
||
}, []);
|
||
|
||
return (
|
||
<span
|
||
ref={viewportRef}
|
||
aria-label={text}
|
||
className={cn(
|
||
'block h-[1.65em] min-w-0 overflow-hidden whitespace-nowrap text-left',
|
||
className,
|
||
)}
|
||
data-progress-alignment="left"
|
||
data-progress-update-motion={rollUpdates ? 'roll' : 'none'}
|
||
data-progress-shimmer={active && shimmer ? 'true' : 'false'}
|
||
data-progress-tail="true"
|
||
data-roll-revision={rollUpdates ? revision : undefined}
|
||
data-testid={testId}
|
||
title={text}
|
||
>
|
||
<span className="inline-flex min-w-max items-center">
|
||
<span
|
||
key={rollUpdates ? revision : 'steady'}
|
||
aria-hidden="true"
|
||
className={cn(
|
||
'block whitespace-nowrap',
|
||
active && rollUpdates && 'streaming-progress-roll',
|
||
)}
|
||
>
|
||
<span className={cn(active && shimmer && 'streaming-progress-shimmer')}>
|
||
{parts.map((part, index) => (
|
||
part.code
|
||
? (
|
||
<code
|
||
key={`${part.text}:${index}`}
|
||
className={cn(
|
||
'rounded bg-foreground/[0.045] px-1 py-0.5 font-mono text-[0.92em]',
|
||
codeClassName,
|
||
)}
|
||
>
|
||
{part.text}
|
||
</code>
|
||
)
|
||
: <span key={`${part.text}:${index}`}>{part.text}</span>
|
||
))}
|
||
</span>
|
||
</span>
|
||
</span>
|
||
</span>
|
||
);
|
||
});
|
||
|
||
function contentBlocksProgressPreview(
|
||
blocks: ConversationContentBlock[],
|
||
fallback: string,
|
||
): string {
|
||
for (let index = blocks.length - 1; index >= 0; index -= 1) {
|
||
const block = blocks[index];
|
||
if (!block) continue;
|
||
if (block.kind === 'image') return '图片附件';
|
||
const preview = latestProgressLine(block.text);
|
||
if (preview) return preview;
|
||
}
|
||
return fallback;
|
||
}
|
||
|
||
function toolDetailsProgress(details: KnownToolDetails | undefined): string | null {
|
||
if (!details) return null;
|
||
if (details.schema === 'changed-file.v1') {
|
||
const latestPath = details.paths.at(-1);
|
||
return latestPath ? `${details.paths.length} 个文件 · ${latestPath}` : '尚未记录文件';
|
||
}
|
||
if (details.schema === 'task-state.v1') {
|
||
const task = details.tasks.find((candidate) => (
|
||
candidate.status === 'running' || candidate.status === 'pending'
|
||
)) ?? details.tasks.at(-1);
|
||
return task ? `${task.title} · ${statusLabel(task.status)}` : '等待任务状态';
|
||
}
|
||
if (details.schema === 'write-lease.v1') {
|
||
return `写入权限 · ${statusLabel(details.status)}`;
|
||
}
|
||
if (details.schema === 'agent-browser.v1') {
|
||
return `浏览器操作 · ${details.action}`;
|
||
}
|
||
if (details.schema === 'game-assets.v1') {
|
||
return details.status === 'pending'
|
||
? `等待确认 ${details.pendingAssetIds.length} 个素材`
|
||
: `已采用 ${details.approvedAssetIds.length} · 已丢弃 ${details.discardedAssetIds.length}`;
|
||
}
|
||
if (details.schema === 'runtime-context.v1') {
|
||
const selectedSkills = details.skills.filter((skill) => skill.selected);
|
||
return `已启用 ${selectedSkills.length} 项技能 · ${details.commands.length} 条命令`;
|
||
}
|
||
const task = details.tasks.find((candidate) => (
|
||
candidate.status === 'running' || candidate.status === 'queued'
|
||
)) ?? details.tasks.at(-1);
|
||
return task
|
||
? `${task.agentId} · ${task.summary || statusLabel(task.status)}`
|
||
: `${subagentModeLabel(details.mode)} · 等待任务`;
|
||
}
|
||
|
||
function toolProgressPreview(node: ConversationToolNode): string {
|
||
for (let index = node.output.length - 1; index >= 0; index -= 1) {
|
||
const block = node.output[index];
|
||
if (!block) continue;
|
||
if (block.kind === 'image') return '已生成图片附件';
|
||
const outputPreview = latestProgressLine(block.text);
|
||
if (outputPreview) return outputPreview;
|
||
}
|
||
return latestProgressLine(node.inputText)
|
||
?? toolDetailsProgress(node.details)
|
||
?? statusLabel(node.status);
|
||
}
|
||
|
||
const ToolNode = memo(function ToolNode({
|
||
node,
|
||
processActive,
|
||
}: {
|
||
node: ConversationToolNode;
|
||
processActive: boolean;
|
||
}) {
|
||
const outputChars = node.output.reduce((total, block) => (
|
||
total + (block.kind === 'image' ? 0 : block.text.length)
|
||
), 0);
|
||
const progressContentVersion = node.inputText.length + outputChars + node.output.length;
|
||
const [expanded, setExpanded] = useState(false);
|
||
const title = node.title || node.toolName;
|
||
const progress = toolProgressPreview(node);
|
||
const showProgress = progress.trim() !== title.trim();
|
||
const active = processActive && toolStatusIsActive(node.status);
|
||
const prominentStatus = active || node.status === 'error' || node.status === 'aborted';
|
||
|
||
return (
|
||
<details
|
||
data-node-id={node.id}
|
||
data-node-kind="tool"
|
||
className="group/tool text-[13px]"
|
||
open={expanded}
|
||
onToggle={(event) => setExpanded(event.currentTarget.open)}
|
||
>
|
||
<summary
|
||
data-collapsed-lines="1"
|
||
className="flex h-8 cursor-pointer list-none items-center gap-2 overflow-hidden rounded-md px-1.5 text-muted-foreground hover:bg-foreground/[0.035] hover:text-foreground"
|
||
>
|
||
{toolStatusIcon(node, active)}
|
||
<span className={cn(
|
||
'min-w-0 truncate text-muted-foreground',
|
||
showProgress ? 'max-w-[42%] shrink-0' : 'flex-1',
|
||
)}>
|
||
{title}
|
||
</span>
|
||
{showProgress && (
|
||
<>
|
||
<span className="shrink-0 text-foreground/25" aria-hidden="true">·</span>
|
||
<RollingProgressPreview
|
||
text={progress}
|
||
active={active}
|
||
contentVersion={progressContentVersion}
|
||
testId="tool-progress-preview"
|
||
className="flex-1 text-muted-foreground/75"
|
||
/>
|
||
</>
|
||
)}
|
||
<span className={cn(
|
||
prominentStatus ? 'shrink-0 text-[11px]' : 'sr-only',
|
||
active && 'text-brand',
|
||
(node.status === 'error' || node.status === 'aborted') && 'text-destructive',
|
||
)}>
|
||
{statusLabel(node.status)}
|
||
</span>
|
||
<ChevronDown className="h-3.5 w-3.5 shrink-0 opacity-50 transition-[opacity,transform] duration-150 group-hover/tool:opacity-100 group-focus-within/tool:opacity-100 group-open/tool:rotate-180 group-open/tool:opacity-100" aria-hidden="true" />
|
||
</summary>
|
||
<div
|
||
data-testid="tool-details"
|
||
className="ml-3.5 space-y-2.5 border-l border-foreground/10 py-2 pl-4 text-muted-foreground"
|
||
>
|
||
<p className="text-[11px]">当前状态:{statusLabel(node.status)}</p>
|
||
{node.details && <ToolDetails details={node.details} />}
|
||
{node.inputText && (
|
||
<pre className="max-h-44 overflow-auto whitespace-pre-wrap break-words font-mono text-[11px] leading-5">
|
||
{node.inputText}
|
||
</pre>
|
||
)}
|
||
{node.output.length > 0 && (
|
||
<div className="max-h-64 space-y-2 overflow-auto text-[11px] leading-5">
|
||
{node.output.map((block) => (
|
||
<ContentBlock key={block.id} block={block} markdown={false} muted />
|
||
))}
|
||
</div>
|
||
)}
|
||
{outputChars > LONG_OUTPUT_CHARS && (
|
||
<p className="text-[11px]">长输出已收纳,不会形成独立消息。</p>
|
||
)}
|
||
{!node.details && !node.inputText && node.output.length === 0 && (
|
||
<p className="text-[11px]">
|
||
{active ? '正在等待工具返回详细进度…' : '该工具没有返回额外详情。'}
|
||
</p>
|
||
)}
|
||
</div>
|
||
</details>
|
||
);
|
||
});
|
||
|
||
const CompactionNode = memo(function CompactionNodeView({
|
||
node,
|
||
processActive,
|
||
}: {
|
||
node: ConversationCompactionNode;
|
||
processActive: boolean;
|
||
}) {
|
||
const [expanded, setExpanded] = useState(false);
|
||
const hasDetails = Boolean(node.summary) || node.status === 'error';
|
||
const running = processActive && node.status === 'running';
|
||
return (
|
||
<details
|
||
data-node-id={node.id}
|
||
data-node-kind="compaction"
|
||
className="group/compaction text-xs"
|
||
open={expanded}
|
||
onToggle={(event) => setExpanded(event.currentTarget.open)}
|
||
>
|
||
<summary className="flex min-h-8 cursor-pointer list-none items-center gap-2 rounded-md px-1.5 text-muted-foreground hover:bg-foreground/[0.035] hover:text-foreground">
|
||
{running
|
||
? <LoaderCircle className="h-3.5 w-3.5 animate-spin text-brand" aria-hidden="true" />
|
||
: <Layers3 className={cn('h-3.5 w-3.5', node.status === 'error' && 'text-destructive')} aria-hidden="true" />}
|
||
<span className="min-w-0 flex-1 text-muted-foreground">整理上下文</span>
|
||
<span className={cn(running && 'text-brand', node.status === 'error' && 'text-destructive')}>{statusLabel(node.status)}</span>
|
||
{hasDetails && <ChevronDown className="h-3.5 w-3.5 transition-transform duration-150 group-open/compaction:rotate-180" aria-hidden="true" />}
|
||
</summary>
|
||
{hasDetails && (
|
||
<div className="ml-3.5 border-l border-foreground/10 py-1.5 pl-4 leading-5 text-muted-foreground">
|
||
{node.summary && <p className="text-pretty">{node.summary}</p>}
|
||
{node.status === 'error' && (
|
||
<p className="mt-1">{node.willRetry ? '系统会自动重试。' : '本次整理已结束,不会自动重试。'}</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
</details>
|
||
);
|
||
});
|
||
|
||
const RetryNode = memo(function RetryNodeView({ node }: { node: ConversationBoundaryNode }) {
|
||
return (
|
||
<div data-node-id={node.id} data-node-kind="boundary" className="flex min-h-8 items-center gap-2 px-1.5 text-xs text-muted-foreground">
|
||
<RotateCcw className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
|
||
<span>
|
||
正在重试{node.attempt ? ` · 第 ${node.attempt} 次` : ''}{node.delayMs ? ` · ${Math.ceil(node.delayMs / 1_000)} 秒后` : ''}
|
||
</span>
|
||
</div>
|
||
);
|
||
});
|
||
|
||
const NoticeNode = memo(function NoticeNodeView({ node }: { node: ConversationNoticeNode }) {
|
||
return (
|
||
<div
|
||
data-node-id={node.id}
|
||
data-node-kind="notice"
|
||
className={cn(
|
||
'flex min-h-8 items-start gap-2 px-1.5 py-1.5 text-xs text-muted-foreground',
|
||
node.level === 'error' && 'text-destructive',
|
||
)}
|
||
>
|
||
<CircleAlert className="mt-0.5 h-3.5 w-3.5 shrink-0" aria-hidden="true" />
|
||
<p className="text-pretty leading-5">{node.message}</p>
|
||
</div>
|
||
);
|
||
});
|
||
|
||
const SubagentNode = memo(function SubagentNodeView({
|
||
node,
|
||
processActive,
|
||
}: {
|
||
node: ConversationSubagentNode;
|
||
processActive: boolean;
|
||
}) {
|
||
const [expanded, setExpanded] = useState(false);
|
||
const status = subagentStatus(node.details);
|
||
const running = processActive && status === 'running';
|
||
return (
|
||
<details
|
||
data-node-id={node.id}
|
||
data-node-kind="subagent"
|
||
className="group/subagent text-xs"
|
||
open={expanded}
|
||
onToggle={(event) => setExpanded(event.currentTarget.open)}
|
||
>
|
||
<summary className="flex min-h-8 cursor-pointer list-none items-center gap-2 rounded-md px-1.5 text-muted-foreground hover:bg-foreground/[0.035] hover:text-foreground">
|
||
{running
|
||
? <LoaderCircle className="h-3.5 w-3.5 animate-spin text-brand" aria-hidden="true" />
|
||
: <Workflow className={cn('h-3.5 w-3.5', status === 'error' && 'text-destructive')} aria-hidden="true" />}
|
||
<span className="min-w-0 flex-1 truncate text-muted-foreground">{subagentModeLabel(node.details.mode)}</span>
|
||
<span>{node.details.tasks.length} 个任务</span>
|
||
<span className={cn(running && 'text-brand', status === 'error' && 'text-destructive')}>{statusLabel(status)}</span>
|
||
<ChevronDown className="h-3.5 w-3.5 transition-transform duration-150 group-open/subagent:rotate-180" aria-hidden="true" />
|
||
</summary>
|
||
<div className="ml-3.5 border-l border-foreground/10 py-2 pl-4">
|
||
<SubagentGraph details={node.details} />
|
||
</div>
|
||
</details>
|
||
);
|
||
});
|
||
|
||
const CompactProcessText = memo(function CompactProcessText({
|
||
label,
|
||
expandLabel,
|
||
collapseLabel,
|
||
streaming,
|
||
shimmer = true,
|
||
rollUpdates = true,
|
||
previewClassName = 'text-muted-foreground/75',
|
||
previewCodeClassName = 'text-muted-foreground',
|
||
contentVersion,
|
||
preview,
|
||
children,
|
||
}: {
|
||
label: string;
|
||
expandLabel: string;
|
||
collapseLabel: string;
|
||
streaming: boolean;
|
||
shimmer?: boolean;
|
||
rollUpdates?: boolean;
|
||
previewClassName?: string;
|
||
previewCodeClassName?: string;
|
||
contentVersion: number;
|
||
preview: string;
|
||
children: ReactNode;
|
||
}) {
|
||
const [expanded, setExpanded] = useState(false);
|
||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||
|
||
useLayoutEffect(() => {
|
||
const element = scrollRef.current;
|
||
if (!element) return;
|
||
if (expanded && streaming) {
|
||
element.scrollTop = element.scrollHeight;
|
||
return;
|
||
}
|
||
if (!expanded) element.scrollTop = 0;
|
||
}, [contentVersion, expanded, streaming]);
|
||
|
||
return (
|
||
<div className="group/process-text relative min-w-0">
|
||
<div
|
||
ref={scrollRef}
|
||
aria-label={label}
|
||
data-collapsed-lines="1"
|
||
data-expanded={expanded ? 'true' : 'false'}
|
||
data-streaming={streaming ? 'true' : 'false'}
|
||
className={cn(
|
||
'min-w-0 pr-10 transition-[max-height] duration-150 ease-out',
|
||
expanded
|
||
? 'max-h-60 overflow-y-auto'
|
||
: 'max-h-[1.65em] overflow-hidden',
|
||
)}
|
||
>
|
||
{expanded
|
||
? children
|
||
: (
|
||
<RollingProgressPreview
|
||
text={preview}
|
||
active={streaming}
|
||
shimmer={shimmer}
|
||
rollUpdates={rollUpdates}
|
||
contentVersion={contentVersion}
|
||
testId="process-progress-preview"
|
||
className={previewClassName}
|
||
codeClassName={previewCodeClassName}
|
||
/>
|
||
)}
|
||
</div>
|
||
<div className="absolute right-0 top-0 flex h-6 items-center gap-0.5 bg-background/90 pl-1">
|
||
{streaming && (
|
||
<span
|
||
className="h-1.5 w-1.5 animate-pulse rounded-full bg-brand"
|
||
title="正在更新"
|
||
aria-hidden="true"
|
||
/>
|
||
)}
|
||
<button
|
||
type="button"
|
||
aria-label={expanded ? collapseLabel : expandLabel}
|
||
aria-expanded={expanded}
|
||
className={cn(
|
||
'flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-[background-color,color,opacity] duration-150 hover:bg-foreground/[0.045] hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/35',
|
||
expanded
|
||
? 'opacity-100'
|
||
: 'opacity-50 group-hover/process-text:opacity-100 group-focus-within/process-text:opacity-100',
|
||
)}
|
||
onClick={() => setExpanded((current) => !current)}
|
||
>
|
||
<ChevronDown
|
||
className={cn(
|
||
'h-3.5 w-3.5 transition-transform duration-150',
|
||
expanded && 'rotate-180',
|
||
)}
|
||
aria-hidden="true"
|
||
/>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
});
|
||
|
||
const ProcessThinking = memo(function ProcessThinking({
|
||
block,
|
||
processActive,
|
||
}: {
|
||
block: ThinkingBlock;
|
||
processActive: boolean;
|
||
}) {
|
||
const streaming = processActive && block.status === 'streaming';
|
||
const thinkingText = streaming && block.text.length > STREAMING_THINKING_WINDOW_CHARS
|
||
? block.text.slice(-STREAMING_THINKING_WINDOW_CHARS)
|
||
: block.text;
|
||
const windowed = thinkingText.length !== block.text.length;
|
||
|
||
return (
|
||
<div
|
||
data-node-id={block.id}
|
||
data-node-kind="thinking"
|
||
className="px-1.5 py-1.5 text-[14px] text-muted-foreground"
|
||
>
|
||
<CompactProcessText
|
||
label="思考过程"
|
||
expandLabel="展开思考详情"
|
||
collapseLabel="收起思考详情"
|
||
streaming={streaming}
|
||
rollUpdates={false}
|
||
contentVersion={block.text.length}
|
||
preview={latestProgressLine(block.text) ?? (
|
||
streaming ? 'Pi 正在形成思路…' : '思考已完成'
|
||
)}
|
||
>
|
||
<div className="min-w-0 whitespace-pre-wrap break-words text-pretty leading-[1.65]">
|
||
<MarkdownText
|
||
text={thinkingText || (streaming ? 'Pi 正在形成思路…' : '')}
|
||
compact
|
||
muted
|
||
/>
|
||
{windowed && (
|
||
<p className="mt-1 text-[11px] text-muted-foreground">
|
||
流式阶段显示最近内容,完成后可查看完整思考。
|
||
</p>
|
||
)}
|
||
</div>
|
||
</CompactProcessText>
|
||
</div>
|
||
);
|
||
});
|
||
|
||
const AssistantCommentary = memo(function AssistantCommentary({
|
||
node,
|
||
blocks,
|
||
processActive,
|
||
}: {
|
||
node: ConversationMessageNode;
|
||
blocks: ConversationContentBlock[];
|
||
processActive: boolean;
|
||
}) {
|
||
const streaming = processActive && (
|
||
node.status === 'streaming'
|
||
|| blocks.some((block) => block.kind !== 'image' && block.status === 'streaming')
|
||
);
|
||
const contentVersion = blocks.reduce((total, block) => (
|
||
total + (block.kind === 'image' ? 1 : block.text.length)
|
||
), 0);
|
||
|
||
return (
|
||
<div
|
||
data-node-id={node.id}
|
||
data-node-kind="assistant-commentary"
|
||
className="px-1.5 py-1.5 text-[14px] text-foreground"
|
||
>
|
||
<CompactProcessText
|
||
label="过程说明"
|
||
expandLabel="展开过程说明"
|
||
collapseLabel="收起过程说明"
|
||
streaming={streaming}
|
||
shimmer={false}
|
||
rollUpdates={false}
|
||
previewClassName={cn(
|
||
node.status === 'error' && 'text-destructive',
|
||
node.status === 'aborted' && 'text-muted-foreground',
|
||
node.status !== 'error' && node.status !== 'aborted' && 'text-foreground',
|
||
)}
|
||
previewCodeClassName="text-foreground"
|
||
contentVersion={contentVersion}
|
||
preview={contentBlocksProgressPreview(
|
||
blocks,
|
||
node.status === 'error'
|
||
? '本次处理失败。'
|
||
: node.status === 'aborted'
|
||
? '本次处理已中止。'
|
||
: streaming
|
||
? '正在更新过程说明…'
|
||
: '过程说明已完成。',
|
||
)}
|
||
>
|
||
<div className="min-w-0 space-y-1 leading-[1.65]">
|
||
{blocks.map((block) => (
|
||
<ContentBlock key={block.id} block={block} markdown compactMarkdown />
|
||
))}
|
||
{blocks.length === 0 && node.status === 'error' && <p className="text-destructive">本次处理失败。</p>}
|
||
{blocks.length === 0 && node.status === 'aborted' && <p className="text-muted-foreground">本次处理已中止。</p>}
|
||
</div>
|
||
</CompactProcessText>
|
||
</div>
|
||
);
|
||
});
|
||
|
||
const ProcessItemView = memo(function ProcessItemView({
|
||
item,
|
||
processActive,
|
||
}: {
|
||
item: ProcessItem;
|
||
processActive: boolean;
|
||
}) {
|
||
if (item.kind === 'thinking') return <ProcessThinking block={item.block} processActive={processActive} />;
|
||
if (item.kind === 'assistant-commentary') {
|
||
return <AssistantCommentary node={item.node} blocks={item.blocks} processActive={processActive} />;
|
||
}
|
||
if (item.kind === 'tool') return <ToolNode node={item.node} processActive={processActive} />;
|
||
if (item.kind === 'compaction') return <CompactionNode node={item.node} processActive={processActive} />;
|
||
if (item.kind === 'retry') return <RetryNode node={item.node} />;
|
||
if (item.kind === 'subagent') return <SubagentNode node={item.node} processActive={processActive} />;
|
||
return <NoticeNode node={item.node} />;
|
||
});
|
||
|
||
function processItemIsActive(item: ProcessItem): boolean {
|
||
if (item.kind === 'thinking') return item.block.status === 'streaming';
|
||
if (item.kind === 'assistant-commentary') return item.node.status === 'streaming';
|
||
if (item.kind === 'tool') return ['declared', 'waiting', 'running'].includes(item.node.status);
|
||
if (item.kind === 'compaction') return item.node.status === 'running';
|
||
if (item.kind === 'subagent') return subagentStatus(item.node.details) === 'running';
|
||
return false;
|
||
}
|
||
|
||
function processItemIsFailed(item: ProcessItem): boolean {
|
||
if (item.kind === 'assistant-commentary') return item.node.status === 'error';
|
||
if (item.kind === 'tool' || item.kind === 'compaction') return item.node.status === 'error';
|
||
if (item.kind === 'subagent') return subagentStatus(item.node.details) === 'error';
|
||
return item.kind === 'notice' && item.node.level === 'error';
|
||
}
|
||
|
||
function processGroupIsFailed(
|
||
items: ProcessItem[],
|
||
run: ConversationRunState,
|
||
latest: boolean,
|
||
final?: ConversationMessageNode,
|
||
): boolean {
|
||
if (latest) {
|
||
if (run.status === 'error' || run.terminalReason === 'failed') return true;
|
||
if (run.terminalReason === 'completed' || run.terminalReason === 'aborted') return false;
|
||
}
|
||
if (final) return final.status === 'error' || final.stopReason === 'error';
|
||
return items.some(processItemIsFailed);
|
||
}
|
||
|
||
function runIsActive(run: ConversationRunState): boolean {
|
||
return ['preparing', 'queued', 'running', 'retrying', 'compacting', 'aborting'].includes(run.status);
|
||
}
|
||
|
||
function settledDurationLabel(run: ConversationRunState): string | null {
|
||
if (run.startedAt === undefined || run.settledAt === undefined || run.settledAt < run.startedAt) return null;
|
||
const seconds = Math.max(1, Math.round((run.settledAt - run.startedAt) / 1_000));
|
||
if (seconds < 60) return `${seconds} 秒`;
|
||
const minutes = Math.floor(seconds / 60);
|
||
const remaining = seconds % 60;
|
||
return remaining === 0 ? `${minutes} 分钟` : `${minutes} 分 ${remaining} 秒`;
|
||
}
|
||
|
||
function runningDurationLabel(run: ConversationRunState, now: number): string | null {
|
||
if (run.startedAt === undefined || run.startedAt < 946_684_800_000 || now < run.startedAt) return null;
|
||
return settledDurationLabel({ ...run, settledAt: now });
|
||
}
|
||
|
||
const ProcessGroup = memo(function ProcessGroup({
|
||
items,
|
||
run,
|
||
latest,
|
||
final,
|
||
}: {
|
||
items: ProcessItem[];
|
||
run: ConversationRunState;
|
||
latest: boolean;
|
||
final?: ConversationMessageNode;
|
||
}) {
|
||
const [expanded, setExpanded] = useState(false);
|
||
const authoritativelySettled = (run.status === 'idle' && run.settledAt !== undefined)
|
||
|| run.status === 'error';
|
||
const active = latest
|
||
&& !authoritativelySettled
|
||
&& (runIsActive(run) || items.some(processItemIsActive));
|
||
const failed = processGroupIsFailed(items, run, latest, final);
|
||
const [now, setNow] = useState(() => Date.now());
|
||
useEffect(() => {
|
||
if (!active || run.startedAt === undefined) return;
|
||
const timer = window.setInterval(() => setNow(Date.now()), 1_000);
|
||
return () => window.clearInterval(timer);
|
||
}, [active, run.startedAt]);
|
||
const duration = latest
|
||
? active
|
||
? runningDurationLabel(run, now)
|
||
: settledDurationLabel(run)
|
||
: null;
|
||
const summary = active
|
||
? `处理中${duration ? ` ${duration}` : ''}`
|
||
: failed
|
||
? `处理失败${duration ? ` · ${duration}` : ''}`
|
||
: `已处理${duration ? ` ${duration}` : ''}`;
|
||
|
||
return (
|
||
<details
|
||
data-testid="coding-process-group"
|
||
data-process-state={active ? 'active' : 'settled'}
|
||
className="group/process"
|
||
open={active || expanded}
|
||
onToggle={(event) => {
|
||
if (!active) setExpanded(event.currentTarget.open);
|
||
}}
|
||
>
|
||
<summary
|
||
className="flex min-h-8 cursor-pointer list-none items-center gap-2 border-b border-border/70 px-1.5 pb-2 text-[13px] text-muted-foreground hover:text-foreground"
|
||
onClick={(event) => {
|
||
if (active) event.preventDefault();
|
||
}}
|
||
>
|
||
{active
|
||
? <LoaderCircle className="h-3.5 w-3.5 shrink-0 animate-spin text-brand" aria-hidden="true" />
|
||
: failed
|
||
? <CircleAlert className="h-3.5 w-3.5 shrink-0 text-destructive" aria-hidden="true" />
|
||
: <CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden="true" />}
|
||
<span className={cn(failed && !active && 'text-foreground')}>{summary}</span>
|
||
{!active && <ChevronDown className="h-3.5 w-3.5 transition-transform duration-150 group-open/process:rotate-180" aria-hidden="true" />}
|
||
</summary>
|
||
<div className="mt-2 space-y-1">
|
||
{items.map((item) => (
|
||
<ProcessItemView key={item.id} item={item} processActive={active} />
|
||
))}
|
||
</div>
|
||
</details>
|
||
);
|
||
});
|
||
|
||
function finalAssistant(nodes: ConversationNode[]): ConversationMessageNode | undefined {
|
||
for (let index = nodes.length - 1; index >= 0; index -= 1) {
|
||
const node = nodes[index];
|
||
if (node?.kind === 'message'
|
||
&& node.role === 'assistant'
|
||
&& node.stopReason !== 'tool-use'
|
||
&& hasVisibleAnswer(node)) {
|
||
const hasDownstreamWork = nodes.slice(index + 1).some((candidate) => (
|
||
candidate.kind === 'tool'
|
||
|| candidate.kind === 'compaction'
|
||
|| candidate.kind === 'subagent'
|
||
|| (candidate.kind === 'boundary' && candidate.boundary === 'retry')
|
||
));
|
||
if (node.stopReason !== undefined || !hasDownstreamWork) return node;
|
||
}
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
function processItems(nodes: ConversationNode[], finalId?: string): ProcessItem[] {
|
||
const items: ProcessItem[] = [];
|
||
for (const node of nodes) {
|
||
if (node.kind === 'message') {
|
||
if (node.role === 'user') continue;
|
||
for (const block of node.blocks) {
|
||
if (block.kind === 'thinking') {
|
||
items.push({ kind: 'thinking', id: `${node.id}:${block.id}:thinking`, block });
|
||
}
|
||
}
|
||
if (node.id !== finalId) {
|
||
const blocks = answerBlocks(node);
|
||
if (blocks.length > 0 || node.status === 'error' || node.status === 'aborted') {
|
||
items.push({ kind: 'assistant-commentary', id: `${node.id}:commentary`, node, blocks });
|
||
}
|
||
}
|
||
continue;
|
||
}
|
||
if (node.kind === 'boundary') {
|
||
if (node.boundary === 'retry') items.push({ kind: 'retry', id: node.id, node });
|
||
continue;
|
||
}
|
||
if (node.kind === 'tool') {
|
||
if (node.toolName !== 'changed_file') items.push({ kind: 'tool', id: node.id, node });
|
||
continue;
|
||
}
|
||
if (node.kind === 'compaction') items.push({ kind: 'compaction', id: node.id, node });
|
||
else if (node.kind === 'subagent') items.push({ kind: 'subagent', id: node.id, node });
|
||
else items.push({ kind: 'notice', id: node.id, node });
|
||
}
|
||
return items;
|
||
}
|
||
|
||
function timelineTurns(nodes: ConversationNode[]): TimelineTurn[] {
|
||
const groups: ConversationNode[][] = [];
|
||
let current: ConversationNode[] = [];
|
||
const flush = () => {
|
||
if (current.length > 0) groups.push(current);
|
||
current = [];
|
||
};
|
||
|
||
for (const node of nodes) {
|
||
if (node.kind === 'message' && node.role === 'user') flush();
|
||
current.push(node);
|
||
}
|
||
flush();
|
||
|
||
return groups.map((group, index) => {
|
||
const user = group[0]?.kind === 'message' && group[0].role === 'user' ? group[0] : undefined;
|
||
const remaining = user ? group.slice(1) : group;
|
||
const final = finalAssistant(remaining);
|
||
return {
|
||
id: user?.id ?? remaining[0]?.id ?? `turn-${index}`,
|
||
...(user ? { user } : {}),
|
||
process: processItems(remaining, final?.id),
|
||
...(final ? { final } : {}),
|
||
};
|
||
});
|
||
}
|
||
|
||
const TimelineTurnView = memo(function TimelineTurnView({
|
||
turn,
|
||
run,
|
||
latest,
|
||
onFork,
|
||
assistantName,
|
||
}: {
|
||
turn: TimelineTurn;
|
||
run: ConversationRunState;
|
||
latest: boolean;
|
||
onFork?(sourceEntryId: string): void;
|
||
assistantName?: string;
|
||
}) {
|
||
return (
|
||
<section className="space-y-3.5" data-testid="coding-conversation-turn">
|
||
{turn.user && <MessageNode node={turn.user} onFork={onFork} />}
|
||
{turn.process.length > 0 && (
|
||
<ProcessGroup items={turn.process} run={run} latest={latest} final={turn.final} />
|
||
)}
|
||
{turn.final && (
|
||
<MessageNode
|
||
node={turn.final}
|
||
assistantName={assistantName}
|
||
/>
|
||
)}
|
||
</section>
|
||
);
|
||
});
|
||
|
||
export const CodingConversationTimeline = memo(function CodingConversationTimeline({
|
||
conversationId,
|
||
onFork,
|
||
assistantName,
|
||
}: {
|
||
conversationId: string;
|
||
onFork?(sourceEntryId: string): void;
|
||
assistantName?: string;
|
||
}) {
|
||
const selectNodes = useCallback((state: CodingConversationStoreState) => (
|
||
selectCodingConversationSnapshot(conversationId)(state)?.nodes ?? EMPTY_NODES
|
||
), [conversationId]);
|
||
const selectRun = useCallback((state: CodingConversationStoreState) => (
|
||
selectCodingConversationSnapshot(conversationId)(state)?.run ?? IDLE_RUN
|
||
), [conversationId]);
|
||
const nodes = useCodingConversationStore(selectNodes);
|
||
const run = useCodingConversationStore(selectRun);
|
||
const [visibleLimit, setVisibleLimit] = useState(INITIAL_WINDOW);
|
||
const windowStart = Math.max(0, nodes.length - visibleLimit);
|
||
const visibleNodes = nodes.slice(windowStart);
|
||
const turns = useMemo(() => timelineTurns(visibleNodes), [visibleNodes]);
|
||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||
const stickToBottomRef = useRef(true);
|
||
|
||
useLayoutEffect(() => {
|
||
if (!stickToBottomRef.current) return;
|
||
const element = scrollRef.current;
|
||
if (element) element.scrollTop = element.scrollHeight;
|
||
}, [nodes]);
|
||
|
||
return (
|
||
<div
|
||
ref={scrollRef}
|
||
role="log"
|
||
aria-live="polite"
|
||
data-testid="coding-conversation-timeline"
|
||
className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-3 py-5 sm:px-3 sm:py-6"
|
||
onScroll={(event) => {
|
||
const element = event.currentTarget;
|
||
stickToBottomRef.current = element.scrollHeight - element.scrollTop - element.clientHeight < 64;
|
||
}}
|
||
>
|
||
<div className="mx-auto flex w-full max-w-[50rem] flex-col gap-8">
|
||
{windowStart > 0 && (
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
className="mx-auto min-h-9 rounded-lg px-4 text-xs transition-transform duration-150 ease-out active:scale-[0.96]"
|
||
onClick={() => setVisibleLimit((current) => current + WINDOW_STEP)}
|
||
>
|
||
加载更早内容
|
||
</Button>
|
||
)}
|
||
{turns.map((turn, index) => (
|
||
<TimelineTurnView
|
||
key={turn.id}
|
||
turn={turn}
|
||
run={run}
|
||
latest={index === turns.length - 1}
|
||
onFork={onFork}
|
||
assistantName={assistantName}
|
||
/>
|
||
))}
|
||
{nodes.length === 0 && (
|
||
<div className="mx-auto flex max-w-md flex-col items-center px-6 py-16 text-center">
|
||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-surface-subtle">
|
||
<Bot className="h-4.5 w-4.5 text-muted-foreground" aria-hidden="true" />
|
||
</div>
|
||
<h2 className="mt-4 text-balance text-base font-semibold">开始一条对话</h2>
|
||
<p className="mt-1.5 text-pretty text-sm leading-6 text-muted-foreground">
|
||
Pi 会连续展示思考和工具进度,处理完成后自动收纳过程并给出结论。
|
||
</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
});
|