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