diff --git a/FEATURES.md b/FEATURES.md index c7f4a34e..c83a1b31 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -251,6 +251,12 @@ otherwise. results without reconstructing missing progress. - [x] **OpenCode event-stream cleanup**: Each completed or cancelled request closes its own subscription without cancelling parallel conversations. +- [x] **Accurate message-footer status**: Distinguishes request preparation, + retry waiting, retry dispatch, tool activity, pending answers, and terminal + states. Local OpenCode reports native retry attempts and scheduled times; + direct models report their own backoff phase. Footer dots are static, and + unsupported Runtime retry details are not invented. Conversation-list + indicators and send/stop behavior remain unchanged. - [x] **Role-bound model connections**: Each role can inherit the default model or select an independent text-model connection. Invalid connections safely fall back to the default; the synthesis role always inherits the default. @@ -432,7 +438,9 @@ otherwise. demand or before every response, show zero-result, degraded, failed, and cancelled states, and expose cited context or safely open its source. - [x] **Magic Notes**: A local-first notes and todo workbench with scope - management, editing, filtering, and controlled AI comments. The left + management, editing, filtering, and controlled AI comments. The todo view + displays the selected item's source entry, with loading, retryable failure, + and missing-source feedback. The left navigation can show the incomplete-todo count, and create, save, and comment results use application-wide notifications. - [ ] **MCP Server Control Plane** (planned): Unified MCP lifecycle, health @@ -449,8 +457,8 @@ otherwise. duplicate chat Markdown is hidden from the artifact list but not physically deleted. Run history is stored in Main SQLite without the previous 500-item, 4,000-character, or 2 MB Renderer limits; the page renders it in batches, - and a persistent warning remains when a migrated legacy cache may already - have lost older records or detail. Token usage is grouped by Runtime and + without a persistent legacy-cache truncation warning; previously lost + history is not restored. Token usage is grouped by Runtime and model and normalizes the different OpenAI-compatible and Anthropic Messages cache-reporting semantics when showing cache hit rate. Activity is grouped by conversation and collapsed by default so long histories do not fill the diff --git a/FEATURES.zh-CN.md b/FEATURES.zh-CN.md index d1f375e0..61652511 100644 --- a/FEATURES.zh-CN.md +++ b/FEATURES.zh-CN.md @@ -41,6 +41,7 @@ - [x] **专家与 Subagent**:支持显式专家、团队分析和最多三个专家并行分析。专家继承父请求 Ask/Execute 模式,可使用已启用的本机直连模型工具,Ask 仍只读;这些工具不是远程 OpenCode 子会话。聊天先展示可逐项展开的专家完整输出,再在其下展示总 Agent 的综合结果,并随会话保存。 - [x] **OpenCode 子代理过程与最终结果**:子代理卡片按顺序显示文字、推理和工具过程,最终结果独立展示。远程实时过程需要 Agent `0.11.20`,旧包仍显示最终结果但不补造缺失过程。 - [x] **OpenCode 事件连接回收**:请求结束或取消后关闭自身订阅,不取消其他并行会话。 +- [x] **准确的消息底部状态**:区分请求准备、等待重试、开始重试、工具活动、待回答和终态;本机 OpenCode 使用原生重试次数和计划时间,直连模型显示自身退避阶段。消息底部状态点保持静态,未接入的 Runtime 重试不编造提示,会话列表闪点及发送、停止行为不变。 - [x] **直连模型命令工作目录**:Execute 命令可通过绝对路径、相对路径或符号链接使用工作区外目录;工作区仍为默认目录和相对路径基准,Ask 保持只读。 - [x] **角色绑定模型连接**:每个角色可继承默认模型或选择独立文本模型连接,失效连接安全回退默认模型,综合角色始终继承默认模型。 - [x] **多协议模型配置**:支持 Anthropic Messages、OpenAI Responses、OpenAI Chat Completions、OpenAI Images 和无认证本机模型;新用户默认连接为本机 Ollama 兼容地址,不预置第三方云模型服务。升级时只替换从未配置凭据且仍完全等于旧内置值的历史默认连接,用户显式保存或加密凭据的连接保持不变;已有部署仅使用兼容环境变量提供凭据时继续沿用对应的历史连接参数,通用模型环境变量仍优先。“保存并测试模型”会发送有界的真实文本或图片生成请求并校验生成结果,而不是只检查 HTTP 连通性,因此可能产生少量服务商用量费用。 @@ -71,13 +72,13 @@ - [x] **分块、维护与评估**:支持固定、结构化和父子分块,分块维护、可取消重建及双语检索评估。 - [x] **受控知识本体**:每个知识库可定义实体、关系、别名和端点约束,保留证据偏移、置信度和抽取来源,并显式提示图谱重建。 - [x] **强制检索与引用上下文**:对话可按需或每次先检索,显示零结果、降级、失败与取消状态,并可查看引用上下文或安全打开来源。 -- [x] **魔法笔记 / Magic Notes**:提供本地优先的笔记与待办工作台、范围管理、编辑、筛选和受控 AI 评论;左侧入口可按设置显示未完成待办数量,创建、保存和评论结果使用统一应用通知。 +- [x] **魔法笔记 / Magic Notes**:提供本地优先的笔记与待办工作台、范围管理、编辑、筛选和受控 AI 评论;待办视图可直接查看所选事项对应的来源记录,保留读取中、失败重试和来源缺失提示。左侧入口可按设置显示未完成待办数量,创建、保存和评论结果使用统一应用通知。 - [ ] **MCP Server Control Plane**(规划中):统一 MCP 生命周期、健康检查、重连、Schema 缓存、隔离、审批和审计。 - [ ] **可追溯笔记摘录与 AI 编辑**(规划中):从对话、知识和网页收集带来源的摘录,并提供需确认的总结、改写和整理操作。 ### 工作管理、长期协作与工作流 -- [x] **任务、活动与成果**:集中管理任务状态、审计活动和独立成果文件;普通聊天回复只保留在会话中,不再自动复制到成果栏,已有重复聊天 Markdown 从成果列表隐藏但不物理删除。运行记录改由 Main SQLite 保存,不再受旧 Renderer 的 500 条、单详情 4,000 字符或约 2 MB 上限影响,页面按批继续显示;迁移旧缓存时若历史可能已被截断,会持续明确提示,不能把既有缺口伪装成完整。Token 用量按 Runtime 与模型归类,并针对 OpenAI 兼容与 Anthropic Messages 的不同上报口径归一化展示缓存命中率;活动按会话分组并默认收起,避免长历史占满页面。 +- [x] **任务、活动与成果**:集中管理任务状态、审计活动和独立成果文件;普通聊天回复只保留在会话中,不再自动复制到成果栏,已有重复聊天 Markdown 从成果列表隐藏但不物理删除。运行记录改由 Main SQLite 保存,不再受旧 Renderer 的 500 条、单详情 4,000 字符或约 2 MB 上限影响,页面按批继续显示;不再常驻旧缓存截断警告,但不会恢复此前已丢失的历史。Token 用量按 Runtime 与模型归类,并针对 OpenAI 兼容与 Anthropic Messages 的不同上报口径归一化展示缓存命中率;活动按会话分组并默认收起,避免长历史占满页面。 - [x] **Task 与定制任务体验**:每个产品级 Task 只关联一条 Conversation,一条 Conversation 可承载多个 Task;左侧会话列表通过行首展开按钮显示带共享状态点的 Task 子项,父会话行不重复任务标签,UI 只展示到 Task,不暴露 Job/Run 层级。新建定制任务可关联当前或新 Conversation,默认 Execute 并沿用 Runtime、工具和审批边界;重复触发复用同一 Task,文本结果回写 Conversation,独立文件和图片保留为成果。普通消息与到期 Scheduled Task 共用 Conversation 级持久发送队列,同一会话一次只执行一项;当前回复期间仍可继续发送,队列按顺序续跑,并允许删除或“立即中断并插入”。Task Center 继续作为完整索引,不建设独立 Automation Center。当前计划触发支持单次、每日和每周;高级时区、Cron、事件触发与重试治理仍按 PRD 逐步实现。详见 [Task Center PRD](./docs/features/task-and-job/task-center-prd.md) 和 [Scheduled Task PRD](./docs/features/task-and-job/scheduled-task-prd.md)。 - [x] **记忆与智能心跳**:当前提供周期回顾、建议记忆、洞察、后续任务和可审计运行轨迹。 - [x] **智能心跳入口与范围改善**:将“智能心跳 > 心跳计划”作为完整配置的唯一权威入口,支持创建和编辑 Global 或指定一个、多个 Project 的计划;旧单项目配置无损迁移,项目级记忆与行动输出必须显式指定范围内的 Project。Task Center 和设置不再复制心跳表单;按分区回顾、候选生成和唤起条件仍待设计。详见 [智能心跳 PRD](./docs/features/smart-heartbeat/prd.md)。 diff --git a/docs/features/assistant-workbar/README.md b/docs/features/assistant-workbar/README.md index 63434cac..8d44f5e2 100644 --- a/docs/features/assistant-workbar/README.md +++ b/docs/features/assistant-workbar/README.md @@ -9,7 +9,7 @@ | [通用助手工作栏与执行空间 PRD](./prd.md) | 工作栏整体产品范围、执行空间和各工作面板归属 | | [多终端页签 PRD](./terminal-tabs-prd.md) | 多终端 Tab、目标选择、关闭确认和终端生命周期 | | [实现与验证进度](./progress.md) | 工作区与 Runtime 相关改动的实现事实、验证证据与剩余阻塞 | -| [Runtime 交互边界](./runtime-interactions.md) | 现有原生权限、结构化问答、后台拒绝和非结构化终端交互的适配范围 | +| [Runtime 交互边界](./runtime-interactions.md) | 原生权限、结构化问答、后台拒绝、终端交互及消息底部请求状态的适配范围 | 当前尚未拆出独立 User Stories、功能逻辑、UI 设计和技术设计文档;相关内容仍保留在 两份现有 PRD 中,后续实际维护该功能时按职责拆分。 diff --git a/docs/features/assistant-workbar/progress.md b/docs/features/assistant-workbar/progress.md index a6f85d14..f72bff57 100644 --- a/docs/features/assistant-workbar/progress.md +++ b/docs/features/assistant-workbar/progress.md @@ -1,5 +1,25 @@ # 工作栏实现与验证进度 +## 2026-09-08 消息底部请求状态 + +- 已接入本机 OpenCode 父会话的原生重试等待及开始事件;直连模型把退避等待与实际发起 + 重试分开显示,Continue 初始文案改为中性的请求处理。显示规则和未接入边界见 + [消息底部请求状态](./runtime-interactions.md#消息底部请求状态)。 +- 消息底部按真实事件显示准备、工具、待回答及终态;有新进展时清除旧状态,结束后不 + 接受迟到状态覆盖。仅该处状态点改为静态,会话列表闪点、发送和停止按钮未改动。 +- Windows 隔离开发桌面通过实际 UI 发送验证 OpenCode 的 503/Retry-After 等待、 + 原生重试开始、真实模型回复与完成;直连模型通过派发前连接拒绝、500 ms 等待、 + 重试、真实回复与完成。故障由本机测试代理注入,不冒充服务商实际故障。 + 三个隔离测试配置总计 5 次真实文本模型调用;另有一次直连重试被测试代理的调用 + 上限明确拒绝,界面正确显示失败,最终单次补测完成。未执行用户命令或修改安装版。 +- DOM 状态记录确认底部无闪烁、完成后无旧重试文案,浅色截图可见原生重试计划与原因; + 底部 WCAG 2 A/AA 定向检查无违规。最小化窗口的补充截图超时,未作为通过证据。 + 隔离窗口及测试代理已结束。 +- 最终 `npm test -- --reporter=json --outputFile=<临时报告路径>` 为 3618 项通过、 + 0 项失败、61 项按平台或手动条件跳过;首轮的压缩状态旧断言已修正,途中被用户中断 + 的运行不计为通过。`npm run typecheck`、`npm run lint`、 + `npm run release:notes:verify`、差异空白与相关文档相对链接检查通过。 + ## 2026-09-08 Desktop 0.12.8 安装包修复 - 真实 `0.12.7` 安装目录确认 OpenCode 配置缺少整个 `node_modules`。新增测试调用实际 diff --git a/docs/features/assistant-workbar/runtime-interactions.md b/docs/features/assistant-workbar/runtime-interactions.md index 1f486c9e..3f74350c 100644 --- a/docs/features/assistant-workbar/runtime-interactions.md +++ b/docs/features/assistant-workbar/runtime-interactions.md @@ -22,4 +22,21 @@ ACP 已提供相同业务问答接口。 使用其非交互参数,或由用户在终端完成;不能把权限确认和业务决策混为一谈。Shell 工具保留其现有取消与超时,不新增日志解析或通用交互代理。 +## 消息底部请求状态 + +- 状态属于当前请求,不与顶部 Runtime 的连接中、就绪、不可用混为一谈。提交后先显示 + “正在准备请求”;没有更具体的活动事件时显示“等待后续进展”,不声称模型正在思考。 +- 本机 OpenCode 只把当前父会话的 `session.status.retry` 映射为等待重试,显示原生 + `attempt`、计划重试时间和有界脱敏原因;后续 `busy` 才显示已开始重试。其他会话的 + 状态不串入当前消息,不猜测总重试次数,不把 SDK 事件流重连当作模型重试。 +- 显示优先级为待回答、待确认、进行中的上下文压缩、明确的请求状态、待执行或执行中的 + 工具、未完成的子任务、等待后续进展。压缩沿用已有活动块,不重复增加底部提示。 + 正文、推理或工具进展会清除旧状态;子任务更新不覆盖父请求的重试提示。 +- 完成、失败、取消后保留真实终态,迟到的状态事件不能把请求恢复成处理中。底部状态点 + 为静态,不凭请求尚未结束暗示仍有进展;会话列表闪点和发送、停止按钮保持原有行为。 +- Continue、DeepSeek Harness 和远端 ACP 保留其已接入的处理与工具事件,不伪造未接入 + 的原生重试状态。直连模型的重试等待与请求开始文案见 + [直连模型 UI 设计](../direct-model-agent/ui-design.md#6-错误与通知);本次不改变任何 + Runtime 的重试上限、请求重放或取消策略。 + 实现与真实 Runtime 验证证据见[进度记录](./progress.md)。 diff --git a/docs/features/direct-model-agent/ui-design.md b/docs/features/direct-model-agent/ui-design.md index 679798c5..710e154e 100644 --- a/docs/features/direct-model-agent/ui-design.md +++ b/docs/features/direct-model-agent/ui-design.md @@ -117,8 +117,12 @@ GoodBuddy 服务崩溃。 - Runtime 初始化、Shell 探测或全局服务失败可通过应用通知提示,并在设置状态卡提供重试。 - 同一错误不同时显示活动块副本和通知副本。 - 失败后保留用户 Prompt、命令摘要、工作目录和已有输出。 -- 直连模型自动重试通过现有聊天状态显示“模型网络请求失败,正在重试(n/3)”,不增加 - 独立按钮、弹窗或通知副本;用户仍可使用当前停止操作取消请求。 +- 直连模型自动重试在消息底部区分“模型网络请求失败,等待重试(n/3,s 秒后)”与 + “〈模型名〉正在重试请求(n/3),等待响应”;只有退避结束且未取消才显示后者。 + 初始请求显示“〈模型名〉请求处理中”,不把尚无输出的等待描述成思考。 +- 不增加独立按钮、弹窗或通知副本;用户仍可使用当前停止操作取消请求。 + 底部静态状态点、工具进展、待回答及终态优先规则由 + [消息底部请求状态](../assistant-workbar/runtime-interactions.md#消息底部请求状态)统一定义。 ## 7. 键盘与无障碍 diff --git a/src/main/agent/continue-runtime.test.ts b/src/main/agent/continue-runtime.test.ts index de94bd15..accb5f02 100644 --- a/src/main/agent/continue-runtime.test.ts +++ b/src/main/agent/continue-runtime.test.ts @@ -133,6 +133,10 @@ describe('ContinueAgentRuntime', () => { const events = await collectEvents(runtime) + expect(events[0]).toMatchObject({ + type: 'status', + message: 'Continue 正在处理请求' + }) expect(mocks.detectRuntimeBinary).toHaveBeenCalledWith({ binaryPath: '', bundledPath: undefined, diff --git a/src/main/agent/continue-runtime.ts b/src/main/agent/continue-runtime.ts index 103dad18..0549e002 100644 --- a/src/main/agent/continue-runtime.ts +++ b/src/main/agent/continue-runtime.ts @@ -504,7 +504,7 @@ export class ContinueAgentRuntime implements AgentRuntime { yield { requestId: request.requestId, type: 'status', - message: 'Continue 正在生成回复' + message: 'Continue 正在处理请求' } const execute = request.workMode === 'execute' diff --git a/src/main/agent/model-runtime.test.ts b/src/main/agent/model-runtime.test.ts index 15a07678..418f828f 100644 --- a/src/main/agent/model-runtime.test.ts +++ b/src/main/agent/model-runtime.test.ts @@ -1483,9 +1483,16 @@ describe('ModelAgentRuntime', () => { expect(events).toContainEqual( expect.objectContaining({ type: 'status', - message: '模型网络请求失败,正在重试(1/3)' + message: '模型网络请求失败,等待重试(1/3,0.5 秒后)' }) ) + const retryWaiting = events.findIndex((event) => + event.type === 'status' && event.message.includes('等待重试')) + const retryStarted = events.findIndex((event) => + event.type === 'status' && event.message.includes('正在重试请求')) + const responseStarted = events.findIndex((event) => event.type === 'text') + expect(retryStarted).toBeGreaterThan(retryWaiting) + expect(responseStarted).toBeGreaterThan(retryStarted) expect(events).toContainEqual( expect.objectContaining({ type: 'text', @@ -1561,7 +1568,7 @@ describe('ModelAgentRuntime', () => { ) expect(events).not.toContainEqual( expect.objectContaining({ - message: expect.stringContaining('正在重试') + message: expect.stringContaining('等待重试') }) ) }) @@ -1598,7 +1605,7 @@ describe('ModelAgentRuntime', () => { expect(fetcher).toHaveBeenCalledOnce() expect(events).not.toContainEqual( expect.objectContaining({ - message: expect.stringContaining('正在重试') + message: expect.stringContaining('等待重试') }) ) }) @@ -1628,12 +1635,12 @@ describe('ModelAgentRuntime', () => { ) await expect(stream.next()).resolves.toMatchObject({ - value: { type: 'status', message: 'sonnet-5 正在思考' } + value: { type: 'status', message: 'sonnet-5 请求处理中' } }) await expect(stream.next()).resolves.toMatchObject({ value: { type: 'status', - message: '模型网络请求失败,正在重试(1/3)' + message: '模型网络请求失败,等待重试(1/3,0.5 秒后)' } }) const pendingRetry = stream.next() @@ -2051,7 +2058,7 @@ describe('ModelAgentRuntime', () => { expect(events).toContainEqual( expect.objectContaining({ type: 'status', - message: '模型网络请求失败,正在重试(1/3)' + message: '模型网络请求失败,等待重试(1/3,0.5 秒后)' }) ) expect(events).toContainEqual( diff --git a/src/main/agent/model-runtime.ts b/src/main/agent/model-runtime.ts index 6379e249..b0b6bdf0 100644 --- a/src/main/agent/model-runtime.ts +++ b/src/main/agent/model-runtime.ts @@ -1898,13 +1898,22 @@ export class ModelAgentRuntime implements AgentRuntime { requestId, type: 'status', message: - `模型网络请求失败,正在重试(${retryCount + 1}/` + - `${modelRequestRetryDelaysMs.length})` + `模型网络请求失败,等待重试(${retryCount + 1}/` + + `${modelRequestRetryDelaysMs.length},` + + `${modelRequestRetryDelaysMs[retryCount]! / 1_000} 秒后)` } await waitForModelRetry( modelRequestRetryDelaysMs[retryCount]!, signal ) + signal.throwIfAborted() + yield { + requestId, + type: 'status', + message: + `${this.options.model} 正在重试请求(${retryCount + 1}/` + + `${modelRequestRetryDelaysMs.length}),等待响应` + } } throw new Error('模型请求重试状态无效') } @@ -3957,7 +3966,7 @@ export class ModelAgentRuntime implements AgentRuntime { yield { requestId: request.requestId, type: 'status', - message: `${this.options.model} 正在思考` + message: `${this.options.model} 请求处理中` } const system = [ diff --git a/src/main/agent/opencode-runtime.test.ts b/src/main/agent/opencode-runtime.test.ts index 77a23aaf..50848bc3 100644 --- a/src/main/agent/opencode-runtime.test.ts +++ b/src/main/agent/opencode-runtime.test.ts @@ -288,6 +288,52 @@ async function collectRun( } describe("OpenCodeRuntime embedded launcher", () => { + it("forwards retry scheduling and resumes only on a matching native busy event", async () => { + const next = Date.now() + 120_000; + const setup = runClient([ + { + type: "session.status", + properties: { sessionID: "another-session", status: { + type: "retry", attempt: 9, message: "Unrelated retry", next, + } }, + }, + { + type: "session.status", + properties: { sessionID: "session-1", status: { + type: "retry", attempt: 2, message: "HTTP 503", next, + } }, + }, + { + type: "session.status", + properties: { sessionID: "another-session", status: { type: "busy" } }, + }, + { + type: "session.status", + properties: { sessionID: "session-1", status: { type: "busy" } }, + }, + { + type: "session.status", + properties: { sessionID: "session-1", status: { type: "busy" } }, + }, + { type: "session.idle", properties: { sessionID: "session-1" } }, + ]); + const runtime = embeddedRuntime(setup.client); + try { + const events = await collectRun(runtime); + const statuses = events.filter((event) => event.type === "status"); + expect(statuses.map((event) => event.message)).toEqual([ + "OpenCode 正在处理请求", + `OpenCode 等待第 2 次重试(计划 ${ + new Date(next).toLocaleTimeString("zh-CN", { hour12: false }) + }):HTTP 503`, + "OpenCode 已开始重试,等待后续进展", + ]); + expect(events.at(-1)).toMatchObject({ type: "done" }); + } finally { + await runtime.dispose(); + } + }); + it("checks embedded availability without starting OpenCode", async () => { const child = fakeChild(); const { diff --git a/src/main/agent/opencode-runtime.ts b/src/main/agent/opencode-runtime.ts index 038a317d..0616db30 100644 --- a/src/main/agent/opencode-runtime.ts +++ b/src/main/agent/opencode-runtime.ts @@ -2466,6 +2466,7 @@ export class OpenCodeRuntime implements AgentRuntime { return false; }; const reportedMessageIds = new Set(); + let waitingForRetry = false; for await (const event of subscription.stream) { const childProgress = subagentProgress.update(event); if ( @@ -2479,6 +2480,32 @@ export class OpenCodeRuntime implements AgentRuntime { if (task) task.subagent = childProgress; yield childProgress; } + if ( + event.type === "session.status" && + event.properties.sessionID === sessionId + ) { + const status = event.properties.status; + if (status.type === "retry") { + waitingForRetry = true; + const scheduled = Number.isSafeInteger(status.next) && status.next > 0 + ? `(计划 ${new Date(status.next).toLocaleTimeString("zh-CN", { hour12: false })})` + : ""; + yield { + requestId: request.requestId, + type: "status", + message: `OpenCode 等待第 ${status.attempt} 次重试${scheduled}:${ + safeToolErrorDetail(status.message, 1_000) || "模型请求失败" + }`, + }; + } else if (status.type === "busy" && waitingForRetry) { + waitingForRetry = false; + yield { + requestId: request.requestId, + type: "status", + message: "OpenCode 已开始重试,等待后续进展", + }; + } + } if ( event.type === "message.updated" && event.properties.sessionID === sessionId && diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index 8a0fbd49..c0218a5f 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -3378,9 +3378,9 @@ describe("App", () => { }); expect( screen - .getByText("正在连接 Agent Runtime") + .getByText("正在准备请求") .querySelector(".message__status-dot"), - ).toHaveClass("message__status-dot--active"); + ).not.toHaveClass("message__status-dot--active"); const userMessage = screen .getAllByText("帮我分析项目") .map((element) => element.closest("article")) @@ -3824,6 +3824,41 @@ describe("App", () => { expect(screen.getByText("正在分析真实推理内容")).toBeVisible(); }); + it("keeps request status truthful through retry, progress, and completion", async () => { + render(); + fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { + target: { value: "检查请求状态" }, + }); + await waitFor(() => expect(screen.getByLabelText("发送")).toBeEnabled()); + fireEvent.click(screen.getByLabelText("发送")); + await waitFor(() => expect(run).toHaveBeenCalledOnce()); + const request = run.mock.calls[0]![0]; + const retry = "OpenCode 等待第 2 次重试(计划 12:34:56):HTTP 503"; + expect(screen.getByRole("status", { name: "正在准备请求" })).toBeVisible(); + act(() => agentListener?.({ + requestId: request.requestId, type: "status", message: retry, + })); + const footer = screen.getByRole("status", { name: retry }); + expect(footer).toBeVisible(); + expect(footer.querySelector(".message__status-dot--active")).toBeNull(); + expect(screen.queryByText("正在准备请求")).not.toBeInTheDocument(); + act(() => agentListener?.({ + requestId: request.requestId, type: "text", delta: "恢复后的实际回复", + })); + expect(screen.queryByText(retry)).not.toBeInTheDocument(); + expect(screen.getByRole("status", { name: "等待后续进展" })).toBeVisible(); + act(() => agentListener?.({ + requestId: request.requestId, type: "done", + })); + expect(screen.queryByText("等待后续进展")).not.toBeInTheDocument(); + expect(screen.getByRole("status", { name: "任务执行完成" })).toBeVisible(); + act(() => agentListener?.({ + requestId: request.requestId, type: "status", message: retry, + })); + expect(screen.queryByText(retry)).not.toBeInTheDocument(); + expect(screen.getByRole("status", { name: "任务执行完成" })).toBeVisible(); + }); + it("updates context usage after model responses and keeps compression status", async () => { const settings = await api.settings.getRuntime(); vi.mocked(api.settings.getRuntime).mockResolvedValueOnce({ @@ -3903,11 +3938,11 @@ describe("App", () => { agentListener?.({ requestId: request.requestId, type: "status", - message: "sonnet-5 正在思考", + message: "sonnet-5 请求处理中", }); }); expect(screen.getByText("正在压缩较早对话…")).toBeInTheDocument(); - expect(screen.getByText("sonnet-5 正在思考")).toBeInTheDocument(); + expect(screen.queryByText("sonnet-5 请求处理中")).not.toBeInTheDocument(); act(() => { agentListener?.({ @@ -6281,9 +6316,9 @@ describe("App", () => { } expect( screen - .getByText("正在连接 Agent Runtime") + .getByText("正在准备请求") .querySelector(".message__status-dot"), - ).toHaveClass("message__status-dot--active"); + ).not.toHaveClass("message__status-dot--active"); expect(screen.getByLabelText("加入待发送队列")).toBeInTheDocument(); vi.mocked(api.conversationQueue.ready).mockClear(); return { @@ -6350,7 +6385,7 @@ describe("App", () => { ).toBeInTheDocument(); await waitFor(() => expect( - screen.queryByText("正在连接 Agent Runtime"), + screen.queryByText("正在准备请求"), ).not.toBeInTheDocument(), ); expect(document.querySelector(".message__status-dot--active")).toBeNull(); @@ -6397,7 +6432,7 @@ describe("App", () => { await screen.findByText("远端 Agent 执行失败"), ).toBeInTheDocument(); expect( - screen.queryByText("正在连接 Agent Runtime"), + screen.queryByText("正在准备请求"), ).not.toBeInTheDocument(); expect(document.querySelector(".message__status-dot--active")).toBeNull(); await waitFor(() => @@ -6427,7 +6462,7 @@ describe("App", () => { expect(api.conversations.list).toHaveBeenCalledTimes(2), ); // The local streaming copy still wins over a non-terminal snapshot. - expect(screen.getByText("正在连接 Agent Runtime")).toBeInTheDocument(); + expect(screen.getByText("正在准备请求")).toBeInTheDocument(); expect(screen.getByLabelText("加入待发送队列")).toBeInTheDocument(); expect(api.conversationQueue.ready).not.toHaveBeenCalled(); @@ -6447,7 +6482,7 @@ describe("App", () => { await screen.findByText("聚焦后读到的最终回复"), ).toBeInTheDocument(); expect( - screen.queryByText("正在连接 Agent Runtime"), + screen.queryByText("正在准备请求"), ).not.toBeInTheDocument(); await waitFor(() => expect(api.conversationQueue.ready).toHaveBeenCalledWith( diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 3c446667..6e8dad87 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -4431,6 +4431,7 @@ function App(): React.JSX.Element { ...message, tools, blocks, + status: undefined, }; }); } else if (event.type === "subagent") { @@ -7015,7 +7016,7 @@ function App(): React.JSX.Element { blocks: [], createdAt: Date.now(), state: "streaming", - status: t("runtime.connecting"), + status: t("chat.status.preparingRequest"), }; activeRuns.current.set(requestId, { diff --git a/src/renderer/src/ChatTimeline.test.tsx b/src/renderer/src/ChatTimeline.test.tsx index 0736d852..86700e74 100644 --- a/src/renderer/src/ChatTimeline.test.tsx +++ b/src/renderer/src/ChatTimeline.test.tsx @@ -66,6 +66,48 @@ describe('ChatTimeline', () => { vi.clearAllMocks() }) + it('shows only known waiting and tool states in a static message footer', () => { + const props = { + artifactById: new Map(), conversationId: 'status-conversation', + hiddenMessageCount: 0, isUnusedConversation: false, locale: 'zh-CN' as const, + messageStartIndex: 0, ...callbacks, retryContent: '', totalMessageCount: 1 + } + const message: Message = { + id: 'status-message', role: 'assistant', content: '已有内容', + createdAt: Date.now(), state: 'streaming' + } + const { rerender } = render() + expect(screen.getByRole('status', { name: '等待后续进展' })).toBeVisible() + expect(document.querySelector('.message__status-dot--active')).toBeNull() + const tool = { callId: 'read-1', name: 'read', summary: 'Read file' } + rerender() + expect(screen.getByRole('status', { name: '工具待执行:read' })).toBeVisible() + rerender() + expect(screen.getByRole('status', { name: '工具执行中:read' })).toBeVisible() + rerender() + expect(screen.getByRole('status', { name: '等待后续进展' })).toBeVisible() + rerender() + expect(screen.getByRole('status', { name: '等待你的回答' })).toBeVisible() + expect(screen.queryByText('OpenCode 正在处理请求')).not.toBeInTheDocument() + rerender() + expect(screen.getByRole('alert', { name: '模型请求失败' })).toBeVisible() + expect(screen.queryByText('等待后续进展')).not.toBeInTheDocument() + }) + it('uses actual message heights for the conversation scroll range', () => { const rule = stylesheet.match(/\.message\s*\{([^}]*)\}/u)?.[1] diff --git a/src/renderer/src/ChatTimeline.tsx b/src/renderer/src/ChatTimeline.tsx index e05a038e..c65fcb6d 100644 --- a/src/renderer/src/ChatTimeline.tsx +++ b/src/renderer/src/ChatTimeline.tsx @@ -525,6 +525,27 @@ function ChatMessageRowView({ ? t('chat.contextCompression.agentFailed') : t('chat.contextCompression.failed') + const activeTool = message.tools?.find((tool) => + tool.state === 'pending' || tool.state === 'running') + const activeSubagent = message.subagents?.some((subagent) => + subagent.state === 'queued' || subagent.state === 'running') + const activeCompression = compressionMarkers.find((marker) => + marker.state === 'compressing') + const statusText = message.role === 'assistant' && message.state === 'streaming' + ? message.question + ? t('chat.status.waitingForAnswer') + : message.approval + ? t('chat.status.waitingForApproval') + : activeCompression + ? undefined + : message.status || (activeTool + ? t(activeTool.state === 'running' + ? 'chat.status.runningTool' : 'chat.status.pendingTool', { name: activeTool.name }) + : activeSubagent + ? t('chat.status.waitingForSubagent') + : t('chat.status.waitingForProgress')) + : message.status + return ( <>
)} - {message.status && ( + {statusText && (
0 ? undefined @@ -1050,13 +1071,9 @@ function ChatMessageRowView({ >
)} {canRetry && ( diff --git a/src/renderer/src/i18n/locales/en-US/app.ts b/src/renderer/src/i18n/locales/en-US/app.ts index efaed2c1..13cb72b0 100644 --- a/src/renderer/src/i18n/locales/en-US/app.ts +++ b/src/renderer/src/i18n/locales/en-US/app.ts @@ -335,6 +335,13 @@ export const app = { loadEarlierMessages: 'Load earlier messages ({{count}} remaining)', scrollToBottom: 'Scroll to bottom', status: { + preparingRequest: 'Preparing request', + waitingForProgress: 'Waiting for further progress', + waitingForAnswer: 'Waiting for your answer', + waitingForApproval: 'Waiting for your confirmation', + pendingTool: 'Tool pending: {{name}}', + runningTool: 'Tool running: {{name}}', + waitingForSubagent: 'Waiting for subtasks to finish', responseTruncated: 'The response was too long and was truncated locally', displayCaptureTruncated: 'Later activity details were not retained locally; the Runtime task continued', diff --git a/src/renderer/src/i18n/locales/zh-CN/app.ts b/src/renderer/src/i18n/locales/zh-CN/app.ts index c7805b9c..6f55f81e 100644 --- a/src/renderer/src/i18n/locales/zh-CN/app.ts +++ b/src/renderer/src/i18n/locales/zh-CN/app.ts @@ -318,6 +318,13 @@ export const app = { loadEarlierMessages: '加载更早的消息(还剩 {{count}} 条)', scrollToBottom: '到底部', status: { + preparingRequest: '正在准备请求', + waitingForProgress: '等待后续进展', + waitingForAnswer: '等待你的回答', + waitingForApproval: '等待你的确认', + pendingTool: '工具待执行:{{name}}', + runningTool: '工具执行中:{{name}}', + waitingForSubagent: '等待子任务完成', responseTruncated: '回答过长,已在本地截断显示', displayCaptureTruncated: '活动记录过多,后续详情未在本地保留;Runtime 任务仍继续执行',