diff --git a/.monkeycode/specs/desktop-background-agent-presence/design.md b/.monkeycode/specs/desktop-background-agent-presence/design.md index e270a9bc9..d8ed7e540 100644 --- a/.monkeycode/specs/desktop-background-agent-presence/design.md +++ b/.monkeycode/specs/desktop-background-agent-presence/design.md @@ -40,11 +40,23 @@ sidecar 增加 `background: [{agentId, tcId, summary, startedAt}]`: 派发闭卡帧(driver 生成"已转后台"回执处)附带结构化标记(沿用 `progress {kind:"background_agent"}` 词汇或闭卡 update 上的专用字段),reducer 优先按结构化字段进入"保持运行态"分支;对引擎中文回执的字符串嗅探保留为旧 journal 回放 fallback,注释标注其唯一用途。 -### 2.6 停止(P3) - -- `session_call` 新增 kind `background_stop {agentId}`:映射 shell sid → engine sid 后调 `session/publishEvent`,`event_id = "bgstop:" + agentId + ":" + uuid`(幂等),`type = "host_request"`,`source = "desktop"`,message 固定模板:点名 `TaskStop(task_id=)`、要求仅执行停止并简短确认。`busy`/`not_found` 原样返回给 UI。 -- `normalize.rs` 通知轮开轮分支放宽:`source == "notification" || source == "desktop"` 共用同一幂等开轮/收轮逻辑;desktop 事件轮的正文以轻量系统行呈现。 -- TaskStop 观察:driver 按事件携带的 `event_id` 关联该轮 `tool_call`,登记 `bgstop` 待决项;见到 `TaskStop` 即确认,超时(30s)未见或轮次结束仍未见 → 经 session-event 通知 UI 回弹。停止成功的终态不需要专门处理——引擎 `manager.Complete` 照常入 `NotificationQueue`,现有 stopped 结果卡与派发卡回填链路原样工作。 +### 2.6 停止(P3,已按直通 RPC 落地) + +引擎 733df47 提供 `subagentControl` 能力(`subagent/list` / `subagent/cancel`), +§4 预留的「直通切换位」直接生效;原 publishEvent 模型中介方案从未实现,整体作废 +(连同 desktop 事件轮、TaskStop 观察与 30s 回弹——直通应答本身就是受理确认): + +- `session_call` 新增 kind `background_stop {agent_id}`:映射 shell sid → engine_id + 后直调 `subagent/cancel`,应答 `{status:"ok", state, stopped}` 原样透传。 + `stopped=false, state="stopping"` = 引擎已发出取消但 5s(shutdownGrace)内未收尾, + 子代理稍后退出时通知照常到达;引擎错误(agent/顶层会话不存在)原样上抛给 UI 外显。 +- 命令层守卫 `subagentControl` cap(契约 2 的唯一强制点);`driver/mod.rs::Caps` + 投影新增 `subagent_control` 供 UI 门控。 +- 终态不走应答:引擎 `manager.Complete` 照常入 `NotificationQueue`,现有 + `task_notification` 链路(stopped 结果卡 + 派发卡回填 + 第三态收敛)原样工作, + 不新增终态路径;通知丢失(引擎更替)由既有孤儿对账兜底。stopped 通知的派发卡 + 收卡语义细化为「已停止」(`chat.tool.bgStopped`),不再与「执行失败」混词。 +- `subagent/list` 暂未消费:pending 派生与对账已由壳侧登记覆盖,留作日后对账校验位。 ## 3. UI(ui-next) @@ -59,7 +71,11 @@ sidecar 增加 `background: [{agentId, tcId, summary, startedAt}]`: - 渲染条件:`!presentation.running && presentation.backgroundRunning > 0 && conn.connected`,占 RunBar 的插槽位; - 内容:脉冲状态点 + `chat.bg.running`(count 插值)。单任务摘要与耗时内联(`rawInput` 的 summary/description 优先,退回工具标题);多任务收起为计数,可展开逐任务一行(摘要 + 耗时 + 各自「查看子会话」,对齐工具卡组「×N 展开」形态)。取材为 items 里全部 `run+background` 卡,ChatView 供给并经签名 memo 稳定引用; - 交互:任务行点击打开对应子会话回放浮层(`childSessionId`);无主循环停止按钮; -- P3:任务行 hover 出"停止"→ 二段确认(按钮原位变"确认停止?")→ 调 `background_stop` → 行进入"停止中…";busy/超时回弹并 toast。 +- P3(已落地):任务行常驻「停止」(与「查看子会话」同形态;实现弃用了 hover 出钮—— + 同排的查看入口是常驻的,忽隐忽现反而更抓眼)→ 二段确认(按钮原位变「确认停止?」, + 4s 未点自动回弹)→ 调 `background_stop` → 行进入「停止中…」直至通知收卡;失败回弹、 + 原因在条内外显(`chat.bg.stopFailed`),可重试。停止入口按 `backgroundAgentId` 寻址, + 无 agentId 的旧 journal 卡与 `engine_caps.subagent_control` 为假时不出入口。 ### 3.3 轮末分隔线变体 @@ -76,16 +92,16 @@ sidecar 增加 `background: [{agentId, tcId, summary, startedAt}]`: ### 3.6 i18n -新增键:`chat.bg.running`、`chat.sys.turnEndBg`、`chat.tool.bgInterrupted`、停止入口与确认文案、系统通知模板。zh/en 同步。 +新增键:`chat.bg.running`、`chat.sys.turnEndBg`、`chat.tool.bgInterrupted`、停止入口与确认文案(`chat.bg.stop/stopConfirm/stopping/stopFailed`、`chat.tool.bgStopped`)、系统通知模板。zh/en 同步。 ## 4. 兼容与升级策略 - 旧 journal:无结构化标记的历史帧靠保留的嗅探 fallback 正常回放;无 sidecar `background` 字段的旧会话对账视为空集。 -- 旧引擎:`publishEvent` 已在 caps 声明,缺失时停止入口整体隐藏(探测 `engine_caps`)。 -- 升级位:引擎将来提供 `task/stop`/轮末任务快照 caps 时,`background_stop` 切直通 RPC,publishEvent 降为 fallback;派生逻辑收敛在 driver 一处,UI 无感。 +- 旧引擎:`subagentControl` 缺失时停止入口整体隐藏(探测 `engine_caps.subagent_control`,命令层同守)。 +- 升级位(已兑现):引擎 733df47 落地 `subagentControl` 直通 RPC,`background_stop` 直调 `subagent/cancel`;publishEvent fallback 不再保留(从未实现,直接按直通落地),见 §2.6。 ## 5. 测试 -- Rust(`ohmy_tests.rs`):pending 派生(两条派发路径、多任务并存);turn/stopped complete/interrupted × pending 有无的状态词;通知清理回写;sidecar 持久化写/删;引擎更替对账(合成终态帧、状态归一、正常重开不触发);desktop 事件轮幂等开轮/收轮;TaskStop 观察确认与超时回弹。 -- UI(Vitest):countItem 拆分与 presentation 全等;BackgroundBar 渲染条件、断连门控、点击定位;turn-end 变体 key;结构化标记优先 + 嗅探 fallback;停止二段确认、停止中、回弹(P3);通知触发条件(P2,mock plugin)。 +- Rust(`ohmy_tests.rs`):pending 派生(两条派发路径、多任务并存);turn/stopped complete/interrupted × pending 有无的状态词;通知清理回写;sidecar 持久化写/删;引擎更替对账(合成终态帧、状态归一、正常重开不触发);background_stop cap 守卫/agent_id 校验/RPC 形状与应答透传/引擎错误透传。 +- UI(Vitest):countItem 拆分与 presentation 全等;BackgroundBar 渲染条件、断连门控、点击定位;turn-end 变体 key;结构化标记优先 + 嗅探 fallback;停止二段确认、停止中、能力门控、失败回弹(P3);stopped 通知按「已停止」收卡;通知触发条件(P2,mock plugin)。 - 门禁:相关 Cargo tests、Vitest、TypeScript typecheck、`git diff --check`。 diff --git a/.monkeycode/specs/desktop-background-agent-presence/tasklist.md b/.monkeycode/specs/desktop-background-agent-presence/tasklist.md index 73408e9af..5d825860d 100644 --- a/.monkeycode/specs/desktop-background-agent-presence/tasklist.md +++ b/.monkeycode/specs/desktop-background-agent-presence/tasklist.md @@ -36,18 +36,20 @@ - [ ] 6.2 失焦/非当前会话的结构化通知 → 系统通知,点击聚焦并定位结果卡 - [ ] 6.3 权限缺失静默降级;触发条件测试(mock plugin) -## P3 停止(best-effort) - -- [ ] 7. Driver 停止通道 - - [ ] 7.1 `session_call` kind `background_stop`:publishEvent 组装(幂等 event_id、点名 TaskStop、busy/not_found 透传) - - [ ] 7.2 `normalize.rs` host 事件轮(source=desktop)接入开轮/收轮,轻量系统行呈现 - - [ ] 7.3 按 event_id 观察 TaskStop tool_call:确认/超时回弹(session-event 通知 UI) - - [ ] 7.4 Rust 测试:幂等开轮、观察确认、超时回弹、busy 透传 -- [ ] 8. UI 停止入口 - - [ ] 8.1 BackgroundBar 任务行与派发卡详情弹窗的"停止"入口,二段确认(提示不可恢复) - - [ ] 8.2 停止中态、busy/超时回弹与 toast - - [ ] 8.3 `engine_caps` 探测:无 publishEvent 时隐藏入口;预留 task/stop 直通切换位 - - [ ] 8.4 UI 测试:二段确认、停止中、回弹 +## P3 停止(已按 subagentControl 直通 RPC 落地;publishEvent 中介方案作废,见 design §2.6) + +- [x] 7. Driver 停止通道 + - [x] 7.1 `session_call` kind `background_stop {agent_id}`:直调 `subagent/cancel`(cap 守卫、engine_id 寻址、应答/错误原样透传) + - [x] 7.2 `Caps` 投影新增 `subagent_control`(engine_caps → UI 门控) + - [x] 7.3 Rust 测试:cap 缺失拒绝、agent_id 校验、RPC 形状与应答透传、引擎错误透传 + - ~~publishEvent 组装 / desktop 事件轮 / TaskStop 观察与超时回弹~~(直通 RPC 后不需要:应答即受理确认,终态归 task_notification 既有链路) +- [x] 8. UI 停止入口 + - [x] 8.1 BackgroundBar 任务行常驻「停止」,二段确认(原位变「确认停止?」,4s 自动回弹) + - [x] 8.2 停止中态(等 task_notification 收卡)、失败回弹 + 条内原因外显(chat.bg.stopFailed) + - [x] 8.3 `engine_caps.subagent_control` 门控:无能力/无 agentId(旧 journal 卡)隐藏入口 + - [x] 8.4 stopped 终态状态词:通知按「已停止」收卡(chat.tool.bgStopped),不再混用「执行失败」 + - [x] 8.5 UI 测试:二段确认、停止中、能力门控、失败回弹;reduce stopped 收卡 + - [ ] 8.6 派发卡详情弹窗的停止入口(暂缓:状态条为唯一入口) ## 验证 diff --git a/agent b/agent index 733df4706..686dcb487 160000 --- a/agent +++ b/agent @@ -1 +1 @@ -Subproject commit 733df47060ff80769f3cfbfe6b8af1440df514d7 +Subproject commit 686dcb487964d3a79f4c3db868973bf3b5984448 diff --git a/desktop/ARCHITECTURE.md b/desktop/ARCHITECTURE.md index 871c95eb7..76a8b907b 100644 --- a/desktop/ARCHITECTURE.md +++ b/desktop/ARCHITECTURE.md @@ -49,7 +49,7 @@ | 角色 | 位置 | |---|---| | 产帧(壳,引擎事件归一化) | src/driver/frame.rs(唯一入口,禁手拼 JSON) | -| 消费(UI) | ui-next/src/lib/protocol/{types,reduce}.ts(Frame/SessionStatus/PermOutcome/EngineStatus 由 ts-rs 自 frame.rs 生成到 ui/src/gen/ 并同步一份到 ui-next/src/gen/(genSync.test.ts 钉字节一致),types.ts 复用) | +| 消费(UI) | ui-next/src/lib/protocol/{types,reduce}.ts(Frame/SessionStatus/PermOutcome/EngineStatus 由 ts-rs 自 frame.rs 直接生成到 ui-next/src/gen/,types.ts 复用;旧工程 ui/ 已删,genSync 并行期契约随之退役) | 帧结构 `{type, kind?, data?(内联 JSON), timestamp(ms), seq}`——data 的 base64 双重编码已去除,只剩存量 journal 与云端帧两条兼容边界; @@ -64,8 +64,9 @@ usage_update/compact_status/task_notification(后台子代理完成通知)/ model_update/think_update(会话思考档变更)/permission_mode_update`。 `llm_call_retry` 仅云端流产出,壳不产,UI 归约兼容。 -改词汇的顺序:frame.rs 是唯一权威(ts-rs 重新生成 ui/src/gen/,ui-next/src/gen/ 随之同步),与 -types.ts/reduce.ts 同一 PR 内同步,reduce.test.ts 补对应归约断言。 +改词汇的顺序:frame.rs 是唯一权威(cargo test 经 ts-rs 重新生成 +ui-next/src/gen/),与 types.ts/reduce.ts 同一 PR 内同步,reduce.test.ts +补对应归约断言。 **折叠是等价变换**(driver/fold.rs):journal 是「一 token 一帧」,回放前把 相邻同类流式碎片合并回一帧(usage/plan 每轮只留最后一条,其余原样)。 @@ -201,7 +202,7 @@ Windows 不得用不能覆盖既有目标的裸 `std::fs::rename`。 ## 契约 5:会话状态机 -状态词汇(Rust `frame::SessionStatus`,ts-rs 生成 ui/src/gen/SessionStatus.ts,ui-next/src/gen/ 同步一份): +状态词汇(Rust `frame::SessionStatus`,ts-rs 生成 ui-next/src/gen/SessionStatus.ts): `created → running → idle | finished | interrupted | error` - `created` = 新建未运行,**不是完成**(否则侧栏/桌宠按完成渲染)。 - `idle` = 当前轮正常结束、会话空闲可继续;`finished` 留给真正结束的 @@ -386,6 +387,10 @@ interactive:true,引擎给持久 Task 工具族)、会话思考档 session/setTh 直调自动提升)、子代理事件带 parent_session_id/parent_tool_call_id (dab1b85,壳精确认领)。固定引擎已删除“同步 Agent 超时后自动转后台”, 后台执行必须由 `run_in_background:true` 显式声明。 +子代理控制面(733df47,cap `subagentControl`:`subagent/list` + +`subagent/cancel`):壳经 `session_call background_stop` 直通 cancel +(UI 后台状态条的停止入口,Caps.subagent_control 门控);list 暂未消费, +pending 派生/对账仍以壳侧登记为权威。 ## 开发与构建产物 diff --git a/desktop/src/driver/frame.rs b/desktop/src/driver/frame.rs index 40e62ce92..6ba45ba4e 100644 --- a/desktop/src/driver/frame.rs +++ b/desktop/src/driver/frame.rs @@ -1,7 +1,7 @@ // Frame 词汇的唯一定义(产帧权威;词汇源自旧 Go 内核的 frame.go, // git 历史可查)。 // -// 对表:本模块产帧、UI 消费(desktop/ui/src/{types.ts,reduce.ts})—— +// 对表:本模块产帧、UI 消费(ui-next/src/lib/protocol/{types,reduce}.ts)—— // 任何新帧类型/字段先改这里与 types.ts,driver 禁止手拼 Frame JSON。 // // 帧结构:{ type, kind?, data?(内联 JSON 对象), timestamp(ms), seq } @@ -17,20 +17,20 @@ // Rust→TS 类型生成:Frame/SessionStatus/PermOutcome 带 ts_rs 导出 // (derive 经 cfg_attr(test) 门控,不进产物二进制)。再生成命令: // cargo test export_bindings -// 产出 desktop/ui/src/gen/(生成物入库,勿手改);ui/src/types.ts 从 +// 产出 desktop/ui-next/src/gen/(生成物入库,勿手改);protocol/types.ts 从 // gen/ 复用这些类型,手写与注释对表自此只剩"跑一次生成"。 use base64::Engine as _; use serde_json::{json, Value}; /// 会话状态词汇(SessionMeta.status;UI/桌宠按此渲染,勿用裸字符串)。 -/// ts-rs 导出 → ui/src/gen/SessionStatus.ts(types.ts 复用); +/// ts-rs 导出 → ui-next/src/gen/SessionStatus.ts(types.ts 复用); /// rename_all 小写与 as_str 一致(两处同改才算改)。 #[derive(Clone, Copy, PartialEq, Eq, Debug)] #[cfg_attr(test, derive(ts_rs::TS))] #[cfg_attr( test, - ts(export, export_to = "../ui/src/gen/", rename_all = "lowercase") + ts(export, export_to = "../ui-next/src/gen/", rename_all = "lowercase") )] pub enum SessionStatus { Created, @@ -65,7 +65,7 @@ impl SessionStatus { #[cfg_attr(test, derive(ts_rs::TS))] #[cfg_attr( test, - ts(export, export_to = "../ui/src/gen/", rename_all = "lowercase") + ts(export, export_to = "../ui-next/src/gen/", rename_all = "lowercase") )] pub enum PermOutcome { Approved, @@ -103,7 +103,7 @@ pub fn b64_text(s: &str) -> String { /// timestamp 以容存量 journal 与云端帧,见 ui/src/types.ts::Frame)。 #[derive(serde::Serialize)] #[cfg_attr(test, derive(ts_rs::TS))] -#[cfg_attr(test, ts(export, export_to = "../ui/src/gen/"))] +#[cfg_attr(test, ts(export, export_to = "../ui-next/src/gen/"))] pub struct Frame { pub r#type: String, #[serde(skip_serializing_if = "Option::is_none")] diff --git a/desktop/src/driver/mod.rs b/desktop/src/driver/mod.rs index 921e1d464..f32ecda4d 100644 --- a/desktop/src/driver/mod.rs +++ b/desktop/src/driver/mod.rs @@ -44,7 +44,7 @@ pub const ENGINE_STABLE_UPTIME: Duration = Duration::from_secs(60); #[derive(Clone, Debug, PartialEq, serde::Serialize)] #[serde(tag = "phase", rename_all = "lowercase")] #[cfg_attr(test, derive(ts_rs::TS))] -#[cfg_attr(test, ts(export, export_to = "../ui/src/gen/"))] +#[cfg_attr(test, ts(export, export_to = "../ui-next/src/gen/"))] pub enum EngineStatus { /// 未启动:冷启动前、或应用退出/重启的中间态。 Stopped, @@ -286,7 +286,7 @@ impl DriverHost { } } -/// 引擎能力表(对表 desktop/ui/src/types.ts 的 EngineCaps)。 +/// 引擎能力表(对表 ui-next/src/lib/ipc/approvals.ts 的 EngineCaps)。 /// 能力仍是渐进的(随上游补齐翻位),由 ready 握手与桌面壳实际能力 /// 共同投影;UI 降级与命令层守卫都读取同一份运行时结果。 #[derive(Clone, Copy, serde::Serialize)] @@ -299,6 +299,8 @@ pub struct Caps { /// 运行中向当前轮追加用户指令。 pub steering: bool, pub attachments: bool, + /// 定向停止后台子代理(subagent/cancel;UI 后台状态条的停止入口)。 + pub subagent_control: bool, } pub fn caps(engine: &OhmyDriver, browser_ext: bool) -> Caps { @@ -315,6 +317,7 @@ pub fn caps(engine: &OhmyDriver, browser_ext: bool) -> Caps { steering: engine.has_capability("session/steer"), // 上传/路径注入由壳实现,不是引擎握手项。 attachments: true, + subagent_control: engine.has_capability("subagentControl"), } } @@ -527,6 +530,7 @@ pub async fn session_call( let ctx = RepoCtx { workdir, wsl_distro: engine.wsl_distro(), + wsl_mount_root: engine.wsl_mount_root(), }; // git/fs 是阻塞操作,丢 blocking 池;15s 超时防文件面板永久转圈 let task = tauri::async_runtime::spawn_blocking(move || { diff --git a/desktop/src/driver/ohmy.rs b/desktop/src/driver/ohmy.rs index 229c0f890..71ff10d06 100644 --- a/desktop/src/driver/ohmy.rs +++ b/desktop/src/driver/ohmy.rs @@ -103,6 +103,21 @@ impl OhmyDriver { self.0.wsl.as_ref().map(|w| w.distro.clone()) } + /// WSL automount 根;repo 等宿主文件操作用它把 `/mnt/c/...` 精确反解 + /// 回 Windows 盘符路径。与 distro 一样随引擎实例更新。 + pub fn wsl_mount_root(&self) -> Option { + self.0.wsl.as_ref().map(|w| w.mount_root.clone()) + } + + /// 会话 guest 路径的宿主文件系统视角。本机模式原样返回;WSL 的盘符 + /// automount 反解为 `C:\...`,真正的 Linux 路径才走 `\\wsl$`。 + pub fn host_fs_view(&self, path: &str) -> PathBuf { + match &self.0.wsl { + Some(w) => crate::wsl::host_fs_view_with_mount_root(&w.distro, &w.mount_root, path), + None => PathBuf::from(path), + } + } + /// WSL guest 网络模式("mirrored"/"nat";本机模式 None)。 /// 浏览器 MCP 等回连宿主 127.0.0.1 的能力按此降级。 pub fn wsl_networking(&self) -> Option { diff --git a/desktop/src/driver/ohmy_tests.rs b/desktop/src/driver/ohmy_tests.rs index 89314edfd..9e29444a5 100644 --- a/desktop/src/driver/ohmy_tests.rs +++ b/desktop/src/driver/ohmy_tests.rs @@ -3788,6 +3788,7 @@ fn public_caps_follow_the_ready_handshake() { let mut engine_caps = inner.transport.engine_caps.lock().unwrap(); engine_caps.insert("turn/stopped".into()); engine_caps.insert("session/steer".into()); + engine_caps.insert("subagentControl".into()); } let driver = OhmyDriver(inner); let caps = crate::driver::caps(&driver, false); @@ -3796,6 +3797,155 @@ fn public_caps_follow_the_ready_handshake() { assert!(!caps.perm_remember); assert!(caps.steering); assert!(caps.attachments); + assert!(caps.subagent_control); +} + +/// background_stop:subagentControl 缺失时命令层拒绝,不下发 RPC。 +#[tokio::test] +async fn background_stop_requires_the_subagent_control_capability() { + let (inner, _events, mut stdin_rx) = bare_inner_rpc("bgstop-no-cap"); + inner + .sess + .sessions + .lock() + .unwrap() + .insert("s1".into(), bare_session("s1")); + let driver = OhmyDriver(inner.clone()); + + let error = driver + .session_call("s1", "background_stop", json!({ "agent_id": "ag-1" })) + .await + .unwrap_err(); + + assert!(error.contains("不支持"), "应提示引擎能力缺口: {error}"); + assert!(stdin_rx.try_recv().is_err(), "能力缺失不得下发 RPC"); +} + +/// background_stop:agent_id 缺失/空白在本地快速失败。 +#[tokio::test] +async fn background_stop_requires_a_nonempty_agent_id() { + let (inner, _events, mut stdin_rx) = bare_inner_rpc("bgstop-agent-id"); + inner + .transport + .engine_caps + .lock() + .unwrap() + .insert("subagentControl".into()); + inner + .sess + .sessions + .lock() + .unwrap() + .insert("s1".into(), bare_session("s1")); + let driver = OhmyDriver(inner.clone()); + + let error = driver + .session_call("s1", "background_stop", json!({ "agent_id": " " })) + .await + .unwrap_err(); + + assert!( + error.contains("agent_id"), + "错误应指出缺少 agent_id: {error}" + ); + assert!(stdin_rx.try_recv().is_err(), "非法 agent_id 不得下发 RPC"); +} + +/// background_stop 直通 subagent/cancel:按 engine_id 寻址、应答原样透传 +/// (stopped=false/state=stopping 表示引擎仍在收尾)。终态不在应答里处理 +/// ——收尾归 task_notification 权威路径。 +#[tokio::test] +async fn background_stop_forwards_subagent_cancel_and_passes_the_reply_through() { + let (inner, _events, mut stdin_rx) = bare_inner_rpc("bgstop-forward"); + inner + .transport + .engine_caps + .lock() + .unwrap() + .insert("subagentControl".into()); + let mut session = bare_session("s1"); + session.running = false; // 后台第三态:主循环空闲时也可停止 + session.engine_id = "eng-1".into(); // 换绑过的会话必须按 engine_id 寻址 + inner + .sess + .sessions + .lock() + .unwrap() + .insert("s1".into(), session); + let driver = OhmyDriver(inner.clone()); + + let call = tokio::spawn(async move { + driver + .session_call("s1", "background_stop", json!({ "agent_id": "ag-7" })) + .await + }); + let request: Value = serde_json::from_str( + &stdin_rx + .recv() + .await + .expect("RPC 出站") + .expect("非 shutdown 消息"), + ) + .unwrap(); + assert_eq!(request["method"], "subagent/cancel"); + assert_eq!( + request["params"], + json!({ "session_id": "eng-1", "agent_id": "ag-7" }) + ); + answer_rpc( + &inner, + &request, + json!({ "jsonrpc": "2.0", "id": request["id"], + "result": { "status": "ok", "state": "stopping", "stopped": false } }), + ); + assert_eq!( + call.await.unwrap().unwrap(), + json!({ "result": { "status": "ok", "state": "stopping", "stopped": false } }) + ); +} + +/// 引擎侧错误(agent 不存在/顶层会话不在)原样上抛给 UI 外显。 +#[tokio::test] +async fn background_stop_surfaces_engine_errors() { + let (inner, _events, mut stdin_rx) = bare_inner_rpc("bgstop-error"); + inner + .transport + .engine_caps + .lock() + .unwrap() + .insert("subagentControl".into()); + inner + .sess + .sessions + .lock() + .unwrap() + .insert("s1".into(), bare_session("s1")); + let driver = OhmyDriver(inner.clone()); + + let call = tokio::spawn(async move { + driver + .session_call("s1", "background_stop", json!({ "agent_id": "gone" })) + .await + }); + let request: Value = serde_json::from_str( + &stdin_rx + .recv() + .await + .expect("RPC 出站") + .expect("非 shutdown 消息"), + ) + .unwrap(); + answer_rpc( + &inner, + &request, + json!({ "jsonrpc": "2.0", "id": request["id"], + "error": { "code": -32000, "message": "sub-agent not found: gone" } }), + ); + let error = call.await.unwrap().unwrap_err(); + assert!( + error.contains("sub-agent not found"), + "引擎错误应透传: {error}" + ); } fn answer_rpc(inner: &Inner, request: &Value, response: Value) { diff --git a/desktop/src/driver/session.rs b/desktop/src/driver/session.rs index 69fcf7b25..061b10eb1 100644 --- a/desktop/src/driver/session.rs +++ b/desktop/src/driver/session.rs @@ -615,27 +615,20 @@ mod chat_workdir_tests { .join("chat-workspaces") } - /// 对话工作区在 WSL 下**不能**用宿主视角验存在性——这条把当年的失败 - /// 现场固定下来(2026-08-09 用户报障:选了 WSL 内核后开普通对话必报 - /// 「工作区目录不存在: /mnt/c/Users/…/chat-workspaces/chat-xxxx」)。 - /// - /// 成因:chat 根落在 Windows 侧,guest 形态是 /mnt/c/…;旧代码把它交给 - /// 「guest 路径 → \\wsl$\<发行版>\… 」的宿主视角去 is_dir(),包出来是 - /// 下面这个串——Windows 穿不过 WSL 共享上的 drvfs 挂载点,于是壳刚 - /// create_dir 成功的目录被判成不存在。本机模式恰好等价,所以只在 - /// Windows+WSL 上现形,Linux 冒烟(guest==host)也复现不了。 + /// chat 根落在 Windows 盘,guest 形态是 /mnt/c/…;宿主文件操作必须 + /// 反解回 C:\…,不能走 Windows 穿不过去的 \\wsl$\…\mnt\c\…。 #[test] - fn host_view_of_a_chat_workdir_under_wsl_is_an_unusable_unc_path() { + fn host_view_of_a_chat_workdir_under_wsl_maps_back_to_the_drive() { let guest = "/mnt/c/Users/phxa1/AppData/Local/com.chaitin.baizhi.monkeycode\ /chat-workspaces/chat-3a6c477966006def8049"; let host_view = crate::wsl::host_fs_view("Ubuntu-22.04", guest); - // 只在 Windows 上才拼 UNC(其余平台恒等,见 wsl::host_fs_view) + // 非 Windows 的假 WSL 冒烟保持 guest == host。 if cfg!(windows) { - assert!( - host_view - .to_string_lossy() - .starts_with(r"\\wsl$\Ubuntu-22.04\mnt\c\"), - "宿主视角会把 chat 的 guest 路径包成穿不过去的 UNC: {host_view:?}" + assert_eq!( + host_view, + PathBuf::from( + r"C:\Users\phxa1\AppData\Local\com.chaitin.baizhi.monkeycode\chat-workspaces\chat-3a6c477966006def8049" + ) ); } // 平台无关的那半条:chat 目录由 create_chat_workdir_in 在宿主建成 @@ -3025,6 +3018,39 @@ impl OhmyDriver { } Ok(json!({ "result": { "status": "queued" } })) } + // 定向停止一个后台子代理(subagent/cancel 直通)。应答只驱动 UI 的 + // 「停止中」瞬态;终态回填仍由 task_notification 权威路径完成 + // (引擎 manager.Complete 照常入通知队列),不新增终态路径—— + // 通知丢失(引擎更替)也有既有孤儿对账兜底。 + "background_stop" => { + if !self.has_cap("subagentControl") { + return Err("当前引擎版本不支持停止后台子代理,请升级引擎".into()); + } + let agent_id = payload + .get("agent_id") + .and_then(Value::as_str) + .map(str::trim) + .filter(|a| !a.is_empty()) + .ok_or_else(|| "停止指令缺少非空 agent_id".to_string())? + .to_string(); + let engine_id = { + let sessions = self.0.sess.sessions.lock_ok(); + let Some(session) = sessions.get(id) else { + return Err("会话未打开".into()); + }; + session.engine_id.clone() + }; + // {status:"ok", state, stopped} 原样透传:stopped=false 且 + // state="stopping" 表示引擎已发出取消但 5s 内未收尾,子代理 + // 稍后退出时通知照常到达 + let response = self + .rpc( + "subagent/cancel", + json!({ "session_id": engine_id, "agent_id": agent_id }), + ) + .await?; + Ok(json!({ "result": response })) + } "session_set_model" => { let name = payload.get("model").and_then(|v| v.as_str()).unwrap_or(""); let model_id = self.model_id_of(name)?; // 前置校验,未知模型不动会话 diff --git a/desktop/src/main.rs b/desktop/src/main.rs index 88623a4e7..68c5feabc 100644 --- a/desktop/src/main.rs +++ b/desktop/src/main.rs @@ -1282,6 +1282,10 @@ fn ensure_pet_window(app: &AppHandle) { .resizable(cfg!(target_os = "linux")) .maximizable(false) .minimizable(false) + // macOS 桌宠会转成 non-activating NSPanel;应用失活时 AppKit 默认 + // 吃掉首击,WKWebView 收不到 mousedown/up,pet.html 就不会发 + // show_main。允许首击直达页面,同时也让第一次拖动立即生效。 + .accept_first_mouse(true) .focusable(false) .focused(false) .visible(false) diff --git a/desktop/src/preview.rs b/desktop/src/preview.rs index 64572cb7c..57088ff04 100644 --- a/desktop/src/preview.rs +++ b/desktop/src/preview.rs @@ -28,6 +28,14 @@ const PREVIEW_RESULT_SCHEME: &str = "monkeycode-preview-result"; const MAX_RESULT_BYTES: usize = 32 * 1024; static PICKER_ACTIVE: AtomicBool = AtomicBool::new(false); static PREVIEW_ZOOM: Mutex = Mutex::new(1.0); +/// preview 命令族的单车道。全族 async 化(见各命令)后不再内联于主线程的 +/// webview IPC 回调——此前它们是同步命令,一个 `preview_create` 在 Windows +/// 上挂死(wry 建 WebView2 走 wait_with_pump 嵌套消息泵,期间重入的 +/// preview IPC 可对半建的 webview 做 close/再建,把泵绞死),整个 IPC +/// 派发路径连同取消一起报废(2026-08-31 报障「preview_create 挂起后 +/// 后续所有 IPC 全挂」)。async 让 IPC 回调立即返回;这把锁恢复原先 +/// 主线程串行给到的次序保证,并从根上杜绝创建期重入。 +static PREVIEW_LANE: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); const SERIALIZE_SCHEME: &str = "monkeycode-serialize"; const SERIALIZE_JS: &str = r#"(()=>{window.__mcSerialize=async id=>{try{const html='\n'+new XMLSerializer().serializeToString(document.documentElement);if(new TextEncoder().encode(html).length>8388608)throw Error('HTML 超过 8 MiB 限制');const n=4000,total=Math.ceil(html.length/n);for(let i=0;isetTimeout(r,0))}}catch(e){location.href=`monkeycode-serialize://${id}?error=${encodeURIComponent(String(e?.message||e))}`}}})()"#; @@ -671,6 +679,27 @@ fn is_artifact_url(url: &Url) -> bool { || matches!(url.scheme(), "http" | "https") && url.host_str() == Some(ARTIFACT_HTTP_HOST) } +/// 自定义协议 URL 的 http 桥接形态(`http://monkeycode-artifact.localhost/…`)。 +/// Windows 上 WebView2 导航不了原生自定义 scheme——tauri 的自定义协议在 +/// Windows/Android 只经此形态可达(tauri app.rs 文档),而 tauri 对 +/// `WebviewUrl::CustomProtocol` 的入口 URL 原样透传不做平台转换。路径保持 +/// 已编码形态原样拼接,不二次编码。 +fn bridged_artifact_url(url: &Url) -> Result { + Url::parse(&format!("http://{ARTIFACT_HTTP_HOST}{}", url.path())) + .map_err(|e| format!("无法创建桥接预览地址: {e}")) +} + +/// artifact 入口按平台选形态:Windows 走 http 桥接(否则永远空白, +/// 2026-08-31 报障「本地 html 无法预览」;导航白名单 is_artifact_url 早已 +/// 两种形态都认,此前唯独漏了入口);macOS/Linux 走真自定义协议。 +fn artifact_webview_url(url: Url) -> Result { + if cfg!(windows) { + Ok(WebviewUrl::External(bridged_artifact_url(&url)?)) + } else { + Ok(WebviewUrl::CustomProtocol(url)) + } +} + fn create_preview( app: AppHandle, url: Url, @@ -685,7 +714,7 @@ fn create_preview( let callback_app = app.clone(); let navigation_root = artifact_root.clone(); let webview_url = if is_artifact_url(&url) { - WebviewUrl::CustomProtocol(url) + artifact_webview_url(url)? } else { WebviewUrl::External(url) }; @@ -704,11 +733,17 @@ fn create_preview( } else if let Some(result) = capture_result(url) { match result { Ok(Some((request_id, data_url, copy_to_clipboard))) => { - let clipboard_error = copy_to_clipboard - .then(|| copy_capture_to_clipboard(&data_url)) - .transpose() - .err(); - let _ = callback_app.emit_to("main", "preview-captured", serde_json::json!({"requestId":request_id,"dataUrl":data_url,"clipboardError":clipboard_error})); + // 本回调在主线程消息泵里跑;Windows 系统剪贴板是全局锁 + // (OpenClipboard 可被他程占住而阻塞),写入挪去独立线程, + // 完成后再发事件 + let app = callback_app.clone(); + std::thread::spawn(move || { + let clipboard_error = copy_to_clipboard + .then(|| copy_capture_to_clipboard(&data_url)) + .transpose() + .err(); + let _ = app.emit_to("main", "preview-captured", serde_json::json!({"requestId":request_id,"dataUrl":data_url,"clipboardError":clipboard_error})); + }); } Ok(None) => {} Err(error) => { let _ = callback_app.emit_to("main", "preview-capture-error", serde_json::json!({"requestId":url.host_str().unwrap_or(""),"error":error})); } @@ -753,8 +788,22 @@ fn create_preview( Ok(()) } +/// Linux 上原生子 webview 不可用:上游 tauri-runtime-wry 把 WindowChild +/// `pack_start` 进窗口的垂直 GtkBox(非定位叠放),窗口被对半分、预览整宽 +/// 落底,且 wry 的 set_bounds 仅在 gtk::Fixed 父容器下生效——摆位全部空转 +/// (2026-08-31 报障截图)。UI 侧已降级为 DOM 内嵌 iframe +/// (DesignPreviewWorkbench 的 inlineFallback),此处硬拒防回归。 +fn reject_native_preview_on_linux() -> Result<(), String> { + if cfg!(target_os = "linux") { + return Err("Linux 上原生预览不可用(上游子 webview 无定位能力),应使用内嵌预览".into()); + } + Ok(()) +} + #[tauri::command] -pub fn preview_create(app: AppHandle, url: String, bounds: PreviewBounds) -> Result<(), String> { +pub async fn preview_create(app: AppHandle, url: String, bounds: PreviewBounds) -> Result<(), String> { + reject_native_preview_on_linux()?; + let _lane = PREVIEW_LANE.lock().await; #[cfg(debug_assertions)] eprintln!("[preview-lifecycle] create url={url}"); *ARTIFACT_SITE.lock().map_err(|_| "预览资源状态锁损坏")? = None; @@ -771,15 +820,14 @@ pub async fn preview_create_artifact( ) -> Result<(), String> { #[cfg(debug_assertions)] eprintln!("[preview-lifecycle] create-artifact id={id} path={path}"); + reject_native_preview_on_linux()?; let engine = host.get()?; let workdir = engine.session_workdir(&id).await?; - let fs_root = match engine.wsl_distro() { - Some(distro) => crate::wsl::host_fs_view(&distro, &workdir), - None => PathBuf::from(workdir), - }; + let fs_root = engine.host_fs_view(&workdir); let site = artifact_preview_file(&fs_root, &path)?; let url = artifact_entry_url(&site)?; let root = site.root.clone(); + let _lane = PREVIEW_LANE.lock().await; *ARTIFACT_SITE.lock().map_err(|_| "预览资源状态锁损坏")? = Some(site); if let Err(error) = create_preview(app, url, bounds, Some(root)) { if let Ok(mut active) = ARTIFACT_SITE.lock() { @@ -790,15 +838,18 @@ pub async fn preview_create_artifact( Ok(()) } #[tauri::command] -pub fn preview_show(app: AppHandle) -> Result<(), String> { +pub async fn preview_show(app: AppHandle) -> Result<(), String> { + let _lane = PREVIEW_LANE.lock().await; webview(&app)?.show().map_err(|e| e.to_string()) } #[tauri::command] -pub fn preview_hide(app: AppHandle) -> Result<(), String> { +pub async fn preview_hide(app: AppHandle) -> Result<(), String> { + let _lane = PREVIEW_LANE.lock().await; webview(&app)?.hide().map_err(|e| e.to_string()) } #[tauri::command] -pub fn preview_set_bounds(app: AppHandle, bounds: PreviewBounds) -> Result<(), String> { +pub async fn preview_set_bounds(app: AppHandle, bounds: PreviewBounds) -> Result<(), String> { + let _lane = PREVIEW_LANE.lock().await; let b = bounds.validate()?; let v = webview(&app)?; v.set_position(LogicalPosition::new(b.x, b.y)) @@ -808,25 +859,29 @@ pub fn preview_set_bounds(app: AppHandle, bounds: PreviewBounds) -> Result<(), S apply_zoom(&v, preview_zoom()?) } #[tauri::command] -pub fn preview_navigate(app: AppHandle, url: String) -> Result<(), String> { +pub async fn preview_navigate(app: AppHandle, url: String) -> Result<(), String> { + let _lane = PREVIEW_LANE.lock().await; webview(&app)? .navigate(preview_url(&url)?) .map_err(|e| e.to_string()) } #[tauri::command] -pub fn preview_reload(app: AppHandle) -> Result<(), String> { +pub async fn preview_reload(app: AppHandle) -> Result<(), String> { + let _lane = PREVIEW_LANE.lock().await; webview(&app)?.reload().map_err(|e| e.to_string()) } #[tauri::command] -pub fn preview_set_zoom(app: AppHandle, scale: f64) -> Result<(), String> { +pub async fn preview_set_zoom(app: AppHandle, scale: f64) -> Result<(), String> { if !scale.is_finite() || !(0.1..=5.0).contains(&scale) { return Err("缩放比例必须在 10% 到 500% 之间".into()); } + let _lane = PREVIEW_LANE.lock().await; *PREVIEW_ZOOM.lock().map_err(|_| "预览缩放状态锁损坏")? = scale; apply_zoom(&webview(&app)?, scale) } #[tauri::command] -pub fn preview_destroy(app: AppHandle) -> Result<(), String> { +pub async fn preview_destroy(app: AppHandle) -> Result<(), String> { + let _lane = PREVIEW_LANE.lock().await; #[cfg(debug_assertions)] eprintln!("[preview-lifecycle] destroy"); PICKER_ACTIVE.store(false, Ordering::Relaxed); @@ -837,12 +892,13 @@ pub fn preview_destroy(app: AppHandle) -> Result<(), String> { Ok(()) } #[tauri::command] -pub fn preview_result_show( +pub async fn preview_result_show( app: AppHandle, data_url: String, status: String, comment_count: usize, ) -> Result<(), String> { + let _lane = PREVIEW_LANE.lock().await; if !data_url.starts_with("data:image/png;base64,") || data_url.len() > MAX_CAPTURE_BYTES * 4 / 3 + 64 || status.chars().count() > 500 @@ -864,7 +920,8 @@ pub fn preview_result_show( } #[tauri::command] -pub fn preview_result_hide(app: AppHandle) -> Result<(), String> { +pub async fn preview_result_hide(app: AppHandle) -> Result<(), String> { + let _lane = PREVIEW_LANE.lock().await; webview(&app)? .eval(format!( "{PREVIEW_RESULT_JS};window.__mcPreviewResultHide()" @@ -873,7 +930,8 @@ pub fn preview_result_hide(app: AppHandle) -> Result<(), String> { } #[tauri::command] -pub fn preview_picker_toggle(app: AppHandle, enabled: bool) -> Result<(), String> { +pub async fn preview_picker_toggle(app: AppHandle, enabled: bool) -> Result<(), String> { + let _lane = PREVIEW_LANE.lock().await; let arg = serde_json::to_string(&enabled).unwrap(); webview(&app)? .eval(format!("{PICKER_JS};window.__mcPicker.toggle({arg})")) @@ -882,7 +940,8 @@ pub fn preview_picker_toggle(app: AppHandle, enabled: bool) -> Result<(), String Ok(()) } #[tauri::command] -pub fn preview_element_apply(app: AppHandle, edit: ElementEdit) -> Result<(), String> { +pub async fn preview_element_apply(app: AppHandle, edit: ElementEdit) -> Result<(), String> { + let _lane = PREVIEW_LANE.lock().await; if !valid_selector(&edit.selector) || !(edit.property == "delete" || edit.property == "text" @@ -930,17 +989,19 @@ pub fn preview_element_apply(app: AppHandle, edit: ElementEdit) -> Result<(), St .map_err(|e| e.to_string()) } #[tauri::command] -pub fn preview_element_undo(app: AppHandle) -> Result<(), String> { +pub async fn preview_element_undo(app: AppHandle) -> Result<(), String> { + let _lane = PREVIEW_LANE.lock().await; webview(&app)? .eval("window.__mcPicker&&window.__mcPicker.undoOne()") .map_err(|e| e.to_string()) } #[tauri::command] -pub fn preview_serialize(app: AppHandle, request_id: String) -> Result<(), String> { +pub async fn preview_serialize(app: AppHandle, request_id: String) -> Result<(), String> { if request_id.len() != 32 || !request_id.bytes().all(|b| b.is_ascii_hexdigit()) { return Err("序列化请求 ID 无效".into()); } + let _lane = PREVIEW_LANE.lock().await; let id = serde_json::to_string(&request_id).unwrap(); webview(&app)? .eval(format!("{SERIALIZE_JS};window.__mcSerialize({id})")) @@ -1212,13 +1273,14 @@ mod native_capture { } #[tauri::command] -pub fn preview_capture(app: AppHandle, mode: String, request_id: String) -> Result<(), String> { +pub async fn preview_capture(app: AppHandle, mode: String, request_id: String) -> Result<(), String> { if !matches!(mode.as_str(), "viewport" | "viewport-no-copy" | "full") || request_id.len() != 32 || !request_id.bytes().all(|b| b.is_ascii_hexdigit()) { return Err("截图参数无效".into()); } + let _lane = PREVIEW_LANE.lock().await; #[cfg(target_os = "macos")] { return native_capture::start(app, mode, request_id); @@ -1246,6 +1308,15 @@ mod tests { assert!(preview_url("http://localhost:3000").is_ok()); assert!(preview_url("https://localhost.evil").is_err()); } + /// Windows 入口桥接:路径已编码段原样保留,不二次编码。 + #[test] + fn artifact_bridge_preserves_encoded_path() { + let url = Url::parse("monkeycode-artifact://localhost/__workspace__/a%20b/index.html").unwrap(); + assert_eq!( + bridged_artifact_url(&url).unwrap().as_str(), + "http://monkeycode-artifact.localhost/__workspace__/a%20b/index.html" + ); + } #[test] fn edit_validation() { assert!(valid_selector("#app > p")); diff --git a/desktop/src/repo.rs b/desktop/src/repo.rs index 04a9e4cdd..8219f4a3d 100644 --- a/desktop/src/repo.rs +++ b/desktop/src/repo.rs @@ -2,9 +2,10 @@ // 应答字段与内核 WS call 完全对齐:{result} / {error},UI 归约层零改动。 // 全部操作强制限定在工作区目录内。 // -// WSL 模式(kernel_env = "wsl:<发行版>"):workdir 是 guest 内 Linux 路径, -// 文件系统操作经 \\wsl$\<发行版> UNC 访问,git 经 wsl.exe 在 guest 内执行 -// (UNC 上跑 Windows git 会撞 ownership 校验且行尾语义不对)。 +// WSL 模式(kernel_env = "wsl:<发行版>"):workdir 是 guest 内 Linux 路径。 +// WSL 文件系统路径经 \\wsl$ UNC 访问,automount 盘符路径(/mnt/c/…) +// 反解回 C:\…;git 经 wsl.exe 在 guest 内执行(UNC 上跑 Windows git 会撞 +// ownership 校验且行尾语义不对)。 use std::path::{Path, PathBuf}; use std::process::Command; @@ -43,14 +44,19 @@ pub struct RepoCtx { pub workdir: String, /// WSL 发行版(仅 Windows + kernel_env=wsl:* 时 Some) pub wsl_distro: Option, + /// WSL automount 根(通常 /mnt,允许 wsl.conf 自定义)。 + pub wsl_mount_root: Option, } impl RepoCtx { - /// 本地文件系统视角的工作区根(WSL 模式转 UNC;Linux 冒烟恒等)。 + /// 本地文件系统视角的工作区根(WSL 文件系统转 UNC,盘符挂载转宿主盘符)。 fn fs_root(&self) -> PathBuf { - match &self.wsl_distro { - Some(d) => crate::wsl::host_fs_view(d, &self.workdir), - None => PathBuf::from(&self.workdir), + match (&self.wsl_distro, &self.wsl_mount_root) { + (Some(distro), Some(mount_root)) => { + crate::wsl::host_fs_view_with_mount_root(distro, mount_root, &self.workdir) + } + (Some(distro), None) => crate::wsl::host_fs_view(distro, &self.workdir), + (None, _) => PathBuf::from(&self.workdir), } } @@ -469,6 +475,17 @@ fn reveal(ctx: &RepoCtx, rel: &str) -> Result { mod tests { use super::*; + #[cfg(windows)] + #[test] + fn wsl_automount_workspace_uses_the_host_drive() { + let ctx = RepoCtx { + workdir: "/mnt/c/Users/chaitin/project".into(), + wsl_distro: Some("Ubuntu".into()), + wsl_mount_root: Some("/mnt".into()), + }; + assert_eq!(ctx.fs_root(), PathBuf::from(r"C:\Users\chaitin\project")); + } + #[test] fn porcelain_z_rename_does_not_parse_arrow_inside_source_name() { let parsed = parse_status_porcelain_z( @@ -506,6 +523,7 @@ mod tests { let ctx = RepoCtx { workdir: ws.to_string_lossy().into_owned(), wsl_distro: None, + wsl_mount_root: None, }; assert!( @@ -545,6 +563,7 @@ mod tests { let ctx = RepoCtx { workdir: dir.to_string_lossy().into_owned(), wsl_distro: None, + wsl_mount_root: None, }; let root = ctx.resolve("").unwrap(); @@ -588,6 +607,7 @@ mod tests { let ctx = RepoCtx { workdir: dir.to_string_lossy().into_owned(), wsl_distro: None, + wsl_mount_root: None, }; let listed = dispatch(&ctx, "repo_preview_files", &json!({})); let paths: Vec<_> = listed["result"]["files"] @@ -633,6 +653,7 @@ mod tests { let ctx = RepoCtx { workdir: dir.to_string_lossy().into_owned(), wsl_distro: None, + wsl_mount_root: None, }; let listed = preview_files(&ctx).unwrap(); assert!(!listed["files"] @@ -652,6 +673,7 @@ mod tests { let ctx = RepoCtx { workdir: dir.to_string_lossy().into_owned(), wsl_distro: None, + wsl_mount_root: None, }; let response = dispatch(&ctx, "repo_file_changes", &json!({})); @@ -676,6 +698,7 @@ mod tests { let ctx = RepoCtx { workdir: dir.to_string_lossy().into_owned(), wsl_distro: None, + wsl_mount_root: None, }; let response = dispatch(&ctx, "repo_file_changes", &json!({})); diff --git a/desktop/src/skills.rs b/desktop/src/skills.rs index 557ec2d8f..2bcb582d8 100644 --- a/desktop/src/skills.rs +++ b/desktop/src/skills.rs @@ -32,21 +32,21 @@ const REVISION_FILE: &str = "skills.revision"; const REVISION_FORMAT: u32 = 1; /// 内置技能的**出厂**缺省启用集:云端建任务的 MC_DEFAULT_SKILL_IDS 四件套 -/// (baizhi/monkeycode.rs)+ 桌面端补充的发布与设计流入口。设计流内部 Skill -/// 由 workflow.json 依赖闭包自动物化,不作为用户可选项。 +/// (baizhi/monkeycode.rs)+ 桌面端补充的发布入口。design-flow 随包提供但 +/// 默认关闭,用户明确勾选后其内部 Skill 才由 workflow.json 依赖闭包自动 +/// 物化;内部 Skill 本身不作为用户可选项。 /// 官方库全默认启用会把 system prompt 塞满几十条 name+description,故其余 /// 按需勾选。用户自建技能不受此表限制,出厂恒默认启用——亲手写的技能就是 /// 要用的;批量导入会显式写入 false,只安装入库,等待用户主动设为默认。 /// 出厂规则之上是用户显式开关(skills-defaults.json,见 load_default_prefs): /// 没拨过的技能跟随出厂,拨过的以开关为准。解析结果经 skills_list 的 /// default_enabled 字段下发,UI 不再自持一份规则镜像。 -pub const DEFAULT_ENABLED: [&str; 6] = [ +pub const DEFAULT_ENABLED: [&str; 5] = [ "feature-design", "project-wiki", "feature-implementer", "implementation-planner", "publish-website", - "design-flow", ]; /// 默认启用开关的持久化(/skills-defaults.json, @@ -1979,7 +1979,7 @@ mod tests { } #[test] - fn design_skills_are_factory_enabled_and_user_prefs_override_them() { + fn design_skills_are_factory_disabled_and_user_prefs_can_enable_them() { const INTERNAL_SKILLS: [&str; 10] = [ "design-generation", "design-refinement", @@ -1992,7 +1992,7 @@ mod tests { "react-native-design", "headless-design-jury", ]; - assert!(DEFAULT_ENABLED.contains(&"design-flow")); + assert!(!DEFAULT_ENABLED.contains(&"design-flow")); assert!(INTERNAL_SKILLS.iter().all(|name| !DEFAULT_ENABLED.contains(name))); let builtin = test_dir("def-builtin"); @@ -2010,7 +2010,7 @@ mod tests { put_skill(&builtin, "tailwindcss-helper", "官方非默认项"); put_skill(&user, "my-skill", "用户技能出厂默认启用"); - // 用户只选择公开入口,内部依赖仍自动物化,但不进入返回的显式启用集。 + // 新任务未显式选择技能时,design-flow 与内部依赖均不物化。 let def = materialize_unlocked( &target, Some(&builtin), @@ -2019,31 +2019,40 @@ mod tests { None, ) .unwrap(); - assert!(def.iter().any(|enabled| enabled == "design-flow")); + assert!(!def.iter().any(|enabled| enabled == "design-flow")); for name in INTERNAL_SKILLS { assert!(!def.iter().any(|enabled| enabled == name)); - assert!(target.join(name).join("SKILL.md").is_file()); + assert!(!target.join(name).exists()); } assert!(def.iter().any(|enabled| enabled == "my-skill")); assert!(!target.join("tailwindcss-helper").exists()); let infos = list_unlocked(Some(&builtin), &user, &user.join("no-defaults.json")); assert!(infos.iter().any(|skill| skill.name == "design-flow")); + assert_eq!( + infos + .iter() + .find(|skill| skill.name == "design-flow") + .map(|skill| skill.default_enabled), + Some(false) + ); assert!(INTERNAL_SKILLS.iter().all(|name| !infos.iter().any(|skill| skill.name == *name))); - // 关闭入口后不再物化它的内部依赖。 + // 用户显式打开公开入口后,内部依赖自动物化但不进入公开启用集。 let prefs_path = test_dir("def-prefs").join("skills-defaults.json"); fs::write( &prefs_path, - r#"{"design-flow": false, "tailwindcss-helper": true, "my-skill": false}"#, + r#"{"design-flow": true, "tailwindcss-helper": true, "my-skill": false}"#, ) .unwrap(); let def = materialize_unlocked(&target, Some(&builtin), &user, &prefs_path, None).unwrap(); - assert!(!def.iter().any(|enabled| enabled == "design-flow")); + assert!(def.iter().any(|enabled| enabled == "design-flow")); assert!(def.iter().any(|enabled| enabled == "tailwindcss-helper")); assert!(!def.iter().any(|enabled| enabled == "my-skill")); - assert!(!target.join("design-flow").exists()); - assert!(INTERNAL_SKILLS.iter().all(|name| !target.join(name).exists())); + assert!(target.join("design-flow/SKILL.md").is_file()); + assert!(INTERNAL_SKILLS + .iter() + .all(|name| target.join(name).join("SKILL.md").is_file())); // list_unlocked() 的 default_enabled 与物化同一解析 let infos = list_unlocked(Some(&builtin), &user, &prefs_path); let enabled = |name: &str| { @@ -2052,7 +2061,7 @@ mod tests { .find(|skill| skill.name == name) .map(|skill| skill.default_enabled) }; - assert_eq!(enabled("design-flow"), Some(false)); + assert_eq!(enabled("design-flow"), Some(true)); assert_eq!(enabled("design-generation"), None); assert_eq!(enabled("tailwindcss-helper"), Some(true)); assert_eq!(enabled("my-skill"), Some(false)); diff --git a/desktop/src/uploads.rs b/desktop/src/uploads.rs index d8de54037..bb450ee40 100644 --- a/desktop/src/uploads.rs +++ b/desktop/src/uploads.rs @@ -59,8 +59,8 @@ pub(crate) fn sanitize_name(name: &str) -> Option { } } -/// 工作区 uploads 目录(WSL 模式下 workdir 转 UNC 访问)。 -/// 工作区根(WSL 模式下映射 UNC)。空 workdir 会让相对路径落到进程 cwd +/// 工作区 uploads 目录。WSL 模式下 automount 路径反解回宿主盘符, +/// Linux 文件系统路径映射 UNC。空 workdir 会让相对路径落到进程 cwd /// (打包应用下是主目录)——硬错误。 fn uploads_root(workdir: &str, wsl_distro: Option<&str>) -> Result { if workdir.trim().is_empty() { diff --git a/desktop/src/wsl.rs b/desktop/src/wsl.rs index e70928dde..954c7a9cb 100644 --- a/desktop/src/wsl.rs +++ b/desktop/src/wsl.rs @@ -1,7 +1,8 @@ // WSL 运行环境支持:Windows 上壳把 ohmyagent 引擎 spawn 进 WSL 发行版 // (wsl.exe -d --exec <登录shell> -l -c …,stdio 经中继透传)。引擎数据 -// 留 Windows 侧经 /mnt/c 访问;会话 workdir 是 guest Linux 路径,壳侧经 -// \\wsl$ UNC 访问(repo 浏览、附件上传共用 host_fs_view/unc_path)。 +// 留 Windows 侧经 /mnt/c 访问;会话 workdir 是 guest Linux 路径。壳侧 +// automount 路径反解回盘符,真正的 WSL 文件系统路径才经 \\wsl$ UNC +// 访问(repo 浏览、附件上传共用 host_fs_view)。 // // 本模块全平台编译:Linux 开发机可经 MC_WSL_EXE 指向假 wsl 脚本冒烟整条 // 代码路径;仅 CREATE_NO_WINDOW 之类 Windows 细节 cfg 局部化。 @@ -90,21 +91,60 @@ pub fn derive_mount_root(host: &Path, guest: &str) -> Option { .map(|root| root.trim_end_matches('/').to_string()) } -/// guest 路径的宿主文件系统视角:Windows 经 \\wsl$ UNC;非 Windows -/// (MC_WSL_EXE 假脚本冒烟,guest == host)原样返回。会话目录判定/创建、 -/// repo 浏览、附件落盘等所有"壳侧 std::fs 摸 guest 文件"的点统一走这里。 -pub fn host_fs_view(distro: &str, guest_path: &str) -> PathBuf { +/// guest automount 路径 → Windows 盘符路径。`/mnt/c/a` → `C:\a`; +/// mount_root 同时支持自定义根与 root=/。非 automount 路径返回 None。 +#[cfg_attr(not(windows), allow(dead_code))] +pub fn host_drive_path(mount_root: &str, guest_path: &str) -> Option { + let root = mount_root.trim_end_matches('/'); + let relative = if root.is_empty() { + guest_path.strip_prefix('/')? + } else { + guest_path.strip_prefix(root)?.strip_prefix('/')? + }; + let mut components = relative.split('/'); + let drive = components.next()?; + if drive.len() != 1 || !drive.as_bytes()[0].is_ascii_alphabetic() { + return None; + } + let tail: Vec<_> = components + .filter(|component| !component.is_empty()) + .collect(); + if tail + .iter() + .any(|component| matches!(*component, "." | "..")) + { + return None; + } + let mut host = format!("{}:\\", drive.to_ascii_uppercase()); + host.push_str(&tail.join(r"\")); + Some(host) +} + +/// guest 路径的宿主文件系统视角。Windows 盘 automount 必须反解回盘符路径: +/// `\\wsl$\\mnt\c\...` 无法从 Windows 穿回 drvfs,会报拒绝访问; +/// `/home/...` 等真正位于 WSL 文件系统的路径才走 UNC。非 Windows 的 WSL +/// 假脚本冒烟仍保持 guest == host。 +pub fn host_fs_view_with_mount_root(distro: &str, mount_root: &str, guest_path: &str) -> PathBuf { #[cfg(windows)] { + if let Some(path) = host_drive_path(mount_root, guest_path) { + return PathBuf::from(path); + } unc_path(distro, guest_path) } #[cfg(not(windows))] { - let _ = distro; + let _ = (distro, mount_root); PathBuf::from(guest_path) } } +/// 默认 automount root(/mnt)的兼容入口。持有 WslCtx 的调用方应优先使用 +/// host_fs_view_with_mount_root,兼容 wsl.conf 自定义 automount root。 +pub fn host_fs_view(distro: &str, guest_path: &str) -> PathBuf { + host_fs_view_with_mount_root(distro, "/mnt", guest_path) +} + /// kernel_env 配置值解析:"wsl:" → Some(distro),其余(本机)→ None。 pub fn distro_of(kernel_env: &str) -> Option<&str> { kernel_env.strip_prefix("wsl:").filter(|d| !d.is_empty()) @@ -645,6 +685,22 @@ mod tests { assert_eq!(guest_path_of_drive("/mnt", "relative"), None); } + #[test] + fn guest_automount_maps_back_to_the_windows_drive() { + assert_eq!( + host_drive_path("/mnt", "/mnt/c/Users/u/project"), + Some(r"C:\Users\u\project".into()) + ); + assert_eq!( + host_drive_path("/custom", "/custom/d/dev/site"), + Some(r"D:\dev\site".into()) + ); + assert_eq!(host_drive_path("", "/e/work"), Some(r"E:\work".into())); + assert_eq!(host_drive_path("/mnt", "/home/u/project"), None); + assert_eq!(host_drive_path("/mnt", "/mnt/cache/project"), None); + assert_eq!(host_drive_path("/mnt", "/mnt/c/../secret"), None); + } + #[test] fn mount_root_derivation() { // 默认根、自定义根、根挂载(root=/) diff --git a/desktop/tauri.conf.json b/desktop/tauri.conf.json index 4c5787d57..4fc48006e 100644 --- a/desktop/tauri.conf.json +++ b/desktop/tauri.conf.json @@ -14,7 +14,7 @@ "windows": [], "macOSPrivateApi": true, "security": { - "csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' data:; media-src 'self'; connect-src 'self' ipc: http://ipc.localhost; object-src 'none'; base-uri 'self'; form-action 'self'; frame-src 'self' blob:", + "csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' data:; media-src 'self'; connect-src 'self' ipc: http://ipc.localhost; object-src 'none'; base-uri 'self'; form-action 'self'; frame-src 'self' blob: http://localhost:* https://localhost:* http://127.0.0.1:* https://127.0.0.1:* http://[::1]:* https://[::1]:* monkeycode-artifact:", "dangerousDisableAssetCspModification": [ "style-src" ], diff --git a/desktop/ui-next/src/app/App.test.tsx b/desktop/ui-next/src/app/App.test.tsx index c608e8bd8..07660c952 100644 --- a/desktop/ui-next/src/app/App.test.tsx +++ b/desktop/ui-next/src/app/App.test.tsx @@ -15,6 +15,8 @@ import { writeSendQueueLane, } from "@/features/chat/composer/sendQueue"; import { resetStashForTests } from "@/features/chat/composer/stash"; +import { resetPreviewLifecycleForTests } from "@/features/design/previewIpc"; +import { nativeObscured } from "@/lib/util/nativeObscure"; import { App } from "./App"; beforeEach(() => { @@ -180,16 +182,24 @@ describe("设置入口(外观/语言/配置在 SettingsView,各有专测)", () = }); }); -describe("设计预览页面生命周期", () => { - it("切换主区页面时强制销毁原生预览", async () => { +describe("设计预览生命周期(设置已改模态、工作台常驻,旧「切页销毁」语义退役)", () => { + it("App 启动清扫孤儿原生预览:上一 DOM 纪元的 webview 无人认领", async () => { + resetPreviewLifecycleForTests(); const shell = stubShell(); render(); await waitFor(() => expect(shell.count("preview_destroy")).toBeGreaterThan(0)); - const before = shell.count("preview_destroy"); + }); + + it("设置模态经遮挡信号令原生预览避让,关闭即释放", async () => { + stubShell(); + render(); + expect(nativeObscured()).toBe(false); await userEvent.click(screen.getByRole("button", { name: "设置" })); + expect(nativeObscured()).toBe(true); - await waitFor(() => expect(shell.count("preview_destroy")).toBeGreaterThan(before)); + await userEvent.click(within(screen.getByRole("dialog", { name: "设置" })).getByRole("button", { name: "关闭" })); + await waitFor(() => expect(nativeObscured()).toBe(false)); }); }); diff --git a/desktop/ui-next/src/app/App.tsx b/desktop/ui-next/src/app/App.tsx index 5e3efc2f7..98f413ecd 100644 --- a/desktop/ui-next/src/app/App.tsx +++ b/desktop/ui-next/src/app/App.tsx @@ -15,7 +15,8 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useCloudProjects, useCloudTasks } from "@/features/cloud/CloudTaskList"; import { CloudQueueCoordinatorProvider } from "@/features/cloud/CloudQueueCoordinator"; -import { previewHide, previewShow } from "@/features/design/previewIpc"; +import { sweepOrphanPreview } from "@/features/design/previewIpc"; +import { acquireNativeObscure } from "@/lib/util/nativeObscure"; import { DownloadsDock } from "@/features/downloads/DownloadsDock"; import { EngineBanner } from "@/features/engine/EngineBanner"; import { SettingsNavigationProvider } from "@/features/settings/SettingsNavigationContext"; @@ -280,12 +281,19 @@ function AppShell({ onTransportChanged }: { onTransportChanged: (generation: num { params: { reason } }, ), ); - const handleTodoDetailOpenChange = useCallback((open: boolean) => { - void (open ? previewHide() : previewShow()).catch(() => {}); - }, []); + // 覆盖工作台的浮层一律经 nativeObscure 计数令原生预览避让,不再直呼 + // previewHide/Show——关闭时无条件 show 会把 workbench 因别的浮层(子会话 + // 回放等)藏起的预览重新顶回最上层。待办详情/DetailModal 族在各自组件内 + // acquire,App 只管设置模态这一个 App 级浮层。 useEffect(() => { - void (settingsOpen ? previewHide() : previewShow()).catch(() => {}); + if (!settingsOpen) return; + return acquireNativeObscure(); }, [settingsOpen]); + // UI 重载(HMR/webview 崩溃恢复)后,上一 DOM 纪元的原生预览无人认领, + // 会永远浮在最上层——启动清一次场(壳侧 destroy 对「不存在」宽容)。 + useEffect(() => { + sweepOrphanPreview(); + }, []); /** 待办「启动任务」:创建意图送进工作台(新建即新格)预填正文,todoId * 供创建成功后回链。 */ @@ -690,7 +698,6 @@ function AppShell({ onTransportChanged }: { onTransportChanged: (generation: num // 云端派发件的跳转:装载入格(工作台即主壳,没有"云端空间" // 可切了;无 id 的存量件退化为无动作) onOpenCloud: (id) => id && loadEntry(cloudSlotId(id)), - onDetailOpenChange: handleTodoDetailOpenChange, }, }} /> diff --git a/desktop/ui-next/src/features/chat/ChatView.test.tsx b/desktop/ui-next/src/features/chat/ChatView.test.tsx index 75fac09bf..e25e31ebb 100644 --- a/desktop/ui-next/src/features/chat/ChatView.test.tsx +++ b/desktop/ui-next/src/features/chat/ChatView.test.tsx @@ -150,6 +150,33 @@ describe("聊天视图", () => { expect(shell.ops.some((op) => op.op === "listen" && op.cmd === "frames:s2")).toBe(true); }); + it("打开历史会话:回放的旧轮末零副作用——不做 git 扫描、不自动开预览", async () => { + // 回放窗口自带完整的一轮(含 localhost URL 与 task-ended):turnEnded + // 为真,但本次挂载没亲历过活轮次(liveTurnSeen 闸),轮末流水线不得触发 + const shell = stubShell({ + frames: [ + { type: "user-input", data: { content: b64encode("设计页面") }, timestamp: 1, seq: 1 }, + { type: "task-started", timestamp: 2, seq: 2 }, + { + type: "task-running", + kind: "acp_event", + data: { update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "完成:http://127.0.0.1:49173/" } } }, + timestamp: 3, + seq: 3, + }, + { type: "task-ended", timestamp: 4, seq: 4 }, + ], + }); + const { container } = render(); + await waitFor(() => expect(screen.getByText(/49173/)).toBeTruthy()); + + // 给异步流水线一个发作窗口再断言(它若触发,首个动作就是 repo_file_changes) + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(shell.ops.some((op) => op.args?.kind === "repo_file_changes")).toBe(false); + expect(container.querySelector('[data-design-preview-open="true"]')).toBeNull(); + expect(screen.queryByLabelText("设计预览工作台")).toBeNull(); + }); + it("当前轮设计完成后自动打开预览并标记压缩布局", async () => { const { emit } = stubShell(); const { container } = render(); diff --git a/desktop/ui-next/src/features/chat/ChatView.tsx b/desktop/ui-next/src/features/chat/ChatView.tsx index a2458acae..4f68a11aa 100644 --- a/desktop/ui-next/src/features/chat/ChatView.tsx +++ b/desktop/ui-next/src/features/chat/ChatView.tsx @@ -26,11 +26,13 @@ import { createPortal } from "react-dom"; import { useApprovalHotkeys } from "@/app/shortcuts"; import { useI18n } from "@/lib/i18n"; import { useSettingsNavigation } from "@/features/settings/SettingsNavigationContext"; -import { sessionOutline, type OutlineItem } from "@/lib/ipc/controls"; +import { sessionBackgroundStop, sessionOutline, type OutlineItem } from "@/lib/ipc/controls"; +import { engineCaps } from "@/lib/ipc/approvals"; import { repoChanges, repoPreviewFiles, repoReveal } from "@/lib/ipc/repo"; import { designTemplatePreviewRead, sessionFrame, sessionPatch, type SessionMeta } from "@/lib/ipc/sessions"; import { onNativeFileDrop, uploadFileURL } from "@/lib/ipc/uploads"; import { workspaceRelativePath } from "@/lib/util/markdownPaths"; +import { useNativeObscured } from "@/lib/util/nativeObscure"; import { consumeProgrammaticScroll, markProgrammaticScroll, @@ -118,7 +120,7 @@ export function ChatView({ const { t } = useI18n(); const { openSettings } = useSettingsNavigation(); const pane = variant === "pane"; - const { state, conn, historyLoaded, openError, hasMore, loadingEarlier, earlierError, loadEarlier, ensureLoaded } = + const { state, conn, sawLive, historyLoaded, openError, hasMore, loadingEarlier, earlierError, loadEarlier, ensureLoaded } = useSessionFeed(meta.id, epoch); useApprovalHotkeys(state, meta.id, undefined, hotkeysActive); const detectedPreviewUrl = useMemo(() => newestAgentPreviewUrl(state.items), [state.items]); @@ -653,6 +655,50 @@ export function ChatView({ setChildId(null); // 切会话关掉上一个会话的子回放 }, [meta.id]); + // 全局浮层遮挡信号(命令式菜单/DetailModal 族/设置模态):原生预览 + // webview 画在所有 DOM 之上,浮层在场时并入 obscured 令其避让 + const overlayObscured = useNativeObscured(); + + // ==== 后台子代理停止入口(background_stop → subagent/cancel 直通) ==== + // 能力门控:无 subagentControl 时入口整体隐藏(契约 2,不按版本猜)。 + // 挂载拉一次即可——引擎自愈会经 epoch 整格重建本组件,快照随之刷新 + const [canStopBg, setCanStopBg] = useState(false); + useEffect(() => { + let alive = true; + void engineCaps().then((caps) => { + if (alive && caps) setCanStopBg(caps.subagent_control); + }); + return () => { + alive = false; + }; + }, []); + // 停止是「已受理」瞬态:成功后行保持「停止中」,真正收卡等 + // task_notification 终态(卡关了行自然消失,集合残留无害);失败回弹 + // 并外显原因,可重试。切会话即作废。 + const [bgStopping, setBgStopping] = useState>(new Set()); + const [bgStopError, setBgStopError] = useState(null); + useEffect(() => { + setBgStopping(new Set()); + setBgStopError(null); + }, [meta.id]); + const onStopBackground = useCallback( + (agentId: string) => { + setBgStopError(null); + setBgStopping((prev) => new Set(prev).add(agentId)); + void sessionBackgroundStop(metaRef.current.id, agentId).catch((e: unknown) => { + setBgStopping((prev) => { + const next = new Set(prev); + next.delete(agentId); + return next; + }); + setBgStopError( + t("chat.bg.stopFailed", { reason: e instanceof Error ? e.message : String(e) }), + ); + }); + }, + [t], + ); + // ==== 空闲态后台状态条取材:全部 run+background 派发卡(时间序) ==== // 摘要优先 SendMessage 的 summary / Agent 的 description(rawInput), // 退回工具标题;入口复用子会话回放浮层。running 时状态条不渲染,跳过 @@ -663,7 +709,14 @@ export function ChatView({ // 断连门控:引擎不在了,"运行中"就是谎言(重启后的孤儿卡另有对账 // 路径补终态);conn 为 null 是"还不知道",按乐观显示 if (conn && !conn.connected) return []; - const out: { key: string; title: string; startedAt?: number; childId?: string }[] = []; + const out: { + key: string; + title: string; + startedAt?: number; + childId?: string; + agentId?: string; + stopping?: boolean; + }[] = []; for (const it of state.items) { if (it.kind === "tool" && it.status === "run" && it.background) { const input = (it.rawInput ?? {}) as { summary?: unknown; description?: unknown }; @@ -676,21 +729,36 @@ export function ChatView({ title, ...(it.startedAt !== undefined ? { startedAt: it.startedAt } : {}), ...(it.childSessionId ? { childId: it.childSessionId } : {}), + ...(it.backgroundAgentId ? { agentId: it.backgroundAgentId } : {}), + ...(it.backgroundAgentId && bgStopping.has(it.backgroundAgentId) + ? { stopping: true } + : {}), }); } } return out; - }, [state, conn]); + }, [state, conn, bgStopping]); const onOpenBackground = useCallback((childId: string) => setChildId(childId), []); const bgSignature = bgCards - .map((c) => `${c.key}|${c.title}|${c.childId ?? ""}|${c.startedAt ?? 0}`) + .map( + (c) => + `${c.key}|${c.title}|${c.childId ?? ""}|${c.startedAt ?? 0}|${c.agentId ?? ""}|${c.stopping ? 1 : 0}`, + ) .join("\n"); const backgroundInfo = useMemo( - () => (bgCards.length === 0 ? undefined : { tasks: bgCards, onOpen: onOpenBackground }), + () => + bgCards.length === 0 + ? undefined + : { + tasks: bgCards, + onOpen: onOpenBackground, + ...(canStopBg ? { onStop: onStopBackground } : {}), + ...(bgStopError !== null ? { stopError: bgStopError } : {}), + }, // bgSignature 覆盖 bgCards 的全部字段值:签名不变则内容必然逐字段相同, // 用它换引用稳定,避免逐批次新数组击穿 memo // eslint-disable-next-line react-hooks/exhaustive-deps - [bgSignature, onOpenBackground], + [bgSignature, onOpenBackground, canStopBg, onStopBackground, bgStopError], ); // pane 连接条的展开态(收/展是会话内瞬态;切会话回到收起) @@ -876,6 +944,10 @@ export function ChatView({ const turnJustEnded = state.turnEnded && !prevTurnEnded.current; prevTurnEnded.current = state.turnEnded; if (!turnJustEnded) return; + // 回放的旧轮末零副作用(sawLive:本次打开后收到过实时帧才算活轮末, + // 见 useSessionFeed):打开历史会话不做全工作区 git 扫描、不把回放里的 + // URL/产物当新产出抢视图开预览;改动徽标交给 FilesPanel 打开时自行探测 + if (!sawLive) return; setChangesToken((n) => n + 1); const sessionId = meta.id; const generation = ++changesGeneration.current; @@ -907,7 +979,7 @@ export function ChatView({ }).catch(() => { if (changesGeneration.current === generation && metaRef.current.id === sessionId) setChangesCount(0); }); - }, [currentTurnPreviewUrl, currentTurnText, meta.id, openPreview, state.items, state.turnEnded]); + }, [currentTurnPreviewUrl, currentTurnText, meta.id, openPreview, sawLive, state.items, state.turnEnded]); // 改动数徽标与自动 artifact 选择共用上面的轮末查询,避免重复读取全工作区。 useEffect(() => { changesGeneration.current += 1; @@ -1412,7 +1484,8 @@ export function ChatView({ initialTarget={previewTarget} refreshKey={previewRefreshKey} composer={composerRef.current} - obscured={sideTab !== "preview" || !!childId} + obscured={sideTab !== "preview" || !!childId || overlayObscured} + workdir={meta.workdir} /> )} diff --git a/desktop/ui-next/src/features/chat/DetailModal.test.tsx b/desktop/ui-next/src/features/chat/DetailModal.test.tsx new file mode 100644 index 000000000..229122d72 --- /dev/null +++ b/desktop/ui-next/src/features/chat/DetailModal.test.tsx @@ -0,0 +1,21 @@ +// DetailModal 是工具详情/子会话回放/快捷键表共用的模态原语:在场期间必须 +// 令原生预览避让(原生 webview 画在所有 DOM 之上,不避让模态就被截断)。 +import { render } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { nativeObscured } from "@/lib/util/nativeObscure"; +import { DetailModal } from "./DetailModal"; + +describe("DetailModal", () => { + it("在场即置原生预览遮挡信号,卸载释放", () => { + expect(nativeObscured()).toBe(false); + const view = render( + {}}> +
正文
+
, + ); + expect(nativeObscured()).toBe(true); + view.unmount(); + expect(nativeObscured()).toBe(false); + }); +}); diff --git a/desktop/ui-next/src/features/chat/DetailModal.tsx b/desktop/ui-next/src/features/chat/DetailModal.tsx index 441b4581a..b4afc0f6a 100644 --- a/desktop/ui-next/src/features/chat/DetailModal.tsx +++ b/desktop/ui-next/src/features/chat/DetailModal.tsx @@ -4,6 +4,7 @@ import { createPortal } from "react-dom"; import { useI18n } from "@/lib/i18n"; import { useEscLayer } from "@/lib/util/escLayer"; +import { acquireNativeObscure } from "@/lib/util/nativeObscure"; export function DetailModal({ ariaLabel, @@ -21,6 +22,8 @@ export function DetailModal({ useEffect(() => { onCloseRef.current = onClose; }, [onClose]); + // 模态在场期间原生预览避让(工具详情/子会话回放/快捷键表共用本组件) + useEffect(() => acquireNativeObscure(), []); useEscLayer( true, useCallback(() => { diff --git a/desktop/ui-next/src/features/chat/composer/Composer.test.tsx b/desktop/ui-next/src/features/chat/composer/Composer.test.tsx index e73cd7fd7..729382b67 100644 --- a/desktop/ui-next/src/features/chat/composer/Composer.test.tsx +++ b/desktop/ui-next/src/features/chat/composer/Composer.test.tsx @@ -38,6 +38,7 @@ function stubShell({ directoryPath = null, runtimePath, directoryResult, + caps = null, }: { models?: ModelInfo[]; skills?: SkillInfo[]; @@ -45,6 +46,8 @@ function stubShell({ directoryPath?: string | null; runtimePath?: string; directoryResult?: Promise; + /** engine_caps 应答;缺省 null = 能力未知,按「不支持」降级渲染。 */ + caps?: Record | null; } = {}) { const ops: Op[] = []; const listeners = new Map void>(); @@ -62,6 +65,7 @@ function stubShell({ if (cmd === "models_list") return Promise.resolve(models); if (cmd === "skills_list") return Promise.resolve({ revision: 1, store_id: "test", skills }); if (cmd === "session_call") return sessionCall ? sessionCall(args) : Promise.resolve({ result: {} }); + if (cmd === "engine_caps") return Promise.resolve(caps); if (cmd === "upload_begin") return Promise.resolve({ handle: 9 }); if (cmd === "upload_finish") return Promise.resolve({ path: ".monkeycode/uploads/shot.png" }); if (cmd === "plugin:dialog|open") return directoryResult ?? Promise.resolve(directoryPath); @@ -1329,3 +1333,139 @@ describe("后台状态条·多任务", () => { expect(screen.getAllByText("分析日志").length).toBeGreaterThan(0); }); }); + +describe("后台状态条·停止", () => { + const CAPS = { + browser_ext: false, + usage_update: true, + perm_remember: true, + attachments: true, + steering: false, + subagent_control: true, + }; + // 派发卡带 background_agent 进度(壳在 async_launched 应答时产出,携带 + // 引擎侧寻址 agentId)——停止入口按它寻址,缺席的旧 journal 卡不出入口 + const STOPPABLE_FRAMES = [ + { type: "task-started", timestamp: 3, seq: 3 }, + { + type: "task-running", + kind: "acp_event", + data: { + update: { + sessionUpdate: "tool_call", + toolCallId: "b1", + title: "SendMessage", + rawInput: { summary: "继续实现" }, + }, + }, + timestamp: 4, + seq: 4, + }, + { + type: "task-running", + kind: "acp_event", + data: { + update: { + sessionUpdate: "tool_call_update", + toolCallId: "b1", + status: "in_progress", + progress: { kind: "background_agent", agentId: "ag-1", status: "running" }, + }, + }, + timestamp: 5, + seq: 5, + }, + { + type: "task-running", + kind: "acp_event", + data: { + update: { + sessionUpdate: "tool_call_update", + toolCallId: "b1", + status: "completed", + rawOutput: "receipt", + backgroundLaunch: true, + }, + }, + timestamp: 6, + seq: 6, + }, + { type: "task-ended", timestamp: 7, seq: 7 }, + ]; + + it("二段确认后调 background_stop,行转停止中;通知到达后消退", async () => { + const { ops, emit } = stubShell({ caps: CAPS }); + render(); + await ready(); + emit("frames:s1", STOPPABLE_FRAMES); + + await waitFor(() => expect(screen.getByText("1 个后台代理运行中")).toBeTruthy()); + // 首击只进入确认态,不发命令(停止不可恢复) + await userEvent.click(await screen.findByRole("button", { name: "停止" })); + expect(calls(ops, "background_stop").length).toBe(0); + await userEvent.click(screen.getByRole("button", { name: "确认停止?" })); + + await waitFor(() => expect(calls(ops, "background_stop").length).toBe(1)); + expect(calls(ops, "background_stop")[0]?.args).toMatchObject({ + id: "s1", + kind: "background_stop", + payload: { agent_id: "ag-1" }, + }); + // 应答只表示受理:行保持「停止中」,收卡等 task_notification 终态 + await waitFor(() => expect(screen.getByText("停止中…")).toBeTruthy()); + expect(screen.queryByRole("button", { name: "停止" })).toBeNull(); + + emit("frames:s1", [ + { + type: "task-running", + kind: "acp_event", + data: { + update: { + sessionUpdate: "task_notification", + agentId: "ag-1", + status: "stopped", + result: "已按请求停止", + text: "📌 后台代理已停止", + }, + }, + timestamp: 8, + seq: 8, + }, + ]); + await waitFor(() => expect(screen.queryByText("1 个后台代理运行中")).toBeNull()); + }); + + it("引擎无 subagentControl 能力时不出停止入口", async () => { + const { emit } = stubShell(); // engine_caps → null,按不支持降级 + render(); + await ready(); + emit("frames:s1", STOPPABLE_FRAMES); + + await waitFor(() => expect(screen.getByText("1 个后台代理运行中")).toBeTruthy()); + expect(screen.queryByRole("button", { name: "停止" })).toBeNull(); + }); + + it("停止失败回弹并外显原因,可重试", async () => { + const { emit } = stubShell({ + caps: CAPS, + sessionCall: (args) => + (args?.kind as string) === "background_stop" + ? Promise.reject(new Error("sub-agent not found: ag-1")) + : Promise.resolve({ result: {} }), + }); + render(); + await ready(); + emit("frames:s1", STOPPABLE_FRAMES); + + await waitFor(() => expect(screen.getByText("1 个后台代理运行中")).toBeTruthy()); + await userEvent.click(await screen.findByRole("button", { name: "停止" })); + await userEvent.click(screen.getByRole("button", { name: "确认停止?" })); + + await waitFor(() => + expect(screen.getByText("停止后台代理失败:sub-agent not found: ag-1")).toBeTruthy(), + ); + // 行回弹,停止入口可重试 + expect(screen.getByRole("button", { name: "停止" })).toBeTruthy(); + expect(screen.queryByText("停止中…")).toBeNull(); + }); +}); diff --git a/desktop/ui-next/src/features/chat/composer/Composer.tsx b/desktop/ui-next/src/features/chat/composer/Composer.tsx index dab986229..e3f3cd4e8 100644 --- a/desktop/ui-next/src/features/chat/composer/Composer.tsx +++ b/desktop/ui-next/src/features/chat/composer/Composer.tsx @@ -164,8 +164,14 @@ interface ComposerProps { focusRequest?: number; onFocusRequestHandled?: (request: number) => void; /** 空闲态后台状态条的取材与入口(ChatView 供给,引用需稳定以保 memo; - * 无后台任务时缺席,渲染由 presentation.backgroundRunning 门控)。 */ - backgroundInfo?: { tasks: BackgroundTask[]; onOpen?: (childId: string) => void }; + * 无后台任务时缺席,渲染由 presentation.backgroundRunning 门控)。 + * onStop 缺席 = 引擎无 subagentControl 能力,停止入口整体不出现。 */ + backgroundInfo?: { + tasks: BackgroundTask[]; + onOpen?: (childId: string) => void; + onStop?: (agentId: string) => void; + stopError?: string; + }; } const ComposerImpl = forwardRef(function Composer({ @@ -714,7 +720,12 @@ const ComposerImpl = forwardRef(function Com openLabel={t("chat.tool.childSession")} expandLabel={t("chat.bg.expand")} collapseLabel={t("chat.bg.collapse")} + stopLabel={t("chat.bg.stop")} + stopConfirmLabel={t("chat.bg.stopConfirm")} + stoppingLabel={t("chat.bg.stopping")} + stopError={backgroundInfo.stopError} onOpen={backgroundInfo.onOpen} + onStop={backgroundInfo.onStop} /> )} diff --git a/desktop/ui-next/src/features/chat/composer/LocalComposerHost.tsx b/desktop/ui-next/src/features/chat/composer/LocalComposerHost.tsx index 95725ff57..005121619 100644 --- a/desktop/ui-next/src/features/chat/composer/LocalComposerHost.tsx +++ b/desktop/ui-next/src/features/chat/composer/LocalComposerHost.tsx @@ -27,8 +27,17 @@ export const LocalComposerHost = forwardRef< onFocusRequestHandled?: (request: number) => void; /** 空闲态后台状态条取材(ChatView 供给,引用稳定;透传给 Composer) */ backgroundInfo?: { - tasks: { key: string; title: string; startedAt?: number; childId?: string }[]; + tasks: { + key: string; + title: string; + startedAt?: number; + childId?: string; + agentId?: string; + stopping?: boolean; + }[]; onOpen?: (childId: string) => void; + onStop?: (agentId: string) => void; + stopError?: string; }; } >(function LocalComposerHost( diff --git a/desktop/ui-next/src/features/chat/composer/composerKit.tsx b/desktop/ui-next/src/features/chat/composer/composerKit.tsx index 85bf8d965..659a359b3 100644 --- a/desktop/ui-next/src/features/chat/composer/composerKit.tsx +++ b/desktop/ui-next/src/features/chat/composer/composerKit.tsx @@ -73,6 +73,10 @@ export interface BackgroundTask { title: string; startedAt?: number; childId?: string; + /** 引擎侧子代理寻址 id(backgroundAgentId);缺席(旧 journal 卡)无停止入口。 */ + agentId?: string; + /** 停止已受理,等 task_notification 终态收卡(调用方维护的瞬态)。 */ + stopping?: boolean; } /** 后台状态条(RunBar 同槽位的安静版):主循环空闲但后台子代理未回完成 @@ -89,7 +93,12 @@ export function BackgroundBar({ openLabel, expandLabel, collapseLabel, + stopLabel, + stopConfirmLabel, + stoppingLabel, + stopError, onOpen, + onStop, }: { label: string; tasks: BackgroundTask[]; @@ -99,10 +108,25 @@ export function BackgroundBar({ openLabel: string; expandLabel: string; collapseLabel: string; + stopLabel: string; + stopConfirmLabel: string; + stoppingLabel: string; + /** 上一次停止失败的成品文案(调用方求值);行已回弹,可重试。 */ + stopError?: string; onOpen?: (childId: string) => void; + /** 缺席 = 引擎无 subagentControl 能力,停止入口整体不渲染。 */ + onStop?: (agentId: string) => void; }) { const [expanded, setExpanded] = useState(false); const [, setTick] = useState(0); + // 二段确认(按钮原位变「确认停止?」):同一时刻至多一行处于确认态, + // 几秒不点自动回弹——停止不可恢复,不能让确认态永久蹲在那等误触 + const [confirmKey, setConfirmKey] = useState(null); + useEffect(() => { + if (confirmKey === null) return; + const id = setTimeout(() => setConfirmKey(null), 4_000); + return () => clearTimeout(id); + }, [confirmKey]); const anyStarted = tasks.some((t) => t.startedAt !== undefined); useEffect(() => { if (!anyStarted) return; @@ -111,6 +135,35 @@ export function BackgroundBar({ }, [anyStarted]); const minutesOf = (startedAt?: number) => startedAt !== undefined ? Math.floor((Date.now() - startedAt) / 60_000) : 0; + const stopControl = (task: BackgroundTask) => { + const agentId = task.agentId; + if (!onStop || !agentId) return null; + if (task.stopping) { + return ( + + + {stoppingLabel} + + ); + } + const confirming = confirmKey === task.key; + return ( + + ); + }; const single = tasks.length === 1 ? tasks[0] : undefined; const singleMinutes = minutesOf(single?.startedAt); const singleChild = single?.childId; @@ -132,6 +185,7 @@ export function BackgroundBar({ {openLabel} )} + {single && stopControl(single)} {!single && ( {(target.kind === "localhost" || (target.kind === "artifact" && target.artifactKind === "html")) && - } + } {filesOpen &&
setFileQuery(e.target.value)} />
{filesTruncated &&

{t("design.preview.truncated")}

} @@ -976,18 +1044,22 @@ export function DesignPreviewWorkbench({ const label = t(`design.preview.device.${name}` as MessageKey); return ; })} -
- - -
- {/* 四个动作两两相似(截图/标记都出图,注释/编辑都要选元素),标签本身 - 分不出差别——tooltip 一律说「点完得到什么」,并点明去向:截图只进 - 剪贴板、编辑只改预览,另两个才发进对话。 */} - - - - - { const n = Math.min(500, Math.max(10, Number(e.target.value))); setZoom(n); if (!inlineFallback) void previewSetZoom(n / 100).catch(report); }}> {[10, 25, 50, 75, 100, 125, 150, 200, 300, 400, 500].map((n) => )}
} @@ -999,7 +1071,35 @@ export function DesignPreviewWorkbench({ : null ) : tab === "preview" ? (
-
+
+ {inlineFallback && native ? ( + /* Linux 内嵌预览:DOM iframe,层级/摆位天然正确,浮层遮挡 + 无需冻结帧;key 带 targetKey 与 reload 计数,切目标/刷新 + 即重载;缩放走 WebKit 的 CSS zoom */ +