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()) && (