feat(agent): restructure task progress display in conversation flow to a collapsible format above the input area, ensuring real-time updates for pending, in-progress, and completed tasks

This commit is contained in:
HouYunFei
2026-07-29 13:36:34 +08:00
parent ed32e44e69
commit 0526290c8f
4 changed files with 35 additions and 18 deletions
@@ -126,16 +126,18 @@ export function AgentToolCard({ title, text, detail, theme }: { title: string; t
}
function AgentPlanCard({ title, plan, theme }: { title: string; plan: PlanDetail; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
const [open, setOpen] = useState(true);
const completed = plan.tasks.filter((item) => item.status === "completed").length;
const state = planCardState(plan, completed);
return (
<div className="min-w-0 flex-1 rounded-xl border px-3 py-3 text-left" style={{ borderColor: theme.node.stroke, background: "transparent", color: theme.node.text }}>
<div className="flex min-w-0 items-center gap-2.5">
<details open={open} onToggle={(event) => setOpen(event.currentTarget.open)} className="group min-w-0 flex-1 rounded-xl border px-3 py-2.5 text-left" style={{ borderColor: theme.node.stroke, background: "transparent", color: theme.node.text }}>
<summary className="flex min-w-0 cursor-pointer list-none items-center gap-2.5">
<ListChecks className="size-4 shrink-0" style={{ color: state.color }} />
<span className="min-w-0 flex-1 truncate text-sm font-medium">{title}</span>
<span className="shrink-0 text-[11px]" style={{ color: state.color }}>{state.label}</span>
<span className="shrink-0 text-[11px] tabular-nums" style={{ color: theme.node.muted }}>{completed}/{plan.tasks.length}</span>
</div>
<span aria-live="polite" className="shrink-0 text-[11px] tabular-nums" style={{ color: theme.node.muted }}>{completed}/{plan.tasks.length}</span>
<ChevronDown className="size-3.5 shrink-0 transition-transform group-open:rotate-180" style={{ color: theme.node.muted }} />
</summary>
{plan.explanation ? <div className="mt-1.5 text-xs leading-5" style={{ color: theme.node.muted }}>{plan.explanation}</div> : null}
<div className="mt-2.5 space-y-2 border-t pt-2.5" style={{ borderColor: theme.node.stroke }}>
{plan.tasks.map((item, index) => {
@@ -149,7 +151,7 @@ function AgentPlanCard({ title, plan, theme }: { title: string; plan: PlanDetail
);
})}
</div>
</div>
</details>
);
}
@@ -17,7 +17,7 @@ import { useShallow } from "zustand/react/shallow";
import { useAgentStore, type AgentAttachment, type AgentCanvasContext, type AgentChatItem, type AgentEventLog, type AgentPanelTab, type AgentPendingToolCall, type AgentThreadSummary, type AgentTokenUsage } from "@/stores/use-agent-store";
import { summarizeCanvasAgentOps, type CanvasAgentOp, type CanvasAgentSnapshot } from "@/lib/canvas/canvas-agent-ops";
import { isSiteTool, runSiteTool, SITE_TOOL_LABELS } from "@/lib/agent/agent-site-tools";
import { AgentChatComposer, AgentChatMessage, AgentPanelTabs, AgentPendingToolCard, AgentWorkingMessage, type CanvasAgentChatAttachment } from "./canvas-agent-chat-ui";
import { AgentChatComposer, AgentChatMessage, AgentPanelTabs, AgentPendingToolCard, AgentToolCard, AgentWorkingMessage, type CanvasAgentChatAttachment } from "./canvas-agent-chat-ui";
const MAX_ATTACHMENTS = 6;
const MAX_ATTACHMENT_PAYLOAD_BYTES = 28 * 1024 * 1024;
@@ -786,6 +786,7 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
) : (
<>
<AgentChatTimeline theme={theme} pendingTool={pendingTool} sending={sending} waiting={waiting} onRejectTool={rejectPendingTool} onApproveTool={approvePendingTool} />
<AgentTaskProgress theme={theme} busy={sending || waiting} />
{tokenUsage ? <AgentUsageBar usage={tokenUsage} theme={theme} /> : null}
<AgentChatComposer
prompt={prompt}
@@ -837,7 +838,6 @@ function AgentChatTimeline({
const followMessagesRef = useRef(true);
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
const streaming = messages.some((message) => message.streamId);
const activePlan = sending || waiting ? currentPlanMessage(messages) : undefined;
const working = workingActivity(messages.at(-1));
const updateScrollState = useCallback(() => {
const list = listRef.current;
@@ -858,15 +858,10 @@ function AgentChatTimeline({
return () => cancelAnimationFrame(frame);
}, [messages, pendingTool, scrollToBottom, updateScrollState, waiting]);
return (
<div className="relative flex min-h-0 flex-1 flex-col">
{activePlan ? (
<div className="relative z-10 shrink-0 px-4 pb-2 pt-4">
<AgentChatMessageRow item={activePlan} theme={theme} user={user} />
</div>
) : null}
<div ref={listRef} className={`thin-scrollbar min-h-0 flex-1 space-y-4 overflow-y-auto px-4 pb-12 ${activePlan ? "pt-2" : "pt-4"}`} onScroll={updateScrollState}>
<div className="relative min-h-0 flex-1">
<div ref={listRef} className="thin-scrollbar h-full space-y-4 overflow-y-auto px-4 pb-12 pt-4" onScroll={updateScrollState}>
{messages.map((item) => (
item.id === activePlan?.id ? null : <AgentChatMessageRow key={item.id} item={item} theme={theme} user={user} />
isPlanMessage(item) ? null : <AgentChatMessageRow key={item.id} item={item} theme={theme} user={user} />
))}
{pendingTool ? (
<AgentPendingToolCard
@@ -896,6 +891,16 @@ function AgentChatTimeline({
);
}
function AgentTaskProgress({ theme, busy }: { theme: (typeof canvasThemes)[keyof typeof canvasThemes]; busy: boolean }) {
const plan = useAgentStore((state) => busy ? currentPlanMessage(state.messages) : latestPlanMessage(state.messages));
if (!plan) return null;
return (
<div className="shrink-0 px-4 pt-2">
<AgentToolCard key={plan.id} title={plan.title || "任务进度"} text={plan.text} detail={plan.detail} theme={theme} />
</div>
);
}
const AgentChatMessageRow = memo(function AgentChatMessageRow({ item, theme, user }: { item: AgentChatItem; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; user: LocalUser | null }) {
return (
<div style={{ contentVisibility: "auto", containIntrinsicSize: "0 80px" }}>
@@ -1621,11 +1626,21 @@ function workingActivity(item?: AgentChatItem) {
function currentPlanMessage(messages: AgentChatItem[]) {
for (let index = messages.length - 1; index >= 0; index--) {
const message = messages[index];
if (message.role === "tool" && objectField(message.detail, "kind") === "todo") return message;
if (isPlanMessage(message)) return message;
if (message.role === "user") return;
}
}
function latestPlanMessage(messages: AgentChatItem[]) {
for (let index = messages.length - 1; index >= 0; index--) {
if (isPlanMessage(messages[index])) return messages[index];
}
}
function isPlanMessage(message: AgentChatItem) {
return message.role === "tool" && objectField(message.detail, "kind") === "todo";
}
function parseToolResult(result: unknown) {
const content = objectField(result, "content");
const text = Array.isArray(content)