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) ] }