1710 lines
64 KiB
TypeScript
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>
|
|
);
|
|
});
|