feat(coding): complete PI conversation UI
This commit is contained in:
@@ -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)]">
|
||||
|
||||
Reference in New Issue
Block a user