From 58e6808f1fe30814c5c26721e58849cf6f146d69 Mon Sep 17 00:00:00 2001 From: mesalogo Date: Mon, 28 Sep 2026 18:10:10 +0800 Subject: [PATCH] fix: deduplicate terminal notices and distinguish cancellation Keep cancellation and failure details in message status while preserving existing replies. Render cancellation neutrally, restore terminal metadata from history, and offer Edit again only when input can be restored. Release note: Cancelled and failed requests show a single notice; cancellation no longer appears as an error, and Edit again accurately describes the recovery action. --- docs/features/assistant-workbar/progress.md | 33 +++++++ .../assistant-workbar/runtime-interactions.md | 10 ++ src/main/assistant/assistant-database.test.ts | 18 +++- src/main/assistant/assistant-database.ts | 19 ++-- src/renderer/src/App.test.tsx | 56 +++++++++-- src/renderer/src/App.tsx | 29 ++---- src/renderer/src/ChatTimeline.test.tsx | 68 ++++++++++++++ src/renderer/src/ChatTimeline.tsx | 28 ++++-- .../src/RuntimeChecklistStrip.test.tsx | 11 +++ src/renderer/src/RuntimeChecklistStrip.tsx | 4 +- src/renderer/src/i18n/locales/en-US/app.ts | 3 +- src/renderer/src/i18n/locales/zh-CN/app.ts | 3 +- src/renderer/src/message-terminal-status.ts | 11 +++ src/renderer/src/styles.css | 1 - src/shared/assistant-contracts.ts | 1 + tests/support/terminal-status-probe-main.ts | 94 +++++++++++++++++++ tests/terminal-status-probe.electron.test.ts | 46 +++++++++ 17 files changed, 382 insertions(+), 53 deletions(-) create mode 100644 src/renderer/src/message-terminal-status.ts create mode 100644 tests/support/terminal-status-probe-main.ts create mode 100644 tests/terminal-status-probe.electron.test.ts diff --git a/docs/features/assistant-workbar/progress.md b/docs/features/assistant-workbar/progress.md index 977f2ecc..e0c5cd34 100644 --- a/docs/features/assistant-workbar/progress.md +++ b/docs/features/assistant-workbar/progress.md @@ -1,5 +1,38 @@ # 工作栏实现与验证进度 +## 2026-09-28:消息取消与失败展示 + +消息终态按[Runtime 交互边界](./runtime-interactions.md)保存和展示。取消使用结构化 +`terminalStatus`,保持已有回答;终止提示不再补入正文。“重新编辑”只回填并聚焦输入框, +没有可回填文字时隐藏入口。旧快照只对精确重复的形态隐藏状态副本,保留原正文。 +深色主题已移除覆盖失败提示颜色的通用规则;检索和压缩记录不再屏蔽终态的无障碍角色。 + +Windows 本地验证: + +- 本次修复完成时 `npm run typecheck`、`npm run lint`、`git diff --check` 通过。 + 后续并行的应用入口修改新增 `App.test.tsx:14430` 的 `getByRole(..., { exact: true })`, + 最后一次类型检查报 TS2769;该选项不属于 `ByRoleOptions`。未修改这处他人新增代码, + 当前组合工作区不能记为类型检查通过。最后一次 lint 和 diff 检查通过。 +- `npm test`:5,367 通过、84 跳过、1 失败。失败项为 + `tests/magic-notes-layout.electron.test.ts` 的原生拖拽测试,90 秒超时;单独复跑仍超时。 + 该文件未修改,本次检查不记为全量通过。 +- 相关五文件复跑为 447 通过、14 失败;14 项均在并行入口改名期间查找“应用”按钮失败。 + 入口源码更新后,单独复跑 `src/renderer/src/App.test.tsx` 的 304 项全部通过。 + 其余四文件(数据库、时间线、清单和终态 Electron 探针)的 157 项在上述复跑中通过。 +- `tests/terminal-status-probe.electron.test.ts` 通过真实 App、Preload、生产 IPC、 + 直连 Runtime 和 SQLite 验证空回答取消、部分回答取消、HTTP 400 失败;检查提示唯一性、 + 浅深主题 computed style、重新编辑回填与焦点,以及数据库重开和页面恢复。 + 每轮使用 3 次本地确定性 HTTP 请求,外部模型调用 0 次。探针在结束时关闭 Runtime、 + HTTP 服务和窗口,并删除本轮独立临时目录。 +- 数据库回归覆盖恢复事件事务回滚、失败与取消重放、空正文和已有回答保留、重开后元数据。 + App 和时间线回归覆盖新事件结构化判定、旧消息精确去重、正常正文保留、无效按钮隐藏、 + 活动及工具终态。 + +本次未连接远程主机,未创建远程资源。远端事件仍通过既有公共事件进入桌面的 +`reduceRecoveredAgentEvent`,本次仅修改其消息投影与元数据保存,不改变 Agent、SSH +传输、取消执行、ACK 或重连控制;本地 SQLite 恢复测试覆盖该修改。未验证 Linux/macOS +原生界面,不把本地探针记为远端取消生命周期验收。 + ## 2026-09-28:DS Web 资源符号链接与 macOS 发布恢复 Desktop `v0.15.0` 原生构建中,两个 macOS 目标均因包外符号链接未通过签名校验。 diff --git a/docs/features/assistant-workbar/runtime-interactions.md b/docs/features/assistant-workbar/runtime-interactions.md index a381b05f..a93cd3ff 100644 --- a/docs/features/assistant-workbar/runtime-interactions.md +++ b/docs/features/assistant-workbar/runtime-interactions.md @@ -100,6 +100,16 @@ DeepSeek Harness 按调用 ID 保留名称、输入和摘要,结束事件省 为静态,不凭请求尚未结束暗示仍有进展;会话列表闪点和发送、停止按钮保持原有行为。 持久化快照中的同一条消息仍为生成中时,也不能覆盖本地已收到的终态,即使快照的 会话更新时间更晚;否则连续发送或刷新会让已结束的请求重新显示停止按钮。 +- 失败和取消的终止提示只进入消息状态,不补写正文或文本块,已有回答、推理与工具输出 + 保留。取消使用中性色和礼貌级 `status`,真实失败使用危险色和 `alert`;运行记录的 + 取消结果标题为“任务已取消”。工具详情已表达同一失败时,不再复制到消息底部。 +- 最新失败或取消消息只有在上一条用户消息含可回填文字时显示“重新编辑”。点击只回填 + 输入框并聚焦,由用户修改后自行发送;附件单独发送且无文字时不显示此按钮。 +- 实时事件和 Main 历史恢复均将事件的 `failed / cancelled` 写入消息元数据 + `terminalStatus`;既有 `state: error` 继续表示未成功终态,不改 SQLite 枚举。 + 新事件不按提示文案识别取消。旧快照无此字段时,仅兼容已发布的精确取消标签。 + 旧记录若正文与状态完全相同,且无文本块或唯一文本块位于末尾并等于该状态,展示时 + 保留正文、隐藏重复状态;不修改持久数据,不删除正文中的同名句子或部分匹配内容。 - Continue、DeepSeek Harness 和远端 ACP 保留其已接入的处理与工具事件,不伪造未接入 的原生重试状态。直连模型的重试等待与请求开始文案见 [直连模型 UI 设计](../direct-model-agent/ui-design.md#6-错误与通知);本次不改变任何 diff --git a/src/main/assistant/assistant-database.test.ts b/src/main/assistant/assistant-database.test.ts index bc7d537c..8ea1c164 100644 --- a/src/main/assistant/assistant-database.test.ts +++ b/src/main/assistant/assistant-database.test.ts @@ -4428,6 +4428,7 @@ describe('AssistantDatabase', () => { id: assistantMessageId, content: recoveredText, state: 'error', + terminalStatus, runtimeChecklist: { source: 'opencode', items: [] }, status: '远端请求已不存在' }) @@ -4553,7 +4554,7 @@ describe('AssistantDatabase', () => { } finally { database.close() } }) - it('rolls back a recovered event when its message update fails', async () => { + it.each(['failed', 'cancelled'] as const)('rolls back and durably recovers a %s event without creating reply text', async (terminalStatus) => { const directory = await mkdtemp( join(tmpdir(), 'goodbuddy-remote-event-atomic-') ) @@ -4617,7 +4618,7 @@ describe('AssistantDatabase', () => { event: { requestId: taskId, type: 'error', - status: 'failed', + status: terminalStatus, message: '远程失败' } }) @@ -4664,7 +4665,7 @@ describe('AssistantDatabase', () => { event: { requestId: taskId, type: 'error', - status: 'failed', + status: terminalStatus, message: '远程失败' } }) @@ -4687,17 +4688,24 @@ describe('AssistantDatabase', () => { expect( recovered.getConversation(conversationId).messages[1] ).toMatchObject({ - content: '远程失败', + content: '', state: 'error', + terminalStatus, status: '远程失败' }) expect( recovered.listTasks().find((task) => task.id === taskId) ).toMatchObject({ - status: 'failed', + status: terminalStatus, error: '远程失败' }) recovered.close() + const reopened = new AssistantDatabase(databasePath) + reopened.initialize('C:\\Workspace') + const message = reopened.getConversation(conversationId).messages[1] + expect(message).toMatchObject({ content: '', state: 'error', terminalStatus, status: '远程失败' }) + expect(message?.blocks?.some(block => block.type === 'text')).toBe(false) + reopened.close() }) it('persists and arbitrates a FIFO conversation input queue', async () => { diff --git a/src/main/assistant/assistant-database.ts b/src/main/assistant/assistant-database.ts index ac394a26..8d13b604 100644 --- a/src/main/assistant/assistant-database.ts +++ b/src/main/assistant/assistant-database.ts @@ -290,6 +290,7 @@ type MessageMetadata = { createdAt?: number queueItemId?: string status?: string + terminalStatus?: ConversationMessage['terminalStatus'] reasoning?: ConversationSnapshot['messages'][number]['reasoning'] runtimeChecklist?: ConversationMessage['runtimeChecklist'] blocks?: ConversationSnapshot['messages'][number]['blocks'] @@ -1213,6 +1214,7 @@ function toConversationSnapshot( createdAt: metadata.createdAt ?? Date.parse(message.created_at), state: message.state, + terminalStatus: metadata.terminalStatus, status: metadata.status, contextCompression: metadata.contextCompression, contextCompressions: metadata.contextCompressions, @@ -1240,6 +1242,7 @@ function serializeConversationMessageMetadata( createdAt: message.createdAt, queueItemId: message.queueItemId, status: message.status, + terminalStatus: message.terminalStatus, reasoning: message.reasoning, runtimeChecklist: message.runtimeChecklist, blocks: message.blocks, @@ -1568,15 +1571,14 @@ function reduceRecoveredAgentEvent( ? ('cancelled' as const) : ('failed' as const) : ('interrupted' as const) - const fallback = - event.type === 'error' && !represented && !message.content - ? event.message - : '' next = { ...message, state: event.type === 'error' ? 'error' : 'complete', + terminalStatus: event.type === 'error' + ? event.status === 'cancelled' ? 'cancelled' : 'failed' + : undefined, status: - event.type === 'error' && !represented + event.type === 'error' && (event.status === 'cancelled' || !represented) ? event.message : undefined, contextCompression: @@ -1620,10 +1622,9 @@ function reduceRecoveredAgentEvent( : subagent ), blocks: terminalizeRecoveredToolBlocks( - appendRecoveredMessageBlock(message.blocks, 'text', fallback), + message.blocks, toolState - ), - content: fallback || message.content + ) } } return conversationMessageSchema.parse(next) @@ -5443,6 +5444,7 @@ export class AssistantDatabase { state: row.state, createdAt: metadata.createdAt ?? Date.parse(row.created_at), status: metadata.status, + terminalStatus: metadata.terminalStatus, reasoning: metadata.reasoning, runtimeChecklist: metadata.runtimeChecklist, blocks: metadata.blocks, @@ -5858,6 +5860,7 @@ export class AssistantDatabase { createdAt: metadata.createdAt ?? Date.parse(row.created_at), status: metadata.status, + terminalStatus: metadata.terminalStatus, reasoning: metadata.reasoning, runtimeChecklist: metadata.runtimeChecklist, blocks: metadata.blocks, diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index 52b8d33e..78811e80 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -5935,9 +5935,13 @@ describe("App", () => { expect(screen.getByText(toolError, { selector: ".tool-execution__error-preview" })).toBeVisible(); expect(screen.queryByText(runtimeError)).not.toBeInTheDocument(); expect( - screen.getByRole("button", { name: "重新编辑并发送" }), + screen.getByRole("button", { name: "重新编辑" }), ).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "重新编辑" })); + expect(screen.getByLabelText("向 GoodBuddy 提问")).toHaveValue("读取演示文稿"); + expect(run).toHaveBeenCalledOnce(); + fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { target: { value: "继续处理" }, }); @@ -5945,7 +5949,7 @@ describe("App", () => { await waitFor(() => expect(run).toHaveBeenCalledTimes(2)); expect( - screen.queryByRole("button", { name: "重新编辑并发送" }), + screen.queryByRole("button", { name: "重新编辑" }), ).not.toBeInTheDocument(); }); @@ -8469,7 +8473,7 @@ describe("App", () => { screen.queryByText("上次运行意外中断,可以重新发送问题"), ).not.toBeInTheDocument(); expect( - screen.queryByRole("button", { name: "重新编辑并发送" }), + screen.queryByRole("button", { name: "重新编辑" }), ).not.toBeInTheDocument(); expect(api.conversationQueue.ready).not.toHaveBeenCalled(); @@ -10074,6 +10078,40 @@ describe("App", () => { expect(mode).toBeDisabled(); }); + it.each([ + ["cancelled", ""], ["cancelled", "Partial answer"], + ["failed", ""], ["failed", "Partial answer"], + ] as const)("preserves reply content and persists a single %s status (%s)", async (status, content) => { + render(); + fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { + target: { value: "Test terminal reply" }, + }); + fireEvent.click(await screen.findByLabelText("发送")); + await waitFor(() => expect(run).toHaveBeenCalledOnce()); + const requestId = run.mock.calls[0]![0].requestId; + // Deliberately unrelated to cancellation copy: classification uses status. + const detail = "Terminal event detail"; + act(() => { + if (content) agentListener?.({ requestId, type: "text", delta: content }); + agentListener?.({ requestId, type: "error", status, message: detail }); + }); + const notice = await screen.findByRole(status === "cancelled" ? "status" : "alert", { name: detail }); + expect(screen.getAllByText(detail)).toHaveLength(1); + expect(notice.classList.contains("message__status--error")).toBe(status === "failed"); + const article = notice.closest("article")!; + expect(article.querySelector(".message__content")?.textContent ?? "").toBe(content); + await waitFor(() => { + const saved = vi.mocked(api.conversations.saveLocal).mock.calls + .flatMap(([batch]) => batch).flatMap(item => item.messages ?? []) + .findLast(message => message.terminalStatus === status); + expect(saved).toMatchObject({ state: "error", terminalStatus: status, content, status: detail }); + expect(saved?.blocks?.filter(block => block.type === "text").map(block => block.content).join("") ?? "").toBe(content); + }); + fireEvent.click(screen.getByRole("button", { name: "重新编辑" })); + expect(screen.getByLabelText("向 GoodBuddy 提问")).toHaveValue("Test terminal reply"); + expect(run).toHaveBeenCalledOnce(); + }); + it("terminalizes tools and activity when a request is cancelled", async () => { vi.mocked(api.agent.getStatus).mockResolvedValue({ id: "opencode", @@ -10111,15 +10149,17 @@ describe("App", () => { }); expect(await screen.findByText("已取消")).toBeInTheDocument(); - const cancelledStatus = screen - .getAllByText("请求已取消") - .find((element) => element.classList.contains("message__status")); - expect(cancelledStatus).toBeDefined(); + const cancelledStatus = screen.getByRole("status", { name: "请求已取消" }); + expect(screen.getAllByText("请求已取消")).toHaveLength(1); + expect(cancelledStatus).not.toHaveClass("message__status--error"); + expect(cancelledStatus).toHaveAttribute("aria-live", "polite"); const cancelledDot = cancelledStatus?.querySelector(".message__status-dot"); expect(cancelledDot).toHaveClass("message__status-dot"); expect(cancelledDot).not.toHaveClass("message__status-dot--active"); fireEvent.click(screen.getByText("运行记录")); expect((await screen.findAllByText("已取消")).length).toBeGreaterThan(0); + expect(await screen.findByText("任务已取消")).toBeInTheDocument(); + expect(screen.queryByText("任务执行失败")).not.toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: /^进行中 \d+$/u })); expect( screen.getByText("当前没有等待中或正在运行的活动。"), @@ -12364,7 +12404,7 @@ describe("App", () => { } else { expect(screen.queryByRole("button", { name: "提交回答" })).not.toBeInTheDocument(); expect(screen.queryByText("Already queued?")).not.toBeInTheDocument(); - if (outcome !== "done") expect(screen.getByRole("alert")).toHaveTextContent("Terminal queue result"); + if (outcome !== "done") expect(screen.getByRole(outcome === "cancelled" ? "status" : "alert", { name: "Terminal queue result" })).toHaveTextContent("Terminal queue result"); } } }, diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 52fee91b..2b407895 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1027,6 +1027,7 @@ function toConversationMessage(message: Message): ConversationMessage { displayCaptureTruncated: message.displayCaptureTruncated, createdAt: message.createdAt, state: message.state, + terminalStatus: message.terminalStatus, status: message.status, runtimeChecklist: message.runtimeChecklist, contextCompression: message.contextCompression, @@ -4835,7 +4836,8 @@ function App(): React.JSX.Element { kind: "result", title: event.type === "error" - ? tRef.current("chat.status.taskFailed") + ? tRef.current(event.status === "cancelled" + ? "chat.status.taskCancelled" : "chat.status.taskFailed") : tRef.current("chat.status.taskCompleted"), detail: event.type === "error" @@ -4869,15 +4871,14 @@ function App(): React.JSX.Element { ? ("cancelled" as const) : ("failed" as const) : ("interrupted" as const); - const fallbackError = - event.type === "error" && !representedToolError && !message.content - ? event.message - : ""; return { ...message, state: event.type === "error" ? "error" : "complete", + terminalStatus: event.type === "error" + ? event.status === "cancelled" ? "cancelled" : "failed" + : undefined, status: - event.type === "error" && !representedToolError + event.type === "error" && (event.status === "cancelled" || !representedToolError) ? event.message : event.type === "done" ? tRef.current("chat.status.taskCompleted") @@ -4928,21 +4929,7 @@ function App(): React.JSX.Element { } : subagent, ), - blocks: toolTerminalState - ? terminalizeMessageToolBlocks( - appendMessageContentBlock( - message.blocks, - "text", - fallbackError, - ), - toolTerminalState, - ) - : appendMessageContentBlock( - message.blocks, - "text", - fallbackError, - ), - content: fallbackError || message.content, + blocks: terminalizeMessageToolBlocks(message.blocks, toolTerminalState), }; }); activeRuns.current.delete(event.requestId); diff --git a/src/renderer/src/ChatTimeline.test.tsx b/src/renderer/src/ChatTimeline.test.tsx index 13055779..c73f25cc 100644 --- a/src/renderer/src/ChatTimeline.test.tsx +++ b/src/renderer/src/ChatTimeline.test.tsx @@ -80,6 +80,74 @@ function createMessages(): Message[] { } describe('ChatTimeline', () => { + it.each(['cancelled', 'failed'] as const)('renders saved %s status and offers editing only with usable input', (terminalStatus) => { + const props = { + ...callbacks, artifactById: new Map(), conversationId: 'terminal', + hiddenMessageCount: 0, isUnusedConversation: false, locale: 'zh-CN' as const, + messageStartIndex: 0, totalMessageCount: 1 + } + const message: Message = { id: 'reply', role: 'assistant', content: 'Existing reply', + state: 'error', terminalStatus, status: 'Terminal detail', createdAt: 1 } + const view = render() + const status = screen.getByRole(terminalStatus === 'cancelled' ? 'status' : 'alert', { name: 'Terminal detail' }) + expect(status.classList.contains('message__status--error')).toBe(terminalStatus === 'failed') + expect(screen.getByText('Existing reply')).toBeVisible() + expect(screen.queryByRole('button', { name: '重新编辑' })).not.toBeInTheDocument() + for (const extra of [ + { contextCompression: { state: 'completed' as const, estimatedBeforeTokens: 20_000, estimatedAfterTokens: 8_000 } }, + { knowledgeRetrieval: { mode: 'always' as const, state: 'succeeded' as const, libraryCount: 1, + resultCount: 2, durationMs: 20, usedChannels: ['fts' as const], warnings: [] } } + ]) { + view.rerender() + expect(screen.getByRole(terminalStatus === 'cancelled' ? 'status' : 'alert', { name: 'Terminal detail' })) + .toHaveAttribute('aria-live', terminalStatus === 'cancelled' ? 'polite' : 'assertive') + } + view.rerender() + expect(screen.queryByRole('button', { name: '重新编辑' })).not.toBeInTheDocument() + view.rerender() + fireEvent.click(screen.getByRole('button', { name: '重新编辑' })) + expect(callbacks.onRetry).toHaveBeenLastCalledWith('Original prompt') + }) + + it.each(['请求已取消', 'Provider failed'])('deduplicates only the exact legacy fallback shape (%s) without removing reply text', (detail) => { + const props = { ...callbacks, artifactById: new Map(), conversationId: 'legacy', + hiddenMessageCount: 0, isUnusedConversation: false, locale: 'zh-CN' as const, + messageStartIndex: 0, totalMessageCount: 1 } + const message: Message = { id: 'old', role: 'assistant', content: detail, + state: 'error', status: detail, createdAt: 1 } + const view = render() + expect(screen.getAllByText(detail)).toHaveLength(1) + expect(screen.getByText(detail).closest('.message__content')).not.toBeNull() + const blocks: Message['blocks'] = [ + { id: 'reasoning', type: 'reasoning', content: 'Preserved reasoning' }, + { id: 'fallback', type: 'text', content: message.content } + ] + view.rerender() + expect(screen.getAllByText(detail)).toHaveLength(1) + // A body that merely quotes or includes the status is never stripped. + view.rerender() + expect(screen.getByText(`The message says ${detail}`)).toBeVisible() + const role = detail === '请求已取消' ? 'status' : 'alert' + expect(screen.getByRole(role, { name: detail }).classList.contains('message__status--error')).toBe(role === 'alert') + // Explicit failed events must override even the old cancellation labels. + view.rerender() + expect(screen.getByRole('alert', { name: detail })).toHaveClass('message__status--error') + expect(screen.getByText(detail, { selector: '.message__content span' })).toBeVisible() + // Content and blocks disagree: do not treat this as the old fallback. + view.rerender() + expect(screen.getByText('Real output')).toBeVisible() + expect(screen.getByRole(role, { name: detail })).toBeVisible() + // Multiple text blocks or a non-final text block are not the old fallback. + for (const nextBlocks of [ + [...blocks, { id: 'later', type: 'reasoning' as const, content: 'Later reasoning' }], + [{ id: 'earlier', type: 'text' as const, content: 'Earlier output' }, ...blocks] + ]) { + view.rerender() + expect(screen.getByRole(role, { name: detail })).toBeVisible() + expect(screen.getByText(detail, { selector: '.message__content span' })).toBeVisible() + } + }) + it('captures only nonempty settled assistant replies, including incomplete replies', () => { const onAddToNote = vi.fn() const messages: Message[] = [ diff --git a/src/renderer/src/ChatTimeline.tsx b/src/renderer/src/ChatTimeline.tsx index 2fe96714..35616be2 100644 --- a/src/renderer/src/ChatTimeline.tsx +++ b/src/renderer/src/ChatTimeline.tsx @@ -41,6 +41,7 @@ import { AgentQuestionCard } from './AgentQuestionCard' import { MarkdownRenderer } from './MarkdownRenderer' import { formatTime, type TimeFormatLocale } from './time-format' import { formatCompactTokens } from './token-format' +import { isCancelledMessage } from './message-terminal-status' export type ToolActivity = ConversationToolActivity @@ -61,6 +62,7 @@ export type Message = { displayCaptureTruncated?: boolean createdAt: number state: 'streaming' | 'complete' | 'error' + terminalStatus?: ConversationMessage['terminalStatus'] status?: string runtimeChecklist?: RuntimeChecklist contextCompression?: ConversationMessage['contextCompression'] @@ -706,7 +708,19 @@ function ChatMessageRowView({ subagent.state === 'queued' || subagent.state === 'running') const activeCompression = compressionMarkers.find((marker) => marker.state === 'compressing') - const statusText = message.role === 'assistant' && message.state === 'streaming' + // Old snapshots may contain the fallback status as the entire reply. Keep + // the body (it could be genuine output) and omit only the duplicate status. + const duplicateLegacyStatus = message.role === 'assistant' && + message.state === 'error' && message.terminalStatus === undefined && + Boolean(message.status) && message.content === message.status && + (!message.blocks?.length || ( + message.blocks.at(-1)?.type === 'text' && + message.blocks.every(block => block.type !== 'text' || + (block === message.blocks?.at(-1) && block.content === message.status)) + )) + const failed = message.state === 'error' && !isCancelledMessage(message) + const statusText = duplicateLegacyStatus ? undefined + : message.role === 'assistant' && message.state === 'streaming' ? question ? t('chat.status.waitingForAnswer') : message.approval @@ -1233,21 +1247,21 @@ function ChatMessageRowView({ aria-atomic="true" aria-label={statusText} aria-live={ - message.knowledgeRetrieval || compressionMarkers.length > 0 + message.state !== 'error' && (message.knowledgeRetrieval || compressionMarkers.length > 0) ? undefined - : message.state === 'error' + : failed ? 'assertive' : 'polite' } className={ - message.state === 'error' + failed ? 'message__status message__status--error' : 'message__status' } role={ - message.knowledgeRetrieval || compressionMarkers.length > 0 + message.state !== 'error' && (message.knowledgeRetrieval || compressionMarkers.length > 0) ? undefined - : message.state === 'error' + : failed ? 'alert' : 'status' } @@ -1259,7 +1273,7 @@ function ChatMessageRowView({ {statusText} )} - {canRetry && ( + {canRetry && Boolean(retryContent?.trim()) && (