feat(coding): complete PI conversation UI

This commit is contained in:
2026-08-24 08:59:16 +08:00
parent 2613d6530b
commit 863f005203
21 changed files with 2040 additions and 137 deletions

View File

@@ -1,7 +1,6 @@
import {
memo,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
@@ -13,13 +12,16 @@ import {
Brain,
ChevronDown,
CircleAlert,
FileImage,
GitFork,
Globe2,
Hammer,
Layers3,
ListChecks,
ShieldCheck,
UserRound,
Workflow,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { loadCodingAttachmentPreview } from '@/lib/coding-attachments';
import { cn } from '@/lib/utils';
import {
selectCodingConversationSnapshot,
@@ -33,13 +35,33 @@ import type {
ConversationMessageNode,
ConversationNode,
ConversationNoticeNode,
ConversationSubagentNode,
ConversationToolNode,
KnownToolDetails,
SubagentDetailsV1,
} from '../../../shared/coding-conversation-contracts';
import { CodingAttachmentPreview } from './CodingAttachmentPreview';
const EMPTY_NODES: ConversationNode[] = [];
const INITIAL_WINDOW = 120;
const WINDOW_STEP = 100;
const LONG_OUTPUT_CHARS = 4_000;
const STREAMING_THINKING_WINDOW_CHARS = 16_000;
const NODE_STATUS_LABELS: Record<string, string> = {
declared: '已声明',
waiting: '等待中',
running: '执行中',
complete: '已完成',
error: '失败',
aborted: '已中止',
queued: '排队中',
skipped: '已跳过',
};
function statusLabel(status: string): string {
return NODE_STATUS_LABELS[status] ?? status;
}
const MarkdownText = memo(function MarkdownText({ text }: { text: string }) {
return (
@@ -75,60 +97,6 @@ const MarkdownText = memo(function MarkdownText({ text }: { text: string }) {
);
});
const AttachmentImage = memo(function AttachmentImage({
attachmentId,
mime,
}: {
attachmentId: string;
mime: string;
}) {
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [failed, setFailed] = useState(false);
useEffect(() => {
let active = true;
let objectUrl: string | null = null;
void loadCodingAttachmentPreview(attachmentId)
.then((preview) => {
if (!active) return;
const bytes = Uint8Array.from(preview.bytes);
objectUrl = URL.createObjectURL(new Blob([bytes], { type: preview.mime }));
setPreviewUrl(objectUrl);
})
.catch(() => {
if (active) setFailed(true);
});
return () => {
active = false;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [attachmentId]);
return (
<figure
className="max-w-sm overflow-hidden rounded-2xl bg-foreground/[0.04] shadow-[0_0_0_1px_rgba(0,0,0,0.06)]"
data-attachment-id={attachmentId}
>
{previewUrl
? (
<img
src={previewUrl}
alt="对话图片附件"
className="max-h-80 w-full object-contain outline outline-1 -outline-offset-1 outline-black/10"
/>
)
: (
<div className="flex min-h-28 items-center justify-center gap-2 px-4 text-sm text-muted-foreground">
<FileImage className="h-5 w-5" aria-hidden="true" />
{failed ? '图片预览不可用' : '正在加载图片…'}
</div>
)}
<figcaption className="truncate px-3 py-2 font-mono text-[11px] text-muted-foreground">
{mime} · {attachmentId}
</figcaption>
</figure>
);
});
const ContentBlock = memo(function ContentBlock({
block,
markdown,
@@ -140,9 +108,14 @@ const ContentBlock = memo(function ContentBlock({
block.kind === 'thinking' && block.status === 'streaming',
);
if (block.kind === 'image') {
return <AttachmentImage attachmentId={block.attachmentId} mime={block.mime} />;
return <CodingAttachmentPreview attachmentId={block.attachmentId} mime={block.mime} />;
}
if (block.kind === 'thinking') {
const thinkingText = block.status === '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 (
<details
className="group rounded-xl bg-foreground/[0.035] px-3 py-2.5"
@@ -152,11 +125,13 @@ const ContentBlock = memo(function ContentBlock({
<summary className="flex min-h-10 cursor-pointer list-none items-center gap-2 text-xs font-semibold text-muted-foreground">
<Brain className="h-4 w-4" aria-hidden="true" />
思考过程
<span className="font-normal">{block.status === 'streaming' ? '生成中' : '已完成'}</span>
<ChevronDown className="ml-auto h-4 w-4 transition-transform duration-150 group-open:rotate-180" aria-hidden="true" />
</summary>
<p className="whitespace-pre-wrap break-words pb-1 text-pretty text-sm leading-6 text-muted-foreground">
{block.text}
{thinkingText}
</p>
{windowed && <p className="pb-1 text-xs text-muted-foreground">流式阶段仅显示最近内容,完成后展示完整思考。</p>}
</details>
);
}
@@ -166,13 +141,19 @@ const ContentBlock = memo(function ContentBlock({
return <MarkdownText text={block.text} />;
});
const MessageNode = memo(function MessageNode({ node }: { node: ConversationMessageNode }) {
const MessageNode = memo(function MessageNode({
node,
onFork,
}: {
node: ConversationMessageNode;
onFork?(sourceEntryId: string): void;
}) {
const user = node.role === 'user';
return (
<article
data-node-id={node.id}
data-node-kind="message"
className={cn('flex gap-3', user && 'justify-end')}
className={cn('group/message flex gap-3', user && 'justify-end')}
>
{!user && (
<div className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-foreground text-background shadow-soft">
@@ -201,6 +182,25 @@ const MessageNode = memo(function MessageNode({ node }: { node: ConversationMess
本次提交未被接受,内容已保留在输入框。
</p>
)}
{node.status === 'aborted' && (
<p className={cn('text-xs', user ? 'text-background/70' : 'text-muted-foreground')}>
本轮已中止。
</p>
)}
{node.sourceEntryId && onFork && node.status !== 'optimistic' && (
<Button
type="button"
variant="ghost"
className={cn(
'min-h-10 rounded-xl px-3 text-xs transition-transform duration-150 ease-out active:scale-[0.96]',
user && 'text-background/80 hover:bg-background/10 hover:text-background',
)}
onClick={() => onFork(node.sourceEntryId!)}
>
<GitFork className="mr-1.5 h-3.5 w-3.5" aria-hidden="true" />
从这里创建新对话分支
</Button>
)}
</div>
{user && (
<div className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-surface-subtle text-foreground shadow-[0_0_0_1px_rgba(0,0,0,0.06)]">
@@ -211,12 +211,107 @@ const MessageNode = memo(function MessageNode({ node }: { node: ConversationMess
);
});
function subagentModeLabel(mode: SubagentDetailsV1['mode']): string {
if (mode === 'parallel') return '并行子任务';
if (mode === 'chain') return '串行子任务';
return '单个子任务';
}
const SubagentGraph = memo(function SubagentGraph({ details }: { details: SubagentDetailsV1 }) {
return (
<div className="rounded-xl bg-foreground/[0.035] p-3" data-subagent-mode={details.mode}>
<div className="flex items-center gap-2 text-xs font-semibold text-muted-foreground">
<Workflow className="h-4 w-4" aria-hidden="true" />
<span>{subagentModeLabel(details.mode)}</span>
<span className="ml-auto font-mono font-normal">{details.dispatchId}</span>
</div>
<div className={cn(
'mt-3 gap-2',
details.mode === 'parallel' ? 'grid sm:grid-cols-2' : 'flex flex-col',
)}>
{details.tasks.map((task, index) => (
<div key={task.taskId} className="relative rounded-xl bg-background p-3 shadow-[0_0_0_1px_rgba(0,0,0,0.06)]">
{details.mode === 'chain' && index > 0 && (
<span className="absolute -top-2 left-5 h-2 w-px bg-foreground/15" aria-hidden="true" />
)}
<div className="flex items-start gap-2">
<span className={cn(
'mt-1 h-2 w-2 shrink-0 rounded-full',
task.status === 'error' ? 'bg-destructive' : task.status === 'complete' ? 'bg-emerald-500' : 'bg-brand',
)} />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{task.agentId}</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{statusLabel(task.status)} · {task.toolProfile === 'coding' ? '可写代码' : '只读'}
</p>
{task.summary && <p className="mt-2 text-pretty text-xs leading-5">{task.summary}</p>}
{task.errorCode && <p className="mt-2 font-mono text-xs text-destructive">{task.errorCode}</p>}
</div>
</div>
</div>
))}
</div>
</div>
);
});
const ToolDetails = memo(function ToolDetails({ details }: { details: KnownToolDetails }) {
if (details.schema === 'changed-file.v1') {
return (
<div className="rounded-xl bg-foreground/[0.035] p-3 text-xs">
<p className="font-semibold">本轮变更文件</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="rounded-xl bg-foreground/[0.035] p-3 text-xs">
<p className="flex items-center gap-2 font-semibold"><ListChecks className="h-4 w-4" 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 rounded-xl bg-foreground/[0.035] p-3 text-xs"><ShieldCheck className="h-4 w-4" aria-hidden="true" />写入权限:{statusLabel(details.status)}</p>;
}
if (details.schema === 'agent-browser.v1') {
return (
<div className="space-y-2 rounded-xl bg-foreground/[0.035] p-3 text-xs">
<p className="flex items-center gap-2 font-semibold"><Globe2 className="h-4 w-4" 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="rounded-xl bg-foreground/[0.035] p-3 text-xs">
<p className="font-semibold">素材确认 · {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 rounded-xl bg-foreground/[0.035] p-3 text-xs sm:grid-cols-2">
<div><p className="font-semibold">技能</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-semibold">命令</p>{details.commands.map((command) => <p key={`${command.source}:${command.name}`} className="mt-1 truncate font-mono text-muted-foreground">/{command.name}</p>)}</div>
</div>
);
}
return <SubagentGraph details={details} />;
});
const ToolNode = memo(function ToolNode({ node }: { node: ConversationToolNode }) {
const outputChars = node.output.reduce((total, block) => (
total + (block.kind === 'image' ? 0 : block.text.length)
), 0);
const open = node.status === 'running' || node.status === 'waiting';
const [expanded, setExpanded] = useState(open);
const initiallyOpen = node.status === 'running' || node.status === 'waiting';
const [expanded, setExpanded] = useState(initiallyOpen);
return (
<details
data-node-id={node.id}
@@ -228,10 +323,11 @@ const ToolNode = memo(function ToolNode({ node }: { node: ConversationToolNode }
<summary className="flex min-h-10 cursor-pointer list-none items-center gap-2 rounded-xl px-2 text-sm font-medium hover:bg-surface-subtle">
<Hammer className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
<span className="min-w-0 flex-1 truncate">{node.title || node.toolName}</span>
<span className="shrink-0 text-xs text-muted-foreground">{node.status}</span>
<span className="shrink-0 text-xs text-muted-foreground">{statusLabel(node.status)}</span>
<ChevronDown className="h-4 w-4 text-muted-foreground transition-transform duration-150 group-open:rotate-180" aria-hidden="true" />
</summary>
<div className="space-y-3 px-2 pb-2 pt-1">
{node.details && <ToolDetails details={node.details} />}
{node.inputText && (
<pre className="max-h-48 overflow-auto whitespace-pre-wrap break-words rounded-xl bg-foreground/[0.035] p-3 font-mono text-xs leading-5 text-muted-foreground">
{node.inputText}
@@ -241,7 +337,7 @@ const ToolNode = memo(function ToolNode({ node }: { node: ConversationToolNode }
<ContentBlock key={block.id} block={block} markdown={false} />
))}
{outputChars > LONG_OUTPUT_CHARS && (
<p className="text-xs text-muted-foreground">长输出已收纳在工具卡片中。</p>
<p className="text-xs text-muted-foreground">长输出已收纳在当前工具卡片中,不会形成独立消息。</p>
)}
</div>
</details>
@@ -257,8 +353,13 @@ const CompactionNode = memo(function CompactionNodeView({ node }: { node: Conver
>
<Layers3 className="mt-0.5 h-4 w-4 shrink-0 text-brand" aria-hidden="true" />
<div className="min-w-0">
<p className="font-medium">上下文整理 · {node.status}</p>
<p className="font-medium">
上下文整理 · {node.status === 'running' ? '进行中' : node.status === 'complete' ? '已完成' : '失败'}
</p>
{node.summary && <p className="mt-1 text-pretty text-muted-foreground">{node.summary}</p>}
{node.status === 'error' && (
<p className="mt-1 text-muted-foreground">{node.willRetry ? '系统会自动重试。' : '本次整理已结束,不会自动重试。'}</p>
)}
</div>
</div>
);
@@ -268,8 +369,8 @@ const BoundaryNode = memo(function BoundaryNodeView({ node }: { node: Conversati
const label = node.boundary === 'turn-start'
? '开始新一轮'
: node.boundary === 'turn-end'
? '本轮完成'
: `正在重试${node.attempt ? ` · 第 ${node.attempt} 次` : ''}`;
? '本轮已结算'
: `正在重试${node.attempt ? ` · 第 ${node.attempt} 次` : ''}${node.delayMs ? ` · ${Math.ceil(node.delayMs / 1000)} 秒后` : ''}`;
return (
<div
data-node-id={node.id}
@@ -288,31 +389,46 @@ const NoticeNode = memo(function NoticeNodeView({ node }: { node: ConversationNo
<div
data-node-id={node.id}
data-node-kind="notice"
className="ml-12 flex items-start gap-2 rounded-xl bg-surface-subtle px-3 py-2.5 text-sm"
className={cn(
'ml-12 flex items-start gap-2 rounded-xl px-3 py-2.5 text-sm',
node.level === 'error' ? 'bg-destructive/5 text-destructive' : 'bg-surface-subtle',
)}
>
<CircleAlert className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
<CircleAlert className="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
<p className="text-pretty">{node.message}</p>
</div>
);
});
const TimelineNode = memo(function TimelineNode({ node }: { node: ConversationNode }) {
if (node.kind === 'message') return <MessageNode node={node} />;
const SubagentNode = memo(function SubagentNodeView({ node }: { node: ConversationSubagentNode }) {
return (
<section data-node-id={node.id} data-node-kind="subagent" className="ml-12 rounded-2xl bg-card p-3 shadow-[0_0_0_1px_rgba(0,0,0,0.06)]">
<SubagentGraph details={node.details} />
</section>
);
});
const TimelineNode = memo(function TimelineNode({
node,
onFork,
}: {
node: ConversationNode;
onFork?(sourceEntryId: string): void;
}) {
if (node.kind === 'message') return <MessageNode node={node} onFork={onFork} />;
if (node.kind === 'tool') return <ToolNode node={node} />;
if (node.kind === 'compaction') return <CompactionNode node={node} />;
if (node.kind === 'boundary') return <BoundaryNode node={node} />;
if (node.kind === 'notice') return <NoticeNode node={node} />;
return (
<div data-node-id={node.id} data-node-kind={node.kind} className="ml-12 rounded-xl bg-surface-subtle px-3 py-2 text-sm text-muted-foreground">
子任务执行状态将在后续功能面板中展开。
</div>
);
if (node.kind === 'subagent') return <SubagentNode node={node} />;
return <NoticeNode node={node} />;
});
export const CodingConversationTimeline = memo(function CodingConversationTimeline({
conversationId,
onFork,
}: {
conversationId: string;
onFork?(sourceEntryId: string): void;
}) {
const selectNodes = useCallback((state: CodingConversationStoreState) => (
selectCodingConversationSnapshot(conversationId)(state)?.nodes ?? EMPTY_NODES
@@ -352,7 +468,9 @@ export const CodingConversationTimeline = memo(function CodingConversationTimeli
加载更早内容
</Button>
)}
{nodes.slice(windowStart).map((node) => <TimelineNode key={node.id} node={node} />)}
{nodes.slice(windowStart).map((node) => (
<TimelineNode key={node.id} node={node} onFork={onFork} />
))}
{nodes.length === 0 && (
<div className="mx-auto flex max-w-md flex-col items-center px-6 py-16 text-center">
<div className="flex h-11 w-11 items-center justify-center rounded-2xl bg-surface-subtle shadow-[0_0_0_1px_rgba(0,0,0,0.06)]">