From 164e89253aa4d3e90f90eaca7706a97d506e6c51 Mon Sep 17 00:00:00 2001 From: mesalogo Date: Sat, 19 Sep 2026 01:39:17 +0800 Subject: [PATCH] fix: reduce repeated rendering during subagent progress Reuse unchanged task and activity state, preserve activity nodes across reordering, and isolate historical tool rendering. Add regression coverage for progress, terminal states, copying, and component reuse. File-level snapshot also includes existing runtime checklist and remote Continue UI changes in the selected files. Their remaining dependencies are still uncommitted in the worktree; this commit is not a standalone validated build. Validation on the combined worktree: typecheck and lint passed; full tests had one packaging timeout that passed separately. Real-model UI runs verified parallel child tasks, progress, details, and persistence with existing internal-network certificate trust unchanged. Release note: Reduce unnecessary UI updates while subagents stream progress and preserve activity list state when records reorder. --- src/renderer/src/ActivityPanel.test.tsx | 87 ++++++++++- src/renderer/src/ActivityPanel.tsx | 8 +- src/renderer/src/App.test.tsx | 200 +++++++++++++++++++++++- src/renderer/src/App.tsx | 53 +++++-- src/renderer/src/ChatTimeline.test.tsx | 137 ++++++++++++++++ src/renderer/src/ChatTimeline.tsx | 158 +++++++++++-------- src/renderer/src/activity-store.test.ts | 68 ++++++++ src/renderer/src/activity-store.ts | 33 +++- 8 files changed, 645 insertions(+), 99 deletions(-) diff --git a/src/renderer/src/ActivityPanel.test.tsx b/src/renderer/src/ActivityPanel.test.tsx index e76d5fb8..446cb784 100644 --- a/src/renderer/src/ActivityPanel.test.tsx +++ b/src/renderer/src/ActivityPanel.test.tsx @@ -12,7 +12,7 @@ import { builtInDefaultProjectSeedName } from '../../shared/assistant-contracts' import { ActivityPanel } from './ActivityPanel' -import { type ActivityRecord } from './activity-store' +import { upsertActivityRecord, type ActivityRecord } from './activity-store' import i18n from './i18n' function makeRecord( @@ -221,6 +221,91 @@ describe('ActivityPanel', () => { ).not.toBeInTheDocument() }) + it('reuses expanded conversation cards when calls reorder and change', () => { + const first = { ...makeRecord(1), callId: 'call-1' } + const second = { + ...makeRecord(2), + conversationId: first.conversationId, + requestId: first.requestId, + callId: 'call-2' + } + const props = { + onClear: vi.fn(), + onOpenConversation: vi.fn(), + tokenUsage: makeTokenUsage() + } + const records = [second, first] + const { rerender } = render() + fireEvent.click(screen.getByText(`对话:${second.title}`)) + const firstCard = screen.getByText(first.title).closest('article')! + const secondCard = screen.getByText(second.title).closest('article')! + const group = firstCard.closest('details')! + const firstItem = firstCard.closest('li')! + const secondItem = secondCard.closest('li')! + + const reordered = upsertActivityRecord(records, { ...first }) + rerender() + expect(screen.getByText(first.title).closest('article')).toBe(firstCard) + expect(firstCard.closest('details')).toBe(group) + expect(group).toHaveAttribute('open') + expect(Array.from(group.querySelectorAll('li'))).toEqual([firstItem, secondItem]) + expect(screen.getByText(second.title).closest('article')).toBe(secondCard) + + rerender( + + ) + expect(screen.getByText('Updated detail').closest('article')).toBe(secondCard) + expect(secondCard).toHaveClass('activity-item--failed') + expect(group).toHaveAttribute('open') + expect(Array.from(group.querySelectorAll('li'))).toEqual([secondItem, firstItem]) + }) + + it('reuses selected timeline nodes when calls reorder and change', () => { + const first = { ...makeRecord(1), callId: 'call-1' } + const second = { + ...makeRecord(2), + conversationId: first.conversationId, + requestId: first.requestId, + callId: 'call-2' + } + const props = { + onClear: vi.fn(), + onOpenConversation: vi.fn(), + tokenUsage: makeTokenUsage() + } + const records = [second, first] + const { rerender } = render() + fireEvent.click(screen.getByRole('tab', { name: '活动时间线' })) + const node = screen.getByRole('button', { name: /活动 1,工具/u }) + fireEvent.click(node) + node.focus() + const detail = screen.getByLabelText('选中的活动节点详情') + + rerender( + + ) + expect(screen.getByRole('button', { name: /活动 1,工具/u })).toBe(node) + expect(node).toHaveFocus() + expect(node).toHaveAttribute('aria-pressed', 'true') + expect(node).toHaveClass('activity-track__node--failed') + expect(screen.getByLabelText('选中的活动节点详情')).toBe(detail) + expect(within(detail).getByText('Updated detail')).toBeInTheDocument() + }) + it('filters active and exceptional activity and opens its conversation', () => { const onOpenConversation = vi.fn() render( diff --git a/src/renderer/src/ActivityPanel.tsx b/src/renderer/src/ActivityPanel.tsx index 22e1454d..c5ef5929 100644 --- a/src/renderer/src/ActivityPanel.tsx +++ b/src/renderer/src/ActivityPanel.tsx @@ -785,10 +785,10 @@ export function ActivityPanel({
    - {conversation.records.map((record, index) => ( + {conversation.records.map((record) => (
  1. {renderRecordCard(record, false)}
  2. @@ -908,7 +908,7 @@ export function ActivityPanel({
    - {conversation.records.map((record, index) => { + {conversation.records.map((record) => { const nodeTime = formatTime( record.createdAt, dateTimeFormatter, @@ -932,7 +932,7 @@ export function ActivityPanel({ )} aria-pressed={selected} className={`activity-track__node activity-track__node--actor-${actor.kind} activity-track__node--${record.status}`} - key={`${record.id}-${index}`} + key={record.id} onClick={() => setSelectedTimelineRecordId( selected ? undefined : record.id diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index 26b5fc7a..ceff389c 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -42,6 +42,18 @@ const speechRecognitionMocks = vi.hoisted(() => ({ })); const messageRenderProbe = vi.hoisted(() => vi.fn()); +const assistantTasksProbe = vi.hoisted(() => vi.fn()); + +vi.mock("./use-unviewed-completions", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + useUnviewedCompletions: (...args: Parameters) => { + assistantTasksProbe(args[0]); + return original.useUnviewedCompletions(...args); + }, + }; +}); vi.mock("./MarkdownRenderer", async (importOriginal) => { const original = await importOriginal(); @@ -4854,6 +4866,92 @@ describe("App", () => { expect(screen.getByText("正在分析真实推理内容")).toBeVisible(); }); + it.each(["done", "cancelled", "failed"] as const)("keeps runtime checklists request-scoped and persisted after %s, including explicit clears", async (outcome) => { + render(); + const send = async (text: string, count: number) => { + fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { target: { value: text } }); + await waitFor(() => expect(screen.getByLabelText("发送")).toBeEnabled()); + fireEvent.click(screen.getByLabelText("发送")); + await waitFor(() => expect(run).toHaveBeenCalledTimes(count)); + return run.mock.calls[count - 1]![0].requestId; + }; + const requestId = await send("清单第一轮", 1); + const checklist = { source: "opencode" as const, items: [{ content: "尚未完成的工作", status: "pending" as const }] }; + act(() => agentListener?.({ requestId, type: "checklist", checklist })); + expect(screen.getByRole("button", { name: /执行清单/ })).toHaveTextContent("已完成 0/1"); + expect(screen.getByLabelText("停止生成")).toBeInTheDocument(); + act(() => agentListener?.({ requestId, type: "text", delta: "仍在正常输出" })); + act(() => agentListener?.(outcome === "done" + ? { requestId, type: "done" } + : { requestId, type: "error", status: outcome, message: outcome === "cancelled" ? "已取消" : "执行失败" })); + expect(screen.getByRole("button", { name: /执行清单/ })).toHaveTextContent("已完成 0/1"); + await waitFor(() => expect(api.conversations.saveLocal).toHaveBeenCalledWith(expect.arrayContaining([ + expect.objectContaining({ messages: expect.arrayContaining([expect.objectContaining({ runtimeChecklist: checklist, state: outcome === "done" ? "complete" : "error" })]) }) + ]))); + const nextId = await send("清单第二轮", 2); + expect(screen.queryByRole("button", { name: /执行清单/ })).not.toBeInTheDocument(); + act(() => agentListener?.({ requestId, type: "checklist", checklist })); + expect(screen.queryByRole("button", { name: /执行清单/ })).not.toBeInTheDocument(); + act(() => agentListener?.({ requestId: nextId, type: "checklist", checklist })); + expect(screen.getByRole("button", { name: /执行清单/ })).toBeVisible(); + const empty = { source: "opencode" as const, items: [] }; + act(() => agentListener?.({ requestId: nextId, type: "checklist", checklist: empty })); + expect(screen.queryByRole("button", { name: /执行清单/ })).not.toBeInTheDocument(); + act(() => agentListener?.({ requestId: nextId, type: "done" })); + await waitFor(() => expect(api.conversations.saveLocal).toHaveBeenCalledWith(expect.arrayContaining([ + expect.objectContaining({ messages: expect.arrayContaining([expect.objectContaining({ runtimeChecklist: empty, state: "complete" })]) }) + ]))); + }); + + it.each([false, true])("restores saved runtime checklists without a Task (remote=%s)", async (remote) => { + vi.mocked(api.conversations.list).mockResolvedValue([{ + id: "00000000-0000-4000-8000-000000000881", projectId: project.id, + title: "Saved checklist", updatedAt: Date.now(), + ...(remote ? { remote: { channel: "weixin" as const, accountDisplay: "sender", conversationType: "direct" as const } } : {}), + messages: [{ + id: "00000000-0000-4000-8000-000000000882", role: "assistant", content: "Saved reply", + createdAt: Date.now(), state: "error", status: "已取消", + runtimeChecklist: { source: "continue", items: [{ content: "Saved unfinished item", status: "pending" }] } + }] + }]); + render(); + const toggle = await screen.findByRole("button", { name: /执行清单/ }); + expect(toggle).toHaveTextContent("已完成 0/1"); + expect(toggle).toHaveTextContent("已取消"); + fireEvent.click(toggle); + expect(screen.getByText("Saved unfinished item")).toBeVisible(); + expect(document.querySelector(".conversation-task-strip")).toBeNull(); + expect(run).not.toHaveBeenCalled(); + }); + + it("does not revive a recovered checklist when a new local request starts", async () => { + const messageId = "00000000-0000-4000-8000-000000000883"; + vi.mocked(api.conversations.list).mockResolvedValue([{ + id: "00000000-0000-4000-8000-000000000884", projectId: project.id, + title: "Recovered checklist", updatedAt: Date.now(), + activeRequest: { requestId: "00000000-0000-4000-8000-000000000885", messageId, questions: [] }, + messages: [{ id: messageId, role: "assistant", content: "Previous result", createdAt: 1, state: "complete", + runtimeChecklist: { source: "opencode", items: [{ content: "Old unfinished item", status: "pending" }] } + }] + }]); + render(); + await screen.findByRole("button", { name: /执行清单/ }); + fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { target: { value: "New request" } }); + await waitFor(() => expect(screen.getByLabelText("发送")).toBeEnabled()); + fireEvent.click(screen.getByLabelText("发送")); + await waitFor(() => expect(run).toHaveBeenCalledOnce()); + expect(screen.queryByRole("button", { name: /执行清单/ })).not.toBeInTheDocument(); + const requestId = run.mock.calls[0]![0].requestId; + act(() => agentListener?.({ requestId, type: "checklist", checklist: { + source: "continue", items: [{ content: "Current request item", status: "pending" }] + } })); + fireEvent.click(screen.getByRole("button", { name: /执行清单/ })); + expect(screen.getByText("Current request item")).toBeVisible(); + expect(screen.queryByText("Old unfinished item")).not.toBeInTheDocument(); + act(() => agentListener?.({ requestId, type: "done" })); + expect(screen.getByText("Current request item")).toBeVisible(); + }); + it("keeps request status truthful through retry, progress, and completion", async () => { render(); fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { @@ -7520,7 +7618,7 @@ describe("App", () => { ); }); - it("switches immediately to a managed SSH project and shows only supported OpenCode choices", async () => { + it("switches immediately to a managed SSH project and preserves supported OC and CN choices", async () => { installRemoteProjectsSetting(true); const remoteProject = { ...project, @@ -7586,11 +7684,11 @@ describe("App", () => { name: /^OpenCode · 默认模型.*sonnet-5$/u, }), ).toBeInTheDocument(); - expect(within(runtimeMenu).getAllByRole("menuitemradio")).toHaveLength(1); + expect(within(runtimeMenu).getAllByRole("menuitemradio")).toHaveLength(2); expect(within(runtimeMenu).queryByText("直连模型")).not.toBeInTheDocument(); expect( within(runtimeMenu).queryByText("Continue Runtime"), - ).not.toBeInTheDocument(); + ).toBeInTheDocument(); expect( within(runtimeMenu).queryByText(/DeepSeek Harness/u), ).not.toBeInTheDocument(); @@ -7604,19 +7702,19 @@ describe("App", () => { fireEvent.click(screen.getByText("旧 Continue 会话").closest("button")!); expect( await screen.findByRole("button", { - name: /OpenCode · 默认模型/u, + name: /Continue ·/u, }), ).toBeInTheDocument(); }); - it("keeps an unselected SSH conversation inheriting the project's fixed model", async () => { + it.each(["opencode", "continue"] as const)("keeps an unselected SSH conversation inheriting the project's %s model", async (provider) => { installRemoteProjectsSetting(true); const remoteProject = { ...project, id: "00000000-0000-4000-8000-000000000119", name: "Fixed remote model", rootPath: "/srv/project", - runtimeSelection: { provider: "opencode" as const, profileId: modelProfileId }, + runtimeSelection: { provider, profileId: modelProfileId }, executionSpace: { kind: "ssh" as const, hostId: "00000000-0000-4000-8000-000000000219", @@ -11347,6 +11445,96 @@ describe("App", () => { ).not.toBeInTheDocument(); }); + it.each(["completed", "cancelled"] as const)( + "keeps subagent progress fresh with stable tasks and propagates %s", + async (state) => { + render(); + fireEvent.change(await screen.findByLabelText("向 GoodBuddy 提问"), { + target: { value: "Check streaming child progress" }, + }); + fireEvent.click(screen.getByLabelText("发送")); + await waitFor(() => expect(run).toHaveBeenCalledOnce()); + expect(agentListener).toBeTypeOf("function"); + const requestId = run.mock.calls[0]![0].requestId; + const child = { + requestId, + type: "subagent" as const, + childTaskId: "00000000-0000-4000-8000-000000000614", + expertId: "00000000-0000-4000-8000-000000000714", + expertName: "Streaming explorer", + routingMode: "native" as const, + runtimeCallId: "streaming-child-call", + state: "running" as const, + reason: "Inspect streaming updates", + }; + act(() => agentListener!({ + requestId, type: "tool", callId: child.runtimeCallId, + name: "Task", state: "running", summary: "Provisional child tool", + })); + act(() => agentListener!(child)); + const region = screen.getByLabelText("子代理状态"); + const card = within(region).getByRole("group"); + fireEvent.click(within(card).getByText(child.expertName, { selector: "strong" })); + fireEvent(card, new Event("toggle")); + const runningTasks = assistantTasksProbe.mock.lastCall![0] as AssistantTask[]; + expect(runningTasks).toContainEqual(expect.objectContaining({ + id: child.childTaskId, status: "running", + })); + + const progress = (step: number) => [{ + id: "child-progress", type: "text" as const, content: `Progress snapshot ${step}`, + }, { + id: "child-tool", type: "tool" as const, + tool: { callId: "child-read", name: "read", state: "running" as const, + summary: `Reading file ${step}` }, + }]; + for (const step of [1, 2, 3]) { + assistantTasksProbe.mockClear(); + act(() => agentListener!({ ...child, progress: progress(step) })); + const process = within(region).getByRole("region", { name: "执行过程" }); + expect(within(process).getByText(`Progress snapshot ${step}`)).toBeVisible(); + expect(within(process).getByText(`Reading file ${step}`, { selector: "span" })).toBeVisible(); + if (step > 1) { + expect(within(process).queryByText(`Progress snapshot ${step - 1}`)).not.toBeInTheDocument(); + expect(within(process).queryByText(`Reading file ${step - 1}`)).not.toBeInTheDocument(); + } + expect(assistantTasksProbe).toHaveBeenCalled(); + for (const [tasks] of assistantTasksProbe.mock.calls) { + expect(tasks).toBe(runningTasks); + } + } + + act(() => agentListener!({ ...child, state, progress: progress(3), output: "Latest child result" })); + const terminalTasks = assistantTasksProbe.mock.lastCall![0] as AssistantTask[]; + expect(terminalTasks).not.toBe(runningTasks); + expect(terminalTasks).toContainEqual(expect.objectContaining({ + id: child.childTaskId, status: state, completedAt: expect.any(String), + })); + expect(within(card).getByText(state === "completed" ? "已完成" : "已取消", { + selector: ".subagent-status-card__status", + })).toBeVisible(); + expect(within(region).getByRole("region", { name: "最终结果" })).toHaveTextContent("Latest child result"); + act(() => agentListener!(state === "completed" + ? { requestId, type: "done" } + : { requestId, type: "error", status: "cancelled", message: "已取消" })); + expect(screen.queryByLabelText("停止生成")).not.toBeInTheDocument(); + await waitFor(() => expect(api.conversations.saveLocal).toHaveBeenCalledWith( + expect.arrayContaining([expect.objectContaining({ + messages: expect.arrayContaining([expect.objectContaining({ + state: state === "completed" ? "complete" : "error", + subagents: expect.arrayContaining([expect.objectContaining({ + childTaskId: child.childTaskId, state, progress: progress(3), output: "Latest child result", + })]), + })]), + })]), + )); + fireEvent.click(screen.getByText("运行记录")); + expect(await screen.findAllByText("子专家")).toHaveLength(1); + expect(screen.queryByText("Provisional child tool")).not.toBeInTheDocument(); + expect(screen.getByText("Latest child result")).toBeInTheDocument(); + }, + ); + it("records direct-model child runs without adding Assistant Tasks", async () => { render(); fireEvent.change(await screen.findByLabelText("向 GoodBuddy 提问"), { diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 33f1c1f3..230c85c3 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -199,6 +199,7 @@ import { type CustomTaskDestination, } from "./CustomTaskDialog"; import { ConversationTaskStrip } from "./ConversationTaskStrip"; +import { RuntimeChecklistStrip } from "./RuntimeChecklistStrip"; import { ConversationInputQueue } from "./ConversationInputQueue"; import { OverflowMarquee } from "./OverflowMarquee"; import { findTaskSchedule } from "./TaskScheduleActions"; @@ -1393,6 +1394,7 @@ function toConversationMessage(message: Message): ConversationMessage { createdAt: message.createdAt, state: message.state, status: message.status, + runtimeChecklist: message.runtimeChecklist, contextCompression: message.contextCompression, contextCompressions: message.contextCompressions, tools: message.tools, @@ -1606,7 +1608,7 @@ function getProjectDefaultRuntimeSelection( settings: RuntimeSettings, ): AgentRuntimeSelection { if (isManagedSshProject(project)) { - return project.runtimeSelection?.provider === "opencode" + return project.runtimeSelection?.provider === "opencode" || project.runtimeSelection?.provider === "continue" ? project.runtimeSelection : getRuntimeSelectionForProvider("opencode", settings); } const selection = project?.runtimeSelection; @@ -4331,7 +4333,7 @@ function App(): React.JSX.Element { !runtimeSettings || !activeConversation || runtimeSwitching || - (activeProjectUsesManagedSsh && selection.provider !== "opencode") + (activeProjectUsesManagedSsh && selection.provider !== "opencode" && selection.provider !== "continue") ) { return; } @@ -4673,7 +4675,13 @@ function App(): React.JSX.Element { }); } - if (event.type === "text") { + if (event.type === "checklist") { + updateMessage(run.conversationId, run.messageId, (message) => + message.state === "streaming" + ? { ...message, runtimeChecklist: event.checklist } + : message, + ); + } else if (event.type === "text") { updateMessage(run.conversationId, run.messageId, (message) => { const blocks = appendMessageContentBlock( message.blocks, @@ -4884,6 +4892,14 @@ function App(): React.JSX.Element { completedAt: completedAt ?? existing?.completedAt, error: event.error, }; + if ( + existing && + (Object.keys(childTask) as (keyof AssistantTask)[]).every( + (key) => existing[key] === childTask[key], + ) + ) { + return current; + } return existing ? current.map((task) => task.id === event.childTaskId ? childTask : task, @@ -4892,16 +4908,17 @@ function App(): React.JSX.Element { }); } if (event.runtimeCallId) { - setActivityRecords((current) => - current.filter( + setActivityRecords((current) => { + const remaining = current.filter( (record) => !( record.requestId === event.requestId && record.kind === "tool" && record.callId === event.runtimeCallId ), - ), - ); + ); + return remaining.length === current.length ? current : remaining; + }); } recordActivity({ conversationId: run.conversationId, @@ -5056,7 +5073,7 @@ function App(): React.JSX.Element { sourceReferences: references, }; }); - } else { + } else if (event.type === "done" || event.type === "error") { const terminalStatus = event.type === "error" ? event.status === "cancelled" @@ -6596,7 +6613,8 @@ function App(): React.JSX.Element { current.map((candidate) => candidate.projectId === selected.id && candidate.runtimeSelection !== undefined && - candidate.runtimeSelection?.provider !== "opencode" + candidate.runtimeSelection?.provider !== "opencode" && + candidate.runtimeSelection?.provider !== "continue" ? { ...candidate, runtimeSelection, @@ -7675,6 +7693,7 @@ function App(): React.JSX.Element { ...conversation.messages.slice(-499), assistantMessage, ], + activeRequest: undefined, } : conversation, ), @@ -9760,7 +9779,8 @@ function App(): React.JSX.Element { quickActions={quickActions} scrollSnapshot={chatScrollSnapshots[conversation.id]} taskStrip={ - !conversation.remote ? ( +
    + {!conversation.remote ? ( - ) : undefined + ) : undefined} + +
    } visibleMessageCount={ visibleMessageCounts[conversation.id] ?? @@ -10575,8 +10600,7 @@ function App(): React.JSX.Element { )} - {!activeProjectUsesManagedSsh && ( - <> + <>
    )} + + {!activeProjectUsesManagedSsh && ( + <>
    vi.fn()) const htmlRenderProbe = vi.hoisted(() => vi.fn()) +const toolRowRenderProbe = vi.hoisted(() => vi.fn()) +const toolDetailRenderProbe = vi.hoisted(() => vi.fn()) + +vi.mock('lucide-react', async (importOriginal) => { + const icons = await importOriginal() + return { + ...icons, + Check: (props: ComponentProps) => { + toolRowRenderProbe() + return + }, + Copy: (props: ComponentProps) => { + toolDetailRenderProbe() + return + } + } +}) const stylesheet = readFileSync( join(process.cwd(), 'src', 'renderer', 'src', 'styles.css'), 'utf8' @@ -177,6 +195,125 @@ describe('ChatTimeline', () => { expect(inputCard.querySelector('pre')?.textContent).toBe('{"incomplete":') }) + it('isolates historical tool rows and unchanged details across cloned child progress snapshots', () => { + const props = { + artifactById: new Map(), conversationId: 'tools', + hiddenMessageCount: 0, isUnusedConversation: false, locale: 'zh-CN' as const, + messageStartIndex: 0, ...callbacks, totalMessageCount: 1 + } + let message: Message = { + id: 'child-progress', role: 'assistant', content: '', createdAt: 0, state: 'streaming', + subagents: [{ + childTaskId: 'child', expertId: 'expert', expertName: 'general', + routingMode: 'native', state: 'running', + progress: Array.from({ length: 12 }, (_, index) => ({ + id: `block-${index}`, type: 'tool', tool: { + callId: `call-${index}`, name: 'read', summary: `Read file ${index}`, + state: 'completed', input: `{"path":"file-${index}"}`, + output: `contents-${index}`, error: undefined + } + })) + }] + } + const parse = vi.spyOn(JSON, 'parse') + try { + const view = render() + const childCard = view.container.querySelector('.subagent-status-card')! + fireEvent.click(childCard.querySelector('summary')!) + fireEvent(childCard, new Event('toggle')) + const cards = view.container.querySelectorAll('.tool-execution') + expect(cards).toHaveLength(12) + const firstCard = cards[0]! + // Folded tool/input details stay mounted for the existing find-in-page path. + expect(cards[1]!.querySelectorAll('pre')).toHaveLength(2) + expect(cards[1]!.open).toBe(false) + fireEvent.click(firstCard.querySelector('summary')!) + const inputCard = firstCard.querySelector('.tool-execution__input')! + fireEvent.click(inputCard.querySelector('summary')!) + expect(toolRowRenderProbe).toHaveBeenCalledTimes(12) + expect(toolDetailRenderProbe).toHaveBeenCalledTimes(24) + expect(parse.mock.calls.filter(([value]) => value === '{"path":"file-0"}')).toHaveLength(1) + toolRowRenderProbe.mockClear() + toolDetailRenderProbe.mockClear() + parse.mockClear() + + for (let index = 0; index < 3; index += 1) { + message = structuredClone(message) + message.subagents![0]!.progress!.push({ id: `text-${index}`, type: 'text', content: `Progress ${index}` }) + view.rerender() + expect(screen.getByText(`Progress ${index}`)).toBeVisible() + } + expect(toolRowRenderProbe).not.toHaveBeenCalled() + expect(toolDetailRenderProbe).not.toHaveBeenCalled() + expect(parse.mock.calls.filter(([value]) => String(value).includes('file-'))).toHaveLength(0) + expect(view.container.querySelector('.tool-execution')).toBe(firstCard) + expect(firstCard.open).toBe(true) + expect(inputCard.open).toBe(true) + + message = structuredClone(message) + const block = message.subagents![0]!.progress![0]! + if (block.type !== 'tool') throw new Error('Expected tool progress') + block.tool.output = 'updated contents' + block.tool.name = 'read_file' + block.tool.summary = 'Updated summary' + view.rerender() + expect(toolRowRenderProbe).toHaveBeenCalledTimes(1) + expect(toolDetailRenderProbe).toHaveBeenCalledTimes(1) + expect(firstCard).toHaveTextContent('updated contents') + expect(firstCard.querySelector('strong')).toHaveTextContent('read_file') + expect(firstCard.querySelector('.tool-execution__full-summary')).toHaveTextContent('Updated summary') + expect(parse.mock.calls.filter(([value]) => value === block.tool.input)).toHaveLength(0) + + const onCopyMessage = vi.fn(async () => true) + view.rerender() + fireEvent.click(within(firstCard).getByRole('button', { name: '复制调用参数' })) + expect(onCopyMessage).toHaveBeenLastCalledWith('{"path":"file-0"}', 'tool') + fireEvent.click(within(firstCard).getByRole('button', { name: '复制执行结果' })) + expect(onCopyMessage).toHaveBeenLastCalledWith('updated contents', 'tool') + expect(callbacks.onCopyMessage).not.toHaveBeenCalled() + expect(parse.mock.calls.filter(([value]) => value === block.tool.input)).toHaveLength(0) + } finally { + parse.mockRestore() + } + }) + + it.each(['cancelled', 'failed', 'completed'] as const)( + 'updates pending child tools when the child becomes %s', (state) => { + const props = { + artifactById: new Map(), conversationId: 'tools', + hiddenMessageCount: 0, isUnusedConversation: false, locale: 'zh-CN' as const, + messageStartIndex: 0, ...callbacks, totalMessageCount: 1 + } + const message: Message = { + id: 'child', role: 'assistant', content: '', createdAt: 0, state: 'streaming', + subagents: [{ + childTaskId: 'child', expertId: 'expert', expertName: 'general', + routingMode: 'native', state: 'running', + progress: ['pending', 'running'].map((toolState, index) => ({ + id: `tool-${index}`, type: 'tool', tool: { + callId: `call-${index}`, name: 'read', summary: 'read', + state: toolState as 'pending' | 'running', input: '{"path":"file"}' + } + })) + }] + } + const view = render() + const childCard = view.container.querySelector('.subagent-status-card')! + fireEvent.click(childCard.querySelector('summary')!) + fireEvent(childCard, new Event('toggle')) + const cards = view.container.querySelectorAll('.tool-execution') + fireEvent.click(cards[0]!.querySelector('summary')!) + const next = structuredClone(message) + next.subagents![0]!.state = state + view.rerender() + for (const card of cards) { + expect(card).toHaveClass(`tool-execution--${state === 'cancelled' ? 'cancelled' : 'interrupted'}`) + } + expect(cards[0]).toHaveAttribute('open') + expect(childCard).toHaveAttribute('open') + } + ) + it('shows an image context limitation as a quiet persistent footer, not an alert', () => { const message: Message = { id: 'image-message', role: 'assistant', content: '', createdAt: Date.now(), diff --git a/src/renderer/src/ChatTimeline.tsx b/src/renderer/src/ChatTimeline.tsx index 49765f63..dac47d99 100644 --- a/src/renderer/src/ChatTimeline.tsx +++ b/src/renderer/src/ChatTimeline.tsx @@ -19,6 +19,7 @@ import { memo, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { ImageOperationStatus } from './ImageOperationStatus' import type { ImageOperation } from '../../shared/image-generation-contracts' +import type { RuntimeChecklist } from '../../shared/runtime-checklist' import type { ApprovalDecision, AgentEvent, @@ -59,6 +60,7 @@ export type Message = { createdAt: number state: 'streaming' | 'complete' | 'error' status?: string + runtimeChecklist?: RuntimeChecklist contextCompression?: ConversationMessage['contextCompression'] contextCompressions?: ConversationMessage['contextCompressions'] tools?: ToolActivity[] @@ -188,21 +190,23 @@ function MessageReasoning({ type CopyContent = (content: string, kind?: 'tool') => Promise -function ToolDetail({ label, content, formatJson = false, onCopy }: { +const ToolDetail = memo(function ToolDetail({ label, content, formatJson = false, onCopy }: { label: string content: string formatJson?: boolean onCopy: CopyContent }): React.JSX.Element { const { t } = useTranslation('app') - let display = content - if (formatJson) { - try { - display = JSON.stringify(JSON.parse(content), null, 2) - } catch { - // Truncated JSON and plain-text arguments remain readable as received. + const display = useMemo(() => { + if (formatJson) { + try { + return JSON.stringify(JSON.parse(content), null, 2) + } catch { + // Truncated JSON and plain-text arguments remain readable as received. + } } - } + return content + }, [content, formatJson]) return (
    @@ -218,7 +222,72 @@ function ToolDetail({ label, content, formatJson = false, onCopy }: {
    ) -} +}) + +const ToolExecutionRow = memo(function ToolExecutionRow({ onCopy, ...tool }: Pick< + ToolActivity, 'name' | 'summary' | 'state' | 'input' | 'output' | 'error' +> & { + onCopy: CopyContent +}): React.JSX.Element { + const { t } = useTranslation('app') + const summary = tool.summary.trim() + // Only suppress known boilerplate; recovery hints and custom summaries remain visible. + const summaryIdentity = summary.replace( + /^(?:(?:OpenCode|Continue|DeepSeek Harness|远端 Runtime) 工具|直连模型工具(?:已完成|执行失败)?|正在执行直连模型工具)\s*[::]\s*/u, + '' + ) + const hasSummary = summary.length > 0 && summaryIdentity !== tool.name.trim() + const StateIcon = tool.state === 'running' ? LoaderCircle + : tool.state === 'pending' ? Clock3 + : tool.state === 'completed' ? Check : XCircle + return ( +
  3. +
    + + +
    + {hasSummary &&

    {tool.summary}

    } + {tool.error && ( +
    + +
    + )} + {tool.output ? ( + + ) : !tool.error && ( +

    {t(tool.state === 'running' ? 'chat.tools.waitingOutput' + : tool.state === 'pending' ? 'chat.tools.waitingStart' + : tool.state === 'completed' ? 'chat.tools.emptyOutput' : 'chat.tools.noDetails')}

    + )} + {tool.input && ( +
    + {t('chat.tools.input')} + +
    + )} +
    +
    + {tool.error && ( +

    {tool.error}

    + )} +
  4. + ) +}) function ToolExecutionList({ tools, @@ -235,65 +304,18 @@ function ToolExecutionList({ className="tool-execution-list" >
      - {tools.map((tool) => { - const summary = tool.summary.trim() - // Only suppress known boilerplate; recovery hints and custom summaries remain visible. - const summaryIdentity = summary.replace( - /^(?:(?:OpenCode|Continue|DeepSeek Harness|远端 Runtime) 工具|直连模型工具(?:已完成|执行失败)?|正在执行直连模型工具)\s*[::]\s*/u, - '' - ) - const hasSummary = summary.length > 0 && summaryIdentity !== tool.name.trim() - const StateIcon = tool.state === 'running' ? LoaderCircle - : tool.state === 'pending' ? Clock3 - : tool.state === 'completed' ? Check : XCircle - return ( -
    1. -
      - - -
      - {hasSummary &&

      {tool.summary}

      } - {tool.error && ( -
      - -
      - )} - {tool.output ? ( - - ) : !tool.error && ( -

      {t(tool.state === 'running' ? 'chat.tools.waitingOutput' - : tool.state === 'pending' ? 'chat.tools.waitingStart' - : tool.state === 'completed' ? 'chat.tools.emptyOutput' : 'chat.tools.noDetails')}

      - )} - {tool.input && ( -
      - {t('chat.tools.input')} - -
      - )} -
      -
      - {tool.error && ( -

      {tool.error}

      - )} -
    2. - ) - })} + {tools.map((tool) => ( + + ))}
    ) diff --git a/src/renderer/src/activity-store.test.ts b/src/renderer/src/activity-store.test.ts index 4b4ebd97..a8f396a5 100644 --- a/src/renderer/src/activity-store.test.ts +++ b/src/renderer/src/activity-store.test.ts @@ -182,6 +182,74 @@ describe('activity-store', () => { ).toEqual(first.scope) }) + it.each(['tool', 'subagent'] as const)( + 'reuses an unchanged leading %s record and readonly array', + (kind) => { + const first: ActivityRecord = { + ...makeRecord(1), + kind, + callId: 'call-1' + } + const records: readonly ActivityRecord[] = Object.freeze([ + first, + makeRecord(2) + ]) + const updated = upsertActivityRecord(records, { + ...first, + id: 'replacement-id', + createdAt: 99, + scope: { kind: 'unavailable' } + }) + + expect(updated).toBe(records) + expect(updated[0]).toBe(first) + } + ) + + it('moves interleaved unchanged calls to the front without rebuilding records', () => { + const first = { ...makeRecord(1), callId: 'call-1' } + const second = { ...makeRecord(2), callId: 'call-2' } + const third = { ...makeRecord(3), callId: 'call-3' } + const records = Object.freeze([third, second, first]) + const updated = upsertActivityRecord(records, { ...first }) + expect(updated).not.toBe(records) + expect(updated[0]).toBe(first) + expect(updated[1]).toBe(third) + expect(updated[2]).toBe(second) + + const interleaved = upsertActivityRecord(updated, { ...second }) + expect(interleaved[0]).toBe(second) + expect(interleaved[1]).toBe(first) + expect(interleaved[2]).toBe(third) + expect(upsertActivityRecord(interleaved, { ...second })).toBe(interleaved) + expect(records).toEqual([third, second, first]) + }) + + it.each([ + { conversationId: 'conversation-2' }, + { title: 'Updated title' }, + { detail: 'Updated detail' }, + { status: 'failed' as const } + ])('replaces a record when effective fields change: %o', (change) => { + const first = { ...makeRecord(1), callId: 'call-1' } + const other = makeRecord(2) + const records = [other, first] + const updated = upsertActivityRecord(records, { + ...first, + ...change, + id: 'replacement-id', + createdAt: 99, + scope: { kind: 'unavailable' } + }) + + expect(updated).not.toBe(records) + expect(updated[0]).not.toBe(first) + expect(updated[0]).toEqual({ ...first, ...change }) + expect(updated[0]?.scope).toBe(first.scope) + expect(updated[1]).toBe(other) + expect(records).toEqual([other, first]) + }) + it('upserts Subagent state transitions', () => { const queued: ActivityRecord = { ...makeRecord(1), diff --git a/src/renderer/src/activity-store.ts b/src/renderer/src/activity-store.ts index 017503b0..6253d58a 100644 --- a/src/renderer/src/activity-store.ts +++ b/src/renderer/src/activity-store.ts @@ -41,10 +41,18 @@ function parseActivityRecord(value: unknown): ActivityRecord | undefined { return parsed.success ? parsed.data : undefined } +export function upsertActivityRecord( + records: ActivityRecord[], + incoming: ActivityRecord +): ActivityRecord[] export function upsertActivityRecord( records: readonly ActivityRecord[], incoming: ActivityRecord -): ActivityRecord[] { +): readonly ActivityRecord[] +export function upsertActivityRecord( + records: readonly ActivityRecord[], + incoming: ActivityRecord +): readonly ActivityRecord[] { if ( (incoming.kind !== 'tool' && incoming.kind !== 'subagent') || !incoming.callId @@ -63,13 +71,24 @@ export function upsertActivityRecord( } const existing = records[existingIndex]! + // Matching fields and the original identity/scope cannot change on an update. + const unchanged = + existing.conversationId === incoming.conversationId && + existing.title === incoming.title && + existing.detail === incoming.detail && + existing.status === incoming.status + if (unchanged && existingIndex === 0) { + return records + } return [ - { - ...incoming, - id: existing.id, - createdAt: existing.createdAt, - scope: existing.scope - }, + unchanged + ? existing + : { + ...incoming, + id: existing.id, + createdAt: existing.createdAt, + scope: existing.scope + }, ...records.filter((_, index) => index !== existingIndex) ] }