Files
makelore/src/pages/Chat/CodingConversationTimeline.tsx
T

1710 lines
64 KiB
TypeScript

import { OPEN_WORK_PROMPT } from '../../../shared/coding-work-preview';
import { CONTINUE_CODING_PROMPT, type CodingRecoveryAction } from '../../../shared/coding-recovery';
import { CodingRecoveryCard } from './CodingRecoveryCard';
import {
memo,
type ReactNode,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import {
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';
import { CodingWelcomeHero } from './CodingWelcomeHero';
import { GameAudioFiles } from './GameAudioFiles';
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 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 CapabilityToolDetails = Extract<KnownToolDetails, { schema: 'makelore-capability.v1' }>;
function capabilityBillingLabel(billing: CapabilityToolDetails['billing']): string {
switch (billing.status) {
case 'not_started': return '尚未开始计费';
case 'included': return '已包含';
case 'external': return '由外部账户结算';
case 'reserved': return `已预留 ${billing.reserved_points} Token Point`;
case 'dispatched': return `处理中 · 已预留 ${billing.reserved_points} Token Point`;
case 'released': return '预留已释放,未收费';
case 'pending_review': return '账单待审核';
case 'expired': return '预留已过期';
case 'settled': return `已结算 ${billing.actual_points} Token Point`;
case 'refunded': return `已退款 ${billing.actual_points} Token Point`;
case 'receipt_unavailable': return '收费状态未同步,请勿重复发起';
}
}
function gameResourceProgressLabel(details: CapabilityToolDetails): string | null {
if (!['makelore.game-resource', 'makelore.game-audio'].includes(details.plugin_id)
|| !['generate', 'preview'].includes(details.operation)
|| !details.data || typeof details.data !== 'object' || Array.isArray(details.data)) return null;
const data = details.data as Record<string, unknown>;
const label = details.plugin_id === 'makelore.game-audio' ? '游戏音频' : '游戏资源';
const phase = typeof data.phase === 'string' ? data.phase : null;
const deliveryStatus = typeof data.deliveryStatus === 'string' ? data.deliveryStatus : null;
const providerStatus = typeof data.providerStatus === 'string' ? data.providerStatus : null;
const outputCount = Number.isSafeInteger(data.outputCount) ? data.outputCount as number : 0;
if (phase === 'saved' || deliveryStatus === 'saved') {
return `${label} · 已保存 ${outputCount} 个文件`;
}
if (phase === 'saving' || deliveryStatus === 'saving') return label + ' · 正在保存到项目';
if (deliveryStatus === 'delivery_failed') return label + ' · 自动保存失败,等待恢复';
if (deliveryStatus === 'needs_reconciliation') return label + ' · 等待原任务状态恢复,请勿重复生成';
if (phase === 'submitted') return label + ' · 已提交';
if (phase === 'generating' || ['reserved', 'accepted', 'running'].includes(providerStatus ?? '')) {
return label + ' · 正在生成';
}
if (providerStatus === 'pending_review') return label + ' · 账单待审核';
if (providerStatus === 'submission_unknown') return label + ' · 提交状态待确认';
if (providerStatus === 'cancelled') return label + ' · 已取消';
if (providerStatus === 'failed') return label + ' · 生成失败';
return null;
}
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]);
if (user && (answerText === OPEN_WORK_PROMPT || answerText === CONTINUE_CODING_PROMPT)) {
return null;
}
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>
);
}
if (details.schema === 'makelore-capability.v1') {
const gameResourceProgress = gameResourceProgressLabel(details);
return (
<div className="rounded-xl bg-foreground/[0.035] p-3 text-xs">
<p className="font-semibold">
{gameResourceProgress ?? `插件能力 · ${details.operation}`}
</p>
<p className="mt-1 text-muted-foreground">
{details.success ? '成功' : details.error ?? '请求失败'} · HTTP {details.status}
</p>
<p className="mt-1 text-muted-foreground">{capabilityBillingLabel(details.billing)}</p>
{details.plugin_id === 'makelore.game-audio' && <GameAudioFiles data={details.data} />}
</div>
);
}
if (details.schema === 'makelore-model-tool.v1') {
return (
<div className="rounded-xl bg-foreground/[0.035] p-3 text-xs">
<p className="font-semibold">模型联网搜索 · {details.modelId}</p>
{details.status === 'succeeded' ? (
<p className="mt-1 text-muted-foreground">
{details.sources.length > 0
? `已返回 ${details.sources.length} 个来源`
: '提供方未返回结构化来源'}
</p>
) : (
<p className="mt-1 text-destructive">{details.error.code} · {details.error.message}</p>
)}
</div>
);
}
if (details.schema === 'makelore-device-package.v1') {
const subject = details.preview?.displayName
?? details.index?.packages.at(-1)?.displayName
?? '本机包';
return (
<div className="rounded-xl bg-foreground/[0.035] p-3 text-xs">
<p className="font-semibold">本机插件 · {subject}</p>
<p className="mt-1 text-muted-foreground">
{details.operation === 'prepare'
? `等待确认${details.preview?.includesExecutableCode ? ' · 包含可执行扩展' : ''}`
: `操作完成 · 共 ${details.index?.packages.length ?? 0} 个本机包`}
</p>
</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 firstProgressLine(value: string): string | null {
const lines = value
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
const line = lines
.at(0)
?.replace(/\s+/g, ' ')
.replace(/^#{1,6}\s+/, '')
.replace(/^(?:[-*+]|\d+[.)])\s+/, '');
if (!line) return null;
return line.length > PROGRESS_PREVIEW_MAX_CHARS
? `${line.slice(0, 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'}:${text}`;
const parts = progressInlineParts(text);
useLayoutEffect(() => {
const viewport = viewportRef.current;
if (!viewport) return;
viewport.scrollLeft = 0;
}, [contentVersion, text]);
useEffect(() => {
const viewport = viewportRef.current;
if (!viewport || typeof ResizeObserver === 'undefined') return;
const observer = new ResizeObserver(() => {
viewport.scrollLeft = 0;
});
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-origin="head"
data-progress-update-motion={rollUpdates ? 'roll' : 'none'}
data-progress-shimmer={active && shimmer ? 'true' : 'false'}
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 = 0; index < blocks.length; index += 1) {
const block = blocks[index];
if (!block) continue;
if (block.kind === 'image') return '图片附件';
const preview = firstProgressLine(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} 条命令`;
}
if (details.schema === 'makelore-capability.v1') {
const gameResourceProgress = gameResourceProgressLabel(details);
if (gameResourceProgress) return gameResourceProgress;
return details.success
? `${details.operation} · 成功`
: `${details.operation} · ${details.error ?? '请求失败'}`;
}
if (details.schema === 'makelore-model-tool.v1') {
return details.status === 'succeeded'
? `联网搜索 · ${details.sources.length} 个来源`
: `联网搜索 · ${details.error.code}`;
}
if (details.schema === 'makelore-device-package.v1') {
return details.operation === 'prepare'
? `本机插件 · 等待确认安装 ${details.preview?.displayName ?? ''}`.trim()
: `本机插件 · ${details.operation}`;
}
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 {
if (node.details?.schema === 'makelore-capability.v1') {
const gameResourceProgress = gameResourceProgressLabel(node.details);
if (gameResourceProgress) return gameResourceProgress;
}
for (let index = 0; index < node.output.length; index += 1) {
const block = node.output[index];
if (!block) continue;
if (block.kind === 'image') return '已生成图片附件';
const outputPreview = firstProgressLine(block.text);
if (outputPreview) return outputPreview;
}
return firstProgressLine(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={firstProgressLine(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);
// The turn-level recovery card carries the next action; an empty error adds no useful detail.
if (blocks.length === 0 && node.status === 'error') return null;
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 === '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 persistedProviderFailureMessage(items: ProcessItem[]): string | undefined {
for (let index = items.length - 1; index >= 0; index -= 1) {
const item = items[index];
if (item?.kind === 'notice'
&& item.node.level === 'error'
&& (item.node.code === 'CODING_PROVIDER_AUTH_REQUIRED'
|| item.node.code === 'CODING_PROVIDER_QUOTA_EXHAUSTED')) {
return item.node.message;
}
}
return undefined;
}
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 failureMessage = failed
? (latest ? run.error?.message : undefined) ?? persistedProviderFailureMessage(items)
: undefined;
const summary = active
? `处理中${duration ? ` ${duration}` : ''}`
: failed
? `${failureMessage ?? (latest ? '这一步还没有完成' : '这一步当时没有完成')}${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,
onRecovery,
workReady,
}: {
turn: TimelineTurn;
run: ConversationRunState;
latest: boolean;
onFork?(sourceEntryId: string): void;
assistantName?: string;
onRecovery?(action: CodingRecoveryAction, work: boolean): Promise<void> | void;
workReady?: boolean;
}) {
const work = turn.user?.blocks.some((block) => block.kind === 'text' && block.text === OPEN_WORK_PROMPT) ?? false;
const failed = processGroupIsFailed(turn.process, run, latest, turn.final) && !runIsActive(run);
const resolvedWork = work && workReady;
const persistedError = [...turn.process].reverse().find((item) => item.kind === 'notice' && item.node.level === 'error');
const code = run.error?.code ?? (persistedError?.kind === 'notice' ? persistedError.node.code : undefined);
return (
<section className="space-y-3.5" data-testid="coding-conversation-turn">
{turn.user && <MessageNode node={turn.user} onFork={onFork} />}
{turn.process.length > 0 && !resolvedWork && (
<ProcessGroup items={turn.process} run={run} latest={latest} final={turn.final} />
)}
{latest && failed && !resolvedWork && onRecovery && <CodingRecoveryCard key={`${turn.id}:${code}`} code={code} work={work} onRecovery={onRecovery} />}
{turn.final && (
<MessageNode
node={turn.final}
assistantName={assistantName}
/>
)}
</section>
);
});
export const CodingConversationTimeline = memo(function CodingConversationTimeline({
conversationId,
onFork,
assistantName,
onRecovery,
workReady,
}: {
conversationId: string;
onFork?(sourceEntryId: string): void;
assistantName?: string;
onRecovery?(action: CodingRecoveryAction, work: boolean): Promise<void> | void;
workReady?: boolean;
}) {
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 scrollRef = useRef<HTMLDivElement | null>(null);
const stickToBottomRef = useRef(true);
const prependAnchorRef = useRef<{ scrollHeight: number; scrollTop: number } | null>(null);
const windowStart = Math.max(0, nodes.length - visibleLimit);
const visibleNodes = nodes.slice(windowStart);
const turns = useMemo(() => timelineTurns(visibleNodes), [visibleNodes]);
const loadEarlier = useCallback(() => {
if (windowStart === 0 || prependAnchorRef.current) return;
const element = scrollRef.current;
if (!element) return;
prependAnchorRef.current = {
scrollHeight: element.scrollHeight,
scrollTop: element.scrollTop,
};
stickToBottomRef.current = false;
setVisibleLimit((current) => Math.min(nodes.length, current + WINDOW_STEP));
}, [nodes.length, windowStart]);
useLayoutEffect(() => {
const anchor = prependAnchorRef.current;
if (!anchor) return;
prependAnchorRef.current = null;
const element = scrollRef.current;
if (!element) return;
element.scrollTop = anchor.scrollTop + (element.scrollHeight - anchor.scrollHeight);
}, [visibleLimit]);
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;
if (element.scrollTop <= 48) loadEarlier();
}}
>
<div className={cn(
'mx-auto flex w-full max-w-[50rem] flex-col gap-8',
nodes.length === 0 && 'min-h-full',
)}>
{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={loadEarlier}
>
加载更早内容
</Button>
)}
{turns.map((turn, index) => (
<TimelineTurnView
key={turn.id}
turn={turn}
run={run}
latest={index === turns.length - 1}
onFork={onFork}
assistantName={assistantName}
onRecovery={onRecovery}
workReady={workReady}
/>
))}
{nodes.length === 0 && (
<CodingWelcomeHero
headingLevel="h2"
className="min-h-[18rem] flex-1 px-6 pb-12 pt-6 sm:pb-16"
/>
)}
</div>
</div>
);
});