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) => (
-
{renderRecordCard(record, false)}
@@ -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 (
+ -
+
+
+
+
+ {tool.name}
+ {hasSummary && {tool.summary}}
+
+
+
+ {(tool.state === 'failed' || tool.state === 'recoverable' || tool.state === 'cancelled') && t(`chat.tools.states.${tool.state}`)}
+
+
+
+ {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}
+ )}
+
+ )
+})
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 (
- -
-
-
-
-
- {tool.name}
- {hasSummary && {tool.summary}}
-
-
-
- {(tool.state === 'failed' || tool.state === 'recoverable' || tool.state === 'cancelled') && t(`chat.tools.states.${tool.state}`)}
-
-
-
- {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}
- )}
-
- )
- })}
+ {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)
]
}