diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml index d12179dde4..6a0f5d94c7 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md -2026-07-10-single-file-executable-sdk-runtime-distribution.md: 56c9770966647e97763e0eaf339e4589fba673fb -2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 04351105a56304020f5696cf1ce82d7a83ed2ced +2026-07-10-single-file-executable-sdk-runtime-distribution.md: ff00327c388b0468e5e98d488141aa35eb40d104 +2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 3f7f18a9907b2f43f7cd8467f054f0d0bfcef14a diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md index 56c9770966..ff00327c38 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md @@ -23,6 +23,8 @@ The exe is packaged with the **`--sea` (enhanced SEA) mode** of [@yao-pkg/pkg](h `@yao-pkg/pkg` is an exact-pinned root `devDependency` invoked as `pnpm exec pkg`, with [`patches/@yao-pkg__pkg@6.21.0.patch`](../../../../patches/@yao-pkg__pkg@6.21.0.patch) removing the SEA bootstrap's `patchChildProcess` call. Unpatched, pkg rewrites spawned commands named `node` — including the string after a `-c`/`/c` flag, exactly the Bash tool's `bash -c` form — to the executable itself and stamps `PKG_EXECPATH` into every child environment, so a model-issued `node --version` silently boots the dsh CLI; Node's own SEA layer performs no such rewrite, and a SEA binary cannot impersonate plain Node because it always boots its embedded app. With the call removed, children resolve `node` through PATH like any other process (a machine without Node reports command-not-found honestly), no `PKG_EXECPATH` reaches children, absolute `process.execPath` spawns still re-enter the app, worker threads never applied the hook, and `process.pkg` sidecar selection is untouched. +The same patch makes the SEA bootstrap's native-addon extraction atomic. On the first `dlopen` of an addon inside the VFS, pkg's `patchDlopen` copies the addon's package tree to `$PKG_NATIVE_CACHE_PATH/pkg//` (default `~/.cache/pkg/...`), a per-user directory that every concurrent process of the same executable shares; upstream writes each file straight to its final path with `writeFileSync`, which truncates a file another process may already have mapped and lets that process's `existsSync`/hash check read a half-written file. Concurrent first starts therefore die with SIGBUS on Linux or fail with `ERR_DLOPEN_FAILED: Library not loaded` on sharp's libvips on macOS (#4664). The patch routes the three write sites through a same-directory temporary file and `renameSync`, so a cache path is either absent or complete, every process finishes its own copy pass before it loads, and concurrent renames of identical content need no lock. The cache location, hash keying, and per-file comparison are unchanged, so a consumer that isolates the cache per instance through `PKG_NATIVE_CACHE_PATH` keeps working; worker threads only define the hook and never install it. Measured (linux-x64, node24 target, pkg 6.21.0, a probe that loads sharp with its 18 MB libvips library, 16 concurrent first starts against one empty cache, five rounds): 39 of 80 processes died with SIGBUS before the change and 0 of 80 after it, with no temporary files left behind. + Terminology reminder: pkg's `/snapshot` VFS has nothing to do with this repo's testing-system "snapshot" (ACP replay expected outputs, `$DSH_SNAPSHOT`); this document says "VFS" for the former. ### The serving interface is a plugin inside the dsh application diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md index 04351105a5..3f7f18a990 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md @@ -23,6 +23,8 @@ exe 使用 [@yao-pkg/pkg](https://github.com/yao-pkg/pkg)(vercel/pkg 归档后 `@yao-pkg/pkg` 是精确钉版的根 `devDependency`,经 `pnpm exec pkg` 调用,并以 [`patches/@yao-pkg__pkg@6.21.0.patch`](../../../../patches/@yao-pkg__pkg@6.21.0.patch) 移除 SEA bootstrap 中的 `patchChildProcess` 调用。未打补丁时,pkg 会把 spawn 的 `node` 命令——包括 `-c`/`/c` 标志后的命令串,恰是 Bash 工具的 `bash -c` 形态——改写为 exe 自身,并向每个子进程环境注入 `PKG_EXECPATH`,模型下发的 `node --version` 会静默启动 dsh CLI;Node 自身的 SEA 层没有这种改写,且 SEA 二进制永远启动内嵌应用、无法充当纯 Node。移除该调用后,子进程像普通进程一样经 PATH 解析 `node`(无 Node 的机器如实报 command not found),子进程环境不再出现 `PKG_EXECPATH`,以 `process.execPath` 绝对路径 spawn 的重入不受影响,worker 线程本来就未应用该钩子,`process.pkg` 侧车选择也不受影响。 +同一补丁还把 SEA bootstrap 的原生插件解包改成原子写入。VFS 内的插件首次 `dlopen` 时,pkg 的 `patchDlopen` 会把该插件所在的包目录整棵拷到 `$PKG_NATIVE_CACHE_PATH/pkg/<.node 文件的 sha256>/`(默认 `~/.cache/pkg/...`),这是同一个 exe 的所有并发进程共用的按用户目录;上游用 `writeFileSync` 直接写目标路径,会把另一个进程可能已经映射的文件截断,并让该进程的 `existsSync`/哈希比对读到写了一半的文件。因此并发首次启动在 Linux 上以 SIGBUS 崩溃,在 macOS 上对 sharp 的 libvips 报 `ERR_DLOPEN_FAILED: Library not loaded`(#4664)。补丁让三处写入都经过同目录临时文件加 `renameSync`,于是缓存路径要么不存在、要么完整,每个进程都在加载前走完自己的拷贝,相同内容的并发 rename 不需要锁。缓存位置、哈希键与逐文件比对均未改变,通过 `PKG_NATIVE_CACHE_PATH` 按实例隔离缓存的消费方照常工作;worker 线程只定义该钩子而从不安装。 实测(linux-x64、node24 构建目标、pkg 6.21.0,一个加载 sharp 及其 18 MB libvips 库的探针,16 个进程对同一个空缓存并发首次启动,跑五轮):改动前 80 个进程中 39 个以 SIGBUS 崩溃,改动后 80 个全部正常,且无残留临时文件。 + 术语提醒:pkg 的 `/snapshot` VFS 与本仓库测试体系的「快照」(ACP(Agent Client Protocol)回放预期输出、`$DSH_SNAPSHOT`)无关,本文用「VFS」指前者。 ### 对外服务接口是 dsh 应用中的插件 diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml index e891710dea..51b99eb8e4 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md -2026-07-25-web-client-session-scope-and-provide-channel.md: 990a9b6eef862a7ac4a549358beae7f9081eaa0b -2026-07-25-web-client-session-scope-and-provide-channel.zh.md: 5c90611fab36611766bb407138c4f0c8a6b3a01b +2026-07-25-web-client-session-scope-and-provide-channel.md: 32be87f3a0a4f8da92100ec7351af788862532f7 +2026-07-25-web-client-session-scope-and-provide-channel.zh.md: b2e7efae98efb69d5f34d6719a382221ffc7a00d diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md index 990a9b6eef..32be87f3a0 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md @@ -28,7 +28,7 @@ Host-side `session.create(workspaceId)` produces Session + Agent + cwd in one pi - Session identity is the host's true form from birth: the sessionId arrives via the `session.create` response / the `host/session-added` frame, and every client-side address (the scope tag, slot store keys, RPC addressing) uses that same id. - The materialization moment = the instant the user picks a Workspace (cwd settled): the client calls `session.create({workspaceId})` on the spot and receives the complete entity. - "New Session with no workspace picked" is a **pure view state** (a navigation position) corresponding to no session/scope entity; until the pick, the composer is locked whole (no slash, no plain text). -- A "blank session" is just an ordinary materialized session whose log is still empty; to every Agent-scope plugin on the host (goal/plan/skill/…) it is indistinguishable from any session, so slash/plan are all naturally live. +- A "blank session" is an ordinary materialized session with no turn yet; to Agent-scope plugins on the host (goal/plan/skill/…) it remains an ordinary Session, so slash/plan are naturally live. ### Agent scope: the actx is the sole session carrier in the client-side cordis world @@ -63,14 +63,14 @@ Session instances share the scope's lifecycle, while the catalog reports discove A session "materialized but with no first prompt" is governed by the summary-derived bit `blank` (a derived column, not a header field; SessionHeader stays immutable): -- The Host criterion is absence of `turn/start`; slash-command and configuration events do not clear `blank`. Live summaries use the Session projection. Cold summaries use cached metadata, leaving cache misses visible as unknown. Reuse acquires the writer under [blank Session acquisition](2026-09-17-process-local-blank-sessions.md). +- The Host criterion is absence of `turn/start`: the `sessionListMetadata.blank` projection starts true and only that event clears it, so slash-command, configuration, and creation-checkpoint events leave it true even after they reach disk — blank does not mean unpersisted. Missing metadata falls back to `session.seq === 0` for live Sessions and false for cold ones; cold summaries otherwise read cached metadata, leaving cache misses visible as unknown. Reuse acquires the writer under [blank Session acquisition](2026-09-17-process-local-blank-sessions.md). - The wire carries it in two places: the required `SessionSummary.blank` column, and the required `blank` field on the `host/session-added` frame (always true at creation, letting other tabs enter the same blank-session state into their mirrors). -- The client mirror only lowers, never raises (monotonic), flipped from three sources, all reusing existing wire signals: - - The sender's own tab: the **successful response** to the first `prompt()` flips false (acceptance proves the user/message is already in the host log — this flip is confirmation, not optimism; `onEngaged` synchronously updates the list mirror, converting the current `New Session` row in place to an ordinary title, adding no list row). A rejected first prompt keeps the session blank: aligned with host authority, still shown as `New Session`, keeping its connectWorkspace reuse eligibility while it remains a Workspace member. - - Other tabs: the `host/session-status (running:true)` frame flips it — a blank session never runs, so the first running necessarily means no longer blank; - - Reconnect alignment: `session.list`'s summary.blank is authoritative, so a tab that missed frames aligns naturally on its next pull; a stale blank:true can never mark a converted session back to blank. +- Client display also reflects accepted/running observations, whose retention the [blank rollback decision](../bug-fix/2026-09-15-client-session-blank-reconciliation.md) owns: + - The sender's own tab: the **successful response** to the first `prompt()` converts the current `New Session` row in place, adding no list row — acceptance is display memory, not proof that a turn or user message reached durable history. A rejected first prompt keeps the session blank: aligned with host authority, still shown as `New Session`, keeping its connectWorkspace reuse eligibility while it remains a Workspace member. + - Other tabs: the `host/session-status (running:true)` frame converts it — a blank session never runs, so the first running necessarily means no longer blank; + - Reconnect alignment: a list pull updates Host summaries while the Manager keeps those observations, and a `sessionListMetadata` hint of existing history prevents re-blanking on top of that; an empty-history response cannot mark a converted session back to blank. - List discipline: the store retains every row; the Workspace browser's grouping, flat view, search, and counts share one visible projection — every non-blank session shows, while blank sessions show only the row retained by the `mainView` source, with its title forced to `New Session`. After a Workspace switch, the old blank entity stays in the mirror but is hidden from the list while the target Workspace's main blank shows; the user-visible surface therefore holds at most one blank row globally. -- Blank Sessions can persist through creation checkpoints and command events. Browser reloads and Host restarts reuse eligible blanks after acquiring their writer; occupied blanks are skipped. Old logs remain on disk without automatic cleanup. +- Blank Sessions can persist through creation checkpoints and command events, and have no automatic garbage collection: browser reloads, Host restarts, and Workspace connections reuse eligible blanks after acquiring their writer and skip occupied candidates, while list-loading and multi-tab races can still create additional blank Sessions, whose old logs remain on disk. ### connectWorkspace: the sole entry point of New Session @@ -129,12 +129,13 @@ Blank Sessions retain the header's leading and corner slots so navigation contro | Components receiving wiring-callback bundles (two-layer inject→props pass-down) | The standard-kit channel lets components fetch their own; the public API converges to hooks + stable props | | Swapping the no-session Hero view for the entire session Conversation | Even with the outer layout unchanged, the Hero, picker, and composer subtrees would remount together, making the whole UI region jump | | Making InputBar itself `session-maybe` | The input state machine, keyboard command surface, and actions would all have to accept absent values; replacing only the disabled input body keeps optionality at the shell boundary | -| A dedicated conversion frame | `session-status(running:true)` semantically implies conversion (a blank session never runs); adding a frame buys zero information for one more wire type | +| A dedicated conversion frame | Existing acceptance and running signals provide the observations used by the display rule; a new frame is unnecessary for that rule | ## Consequences - Plugins gain session context isomorphic to the host's: per-session state hangs on the actx and mounts/tears down in one piece with the scope fiber, making leaks structurally impossible; two-session isolation is structurally guaranteed by the scope filter. - The client object layer converges to a wire mirror: session identity, lifecycle, and capability adjudication all defer to the host entity — the input system (the next layer) always faces a session with a real Agent, and providers like slash/skill uniformly address by sessionId directly. -- Blank-session governance takes zero dedicated mechanisms: state rides one derived bit, visibility rides the unified list projection (only the current blank shows, as `New Session`), reuse acquires the persisted writer, and the ordinary ceiling rides same-Workspace reuse. +- Blank-session governance takes zero dedicated mechanisms: state rides one derived bit, visibility rides the unified list projection (only the current blank shows, as `New Session`), reuse acquires the persisted writer, and the ordinary ceiling rides same-Workspace reuse. The accepted/running display memory that guards the conversion is Client-local. + - The cost: the id→ctx handoff discipline and provide's Concurrent discipline are conventions rather than type-enforced, pinned by review and tests. The single state axis still withholds machine faces until a Session exists; the [resident conversation shell](../../../../packages/client/ui-conversation/README.md) routes activation to the Workspace picker during that interval. - Known gaps: approval/question recovery across prune (TODO); model selection returns in live-mutation shape (the host `selectModel` trio is ready-made, its client consumer not yet built). diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md index 5c90611fab..b2e7efae98 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md @@ -28,7 +28,7 @@ host 侧 `session.create(workspaceId)` 一体产出 Session + Agent + cwd(作 - 会话身份自出生即为 host 真身:sessionId 由 `session.create` 响应 / `host/session-added` 帧带来,client 侧一切寻址(scope tag、slot store 键、RPC 地址)用的都是同一个 id。 - 实体化时点 = 用户选定 Workspace(cwd 确定)的瞬间:client 当场调 `session.create({workspaceId})`,拿到完整实体。 - 「New Session 且未选 workspace」是**纯视图态**(一个导航位置),不对应任何会话/scope 实体;选定之前 composer 整体锁死(无 slash、无纯文本)。 -- 「空会话」就是一个日志还空着的普通实体化会话;对 host 上所有 Agent-scope 插件(goal/plan/skill(技能)/…)它与任何会话无异,slash/plan 天然全活。 +- 「空会话」是尚无轮次的普通实体化会话;对 host 上的 Agent-scope 插件(goal/plan/skill(技能)/…)而言,它仍是普通 Session,因此 slash/plan 自然可用。 ### Agent scope:actx 是 client 侧 cordis 世界的唯一会话载体 @@ -63,14 +63,14 @@ Session 实例与 scope 同生命周期;catalog 只报告可发现性,不持 「实体化但无首条提示词」的会话经 summary 派生位 `blank` 治理(派生列而非 header 字段,SessionHeader 保持不可变): -- Host 判据是没有 `turn/start`;slash 命令与配置事件不会清除 `blank`。Live summary 使用 Session 投影。Cold summary 使用缓存元数据,缓存缺失时以未知状态保留在列表中。复用按[空白 Session 获取](2026-09-17-process-local-blank-sessions.zh.md)规则取得写锁。 +- Host 判据是没有 `turn/start`:`sessionListMetadata.blank` 投影初始为 true,且只有该事件会清除它,因此 slash 命令、配置事件与创建检查点即使已落盘也不会清除——blank 不代表未持久化。metadata 缺失时,live Session 回退到 `session.seq === 0`,cold Session 回退到 false;cold summary 其余情况读缓存元数据,缓存缺失时以未知状态保留在列表中。复用按[空白 Session 获取](2026-09-17-process-local-blank-sessions.zh.md)规则取得写锁。 - wire 承载两处:`SessionSummary.blank` 必填列;`host/session-added` 帧必填 `blank` 字段(创建时恒 true,供别的 tab 按同一空会话状态入镜像)。 -- client 镜像只降不升(单调),三来源翻转,全部复用既有 wire 信号: - - 发送方本地:首次 `prompt()` 的**成功响应**翻 false(受理即证明用户消息已入 host 日志——此点翻转是确证而非乐观;`onEngaged` 同步更新列表镜像,当前 `New Session` 行原地转为普通标题,不新增列表行)。首条提示词被拒则会话保持 blank:与 host 权威对齐、继续显示为 `New Session`、在仍为该工作区成员时保持 connectWorkspace 复用资格。 - - 其他端:`host/session-status (running:true)` 帧翻转——blank 会话从不 running,首次 running 必然已非 blank; - - 重连对齐:`session.list` 的 summary.blank 是权威,错过帧的端下次拉取自然对齐;陈旧的 blank:true 不能把已转正的会话重新标回 blank。 +- 客户端展示还受已观察到的受理/运行影响,其保留期限由 [blank 回退修复决策](../bug-fix/2026-09-15-client-session-blank-reconciliation.zh.md) 拥有: + - 发送方本地:首次 `prompt()` 的**成功响应**把当前 `New Session` 行原地转正,不新增列表行——受理是展示记忆,不证明轮次或用户消息已写入持久历史。首条提示词被拒则会话保持 blank:与 host 权威对齐、继续显示为 `New Session`、在仍为该工作区成员时保持 connectWorkspace 复用资格。 + - 其他端:`host/session-status (running:true)` 帧转正——blank 会话从不 running,首次 running 必然已非 blank; + - 重连对齐:列表拉取更新 Host 摘要,Manager 则保留这些观察;在此基础上,`sessionListMetadata` 提示已有历史也会阻止回退 blank,因此空历史响应不能把已转正的会话标回 blank。 - 列表纪律:store 保留全部行;Workspace browser 的分组、平铺、搜索和计数共用同一可见投影——所有非 blank 会话都显示,blank 会话只显示由 `mainView` 来源持有的一行,并强制标题为 `New Session`。切换 Workspace 后,旧 blank 实体仍在镜像中但从列表隐藏,目标 Workspace 的主 blank 显示;因此用户可见面全局至多一条 blank 行。 -- 空白 Session 可以通过创建检查点与命令事件持久化。浏览器刷新与 Host 重启会在取得写锁后复用符合条件的空白会话,并跳过被占用的候选。旧日志保留在磁盘上,不自动清理。 +- blank Session 可以通过创建检查点与命令事件持久化,且没有自动垃圾回收:浏览器刷新、Host 重启与工作区连接会在取得写锁后复用符合条件的空白会话并跳过被占用的候选;但列表加载与多 tab 竞态仍可能创建额外的 blank Session,其旧日志保留在磁盘上。 ### connectWorkspace:New Session 的唯一入口 @@ -129,12 +129,13 @@ blank Session 保留 header 的 leading 与 corner slot,让右侧栏展开入 | 组件收 wiring 回调包(inject→props 两层下传) | 标准件通道让组件自取;公共 API 收敛为 hooks + 稳定 props | | Hero 无会话视图与会话 Conversation 整支互换 | 即使外层 layout 不变,Hero、picker 与 composer 子树仍会一起重建,界面产生整块抖动 | | 让 InputBar 自身变成 `session-maybe` | 输入状态机、键盘命令面与动作都被迫接受缺省值;只替换 disabled 输入体能把可选性留在外壳边界 | -| 专用「转正」帧 | `session-status(running:true)` 语义蕴含转正(blank 会话从不 running),加帧是 wire 多一型换零信息 | +| 专用「转正」帧 | 既有受理和运行信号已经提供展示规则所需的观察;该规则不需要新增帧 | ## 后果 - 插件获得与 host 同构的会话上下文:逐会话状态挂 actx、随 scope fiber 一次拆装,泄漏结构性不可能;双会话隔离由 scope filter 结构性保证。 - client 对象层收敛为 wire 镜像:会话身份、生命周期、能力判别全部以 host 实体为准——输入体系(下一层)面对的永远是「有真 Agent 的会话」,slash/skill 等提供方一律以 sessionId 直接寻址。 -- 空会话治理零专用机制:状态靠一个派生位,可见性靠统一列表投影(仅 current blank 以 `New Session` 展示),复用时获取持久化写锁,常规上限靠同 Workspace 复用。 +- 空会话治理零专用机制:状态靠一个派生位,可见性靠统一列表投影(仅 current blank 以 `New Session` 展示),复用时获取持久化写锁,常规上限靠同 Workspace 复用。转正所依赖的受理/运行展示记忆仅保留在客户端。 + - 代价:id→ctx 换乘纪律、provide 的 Concurrent 纪律都是约定而非类型强制,靠 review 与测试钉住。单一状态轴仍会在 Session 存在前隐藏 machine face;这段时间内,[常驻会话壳](../../../../packages/client/ui-conversation/README.zh.md)会把激活操作转到 Workspace picker。 - 已知欠账:approval/question 跨 prune 恢复(TODO);模型选择以 live-mutation 形状回归(host `selectModel` 三件套现成,其 client 消费方尚未构建)。 diff --git a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.i18n.yaml index ad8ffb6167..04fa599e6f 100644 --- a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md -2026-08-31-released-session-format-migrations.md: 273b51ac7ce9c507c2322499788dc2084b857415 -2026-08-31-released-session-format-migrations.zh.md: 69132ec1763ad12b51480e150c3968aa1dcacf61 +2026-08-31-released-session-format-migrations.md: e21793e0ea3d0c5376cbb3949c5d6ef599b9afb1 +2026-08-31-released-session-format-migrations.zh.md: 637d8fcf0206661df9a6f1b3b470ad8a55746720 diff --git a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md index 273b51ac7c..e21793e0ea 100644 --- a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md +++ b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md @@ -131,7 +131,7 @@ Existing write handles retain the process-local claim and kernel-backed cross-pr ### Parent catalog prerequisites -V3→V4 completes parent child discovery from retained direct-child headers and own descriptors. Storage supplies compact descriptor evidence to the catalog assembly, which binds it into the V3→V4 stage factory; the Stage waits for the parent's final inherited cut before validating catalog payloads and deriving available own discovery facts. Nested seed markers discard inherited catalog candidates without interpreting their payloads. An existing catalog entry survives an unavailable descriptor, including a child that failed before its first step. Descriptor v1 denotes continuable mode; v2/v3 explicitly record mode. Available unambiguous discovery fields must agree with the parent; absent entries require one supported descriptor. Missing, unsupported, or multiple descriptors preserve the logs without backfilling that child, as recorded in [incomplete child evidence](../bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.md). JSONL rechecks related membership and source revisions when preparing, reusing, and publishing. An unsupported generation or unreadable header elsewhere in that scan refuses migration because it cannot prove unrelatedness. Source drift invalidates preparation; read open retries once automatically, while write open refuses publication. Diagnostics retain the offending child path and distinguish unsupported migration evidence from malformed child data. The storage decoder classifies physical JSON and compression failures before the collector adds child context; filesystem reads and cancellation keep their original errors. The V0–V3-only catalog avoids recursive child migration. Current V4 reads skip that scan but apply the same own-catalog field, uniqueness, and current delivery ownership checks as strict restoration. See the [format specification](../../../../packages/session/session-format-v3-to-v4/README.md). +Runtime child-failure handling in this paragraph is partially superseded by [session-local catalog preparation](../bug-fix/2026-09-19-session-local-subagent-migration.md): opening the parent still completes its catalog from direct-child reads, but unreadable children do not block healthy parent history. V3→V4 completes parent child discovery from retained direct-child headers and own descriptors. Storage supplies compact descriptor evidence to the catalog assembly, which binds it into the V3→V4 stage factory; the Stage waits for the parent's final inherited cut before validating catalog payloads and deriving available own discovery facts. Nested seed markers discard inherited catalog candidates without interpreting their payloads. An existing catalog entry survives an unavailable descriptor, including a child that failed before its first step. Descriptor v1 denotes continuable mode; v2/v3 explicitly record mode. Available unambiguous discovery fields must agree with the parent; new complete entries require one supported descriptor. Missing, unsupported, or multiple descriptors preserve the logs and retain unknown-mode membership without inventing discovery fields, as recorded in [incomplete child evidence](../bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.md). JSONL rechecks related membership and source revisions when preparing, reusing, and publishing. Unreadable or unsupported headers are excluded from discovery; failures reading a recognized child preserve its header identity as unknown-mode membership. Source drift invalidates preparation; read open retries once automatically, while write open refuses publication. Diagnostics retain the offending child path and distinguish unsupported migration evidence from malformed child data. The collector reports child decoding and descriptor failures as warnings with the child path; cancellation and source-consistency checks still stop preparation. The V0–V3-only catalog avoids recursive child migration. Current V4 reads skip that scan but apply the same own-catalog field, uniqueness, and current delivery ownership checks as strict restoration. See the [format specification](../../../../packages/session/session-format-v3-to-v4/README.md). Raw parent/child migration tests retain 24 historical creation-time conflicts as rejection evidence. Detached controls align only the child creation time and prove event-preserving restoration, including a published child without a descriptor. Headless/ACP and SDK snapshot adapters validate live parent/child clocks before normalization and preserve their equality during refresh; committed predecessor generations remain unchanged. diff --git a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md index 69132ec176..637d8fcf02 100644 --- a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md @@ -131,7 +131,7 @@ POSIX publication 使用 hard-link creation 加目录 sync;Windows 使用 no-o ### 父目录前置事实 -V3→V4 使用保留的直属子 Session header 和自身 descriptor 补齐父 Session 的子代理发现记录。存储将紧凑的 descriptor 证据提供给 catalog 组装处,由其绑定到 V3→V4 stage 工厂;Stage 等待父 Session 的最终继承截点确定后,才校验目录 payload 并导出可获得的自身发现事实。嵌套的 seed 标记丢弃继承目录候选,不解释其 payload。已有目录记录允许 descriptor 不可用,包括在首次 step 之前失败的子 Session。Descriptor v1 表示 continuable 模式;v2/v3 显式记录模式。可获得且明确的发现字段必须与父目录一致;缺失记录要求一个受支持的 descriptor。Descriptor 缺失、版本不受支持或存在多个时,保留日志但不补填对应子 Session,理由见[不完整子目录证据](../bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.zh.md)。JSONL 在准备、复用和发布时重新检查关联成员与来源修订。扫描中其他任何不支持代际或不可读 header 都会拒绝迁移,因为无法证明它与父 Session 无关。来源变化使准备缓存失效;只读打开自动重试一次,写打开则拒绝发布。诊断保留出错子日志的路径,并区分不支持的迁移证据与畸形子数据。存储解码器先将物理 JSON 与压缩错误归类,再由收集器补充子日志信息;文件系统读取与取消保留原始错误。仅支持 V0–V3 的目录避免递归迁移子 Session。当前 V4 读取跳过该扫描,但会执行与严格恢复相同的自身目录字段、唯一性及当前投递归属检查。见[格式规范](../../../../packages/session/session-format-v3-to-v4/README.zh.md)。 +本段的运行时子会话错误处理部分由[逐会话目录准备](../bug-fix/2026-09-19-session-local-subagent-migration.zh.md)取代:打开父会话仍通过读取直属子会话补齐目录,但不可读子会话不再阻断健康父历史。V3→V4 使用保留的直属子 Session header 和自身 descriptor 补齐父 Session 的子代理发现记录。存储将紧凑的 descriptor 证据提供给 catalog 组装处,由其绑定到 V3→V4 stage 工厂;Stage 等待父 Session 的最终继承截点确定后,才校验目录 payload 并导出可获得的自身发现事实。嵌套的 seed 标记丢弃继承目录候选,不解释其 payload。已有目录记录允许 descriptor 不可用,包括在首次 step 之前失败的子 Session。Descriptor v1 表示 continuable 模式;v2/v3 显式记录模式。可获得且明确的发现字段必须与父目录一致;新增完整记录要求一个受支持的 descriptor。Descriptor 缺失、版本不受支持或存在多个时,保留日志与未知模式成员关系,不编造发现字段,理由见[不完整子目录证据](../bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.zh.md)。JSONL 在准备、复用和发布时重新检查关联成员与来源修订。不可读或不支持的 header 不参与发现;读取已识别子会话失败时,其 header 身份保留为未知模式成员关系。来源变化使准备缓存失效;只读打开自动重试一次,写打开则拒绝发布。诊断保留出错子日志的路径,并区分不支持的迁移证据与畸形子数据。收集器将子解码和 descriptor 失败报告为带子路径的警告;取消与来源一致性检查仍中止准备。仅支持 V0–V3 的目录避免递归迁移子 Session。当前 V4 读取跳过该扫描,但会执行与严格恢复相同的自身目录字段、唯一性及当前投递归属检查。见[格式规范](../../../../packages/session/session-format-v3-to-v4/README.zh.md)。 原始父子迁移测试保留 24 个历史创建时间冲突作为拒绝证据。独立的内存对照只对齐子创建时间,证明恢复保留事件,包括已发布但没有 descriptor 的子 Session。Headless/ACP 与 SDK 快照适配器在规范化之前校验实时父子时钟,并在刷新时保持两者相等;已提交的前代文件保持不变。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.i18n.yaml new file mode 100644 index 0000000000..154afe129e --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.md +2026-09-15-client-session-blank-reconciliation.md: 7213636e32702c4c51887adba3da019f49b8703c +2026-09-15-client-session-blank-reconciliation.zh.md: 4b50340fea668b6d850c1d00787dda3874b61e0c diff --git a/.agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.md b/.agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.md new file mode 100644 index 0000000000..7213636e32 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.md @@ -0,0 +1,43 @@ +# Agent Note: Keep accepted Session presentation across list refreshes + +Status: implemented + +English | [中文](2026-09-15-client-session-blank-reconciliation.zh.md) + +## Problem + +A successful prompt response converts the Client's `New Session` row before a turn necessarily starts. A later `session.list` response can still report `blank: true` and undo that conversion. Reconnect triggers the same refresh. Keeping the conversion only inside a Session object also loses it when that object is replaced. + +## Decision + +The [Manager](../../../../packages/api/session-controller/src/client/sessions/manager.ts) retains accepted/running observations in a private set keyed by Session id. List rows and the existing Session blank updates use `summary.blank && !engagedSessions.has(sessionId)`, with `sessionListMetadata` hints still applied on top. The existing summary mutations, Session snapshot fields, constructor, and projection stores retain their responsibilities. + +Every successful prompt response invokes the existing `onEngaged` callback, including from a replaced Session object. The Manager updates its retained observation, the list row, and the currently resident Session by id. Rejection records no engagement. A `running: true` status, addition, or list baseline also records engagement; a baseline predating removal cannot restore that observation, even if the row is re-added during the pull. + +The observation survives refresh, reconnect, and object replacement. Removal, drop, and successful list refresh forget it only when no list row, Session object, or child address retains the identity. Refresh evaluates retention after replaying in-flight mutations; failed and superseded pulls do not prune observations. Late acceptance cannot recreate a removed row or a Session object. Manager disposal clears the set and prevents late acceptance and outstanding list responses from publishing. + +Engagement is display memory, not proof that a turn started, work remains queued, or a message reached durable storage. It is not persisted across page reload. Historical `sessionListMetadata.blank` and the Host summary defaults are unchanged. + +## Alternatives considered + +**Preserve every previously false summary bit.** A previous Host summary is not evidence of local acceptance or running. Making all false values permanent would also suppress later Host corrections without either observation. + +**Introduce a shared Session record.** Combining this memory with projection stores requires constructor and cache-lifetime changes. Those changes are independent of preventing a blank rollback and are excluded. + +**Wait for history before converting the row.** This changes the existing acceptance-time display rule and is a separate UI decision. + +## Consequences + +Accepted or observed-running Sessions retain their existing display conversion, including when accepted input never starts a turn. Drafts, submission-error recovery, sidebar filtering and title rules, creation/reuse, UUIDs, Host protocol, persistence, and projection-cache lifetimes are unchanged. Reverting this Client-only fix needs no data migration. + +The Manager owns one additional per-id display flag. It does not replace Session-local presentation fields or implement a history/draft/presentation redesign. + +For an identity with neither subagent origin nor a retained child address, a removal notification discards its projection store unless that store already carries a non-empty `subagentCatalog`; a retained Session object keeps the store it was constructed with — for example a current row removed while its Session is still staged. If its projection store was discarded and that identity returns to the list before its Session object is dropped, the Manager creates and seeds a second store that the resident object never reads, so list rows and that object's projection readers can disagree until a replacement instance adopts the current store. Repairing that projection ownership is deferred to its own change; blank display does not depend on it. + +## Verification + +Manager tests cover refresh without a turn, running-to-idle updates, object replacement, late acceptance, removal through notifications or list reconciliation, retained instances and child addresses, failed and superseded pulls, and disposal. They check both list and Session state where an instance exists. Session tests retain first-send rejection and later-send behavior. Browser verification uses the recorded first-turn scenario with acceptance held before `turn/start` and a same-page reconnect. A timestamp substituted only in the reconnect list response provides a visible synchronization marker; the Host's blank/running values and durable log remain unchanged. + +## Related decisions + +The [scope and provisioning decision](../architecture/2026-07-25-web-client-session-scope-and-provide-channel.md) owns Session scope, creation/reuse, and the visible blank row. The [Session/Conversation ownership decision](../architecture/2026-08-20-client-session-conversation-ownership.md) owns transport, assembly, and snapshot contents. diff --git a/.agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.zh.md b/.agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.zh.md new file mode 100644 index 0000000000..4b50340fea --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.zh.md @@ -0,0 +1,43 @@ +# Agent Note: 在列表刷新后保留已受理会话的展示状态 + +Status: implemented + +[English](2026-09-15-client-session-blank-reconciliation.md) | 中文 + +## 问题 + +提示词请求成功后,客户端会把 `New Session` 行转换为普通会话,但此时轮次不一定已经开始。后续 `session.list` 响应仍可能返回 `blank: true`,从而撤销该转换。重连会触发同样的刷新。若只在 Session 对象内保留转换状态,对象被替换时也会丢失。 + +## 决策 + +[Manager](../../../../packages/api/session-controller/src/client/sessions/manager.ts) 使用按 Session id 索引的私有集合,保留已观察到的受理/运行。列表行与既有 Session blank 更新使用 `summary.blank && !engagedSessions.has(sessionId)`,其上仍叠加 `sessionListMetadata` 提示。既有摘要变更、Session 快照字段、构造函数和投影存储保留原有职责。 + +每个成功的提示词响应都会调用既有 `onEngaged` 回调,包括来自已被替换的 Session 对象的响应。Manager 按 id 更新其保留的观察、列表行和当前驻留 Session。拒绝不会记录受理/运行。`running: true` 状态、添加事件或列表基线也会记录运行;早于移除的基线不能恢复该观察,即使该行在拉取期间重新加入。 + +该观察跨刷新、重连和对象替换保留。移除、drop 和成功的列表刷新仅在列表行、Session 对象及子会话地址均不再保留该身份时清除它。刷新在重放请求期间的变更后判断保留资格;失败及被替代的请求不会清理观察。迟到的受理不能重建已移除的行或 Session 对象。Manager 销毁会清空集合,并阻止迟到的受理和未完成的列表响应发布。 + +受理/运行是展示记忆,不证明轮次已经开始、工作仍在排队,或消息已经写入持久存储。它不会跨页面重载持久化。历史 `sessionListMetadata.blank` 与 Host 摘要默认值保持不变。 + +## 考虑过的替代方案 + +**保留所有曾为 false 的摘要位。** 先前的 Host 摘要不能证明客户端观察过受理或运行。将所有 false 永久保留,也会在两种观察均不存在时阻止后续 Host 校正。 + +**引入共享 Session 记录。** 将这份记忆与投影存储合并,需要修改构造参数及缓存生命周期。这些改动独立于防止 blank 回退,因此不纳入。 + +**等待历史后再转换行。** 这会改变现有的受理时展示规则,属于独立的 UI 决策。 + +## 后果 + +已受理或观察过运行的 Session 保持既有展示转换,包括已受理输入始终没有开始轮次的情况。草稿、发送错误恢复、侧边栏筛选与标题规则、创建/复用、UUID、Host 协议、持久化及投影缓存生命周期保持不变。回退这项纯客户端修复不需要数据迁移。 + +Manager 额外维护一个按 id 保存的展示标记。它不替代 Session 本地展示字段,也不实施历史/草稿/展示状态重构。 + +对于来源不是 subagent、也没有保留子会话地址的身份,移除通知会丢弃它的投影存储——除非该存储已带有非空的 `subagentCatalog`;而仍被保留的 Session 对象继续持有构造时采用的存储,例如当前行被移除而它的 Session 仍留在舞台上。若投影存储已被丢弃,且该身份在其 Session 对象被销毁前回到列表,Manager 会新建并写入第二个存储,而常驻对象从不读取它,因此在替换实例采用当前存储之前,列表行与该对象的投影读取方可能不一致。该投影归属的修复延期到独立的改动;blank 展示不依赖它。 + +## 验证 + +Manager 测试覆盖无轮次刷新、运行转空闲、对象替换、迟到受理、通过通知或列表对账移除、保留实例与子会话地址、失败及被替代的请求,以及销毁。在存在实例的场景中,测试同时检查列表和 Session 状态。Session 测试保留首发拒绝及后续发送行为。浏览器验证使用已有首轮录制场景,在受理后、`turn/start` 前暂停并进行同页重连。测试仅替换重连列表响应中的时间戳,用其显示结果作为同步标记;Host 的 blank/running 值及持久日志保持不变。 + +## 相关决策 + +[作用域与供给决策](../architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md) 拥有 Session 作用域、创建/复用及可见 blank 行。[Session/Conversation 归属决策](../architecture/2026-08-20-client-session-conversation-ownership.zh.md) 拥有传输、装配及快照内容。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.i18n.yaml index a5158a3994..9c2cdd061a 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.md -2026-09-16-desktop-window-menus.md: 85ac42aa287b1cd9e595ade7cb44b965dea787dd -2026-09-16-desktop-window-menus.zh.md: 1376b90058ceafa7e378b4fd543d261064f5d8aa +2026-09-16-desktop-window-menus.md: 8a5343404e0cb3d2e7c4e20248a8b29f3bfbec47 +2026-09-16-desktop-window-menus.zh.md: d6ee6412462aecf49557a7994a683d37bf8e7253 diff --git a/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.md b/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.md index 85ac42aa28..8a5343404e 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.md +++ b/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.md @@ -12,7 +12,7 @@ The Desktop shell replaces Electron's default application menu with a custom tem On macOS the template declares `{ role: 'fileMenu' }` before the Edit menu and `{ role: 'windowMenu' }` after it, and a separator-delimited run of `hide`, `hideOthers`, and `unhide` before Quit in the application submenu. Those roles contribute only the standard items; no Services submenu, window list, or other macOS default is declared. Windows and Linux keep the application and Edit menus. -Electron's role labels are English string literals inside Electron (`lib/browser/api/menu-item-roles.ts`, `filemenu`, `windowmenu`, `close`, `minimize`, `hide`) with no locale lookup, and Electron re-applies them to the native menu items before the menu is shown, so a non-English Desktop shows them in English; the existing Edit menu behaves the same way. No custom close, minimize, or hide code is added: ⌘W destroys the window through Electron's own role. +Electron supplies English defaults for the File, Window, and Edit roles. Explicit labels override role defaults while retaining native actions and shortcuts; the localized application commands are documented in the [Desktop README](../../../../apps/desktop/README.md). No custom close, minimize, or hide code is added: ⌘W destroys the window through Electron's own role. ## Alternatives considered @@ -28,8 +28,8 @@ Electron's role labels are English string literals inside Electron (`lib/browser ## Consequences -macOS regains the window and application commands the custom menu suppressed, at the cost of four menu roles. The labels stay English on a non-English Desktop. +macOS regains the window and application commands the custom menu suppressed. File, Window, and Edit retain Electron’s default English labels; application commands use the Desktop locale. ## Testing -A `apps/desktop/tests/main-startup.spec.ts` case pins the declared menu roles per platform, including the macOS hide commands. Role-based menu items execute natively, so a programmatic `click()` and the vitest Electron mock cannot exercise the shortcuts; a real Electron 44 run of the same template showed the standard File, Window, and application items present only after this change, with the same English labels while `app.getLocale()`, `getSystemLocale()`, and `getPreferredSystemLanguages()` all reported `zh-CN`. +`apps/desktop/tests/main-startup.spec.ts` verifies the declared menu roles per platform and records English and Chinese application-label snapshots. Role-based menu items execute natively, so a programmatic `click()` and the Vitest Electron mock cannot exercise the shortcuts. The isolated Electron 44 demo confirms that explicit application labels appear in native menus; the template snapshots do not qualify native shortcut execution. diff --git a/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.zh.md b/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.zh.md index 1376b90058..d6ee641246 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-09-16-desktop-window-menus.zh.md @@ -12,7 +12,7 @@ Desktop shell 用自定义模板替换了 Electron 的默认应用菜单,该 macOS 上的模板在 Edit 菜单之前声明 `{ role: 'fileMenu' }`,在其之后声明 `{ role: 'windowMenu' }`,并在应用子菜单的 Quit 之前声明由分隔符隔开的 `hide`、`hideOthers` 和 `unhide`。这些 role 只提供标准菜单项;不声明 Services 子菜单、窗口列表或其他 macOS 默认项。Windows 和 Linux 保留应用菜单和 Edit 菜单。 -这些 role 的标签是 Electron 内部的英文常量(`lib/browser/api/menu-item-roles.ts` 的 `filemenu`、`windowmenu`、`close`、`minimize`、`hide`),没有语言查询,而且 Electron 在菜单显示前会把这些标签重新写到本机菜单项上,因此在非英文的 Desktop 上它们仍是英文;现有的 Edit 菜单同样如此。这里不新增任何自定义的关闭、最小化或隐藏代码:⌘W 通过 Electron 自身的 role 销毁窗口。 +Electron 为 File、Window 和 Edit role 提供英文默认标签。显式标签会覆盖 role 的默认标签,同时保留原生命令和快捷键;本地化的应用命令见 [Desktop README](../../../../apps/desktop/README.zh.md)。这里不新增任何自定义的关闭、最小化或隐藏代码:⌘W 通过 Electron 自身的 role 销毁窗口。 ## 考虑过的替代方案 @@ -28,8 +28,8 @@ macOS 上的模板在 Edit 菜单之前声明 `{ role: 'fileMenu' }`,在其之 ## 影响 -macOS 恢复了自定义菜单压掉的窗口和应用命令,代价是四个菜单 role。在非英文的 Desktop 上这些标签仍是英文。 +macOS 恢复了自定义菜单压掉的窗口和应用命令。File、Window 和 Edit 保留 Electron 的默认英文标签;应用命令使用 Desktop 的语言。 ## 测试 -`apps/desktop/tests/main-startup.spec.ts` 中的一个用例固定了各平台声明的菜单 role,包括 macOS 的隐藏命令。基于 role 的菜单项由本机执行,因此程序化的 `click()` 和 vitest 的 Electron mock 都无法触达这些快捷键;用真实 Electron 44 运行同一模板显示,只有在本改动之后才出现标准的 File、Window 和应用菜单项,并且在 `app.getLocale()`、`getSystemLocale()` 和 `getPreferredSystemLanguages()` 都报告 `zh-CN` 时标签保持不变。 +`apps/desktop/tests/main-startup.spec.ts` 验证各平台声明的菜单 role,并记录中英文应用标签快照。基于 role 的菜单项由本机执行,因此程序化的 `click()` 和 Vitest 的 Electron mock 都无法触达这些快捷键。独立 Electron 44 demo 确认显式应用标签会显示在原生菜单中;模板快照不能验证原生快捷键的执行。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-19-session-local-subagent-migration.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-19-session-local-subagent-migration.i18n.yaml new file mode 100644 index 0000000000..e2f0f66dda --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-19-session-local-subagent-migration.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-19-session-local-subagent-migration.md +2026-09-19-session-local-subagent-migration.md: 26911ea6e2cb31126d13e4c8c6488ff3b45563e6 +2026-09-19-session-local-subagent-migration.zh.md: 673040a3dc73d3665e42013a4704ef2d5911b23d diff --git a/.agents/notes/implemented/bug-fix/2026-09-19-session-local-subagent-migration.md b/.agents/notes/implemented/bug-fix/2026-09-19-session-local-subagent-migration.md new file mode 100644 index 0000000000..26911ea6e2 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-19-session-local-subagent-migration.md @@ -0,0 +1,33 @@ +# Agent Note: Complete parent catalogs without migrating child catalogs + +Status: implemented + +English | [中文](2026-09-19-session-local-subagent-migration.zh.md) + +## Problem + +Opening a historical parent requires discovery facts from its direct children. A child's corrupt body or invalid descriptor, or an unreadable header elsewhere in the root, could reject the parent's V4 preparation and hide otherwise readable history. + +## Decision + +Opening A discovers candidate B Sessions through headers, reads their own descriptors, and completes A's catalog. Historical child reads use the V0–V3 catalog; current child reads use native validation. These reads neither prepare B's own catalog nor publish B's successor. Opening B separately performs B's migration and catalog preparation. Frontend discovery retains its parent-catalog source. Unknown-mode rows remain browsable; `list_agents` continues to select only known continuable children. + +JSONL omits unreadable or unsupported headers from discovery. A child-body or descriptor-field failure produces a warning with the child path and retains the child's header identity. If the parent lacks a complete entry, migration appends `subagent/catalog`, projected as `mode: 'unknown'` alongside healthy siblings. Existing complete entries remain authoritative. Cancellation and source-consistency failures still stop the operation. Inspected child revisions, including failed decodes, are rechecked before reuse and publication so repairs cannot silently reuse stale evidence. Released predecessors remain byte-identical. + +This partially supersedes child-failure propagation in [released migrations](../architecture/2026-08-31-released-session-format-migrations.md) and [incomplete child evidence](2026-09-19-v3-incomplete-child-catalog-evidence.md). Their format conversion, descriptor cardinality, conflict validation, and immutable publication rules remain active. + +## Alternatives considered + +**Skip catalog completion and discover children in each consumer.** This introduces incomplete tool results and additional UI states despite the parent's readable child descriptors. The parent catalog already serves these consumers. + +**Drop unreadable children from the catalog.** Published parents would lose their navigation entries. Header identity is sufficient to retain membership without inventing a mode or label. + +**Recursively prepare child catalogs.** A only needs B's own descriptor. Preparing B's descendants adds unrelated work and failure dependencies. + +**Refuse A when one child cannot be read.** The child's discovery metadata does not justify withholding the parent's healthy history. Warnings retain the child location without inventing mode or label fields. + +## Consequences + +Initial parent preparation still scans headers and reads direct-child bodies one at a time. It does not claim to eliminate this I/O. Published V4 parents use their catalog without rescanning children. Unknown entries are not automatically rewritten after publication. Opening a child retries its log and determines browsing capabilities from its actual descriptor, or reports the child-local error. Catalog payload v0 retains its two known modes; v1 also supports `unknown`. Readers support both versions; healthy creation and complete historical facts retain v0, while unknown migration facts use v1. The V4 Session header is unchanged, and older readers reject the new payload version. + +Raw and compressed tests cover catalog completion, child-local errors, source changes and repairs, unchanged child generations, and deferred child-catalog preparation. The shipped Web migration snapshot checks the parent catalog with a corrupt sibling, then opens the child independently. Its additional historical child raises the corpus budget from ten to eleven roles; the guard rejects a twelfth role. diff --git a/.agents/notes/implemented/bug-fix/2026-09-19-session-local-subagent-migration.zh.md b/.agents/notes/implemented/bug-fix/2026-09-19-session-local-subagent-migration.zh.md new file mode 100644 index 0000000000..673040a3dc --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-19-session-local-subagent-migration.zh.md @@ -0,0 +1,33 @@ +# Agent Note:补齐父目录而不迁移子目录 + +Status: implemented + +[English](2026-09-19-session-local-subagent-migration.md) | 中文 + +## 问题 + +打开历史父会话需要直属子会话的发现事实。子日志正文损坏、descriptor 无效,或根目录其他位置的 header 不可读,都可能拒绝父会话的 V4 准备,使原本可读的历史无法打开。 + +## 决策 + +打开 A 时通过 header 发现候选 B Session,读取其自身 descriptor,并补齐 A 的 catalog。历史子日志使用 V0–V3 catalog 读取,当前子日志使用原生校验。这些读取不准备 B 自己的 catalog,也不发布 B 的后继。单独打开 B 时才执行 B 的迁移与目录准备。前端发现继续读取父 catalog。模式未知的条目仍可浏览;`list_agents` 继续只选择已知可继续的子会话。 + +JSONL 在发现时跳过不可读或不支持的 header。子正文或 descriptor 字段失败会产生带子路径的警告,并保留该子的 header 身份。父目录没有完整条目时,迁移追加 `subagent/catalog`,与健康兄弟项一同投影为目录条目,其模式为 `mode: 'unknown'`。已有完整条目仍具有权威性。取消与来源一致性失败仍中止操作。复用和发布前会重新检查已检查子日志的修订,包括解码失败的日志,使修复不能静默复用旧证据。已发布前驱保持逐字节不变。 + +本决策部分取代[已发布迁移](../architecture/2026-08-31-released-session-format-migrations.zh.md)和[不完整子证据](2026-09-19-v3-incomplete-child-catalog-evidence.zh.md)中的子错误传播规则。其格式转换、descriptor 数量、冲突校验和不可变发布规则仍有效。 + +## 考虑过的替代方案 + +**跳过目录补齐,让每个消费者发现子项。** 父会话明明有可读的子 descriptor,却会因此产生不完整工具结果和额外 UI 状态。父 catalog 已经服务这些消费者。 + +**从目录删除不可读子会话。** 已发布父会话会丢失相应导航入口。Header 身份足以保留成员关系,无须编造模式或标签。 + +**递归准备子目录。** A 只需要 B 自身的 descriptor。准备 B 的后代会增加无关工作与失败依赖。 + +**一个子会话不可读就拒绝 A。** 子会话的发现元数据不足以成为阻断健康父历史的理由。警告保留子位置,不虚构 mode 或 label 字段。 + +## 后果 + +首次父准备仍扫描 header 并逐个读取直属子正文,不宣称消除此 I/O。已发布 V4 父会话使用其 catalog,不重新扫描子日志。发布后不自动重写未知条目。打开子会话会重试其日志,根据实际 descriptor 确定浏览能力,或报告该子会话的错误。Catalog 载荷 v0 保留两种已知模式,v1 另支持 `unknown`。读取器支持两版;正常创建及完整历史事实继续使用 v0,未知迁移事实使用 v1。V4 Session header 不变,旧读取器拒绝新载荷版本。 + +原始和压缩测试覆盖目录补齐、子会话错误隔离、来源变化与修复、子代际不变和延后的子目录准备。随产品 Web 迁移快照在损坏兄弟存在时检查父目录,再独立打开子会话。额外的历史子角色使语料预算由十个增加到十一个;守卫拒绝第十二个角色。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.i18n.yaml index 4ede5bd457..bf6d6c2fd0 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.md -2026-09-19-v3-incomplete-child-catalog-evidence.md: 22ca91502425257ed30aa4962f5a38497b8a8a92 -2026-09-19-v3-incomplete-child-catalog-evidence.zh.md: c11e6de2c27b9892a72c28f327b6d06834d84038 +2026-09-19-v3-incomplete-child-catalog-evidence.md: 400a84f376a7babc0a95492ca33a3bcd0761cd79 +2026-09-19-v3-incomplete-child-catalog-evidence.zh.md: 535951630bc600647724d50107e74e83ab626739 diff --git a/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.md b/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.md index 22ca915024..400a84f376 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.md +++ b/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.md @@ -10,9 +10,9 @@ A valid V3 child log can have no own descriptor, an unsupported descriptor versi ## Decision -The V3→V4 migration appends a missing parent catalog entry only when exactly one supported own child descriptor supplies its discovery fields. Other descriptor counts and unsupported versions contribute no new catalog fact. Existing parent entries and child events remain intact. This replaces only the missing-evidence refusal in the [adjacent migration decision](../architecture/2026-08-31-released-session-format-migrations.md). +The V3→V4 migration appends a complete missing parent catalog entry only when exactly one supported own child descriptor supplies its discovery fields. Other descriptor counts and unsupported versions retain unknown-mode membership through [session-local catalog preparation](2026-09-19-session-local-subagent-migration.md). Existing parent entries and child events remain intact. This replaces only the missing-evidence refusal in the [adjacent migration decision](../architecture/2026-08-31-released-session-format-migrations.md). -Known child creation times must still match existing parent entries. Mode/label and descriptor fields are checked only for exactly one supported own descriptor. Invalid fields in that descriptor, corrupt logs, unreadable membership, unsupported selected generations, and changed source revisions retain their existing failures. +The JSONL failure-propagation rules below are partially superseded by [session-local catalog preparation](2026-09-19-session-local-subagent-migration.md); the converter still validates supplied facts and conflicts. Known child creation times must still match existing parent entries. Mode/label and descriptor fields are checked only for exactly one supported own descriptor. JSONL isolates invalid descriptor fields and corrupt child bodies while retaining known header identity; unreadable or unsupported headers are excluded from discovery. Changed source revisions still refuse publication. ## Alternatives considered @@ -24,6 +24,6 @@ Known child creation times must still match existing parent entries. Mode/label ## Consequences -A historical child without a usable descriptor remains readable by id but may be absent from its parent's direct-child catalog. Existing catalog entries remain visible. Once a V4 successor is published, opening it does not rescan historical children; automatic later catalog repair is outside this migration. Preparation still rechecks child revisions before publication, so newly available evidence cannot silently bypass source consistency checks. +A historical child with a readable header but no usable descriptor retains unknown-mode membership in its parent's direct-child catalog. Existing catalog entries remain visible. Once a V4 successor is published, opening it does not rescan historical children; automatic later mode backfill is outside this migration. Preparation still rechecks child revisions before publication, so newly available evidence cannot silently bypass source consistency checks. Unit, JSONL read/write, and Preview packing tests cover unavailable evidence, unchanged source bytes, retained entries, identity conflicts, and evidence changes before publication. diff --git a/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.zh.md b/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.zh.md index c11e6de2c2..535951630b 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-09-19-v3-incomplete-child-catalog-evidence.zh.md @@ -10,9 +10,9 @@ Status: implemented ## Decision -V3→V4 迁移只在恰好一个受支持的自身子 descriptor 提供发现字段时,追加缺失的父目录项。其他 descriptor 数量及不受支持的版本不贡献新的目录事实。已有父目录项和子事件保持完整。这只替代[相邻迁移决策](../architecture/2026-08-31-released-session-format-migrations.zh.md)中的证据缺失拒绝规则。 +V3→V4 迁移仅在恰好一个受支持的自身子 descriptor 提供发现字段时,追加完整的缺失父目录项。其他 descriptor 数量和不支持的版本通过[逐会话目录准备](2026-09-19-session-local-subagent-migration.zh.md)保留模式未知的成员关系。已有父目录项和子事件保持不变。这取代[相邻迁移决策](../architecture/2026-08-31-released-session-format-migrations.zh.md)中对缺失证据的拒绝。 -已知子创建时间仍必须与已有父目录项一致。只有恰好一个受支持的自身 descriptor 时,才校验其字段并比较 mode/label。该 descriptor 字段无效、日志损坏、成员关系不可读、所选代际不受支持以及源修订变化仍保留原有失败行为。 +JSONL 错误传播规则由[逐会话目录准备](2026-09-19-session-local-subagent-migration.zh.md)部分取代;转换器仍校验已提供事实及冲突。已知子创建时间必须匹配已有父目录项。只有恰好一个受支持的自身 descriptor 时才检查 mode/label 和 descriptor 字段。JSONL 隔离无效 descriptor 字段和损坏子正文,并保留已知 header 身份;不可读或不支持的 header 不参与发现。来源修订变化仍拒绝发布。 ## Alternatives considered @@ -24,6 +24,6 @@ V3→V4 迁移只在恰好一个受支持的自身子 descriptor 提供发现字 ## Consequences -没有可用 descriptor 的历史子 Session 仍可按 id 读取,但可能不出现在其父级的直属子目录中。已有目录项仍可见。V4 后继发布后,打开它不会重新扫描历史子日志;后续自动修复目录不属于本迁移。准备过程仍会在发布前复查子修订,因此新出现的证据无法悄悄绕过源一致性检查。 +历史子会话具有可读 header 但没有可用 descriptor 时,在父直属子目录中保留未知模式成员关系。已有目录项仍可见。V4 后继发布后,打开它不重新扫描历史子日志;自动补齐后续模式不属于本迁移。准备过程仍在发布前重新检查子修订,因此新出现的证据不能静默绕过来源一致性检查。 单元、JSONL 读写和 Preview 打包测试覆盖不可用证据、源字节不变、条目保留、身份冲突以及发布前的证据变化。 diff --git a/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.i18n.yaml b/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.i18n.yaml index 69db5487b8..af257a325c 100644 --- a/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-17-developer-tools-settings.md -2026-09-17-developer-tools-settings.md: e76cb7f367faff71ef916d8a263dc523583f1cd2 -2026-09-17-developer-tools-settings.zh.md: 5a7d6d59db686f57ef4945b572ea65ffe54b7f9c +2026-09-17-developer-tools-settings.md: 7819b2a2df3c9eaf2db1e1271d7b419768affaff +2026-09-17-developer-tools-settings.zh.md: 5ff9f2c1e2380f086969cc392bf649edd13e2893 diff --git a/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.md b/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.md index e76cb7f367..7819b2a2df 100644 --- a/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.md +++ b/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.md @@ -10,9 +10,9 @@ Diagnostic views, preset selection and changed-file summaries add complexity to ## Decision -The settings-owned `ctx.settingsScope.developerTools` preference binds a namespace scope and exposes one accepted `ui-developer-tools.enabled` preference, defaulting off. General Settings offers the same switch on Web and desktop. The existing settings transport owns validation, ordered writes, persistence and recovery; loopback Web and desktop follow Host acceptance. Remote Web owns one browser-local observable shared by all consumers until reload, with no Host writes. +The settings-owned `ctx.settingsScope.developerTools` preference binds a namespace scope and exposes one accepted `ui-developer-tools.enabled` preference. The initial default-off choice reduces routine-task complexity; [Developer tools default on after preference resolution](2026-09-20-developer-tools-default-on.md) supersedes that default and owns startup enablement. General Settings offers the same switch on Web and desktop. The existing settings transport owns validation, ordered writes, persistence and recovery; loopback Web and desktop follow Host acceptance. Remote Web owns one browser-local observable shared by all consumers until reload, with no Host writes. -Off exposes only Chat, hides tool Inspect actions when no visible View declares tool-call inspection, removes the conversation View tab bar, hides the new-session preset chip, and omits the changed-files card and its summary read. Turning it off from another View activates Chat. The preset composition, saved default, Session events and explicit delivery cards remain unchanged. This preference is presentation policy, not Host authorization. +Off hides the Trajectory View, hides tool Inspect actions when no visible View declares tool-call inspection, hides the conversation View tab bar while fewer than two Views are available, hides the new-session preset chip and the settings row that allows switching Agent modes, and omits the changed-files card and its summary read. Views registered by other plugins stay available. Turning it off while Trajectory is active activates Chat. The preset composition, saved default, Session events and explicit delivery cards remain unchanged. This preference is presentation policy, not Host authorization. Plugin assembly translates the shared preference into component-specific capabilities (`showCodeDiff`, `showPresetPicker`, and `interactivePreview`). Conversation supplies the inspection callback using the visible target's `toolCallFocus`; Chat does not name the target. The builtin HTML renderer uses DOMPurify for complete-document sanitization and parser-mutation defenses. Off preserves root styling, removes active documents, resource hints, SVG animation and all `href` attributes, and mounts an iframe with no sandbox permissions and a CSP denying scripts and external resources. It reads no related files. On retains the opaque scripted Blob renderer and its bounded related-file reads. Switching modes replaces the browsing context and disposes pending reads. The [document-preview decision](../architecture/2026-09-08-document-preview-operations.md) and [filesystem-read authority](../architecture/2026-09-09-workspace-file-read-authority.md) remain authoritative for advanced rendering and Host read access. @@ -26,4 +26,4 @@ Plugin assembly translates the shared preference into component-specific capabil Basic HTML sacrifices JavaScript, external and related resources, and link navigation; inline styles and data images remain available. Advanced mode retains normal browser networking and does not gain parent-origin, popup, form or top-navigation privileges. The setting does not constrain third-party preview implementations or revoke Host filesystem authority. -Component and settings tests cover defaulting, schema rejection, accepted-state updates, chip disclosure, changed-file visibility and iframe disposal. The recorded-session [document preview](../../../../apps/web/tests/document-preview.e2e.ts) and [changed-files](../../../../apps/web/tests/changed-files-turn.e2e.ts) scenarios exercise the real settings control and the shared renderer. The browser scaffold enables developer tools by default. Only settings chrome, document preview, changed-files and developer-tools scenarios explicitly retain the shipped default; together they cover hidden diagnostic controls, static previews, and switching back to Chat. Refused Host writes recover accepted state and show the localized retry message. Native Electron execution remains unverified. +Component and settings tests cover defaulting, schema rejection, accepted-state updates, chip disclosure, changed-file visibility and iframe disposal. The recorded-session [document preview](../../../../apps/web/tests/document-preview.e2e.ts) and [changed-files](../../../../apps/web/tests/changed-files-turn.e2e.ts) scenarios exercise the real settings control and the shared renderer. The browser scaffold uses the shipped default unless a scenario specifies a preference. Settings chrome, document preview and changed-files scenarios explicitly disable developer tools; together they cover hidden diagnostic controls, static previews, and switching back to Chat. Refused Host writes recover accepted state and show the localized retry message. Native Electron execution remains unverified. diff --git a/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.zh.md b/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.zh.md index 5a7d6d59db..5ff9f2c1e2 100644 --- a/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.zh.md +++ b/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.zh.md @@ -10,9 +10,9 @@ Status: implemented ## Decision -设置域内的 `ctx.settingsScope.developerTools` 偏好对象绑定命名空间 scope,提供一个已接受的 `ui-developer-tools.enabled` 偏好,默认关闭。Web 和桌面端的通用设置提供同一个开关。现有设置传输负责验证、有序写入、持久化和恢复;回环 Web 和桌面端跟随 Host 接受的状态。远程 Web 持有一个所有消费者共享的浏览器本地可观察值,刷新后重置,不发送 Host 写入。 +设置域内的 `ctx.settingsScope.developerTools` 偏好对象绑定命名空间 scope,提供一个已接受的 `ui-developer-tools.enabled` 偏好。最初默认关闭的选择用于降低日常任务的复杂度;[偏好解析后默认开启开发者工具](2026-09-20-developer-tools-default-on.zh.md)取代该默认值并负责启动时的启用策略。Web 和桌面端的通用设置提供同一个开关。现有设置传输负责验证、有序写入、持久化和恢复;回环 Web 和桌面端跟随 Host 接受的状态。远程 Web 持有一个所有消费者共享的浏览器本地可观察值,刷新后重置,不发送 Host 写入。 -关闭时仅展示 Chat,在没有可见 View 声明工具调用检查能力时隐藏 Inspect 操作,移除会话 View 标签栏,隐藏新会话预设入口,并省略改动文件卡片及其摘要读取。在其他 View 中关闭开关会激活 Chat。预设组合、保存的默认值、Session 事件和显式交付卡片保持不变。此偏好是展示策略,不是 Host 授权。 +关闭时隐藏 Trajectory View,在没有可见 View 声明工具调用检查能力时隐藏 Inspect 操作,可见 View 少于两个时隐藏会话 View 标签栏,隐藏新会话预设入口和「允许切换agent模式」设置行,并省略改动文件卡片及其摘要读取。其他插件注册的 View 保持可用。在 Trajectory 中关闭开关会激活 Chat。预设组合、保存的默认值、Session 事件和显式交付卡片保持不变。此偏好是展示策略,不是 Host 授权。 插件组装层将共享偏好转换为组件自身的能力(`showCodeDiff`、`showPresetPicker` 和 `interactivePreview`)。Conversation 根据可见目标的 `toolCallFocus` 提供检查回调;Chat 不再指定目标名称。内置 HTML 渲染器使用 DOMPurify 清理完整文档并防护解析变异。关闭时保留根节点样式,移除主动文档、资源提示、SVG 动画及所有 `href` 属性,再放入不授予沙箱权限的 iframe,并通过 CSP 禁止脚本和外部资源。不读取关联文件。开启时保留不透明源的脚本 Blob 渲染器及其有界关联文件读取。切换模式会替换浏览上下文并清理待处理读取。[文档预览决策](../architecture/2026-09-08-document-preview-operations.zh.md)和[文件系统读取权限](../architecture/2026-09-09-workspace-file-read-authority.zh.md)继续定义高级渲染与 Host 读取访问。 @@ -26,4 +26,4 @@ Status: implemented 基础 HTML 放弃 JavaScript、外部与关联资源和链接导航;行内样式与 data 图片仍可用。高级模式保留正常浏览器网络能力,不增加父源、弹窗、表单或顶层导航权限。此设置不约束第三方预览实现,也不撤销 Host 文件系统读取权限。 -组件和设置测试覆盖默认值、schema 拒绝、已接受状态更新、入口折叠、改动文件展示和 iframe 清理。基于已记录 Session 的[文档预览](../../../../apps/web/tests/document-preview.e2e.ts)与[改动文件](../../../../apps/web/tests/changed-files-turn.e2e.ts)场景验证真实设置控件及共用渲染器。浏览器脚手架默认开启开发者工具,仅设置外壳、文档预览、改动文件和开发者工具场景显式保留产品默认值;这些场景共同覆盖诊断控件隐藏、静态预览和切回 Chat。Host 拒绝写入后会恢复已接受状态,并显示本地化重试提示。原生 Electron 执行尚未验证。 +组件和设置测试覆盖默认值、schema 拒绝、已接受状态更新、入口折叠、改动文件展示和 iframe 清理。基于已记录 Session 的[文档预览](../../../../apps/web/tests/document-preview.e2e.ts)与[改动文件](../../../../apps/web/tests/changed-files-turn.e2e.ts)场景验证真实设置控件及共用渲染器。浏览器脚手架使用产品默认值,除非场景指定偏好。设置外壳、文档预览和改动文件场景显式关闭开发者工具;这些场景共同覆盖诊断控件隐藏、静态预览和切回 Chat。Host 拒绝写入后会恢复已接受状态,并显示本地化重试提示。原生 Electron 执行尚未验证。 diff --git a/.agents/notes/implemented/feature/2026-09-20-developer-tools-default-on.i18n.yaml b/.agents/notes/implemented/feature/2026-09-20-developer-tools-default-on.i18n.yaml new file mode 100644 index 0000000000..aa7199eff4 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-20-developer-tools-default-on.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-20-developer-tools-default-on.md +2026-09-20-developer-tools-default-on.md: 42c89faed11b966ac8ea0882236af9bf13557702 +2026-09-20-developer-tools-default-on.zh.md: a684844f5ba53a0bebdd1c3a3a3ade1ed150cc31 diff --git a/.agents/notes/implemented/feature/2026-09-20-developer-tools-default-on.md b/.agents/notes/implemented/feature/2026-09-20-developer-tools-default-on.md new file mode 100644 index 0000000000..42c89faed1 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-20-developer-tools-default-on.md @@ -0,0 +1,31 @@ +# Agent Note: Developer tools default on after preference resolution + +Status: implemented + +English | [中文](2026-09-20-developer-tools-default-on.zh.md) + +## Problem + +New installations need access to developer views, preset selection, change summaries and interactive HTML previews without first finding a Settings switch. A saved disabled preference must still prevent those features from activating during startup, including script execution that cannot be undone after it occurs. + +## Decision + +The Host schema defaults `ui-developer-tools.enabled` to `true`. Remote browsers with process-local preferences also start enabled. Saved Host values take precedence, including `false`. This supersedes only the initial default-off choice in [Shared developer-tool settings](2026-09-17-developer-tools-settings.md); that note retains ownership of shared persistence and rendering policy. + +Host-backed clients publish disabled until an accepted schema-resolved value arrives. Loading, failed initial reads and an unavailable namespace do not grant interactive-preview capabilities. The Host supplies the default for an absent stored field; the client does not interpret an absent response as consent to enable scripts. An accepted value remains authoritative during later refreshes. + +Only the Trajectory tab follows the developer-tools preference; third-party Views remain available. The conversation shell retains the explicit Trajectory id because the requested rule names that View. No current requirement makes arbitrary diagnostic Views share the switch. + +## Alternatives considered + +**Keep the default off.** This minimizes the initial interface but requires a separate opt-in for the features new installations now expose. Users can still save the disabled preference. + +**Default the Host client to enabled before settings arrive.** This makes rendering immediate, but briefly activates features for users who disabled them. Unmounting a scripted HTML iframe cannot undo scripts or requests already executed. + +**Add a generic developer-tool flag to View registration.** This expands the plugin API and allows other Views to join a switch whose current requirement is specific to Trajectory. A future requirement can justify that API separately. + +## Consequences + +Fresh profiles expose developer features after settings resolve; users with a saved disabled choice retain the simpler presentation. New users also receive interactive HTML previews with the capabilities documented by the [filesystem-read authority](../architecture/2026-09-09-workspace-file-read-authority.md). Host initialization can briefly withhold developer controls even when the eventual preference is enabled. + +Settings tests cover initial withholding, unavailable namespaces, explicit disabled values and process-local defaults. HTML renderer coverage checks that pending and disabled preferences neither read related files nor create script-enabled frames. Browser settings coverage verifies the default and a disabled choice across reload; document-preview replay pins the explicitly disabled scenario. diff --git a/.agents/notes/implemented/feature/2026-09-20-developer-tools-default-on.zh.md b/.agents/notes/implemented/feature/2026-09-20-developer-tools-default-on.zh.md new file mode 100644 index 0000000000..a684844f5b --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-20-developer-tools-default-on.zh.md @@ -0,0 +1,31 @@ +# Agent Note: 偏好解析后默认开启开发者工具 + +Status: implemented + +[English](2026-09-20-developer-tools-default-on.md) | 中文 + +## Problem + +新安装需要直接使用开发者视图、预设选择、改动摘要和交互式 HTML 预览,无需先寻找设置开关。已保存的关闭偏好仍必须阻止这些功能在启动期间激活,包括发生后无法撤销的脚本执行。 + +## Decision + +Host schema 将 `ui-developer-tools.enabled` 默认为 `true`。使用进程内偏好的远程浏览器也从开启状态启动。已保存的 Host 值优先,包括 `false`。这仅取代[共享开发者工具设置](2026-09-17-developer-tools-settings.zh.md)中最初默认关闭的选择;该记录继续负责共享持久化和渲染策略。 + +使用 Host 偏好的客户端在收到经过 schema 解析并接受的值之前发布关闭状态。加载中、首次读取失败和命名空间不可用均不授予交互式预览能力。缺少存储字段时由 Host 提供默认值;客户端不会把缺少响应解释为允许开启脚本。后续刷新期间,已接受的值继续有效。 + +只有 Trajectory 标签页受开发者工具偏好控制;第三方 View 保持可用。对话外壳保留显式 Trajectory id,因为当前需求明确指定此 View。没有现行需求要求任意诊断 View 共用该开关。 + +## Alternatives considered + +**保持默认关闭。** 这可简化初始界面,但新安装现在直接提供的功能将需要额外启用。用户仍可保存关闭偏好。 + +**在设置到达前让 Host 客户端默认开启。** 这可以立即渲染,却会短暂激活用户已关闭的功能。卸载可执行脚本的 HTML iframe 无法撤销已经执行的脚本或请求。 + +**在 View 注册中增加通用开发者工具标记。** 这会扩展插件 API,并允许其他 View 加入当前仅针对 Trajectory 的开关。未来需求可以单独论证该 API。 + +## Consequences + +新 profile 在设置解析后展示开发者功能;已保存关闭选择的用户保留简化展示。新用户也会获得带有[文件系统读取权限](../architecture/2026-09-09-workspace-file-read-authority.zh.md)所述能力的交互式 HTML 预览。即使最终偏好为开启,Host 初始化期间也可能短暂不展示开发者控件。 + +设置测试覆盖首次等待、命名空间不可用、显式关闭值和进程内默认值。HTML 渲染器覆盖验证等待中及关闭偏好均不读取关联文件,也不创建允许脚本的 frame。浏览器设置覆盖验证默认值和关闭选择在刷新后保持;文档预览回放固定显式关闭的场景。 diff --git a/.agents/notes/implemented/process/2026-09-11-persistence-type-history.i18n.yaml b/.agents/notes/implemented/process/2026-09-11-persistence-type-history.i18n.yaml index 94e92c9bac..969ab3fd9e 100644 --- a/.agents/notes/implemented/process/2026-09-11-persistence-type-history.i18n.yaml +++ b/.agents/notes/implemented/process/2026-09-11-persistence-type-history.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-09-11-persistence-type-history.md -2026-09-11-persistence-type-history.md: c7cddea5ecc479a64ad5f24f5447e869779b7f9e -2026-09-11-persistence-type-history.zh.md: 001ce197c4cd12dc64a5e2bd710e39cb2b61f23a +2026-09-11-persistence-type-history.md: 87e87041dd44c4f671409a75c1b374312736342a +2026-09-11-persistence-type-history.zh.md: cfe5c4f7bd1998d4329dc422607cfe33273d1a5a diff --git a/.agents/notes/implemented/process/2026-09-11-persistence-type-history.md b/.agents/notes/implemented/process/2026-09-11-persistence-type-history.md index c7cddea5ec..87e87041dd 100644 --- a/.agents/notes/implemented/process/2026-09-11-persistence-type-history.md +++ b/.agents/notes/implemented/process/2026-09-11-persistence-type-history.md @@ -14,7 +14,7 @@ One normalized type model supplies the readable persistence catalog, the complet The dedicated [persistence-change records](../../../../docs/persistence-changes/README.md) bind acknowledgement to the after digest of each affected root. A generated companion stores complete after schemas. The initial record covers all roots; later records name each root's predecessor. A predecessor's after schema supplies the next before schema. Verification rejects ambiguous history and requires current source to match the terminal recorded state without consulting Git history or remote services. -Automatic classification permits optional body additions, required-to-optional body changes, and ordinary event additions within one version. The scoped [source-attribution policy](2026-09-17-persistence-schema-review.md) also permits qualified new producer kinds when both saved schemas opt in. Other structural changes require a version-bump decision that includes an increasing header version in that record. The [format-version procedure](../../../../docs/cookbook/adding-a-session-format-version.md) continues to own adjacent migration work. Every structural difference requires an explicit record, including additions allowed within the same version. +Automatic classification permits optional body additions, required-to-optional body changes, and ordinary event additions within one version. The scoped [source-attribution policy](2026-09-17-persistence-schema-review.md) also permits qualified new producer kinds when both saved schemas opt in. The [event-payload version rule](../../../../docs/persistence-changes/README.md#compatibility-rules) admits higher numeric payload versions when every old alternative remains unchanged and records the older-reader limitation. Other structural changes require a version-bump decision that includes an increasing header version in that record. The [format-version procedure](../../../../docs/cookbook/adding-a-session-format-version.md) continues to own adjacent migration work. Every structural difference requires an explicit record, including additions allowed within the same version. The new document kind preserves the compatibility reasoning and evidence for a historical type transition. Agent Notes retain mechanism-level decisions; they do not become a growing inventory of individual acknowledgements. Machine declarations remain identical across the bilingual pair and are parsed once. diff --git a/.agents/notes/implemented/process/2026-09-11-persistence-type-history.zh.md b/.agents/notes/implemented/process/2026-09-11-persistence-type-history.zh.md index 001ce197c4..cfe5c4f7bd 100644 --- a/.agents/notes/implemented/process/2026-09-11-persistence-type-history.zh.md +++ b/.agents/notes/implemented/process/2026-09-11-persistence-type-history.zh.md @@ -14,7 +14,7 @@ Status: implemented 专用的[持久化变更记录](../../../../docs/persistence-changes/README.zh.md)将确认绑定到每个受影响根的变更后摘要。生成的伴随文件保存完整的变更后 schema。初始记录覆盖所有根;后续记录为每个根指定前驱。前驱的变更后 schema 提供下一次的变更前 schema。验证拒绝有歧义的历史,并要求当前源码匹配最终记录状态,无需查询 Git 历史或远端服务。 -自动分类允许可选事件体新增、事件体属性从必选改为可选,以及普通事件新增保持同一版本。当双方保存的 schema 均声明支持时,受限的[消息源归属策略](2026-09-17-persistence-schema-review.zh.md)也允许添加符合条件的生产者 kind。其他结构变更需要升版本决策,并在该记录中包含递增的头部版本。[格式版本流程](../../../../docs/cookbook/adding-a-session-format-version.zh.md)继续负责相邻迁移工作。每个结构差异都需要显式记录,包括允许保持同一版本的新增。 +自动分类允许可选事件体新增、事件体属性从必选改为可选,以及普通事件新增保持同一版本。当双方保存的 schema 均声明支持时,受限的[消息源归属策略](2026-09-17-persistence-schema-review.zh.md)也允许添加符合条件的生产者 kind。[事件载荷版本规则](../../../../docs/persistence-changes/README.zh.md#compatibility-rules) 允许在原样保留所有旧分支时增加更高的数字载荷版本,并记录旧读取器限制。其他结构变更需要升版本决策,并在该记录中包含递增的头部版本。[格式版本流程](../../../../docs/cookbook/adding-a-session-format-version.zh.md)继续负责相邻迁移工作。每个结构差异都需要显式记录,包括允许保持同一版本的新增。 新文档类型保留历史类型转换的兼容性说明和证据。Agent Note 保留机制层面的决策,不积累单次确认清单。双语对中的机器声明保持相同,且只解析一次。 diff --git a/.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.i18n.yaml b/.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.i18n.yaml index b2f71a9f4d..0ace5db1f2 100644 --- a/.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.i18n.yaml +++ b/.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.md -2026-09-17-windows-runtime-signature-cache.md: 911e0277199bac55165fbc4adebb22e2599043b9 -2026-09-17-windows-runtime-signature-cache.zh.md: bb8cdd6bdca445c94e8e77cddb68008d7b27f7ad +2026-09-17-windows-runtime-signature-cache.md: e9229e47929664984a92499e54f87bb2166bde9d +2026-09-17-windows-runtime-signature-cache.zh.md: 53b6182170d807843345fa90f962ee18807f2cf2 diff --git a/.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.md b/.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.md index 911e027719..e9229e4792 100644 --- a/.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.md +++ b/.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.md @@ -20,6 +20,8 @@ Serialize each complete signing stage and cache-maintenance operation with an ex Group public-key inspection into bounded batches of 32 files with at most four processes. Require one ordered result for every requested path and await every process in a failed batch. Preserve verification immediately after each hardware signature, before the next signing request. +Within the stage lock, restore distinct cache-hit targets with a bounded worker pool and complete their post-verification before serially signing misses. Stop dispatch on the first restore, verification or audit failure and drain active workers before releasing ownership. Four workers are the configurable default: warm-cache experiments on 134 primary-runtime and 228 application files reduce combined median restore-plus-post-verification time from 226.18 seconds at one worker to 63.22 seconds at four; eight takes 42.18 seconds but increases measured CPU by 25.5% and peak committed memory from 387 to 631 MiB. These three-repeat measurements use exact reconstructed unsigned inputs, real Windows verification and a rejecting hardware callback; they exclude full packaging, cold trust state and hardware misses. + The [primary-runtime decision](../feature/2026-09-14-desktop-primary-runtime.md) continues to own runtime contents, vendor-signature preservation and execution checks. The [release decision](../architecture/2026-08-25-electron-desktop-packaging-and-updates.md) continues to own release identity and signing. Neither is superseded. The cache must preserve their supervised preflight, per-user signing interlock, single-file hardware calls, signed inventory and final packaged-runtime validation. ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.zh.md b/.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.zh.md index bb8cdd6bdc..53b6182170 100644 --- a/.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.zh.md +++ b/.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.zh.md @@ -20,6 +20,8 @@ Windows 打包每次都会重新生成未签名的原生依赖。为未变化的 公钥验签按每批 32 个文件分组,最多同时运行四个进程。要求每个请求路径都有一条顺序对应的结果,并等待失败批次中的所有进程退出。每次硬件签名后仍立即验签,通过后才开始下一个签名请求。 +在阶段锁内,使用有界工作池恢复不同的缓存命中目标,完成后置验签后才串行签署未命中项。首次恢复、验签或审计失败时停止派发,并在释放所有权前等待在途任务结束。可配置的默认并发为四:对 134 个主要运行时文件及 228 个应用文件的热缓存实验中,恢复加后置验签的合计中位耗时从单任务的 226.18 秒降至四任务的 63.22 秒;八任务为 42.18 秒,但实测 CPU 消耗增加 25.5%,峰值提交内存从 387 增至 631 MiB。这些三轮测量使用精确重建的未签名输入、真实 Windows 验签和拒绝硬件调用的回调;不包含完整打包、冷信任状态或硬件未命中。 + [主要运行时决策](../feature/2026-09-14-desktop-primary-runtime.zh.md) 继续定义运行时内容、厂商签名保留和执行检查。[发布决策](../architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md) 继续定义发布身份和签名。两者均未被取代。缓存必须保留受监督预检、每用户签名互锁、单文件硬件调用、已签名内容清单和最终打包运行时验证。 ## Alternatives considered diff --git a/AGENTS.md b/AGENTS.md index 7a128b6167..aa83ae8c08 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -101,6 +101,8 @@ pnpm run test:docs # quick documentation checks (no build; doc-quick aggreg pnpm run website:build # VitePress build (doubles as dead-link check) pnpm dsh --profile headless "task" # run one task from source (needs DEEPSEEK_API_KEY) pnpm run demo:ptc -- "task" # headless PTC mode run (needs key) +pnpm run dev:web | dev:desktop # build, then launch; Web also rebuilds client bundles on edits. start:web | start:desktop skip the build +make web|dev-web|desktop|dev-desktop|build # the same commands; ARGS='--no-open' forwards options ``` ### Host sandbox failures diff --git a/Makefile b/Makefile new file mode 100644 index 0000000000..4b95df6766 --- /dev/null +++ b/Makefile @@ -0,0 +1,31 @@ +# Short names for the Web and Desktop application commands. The package.json +# scripts they call stay the source of truth; docs/development.md documents both. +.DEFAULT_GOAL := help +.PHONY: help build web desktop dev-web dev-desktop + +PNPM ?= pnpm +ARGS ?= + +help: + @echo "make build pnpm run build complete repository build" + @echo "make web pnpm run start:web serve the built Web artifacts from source" + @echo "make desktop pnpm run start:desktop launch the built Desktop artifacts" + @echo "make dev-web pnpm run dev:web build, serve, and rebuild Web on source edits" + @echo "make dev-desktop pnpm run dev:desktop build, then launch Desktop" + @echo "ARGS='--no-open --port 3081' forwards options to the launched application;" + @echo "the Web commands accept dsh web flags, the Desktop launcher accepts none." + +build: + $(PNPM) run build + +web: + $(PNPM) run start:web $(ARGS) + +desktop: + $(PNPM) run start:desktop $(ARGS) + +dev-web: + $(PNPM) run dev:web $(ARGS) + +dev-desktop: + $(PNPM) run dev:desktop $(ARGS) diff --git a/README.i18n.yaml b/README.i18n.yaml index 904c7928d2..ce6f75b18d 100644 --- a/README.i18n.yaml +++ b/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write README.md -README.md: 36adfe913902e75ce0673adc2cb5cb692151a291 -README.zh.md: f6861b4c9490741b5e5f2d825a370766178dd9f6 +README.md: 423bccd7d9352dbc48f1705f37d35642e74f5b41 +README.zh.md: c760f87e4ac3b7dbb7bb55d733e31db5ab379836 diff --git a/README.md b/README.md index 36adfe9139..423bccd7d9 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,8 @@ See [CONTRIBUTING.md](CONTRIBUTING.md). Start with the [development guide](docs/development.md) and [architecture documentation](docs/architecture.md). +`pnpm run dev:web` builds, serves, and rebuilds client bundles on source edits in one terminal, and `make help` lists the matching Make targets for Web and Desktop; the guide's application commands section owns the full table. + For agents, follow [AGENTS.md](AGENTS.md). ## Citation diff --git a/README.zh.md b/README.zh.md index f6861b4c94..c760f87e4a 100644 --- a/README.zh.md +++ b/README.zh.md @@ -75,6 +75,8 @@ pnpm dsh web 请先阅读[开发指南](docs/development.zh.md)与[架构文档](docs/architecture.zh.md)。 +`pnpm run dev:web` 会在一个终端里完成构建、启动,并在源码修改时重建 client bundle;`make help` 列出 Web 与 Desktop 对应的 Make target。完整表格见开发指南的「应用命令」一节。 + 面向 agent:请遵循 [AGENTS.md](AGENTS.md)。 ## 引用 diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index 0cff0e5cdb..cb0eddf183 100644 --- a/apps/cli/reference/README.i18n.yaml +++ b/apps/cli/reference/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/cli/reference/README.md -README.md: cf0e1400f2dc8cd29cc59cefbdda379df2e0b899 -README.zh.md: a01818f59ccf54f3f826c68505bfb830a9316664 +README.md: 33465e8c96df9cbfbecc038616caf297146ec971 +README.zh.md: d25d27851ca789a3db358157368b97c3669ed79d diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index cf0e1400f2..33465e8c96 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -85,7 +85,7 @@ Git-hosted plugins that ship sources build during install through their `prepare ## Web profile -`dsh web` uses the profile shorthand. Launcher flags are parsed first; the remaining flags belong to the web app, whose ordinary bundle provider parses them. `--host` and `--port` override the composed values of the rows that carry them, repeatable `--trusted-host` contributes invocation authorities through `ctx.webRuntime.trustedHosts` (a deployment expression concatenates its own authorities), and `--no-open` disables the default-browser handoff for this invocation. The client-plugin HMR receiver is always mounted and stays idle until a separate `pnpm run dev:web` watcher rebuilds client bundles. +`dsh web` uses the profile shorthand. Launcher flags are parsed first; the remaining flags belong to the web app, whose ordinary bundle provider parses them. `--host` and `--port` override the composed values of the rows that carry them, repeatable `--trusted-host` contributes invocation authorities through `ctx.webRuntime.trustedHosts` (a deployment expression concatenates its own authorities), and `--no-open` disables the default-browser handoff for this invocation. The client-plugin HMR receiver is always mounted and stays idle until `pnpm run dev:web` rebuilds client bundles; that command builds once, starts this same launcher, and keeps client bundles rebuilt, or with `--no-serve` runs only the watchers beside a `dsh web` started elsewhere. ```sh dsh web diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md index a01818f59c..d25d27851c 100644 --- a/apps/cli/reference/README.zh.md +++ b/apps/cli/reference/README.zh.md @@ -87,7 +87,7 @@ dsh --profile tui ## Web Profile -`dsh web` 使用 profile 简写。启动器先解析自身的 flag,其余 flag 属于 web 应用,由组合包中的普通提供方解析。`--host` 和 `--port` 覆盖承载它们的那些行的组合取值,可重复的 `--trusted-host` 通过 `ctx.webRuntime.trustedHosts` 提供本次调用的 authority(部署表达式会拼接自己的 authority),`--no-open` 则只对本次调用关闭默认浏览器交接。客户端插件 HMR(热模块替换)接收器始终挂载,在单独运行的 `pnpm run dev:web` watcher 重建客户端 bundle 之前保持空闲。 +`dsh web` 使用 profile 简写。启动器先解析自身的 flag,其余 flag 属于 web 应用,由组合包中的普通提供方解析。`--host` 和 `--port` 覆盖承载它们的那些行的组合取值,可重复的 `--trusted-host` 通过 `ctx.webRuntime.trustedHosts` 提供本次调用的 authority(部署表达式会拼接自己的 authority),`--no-open` 则只对本次调用关闭默认浏览器交接。客户端插件 HMR(热模块替换)接收器始终挂载,在 `pnpm run dev:web` 重建客户端 bundle 之前保持空闲;该命令先构建一次,再启动这同一个启动器并持续重建客户端 bundle,加 `--no-serve` 则只运行 watcher、配合别处启动的 `dsh web`。 ```sh dsh web diff --git a/apps/cli/tests/profiles/web/tests/web-default-isolation.expected.e2e.ts b/apps/cli/tests/profiles/web/tests/web-default-isolation.expected.e2e.ts index 0e58af42f3..ecf19a7253 100644 --- a/apps/cli/tests/profiles/web/tests/web-default-isolation.expected.e2e.ts +++ b/apps/cli/tests/profiles/web/tests/web-default-isolation.expected.e2e.ts @@ -8,7 +8,7 @@ import { withDefaultWeb, webGet } from './default-web-process.ts' const experimentalName = '@deepseek-ai/dsh-experimental-client-ui-agent-team' -it('boots the default Web profile without experimental Host modules, mounted plugins, or Client entries', async (test) => { +it('boots default Web without experimental modules or an active built-in Browser', async (test) => { await withDefaultWeb(test, async ({ url, request }) => { const auth = await webGet(url, test.signal) const cookie = auth.headers['set-cookie']?.[0]?.split(';', 1)[0] @@ -29,6 +29,11 @@ it('boots the default Web profile without experimental Host modules, mounted plu expect(roster.plugins.length).toBeGreaterThan(roster.entries.length) expect(roster.modules.some(url => modulePackage(url) === '@deepseek-ai/dsh')).toBe(true) expect(roster.client.entries.length).toBeGreaterThan(0) + const browserName = '@deepseek-ai/dsh-client-ui-sidebar-browser' + const browserEntry = roster.entries.find(entry => entry.name === browserName) + expect(browserEntry).toBeDefined() + expect(browserEntry!.state).toBeUndefined() + expect(delivered.entries.some(entry => entry.id === browserName)).toBe(false) expect(experimentalRuntimeReferences(roster)).toEqual([]) const contaminated = await request('mount-experimental') diff --git a/apps/desktop/.env.windows.example b/apps/desktop/.env.windows.example index 9b38d46df9..28e35a06d2 100644 --- a/apps/desktop/.env.windows.example +++ b/apps/desktop/.env.windows.example @@ -20,6 +20,9 @@ DSH_DESKTOP_WINDOWS_TOKEN_PIN= # Optional absolute, private local directory; defaults to %USERPROFILE%\.dsh-desktop-signing\signature-cache\v1. # DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR=C:\Users\build\.dsh-desktop-signing\signature-cache\v1 +# Cache-hit restore and verification workers (1-8). Hardware signing remains serial. +DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY=4 + # Optional upload configuration; packaging does not require these credentials. # DOWNLOAD_TEST_COS_BUCKET= # DOWNLOAD_TEST_COS_SECRET_ID= diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 455501862e..ffe9a61ddd 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/desktop/README.md -README.md: 6c9a6a8784732e6fa1369df5632391b789786b22 -README.zh.md: a0f2184d911eeab46902670cf992b6847e2bca31 +README.md: 0fc43998495f43b5b8586a21e88e13e7764be73e +README.zh.md: 8eadd2fc96199cc2a3f58bb3d746851e3e8b5816 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 6c9a6a8784..0fc4399849 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) The desktop application is an Electron shell around the complete dsh Web application. An Electron RunAsNode child starts the shared profile runner, and Electron immediately loads the packaged Web entry at `dsh-app://app/`. Its shared loading page waits for Host boot injections, then starts the client without navigating to another document. Electron forwards application HTTP requests to the authenticated Web Host; WebSocket streams connect to that Host with credentials attached only for the owned application window. Node IPC carries boot injections, readiness, and shutdown. Desktop defaults to port `19387`, separate from Web’s `3080`; a `webserver.config.port` patch can override it. -The first application-menu command, **About DeepSeek Harness**, opens Electron's native About panel with the application icon, product name, and installed release version. The menu follows the Desktop shell locale. macOS reads the icon from its application bundle, so an unpackaged development launch displays Electron's icon; Windows receives the packaged PNG. +The first application-menu command, **About DeepSeek Harness**, opens Electron's native About panel with the application icon, product name, and installed release version. The menu follows the Desktop shell locale. On macOS, Hide, Hide Others, Show All, and Quit use localized labels; Hide and Quit include the DeepSeek Harness product name. These commands retain their native actions and shortcuts. macOS reads the icon from its application bundle, so an unpackaged development launch displays Electron's icon; Windows receives the packaged PNG. Desktop’s local native directory flow opens an Electron folder dialog attached to the application window, restoring, showing, and focusing that window first. Concurrent requests share one dialog; cancellation returns no path and failures remain retryable. Ordinary Web uses the Host chooser. Browse mode lists Host directories. On Linux without zenity or kdialog, automatic selection uses browse instead of the Electron dialog. @@ -93,7 +93,7 @@ After an explicit build, `start:desktop` reconstructs the disposable project and pnpm run start:desktop ``` -Workspace development runs the current CLI and private Desktop Host packages under Electron RunAsNode. Plugin management and recovery use `$DSH_HOME/profiles/desktop`, separate from the disposable workspace runtime. The Host uses runtime module resolution in both development and packaged builds without creating official-package fallback links; developer-installed packages, including links, retain native priority. Use an unpacked application to exercise Electron RunAsNode, bundled pnpm, bundled dsh resources, plugin installation and repair paths. +The Web counterparts are `pnpm run dev:web` and `pnpm run start:web`, documented in the [development guide](../../docs/development.md). Workspace development runs the current CLI and private Desktop Host packages under Electron RunAsNode. Plugin management and recovery use `$DSH_HOME/profiles/desktop`, separate from the disposable workspace runtime. The Host uses runtime module resolution in both development and packaged builds without creating official-package fallback links; developer-installed packages, including links, retain native priority. Use an unpacked application to exercise Electron RunAsNode, bundled pnpm, bundled dsh resources, plugin installation and repair paths. ## Package @@ -232,7 +232,9 @@ Runtime signing shares complete signed files across the current Windows account' Preflight, primary-runtime signing, application-runtime signing and artifact creation each hold the account's signing-stage lock until their supervised child processes settle. Other builds wait before entering those stages; compilation and preparation do not hold the lock. Runtime signing workers own their locks, so terminating only the outer packaging process does not unlock a surviving cache user. Migration and maintenance acquire the same lock; older or external signing commands do not participate and must be scheduled separately. Stage waiting is outside the preflight deadline. Closing the stage handle releases ordinary contention without removing the separate hardware-attempt interlock; a retained hardware failure still requires operator recovery. -Each runtime stage writes `SIGNATURE_CACHE_SUMMARY` to stdout and its packaging journal, including the actual directory, policy identity, hit/miss counts, newly published and retained entries, signing requests, avoided signing requests and validation failures. Timings distinguish signing, trust verification of restored and newly signed files, and restoration; restoration includes its trust check and staging cleanup. These counters exclude preflight, final artifact signing and the outer runtime signature checks. Stage lock events record waiting separately. +Cache-hit copying, hashing and per-file trust checks run with `DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY` workers from `.env.windows` (default `4`, integer `1`–`8`). All restores and their post-verification finish before cache misses enter serial hardware signing. A restore or verification failure stops new dispatch and drains active work before releasing the stage lock; hardware signing, runtime smoke checks and final integrity validation remain required. + +Each runtime stage writes `SIGNATURE_CACHE_SUMMARY` to stdout and its packaging journal, including the actual directory, policy identity, hit/miss counts, newly published and retained entries, signing requests, avoided signing requests and validation failures. Timings distinguish signing, trust verification of restored and newly signed files, and restoration; restoration includes its trust check and staging cleanup. These counters exclude preflight, final artifact signing and the outer runtime signature checks. Stage lock events record waiting separately. Concurrent timings sum per-file work rather than stage wall time. Use `pnpm --dir apps/desktop run cache:windows-signatures --usage` to inspect structurally complete-entry bytes and counts, or `--from ` to import an explicitly selected cache owned by the same account. Migration leaves the source unchanged, skips staging names, rejects corrupt entries and retains valid existing entries even when their timestamp bytes differ. `--clear` explicitly removes complete entries under the stage lock; no automatic capacity eviction runs. `--directory ` selects a maintenance destination without loading release credentials. Incomplete staging entries remain untouched and are counted separately; inspect them only after all builds have stopped. These commands never clear hardware failure evidence. Default storage is outside AppData so MSIX launcher virtualization does not split the account's cache; redirected overrides fail explicitly. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index a0f2184d91..8eadd2fc96 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -4,7 +4,7 @@ 桌面应用是完整 dsh Web 应用外的一层 Electron 壳。Electron RunAsNode 子进程启动共享 profile runner,Electron 立即从 `dsh-app://app/` 加载打包内的 Web 入口。共享加载页等待 Host 启动注入,然后在同一文档中启动客户端。Electron 将应用 HTTP 请求转发给已认证的 Web Host;WebSocket 流连接到该 Host,仅为归属的应用窗口附加凭据。Node IPC 承载启动注入、就绪与关闭。Desktop 默认使用端口 `19387`,与 Web 的 `3080` 分开;可通过 `webserver.config.port` patch 覆盖。 -应用菜单第一项“**关于 DeepSeek Harness**”打开 Electron 原生关于面板,展示应用图标、产品名称和当前安装的发布版本。菜单文案跟随桌面壳的语言。macOS 从应用包读取图标,因此未打包的开发启动会显示 Electron 图标;Windows 使用随包分发的 PNG。 +应用菜单第一项“**关于 DeepSeek Harness**”打开 Electron 原生关于面板,展示应用图标、产品名称和当前安装的发布版本。菜单文案跟随桌面壳的语言。macOS 的隐藏、隐藏其他、显示全部和退出条目使用本地化文案,隐藏和退出条目包含 DeepSeek Harness 产品名称。这些条目保留原生动作和快捷键。macOS 从应用包读取图标,因此未打包的开发启动会显示 Electron 图标;Windows 使用随包分发的 PNG。 Desktop 的本地原生目录流程打开绑定应用窗口的 Electron 文件夹对话框,并先恢复、显示和聚焦该窗口。并发请求共用一个对话框;取消不返回路径,失败后可以重试。普通 Web 使用 Host 选择器。浏览模式列出 Host 目录。Linux 缺少 zenity 或 kdialog 时,自动选择使用浏览模式,不使用 Electron 对话框。 @@ -93,7 +93,7 @@ pnpm run dev:desktop pnpm run start:desktop ``` -Workspace 开发使用 Electron RunAsNode 运行当前 CLI 与私有 Desktop Host 包,插件管理和恢复使用 `$DSH_HOME/profiles/desktop`,与一次性工作区运行时分离。Host 在开发与打包构建中都使用 runtime 模块解析,不创建官方包的 fallback 链接;开发者安装的包(包括链接)保留原生优先级。需要验证 Electron RunAsNode、内置 pnpm、内置 dsh 资源、插件安装和修复时,应运行未封装安装器的应用目录。 +Web 侧的对应命令是 `pnpm run dev:web` 与 `pnpm run start:web`,见[开发指南](../../docs/development.zh.md)。Workspace 开发使用 Electron RunAsNode 运行当前 CLI 与私有 Desktop Host 包,插件管理和恢复使用 `$DSH_HOME/profiles/desktop`,与一次性工作区运行时分离。Host 在开发与打包构建中都使用 runtime 模块解析,不创建官方包的 fallback 链接;开发者安装的包(包括链接)保留原生优先级。需要验证 Electron RunAsNode、内置 pnpm、内置 dsh 资源、插件安装和修复时,应运行未封装安装器的应用目录。 ## 打包 @@ -232,7 +232,9 @@ Windows 打包使用 Visual C++ Build Tools 和 Windows SDK 编译 x86 Win32/GDI 预检、主要运行时签名、应用运行时签名和产物生成分别持有账户级签名阶段锁,直到受监督的子进程结束。其他构建进入这些阶段前等待;编译和准备步骤不持锁。运行时签名进程自行持锁,因此仅终止外层打包进程不会释放仍在访问缓存的阶段锁。迁移及维护获取同一把锁;旧版或外部签名命令不参与排队,应另行避免并发。阶段等待不计入预检期限。关闭阶段句柄会释放普通竞争锁,不删除独立的硬件尝试互锁;遗留硬件失败仍需操作人员恢复。 -每个运行时阶段向标准输出及打包日志写入 `SIGNATURE_CACHE_SUMMARY`,包含实际目录、策略标识、命中及未命中数、新发布及保留项数、签名请求数、省去的签名请求数和验证失败数。计时区分签名、恢复文件和新签名文件的信任验证及恢复;恢复耗时包含其信任检查和暂存清理。这些统计不包含预检、最终产物签名及外层运行时验签。阶段锁事件单独记录等待时间。 +缓存命中的复制、摘要和逐文件信任检查使用 `.env.windows` 中的 `DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY` 个工作任务(默认 `4`,整数 `1`–`8`)。所有恢复及后置验签完成后,未命中项才进入串行硬件签名。恢复或验签失败会停止派发新任务,并在释放阶段锁前等待在途工作结束;硬件签名、运行时 smoke 检查和最终完整性校验仍须执行。 + +每个运行时阶段向标准输出及打包日志写入 `SIGNATURE_CACHE_SUMMARY`,包含实际目录、策略标识、命中及未命中数、新发布及保留项数、签名请求数、省去的签名请求数和验证失败数。计时区分签名、恢复文件和新签名文件的信任验证及恢复;恢复耗时包含其信任检查和暂存清理。这些统计不包含预检、最终产物签名及外层运行时验签。阶段锁事件单独记录等待时间。 并发计时累加逐文件工作耗时,不代表阶段墙钟时间。 使用 `pnpm --dir apps/desktop run cache:windows-signatures --usage` 查看结构完整的缓存项字节数和数量,或用 `--from ` 导入显式指定、属于同账户的旧缓存。迁移不修改源目录,跳过暂存名称,拒绝损坏项,并保留已有有效项,即使它们的时间戳字节不同。`--clear` 在阶段锁保护下显式删除完整缓存项;不执行自动容量淘汰。`--directory ` 指定维护目标目录,无需加载发布凭据。不完整暂存项保持原状并单独计数;仅在所有构建停止后检查它们。这些命令绝不清除硬件失败证据。默认存储位于 AppData 之外,避免 MSIX 启动器虚拟化将账户缓存拆开;被重定向的覆盖目录会明确失败。 diff --git a/apps/desktop/scripts/desktop-package-environment.mjs b/apps/desktop/scripts/desktop-package-environment.mjs index c8a794e3c7..f07b9653fa 100644 --- a/apps/desktop/scripts/desktop-package-environment.mjs +++ b/apps/desktop/scripts/desktop-package-environment.mjs @@ -10,10 +10,11 @@ import { createWindowsTokenSigner } from './windows-sign.mjs' import { resolveDesktopPolicyEnvironment } from './desktop-policy-environment.mjs' import { resolveMacOSPackageSettings } from './macos-package-settings.mjs' import { resolveWindowsSignatureCacheDirectory } from './windows-signature-cache-directory.mjs' +import { resolveWindowsPackageSettings } from './windows-package-settings.mjs' const APP_ROOT = fileURLToPath(new URL('..', import.meta.url)) const SHARED_SETTING = /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV|MANDATORY_UPDATE_(?:CONFIG|(?:TEST|PROD)_ORIGIN))|DOWNLOAD_(?:TEST|PROD)_(?:ORIGIN|COS_BUCKET|COS_SECRET_ID|COS_SECRET_KEY))$/u -const WINDOWS_SETTING = /^DSH_DESKTOP_WINDOWS_(?:CER_FILE|SIGNTOOL|KEY_CONTAINER|TOKEN_PIN|SIGNATURE_CACHE_DIR)$/u +const WINDOWS_SETTING = /^DSH_DESKTOP_WINDOWS_(?:CER_FILE|SIGNTOOL|KEY_CONTAINER|TOKEN_PIN|SIGNATURE_CACHE_DIR|SIGNATURE_CACHE_CONCURRENCY)$/u const MACOS_SETTING = /^(?:DSH_DESKTOP_MACOS_(?:SIGNING_IDENTITY|TEAM_ID|PACK_CONCURRENCY|DOWNLOAD_PROXY|NOTARIZATION_PROXY)|APPLE_(?:API_KEY|API_KEY_ID|API_ISSUER|ID|APP_SPECIFIC_PASSWORD|TEAM_ID|KEYCHAIN|KEYCHAIN_PROFILE)|CSC_(?:LINK|KEY_PASSWORD))$/u const AMBIENT_RELEASE_SETTING = /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV|MANDATORY_UPDATE_.*|WINDOWS_.*|MACOS_.*)|APPLE_.*|(?:WIN_)?CSC_.*|DOWNLOAD_(?:TEST|PROD)_.*)$/iu const FILE_SETTINGS = ['DSH_DESKTOP_WINDOWS_CER_FILE', 'DSH_DESKTOP_WINDOWS_SIGNTOOL', 'APPLE_API_KEY', 'APPLE_KEYCHAIN', 'CSC_LINK'] @@ -79,6 +80,7 @@ export function validateDesktopPackageEnvironment(environment, target, options = resolveDesktopAppId(environment) resolveDesktopPolicyEnvironment(environment) if (target.platform === 'darwin') resolveMacOSPackageSettings(environment) + else resolveWindowsPackageSettings(environment) if (options.unsigned) return if (!options.prepareOnly) resolveDesktopAutoUpdateConfig(environment, target.platform, target.arch) if (target.platform === 'win32') { diff --git a/apps/desktop/scripts/package-target.ts b/apps/desktop/scripts/package-target.ts index 651bd64cce..688d3d9671 100644 --- a/apps/desktop/scripts/package-target.ts +++ b/apps/desktop/scripts/package-target.ts @@ -30,6 +30,7 @@ const WINDOWS_SIGNING_ENV_NAMES = [ 'DSH_DESKTOP_WINDOWS_SIGNTOOL', 'DSH_DESKTOP_WINDOWS_TOKEN_PIN', 'DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR', + 'DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY', ] as const const DESKTOP_UPLOAD_CREDENTIAL_ENV_NAMES = new Set([ 'DOWNLOAD_TEST_COS_SECRET_ID', diff --git a/apps/desktop/scripts/sign-primary-runtime.ts b/apps/desktop/scripts/sign-primary-runtime.ts index d48711b4a0..d9a9b36c25 100644 --- a/apps/desktop/scripts/sign-primary-runtime.ts +++ b/apps/desktop/scripts/sign-primary-runtime.ts @@ -5,6 +5,7 @@ import { join, resolve } from 'node:path' import { createWindowsTokenSigner } from './windows-sign.mjs' import { inspectWindowsRuntimeSignature, signWindowsCode, type WindowsCodeSigningOptions } from './windows-runtime-signature.mjs' import { createCachedSigner, signatureCacheIdentity } from './windows-signature-cache.mjs' +import { resolveWindowsPackageSettings } from './windows-package-settings.mjs' import { prepareWindowsSignatureCacheDirectory, resolveWindowsSignatureCacheDirectory } from './windows-signature-cache-directory.mjs' import { withWindowsSigningStage } from './windows-signing-stage.mjs' import { failPackagingRun, recordPackagingEvent } from './packaging-run.mjs' @@ -46,6 +47,7 @@ export async function signWindowsDesktopRuntime(root: string, version: string, async function main(): Promise { if (process.platform !== 'win32' || resolveDesktopBuildTarget() !== 'win-x64') throw new Error('primary runtime signing requires Windows x64') + const { signatureCacheConcurrency } = resolveWindowsPackageSettings(process.env) const runDirectory = process.env.DSH_DESKTOP_PACKAGING_RUN_DIR if (!runDirectory) throw new Error('primary runtime signing requires a supervised packaging run') await readFile(join(runDirectory, 'run.json')) @@ -67,7 +69,8 @@ async function main(): Promise { await prepareWindowsSignatureCacheDirectory(cacheRoot) const cachedSign = createCachedSigner({ root: cacheRoot, identity, thumbprint, sign, inspect: inspectWindowsRuntimeSignature, record }) - const options = { thumbprint, sign: cachedSign, record } + const options = { thumbprint, sign: cachedSign, record, + cache: { restore: cachedSign.restore, concurrency: signatureCacheConcurrency } } record({ type: 'signature-cache-open', root: cachedSign.summary().root, identity }) try { if (process.argv.includes('--dsh')) { diff --git a/apps/desktop/scripts/windows-package-settings.d.mts b/apps/desktop/scripts/windows-package-settings.d.mts new file mode 100644 index 0000000000..a7627e3b7d --- /dev/null +++ b/apps/desktop/scripts/windows-package-settings.d.mts @@ -0,0 +1,6 @@ +/** + * Validate the file-owned Windows cache worker limit before packaging starts. + * @param environment Windows packaging settings loaded from .env.windows. + * @returns Worker limit from one to eight, defaulting to four when omitted; invalid values throw. + */ +export function resolveWindowsPackageSettings(environment: NodeJS.ProcessEnv): { signatureCacheConcurrency: number } diff --git a/apps/desktop/scripts/windows-package-settings.mjs b/apps/desktop/scripts/windows-package-settings.mjs new file mode 100644 index 0000000000..847849af97 --- /dev/null +++ b/apps/desktop/scripts/windows-package-settings.mjs @@ -0,0 +1,14 @@ +/** Resolve file-owned Windows signature-cache concurrency before packaging starts. */ + +/** + * Validate the cache worker limit without accessing signing hardware. + * @param {NodeJS.ProcessEnv} environment File-owned Windows packaging settings. + * @returns {{signatureCacheConcurrency: number}} Worker limit, defaulting to four when omitted. + */ +export function resolveWindowsPackageSettings(environment) { + const value = environment.DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY ?? '4' + if (!/^[1-8]$/u.test(value)) { + throw new Error('desktop package: DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY must be an integer from 1 to 8') + } + return { signatureCacheConcurrency: Number(value) } +} diff --git a/apps/desktop/scripts/windows-runtime-signature.d.mts b/apps/desktop/scripts/windows-runtime-signature.d.mts index 9c6e207a17..f47e306885 100644 --- a/apps/desktop/scripts/windows-runtime-signature.d.mts +++ b/apps/desktop/scripts/windows-runtime-signature.d.mts @@ -31,6 +31,11 @@ export interface WindowsCodeSigningOptions { sign: ReturnType inspect?: typeof inspectWindowsRuntimeSignature record: (event: object) => void + /** Hardware-free restores on distinct targets; concurrency is a resolved positive integer. All restores and their verification drain before serial signing starts. */ + cache?: { + restore: import('./windows-signature-cache.mjs').WindowsCachedSigner['restore'] + concurrency: number + } } /** @@ -44,7 +49,7 @@ export function windowsRuntimeCode(root: string): Promise * Preserve valid signatures and sign unsigned PE files with a supervised signer. * @param root Materialized directory to sign. * @param options Signer, certificate identity and audit sink. - * @returns Resolves after verified sequential signing; rejects without retries. + * @returns Resolves after verified parallel cache restores and sequential signing; stops dispatch and drains active restores on failure, without retries. */ export function signWindowsCode(root: string, options: WindowsCodeSigningOptions): Promise diff --git a/apps/desktop/scripts/windows-runtime-signature.mjs b/apps/desktop/scripts/windows-runtime-signature.mjs index ee70330fd7..8780f182ca 100644 --- a/apps/desktop/scripts/windows-runtime-signature.mjs +++ b/apps/desktop/scripts/windows-runtime-signature.mjs @@ -101,7 +101,7 @@ async function inspectWindowsCode(files, inspect) { * Preserve valid signatures and sign unsigned PE files with a supervised signer. * @param {string} root - Owned final runtime directory. * @param {import('./windows-runtime-signature.mjs').WindowsCodeSigningOptions} options - Supervised signer, certificate identity, audit sink. - * @returns {Promise} Resolves only after sequential signatures and verification; no retries. + * @returns {Promise} Resolves after bounded cache restores and sequential hardware signing, each with verification; failures drain active restores before rejecting. */ export async function signWindowsCode(root, options) { const inspect = options.inspect ?? inspectWindowsRuntimeSignature @@ -116,14 +116,36 @@ export async function signWindowsCode(root, options) { else if (signature.status !== 'Valid') throw new Error(`Windows code: refusing ${signature.status} signature: ${path}`) } options.record({ type: 'windows-code-signing-plan', files: files.length, unsigned: unsigned.length }) - for (const path of unsigned) { - await options.sign({ path, hash: 'sha256', isNest: false }) + async function verifySigned(path) { const signature = await inspect(path) if (signature.status !== 'Valid' || !signature.timestamped || signature.thumbprint?.toUpperCase() !== options.thumbprint.toUpperCase()) { throw new Error(`Windows code: signing verification failed: ${path}`) } options.record({ type: 'windows-code-signature-verified', path, ...signature }) } + const restored = new Set() + if (options.cache) { + let next = 0 + let failure + const worker = async () => { + while (failure === undefined && next < unsigned.length) { + const path = unsigned[next++] + try { + if (await options.cache.restore({ path, hash: 'sha256', isNest: false })) { + await verifySigned(path) + restored.add(path) + } + } catch (error) { failure ??= { error } } + } + } + await Promise.all(Array.from({ length: Math.min(options.cache.concurrency, unsigned.length) }, worker)) + if (failure !== undefined) throw failure.error + } + for (const path of unsigned) { + if (restored.has(path)) continue + await options.sign({ path, hash: 'sha256', isNest: false }) + await verifySigned(path) + } } /** diff --git a/apps/desktop/scripts/windows-signature-cache.d.mts b/apps/desktop/scripts/windows-signature-cache.d.mts index e68abdf345..dcc2301371 100644 --- a/apps/desktop/scripts/windows-signature-cache.d.mts +++ b/apps/desktop/scripts/windows-signature-cache.d.mts @@ -18,7 +18,7 @@ export interface WindowsSignatureCacheOptions { */ export function signatureCacheIdentity(files: readonly string[]): Promise -/** Counters include failed attempts; verification covers both restored and newly signed files, while restore time includes its verification and staging cleanup. */ +/** Counters include failed attempts; times sum per-file work. Verification covers restored and newly signed files; restore time includes its verification and staging cleanup. */ export interface WindowsSignatureCacheSummary { root: string identity: string @@ -34,8 +34,14 @@ export interface WindowsSignatureCacheSummary { signingMs: number } -/** Serial cache callback with a snapshot of this instance's completed and failed operations. */ +/** Serial signing callback with a separate hardware-free restore operation and shared counters. */ export type WindowsCachedSigner = ReturnType & { + /** + * Restore a hit without accessing hardware; distinct targets may run concurrently under the stage lock. + * @param configuration Unsigned target owned exclusively by the caller. + * @returns True after verified replacement, false for an absent entry without counting a signing miss; corrupt entries reject. The caller drains all restores before signing or reporting failure. + */ + restore(configuration: Parameters>[0]): Promise /** @returns Current counters and elapsed times; does not wait for queued operations. */ summary(): WindowsSignatureCacheSummary } @@ -43,7 +49,7 @@ export type WindowsCachedSigner = ReturnType & /** * Reuse complete signed files only after input, integrity, identity and timestamp checks. * @param options Cache policy, supervised signer, public-key verifier and audit sink. - * @returns Serial signer that stops its queue on any failure and never repairs invalid cache entries. + * @returns Serial fail-stop signer with hardware-free restores; invalid cache entries are never repaired. */ export function createCachedSigner(options: WindowsSignatureCacheOptions): WindowsCachedSigner diff --git a/apps/desktop/scripts/windows-signature-cache.mjs b/apps/desktop/scripts/windows-signature-cache.mjs index 1df9cd955f..39f75da374 100644 --- a/apps/desktop/scripts/windows-signature-cache.mjs +++ b/apps/desktop/scripts/windows-signature-cache.mjs @@ -125,7 +125,7 @@ export async function maintainSignatureCache(root, clear = false) { /** * Wrap a supervised signer with immutable entries keyed by unsigned bytes and signing policy. * @param {import('./windows-signature-cache.mjs').WindowsSignatureCacheOptions} options Cache policy and existing supervised operations. - * @returns {import('./windows-signature-cache.mjs').WindowsCachedSigner} Serial, fail-stop callback with counters; cache hits still verify trust and timestamp. + * @returns {import('./windows-signature-cache.mjs').WindowsCachedSigner} Serial, fail-stop signer plus hardware-free restores; cache hits still verify trust and timestamp. */ export function createCachedSigner(options) { let pending = Promise.resolve() @@ -142,7 +142,7 @@ export function createCachedSigner(options) { if (record.inputDigest !== inputDigest || record.identity !== options.identity) throw new Error('signature cache: invalid input record') return record } - async function sign(configuration) { + async function sign(configuration, restoreOnly = false) { if (configuration.hash !== 'sha256' || configuration.isNest) throw new Error('signature cache: only unsigned SHA-256 runtime files are supported') const inputDigest = digest(await regularFile(configuration.path)) const key = digest(JSON.stringify({ version: 1, inputDigest, identity: options.identity })) @@ -173,8 +173,9 @@ export function createCachedSigner(options) { } finally { statistics.restoreMs += performance.now() - start } statistics.hits++ options.record({ type: 'signature-cache-hit', key, path: configuration.path }) - return + return true } + if (restoreOnly) return false statistics.misses++ options.record({ type: 'signature-cache-miss', key, path: configuration.path }) const start = performance.now() @@ -210,9 +211,10 @@ export function createCachedSigner(options) { } finally { await rm(staging, { recursive: true, force: true }) } } const signer = configuration => { - pending = pending.then(() => sign(configuration)) + pending = pending.then(async () => { await sign(configuration) }) return pending } + signer.restore = configuration => sign(configuration, true) signer.summary = () => ({ root: options.root, identity: options.identity, ...statistics, avoidedSigningCalls: statistics.hits }) return signer } diff --git a/apps/desktop/src/locale.ts b/apps/desktop/src/locale.ts index 03e6f29a57..09dedf44e5 100644 --- a/apps/desktop/src/locale.ts +++ b/apps/desktop/src/locale.ts @@ -3,6 +3,10 @@ export const en = { application: 'Application', aboutMenu: 'About DeepSeek Harness', + hideApplication: 'Hide DeepSeek Harness', + hideOtherApplications: 'Hide Others', + showAllApplications: 'Show All', + quitApplication: 'Quit DeepSeek Harness', edit: 'Edit', menuBar: 'Application menu', delete: 'Delete', @@ -94,6 +98,10 @@ export type DesktopMessages = { readonly [Key in keyof typeof en]: string } export const zh = { application: '应用', aboutMenu: '关于 DeepSeek Harness', + hideApplication: '隐藏 DeepSeek Harness', + hideOtherApplications: '隐藏其他', + showAllApplications: '显示全部', + quitApplication: '退出 DeepSeek Harness', edit: '编辑', menuBar: '应用菜单', delete: '删除', diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index e5aa563c6e..63680a5c0b 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -608,12 +608,15 @@ async function main(): Promise { }) // A custom application menu replaces Electron's default menu, so macOS needs // its standard menus and application hide commands declared explicitly. + // Keep app.name stable: Electron derives its default userData directory from it. const darwin = process.platform === 'darwin' const platformMenus: MenuItemConstructorOptions[] = darwin ? [{ role: 'fileMenu' }, { role: 'editMenu' }, { role: 'windowMenu' }] : [{ role: 'editMenu' }] const hideCommands: MenuItemConstructorOptions[] = darwin - ? [{ role: 'hide' }, { role: 'hideOthers' }, { role: 'unhide' }, { type: 'separator' }] + ? [{ role: 'hide', label: currentDesktopLocale().messages.hideApplication }, + { role: 'hideOthers', label: currentDesktopLocale().messages.hideOtherApplications }, + { role: 'unhide', label: currentDesktopLocale().messages.showAllApplications }, { type: 'separator' }] : [] const applicationItems = (): MenuItemConstructorOptions[] => [ { label: currentDesktopLocale().messages.aboutMenu, role: 'about' }, @@ -621,7 +624,8 @@ async function main(): Promise { { label: currentDesktopLocale().messages.checkUpdatesMenu, click: () => { void openUpdatePrompt(true) } }, { type: 'separator' }, ...hideCommands, - { role: 'quit', ...(process.platform === 'win32' ? { label: currentDesktopLocale().messages.exitApplication } : {}) }, + { role: 'quit', ...(darwin ? { label: currentDesktopLocale().messages.quitApplication } + : process.platform === 'win32' ? { label: currentDesktopLocale().messages.exitApplication } : {}) }, ] Menu.setApplicationMenu(process.platform === 'win32' ? null : Menu.buildFromTemplate([{ label: darwin ? app.name : currentDesktopLocale().messages.application, diff --git a/apps/desktop/tests/desktop-package-environment.spec.ts b/apps/desktop/tests/desktop-package-environment.spec.ts index 7153f135a3..6335fe8eaa 100644 --- a/apps/desktop/tests/desktop-package-environment.spec.ts +++ b/apps/desktop/tests/desktop-package-environment.spec.ts @@ -3,6 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it } from 'vitest' import { loadDesktopPackageEnvironment, validateDesktopPackageEnvironment } from '../scripts/desktop-package-environment.mjs' +import { resolveWindowsPackageSettings } from '../scripts/windows-package-settings.mjs' const WINDOWS = { platform: 'win32', arch: 'x64' } as const const MACOS = { platform: 'darwin', arch: 'arm64' } as const @@ -21,6 +22,30 @@ async function withDirectory(action: (directory: string) => Promise): Prom } describe('Desktop local packaging configuration', () => { + it('takes cache concurrency from the Windows file and defaults to four without ambient overrides', async () => { + await withDirectory(async (directory) => { + const parent = { DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY: '8' } + await writeFile(join(directory, '.env.windows'), '') + expect(resolveWindowsPackageSettings(loadDesktopPackageEnvironment('win32', parent, directory)).signatureCacheConcurrency).toBe(4) + await writeFile(join(directory, '.env.windows'), 'DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY=2\n') + expect(resolveWindowsPackageSettings(loadDesktopPackageEnvironment('win32', parent, directory)).signatureCacheConcurrency).toBe(2) + }) + }) + + it.each(['1', '2', '4', '8'])('accepts cache concurrency %s', (value) => { + const settings = resolveWindowsPackageSettings({ DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY: value }) + expect(settings.signatureCacheConcurrency).toBe(Number(value)) + }) + + it.each(['', '0', '-1', '9', '1.5', 'Infinity', '01'])('rejects invalid cache concurrency %s', (value) => { + expect(() => resolveWindowsPackageSettings({ DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY: value })).toThrow('integer from 1 to 8') + for (const options of [{ unsigned: true }, { prepareOnly: true }]) { + expect(() => { + validateDesktopPackageEnvironment({ ...RELEASE, DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY: value }, WINDOWS, options) + }).toThrow('integer from 1 to 8') + } + }) + it('selects the platform file, preserves literal secrets, and excludes stale ambient release settings', async () => { await withDirectory(async (directory) => { await writeFile(join(directory, '.env.windows'), '\uFEFFDSH_DESKTOP_APP_ID=com.example.windows\r\nDSH_DESKTOP_WINDOWS_TOKEN_PIN=" #!$%&literal "\r\nDSH_DESKTOP_WINDOWS_CER_FILE="keys/public certificate.cer"\r\n') diff --git a/apps/desktop/tests/expected/application-menu-en-US.json b/apps/desktop/tests/expected/application-menu-en-US.json new file mode 100644 index 0000000000..af72c9b264 --- /dev/null +++ b/apps/desktop/tests/expected/application-menu-en-US.json @@ -0,0 +1,18 @@ +[ + { + "role": "hide", + "label": "Hide DeepSeek Harness" + }, + { + "role": "hideOthers", + "label": "Hide Others" + }, + { + "role": "unhide", + "label": "Show All" + }, + { + "role": "quit", + "label": "Quit DeepSeek Harness" + } +] diff --git a/apps/desktop/tests/expected/application-menu-zh-CN.json b/apps/desktop/tests/expected/application-menu-zh-CN.json new file mode 100644 index 0000000000..991b9d7639 --- /dev/null +++ b/apps/desktop/tests/expected/application-menu-zh-CN.json @@ -0,0 +1,18 @@ +[ + { + "role": "hide", + "label": "隐藏 DeepSeek Harness" + }, + { + "role": "hideOthers", + "label": "隐藏其他" + }, + { + "role": "unhide", + "label": "显示全部" + }, + { + "role": "quit", + "label": "退出 DeepSeek Harness" + } +] diff --git a/apps/desktop/tests/main-startup.spec.ts b/apps/desktop/tests/main-startup.spec.ts index 171e3855f1..3c792be2eb 100644 --- a/apps/desktop/tests/main-startup.spec.ts +++ b/apps/desktop/tests/main-startup.spec.ts @@ -672,6 +672,22 @@ describe('desktop main startup', () => { expect(harness.menu.setApplicationMenu).toHaveBeenCalledOnce() }) + it.each(['en-US', 'zh-CN'])('localizes macOS visibility and quit commands without changing the application name (%s)', async (locale) => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + vi.spyOn(harness.app, 'getLocale').mockReturnValue(locale) + const originalName = harness.app.name + harness.app.name = '@deepseek-ai/dsh-desktop' + try { + await import('../src/main.ts') + await harness.preparing.promise + const commands = applicationMenuItems().filter(item => + item.role === 'hide' || item.role === 'hideOthers' || item.role === 'unhide' || item.role === 'quit') + await expect(JSON.stringify(commands, null, 2) + '\n') + .toMatchFileSnapshot(`./expected/application-menu-${locale}.json`) + expect(harness.app.name).toBe('@deepseek-ai/dsh-desktop') + } finally { harness.app.name = originalName } + }) + it('attaches Host socket credentials only to the owned application origin and window', async () => { await import('../src/main.ts') await harness.preparing.promise diff --git a/apps/desktop/tests/package-target-stages.spec.ts b/apps/desktop/tests/package-target-stages.spec.ts index 5b8880ce6a..410c853fc8 100644 --- a/apps/desktop/tests/package-target-stages.spec.ts +++ b/apps/desktop/tests/package-target-stages.spec.ts @@ -29,7 +29,8 @@ vi.mock('node:fs', async importOriginal => ({ afterEach(() => { vi.unstubAllEnvs(); vi.clearAllMocks() }) const environment = { DSH_DESKTOP_APP_ID: 'com.example.test', DSH_DESKTOP_AUTO_UPDATE_ENV: 'test', - DOWNLOAD_TEST_ORIGIN: 'https://updates.example.com', DSH_DESKTOP_WINDOWS_TOKEN_PIN: 'fixture-pin' } + DOWNLOAD_TEST_ORIGIN: 'https://updates.example.com', DSH_DESKTOP_WINDOWS_TOKEN_PIN: 'fixture-pin', + DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY: '2' } function supervisor(failure?: string) { vi.stubEnv('npm_execpath', 'fixture-pnpm.cjs') @@ -56,6 +57,10 @@ it('requires one signing preflight before building, then records only the comple for (const call of run.run.mock.calls) { if (call[0].startsWith('run prepare:') || call[0].includes('smoke-packaged-runtime')) { expect(call[3].env).not.toHaveProperty('DSH_DESKTOP_WINDOWS_TOKEN_PIN') + expect(call[3].env).not.toHaveProperty('DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY') + } + if (call[0].startsWith('run sign:primary-runtime')) { + expect(call[3].env).toHaveProperty('DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_CONCURRENCY', '2') } } expect(writeFileSync).toHaveBeenCalledOnce() diff --git a/apps/desktop/tests/primary-runtime-signing.spec.ts b/apps/desktop/tests/primary-runtime-signing.spec.ts index 242c93f339..5c7b4aa080 100644 --- a/apps/desktop/tests/primary-runtime-signing.spec.ts +++ b/apps/desktop/tests/primary-runtime-signing.spec.ts @@ -26,6 +26,86 @@ async function fixture(names = ['a.exe', 'b.pyd', 'vendor.dll']): Promise { for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) }) +it('bounds overlapping cache restores and verifies all hits before serial hardware misses', async () => { + const root = await fixture(['a.exe', 'b.exe', 'c.exe', 'd.exe']) + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + const signed = new Set() + const verified = new Set() + let active = 0 + let peak = 0 + let hits = 0 + const sign = vi.fn(async ({ path }: { path: string }) => { + expect(active).toBe(0) + expect(hits).toBe(2) + expect(verified.has(join(root, 'a.exe')) && verified.has(join(root, 'b.exe'))).toBe(true) + signed.add(path) + }) + const operation = signWindowsCode(root, { thumbprint, record: () => {}, sign, + inspect: async (path) => { + if (!signed.has(path)) return unsigned + verified.add(path) + return valid + }, + cache: { concurrency: 2, restore: async ({ path }) => { + active++ + peak = Math.max(peak, active) + if (active === 2) entered.resolve(undefined) + await release.promise + active-- + if (path.endsWith('a.exe') || path.endsWith('b.exe')) { signed.add(path); hits++; return true } + return false + } }, + }) + try { + await Promise.race([entered.promise, operation]) + expect(sign).not.toHaveBeenCalled() + } finally { release.resolve(undefined); await operation } + expect(peak).toBe(2) + expect(sign.mock.calls.map(([request]) => request.path)).toEqual(['c.exe', 'd.exe'].map(name => join(root, name))) +}) + +it.each(['restore', 'verification', 'audit'])('drains active cache restores after %s failure without dispatching more work', async (failure) => { + const root = await fixture(['a.exe', 'b.exe', 'c.exe']) + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + const failed = Promise.withResolvers() + const restored = new Set() + const calls: string[] = [] + const sign = vi.fn(async () => {}) + let settled = false + const operation = signWindowsCode(root, { thumbprint, sign, + record: (event) => { + if (failure === 'audit' && 'type' in event && event.type === 'windows-code-signature-verified') { + failed.resolve(undefined) + throw new Error('audit failed') + } + }, + inspect: async (path) => { + if (!restored.has(path)) return unsigned + if (failure === 'verification' && path.endsWith('a.exe')) { failed.resolve(undefined); throw new Error('verification failed') } + return valid + }, + cache: { concurrency: 2, restore: async ({ path }) => { + calls.push(path) + if (path.endsWith('b.exe')) { entered.resolve(undefined); await release.promise; return false } + await entered.promise + if (failure === 'restore') { failed.resolve(undefined); throw new Error('restore failed') } + restored.add(path) + return true + } }, + }).then(() => { settled = true; return undefined }, (error: unknown) => { settled = true; return error }) + try { + await Promise.race([failed.promise, operation]) + await new Promise(resolve => setImmediate(resolve)) + expect(settled).toBe(false) + expect(sign).not.toHaveBeenCalled() + } finally { release.resolve(undefined); await operation } + expect(await operation).toEqual(new Error(`${failure} failed`)) + expect(calls).toEqual(['a.exe', 'b.exe'].map(name => join(root, name))) + expect(sign).not.toHaveBeenCalled() +}) + it('selects real PE code, including .node, without signing foreign native modules or data', async () => { const root = await fixture(['runtime.node', 'python.exe', 'extensionless', 'custom.binary']) await mkdir(join(root, 'nested')) diff --git a/apps/desktop/tests/windows-signature-cache.spec.ts b/apps/desktop/tests/windows-signature-cache.spec.ts index 975feb6fbe..9514b102e5 100644 --- a/apps/desktop/tests/windows-signature-cache.spec.ts +++ b/apps/desktop/tests/windows-signature-cache.spec.ts @@ -23,6 +23,22 @@ async function fixture(t: TestContext) { return { root, path, cache, state, options, request } } +test('restore misses leave inputs and hardware untouched, then share a cached payload across targets', async (t) => { + const f = await fixture(t) + const signer = createCachedSigner(f.options) + assert.equal(await signer.restore(f.request), false) + assert.equal(f.state.hardwareCalls, 0) + assert.equal(signer.summary().misses, 0) + assert.equal(await readFile(f.path, 'utf8'), 'original') + await signer(f.request) + const targets = [join(f.root, 'first.node'), join(f.root, 'second.node')] + await Promise.all(targets.map(path => writeFile(path, 'original'))) + assert.deepEqual(await Promise.all(targets.map(path => signer.restore({ ...f.request, path }))), [true, true]) + assert.deepEqual(await Promise.all(targets.map(path => readFile(path, 'utf8'))), ['signed:original', 'signed:original']) + assert.equal(f.state.hardwareCalls, 1) + assert.equal(signer.summary().hits, 2) +}) + test('unchanged bytes restored in another file avoid another hardware call', async (t) => { const f = await fixture(t) await createCachedSigner(f.options)(f.request) @@ -71,7 +87,7 @@ test('different content or signing policy misses the cache', async (t) => { assert.equal(f.state.hardwareCalls, 3) }) -for (const corrupt of ['payload', 'record.json']) test(`corrupt ${corrupt} stops without signing or replacing the input`, async (t) => { +for (const restoreOnly of [false, true]) for (const corrupt of ['payload', 'record.json']) test(`corrupt ${corrupt} stops without signing or replacing the input (restore-only: ${restoreOnly})`, async (t) => { const f = await fixture(t) await createCachedSigner(f.options)(f.request) await writeFile(f.path, 'original') @@ -79,7 +95,7 @@ for (const corrupt of ['payload', 'record.json']) test(`corrupt ${corrupt} stops assert(entry) await writeFile(join(f.cache, entry, corrupt), 'corrupt') const signer = createCachedSigner(f.options) - await assert.rejects(signer(f.request)) + await assert.rejects(restoreOnly ? signer.restore(f.request) : signer(f.request)) assert.equal(signer.summary().validationFailures, 1) assert.equal(signer.summary().avoidedSigningCalls, 0) assert.equal(f.state.hardwareCalls, 1) diff --git a/apps/web/tests/assembled-remote.ts b/apps/web/tests/assembled-remote.ts index 72a3db40db..5d9b7f8a14 100644 --- a/apps/web/tests/assembled-remote.ts +++ b/apps/web/tests/assembled-remote.ts @@ -99,7 +99,7 @@ interface CapturedFixture { } export interface AssembledRemoteOptions { - /** Supply an enabled Host preference for diagnostic View scenarios. */ + /** Override the schema-resolved Host preference for developer-tool scenarios. */ readonly developerTools?: boolean /** Return the fixture's image-dimension admission error from Session prompt. */ readonly rejectPrompt?: boolean @@ -142,16 +142,14 @@ export function createAssembledRemote(options: AssembledRemoteOptions = {}): Ass const mock = RemoteMock.create().load(remoteDefaultResponses) mock.load({ unary: { - 'settings/describe': options.developerTools === true - ? ok({ - ...fixture.settingsDescribe.value, - namespaces: [...fixture.settingsDescribe.value.namespaces, { - ns: 'ui-developer-tools', - schema: { type: 'object', dict: { enabled: { type: 'boolean' } } }, - value: { enabled: true }, applies: 'live', secrets: [], revision: 0, - }], - }) - : structuredClone(fixture.settingsDescribe), + 'settings/describe': ok({ + ...fixture.settingsDescribe.value, + namespaces: [...fixture.settingsDescribe.value.namespaces, { + ns: 'ui-developer-tools', + schema: { type: 'object', dict: { enabled: { type: 'boolean' } } }, + value: { enabled: options.developerTools ?? true }, applies: 'live', secrets: [], revision: 0, + }], + }), 'credentials/describe': structuredClone(fixture.credentialsDescribe), 'session/modelCatalog': structuredClone(fixture.modelCatalog), 'agentPresets/list': structuredClone(fixture.agentPresets), diff --git a/apps/web/tests/clickable-links-gallery.e2e.ts b/apps/web/tests/clickable-links-gallery.e2e.ts index 21e4f78bae..2cf02b02eb 100644 --- a/apps/web/tests/clickable-links-gallery.e2e.ts +++ b/apps/web/tests/clickable-links-gallery.e2e.ts @@ -318,7 +318,7 @@ describe('web e2e: clickable links gallery', () => { await seedSession(scaffold, galleryFixture(imageUrl), SEED_ID, undefined, { createdAt: GALLERY_TIME }) browser = await chromium.launch() page = await newEnglishPage(browser) - await page.route(/https?:\/\/docs\.example\.test\/.*/u, async route => route.fulfill({ + await page.context().route(/https?:\/\/docs\.example\.test\/.*/u, async route => route.fulfill({ contentType: 'text/html', body: `

${new URL(route.request().url()).pathname}

`, })) @@ -454,5 +454,31 @@ describe('web e2e: clickable links gallery', () => { await expect.poll(() => browserAddress.inputValue()).toBe(GUIDE_URL) await markdown.locator(`a[href="${HTTP_URL}"]`).click() await expect.poll(() => browserAddress.inputValue()).toBe(HTTP_URL) + + await page.getByRole('button', { name: 'Settings', exact: true }).click() + await page.getByRole('button', { name: 'Built-in browser', exact: true }).click() + await page.getByRole('menuitem', { name: 'New browser tab', exact: true }).click() + await expect.poll(() => scaffold.ctx.settings.get('ui-chat')).toMatchObject({ linkOpening: 'new-tab' }) + await page.keyboard.press('Escape') + const popupPromise = page.waitForEvent('popup') + await guideLink.click() + const popup = await popupPromise + try { + await popup.waitForURL(GUIDE_URL) + expect(await popup.evaluate(() => window.opener === null)).toBe(true) + expect(await browserAddress.inputValue()).toBe(HTTP_URL) + } finally { + await popup.close() + } + + await page.reload() + await page.getByRole('button', { name: 'Settings', exact: true }).click() + await page.getByRole('button', { name: 'New browser tab', exact: true }).click() + await page.getByRole('menuitem', { name: 'Built-in browser', exact: true }).click() + await expect.poll(() => scaffold.ctx.settings.get('ui-chat')).toMatchObject({ linkOpening: 'sidebar' }) + await page.keyboard.press('Escape') + await guideLink.click() + await expect.poll(() => browserAddress.inputValue()).toBe(GUIDE_URL) + expect(tripwire.pageErrors).toEqual([]) }, 90_000) }) diff --git a/apps/web/tests/clickable-links-gallery.overlay.yml b/apps/web/tests/clickable-links-gallery.overlay.yml index d8adc80e3b..ad0114d151 100644 --- a/apps/web/tests/clickable-links-gallery.overlay.yml +++ b/apps/web/tests/clickable-links-gallery.overlay.yml @@ -7,3 +7,5 @@ - id: settings-controller config: nativeOpen: true +- id: ui-sidebar-browser + disabled: false diff --git a/apps/web/tests/developer-tools-settings.e2e.ts b/apps/web/tests/developer-tools-settings.e2e.ts index 16c0e6500a..28dddd3c02 100644 --- a/apps/web/tests/developer-tools-settings.e2e.ts +++ b/apps/web/tests/developer-tools-settings.e2e.ts @@ -1,4 +1,4 @@ -/** Shared settings are off on a fresh Host and survive browser reload through the ordinary settings UI. */ +/** Developer tools default on for a fresh Host and survive browser reload through the ordinary settings UI. */ import { readFile } from 'node:fs/promises' import { join } from 'node:path' import { chromium } from 'playwright' @@ -7,7 +7,7 @@ import { launchWebScaffold } from './scaffold.ts' import { newEnglishPage } from './support.ts' it('persists developer tools in the Host settings document and restores the accepted choice', async () => { - const scaffold = await launchWebScaffold({ developerTools: false }) + const scaffold = await launchWebScaffold() onTestFinished(() => scaffold.close()) const browser = await chromium.launch() onTestFinished(() => browser.close()) @@ -15,15 +15,26 @@ it('persists developer tools in the Host settings document and restores the acce await page.goto(scaffold.authenticatedUrl) await page.getByRole('button', { name: 'Settings', exact: true }).click() const toggle = page.getByRole('switch', { name: 'Developer tools' }) - expect(await toggle.getAttribute('aria-checked')).toBe('false') + expect(await toggle.getAttribute('aria-checked')).toBe('true') expect(await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')).not.toContain('ui-developer-tools:') await toggle.click() - await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('true') - expect(await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')).toContain('ui-developer-tools:\n enabled: true') - await page.reload() - await page.getByRole('button', { name: 'Settings', exact: true }).click() - await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('true') - await toggle.click() await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('false') expect(await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')).toContain('ui-developer-tools:\n enabled: false') + await page.reload() + await page.getByRole('button', { name: 'Settings', exact: true }).click() + await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('false') + + await page.getByRole('button', { name: 'Agent presets', exact: true }).click() + const pickerToggle = page.getByRole('switch', { name: 'Allow switching Agent modes' }) + await expect.poll(() => page.getByRole('heading', { name: 'Agent presets', exact: true }).count()).toBe(1) + expect(await pickerToggle.count()).toBe(0) + await scaffold.ctx.settings.update('ui-developer-tools', { enabled: true }) + await expect.poll(() => pickerToggle.count()).toBe(1) + await expect.poll(() => pickerToggle.isEnabled()).toBe(true) + const pickerEnabled = await pickerToggle.getAttribute('aria-checked') + await scaffold.ctx.settings.update('ui-developer-tools', { enabled: false }) + await expect.poll(() => pickerToggle.count()).toBe(0) + await scaffold.ctx.settings.update('ui-developer-tools', { enabled: true }) + await expect.poll(() => pickerToggle.isEnabled()).toBe(true) + await expect.poll(() => pickerToggle.getAttribute('aria-checked')).toBe(pickerEnabled) }) diff --git a/apps/web/tests/document-preview.e2e.ts b/apps/web/tests/document-preview.e2e.ts index 31b2fd06b6..8c41184009 100644 --- a/apps/web/tests/document-preview.e2e.ts +++ b/apps/web/tests/document-preview.e2e.ts @@ -365,7 +365,7 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action }) + await successShot(page, 'html-basic') sections.push([ '## Basic HTML', '', - '- Developer tools: off by default on Web and desktop', + '- Developer tools: disabled for this scenario', '- Sandbox: no permissions', `- Inline script: ${await basicHtml.locator('#result').innerText()}`, `- Local script: ${await basicHtml.locator('#local-result').innerText()}`, diff --git a/apps/web/tests/expected/plugin-manager/icons.expected.md b/apps/web/tests/expected/plugin-manager/icons.expected.md new file mode 100644 index 0000000000..fb9df5edb1 --- /dev/null +++ b/apps/web/tests/expected/plugin-manager/icons.expected.md @@ -0,0 +1,5 @@ +Third-party bundle card: image, 36×36, decoded +Disabled Agent Teams card: image, 36×36, decoded +Agent Teams detail: image, 36×36, decoded +Third-party bundle detail: image, 36×36, decoded +Independent search row: image, 30×30, decoded diff --git a/apps/web/tests/expected/settings-chrome/dialog-no-browser.expected.md b/apps/web/tests/expected/settings-chrome/dialog-no-browser.expected.md new file mode 100644 index 0000000000..41d6462e11 --- /dev/null +++ b/apps/web/tests/expected/settings-chrome/dialog-no-browser.expected.md @@ -0,0 +1,28 @@ +- dialog "Settings": + - navigation: + - text: Settings + - button "General" + - button "Models" + - button "Built-in plugins" + - button "Agent presets" + - button "Open configuration file" + - button "Close" + - text: Permission Choose the default permission mode for new sessions + - button "Workspace Write" + - text: Language + - button "English" + - text: Appearance + - button "Light" + - button "Dark" + - button "System" [pressed] + - text: Font size Only affects conversation content 14 + - button "Increase font size" + - button "Decrease font size" + - text: px Conversation display Controls process content in completed turns + - button "Compact" + - text: Performance & usage Choose how much performance and usage information to show + - button "Detailed" + - text: Developer tools Show tools and information for debugging and troubleshooting + - switch "Developer tools" + - text: Send behavior while busy What Enter and the Send button do while the agent is running; Cmd/Ctrl+Enter uses the other behavior + - button "Queue" diff --git a/apps/web/tests/fixtures/plugins/fixture-bundle/icon.svg b/apps/web/tests/fixtures/plugins/fixture-bundle/icon.svg new file mode 100644 index 0000000000..ad85688706 --- /dev/null +++ b/apps/web/tests/fixtures/plugins/fixture-bundle/icon.svg @@ -0,0 +1 @@ + diff --git a/apps/web/tests/fixtures/plugins/fixture-bundle/package.json b/apps/web/tests/fixtures/plugins/fixture-bundle/package.json index 3224dfa296..71aa6bbd5f 100644 --- a/apps/web/tests/fixtures/plugins/fixture-bundle/package.json +++ b/apps/web/tests/fixtures/plugins/fixture-bundle/package.json @@ -1,6 +1,7 @@ { "name": "@fixture/bundle", "version": "0.0.1", + "icon": "./icon.svg", "private": true, "description": "Registry description for the fixture bundle.", "type": "module", diff --git a/apps/web/tests/fixtures/plugins/fixture-bundle/search.package.json b/apps/web/tests/fixtures/plugins/fixture-bundle/search.package.json index 52a0c0f420..79083aafff 100644 --- a/apps/web/tests/fixtures/plugins/fixture-bundle/search.package.json +++ b/apps/web/tests/fixtures/plugins/fixture-bundle/search.package.json @@ -1,4 +1,5 @@ { "name": "Search plugin", + "icon": "./icon.svg", "description": "Search package introduction." } diff --git a/apps/web/tests/hmr-live.e2e.ts b/apps/web/tests/hmr-live.e2e.ts index 716176a176..87adea12cc 100644 --- a/apps/web/tests/hmr-live.e2e.ts +++ b/apps/web/tests/hmr-live.e2e.ts @@ -1,4 +1,4 @@ -/** Published dsh web + pnpm dev:web → browser HMR, with no page reload. */ +/** Built dsh web + the `pnpm run dev:web --no-serve` watchers → browser HMR, with no page reload. */ import { existsSync, globSync, statSync } from 'node:fs' import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' @@ -108,12 +108,14 @@ it('hot-reloads a real client-plugin source edit without refreshing the page', a const failures: unknown[] = [] try { subprocessFiber = await subprocessCtx.plugin(LocalSubprocessRuntime) + // Watchers only: the built `dsh web` below is the server under test, and the + // built tree is this lane's precondition rather than something to rebuild. watcher = subprocessCtx.subprocess.spawn(spawnSpec( - ['pnpm', 'run', 'dev:web'], + ['pnpm', 'run', 'dev:web', '--skip-build', '--no-serve'], REPO_ROOT, { ...clientBuildEnvironment }, )) - await waitForOutput(watcher, /dev-web: watching/, 'pnpm run dev:web') + await waitForOutput(watcher, /dev-web: watching/, 'pnpm run dev:web --skip-build --no-serve') host = subprocessCtx.subprocess.spawn(spawnSpec( [process.execPath, binPath, 'web', '--no-open', '--port', '0'], world, diff --git a/apps/web/tests/markdown-inline-code-links.e2e.ts b/apps/web/tests/markdown-inline-code-links.e2e.ts index 8fb8b5942c..fc7392c433 100644 --- a/apps/web/tests/markdown-inline-code-links.e2e.ts +++ b/apps/web/tests/markdown-inline-code-links.e2e.ts @@ -92,7 +92,9 @@ describe('web e2e: Markdown inline-code links', () => { let tripwire: ReturnType beforeAll(async () => { - scaffold = await launchWebScaffold({}) + scaffold = await launchWebScaffold({ + extraOverlayPath: fileURLToPath(new URL('./sidebar-browser.overlay.yml', import.meta.url)), + }) await seedSession(scaffold, markdownFixture(LINK_URL), SEED_ID) browser = await chromium.launch() page = await newEnglishPage(browser) diff --git a/apps/web/tests/no-sidebar-browser.overlay.yml b/apps/web/tests/no-sidebar-browser.overlay.yml new file mode 100644 index 0000000000..2af93975a4 --- /dev/null +++ b/apps/web/tests/no-sidebar-browser.overlay.yml @@ -0,0 +1,3 @@ +# Exercise the settings surface without a built-in browser provider. +- id: ui-sidebar-browser + disabled: true diff --git a/apps/web/tests/plugin-manager.e2e.ts b/apps/web/tests/plugin-manager.e2e.ts index 373b0ba459..8ce5950d4c 100644 --- a/apps/web/tests/plugin-manager.e2e.ts +++ b/apps/web/tests/plugin-manager.e2e.ts @@ -140,6 +140,50 @@ describe('web e2e: plugin manager', () => { expect(tripwire.pageErrors).toEqual([]) }, 60_000) + it('decodes manifest icons for disabled bundles and independent plugin rows', async () => { + const panel = await openPluginsPanel() + const fixtureIcon = `data:image/svg+xml;base64,${(await readFile(join(FIXTURE_PLUGINS, 'fixture-bundle/icon.svg'))).toString('base64')}` + const teamIcon = `data:image/svg+xml;base64,${(await readFile(fileURLToPath(new URL('../../../packages/experimental/agent-team-profile/icon.svg', import.meta.url)))).toString('base64')}` + const images: string[] = [] + const checkImage = async (selector: string, source: string, label: string) => { + const image = panel.locator(`${selector} img`).first() + await image.waitFor() + expect(await image.getAttribute('src')).toBe(source) + await image.evaluate(async (node: HTMLImageElement) => { await node.decode() }) + const size = await image.evaluate((node: HTMLImageElement) => ({ + width: node.width, height: node.height, naturalWidth: node.naturalWidth, + })) + expect(size.naturalWidth).toBeGreaterThan(0) + images.push(`${label}: image, ${size.width}×${size.height}, decoded`) + } + await checkImage('[data-plugin-package="@fixture/bundle"]', fixtureIcon, 'Third-party bundle card') + const team = panel.locator('[data-plugin-package="@deepseek-ai/dsh-experimental-agent-team-profile"]') + expect(await team.getByRole('switch').getAttribute('aria-checked')).toBe('false') + await checkImage('[data-plugin-package="@deepseek-ai/dsh-experimental-agent-team-profile"]', teamIcon, 'Disabled Agent Teams card') + try { + for (const colorScheme of ['light', 'dark'] as const) { + await page.emulateMedia({ colorScheme }) + expect(await team.locator('img').evaluate((node: HTMLImageElement) => node.naturalWidth)).toBe(36) + if (MODE === 'refresh') { + const path = fileURLToPath(new URL(`../../../.artifacts/plugin-icons-${process.pid}-${colorScheme}.png`, import.meta.url)) + await page.screenshot({ path }) + console.log(`Plugin icon screenshot: ${path}`) + } + } + } finally { + await page.emulateMedia({ colorScheme: null }) + } + await panel.getByRole('button', { name: '查看 智能体团队', exact: true }).click() + await checkImage('[data-plugin-detail]', teamIcon, 'Agent Teams detail') + await panel.getByRole('button', { name: '返回插件列表' }).click() + await panel.getByRole('button', { name: '查看 @fixture/bundle', exact: true }).click() + await checkImage('[data-plugin-detail]', fixtureIcon, 'Third-party bundle detail') + await checkImage('[data-plugin-row="fixture-search"]', fixtureIcon, 'Independent search row') + await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'icons.expected.md'), images.join('\n'), MODE) + await panel.getByRole('button', { name: '返回插件列表' }).click() + expect(tripwire.pageErrors).toEqual([]) + }) + it('localizes independent exports and falls back per field without activating the plugins', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-exports')) const panel = await openPluginsPanel() @@ -331,7 +375,7 @@ describe('web e2e: plugin manager', () => { it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { expect(tripwire.warnings).toEqual([]) await assertFixtureInventory(SNAPSHOT_DIR, [ - 'manager.expected.md', 'live-enabled.expected.md', 'missing-bundle.expected.md', 'exports.expected.md', 'exports-en.expected.md', + 'manager.expected.md', 'live-enabled.expected.md', 'missing-bundle.expected.md', 'exports.expected.md', 'exports-en.expected.md', 'icons.expected.md', ]) }) }) diff --git a/apps/web/tests/preset-migration.snapshot.ts b/apps/web/tests/preset-migration.snapshot.ts index d8fccb3271..37fdd06073 100644 --- a/apps/web/tests/preset-migration.snapshot.ts +++ b/apps/web/tests/preset-migration.snapshot.ts @@ -13,6 +13,7 @@ import type {} from '@deepseek-ai/dsh-api-session-controller' import { normalizeSessionSnapshots } from '@deepseek-ai/dsh-session-snapshot' import { launchWebScaffold, webSnapshotMode } from './scaffold.ts' +const childFixturePath = fileURLToPath(new URL('../../../snapshots/web/preset-migration/session.1.v2.jsonl', import.meta.url)) const fixturePath = fileURLToPath(new URL('../../../snapshots/web/preset-migration/session.v2.jsonl', import.meta.url)) describe.skipIf(webSnapshotMode() === 'record')('historical preset restoration through the Web Host', () => { @@ -32,6 +33,33 @@ describe.skipIf(webSnapshotMode() === 'record')('historical preset restoration t await mkdir(dirname(predecessor), { recursive: true }) await writeFile(predecessor, source) + const childId = SessionId('preset-migration-child') + const brokenId = SessionId('preset-migration-broken-child') + const childFixture = (await readFile(childFixturePath, 'utf8')).trimEnd().split('\n') + .map((line): Record => JSON.parse(line) as Record) + const childRows: Record[] = childFixture.slice(1).map((row, seq) => ({ ...row, seq, time: seq + 3 })) + const childHeader = { ...childFixture[0], id: childId, parentSession: id, cwd: scaffold.workspaceCwd } + const childSource = Buffer.concat([childHeader, ...childRows].map(row => zstdCompressSync(Buffer.from(JSON.stringify(row) + '\n')))) + const childPath = generationLogPath(scaffold.persistenceRoot, scaffold.workspaceCwd, childId, 2, 'zstd') + const brokenPath = generationLogPath(scaffold.persistenceRoot, scaffold.workspaceCwd, brokenId, 2, 'zstd') + await mkdir(dirname(childPath), { recursive: true }) + await mkdir(dirname(brokenPath), { recursive: true }) + await writeFile(childPath, childSource) + await writeFile(brokenPath, Buffer.concat([ + zstdCompressSync(Buffer.from(JSON.stringify({ ...childHeader, id: brokenId }) + '\n')), + zstdCompressSync(Buffer.from('{broken\n' + JSON.stringify({ type: 'turn/end', seq: 1, time: 2, + data: { turn: 1, reason: { kind: 'completed' } } }) + '\n')), + ])) + const brokenSource = await readFile(brokenPath) + const corruptHeaderId = SessionId('preset-migration-corrupt-header') + const corruptHeaderPath = generationLogPath(scaffold.persistenceRoot, scaffold.workspaceCwd, corruptHeaderId, 2, 'zstd') + const corruptHeaderSource = Buffer.from('invalid Zstandard header frame') + await mkdir(dirname(corruptHeaderPath), { recursive: true }) + await writeFile(corruptHeaderPath, corruptHeaderSource) + const listed = await scaffold.ctx.sessionController.list({}, new AbortController().signal) + expect(listed.items.map(item => item.sessionId)).toEqual(expect.arrayContaining([id, childId, brokenId])) + expect(listed.items.map(item => item.sessionId)).not.toContain(corruptHeaderId) + const reader = await scaffold.ctx.sessionPersistence.open(id, 'read') try { expect(reader.header.agentPreset).toBe('ptc') @@ -48,7 +76,35 @@ describe.skipIf(webSnapshotMode() === 'record')('historical preset restoration t expect(resolved.agent.session.snapshotEvents() .filter(event => event.type === 'agent-preset/selected') .map(event => event.data.agentPreset)).toEqual(withSelections ? ['ptc', 'standard', 'ptc'] : []) + expect(await scaffold.ctx.subagents.listChildren(id)).toEqual([ + { id: brokenId, createdAt: childFixture[0]?.['createdAt'], mode: 'unknown' }, + { id: childId, createdAt: childFixture[0]?.['createdAt'], mode: 'one-shot', label: 'historical child' }, + ]) + const childSuccessor = generationLogPath(scaffold.persistenceRoot, scaffold.workspaceCwd, childId, SESSION_FORMAT_VERSION, 'zstd') + await expect(readFile(childSuccessor)).rejects.toMatchObject({ code: 'ENOENT' }) + const signal = new AbortController().signal + const address = { kind: 'subagent', parentSessionId: id, childSessionId: childId, mode: 'one-shot' } as const + const childPage = await scaffold.ctx.sessionController.page({ address, throughSeq: childRows.length - 1 }, signal) + expect(childPage.records.map(record => record.event.type)).toEqual(childRows.map(row => row['type'])) + await expect(scaffold.ctx.sessionController.page({ address: { ...address, childSessionId: brokenId, mode: 'unknown' }, throughSeq: 1 }, signal)) + .rejects.toThrow() + await expect(scaffold.ctx.sessionController.page({ address: { kind: 'session', sessionId: id }, throughSeq: rows.length - 1 }, signal)) + .resolves.toMatchObject({ hasMore: false }) + const childWriter = await scaffold.ctx.sessionPersistence.open(childId, 'write') + await childWriter.close() + const childPublished = await readFile(childSuccessor) + const childText = Buffer.concat(scanZstdFrames(childPublished).frames + .map(({ start, end }) => zstdDecompressSync(childPublished.subarray(start, end)))).toString('utf8') + expect(childText.trimEnd().split('\n').map((line): unknown => JSON.parse(line))).toEqual([ + { ...childHeader, version: SESSION_FORMAT_VERSION, agentPreset: 'ptc' }, ...childRows, + ]) + expect(await readFile(childPath)).toEqual(childSource) + expect(await readFile(brokenPath)).toEqual(brokenSource) + await expect(readFile(generationLogPath(scaffold.persistenceRoot, scaffold.workspaceCwd, brokenId, SESSION_FORMAT_VERSION, 'zstd'))) + .rejects.toMatchObject({ code: 'ENOENT' }) + + await scaffold.ctx.sessions.flush(resolved.agent.session) const publishedBytes = await readFile(successor) const published = Buffer.concat(scanZstdFrames(publishedBytes).frames .map(({ start, end }) => zstdDecompressSync(publishedBytes.subarray(start, end)))).toString('utf8') @@ -58,12 +114,23 @@ describe.skipIf(webSnapshotMode() === 'record')('historical preset restoration t && (row['data'] as { agentPreset: string }).agentPreset === 'code' ? { ...row, data: { agentPreset: 'ptc' } } : row), + { type: 'subagent/catalog', seq: rows.length, time: rows.at(-1)?.['time'], data: { + version: 1, childId: brokenId, childCreatedAt: childFixture[0]?.['createdAt'], mode: 'unknown', + } }, + { type: 'subagent/catalog', seq: rows.length + 1, time: rows.at(-1)?.['time'], data: { + version: 0, childId, childCreatedAt: childFixture[0]?.['createdAt'], mode: 'one-shot', label: 'historical child', + } }, // Agent activation closes its restored prefix with a fresh seed marker. - { type: 'session/end-seed', seq: rows.length, time: 0, data: {} }, + { type: 'session/end-seed', seq: rows.length + 2, time: 0, data: {} }, + { type: 'permission/preset', seq: rows.length + 3, time: 0, data: { preset: 'workspace-write' } }, + { type: 'sandbox/mode', seq: rows.length + 4, time: 0, data: { mode: 'workspace-write' } }, + { type: 'approval/policy', seq: rows.length + 5, time: 0, data: { policy: 'ask' } }, ].map(row => JSON.stringify(row)).join('\n') + '\n' const context = { sessionIds: [id], cwd: scaffold.workspaceCwd } expect(normalizeSessionSnapshots([published], context)).toEqual(normalizeSessionSnapshots([expected], context)) expect(await readFile(predecessor)).toEqual(source) + await expect(scaffold.ctx.sessionPersistence.open(corruptHeaderId, 'read')).rejects.toThrow(/invalid frame magic/) + expect(await readFile(corruptHeaderPath)).toEqual(corruptHeaderSource) expect((await readdir(dirname(predecessor))).filter(name => name.endsWith('.jsonl.zstd')).sort()) .toEqual(['session.v2.jsonl.zstd', `session.v${SESSION_FORMAT_VERSION}.jsonl.zstd`]) } finally { diff --git a/apps/web/tests/replay-round-trip.e2e.ts b/apps/web/tests/replay-round-trip.e2e.ts index 8bddaee2d7..48b505f398 100644 --- a/apps/web/tests/replay-round-trip.e2e.ts +++ b/apps/web/tests/replay-round-trip.e2e.ts @@ -4,20 +4,26 @@ // or the live adapter (record). Drive steps run in every mode and wait only // on generic completion (whenTurnSettled — never model-content selectors, so // record cannot hang on a live model answering differently); assertion steps -// run in replay/refresh only. Settled states only — streaming fidelity is -// asserted from the durable embedded Assistant stream, not transient DOM. +// run in replay/refresh only. Streaming fidelity is asserted from the durable +// embedded Assistant stream, not transient DOM. // Record: DSH_SNAPSHOT=record writes session.v3.jsonl, then a keyless // DSH_SNAPSHOT=refresh regenerates ui.expected.md. +// Suite setup (beforeAll, not per step): one fixed page clock +// (page.clock.setFixedTime) and one routed Remote mux socket +// (page.routeWebSocket) serve every step below, so the reconnect checkpoints +// rely on both without re-pinning or re-routing them. import { readFile } from 'node:fs/promises' import { join } from 'node:path' import { fileURLToPath } from 'node:url' -import type { Browser, Page } from 'playwright' +import type { Browser, Page, Route, WebSocketRoute } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import { ToolCallId, expandAssistantStream } from '@deepseek-ai/dsh-llm' import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session' +import type { SessionListValue } from '@deepseek-ai/dsh-api-session-controller/types' +import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' import { - assertFixtureInventory, captureExpandedTurnProcessAria, captureStableAria, + acknowledgeReloadConnectionLoss, assertFixtureInventory, captureExpandedTurnProcessAria, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' @@ -29,6 +35,7 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/fresh-round-t const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip/session.v3.jsonl', import.meta.url)) const UI_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip/ui.expected.md', import.meta.url)) const ECHO_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip/submission-echo.expected.md', import.meta.url)) +const ACCEPTED_RECONNECT_EXPECTED = join(SNAPSHOT_DIR, 'accepted-reconnect.expected.md') const BLANK_EXPECTED = join(SNAPSHOT_DIR, 'blank-reload.expected.md') const UI_EXPANDED_EXPECTED = fileURLToPath( new URL('../../../snapshots/web/fresh-round-trip/ui-expanded.expected.md', import.meta.url), @@ -41,6 +48,8 @@ const MODE = webSnapshotMode() // drift apart. const PROMPT = 'Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop.' +type SessionListResponse = { result: RemoteResult } + /** Rendered text of the system prompt surface node, or undefined when the surface carries none. */ function systemPromptText(session: Session): string | undefined { const message = session.deriveMessages().find(candidate => candidate.role === 'system') @@ -53,7 +62,9 @@ describe('web e2e: fresh round trip through the real assembly', () => { let page: Page let tripwire: ReturnType let settledSessionId: SessionId | undefined + let remoteSocket: WebSocketRoute | undefined const sessionEvents: SessionEvent[] = [] + const browserNow = Date.now() beforeAll(async () => { scaffold = await launchWebScaffold({ @@ -63,6 +74,11 @@ describe('web e2e: fresh round trip through the real assembly', () => { scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) }) browser = await chromium.launch() page = await newEnglishPage(browser) + await page.clock.setFixedTime(browserNow) + await page.routeWebSocket('**/api/remote.mux', (route) => { + remoteSocket = route + route.connectToServer() + }) tripwire = watchConsole(page) await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) @@ -94,26 +110,104 @@ describe('web e2e: fresh round trip through the real assembly', () => { } const input = page.locator('[data-composer-input]').first() await input.waitFor({ timeout: 10_000 }) - // Arm the host-side settled barrier BEFORE the send click. - const settled = scaffold.whenTurnSettled() - await input.fill(PROMPT) - const echoSnapshot = await input.evaluate(async (element, prompt) => { - element.dispatchEvent(new KeyboardEvent('keydown', { - key: 'Enter', code: 'Enter', bubbles: true, cancelable: true, - })) - // sendSession registered its paint yield first. This frame observes the - // committed echo while admission remains queued on the following task. - await new Promise((resolve) => { requestAnimationFrame(() => { resolve() }) }) - const echo = document.querySelector('[data-submission-echo]') - return [ - `echo: ${echo?.textContent?.includes(prompt) === true ? prompt : '(missing)'}`, - `composer: ${JSON.stringify(element.textContent ?? '')}`, - `contenteditable: ${element.getAttribute('contenteditable')}`, - ].join('\n') - }, PROMPT) - await compareOrRefreshGolden(ECHO_EXPECTED, echoSnapshot, MODE) - const sessionId = await settled - settledSessionId = sessionId + const sessionId = await page.evaluate(() => { + const selected = JSON.parse(localStorage.getItem('dsh.sessions.current')!) as { sessionId: string } + return selected.sessionId + }) as SessionId + const agent = scaffold.ctx.agents.get(sessionId) + if (agent === undefined) throw new Error('the selected Web Session has no live Agent') + await agent.whenIdle() + const release = Promise.withResolvers() + // Maintenance leaves status idle and accepts inbox input without starting a turn. + const maintenance = agent.runMaintenance(() => release.promise) + let accepted = false + try { + await input.fill(PROMPT) + const admitted = page.waitForResponse('**/api/session/prompt') + const [echoSnapshot, response] = await Promise.all([ + input.evaluate(async (element, prompt) => { + element.dispatchEvent(new KeyboardEvent('keydown', { + key: 'Enter', code: 'Enter', bubbles: true, cancelable: true, + })) + // sendSession registered its paint yield first. This frame observes the + // committed echo while admission remains queued on the following task. + await new Promise((resolve) => { requestAnimationFrame(() => { resolve() }) }) + const echo = document.querySelector('[data-submission-echo]') + return [ + `echo: ${echo?.textContent?.includes(prompt) === true ? prompt : '(missing)'}`, + `composer: ${JSON.stringify(element.textContent ?? '')}`, + `contenteditable: ${element.getAttribute('contenteditable')}`, + ].join('\n') + }, PROMPT), + admitted, + ]) + expect(await response.json()).toMatchObject({ result: { ok: true, value: { accepted: true } } }) + accepted = true + await compareOrRefreshGolden(ECHO_EXPECTED, echoSnapshot, MODE) + const row = page.getByRole('tree', { name: 'Sessions', exact: true }) + .locator('[role="treeitem"][aria-selected="true"]') + await expect.poll(() => row.getAttribute('draggable'), { timeout: 5_000 }).toBe('true') + expect(sessionEvents.some(event => event.type === 'turn/start')).toBe(false) + + const disconnected = remoteSocket + if (disconnected === undefined) throw new Error('the Web Remote socket never connected') + const warningStart = tripwire.warnings.length + // Only this list response carries the 2d timestamp. Its rendered label + // confirms the refresh reached the row, without altering blank/running. + const listUrl = new URL('/api/session/list', scaffold.baseUrl).href + const markListResponse = async (route: Route): Promise => { + const response = await route.fetch() + try { + const body = await response.json() as SessionListResponse + if (body.result.ok) { + body.result = { ...body.result, value: { + ...body.result.value, + items: body.result.value.items.map(item => item.sessionId === sessionId + ? { ...item, updatedAt: browserNow - 2 * 86_400_000 } + : item), + } } + } + await route.fulfill({ response, json: body }) + } finally { + await response.dispose() + } + } + await page.route(listUrl, markListResponse, { times: 1 }) + try { + const refreshing = page.waitForResponse(listUrl) + const [, refreshed] = await Promise.all([disconnected.close(), refreshing]) + // Cross-network reconnect: allow more than expect.poll's default. + await expect.poll(() => remoteSocket !== disconnected, { timeout: 15_000 }).toBe(true) + const baseline = await refreshed.json() as SessionListResponse + expect(baseline.result.ok).toBe(true) + if (!baseline.result.ok) throw new Error('the reconnect list request failed') + const hostSummary = baseline.result.value.items.find(item => item.sessionId === sessionId) + expect(hostSummary).toMatchObject({ blank: true, running: false }) + await row.getByText('2d', { exact: true }).waitFor() + expect(await row.getAttribute('draggable')).toBe('true') + expect(await row.getByText('New Session', { exact: true }).count()).toBe(0) + expect(sessionEvents.some(event => event.type === 'turn/start')).toBe(false) + const checkpoint = [ + `selected sidebar rows: ${await row.count()}`, + `new-session labels: ${await row.getByText('New Session', { exact: true }).count()}`, + `row draggable: ${await row.getAttribute('draggable')}`, + `host history blank: ${hostSummary?.blank}`, + `composer: ${JSON.stringify(await input.textContent())}`, + `contenteditable: ${await input.getAttribute('contenteditable')}`, + ].join('\n') + await compareOrRefreshGolden(ACCEPTED_RECONNECT_EXPECTED, checkpoint, MODE) + acknowledgeReloadConnectionLoss(tripwire, warningStart) + } finally { + await page.unroute(listUrl, markListResponse) + } + } finally { + // No turn can settle before maintenance releases the accepted input. + const settled = accepted ? scaffold.whenTurnSettled() : undefined + release.resolve(undefined) + await maintenance + if (settled !== undefined) settledSessionId = await settled + await agent.whenIdle() + } if (MODE === 'record') { await recordFixture(scaffold, sessionId, FIXTURE) } @@ -237,12 +331,13 @@ describe('web e2e: fresh round trip through the real assembly', () => { await expect.poll(() => think.getAttribute('aria-expanded'), { timeout: 5_000 }).toBe('false') }) - it.skipIf(MODE === 'record')('stayed clean: no pageerrors, no reconnect self-healing, no server errors', async () => { + it.skipIf(MODE === 'record')('stayed clean after the intentional reconnect: no pageerrors or server errors', async () => { expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) await assertFixtureInventory(SNAPSHOT_DIR, [ 'blank-reload.expected.md', 'session.v3.jsonl', + 'accepted-reconnect.expected.md', 'submission-echo.expected.md', 'system-prompt.expected.md', 'tool-schemas.expected.json', diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index e7249a0b67..fa3e98dd35 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -302,7 +302,7 @@ export interface WebScaffold { /** Options for {@link launchWebScaffold}. */ export interface LaunchOptions { - /** The scaffold enables developer tools unless false preserves the shipped default. */ + /** Override the developer-tools preference; omitted uses the shipped default. */ developerTools?: boolean /** Enable the real Open In rows with deterministic launch-environment facts. */ openInAppEnvironment?: LaunchEnvironmentSnapshot @@ -818,8 +818,8 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise { } }, 90_000) + it('hides link-opening settings when the built-in browser is disabled', async () => { + const fresh = await launchWebScaffold({ + developerTools: false, + extraOverlayPath: fileURLToPath(new URL('./no-sidebar-browser.overlay.yml', import.meta.url)), + }) + onTestFinished(() => fresh.close()) + const withoutBrowser = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: 'en-US' }) + onTestFinished(() => withoutBrowser.close()) + const browserConsole = watchConsole(withoutBrowser) + await withoutBrowser.goto(fresh.authenticatedUrl, { waitUntil: 'load' }) + await withoutBrowser.getByRole('button', { name: 'Settings', exact: true }).click() + const dialog = withoutBrowser.getByRole('dialog', { name: 'Settings' }) + await dialog.getByRole('button', { name: 'Detailed', exact: true }).waitFor() + expect(await dialog.getByText('Open chat links in', { exact: true }).count()).toBe(0) + const snapshot = await captureStableAria(withoutBrowser, '[role="dialog"]', fresh.workspaceCwd) + await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'dialog-no-browser.expected.md'), snapshot, MODE) + expect(browserConsole.pageErrors).toEqual([]) + expect(browserConsole.warnings).toEqual([]) + }) + it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { expect(tripwire.warnings).toEqual([]) await assertFixtureInventory(SNAPSHOT_DIR, [ 'dialog-en.expected.md', + 'dialog-no-browser.expected.md', 'dialog.expected.md', 'plugin-instances.expected.md', 'plugins.expected.md', diff --git a/apps/web/tests/sidebar-browser.e2e.ts b/apps/web/tests/sidebar-browser.e2e.ts index 7a3334bb1b..ca26a11a4c 100644 --- a/apps/web/tests/sidebar-browser.e2e.ts +++ b/apps/web/tests/sidebar-browser.e2e.ts @@ -29,7 +29,11 @@ describe.skipIf(MODE === 'record')('web e2e: Sidebar Browser', () => { let tripwire: ReturnType beforeAll(async () => { - scaffold = await launchWebScaffold({ replayFixture: FIXTURE, compareReplaySession: false }) + scaffold = await launchWebScaffold({ + replayFixture: FIXTURE, + compareReplaySession: false, + extraOverlayPath: fileURLToPath(new URL('./sidebar-browser.overlay.yml', import.meta.url)), + }) browser = await chromium.launch() page = await newEnglishPage(browser) await page.route('https://browser.test/**', async (route) => { diff --git a/apps/web/tests/sidebar-browser.overlay.yml b/apps/web/tests/sidebar-browser.overlay.yml new file mode 100644 index 0000000000..66012abfce --- /dev/null +++ b/apps/web/tests/sidebar-browser.overlay.yml @@ -0,0 +1,3 @@ +# Opt in to Browser for its integration scenarios. +- id: ui-sidebar-browser + disabled: false diff --git a/apps/web/tests/sidebar-right.e2e.ts b/apps/web/tests/sidebar-right.e2e.ts index 49be02de85..90571db3f4 100644 --- a/apps/web/tests/sidebar-right.e2e.ts +++ b/apps/web/tests/sidebar-right.e2e.ts @@ -409,7 +409,8 @@ describe('web e2e: shipped right Sidebar', () => { } await expect.poll(async () => await tabTitles(column)).toEqual(['Start']) - await expect.poll(async () => await column.locator('[data-sidebar-right-guide-entry]').count()).toBe(3) + await expect.poll(async () => await column.locator('[data-sidebar-right-guide-entry]').count()).toBe(2) + expect(await column.locator('[data-sidebar-right-guide-entry="browser"]').count()).toBe(0) await column.locator('[data-sidebar-right-guide-entry="files"]').click() // A manual guide is closable beside Files and suppresses another add diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index ddf3683c59..9984bf38de 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -10,7 +10,7 @@ import { productWebBundleIsolation } from './product-isolation.ts' const src = (rel: string): string => fileURLToPath(new URL(rel, import.meta.url)) const STANDALONE_ERROR = 'apps/web is not a standalone application: bare Vite cannot inject window.__DSH_BOOT__. ' + 'From a repository checkout, run `pnpm dsh web`; an installed package uses `dsh web`. ' - + 'For client-plugin HMR, run `pnpm dsh web` together with `pnpm run dev:web`.' + + 'For client-plugin HMR, run `pnpm run dev:web`, which starts `dsh web` and the rebuild watchers together.' const DEFAULT_CLIENT_TITLE = 'DSH Local Build' /** Escape build-time text before placing it in the HTML title element. */ diff --git a/docs/api-gateway.i18n.yaml b/docs/api-gateway.i18n.yaml index fa62039df7..673807013b 100644 --- a/docs/api-gateway.i18n.yaml +++ b/docs/api-gateway.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/api-gateway.md -api-gateway.md: 2cf7b2f345991fd20639e1294662cc64fc02093d -api-gateway.zh.md: 5b80db08de49591b27751e187aa6278384a7444e +api-gateway.md: aa8339219a563901d5d132f4f401325ca30a3d53 +api-gateway.zh.md: 99b2d9866d38e520880e5d13115c2e6370fca474 diff --git a/docs/api-gateway.md b/docs/api-gateway.md index 2cf7b2f345..aa8339219a 100644 --- a/docs/api-gateway.md +++ b/docs/api-gateway.md @@ -138,14 +138,13 @@ SRC solves only dispatch for a Host process running from source. The Client does ## Development mode -Web development prepares current Host, Client, and Web artifacts with `pnpm run build`, then runs the source Host and the Client plugin watcher in separate terminals: +Web development runs one command, which builds current Host, Client, and Web artifacts, starts the source Host, and keeps Client bundles rebuilt: ```sh -pnpm dsh web pnpm run dev:web ``` -`dsh` starts the Host source through tsx, so the Host can use the SRC fallback; `dev:web` watches only Client plugins with a `dsh.client` declaration and rewrites their `lib/client.js`. It does not analyze Host decorators or generate Remote Client DTS. +`dsh` starts the Host source through tsx, so the Host can use the SRC fallback; the `dev:web` watchers rebuild the client-face type emit, Client plugin and library bundles, and the Web shell on source edits. They do not analyze Host decorators or generate Remote Client DTS; only the complete build that `dev:web` runs first (or `pnpm run build`) does. Changing only a Remote method's implementation body without changing its contract does not require regenerating the Typert files. After adding or removing a decorator or changing an export name, namespace, parameter, return value, lookup, Context, or cancellation signature, rerun the ordered lib build so the Host generates the strict contract before the Client compiles and bundles the new contribution: diff --git a/docs/api-gateway.zh.md b/docs/api-gateway.zh.md index 5b80db08de..99b2d9866d 100644 --- a/docs/api-gateway.zh.md +++ b/docs/api-gateway.zh.md @@ -138,14 +138,13 @@ SRC 只解决 Host 源码进程的分发问题。Client 不会从运行中的 Ho ## 开发模式 -Web 开发先使用 `pnpm run build` 准备当前 Host、Client 与 Web 产物,然后在两个终端中分别运行源码 Host 和 Client plugin watcher: +Web 开发只需一条命令:它先构建当前 Host、Client 与 Web 产物,再启动源码 Host,并持续重建 Client bundle: ```sh -pnpm dsh web pnpm run dev:web ``` -`dsh` 通过 tsx 启动 Host 源码,所以 Host 可以使用 SRC 回退;`dev:web` 只监听带 `dsh.client` 声明的 Client 插件并重写其 `lib/client.js`,它不会分析 Host decorator,也不会生成 Remote Client DTS。 +`dsh` 通过 tsx 启动 Host 源码,所以 Host 可以使用 SRC 回退;`dev:web` 的 watcher 会在源码修改时重建 client 侧类型输出、Client 插件与库 bundle 以及 Web 壳层。它们不会分析 Host decorator,也不会生成 Remote Client DTS;只有 `dev:web` 首先执行的完整构建(或 `pnpm run build`)才会。 只修改 Remote 方法实现体而不改变约定时,无需重新生成 Typert 文件。新增或删除 decorator、修改导出名、namespace、参数、返回值、lookup、Context 或取消签名时,重新执行有序 lib 构建,让 Host 先生成严格约定,再让 Client 编译并打包新的贡献: diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 3d9fba9385..66b457c8fa 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/architecture.md -architecture.md: 0bbf5ef7f4c0444635178ba0ba85fbb9527d76ec -architecture.zh.md: 0d28dc349fd9c655529bcc43deabc47055e0d394 +architecture.md: 5e0bb09770e013349bcc1326bea1eab9cdc3e595 +architecture.zh.md: a146c93c14ef0f563babf98f7311f1e5686895cc diff --git a/docs/architecture.md b/docs/architecture.md index 0bbf5ef7f4..5e0bb09770 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -44,7 +44,7 @@ Composition mechanics are in [app-boot](../packages/boot/app-boot/README.md#prof Supported Node applications launch through named `dsh` profiles. The shipped profiles are `web`, `headless`, `sdk`, `sdk-minimal`, and `acp`, selected with `dsh --profile ` or `dsh `. `plugin` names the management command; a profile with that name requires `--profile plugin`. The TypeScript SDK resolves its same-version `dsh` dependency and selects `sdk`; custom plugin composition remains a profile plus ordered patch files, not another executable or inline application tree. `sdk-minimal` is a repository-owned standalone bundle behind the same launcher, not a caller-supplied Cordis tree. -Vendored CLIs, build-only and test-only executables, direct in-process plugin mounting, and the private browser WebWorker preview are not Harness application launchers. [`verify-application-entrypoints`](../scripts/verify-application-entrypoints.ts) keeps every package bin, executable source, and root demo in an explicit class and rejects a Node application path that bypasses `dsh`. +Vendored CLIs, build-only and test-only executables, direct in-process plugin mounting, and the private browser WebWorker preview are not Harness application launchers. [`verify-application-entrypoints`](../scripts/verify-application-entrypoints.ts) keeps every package bin, executable source, root demo, and the root `start:web` and `dev:web` scripts in an explicit class and rejects a Node application path that bypasses `dsh`. The Python SDK follows the same application architecture. Its runtime wheel packages the normal `dsh` CLI as `deepseek-harness-sdk-runtime--`, and the client launches `dsh --profile sdk` with an explicit Harness home by default. The minimal example selects the shipped `sdk-minimal` profile. Python exposes profile selection and ordered patch files rather than a complete Cordis tree; persistent external plugins are installed through `dsh plugin`. The removed private direct-config carrier has no compatibility bin or fallback parser. diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 0d28dc349f..a146c93c14 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -44,7 +44,7 @@ dsh --profile web --dump-config 受支持的 Node 应用通过具名 `dsh` profile 启动。随附 profile 为 `web`、`headless`、`sdk`、`sdk-minimal` 和 `acp`,可通过 `dsh --profile ` 或 `dsh ` 选择。`plugin` 表示管理命令;同名 profile 必须用 `--profile plugin` 选择。TypeScript SDK 会解析其同版本 `dsh` 依赖并选择 `sdk`;自定义插件组合继续由 profile 与有序 patch 文件表达,而不是另一个可执行文件或内联应用树。`sdk-minimal` 是位于同一 launcher 后的仓库自有独立组合包,而不是由调用方提供的 Cordis 配置树。 -Vendored CLI、仅用于构建和测试的可执行文件、进程内直接挂载插件以及私有浏览器 WebWorker 预览都不属于 Harness 应用启动器。[`verify-application-entrypoints`](../scripts/verify-application-entrypoints.ts)将每个包 bin、可执行源码与根 demo 归入显式类别,并拒绝任何绕过 `dsh` 的 Node 应用路径。 +Vendored CLI、仅用于构建和测试的可执行文件、进程内直接挂载插件以及私有浏览器 WebWorker 预览都不属于 Harness 应用启动器。[`verify-application-entrypoints`](../scripts/verify-application-entrypoints.ts)将每个包 bin、可执行源码、根 demo 以及根脚本 `start:web` 与 `dev:web` 归入显式类别,并拒绝任何绕过 `dsh` 的 Node 应用路径。 Python SDK 遵循相同的应用架构。其运行时 wheel 把普通 `dsh` CLI 打包为 `deepseek-harness-sdk-runtime--`,客户端默认以显式 Harness home 启动 `dsh --profile sdk`。极简示例选择随附的 `sdk-minimal` profile。Python 暴露 profile 选择与有序 patch 文件,而不是完整 Cordis 树;持久外部插件通过 `dsh plugin` 安装。已删除的私有直读配置载体没有兼容 bin 或回退 parser。 diff --git a/docs/cookbook/adding-a-package.i18n.yaml b/docs/cookbook/adding-a-package.i18n.yaml index b2b0467564..8c94de4306 100644 --- a/docs/cookbook/adding-a-package.i18n.yaml +++ b/docs/cookbook/adding-a-package.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cookbook/adding-a-package.md -adding-a-package.md: 8c818bc371b4fcd646878ec440dd713f8c116083 -adding-a-package.zh.md: f6a09b411e246c50fcf3f44117c05433b12cbd4c +adding-a-package.md: 60b5d6e047da5f9cdfab3ab504104a0f29b208f7 +adding-a-package.zh.md: 44a613bcf002009c4c58a34c0f68cab85ef097f3 diff --git a/docs/cookbook/adding-a-package.md b/docs/cookbook/adding-a-package.md index 8c818bc371..60b5d6e047 100644 --- a/docs/cookbook/adding-a-package.md +++ b/docs/cookbook/adding-a-package.md @@ -143,7 +143,9 @@ Fields fall back independently before view-specific name formatting, using the e - Title: locale `meta.title` → `package.json.name` → complete Cordis plugin name. - Description: locale `meta.description` → `package.json.description` → no description. -Export `/locale/en.json` for locale lookup; expose `/package.json` when package-field fallback is needed. +Export `/locale/en.json` for locale lookup; expose `/package.json` for package-field fallback or an icon declaration. + +For an image on bundle cards, details, and component rows, set top-level `"icon": "./icon.svg"` in that exported manifest and include the image in `files`. The path is relative to the declaring manifest's directory, including for independently exported plugin manifests. SVG, PNG, JPEG (`.jpg`/`.jpeg`), and WebP files are supported up to 256 KiB. Absolute paths, URLs, paths outside that directory, and symlinks resolving outside it are rejected. Images need no separate export and must be self-contained; SVG is rendered as an image, not inline HTML. The Host returns a data URL without activating the plugin. Invalid declarations or unreadable files produce a metadata diagnostic while retaining valid text; missing or undecodable images use the panel's default artwork. Installed bundle cards and details, component lists and configuration details, and Settings' plugin inventory display this metadata, including disabled and preset plugins. Reads do not activate plugins. diff --git a/docs/cookbook/adding-a-package.zh.md b/docs/cookbook/adding-a-package.zh.md index f6a09b411e..44a613bcf0 100644 --- a/docs/cookbook/adding-a-package.zh.md +++ b/docs/cookbook/adding-a-package.zh.md @@ -143,7 +143,9 @@ Append-only, prefix-stable, replacing, or independent behavior, including the ex - 标题:locale `meta.title` → `package.json.name` → 完整 Cordis 插件名。 - 描述:locale `meta.description` → `package.json.description` → 不显示描述。 -导出 `<包名>/locale/en.json` 供 locale 查询;需要包字段回退时,再开放 `<包名>/package.json`。 +导出 `<包名>/locale/en.json` 供 locale 查询;需要包字段回退或声明图标时,开放 `<包名>/package.json`。 + +要在组合包卡片、详情和组件行显示图片,在该导出清单顶层设置 `"icon": "./icon.svg"`,并将图片加入 `files`。路径相对于声明清单所在目录,独立导出的插件清单也遵循此规则。支持不超过 256 KiB 的 SVG、PNG、JPEG(`.jpg`/`.jpeg`)和 WebP 文件。绝对路径、URL、目录外路径,以及解析到目录外的符号链接均被拒绝。图片不需要单独导出,且必须自包含;SVG 作为图片渲染,不作为内联 HTML。Host 返回 data URL,不激活插件。声明无效或文件不可读时,显示元信息诊断并保留有效文本;图片缺失或无法解码时使用面板的默认插画。 已安装 bundle 的卡片和详情、组件列表与配置详情、设置中的插件清单都展示这些元信息,包括禁用插件和预设内插件。读取时不激活插件。 diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index 8959b05a1d..fd6198a8ca 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/development.md -development.md: ce8f1e7746562e696c96e6359ec9fcfde596d6e2 -development.zh.md: 85e95946dfd7210201f0807880d5ed1a92859356 +development.md: 7e8d0ded70ca880ce986cb3879d959fbe2a04304 +development.zh.md: 84d0d76d8c76f829c0ed928d28336df3e58ed7f0 diff --git a/docs/development.md b/docs/development.md index ce8f1e7746..7e8d0ded70 100644 --- a/docs/development.md +++ b/docs/development.md @@ -83,7 +83,7 @@ Both tsdown passes use the same complete workspace match. They neither scan buil Typert runs only during Host tsdown, seeded by `tsconfig.host.json`. It analyzes Host types and generates both Host reflection artifacts and the Host-for-Client Remote projection; Client tsdown does not start Typert. Consequently, `pnpm run typecheck` runs the complete Host lib phase before Client tsc, while `pnpm run build` continues through Client tsdown and the Web build. -`pnpm run build` embeds the root package version, the seven-character source commit, and a dirty marker when Git reports local changes; it also inherits other caller-supplied `DSH_CLIENT_*` values. `pnpm run build:official` is the cross-platform local equivalent of the CI and release artifact build and omits the local dirty marker. Each successful complete build writes a gitignored record that binds the exact public values to the Vite output and dynamic client bundles; release packing and built Web tests reject a missing record or artifacts changed by a later partial build. `pnpm run dev:web` still requires the artifact tree from a prior complete build, but it samples the current version and Git state once at startup and shares that environment across every watcher stage for the session; it does not validate the complete-build record because the watcher stages rewrite its recorded artifacts. +`pnpm run build` embeds the root package version, the seven-character source commit, and a dirty marker when Git reports local changes; it also inherits other caller-supplied `DSH_CLIENT_*` values. `pnpm run build:official` is the cross-platform local equivalent of the CI and release artifact build and omits the local dirty marker. Each successful complete build writes a gitignored record that binds the exact public values to the Vite output and dynamic client bundles; release packing and built Web tests reject a missing record or artifacts changed by a later partial build. `pnpm run dev:web` runs that complete build first (`--skip-build` reuses an existing artifact tree instead), then samples the current version and Git state once and shares that environment across every watcher stage for the session; it does not validate the complete-build record because the watcher stages rewrite its recorded artifacts. Static analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Generated Host-for-Client Remote declarations are the deliberate exception: the public `typecheck`, `lint`, and `doc-typecheck` commands generate them first, while internal `*:contracts-ready` scripts assume that an invoking public command or scheduler gate already depends on the Typert contract-generation pass or the complete build. See the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md) for tsc-first emit ownership and the [Typert Remote note](../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md) for the gate-preparation contract. @@ -156,6 +156,19 @@ The PTC mode demo runs the same headless profile with code presentation enabled: pnpm run demo:ptc -- "summarize this workspace" ``` +### Application commands + +Web and Desktop share one command pair. `start:*` launches the artifacts of a prior `pnpm run build`; `dev:*` runs that build first and then launches. Web additionally keeps client bundles rebuilt on source edits, because its Host runs from source while the browser loads built bundles: + +```sh +pnpm run start:web # serve built Web artifacts through the source launcher (the same launch as pnpm dsh web) +pnpm run dev:web # build, serve, and rebuild Web client bundles on source edits +pnpm run start:desktop # launch built Desktop artifacts +pnpm run dev:desktop # build, then launch Desktop +``` + +Arguments after a Web command reach `dsh web`, for example `pnpm run dev:web --no-open --port 3081`; `dev:web` also accepts `--skip-build` to reuse the existing artifact tree and `--no-serve` to run only the rebuild watchers beside a server started elsewhere. Both Web commands use the normal Harness home, while the Desktop commands use the isolated development home described in the [Desktop README](../apps/desktop/README.md). The root `Makefile` names the same commands as `make web`, `make dev-web`, `make desktop`, `make dev-desktop`, and `make build`; `ARGS='--no-open'` forwards options. + ### TODO markers Use one of three comment tags to flag known issues in the code, ordered by urgency: diff --git a/docs/development.zh.md b/docs/development.zh.md index 85e95946df..84d0d76d8c 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -87,7 +87,7 @@ pnpm run build:web Typert 只在 Host tsdown 中以 `tsconfig.host.json` 为种子运行。它分析 Host 类型并生成 Host 反射产物及 Host-for-Client Remote 投影;Client tsdown 不启动 Typert。`pnpm run typecheck` 因此先执行完整 Host lib 阶段,再运行 Client tsc;`pnpm run build` 继续执行 Client tsdown 和 Web 构建。 -`pnpm run build` 会内联根包版本、七位源码 commit,并在 Git 报告本地变化时内联 dirty 标记;调用方提供的其他 `DSH_CLIENT_*` 值也会被继承。`pnpm run build:official` 是与 CI 和 release 产物构建等价的跨平台本地命令,并省略本地 dirty 标记。每次完整构建成功后都会写入一份被 gitignore 的记录,把精确公开值与 Vite 输出及动态 client bundle 绑定;release 打包和 built Web 测试会拒绝缺少记录或被后续局部构建改动的产物。`pnpm run dev:web` 仍需要先执行完整构建来准备产物树,但会在启动时读取一次当前版本和 Git 状态,并在本次会话的所有 watcher stage 之间共享该环境;它不会校验完整构建记录,因为 watcher stage 会重写记录覆盖的产物。 +`pnpm run build` 会内联根包版本、七位源码 commit,并在 Git 报告本地变化时内联 dirty 标记;调用方提供的其他 `DSH_CLIENT_*` 值也会被继承。`pnpm run build:official` 是与 CI 和 release 产物构建等价的跨平台本地命令,并省略本地 dirty 标记。每次完整构建成功后都会写入一份被 gitignore 的记录,把精确公开值与 Vite 输出及动态 client bundle 绑定;release 打包和 built Web 测试会拒绝缺少记录或被后续局部构建改动的产物。`pnpm run dev:web` 会先执行这次完整构建(`--skip-build` 则复用现有产物树),再读取一次当前版本和 Git 状态,并在本次会话的所有 watcher stage 之间共享该环境;它不会校验完整构建记录,因为 watcher stage 会重写记录覆盖的产物。 静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。生成的 Host-for-Client Remote 声明是有意设置的例外:公共 `typecheck`、`lint` 和 `doc-typecheck` 命令会先生成这些声明,而内部 `*:contracts-ready` 脚本假定调用它的公共命令或调度器门禁已经依赖 Typert 约定生成阶段或完整构建。tsc-first 发射职责见 [ts-build-config Note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.zh.md),门禁准备约定见 [Typert Remote Agent Note](../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md)。 @@ -160,6 +160,19 @@ PTC mode 演示启用代码式工具展示,并运行同一个 headless profile pnpm run demo:ptc -- "summarize this workspace" ``` +### 应用命令 + +Web 与 Desktop 共用同一对命令。`start:*` 启动上一次 `pnpm run build` 的产物;`dev:*` 先执行该构建再启动。Web 还会在源码修改时持续重建 client bundle,因为它的 Host 从源码运行,而浏览器加载的是构建产物: + +```sh +pnpm run start:web # serve built Web artifacts through the source launcher (the same launch as pnpm dsh web) +pnpm run dev:web # build, serve, and rebuild Web client bundles on source edits +pnpm run start:desktop # launch built Desktop artifacts +pnpm run dev:desktop # build, then launch Desktop +``` + +Web 命令后面的参数会传给 `dsh web`,例如 `pnpm run dev:web --no-open --port 3081`;`dev:web` 还接受 `--skip-build` 复用现有产物树,以及 `--no-serve` 只运行重建 watcher、配合别处启动的服务器。两个 Web 命令使用正常的 Harness home,而 Desktop 命令使用 [Desktop README](../apps/desktop/README.zh.md) 描述的隔离开发 home。根目录 `Makefile` 以 `make web`、`make dev-web`、`make desktop`、`make dev-desktop` 和 `make build` 命名同一套命令;`ARGS='--no-open'` 用于转发参数。 + ### TODO 标记 请使用以下三种注释标签之一标记代码中的已知问题,按紧急程度排序: diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index 251a673f01..5f847038ca 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/persistence-catalog.md -persistence-catalog.md: 4e62b69e07daf5809d6070bcc182b71305e70151 -persistence-catalog.zh.md: c001f06e98ac3b7e331044510639b73ec640eb85 +persistence-catalog.md: 11cea4328db6e69c1ee08994a6d36fe26e308a7c +persistence-catalog.zh.md: 6207dc17023e566f73cb1e0c7cba40533a4a96ff diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 4e62b69e07..11cea4328d 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -57,7 +57,7 @@ The [format references](persistence-changes/historical-formats/README.md) cover | `event:session/title-llm-request` | event | `fa8f7d3ebf08a76c7f7a8b0781873c4d819b964da5dbb52cd3cdfa5da34f452d` | [`{ type: "session/title-llm-request" }`](#persistence-type-sha256-fa8f7d3ebf08a76c7f7a8b0781873c4d819b964da5dbb52cd3cdfa5da34f452d) | | `event:step/end` | event | `e0a787e6ec76c7c94fecbc501b489164ab0293db05bc947914077ad01e674f05` | [`{ type: "step/end" }`](#persistence-type-sha256-e0a787e6ec76c7c94fecbc501b489164ab0293db05bc947914077ad01e674f05) | | `event:step/start` | event | `4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986` | [`{ type: "step/start" }`](#persistence-type-sha256-4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986) | -| `event:subagent/catalog` | event | `ae1f7110feeec697b8cab42b68f7709aa7b3279764099dfb53c25890d2e5c871` | [`{ type: "subagent/catalog" }`](#persistence-type-sha256-ae1f7110feeec697b8cab42b68f7709aa7b3279764099dfb53c25890d2e5c871) | +| `event:subagent/catalog` | event | `3abae7324356f155cb42450c00b806d134ec93bd6439d2063b8d724162d58604` | [`{ type: "subagent/catalog" }`](#persistence-type-sha256-3abae7324356f155cb42450c00b806d134ec93bd6439d2063b8d724162d58604) | | `event:subagent/descriptor` | event | `b79ada42962cad0190a9d465805260567621fa3a4abd757eb31e6016b52d5ab5` | [`{ type: "subagent/descriptor" }`](#persistence-type-sha256-b79ada42962cad0190a9d465805260567621fa3a4abd757eb31e6016b52d5ab5) | | `event:subagent/model-selection-policy` | event | `a6567ccb2e530606b775371eb4fa31468d72084339968e8b0440a516a23b39dc` | [`{ type: "subagent/model-selection-policy" }`](#persistence-type-sha256-a6567ccb2e530606b775371eb4fa31468d72084339968e8b0440a516a23b39dc) | | `event:system/message` | event | `69081694be231d56fd9580ba14645fd5e35373202605d5c5c841a9435b5fa3b1` | [`{ type: "system/message" }`](#persistence-type-sha256-69081694be231d56fd9580ba14645fd5e35373202605d5c5c841a9435b5fa3b1) | @@ -894,13 +894,13 @@ Source: [`packages/core/session/src/types.ts:299`](../packages/core/session/src/ ```ts persistence-catalog /** - * A direct child's complete discovery fact. + * A direct child's identity and available discovery fields. * @param data - versioned parent-owned catalog entry. */ 'subagent/catalog': SubagentCatalogEvent ``` -Source: [`packages/subagent/subagent/src/catalog.ts:40`](../packages/subagent/subagent/src/catalog.ts) +Source: [`packages/subagent/subagent/src/catalog.ts:43`](../packages/subagent/subagent/src/catalog.ts) @@ -2787,6 +2787,14 @@ SHA-256: `2c4c21f19f426a58f2dac914e34313aa43f8408972de857a486d1f086d5adb6c` `"unavailable"` + + +### `"unknown"` + +SHA-256: `05a2796621b1ee6c03ef7abe560b25116bb4da4d592312117242a78415a32b04` + +`"unknown"` + ### `"usage"` @@ -4547,7 +4555,7 @@ One of: - [`{ type: "finish" }`](#persistence-type-sha256-a2b49331716ba0a7078b026d145306a70eae64b7313c3e6204bd931e75ec3bb1) - [`{ type: "usage" }`](#persistence-type-sha256-f1a96afe7503ac1817de769cd0ba13d9b80111c7034c50106b3344ecc14cc515) - + @@ -4555,14 +4563,17 @@ One of: ### `SubagentCatalogEvent` -SHA-256: `3ac1196f59d5e9e0e9345122c8c403b102478b526404a2e591a38f613483321e` +SHA-256: `1de64ea9a0849ce631b7e4e1255ae773fabd94a4a7c8d27e716f8012302e12c0` -Sources: [`packages/subagent/subagent/src/catalog.ts:24`](../packages/subagent/subagent/src/catalog.ts) +Sources: [`packages/subagent/subagent/src/catalog.ts:28`](../packages/subagent/subagent/src/catalog.ts) One of: - [`{ childCreatedAt, childId, label, mode, … }`](#persistence-type-sha256-0e10d7c096b47e61defe372a5fc8bd37d835199410eac44367f95c45082498bd) +- [`{ childCreatedAt, childId, label, mode, … }`](#persistence-type-sha256-e2ad8f5bcd813f268ffef1b10a12d7ddd2b8ced8b9b6d0275b3f6b7ed094a187) - [`{ childCreatedAt, childId, label?, mode, … }`](#persistence-type-sha256-5d542ac7bdf13310c46e1a884dec9fb18ad8c6e153cff207e639cd7bf9d7e694) +- [`{ childCreatedAt, childId, label?, mode, … }`](#persistence-type-sha256-b6896938660a1dfe4ddd100fb3164d9f8fa57feeabfe37441a80106c9942697a) +- [`{ childCreatedAt, childId, label?, mode, … }`](#persistence-type-sha256-a0f25b5aeeebf2bf1542ce67726b095e3b139a39c71861d57e827f12dea1f499) @@ -5612,6 +5623,20 @@ SHA-256: `0e10d7c096b47e61defe372a5fc8bd37d835199410eac44367f95c45082498bd` | `mode` | required | `"continuable"` | | `version` | required | `0` | + + +### `{ childCreatedAt, childId, label, mode, … }` + +SHA-256: `e2ad8f5bcd813f268ffef1b10a12d7ddd2b8ced8b9b6d0275b3f6b7ed094a187` + +| Property | Presence | Type | +|---|---|---| +| `childCreatedAt` | required | `number` | +| `childId` | required | `string` | +| `label` | required | `string` | +| `mode` | required | `"continuable"` | +| `version` | required | `1` | + ### `{ childCreatedAt, childId, label?, mode, … }` @@ -5626,6 +5651,34 @@ SHA-256: `5d542ac7bdf13310c46e1a884dec9fb18ad8c6e153cff207e639cd7bf9d7e694` | `mode` | required | `"one-shot"` | | `version` | required | `0` | + + +### `{ childCreatedAt, childId, label?, mode, … }` + +SHA-256: `a0f25b5aeeebf2bf1542ce67726b095e3b139a39c71861d57e827f12dea1f499` + +| Property | Presence | Type | +|---|---|---| +| `childCreatedAt` | required | `number` | +| `childId` | required | `string` | +| `label` | optional | `string` | +| `mode` | required | `"unknown"` | +| `version` | required | `1` | + + + +### `{ childCreatedAt, childId, label?, mode, … }` + +SHA-256: `b6896938660a1dfe4ddd100fb3164d9f8fa57feeabfe37441a80106c9942697a` + +| Property | Presence | Type | +|---|---|---| +| `childCreatedAt` | required | `number` | +| `childId` | required | `string` | +| `label` | optional | `string` | +| `mode` | required | `"one-shot"` | +| `version` | required | `1` | + ### `{ code, name, reason? }` @@ -8221,17 +8274,17 @@ SHA-256: `4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986` | `time` | required | `number` | | `type` | required | `"step/start"` | - + ### `{ type: "subagent/catalog" }` -SHA-256: `ae1f7110feeec697b8cab42b68f7709aa7b3279764099dfb53c25890d2e5c871` +SHA-256: `3abae7324356f155cb42450c00b806d134ec93bd6439d2063b8d724162d58604` | Property | Presence | Type | |---|---|---| -| `data` | required | [`SubagentCatalogEvent`](#persistence-type-sha256-3ac1196f59d5e9e0e9345122c8c403b102478b526404a2e591a38f613483321e) | +| `data` | required | [`SubagentCatalogEvent`](#persistence-type-sha256-1de64ea9a0849ce631b7e4e1255ae773fabd94a4a7c8d27e716f8012302e12c0) | | `ignorable` | optional | `true` | | `seq` | required | `number` | | `time` | required | `number` | diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index c001f06e98..6207dc1702 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -59,7 +59,7 @@ | `event:session/title-llm-request` | event | `fa8f7d3ebf08a76c7f7a8b0781873c4d819b964da5dbb52cd3cdfa5da34f452d` | [`{ type: "session/title-llm-request" }`](#persistence-type-sha256-fa8f7d3ebf08a76c7f7a8b0781873c4d819b964da5dbb52cd3cdfa5da34f452d) | | `event:step/end` | event | `e0a787e6ec76c7c94fecbc501b489164ab0293db05bc947914077ad01e674f05` | [`{ type: "step/end" }`](#persistence-type-sha256-e0a787e6ec76c7c94fecbc501b489164ab0293db05bc947914077ad01e674f05) | | `event:step/start` | event | `4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986` | [`{ type: "step/start" }`](#persistence-type-sha256-4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986) | -| `event:subagent/catalog` | event | `ae1f7110feeec697b8cab42b68f7709aa7b3279764099dfb53c25890d2e5c871` | [`{ type: "subagent/catalog" }`](#persistence-type-sha256-ae1f7110feeec697b8cab42b68f7709aa7b3279764099dfb53c25890d2e5c871) | +| `event:subagent/catalog` | event | `3abae7324356f155cb42450c00b806d134ec93bd6439d2063b8d724162d58604` | [`{ type: "subagent/catalog" }`](#persistence-type-sha256-3abae7324356f155cb42450c00b806d134ec93bd6439d2063b8d724162d58604) | | `event:subagent/descriptor` | event | `b79ada42962cad0190a9d465805260567621fa3a4abd757eb31e6016b52d5ab5` | [`{ type: "subagent/descriptor" }`](#persistence-type-sha256-b79ada42962cad0190a9d465805260567621fa3a4abd757eb31e6016b52d5ab5) | | `event:subagent/model-selection-policy` | event | `a6567ccb2e530606b775371eb4fa31468d72084339968e8b0440a516a23b39dc` | [`{ type: "subagent/model-selection-policy" }`](#persistence-type-sha256-a6567ccb2e530606b775371eb4fa31468d72084339968e8b0440a516a23b39dc) | | `event:system/message` | event | `69081694be231d56fd9580ba14645fd5e35373202605d5c5c841a9435b5fa3b1` | [`{ type: "system/message" }`](#persistence-type-sha256-69081694be231d56fd9580ba14645fd5e35373202605d5c5c841a9435b5fa3b1) | @@ -896,13 +896,13 @@ export type SessionEvent = { ```ts persistence-catalog /** - * A direct child's complete discovery fact. + * A direct child's identity and available discovery fields. * @param data - versioned parent-owned catalog entry. */ 'subagent/catalog': SubagentCatalogEvent ``` -来源:[`packages/subagent/subagent/src/catalog.ts:40`](../packages/subagent/subagent/src/catalog.ts) +来源:[`packages/subagent/subagent/src/catalog.ts:43`](../packages/subagent/subagent/src/catalog.ts) @@ -2789,6 +2789,14 @@ SHA-256: `2c4c21f19f426a58f2dac914e34313aa43f8408972de857a486d1f086d5adb6c` `"unavailable"` + + +### `"unknown"` + +SHA-256: `05a2796621b1ee6c03ef7abe560b25116bb4da4d592312117242a78415a32b04` + +`"unknown"` + ### `"usage"` @@ -4549,7 +4557,7 @@ SHA-256: `47e9095591ae36e7c39ee62553097772f2bfa16304536a72dbbc3f691f605180` - [`{ type: "finish" }`](#persistence-type-sha256-a2b49331716ba0a7078b026d145306a70eae64b7313c3e6204bd931e75ec3bb1) - [`{ type: "usage" }`](#persistence-type-sha256-f1a96afe7503ac1817de769cd0ba13d9b80111c7034c50106b3344ecc14cc515) - + @@ -4557,14 +4565,17 @@ SHA-256: `47e9095591ae36e7c39ee62553097772f2bfa16304536a72dbbc3f691f605180` ### `SubagentCatalogEvent` -SHA-256: `3ac1196f59d5e9e0e9345122c8c403b102478b526404a2e591a38f613483321e` +SHA-256: `1de64ea9a0849ce631b7e4e1255ae773fabd94a4a7c8d27e716f8012302e12c0` -来源:[`packages/subagent/subagent/src/catalog.ts:24`](../packages/subagent/subagent/src/catalog.ts) +来源:[`packages/subagent/subagent/src/catalog.ts:28`](../packages/subagent/subagent/src/catalog.ts) 以下类型之一: - [`{ childCreatedAt, childId, label, mode, … }`](#persistence-type-sha256-0e10d7c096b47e61defe372a5fc8bd37d835199410eac44367f95c45082498bd) +- [`{ childCreatedAt, childId, label, mode, … }`](#persistence-type-sha256-e2ad8f5bcd813f268ffef1b10a12d7ddd2b8ced8b9b6d0275b3f6b7ed094a187) - [`{ childCreatedAt, childId, label?, mode, … }`](#persistence-type-sha256-5d542ac7bdf13310c46e1a884dec9fb18ad8c6e153cff207e639cd7bf9d7e694) +- [`{ childCreatedAt, childId, label?, mode, … }`](#persistence-type-sha256-b6896938660a1dfe4ddd100fb3164d9f8fa57feeabfe37441a80106c9942697a) +- [`{ childCreatedAt, childId, label?, mode, … }`](#persistence-type-sha256-a0f25b5aeeebf2bf1542ce67726b095e3b139a39c71861d57e827f12dea1f499) @@ -5614,6 +5625,20 @@ SHA-256: `0e10d7c096b47e61defe372a5fc8bd37d835199410eac44367f95c45082498bd` | `mode` | 必需 | `"continuable"` | | `version` | 必需 | `0` | + + +### `{ childCreatedAt, childId, label, mode, … }` + +SHA-256: `e2ad8f5bcd813f268ffef1b10a12d7ddd2b8ced8b9b6d0275b3f6b7ed094a187` + +| 属性 | 存在性 | 类型 | +|---|---|---| +| `childCreatedAt` | 必需 | `number` | +| `childId` | 必需 | `string` | +| `label` | 必需 | `string` | +| `mode` | 必需 | `"continuable"` | +| `version` | 必需 | `1` | + ### `{ childCreatedAt, childId, label?, mode, … }` @@ -5628,6 +5653,34 @@ SHA-256: `5d542ac7bdf13310c46e1a884dec9fb18ad8c6e153cff207e639cd7bf9d7e694` | `mode` | 必需 | `"one-shot"` | | `version` | 必需 | `0` | + + +### `{ childCreatedAt, childId, label?, mode, … }` + +SHA-256: `a0f25b5aeeebf2bf1542ce67726b095e3b139a39c71861d57e827f12dea1f499` + +| 属性 | 存在性 | 类型 | +|---|---|---| +| `childCreatedAt` | 必需 | `number` | +| `childId` | 必需 | `string` | +| `label` | 可选 | `string` | +| `mode` | 必需 | `"unknown"` | +| `version` | 必需 | `1` | + + + +### `{ childCreatedAt, childId, label?, mode, … }` + +SHA-256: `b6896938660a1dfe4ddd100fb3164d9f8fa57feeabfe37441a80106c9942697a` + +| 属性 | 存在性 | 类型 | +|---|---|---| +| `childCreatedAt` | 必需 | `number` | +| `childId` | 必需 | `string` | +| `label` | 可选 | `string` | +| `mode` | 必需 | `"one-shot"` | +| `version` | 必需 | `1` | + ### `{ code, name, reason? }` @@ -8223,17 +8276,17 @@ SHA-256: `4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986` | `time` | 必需 | `number` | | `type` | 必需 | `"step/start"` | - + ### `{ type: "subagent/catalog" }` -SHA-256: `ae1f7110feeec697b8cab42b68f7709aa7b3279764099dfb53c25890d2e5c871` +SHA-256: `3abae7324356f155cb42450c00b806d134ec93bd6439d2063b8d724162d58604` | 属性 | 存在性 | 类型 | |---|---|---| -| `data` | 必需 | [`SubagentCatalogEvent`](#persistence-type-sha256-3ac1196f59d5e9e0e9345122c8c403b102478b526404a2e591a38f613483321e) | +| `data` | 必需 | [`SubagentCatalogEvent`](#persistence-type-sha256-1de64ea9a0849ce631b7e4e1255ae773fabd94a4a7c8d27e716f8012302e12c0) | | `ignorable` | 可选 | `true` | | `seq` | 必需 | `number` | | `time` | 必需 | `number` | diff --git a/docs/persistence-changes/2026-09-20-unknown-child-catalog.i18n.yaml b/docs/persistence-changes/2026-09-20-unknown-child-catalog.i18n.yaml new file mode 100644 index 0000000000..60ad1d8f73 --- /dev/null +++ b/docs/persistence-changes/2026-09-20-unknown-child-catalog.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/persistence-changes/2026-09-20-unknown-child-catalog.md +2026-09-20-unknown-child-catalog.md: ba729661d60856ff948854641a1520339ab30210 +2026-09-20-unknown-child-catalog.zh.md: 468b4c9a3d3afab56ec8a9b03359016c9c0307e0 diff --git a/docs/persistence-changes/2026-09-20-unknown-child-catalog.md b/docs/persistence-changes/2026-09-20-unknown-child-catalog.md new file mode 100644 index 0000000000..ba729661d6 --- /dev/null +++ b/docs/persistence-changes/2026-09-20-unknown-child-catalog.md @@ -0,0 +1,48 @@ +--- +description: "Records a persistence type transition and its compatibility acknowledgement." +kind: persistence-change +--- + +# 2026-09-20-unknown-child-catalog + +English | [中文](2026-09-20-unknown-child-catalog.zh.md) + +## Summary + +Retain unreadable historical children in subagent/catalog with unknown mode. + +## Table of Contents + +- [Declaration](#declaration) +- [Compatibility](#compatibility) +- [Verification](#verification) +- [Dev Note](#dev-note) + + +## Declaration + +```yaml persistence-change +schemaVersion: 1 +id: 2026-09-20-unknown-child-catalog +baseline: false +changes: + - root: "event:subagent/catalog" + previous: "2026-09-11-initial" + after: "3abae7324356f155cb42450c00b806d134ec93bd6439d2063b8d724162d58604" + decision: same-version +``` + + +## Compatibility + +Catalog payload v0 remains unchanged. Payload v1 adds unknown mode, and current readers accept v0 and v1. Complete facts still use v0; migration emits v1 for unknown children. Existing logs need no rewrite, and the Session header remains V4. Older readers reject v1. The classifier allows higher event payload versions only when all old payload alternatives remain unchanged; same-version widening and removal of old readers stay breaking. + + +## Verification + +Migration, restoration, projection, and Web regressions retain unknown membership and child-local errors. Classifier tests accept higher payload versions with preserved predecessors and reject same-version additions, invalid versions, changed or removed old alternatives, and header/surface changes. + + +## Dev Note + +None. diff --git a/docs/persistence-changes/2026-09-20-unknown-child-catalog.schema.json b/docs/persistence-changes/2026-09-20-unknown-child-catalog.schema.json new file mode 100644 index 0000000000..14c4b254ad --- /dev/null +++ b/docs/persistence-changes/2026-09-20-unknown-child-catalog.schema.json @@ -0,0 +1,250 @@ +{ + "formatVersion": 2, + "roots": [ + { + "key": "event:subagent/catalog", + "kind": "event", + "event": "subagent/catalog", + "surface": false, + "digest": "3abae7324356f155cb42450c00b806d134ec93bd6439d2063b8d724162d58604", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "object", + "properties": [ + { + "name": "data", + "type": 1, + "optional": false + }, + { + "name": "ignorable", + "type": 14, + "optional": true + }, + { + "name": "seq", + "type": 3, + "optional": false + }, + { + "name": "time", + "type": 3, + "optional": false + }, + { + "name": "type", + "type": 15, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "union", + "types": [ + 2, + 7, + 9, + 11, + 12 + ] + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 3, + "optional": false + }, + { + "name": "childId", + "type": 4, + "optional": false + }, + { + "name": "label", + "type": 4, + "optional": false + }, + { + "name": "mode", + "type": 5, + "optional": false + }, + { + "name": "version", + "type": 6, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "number" + }, + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "literal", + "value": "continuable" + }, + { + "kind": "literal", + "value": 0 + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 3, + "optional": false + }, + { + "name": "childId", + "type": 4, + "optional": false + }, + { + "name": "label", + "type": 4, + "optional": false + }, + { + "name": "mode", + "type": 5, + "optional": false + }, + { + "name": "version", + "type": 8, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": 1 + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 3, + "optional": false + }, + { + "name": "childId", + "type": 4, + "optional": false + }, + { + "name": "label", + "type": 4, + "optional": true + }, + { + "name": "mode", + "type": 10, + "optional": false + }, + { + "name": "version", + "type": 6, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "one-shot" + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 3, + "optional": false + }, + { + "name": "childId", + "type": 4, + "optional": false + }, + { + "name": "label", + "type": 4, + "optional": true + }, + { + "name": "mode", + "type": 10, + "optional": false + }, + { + "name": "version", + "type": 8, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 3, + "optional": false + }, + { + "name": "childId", + "type": 4, + "optional": false + }, + { + "name": "label", + "type": 4, + "optional": true + }, + { + "name": "mode", + "type": 13, + "optional": false + }, + { + "name": "version", + "type": 8, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "unknown" + }, + { + "kind": "literal", + "value": true + }, + { + "kind": "literal", + "value": "subagent/catalog" + } + ] + } + } + ], + "types": [] +} diff --git a/docs/persistence-changes/2026-09-20-unknown-child-catalog.zh.md b/docs/persistence-changes/2026-09-20-unknown-child-catalog.zh.md new file mode 100644 index 0000000000..468b4c9a3d --- /dev/null +++ b/docs/persistence-changes/2026-09-20-unknown-child-catalog.zh.md @@ -0,0 +1,48 @@ +--- +description: "记录持久化类型更改及其兼容性确认。" +kind: persistence-change +--- + +# 2026-09-20-unknown-child-catalog + +[English](2026-09-20-unknown-child-catalog.md) | 中文 + +## 概述 + +在 subagent/catalog 中使用 unknown 模式保留不可读的历史子会话。 + +## 目录 + +- [声明](#declaration) +- [兼容性](#compatibility) +- [验证](#verification) +- [开发备注](#dev-note) + + +## 声明 + +```yaml persistence-change +schemaVersion: 1 +id: 2026-09-20-unknown-child-catalog +baseline: false +changes: + - root: "event:subagent/catalog" + previous: "2026-09-11-initial" + after: "3abae7324356f155cb42450c00b806d134ec93bd6439d2063b8d724162d58604" + decision: same-version +``` + + +## 兼容性 + +Catalog 载荷 v0 保持不变。载荷 v1 增加 unknown 模式,当前读取器支持 v0 和 v1。完整事实继续使用 v0;迁移为未知子会话写入 v1。已有日志无需改写,Session header 保持 V4。旧读取器拒绝 v1。分类器仅在所有旧载荷分支保持不变时允许增加更高的事件载荷版本;同版本扩展和移除旧版读取支持仍属于破坏性变更。 + + +## 验证 + +迁移、恢复、投影和 Web 回归覆盖未知成员保留与子会话局部报错。分类器测试接受保留前代的更高载荷版本,并拒绝同版本新增、无效版本、旧分支修改或删除,以及 header/model-surface 变更。 + + +## 开发备注 + +无。 diff --git a/docs/persistence-changes/README.i18n.yaml b/docs/persistence-changes/README.i18n.yaml index 830e77b05e..c2d80f8829 100644 --- a/docs/persistence-changes/README.i18n.yaml +++ b/docs/persistence-changes/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/persistence-changes/README.md -README.md: 3fd95e48548fdb9c1d04330c2925096d072850d1 -README.zh.md: f4ab3b3d22b1aa2f7f5923e84fd4effc339874db +README.md: 2f3d2dd50adee751d9dce9ab250649ae13e45521 +README.zh.md: d2cec933b2210cc5652a7aa6c725517e1baff948 diff --git a/docs/persistence-changes/README.md b/docs/persistence-changes/README.md index 3fd95e4854..2f3d2dd50a 100644 --- a/docs/persistence-changes/README.md +++ b/docs/persistence-changes/README.md @@ -53,15 +53,18 @@ Every detected structural change requires an acknowledgement. Record creation an | Add an optional event-body property, including its complete subtree | `same-version` | | Make a required event-body property optional | `same-version` | | Add an ordinary event type | `same-version` | +| Add a higher numeric `data.version` to an ordinary event while retaining every old payload alternative unchanged | `same-version` | | Add an explicitly qualified attribution kind to a user/developer source slot whose before and after schemas carry the same supported policy | `same-version` | | Make an optional property required, add a required property, change an existing type, or remove/rename a property or event | `version-bump` | | Change the Session header or event envelope | `version-bump` | A finalized checkpoint protects the accepted baseline without replacing these compatibility rules. At V4, optional additions, ordinary events, and qualified attribution additions can receive new same-version records. Breaking differences require a higher writer version and an acknowledgement containing its own header increase. The accepted V4 record cannot be updated to reuse its original 3→4 transition. +An ordinary event may add payload alternatives with a required, nonnegative integer `data.version` greater than every existing payload version, provided all existing alternatives remain structurally unchanged. The reader must retain support for the old payloads; adding variants at an existing version, dropping old versions, and changing the Session header or envelope remain breaking. Older readers may reject the new payload version. The [catalog acknowledgement](2026-09-20-unknown-child-catalog.md) records one such transition. + The extractor accepts an explicit `@persistenceSource` binding for a core-owned source property on a literal user or developer role; it does not infer a binding from an unannotated type. A producer qualifies its `MessageSourceMap` entry with `@persistenceAttribution`. Qualification promises that an unknown kind and its JSON metadata survive reading without the producer, and that the kind imposes no validation, replay, or authority requirement. A producer may inspect its own kind to resume duplicate suppression; other readers must preserve and derive the recorded messages without that projection. The recorded schema retains the binding, policy version, literal `kind` discriminator, preservation promise, and qualified kind set. Inventory format 2 stores those promises; extraction without a bound policy retains format 1. Session format versions are independent. Both compared snapshots must carry compatible policy state. Existing kind groups still receive ordinary structural comparison; removals, unmarked additions, policy changes, and unrelated breaking changes remain strict. Multiple context-form alternatives with the same wire kind form one group. -A same-version explanation states why old records can omit the addition and why older readers can ignore it without changing replay. For required-to-optional changes, it explains how readers handle an absent value. The checker validates the type classification; reviewers assess the explanation. A version-bump record includes the increasing header version in the same transition and follows the [Session-format procedure](../cookbook/adding-a-session-format-version.md). +A same-version explanation states how old records remain readable and how older readers handle new records. Optional additions explain why older readers can ignore them without changing replay; a new payload version records the older-reader rejection. For required-to-optional changes, it explains how readers handle an absent value. The checker validates the type classification; reviewers assess the explanation. A version-bump record includes the increasing header version in the same transition and follows the [Session-format procedure](../cookbook/adding-a-session-format-version.md). When a reader rejects a JSON property by name, declare the property as optional `never` and mark it with argument-free `@persistenceReserved`. The extractor retains the forbidden field, so allowing a JSON value later requires an existing-field type change. Required or JSON-valued properties cannot carry this marker. Unmarked optional `never` and `undefined` properties retain their existing omission behavior. diff --git a/docs/persistence-changes/README.zh.md b/docs/persistence-changes/README.zh.md index f4ab3b3d22..d2cec933b2 100644 --- a/docs/persistence-changes/README.zh.md +++ b/docs/persistence-changes/README.zh.md @@ -53,15 +53,18 @@ description: "审阅和维护已记录的会话持久化类型变更、对应 sc | 添加可选事件体属性,包括其完整子树 | `same-version` | | 将必选事件体属性改为可选 | `same-version` | | 添加普通事件类型 | `same-version` | +| 为普通事件增加更高的数字 `data.version`,并原样保留所有旧载荷分支 | `same-version` | | 在前后 schema 均带有相同受支持策略的 user/developer 消息源字段中,添加显式声明为归属信息的 kind | `same-version` | | 将可选属性改为必选、添加必选属性、更改已有类型,或删除/重命名属性或事件 | `version-bump` | | 更改会话头或事件封装 | `version-bump` | 定稿检查点保护已接受基线,不替换这些兼容性规则。在 V4 中,可选新增、普通事件及符合条件的归属 kind 新增可以使用新的同版本记录。破坏性差异要求更高的写入器版本,以及包含自身头版本递增的确认记录。不能更新已接受 V4 记录以复用其原有 3→4 转换。 +普通事件可增加具有必选、非负整数 `data.version` 的载荷分支,新版本必须高于所有已有载荷版本,且所有已有分支结构保持不变。读取器必须保留旧载荷支持;在已有版本中增加分支、移除旧版本,以及修改 Session header 或事件封装仍属于破坏性变更。旧读取器可能拒绝新载荷版本。[Catalog 确认记录](2026-09-20-unknown-child-catalog.zh.md) 记录了此类演进。 + 提取器接受核心拥有的 source 属性上针对 user 或 developer 字面量角色的显式 `@persistenceSource` 绑定,不会从未标记类型中推断绑定。生产者用 `@persistenceAttribution` 标记其 `MessageSourceMap` 成员。该标记承诺:读取器无需生产者即可保留未知 kind 及其 JSON 元数据,且该 kind 不引入校验、回放或权限要求。生产者可以检查自身 kind 来恢复去重状态;其他读取器必须无需该投影也能保留并派生已记录的消息。记录的 schema 保存绑定、策略版本、字面量 `kind` 判别字段、保留承诺及符合条件的 kind 集合。Inventory format 2 保存这些承诺;没有绑定策略的提取仍使用 format 1。Session 格式版本独立于此。比较双方的快照必须带有兼容的策略状态。已有 kind 分组仍进行常规结构比较;删除、未标记的添加、策略更改及无关破坏性变更继续采用严格规则。同一 wire kind 的多个上下文形式分支归为一组。 -同版本说明须解释旧记录为何可以缺少新增内容,以及旧读取器为何可以忽略它而不改变回放。对于必选改可选的变更,说明须解释读取器如何处理缺失值。检查器验证类型分类;审阅者判断说明是否成立。升版本记录在同一转换中包含递增的头部版本,并遵循[会话格式流程](../cookbook/adding-a-session-format-version.zh.md)。 +同版本说明须解释旧记录如何保持可读,以及旧读取器如何处理新记录。可选新增须说明旧读取器为何可以忽略它而不改变回放;新增载荷版本须记录旧读取器的拒绝行为。对于必选改可选的变更,说明须解释读取器如何处理缺失值。检查器验证类型分类;审阅者判断说明是否成立。升版本记录在同一转换中包含递增的头部版本,并遵循[会话格式流程](../cookbook/adding-a-session-format-version.zh.md)。 当读取器按属性名拒绝 JSON 字段时,将该属性声明为可选 `never`,并添加无参数的 `@persistenceReserved` 标记。提取器保留这一禁止字段,因此后续允许 JSON 值属于已有字段类型变更。必选属性或允许 JSON 值的属性不能携带该标记。未标记的可选 `never` 和 `undefined` 属性保留现有省略行为。 diff --git a/docs/persistence-changes/historical-formats/README.i18n.yaml b/docs/persistence-changes/historical-formats/README.i18n.yaml index 15481ef915..1f6bfd547a 100644 --- a/docs/persistence-changes/historical-formats/README.i18n.yaml +++ b/docs/persistence-changes/historical-formats/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/persistence-changes/historical-formats/README.md -README.md: 9bcfaa1246a03446d4d4fc95dd19f9ca60196301 -README.zh.md: 1f17367beb484feaeb289a42b3e5de50d3139be5 +README.md: a1064e815879d709fe466441bb4e8441c10b3aae +README.zh.md: 87c9d32fa6ca5248886dad663a926afc6983406f diff --git a/docs/persistence-changes/historical-formats/README.md b/docs/persistence-changes/historical-formats/README.md index 9bcfaa1246..a1064e8158 100644 --- a/docs/persistence-changes/historical-formats/README.md +++ b/docs/persistence-changes/historical-formats/README.md @@ -32,7 +32,7 @@ The index is generated from validated snapshots and the current writer constant. | 1 | PR #3349 | [V1](v1.md) | [JSON](v1.schema.json) | 54 / 415 | | 2 | `dsh-v0.1.3-alpha.2` | [V2](v2.md) | [JSON](v2.schema.json) | 56 / 435 | | 3 | PR #4320 | [V3](v3.md) | [JSON](v3.schema.json) | 60 / 467 | -| 4 | Current checkout | [Current catalog](../../persistence-catalog.md) | [JSON](../../persistence-schema.json) | 62 / 583 | +| 4 | Current checkout | [Current catalog](../../persistence-catalog.md) | [JSON](../../persistence-schema.json) | 62 / 587 | diff --git a/docs/persistence-changes/historical-formats/README.zh.md b/docs/persistence-changes/historical-formats/README.zh.md index 1f17367beb..87c9d32fa6 100644 --- a/docs/persistence-changes/historical-formats/README.zh.md +++ b/docs/persistence-changes/historical-formats/README.zh.md @@ -32,7 +32,7 @@ description: "查找从 V0 到工作区写入器版本的每个 Session 格式 | 1 | PR #3349 | [V1](v1.zh.md) | [JSON](v1.schema.json) | 54 / 415 | | 2 | `dsh-v0.1.3-alpha.2` | [V2](v2.zh.md) | [JSON](v2.schema.json) | 56 / 435 | | 3 | PR #4320 | [V3](v3.zh.md) | [JSON](v3.schema.json) | 60 / 467 | -| 4 | 当前工作树 | [当前目录](../../persistence-catalog.zh.md) | [JSON](../../persistence-schema.json) | 62 / 583 | +| 4 | 当前工作树 | [当前目录](../../persistence-catalog.zh.md) | [JSON](../../persistence-schema.json) | 62 / 587 | diff --git a/docs/persistence-schema.json b/docs/persistence-schema.json index db1c54ff38..59b8a86b4a 100644 --- a/docs/persistence-schema.json +++ b/docs/persistence-schema.json @@ -14850,7 +14850,7 @@ "kind": "event", "event": "subagent/catalog", "surface": false, - "digest": "ae1f7110feeec697b8cab42b68f7709aa7b3279764099dfb53c25890d2e5c871", + "digest": "3abae7324356f155cb42450c00b806d134ec93bd6439d2063b8d724162d58604", "schema": { "root": 0, "nodes": [ @@ -14864,7 +14864,7 @@ }, { "name": "ignorable", - "type": 9, + "type": 14, "optional": true }, { @@ -14879,7 +14879,7 @@ }, { "name": "type", - "type": 10, + "type": 15, "optional": false } ], @@ -14889,7 +14889,10 @@ "kind": "union", "types": [ 2, - 7 + 7, + 9, + 11, + 12 ] }, { @@ -14939,6 +14942,41 @@ "kind": "literal", "value": 0 }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 3, + "optional": false + }, + { + "name": "childId", + "type": 4, + "optional": false + }, + { + "name": "label", + "type": 4, + "optional": false + }, + { + "name": "mode", + "type": 5, + "optional": false + }, + { + "name": "version", + "type": 8, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": 1 + }, { "kind": "object", "properties": [ @@ -14959,7 +14997,7 @@ }, { "name": "mode", - "type": 8, + "type": 10, "optional": false }, { @@ -14974,6 +15012,72 @@ "kind": "literal", "value": "one-shot" }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 3, + "optional": false + }, + { + "name": "childId", + "type": 4, + "optional": false + }, + { + "name": "label", + "type": 4, + "optional": true + }, + { + "name": "mode", + "type": 10, + "optional": false + }, + { + "name": "version", + "type": 8, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 3, + "optional": false + }, + { + "name": "childId", + "type": 4, + "optional": false + }, + { + "name": "label", + "type": 4, + "optional": true + }, + { + "name": "mode", + "type": 13, + "optional": false + }, + { + "name": "version", + "type": 8, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "unknown" + }, { "kind": "literal", "value": true @@ -21691,6 +21795,20 @@ "names": [], "sources": [] }, + { + "digest": "05a2796621b1ee6c03ef7abe560b25116bb4da4d592312117242a78415a32b04", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "literal", + "value": "unknown" + } + ] + }, + "names": [], + "sources": [] + }, { "digest": "05d2ae1e0c7f8770f83f9a09047afb39a335d8ac986df3193659aa1e581472cf", "schema": { @@ -30929,6 +31047,213 @@ "names": [], "sources": [] }, + { + "digest": "1de64ea9a0849ce631b7e4e1255ae773fabd94a4a7c8d27e716f8012302e12c0", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "union", + "types": [ + 1, + 6, + 8, + 10, + 11 + ] + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 2, + "optional": false + }, + { + "name": "childId", + "type": 3, + "optional": false + }, + { + "name": "label", + "type": 3, + "optional": false + }, + { + "name": "mode", + "type": 4, + "optional": false + }, + { + "name": "version", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "number" + }, + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "literal", + "value": "continuable" + }, + { + "kind": "literal", + "value": 0 + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 2, + "optional": false + }, + { + "name": "childId", + "type": 3, + "optional": false + }, + { + "name": "label", + "type": 3, + "optional": false + }, + { + "name": "mode", + "type": 4, + "optional": false + }, + { + "name": "version", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": 1 + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 2, + "optional": false + }, + { + "name": "childId", + "type": 3, + "optional": false + }, + { + "name": "label", + "type": 3, + "optional": true + }, + { + "name": "mode", + "type": 9, + "optional": false + }, + { + "name": "version", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "one-shot" + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 2, + "optional": false + }, + { + "name": "childId", + "type": 3, + "optional": false + }, + { + "name": "label", + "type": 3, + "optional": true + }, + { + "name": "mode", + "type": 9, + "optional": false + }, + { + "name": "version", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 2, + "optional": false + }, + { + "name": "childId", + "type": 3, + "optional": false + }, + { + "name": "label", + "type": 3, + "optional": true + }, + { + "name": "mode", + "type": 12, + "optional": false + }, + { + "name": "version", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "unknown" + } + ] + }, + "names": [ + "packages/subagent/subagent/src/catalog.ts#SubagentCatalogEvent" + ], + "sources": [ + "packages/subagent/subagent/src/catalog.ts:28" + ] + }, { "digest": "1df34a6c086866ffdc2dd9e8e5c581c3751a010cdf6b8538a7327f7a3237f0b4", "schema": { @@ -35234,15 +35559,49 @@ ] }, { - "digest": "3ac1196f59d5e9e0e9345122c8c403b102478b526404a2e591a38f613483321e", + "digest": "3abae7324356f155cb42450c00b806d134ec93bd6439d2063b8d724162d58604", "schema": { "root": 0, "nodes": [ + { + "kind": "object", + "properties": [ + { + "name": "data", + "type": 1, + "optional": false + }, + { + "name": "ignorable", + "type": 14, + "optional": true + }, + { + "name": "seq", + "type": 3, + "optional": false + }, + { + "name": "time", + "type": 3, + "optional": false + }, + { + "name": "type", + "type": 15, + "optional": false + } + ], + "indices": [] + }, { "kind": "union", "types": [ - 1, - 6 + 2, + 7, + 9, + 11, + 12 ] }, { @@ -35250,28 +35609,28 @@ "properties": [ { "name": "childCreatedAt", - "type": 2, + "type": 3, "optional": false }, { "name": "childId", - "type": 3, - "optional": false - }, - { - "name": "label", - "type": 3, - "optional": false - }, - { - "name": "mode", "type": 4, "optional": false }, { - "name": "version", + "name": "label", + "type": 4, + "optional": false + }, + { + "name": "mode", "type": 5, "optional": false + }, + { + "name": "version", + "type": 6, + "optional": false } ], "indices": [] @@ -35297,27 +35656,62 @@ "properties": [ { "name": "childCreatedAt", - "type": 2, + "type": 3, "optional": false }, { "name": "childId", - "type": 3, + "type": 4, "optional": false }, { "name": "label", - "type": 3, - "optional": true + "type": 4, + "optional": false }, { "name": "mode", - "type": 7, + "type": 5, "optional": false }, { "name": "version", - "type": 5, + "type": 8, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": 1 + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 3, + "optional": false + }, + { + "name": "childId", + "type": 4, + "optional": false + }, + { + "name": "label", + "type": 4, + "optional": true + }, + { + "name": "mode", + "type": 10, + "optional": false + }, + { + "name": "version", + "type": 6, "optional": false } ], @@ -35326,15 +35720,85 @@ { "kind": "literal", "value": "one-shot" + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 3, + "optional": false + }, + { + "name": "childId", + "type": 4, + "optional": false + }, + { + "name": "label", + "type": 4, + "optional": true + }, + { + "name": "mode", + "type": 10, + "optional": false + }, + { + "name": "version", + "type": 8, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 3, + "optional": false + }, + { + "name": "childId", + "type": 4, + "optional": false + }, + { + "name": "label", + "type": 4, + "optional": true + }, + { + "name": "mode", + "type": 13, + "optional": false + }, + { + "name": "version", + "type": 8, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "unknown" + }, + { + "kind": "literal", + "value": true + }, + { + "kind": "literal", + "value": "subagent/catalog" } ] }, - "names": [ - "packages/subagent/subagent/src/catalog.ts#SubagentCatalogEvent" - ], - "sources": [ - "packages/subagent/subagent/src/catalog.ts:24" - ] + "names": [], + "sources": [] }, { "digest": "3b04790e08b35241ffa0d38f6d7e4f8788aa921341f6c0d7574a329397c59408", @@ -68069,6 +68533,63 @@ "names": [], "sources": [] }, + { + "digest": "a0f25b5aeeebf2bf1542ce67726b095e3b139a39c71861d57e827f12dea1f499", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 1, + "optional": false + }, + { + "name": "childId", + "type": 2, + "optional": false + }, + { + "name": "label", + "type": 2, + "optional": true + }, + { + "name": "mode", + "type": 3, + "optional": false + }, + { + "name": "version", + "type": 4, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "number" + }, + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "literal", + "value": "unknown" + }, + { + "kind": "literal", + "value": 1 + } + ] + }, + "names": [], + "sources": [] + }, { "digest": "a0f906856fc06f1c6f2083eb970e5a657a82b1a84c06c095445e5d6d161cda80", "schema": { @@ -70165,144 +70686,6 @@ "names": [], "sources": [] }, - { - "digest": "ae1f7110feeec697b8cab42b68f7709aa7b3279764099dfb53c25890d2e5c871", - "schema": { - "root": 0, - "nodes": [ - { - "kind": "object", - "properties": [ - { - "name": "data", - "type": 1, - "optional": false - }, - { - "name": "ignorable", - "type": 9, - "optional": true - }, - { - "name": "seq", - "type": 3, - "optional": false - }, - { - "name": "time", - "type": 3, - "optional": false - }, - { - "name": "type", - "type": 10, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "union", - "types": [ - 2, - 7 - ] - }, - { - "kind": "object", - "properties": [ - { - "name": "childCreatedAt", - "type": 3, - "optional": false - }, - { - "name": "childId", - "type": 4, - "optional": false - }, - { - "name": "label", - "type": 4, - "optional": false - }, - { - "name": "mode", - "type": 5, - "optional": false - }, - { - "name": "version", - "type": 6, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "primitive", - "type": "number" - }, - { - "kind": "primitive", - "type": "string" - }, - { - "kind": "literal", - "value": "continuable" - }, - { - "kind": "literal", - "value": 0 - }, - { - "kind": "object", - "properties": [ - { - "name": "childCreatedAt", - "type": 3, - "optional": false - }, - { - "name": "childId", - "type": 4, - "optional": false - }, - { - "name": "label", - "type": 4, - "optional": true - }, - { - "name": "mode", - "type": 8, - "optional": false - }, - { - "name": "version", - "type": 6, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": "one-shot" - }, - { - "kind": "literal", - "value": true - }, - { - "kind": "literal", - "value": "subagent/catalog" - } - ] - }, - "names": [], - "sources": [] - }, { "digest": "ae63d9d1080ed8945031594d93f8d96651d7101b848719cc3e1475875cd3d649", "schema": { @@ -71795,6 +72178,63 @@ "names": [], "sources": [] }, + { + "digest": "b6896938660a1dfe4ddd100fb3164d9f8fa57feeabfe37441a80106c9942697a", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 1, + "optional": false + }, + { + "name": "childId", + "type": 2, + "optional": false + }, + { + "name": "label", + "type": 2, + "optional": true + }, + { + "name": "mode", + "type": 3, + "optional": false + }, + { + "name": "version", + "type": 4, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "number" + }, + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "literal", + "value": "one-shot" + }, + { + "kind": "literal", + "value": 1 + } + ] + }, + "names": [], + "sources": [] + }, { "digest": "b6ef9c6210be8350cd465f485cf520031fffa6bf3809c61a1e713e834c5982d0", "schema": { @@ -81359,6 +81799,63 @@ "packages/schedule/schedule/src/types.ts:105" ] }, + { + "digest": "e2ad8f5bcd813f268ffef1b10a12d7ddd2b8ced8b9b6d0275b3f6b7ed094a187", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "object", + "properties": [ + { + "name": "childCreatedAt", + "type": 1, + "optional": false + }, + { + "name": "childId", + "type": 2, + "optional": false + }, + { + "name": "label", + "type": 2, + "optional": false + }, + { + "name": "mode", + "type": 3, + "optional": false + }, + { + "name": "version", + "type": 4, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "number" + }, + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "literal", + "value": "continuable" + }, + { + "kind": "literal", + "value": 1 + } + ] + }, + "names": [], + "sources": [] + }, { "digest": "e2d0367e5716bc188435e51718ebff8ce873190625fa93b1c5331102d98758fa", "schema": { diff --git a/docs/subsystems/sidebar-right.i18n.yaml b/docs/subsystems/sidebar-right.i18n.yaml index a1164e5891..30b50d6a0a 100644 --- a/docs/subsystems/sidebar-right.i18n.yaml +++ b/docs/subsystems/sidebar-right.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/sidebar-right.md -sidebar-right.md: 9ad6bd63ed63c5c59dbe29cd1229a6412ad22de2 -sidebar-right.zh.md: d0a967f400ea80ab696e24e1b4f46d7d8664eff8 +sidebar-right.md: 27e5219aec8997cf0d168ac9ea2665baaf943906 +sidebar-right.zh.md: 63f2c1081009778e191789038ffd1923312fc55b diff --git a/docs/subsystems/sidebar-right.md b/docs/subsystems/sidebar-right.md index 9ad6bd63ed..27e5219aec 100644 --- a/docs/subsystems/sidebar-right.md +++ b/docs/subsystems/sidebar-right.md @@ -136,7 +136,7 @@ The Host `ctx.workspaceFiles` service and generated `workspaceFiles` Remote name - **`guide`** — `builtin`, opened as `openTab('guide')`. A muted compass sits above one capsule per contributed `guide` entry, in `order`; short lists show registered descriptions, and every missing icon uses the shipped placeholder. Picking a capsule opens the contributing type as a page in the guide tab's place. A pane holds at most one guide tab, and the strip's add control appears only while its pane has none. A new pane receives the registered default page: the sole guide entry directly, or the guide when the entry count is not one ([guide](../../packages/client/ui-sidebar-right/README.md#the-guide)). - **`text`** — `fallback`, `dsh-resource://file/**`, claiming Session addresses only. Document Preview observes metadata through `useResource<'file'>`, loads content through Remote callbacks, and owns renderer selection, the toolbar, per-tab refresh, scroll, and source navigation; unknown extensions render as plain text ([README](../../packages/client/ui-sidebar-documentpreview/README.md)). - **`files`** — `builtin`, opened as `openTab('files')`. The workspace directory tree, listed lazily through `list` and watched by expanded directory, opening a file with `tab.actions.openResource(fileAddressFor(sessionId, root, path))` into its own pane ([README](../../packages/client/ui-sidebar-files/README.md)). -- **`browser`** — multi-instance `builtin`, opened as `openTab('browser', { params: { url? } })`. Assistant Markdown delegates HTTP(S) links to this page type. It accepts public and loopback HTTP(S) targets under the default sandbox, rejects local files in favor of Document Preview, and uses application-known iframe history ([README](../../packages/client/ui-sidebar-browser/README.md)). +- **`browser`** — multi-instance `builtin`, opened as `openTab('browser', { params: { url? } })`. Chat's [link preference](../../packages/client/ui-chat/README.md) controls whether Assistant Markdown opens HTTP(S) links here. It accepts public and loopback HTTP(S) targets under the default sandbox, rejects local files in favor of Document Preview, and uses application-known iframe history ([README](../../packages/client/ui-sidebar-browser/README.md)). - **`subagentchat`** — `builtin`, `dsh-resource://subagentchat/session/?parent=&mode=`. An explicitly addressed subagent Conversation retained by the resource provider and rendered through the shared Conversation Factory ([README](../../packages/client/ui-subagent/README.md)). diff --git a/docs/subsystems/sidebar-right.zh.md b/docs/subsystems/sidebar-right.zh.md index d0a967f400..63f2c10810 100644 --- a/docs/subsystems/sidebar-right.zh.md +++ b/docs/subsystems/sidebar-right.zh.md @@ -136,7 +136,7 @@ Host 的 `ctx.workspaceFiles` 服务与生成的 `workspaceFiles` Remote 命名 - **`guide`**——`builtin`,以 `openTab('guide')` 打开。一枚弱化的罗盘位于各类型按 `order` 贡献的入口胶囊上方;入口较少时显示已注册的描述,未提供图标的入口统一使用内置占位符。点选胶囊即在引导 tab 的位置把贡献它的类型作为页面打开。每个 pane 最多一个引导 tab,tab 条的新增控件只在本 pane 没有引导时出现。新 pane 使用已注册的默认页:只有一个引导入口时直接使用该入口,否则使用引导页([引导](../../packages/client/ui-sidebar-right/README.zh.md#the-guide))。 - **`text`**——`fallback`,`dsh-resource://file/**`,只认领 Session 地址。Document Preview 通过 `useResource<'file'>` 观察元数据,经 Remote 回调加载内容,并拥有渲染器选择、工具栏、逐 tab 刷新、滚动与源码定位;未知扩展名按纯文本渲染([README](../../packages/client/ui-sidebar-documentpreview/README.zh.md))。 - **`files`**——`builtin`,以 `openTab('files')` 打开。工作区目录树,经 `list` 懒加载,按展开目录建立监听,用 `tab.actions.openResource(fileAddressFor(sessionId, root, path))` 在自己所在 pane 打开文件([README](../../packages/client/ui-sidebar-files/README.zh.md))。 -- **`browser`**——可多开的 `builtin`,以 `openTab('browser', { params: { url? } })` 打开。Assistant Markdown 会把 HTTP(S) 链接委托给该页面类型。它在默认 sandbox 下接受公共与 loopback HTTP(S) 目标,本地文件改用 Document Preview,并使用应用已知的 iframe history([README](../../packages/client/ui-sidebar-browser/README.zh.md))。 +- **`browser`**——可多开的 `builtin`,以 `openTab('browser', { params: { url? } })` 打开。Chat 的[链接偏好](../../packages/client/ui-chat/README.zh.md)决定 Assistant Markdown 是否在此打开 HTTP(S) 链接。它在默认 sandbox 下接受公共与 loopback HTTP(S) 目标,本地文件改用 Document Preview,并使用应用已知的 iframe history([README](../../packages/client/ui-sidebar-browser/README.zh.md))。 - **`subagentchat`**——`builtin`,`dsh-resource://subagentchat/session/?parent=&mode=`。资源提供方保留一个显式寻址的 subagent Conversation,并通过共享 Conversation Factory 渲染([README](../../packages/client/ui-subagent/README.zh.md))。 diff --git a/docs/subsystems/subagent.i18n.yaml b/docs/subsystems/subagent.i18n.yaml index 034d835426..805d66f003 100644 --- a/docs/subsystems/subagent.i18n.yaml +++ b/docs/subsystems/subagent.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/subagent.md -subagent.md: d642fe1658926b17cb29f280803f12e61228be60 -subagent.zh.md: ff153c7861b4ac46bb058efd00a2f5a50181b6f2 +subagent.md: 67f9bc31b46617ea2f7b1ec81bf8c52fd62720cb +subagent.zh.md: a69e032ed96a24a980badaf9e14d924311e1d175 diff --git a/docs/subsystems/subagent.md b/docs/subsystems/subagent.md index d642fe1658..67f9bc31b4 100644 --- a/docs/subsystems/subagent.md +++ b/docs/subsystems/subagent.md @@ -8,7 +8,7 @@ Service Definition: [dsh-subagent](../../packages/subagent/subagent) (`ctx.subag Sources: [`packages/subagent/subagent/src/types.ts`](../../packages/subagent/subagent/src/types.ts), [`packages/subagent/subagent/src/index.ts`](../../packages/subagent/subagent/src/index.ts), and [`packages/subagent/subagent/src/continuation.ts`](../../packages/subagent/subagent/src/continuation.ts) -The `subagentCatalog` projection exposes `SubagentCatalogEntry[]` in parent event order through Session observations and client snapshots. Each entry contains the child id, creation time, mode, and mode-dependent label; fork-inherited catalog facts are excluded. [The subagent package](../../packages/subagent/subagent/README.md) owns catalog creation and persistence semantics. +The `subagentCatalog` projection exposes `SubagentCatalogEntry[]` in parent event order through Session observations and client snapshots. Each entry contains the child id, creation time, mode, and mode-dependent label; fork-inherited catalog facts are excluded. [The subagent package](../../packages/subagent/subagent/README.md) owns catalog creation and persistence semantics. Historical children with unavailable descriptors have `mode: 'unknown'`; their header identity remains discoverable without granting continuation capabilities. ## Two kinds of capability, discovered two ways @@ -465,7 +465,7 @@ The spawn and fork backends create an ordinary one-shot agent through `parent.ct - **Delegation depth** is durable `SessionHeader.delegationDepth` plus the merge-extensible runtime field `AgentOptions.subagentDepth`; absence means top-level depth zero, and the greater present value is authoritative. The seam owns both fields — the loop neither sets nor reads them — so an in-process child persists parent depth + 1, cold resume cannot lower it, and every start rejects a derived depth outside the safe-integer domain or above a defined absolute `request.maxDepth` cap. - **Fork seeding** uses [`CreateAgentOptions.seed`](core.md#creation-and-ownership) (a `SessionEvent[]` prefix threaded through `AgentLoop.createAgent` → `ctx.sessions.prepare({ seed })`, the same primitive `ctx.agents.resume()` uses). The fork backend passes a *balanced completed-turn prefix* of the parent's log — the parent's events up to and including its last `turn/end` — so the seed is contiguous-from-0 and the [invariants](../../packages/runtime-diagnostics/invariants) replay accepts it (the in-flight, unbalanced turn is excluded). -`SubagentCatalogEntry` describes one successful direct-child creation fact; `SubagentCatalogState` is the host-only projection state. `listChildren()` owns a live-preferred parent observation without opening child logs. Browser consumers read `subagentCatalog` through the shared Session projection store and combine membership with Session-list activity. `SubagentCatalogRow` belongs to complete-descendant listing. [The parent-catalog decision](../../.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md) owns the persistent facts and read semantics. +`SubagentCatalogEntry` describes a direct child with complete or unknown-mode discovery information; `SubagentCatalogState` is the host-only projection state. `listChildren()` owns a live-preferred parent observation without opening child logs. Browser consumers read `subagentCatalog` through the shared Session projection store and combine membership with Session-list activity. `SubagentCatalogRow` belongs to complete-descendant listing. [The parent-catalog decision](../../.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md) owns the persistent facts and read semantics. diff --git a/docs/subsystems/subagent.zh.md b/docs/subsystems/subagent.zh.md index ff153c7861..a69e032ed9 100644 --- a/docs/subsystems/subagent.zh.md +++ b/docs/subsystems/subagent.zh.md @@ -8,7 +8,7 @@ Service Definition:[dsh-subagent](../../packages/subagent/subagent)(`ctx.sub 源码:[`packages/subagent/subagent/src/types.ts`](../../packages/subagent/subagent/src/types.ts)、[`packages/subagent/subagent/src/index.ts`](../../packages/subagent/subagent/src/index.ts)和 [`packages/subagent/subagent/src/continuation.ts`](../../packages/subagent/subagent/src/continuation.ts) -`subagentCatalog` projection 通过 Session 观察和客户端快照暴露按父会话事件排序的 `SubagentCatalogEntry[]`。每个条目包含子级 id、创建时间、模式和依模式确定的标签;fork 继承的目录事实不在其中。[subagent 包](../../packages/subagent/subagent/README.zh.md) 定义目录创建和持久化语义。 +`subagentCatalog` projection 通过 Session 观察和客户端快照暴露按父会话事件排序的 `SubagentCatalogEntry[]`。每个条目包含子级 id、创建时间、模式和依模式确定的标签;fork 继承的目录事实不在其中。[subagent 包](../../packages/subagent/subagent/README.zh.md) 定义目录创建和持久化语义。历史子会话的 descriptor 不可用时使用 `mode: 'unknown'`,保留 header 身份供发现,但不授予继续执行能力。 ## 两类能力,两种发现方式 @@ -469,7 +469,7 @@ spawn 和 fork 后端通过 `parent.ctx` 创建一个普通的单次 agent,将 - **委派深度**由持久 `SessionHeader.delegationDepth` 与可合并扩展的运行时字段 `AgentOptions.subagentDepth` 共同表示;缺失表示顶层深度为零,存在的较大值具有权威性。两个字段都归该 seam 所有——循环既不设置也不读取它们——因此进程内子 agent 会持久保存 parent 深度 + 1,冷恢复无法降低深度,而且每次 start 都会拒绝超出安全整数域、或高于已定义绝对 `request.maxDepth` 上限的派生深度。 - **Fork 种子注入**使用 [`CreateAgentOptions.seed`](core.zh.md#creation-and-ownership)(一个 `SessionEvent[]` 前缀,经由 `AgentLoop.createAgent` → `ctx.sessions.prepare({ seed })` 传递,与 `ctx.agents.resume()` 使用的原语相同)。fork 后端传入父级日志的一段*平衡的已完成轮次前缀*——父级事件直到并包括其最后一个 `turn/end`——因此种子从 0 连续,[invariants](../../packages/runtime-diagnostics/invariants) 回放可以接受它(进行中的、未平衡的轮次被排除在外)。 -`SubagentCatalogEntry` 描述一条成功创建直接子级的事实;`SubagentCatalogState` 是仅 host 使用的 projection state。`listChildren()` 拥有一次 live-preferred 父 Session observation,不打开子级日志。浏览器消费者通过共享 Session projection store 读取 `subagentCatalog`,并将成员关系与 Session 列表活动状态组合。`SubagentCatalogRow` 属于完整后代列表。[父目录决策](../../.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md) 规定持久事实与读取语义。 +`SubagentCatalogEntry` 描述一条完整或未知模式的直接子级目录记录;`SubagentCatalogState` 是仅 host 使用的 projection state。`listChildren()` 拥有一次 live-preferred 父 Session observation,不打开子级日志。浏览器消费者通过共享 Session projection store 读取 `subagentCatalog`,并将成员关系与 Session 列表活动状态组合。`SubagentCatalogRow` 属于完整后代列表。[父目录决策](../../.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md) 规定持久事实与读取语义。 diff --git a/package.json b/package.json index 4d36479376..eb4b2b92c9 100644 --- a/package.json +++ b/package.json @@ -196,6 +196,7 @@ "demo:ptc": "node scripts/demo-ptc.mjs", "demo:inspector": "node --import tsx/esm apps/cli/src/bin.ts web --patch ./packages/experimental/inspector/cordis.source.patch.yml", "mock:llm": "node --import tsx packages/test-support/llm-mock-server/src/bin.ts", + "start:web": "node --import tsx/esm apps/cli/src/bin.ts web", "dev:web": "tsx scripts/dev-web.ts --poll", "postinstall": "node scripts/install-lefthook.mjs" }, diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index f0c39d8dbb..e63c0b2a46 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/api/session-controller/README.md -README.md: c9f63d462c6e291abda7312c66c8fffcd7109c57 -README.zh.md: f6d03b45b6bf9d3e809e70154adacd59ce341721 +README.md: 49f38e5af8f0939dd8f1ccafcd7928b10954ca33 +README.zh.md: 57112fc5ce88567ecfe415ed7ee4dd5ac9d34224 diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index c9f63d462c..49f38e5af8 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -39,6 +39,8 @@ Client list refreshes retain unchanged row objects and reuse the items array whe Projection reads retain loading and failure state independently of their values. Reconnect cancels reads from the previous connection and reloads previously requested projections; live membership updates arrive through the control stream. Parent availability comes from Host summaries and remains unknown until a summary or a successful list baseline establishes it. Address lookup resolves projected children without selecting them or creating scopes. +Accepted prompts and observed running keep their Client display conversion across list refresh, reconnect, and Session-object replacement. The Manager retains these observations by Session id; they do not establish durable history. Drafts, projection stores, and sidebar display rules retain their existing behavior. The [blank rollback decision](../../../.agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.md) defines retention and late-response handling. + The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, `append`, and `settle-assistant` changes, and repairs reconnect or sequence gaps through a tail page. Backwards paging has two verbs: `loadOlder()` pulls one 50-message page, and `loadThrough(seq)` — the turn-jump loader — loops 200-message pages until the window covers the target seq, lowering a shared target on repeated calls, stopping on a page that makes no progress, and reporting busy through the same `loadingOlder` snapshot bit. The Web adapter explicitly opts into cursorless Assistant frames: each opening carries the active attempt's `startedAfterSeq`, `nextIndex`, and compact stream, and every stream member becomes a Client-only `assistant/live-chunk` entry ordered between durable cursors. The Host captures a follower-local arrival ordinal with that baseline and suppresses buffered frames at or before the cut; a replacement Agent may restart frame revision at one. A durable `assistant/message` or `assistant/attempt` arriving after an active opening stays staged only when its seq follows `startedAfterSeq` and its Turn and Step match; the matching end type, seq, and index publishes one named settlement delta that retires the attempt's transient rows and adds the durable entry while earlier same-step retries remain visible. Revision, dense-index, or settlement gaps for a known attempt reopen follow, while a controller that missed the start ignores unknown-attempt frames and publishes their durable settlement normally. An abandoned end publishes a settlement delta without a durable entry so its transient rows retire immediately. A durable gap-repair page has no Assistant baseline, so its held notification reopens follow once for a paired page and baseline. Every history record covers exactly its event seq. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces jobs and projection state instead of treating transient values as durable events. At each ready Host generation, a synchronous Client subscription clears retained projection values and watermarks before refreshing queries and restarting the control stream, including Sessions absent from the control baseline. The first control stream waits for generation readiness, so its opening values cannot precede invalidation. Outstanding list responses from the previous generation cannot republish those values. Within a generation, a delayed control baseline cannot overwrite or clear newer sequenced values, whether they arrived through a live Session's list block, a history page, or a frame; a cached list block, viewed from the projection cache for a cold Session, yields to the connected Session's baseline whatever watermark it carries. The durable `inbox` projection carries both pending lists through the same cold-read and reconnect path as other projections. Client Agent contexts provide the identity used by the independent [`fileUpload`](../../client/file-upload/README.md) service; Session objects expose lifecycle, prompt, queue, and history operations rather than file transfer. The Session object also carries local submission echoes: `session.beginSubmission` inserts one into `SessionSnapshot.pendingSubmissions` synchronously, before the caller serializes and prompts, so a conversation UI can show the message on the submit click's own frame. The echo stores ordered image previews and durable file references. Session derives its `transcript`, `queued`, or `steering` placement from the current running state and requested delivery mode, then retains that placement while serialization is in flight. The prompt's `requestId` is the correlation identity: the Host echoes it as the durable user source's `rpcId`, including pending messages in the `inbox` projection. An echo retires one animation frame after its durable event or queue occurrence is observed, immediately when its identified prompt fails or is abandoned, and as failed on disposal. Each retirement fires `onRetire` exactly once; an observed retirement includes the ordered durable attachment references so the composer can release successful cards while preserving failed drafts. Echoes are Client memory only; reload and reconnect rebuild the conversation from durable events alone. @@ -61,7 +63,7 @@ Attachment authorization reads declared content fields of built-in Session event `sessions.retain(target, { source, signal? })` immediately acquires one exact Client generation and starts its shared initial history opening. The target is a known Session id or a durable direct-parent subagent address; the Host validates an explicit address when history opens. The returned reference supports idempotent `release()` and `Symbol.dispose`; its `ready` Promise follows the shared `Session.open()` result and resolves to the exact binding when that attempt settles, including when a Remote failure is represented by `openState: 'error'`. It rejects when `Session.open()` rejects, its waiter is cancelled, or the reference is released early. Cancelling one waiter does not cancel another owner's opening. `sessions.using(target, options, operation)` waits for that settlement, holds its reference until the callback settles, and propagates rejected readiness and callback failures. -References keep local Session data, scoped Contexts, and history streams alive, not Host Agents. Ordinary Session retention never adds a catalog row. Retained subagents with a known direct-parent address keep a fallback row and notify list readers even before the parent catalog arrives; these rows do not join Host-list membership. Fork-title assignment sends rename directly and applies the returned title projection without retaining the child or opening its history. Final release withdraws the generation before teardown; later acquisition can create a new generation with the same id. `binding(id)` and `scope(id)` only borrow an existing generation. `retainInfo(id)` observes stable read-only source counts independently of catalog membership and performs no history I/O. Consumer source keys are declaration-merge extensible; navigation and completion acknowledgement belong to UI consumers, not this Controller. See [Client Session references](../../../.agents/notes/implemented/architecture/2026-09-15-client-session-references.md) for ownership and teardown rules. +References keep local Session data, scoped Contexts, and history streams alive, not Host Agents. Ordinary Session retention never adds a catalog row. Retained subagents with a known direct-parent address keep a fallback row and notify list readers even before the parent catalog arrives; these rows do not join Host-list membership. Fork-title assignment sends rename directly and applies the returned title projection without retaining the child or opening its history. Final release withdraws the generation before teardown; later acquisition can create a new generation with the same id. `binding(id)` and `scope(id)` only borrow an existing generation. `retainInfo(id)` observes stable read-only source counts independently of catalog membership and performs no history I/O. Consumer source keys are declaration-merge extensible; navigation and completion acknowledgement belong to UI consumers, not this Controller. See [Client Session references](../../../.agents/notes/implemented/architecture/2026-09-15-client-session-references.md) for ownership and teardown rules. An unknown-mode subagent address permits history reads with direct-parent validation. Successful descriptor restoration determines the displayed mode; failures remain local to the child, and control requests still require a confirmed continuable identity. ## Session media references @@ -99,6 +101,8 @@ No direct effect; model requests remain owned by the Agent and LLM packages. - A failed follow resumption remains visible to the caller instead of retrying indefinitely. - The raw browser upload is one streaming HTTP request without resumable offsets; a retry sends the file again from byte zero. - File-reference completion uses the shared Agent lookup and can resume a cold Session; the `skills/list` catalog is the non-activating alternative for skill metadata. +- Accepted/running display memory lives only in Client memory and is lost on page reload. +- That memory is not shared between tabs: one tab can show a converted row while another still shows `New Session` for the same Session. diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index f6d03b45b6..57112fc5ce 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -39,6 +39,8 @@ Client 列表刷新保留未变化的行对象,并在顺序和值均相同时 投影读取独立于其值保留加载与失败状态。重连会取消上一连接的读取,并重新加载已请求的投影;实时成员更新通过 control stream 到达。父 Agent 可用性来自 Host 摘要,在收到对应摘要或成功的列表 baseline 前保持未知。地址查找直接解析投影中的子项,不会选择它们或创建 scope。 +已受理的提示词与已观察到的运行,会使客户端展示转换跨列表刷新、重连及 Session 对象替换保留。Manager 按 Session id 保留这些观察;它们不证明持久历史已经产生。草稿、投影存储和侧边栏展示规则保留原有行为。[blank 回退修复决策](../../../.agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.zh.md) 定义保留期限及迟到响应处理。 + Client 适配器提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend`、`append` 与 `settle-assistant` 变更,并通过 tail page 修复重连或 seq 缺口。向后分页有两个动词:`loadOlder()` 拉一页 50 条消息,而 `loadThrough(seq)`——轮次跳转加载器——按每页 200 条消息循环拉取直到窗口覆盖目标 seq,重复调用会下调共享目标,遇到无进展的页即停止,忙碌状态复用同一个 `loadingOlder` 快照位。Web 适配器显式选择接收无 cursor 的 Assistant frame:每个 opening 携带活跃 attempt 的 `startedAfterSeq`、`nextIndex` 与紧凑 stream,每个 stream member 都成为排在持久 cursor 之间的 Client-only `assistant/live-chunk` 条目。Host 会随该 baseline 捕获 follower 本地到达序号,并抑制该 cut 及之前的 buffered frame;replacement Agent 可以从 revision 一重新开始。活跃 opening 之后到达的持久 `assistant/message` 或 `assistant/attempt` 只有在其 seq 晚于 `startedAfterSeq` 且轮次与步骤匹配时才会保持暂存;匹配的 end type、seq 与 index 会发布一个具名 settlement delta,删除该 attempt 的瞬态 row、加入持久条目,并保留同一步骤中更早的 retry。已知 attempt 的 revision、密集 index 或 settlement 缺口会重新打开 follow;若 controller 错过 start,则忽略 unknown-attempt frame,并正常发布其持久 settlement。Abandoned end 会发布不含持久条目的 settlement delta,使瞬态 row 立即退出。持久缺口修复 page 不携带 Assistant baseline,因此 held notification 会重新打开 follow 一次,以取得配对的 page 与 baseline。每条历史 record 只覆盖自身的事件 seq。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 jobs 和 projection 状态,而不会把瞬态值当作 durable event。每次 Host generation 就绪时,同步的 Client 订阅会先清除保留的投影值及其水位,再刷新查询并重新打开 control stream,其中也包括 control baseline 中没有列出的 Session。首次 control stream 会等待 generation 就绪,确保其 opening 值不会先于旧状态清理到达。上一代尚未完成的 list 响应无法重新发布这些值。同一 generation 内,延迟到达的 control baseline 不能覆盖或清除较新的 sequenced 值,无论它们来自活 Session 的 list block、history page 还是 frame;冷 Session 从 projection cache 看出来的 cached list block 则不论水位都让位于建连 Session 的 baseline。持久 `inbox` 投影通过与其他投影相同的冷读取和重连路径传输两份待处理列表。Client Agent 上下文提供独立 [`fileUpload`](../../client/file-upload/README.zh.md) 服务使用的身份;Session 对象提供生命周期、prompt、queue 与历史操作,不提供文件传输。 Session 对象还承载本地提交回显:`session.beginSubmission` 在调用方序列化与提示词之前,同步把一条回显写入 `SessionSnapshot.pendingSubmissions`,会话 UI 因此能在点击提交的当帧显示消息。回显按顺序存放图片预览与持久文件引用。Session 根据当前运行状态与请求的投递模式推导其 `transcript`、`queued` 或 `steering` 位置,并在序列化期间保留该位置。提示词的 `requestId` 是关联标识:Host 把它回显为 durable user source 的 `rpcId`,`inbox` 投影中的待处理消息也保留同一 source。回显在观察到其 durable event 或 queue occurrence 后延迟一个动画帧退休,带标识的提示词失败或被放弃时立即退休,销毁时按 failed 退休。每次退休恰好触发一次 `onRetire`;observed 退休还会携带有序的持久附件引用,让 composer 释放成功卡片并保留失败草稿。回显只存在于 Client 内存;刷新与重连只从持久事件重建会话。 @@ -61,7 +63,7 @@ Fork 复制 `atSeq` 所选的精确事件前缀,包含切点事件,允许在 `sessions.retain(target, { source, signal? })` 立即获取一个精确 Client generation 的引用,并启动其共享的首次历史打开。目标是已知 Session id 或持久的直接父子 subagent 地址;Host 在打开历史时校验显式地址。返回引用支持幂等的 `release()` 和 `Symbol.dispose`;其 `ready` Promise 跟随共享的 `Session.open()` 结果,并在该次尝试结算时解析为确切 binding,包括 Remote failure 以 `openState: 'error'` 表示的情况。仅当 `Session.open()` 拒绝、等待方取消或引用提前释放时,`ready` 才拒绝。取消一个等待方不会取消其他 owner 的打开。`sessions.using(target, options, operation)` 等待该次结算,持有引用直到回调结束,并传播被拒绝的就绪与回调失败。 -引用保活本地会话数据、作用域 Context 和历史流,不保活 Host Agent。普通 Session 不因保留引用而添加目录行。已保留且具有明确直接父子地址的 subagent 即使尚未收到父目录,也会保留兜底行并通知列表读取方;这些行不加入 Host 列表成员集合。Fork 标题设置直接发送 rename 并应用返回的标题投影,不 retain 子会话,也不打开其历史。最后一个引用释放时,generation 先退出可访问映射,再执行清理;后续获取可以为同一 id 创建新 generation。`binding(id)` 和 `scope(id)` 只借用已有 generation。`retainInfo(id)` 独立于目录成员关系观察稳定的只读来源计数,不执行历史 I/O。消费方来源键可通过声明合并扩展;导航和完成确认属于 UI 消费方,不属于本控制器。所有权与清理规则见 [Client 会话引用](../../../.agents/notes/implemented/architecture/2026-09-15-client-session-references.zh.md)。 +引用保活本地会话数据、作用域 Context 和历史流,不保活 Host Agent。普通 Session 不因保留引用而添加目录行。已保留且具有明确直接父子地址的 subagent 即使尚未收到父目录,也会保留兜底行并通知列表读取方;这些行不加入 Host 列表成员集合。Fork 标题设置直接发送 rename 并应用返回的标题投影,不 retain 子会话,也不打开其历史。最后一个引用释放时,generation 先退出可访问映射,再执行清理;后续获取可以为同一 id 创建新 generation。`binding(id)` 和 `scope(id)` 只借用已有 generation。`retainInfo(id)` 独立于目录成员关系观察稳定的只读来源计数,不执行历史 I/O。消费方来源键可通过声明合并扩展;导航和完成确认属于 UI 消费方,不属于本控制器。所有权与清理规则见 [Client 会话引用](../../../.agents/notes/implemented/architecture/2026-09-15-client-session-references.zh.md)。 模式未知的子代理地址允许读取历史,但仍校验直接父级。成功恢复 descriptor 后确定展示模式;失败只影响该子会话,控制请求仍要求已确认的 continuable 身份。 ## 会话媒体引用 @@ -99,6 +101,8 @@ Fork 复制 `atSeq` 所选的精确事件前缀,包含切点事件,允许在 - follow 恢复失败会对调用方可见,而不会无限重试。 - 浏览器原始字节上传使用一次不带断点续传偏移的流式 HTTP 请求;重试会从第零字节重新传输整个文件。 - 文件引用补全使用共享 Agent lookup,因此可能恢复冷 Session;`skills/list` 目录是不激活 Agent 的 skill 元数据读取路径。 +- 受理/运行的展示记忆只存在于客户端内存,页面重载后即丢失。 +- 该记忆不在 tab 之间共享:同一会话可能在一个 tab 中已转正,在另一个 tab 中仍显示为 `New Session`。 diff --git a/packages/api/session-controller/src/client/sessions/lineage.ts b/packages/api/session-controller/src/client/sessions/lineage.ts index 56d06d3e43..b0b49311f3 100644 --- a/packages/api/session-controller/src/client/sessions/lineage.ts +++ b/packages/api/session-controller/src/client/sessions/lineage.ts @@ -19,7 +19,10 @@ export interface SessionListEntry { title?: string updatedAt: number running: boolean - /** Summary blankness reconciled with `sessionListMetadata`; lists hide blank sessions (filtering stays with the consumer). */ + /** + * New Session presentation and reuse eligibility, reconciled with + * `sessionListMetadata`; lists hide blank sessions (filtering stays with the consumer). + */ blank: boolean parentSessionId?: SessionId /** Coarse durable origin for navigation filtering; not a continuation capability. */ diff --git a/packages/api/session-controller/src/client/sessions/manager.ts b/packages/api/session-controller/src/client/sessions/manager.ts index d3564d763b..9744bf3c75 100644 --- a/packages/api/session-controller/src/client/sessions/manager.ts +++ b/packages/api/session-controller/src/client/sessions/manager.ts @@ -87,6 +87,15 @@ export class SessionManager { private readonly sessions = new Map() /** In-flight Session disposals remain here after instances leave `sessions`, so manager disposal can await quiescence. */ private readonly sessionDisposals = new Set>() + /** + * Accepted/running presentation must survive a later empty-history list + * response. Host-asserted running is recorded even before a row, instance, or + * address holds the identity — the listing that would hold it may not have + * landed yet — while the client-local acceptance callback requires a current + * holder, because it can arrive from a replaced or already-dropped Session. + */ + private readonly engagedSessions = new Set() + private disposed = false /** Per-session projection value stores, retained independently of instance arrival (the * title-snapshot precedent, generalized): push frames land here whether or not the Session * is instantiated (list rows read the 'title' key), and an instantiated Session adopts the @@ -184,6 +193,7 @@ export class SessionManager { if (session !== expected) return Promise.resolve() this.sessions.delete(sessionId) this.addresses.delete(sessionId) + this.pruneEngagement(sessionId, this.retainedIds(this.summaries)) return this.startSessionDisposal(session) } @@ -192,6 +202,10 @@ export class SessionManager { * @returns once catalog requests and every Session stream have stopped. */ async dispose(): Promise { + this.disposed = true + this.listMutations = null + this.listInflight = null + this.engagedSessions.clear() const reads = [...this.projectionInflight.values()] for (const { controller } of reads) controller.abort() this.projectionInflight.clear() @@ -235,7 +249,7 @@ export class SessionManager { // instance (consistency when the list precedes open). const summary = this.summaries.find(s => s.sessionId === sessionId) if (summary !== undefined) { - session.handleBlank(summary.blank) + session.handleBlank(this.effectiveBlank(summary)) session.handleRunning(summary.running) } else { const address = this.addresses.get(sessionId) @@ -268,12 +282,44 @@ export class SessionManager { // The sender's local first-send flip mirrors into the list row so the // session surfaces (lists filter on blank) before any host frame lands. onEngaged: (engaged) => { - this.recordMutation({ kind: 'engaged', sessionId: engaged.sessionId }) + if (this.disposed || !this.retainedIds(this.summaries).has(engaged.sessionId)) return + // An identity already on the ledger needs no second row mutation. + if (!this.engagedSessions.has(engaged.sessionId)) { + this.engagedSessions.add(engaged.sessionId) + this.recordMutation({ kind: 'engaged', sessionId: engaged.sessionId }) + } + this.sessions.get(engaged.sessionId)?.handleBlank(false) }, projections: this.projectionStore(sessionId), }) } + private effectiveBlank(summary: SessionSummary): boolean { + return summary.blank && !this.engagedSessions.has(summary.sessionId) + } + + /** + * Identities an engagement may still belong to: the given list rows, resident + * Session instances, and retained child addresses. + * @param summaries - list rows of the caller's snapshot. + * @returns the retained identity set. + */ + private retainedIds(summaries: readonly SessionSummary[]): Set { + const retained = new Set(summaries.map(summary => summary.sessionId)) + for (const sessionId of this.sessions.keys()) retained.add(sessionId) + for (const sessionId of this.addresses.keys()) retained.add(sessionId) + return retained + } + + /** + * Forget one engagement that no retained identity holds. + * @param sessionId - identity whose engagement may be dropped. + * @param retained - identities from {@link retainedIds} for the caller's snapshot. + */ + private pruneEngagement(sessionId: SessionId, retained: ReadonlySet): void { + if (!retained.has(sessionId)) this.engagedSessions.delete(sessionId) + } + /** Resident per-session projection store (create-on-demand; outlives instantiation). */ private projectionStore(sessionId: SessionId): ProjectionValueStore { let store = this.projectionStores.get(sessionId) @@ -367,15 +413,27 @@ export class SessionManager { const baseline: SessionSummary[] = this.listPhase === 'pending' ? [...result.value.items] : mergeOrderedBaseline(established, result.value.items, summary => summary.sessionId) - this.summaries = mutations.reduce(applyMutation, baseline) + // A removal supersedes running observed in the pull, including after re-addition. + const removedSincePull = new Set() + for (const mutation of mutations) { + if (mutation.kind === 'remove') removedSincePull.add(mutation.sessionId) + } + for (const s of baseline) { + if (s.running && !removedSincePull.has(s.sessionId)) this.engagedSessions.add(s.sessionId) + } + const summaries = mutations.reduce(applyMutation, baseline) + this.summaries = summaries + // A full list can remove identities without a removal frame. + const retained = this.retainedIds(summaries) + for (const sessionId of this.engagedSessions) this.pruneEngagement(sessionId, retained) this.listState = 'idle' this.listPhase = 'ready' this.updateParentAvailability() - // Sessions reconcile list blank hints with their current metadata projection. + // Resident Sessions and list rows share one display blank, reconciled with the metadata projection. for (const s of this.summaries) { const session = this.sessions.get(s.sessionId) if (session === undefined) continue - session.handleBlank(s.blank) + session.handleBlank(this.effectiveBlank(s)) session.handleRunning(s.running) } // Land each row's projection block in the per-session value store @@ -524,6 +582,7 @@ export class SessionManager { /** Apply immediately and retain for replay when a list response is in flight. */ private recordMutation(mutation: SessionListMutation): void { + if (this.disposed) return this.listMutations?.push(mutation) this.summaries = applyMutation(this.summaries, mutation) this.notifier.markDirty() @@ -599,7 +658,8 @@ export class SessionManager { */ handleSessionAdded(summary: SessionSummary): void { this.mergeSummary(summary) - this.sessions.get(summary.sessionId)?.handleBlank(summary.blank) + if (!this.disposed && summary.running) this.engagedSessions.add(summary.sessionId) + this.sessions.get(summary.sessionId)?.handleBlank(this.effectiveBlank(summary)) if (summary.projections !== undefined) this.applyListBlock(summary.sessionId, summary.projections) } @@ -644,6 +704,7 @@ export class SessionManager { if (!durableSubagent && (catalog === undefined || catalog.length === 0)) { this.projectionStores.delete(sessionId) } + this.pruneEngagement(sessionId, this.retainedIds(this.summaries)) this.projectionInflight.get(sessionId)?.controller.abort() this.projectionInflight.delete(sessionId) this.projectionLoads.delete(sessionId) @@ -660,6 +721,7 @@ export class SessionManager { * @param running - current Agent running state. */ handleSessionStatus(sessionId: SessionId, running: boolean): void { + if (!this.disposed && running) this.engagedSessions.add(sessionId) this.recordMutation({ kind: 'status', sessionId, running, agentAvailable: true }) this.updateParentAvailability() this.sessions.get(sessionId)?.handleRunning(running) @@ -716,7 +778,7 @@ export class SessionManager { return { ...summary, // Cached list hints can precede a history opening or control update. - blank: summary.blank && metadata?.blank !== false, + blank: this.effectiveBlank(summary) && metadata?.blank !== false, updatedAt: Math.max(summary.updatedAt, metadata?.lastPromptAt ?? 0), ...(typeof title === 'string' && title !== '' ? { title } : {}), ...(projectionValues === undefined ? {} : { projectionValues }), @@ -786,8 +848,7 @@ function applyMutation(summaries: readonly SessionSummary[], mutation: SessionLi case 'remove': return summaries.filter(summary => summary.sessionId !== mutation.sessionId) case 'status': - // running:true doubles as the cross-client blank flip (a blank session - // never runs, so the first running frame proves a message landed). + // Running converts display state; it does not establish durable history. return summaries.map(summary => summary.sessionId === mutation.sessionId && (summary.running !== mutation.running || summary.agentAvailable !== mutation.agentAvailable || (mutation.running && summary.blank)) diff --git a/packages/api/session-controller/src/client/sessions/service.ts b/packages/api/session-controller/src/client/sessions/service.ts index 28b22c796a..2d5cea16f0 100644 --- a/packages/api/session-controller/src/client/sessions/service.ts +++ b/packages/api/session-controller/src/client/sessions/service.ts @@ -39,7 +39,8 @@ export interface SessionSummary { /** Local ownership counts; Host metadata refreshes cannot overwrite them. */ readonly retainedBy: SessionRetainInfo['retainedBy'] /** - * Blankness from the Host summary reconciled with `sessionListMetadata`. + * New Session presentation and reuse eligibility, derived from the Host + * summary, `sessionListMetadata`, and client acceptance/running observations. * New Session reuses a blank one targeting the same workspace. Filtering * stays with the consumer: the store carries every row, while the Workspace * browser shows only the selected blank entry. diff --git a/packages/api/session-controller/src/client/sessions/session.ts b/packages/api/session-controller/src/client/sessions/session.ts index fb1eb7a01d..ec880192f0 100644 --- a/packages/api/session-controller/src/client/sessions/session.ts +++ b/packages/api/session-controller/src/client/sessions/session.ts @@ -66,12 +66,9 @@ export interface SessionOptions { /** Whether the exact direct parent Agent is available in Host summaries; absent until known. */ parentAvailable?: boolean /** - * First ACCEPTED prompt on a blank session (fires at most once, on the - * prompt RPC's success response): the manager mirrors the blank→false flip - * into its list row so the session surfaces without waiting for a host - * frame. Acceptance is the flip point because it proves the user message - * is in the host log; a rejected first prompt keeps the session blank - * (hidden, still reusable by connectWorkspace). + * Publish each accepted prompt to the Manager, including after this Session + * object is replaced. Acceptance converts display state, but does not + * establish that a turn started or reached durable history. */ onEngaged?(session: Session): void /** @@ -117,7 +114,7 @@ export class Session implements SessionFace { private promptAttempted = false /** A first accepted prompt stays in the engaging phase until its turn is observable. */ private firstPromptPendingTurn = false - /** Empty-log mirror (see ConversationSnapshot.blank); unknown bare sessions begin conservatively blank. */ + /** New Session display state; unknown bare sessions begin conservatively blank. */ private blankBit = true private removed = false private promptError: PromptError | null = null @@ -291,19 +288,12 @@ export class Session implements SessionFace { this.notifier.markDirty() return result } - // Blank flips on ACCEPTANCE, not attempt: an accepted prompt starts the - // conversation's first turn on the host (the host criterion — a logged - // turn/start — is fact, not optimism; standalone command and projection - // events never flip it), while a rejected first prompt must keep the - // session blank — the client-side blank mirror only ever lowers, so - // flipping early on a failure would surface the session forever and - // strip its connectWorkspace reuse eligibility against the host's - // authority. + // Rejection must leave a first prompt blank and eligible for workspace reuse. if (this.blankBit) { this.blankBit = false - this.options.onEngaged?.(this) this.notifier.markDirty() } + this.options.onEngaged?.(this) return result } @@ -514,8 +504,7 @@ export class Session implements SessionFace { * @param running - the new running state. */ handleRunning(running: boolean): void { - // Turn-start conversion: a blank session never runs, so the first - // running:true proves another side's first message landed. + // Running converts display state without establishing durable turn history. if (running && this.blankBit) { this.blankBit = false this.notifier.markDirty() @@ -553,9 +542,12 @@ export class Session implements SessionFace { } /** - * Relay list blankness without overriding a started conversation established - * by the current projection, a local prompt, or running state. - * @param blank - whether the list or projection reports an unstarted conversation. + * Apply the Manager's effective display blank, further reconciled with the + * current `sessionListMetadata` projection. Local send attempts and current + * running state prevent re-blanking; an earlier false summary alone does not. + * The Manager retains acceptance and earlier running observations across + * Session-object replacement. + * @param blank - New Session display state after Manager reconciliation. */ handleBlank(blank: boolean): void { blank = blank && this.projections.values().sessionListMetadata?.blank !== false @@ -813,6 +805,7 @@ export class Session implements SessionFace { } private buildSnapshot(): SessionSnapshot { + const identity = this.projections.values().subagent return { sessionId: this.sessionId, pendingSubmissions: this.pendingSubmissions, @@ -820,7 +813,9 @@ export class Session implements SessionFace { subagent: this.address === undefined ? null : { - address: this.address, + address: this.address.mode === 'unknown' && identity != null + ? { ...this.address, mode: identity.mode } + : this.address, ...(this.parentAvailable === undefined ? {} : { parentAvailable: this.parentAvailable }), }, removed: this.removed, diff --git a/packages/api/session-controller/src/history.ts b/packages/api/session-controller/src/history.ts index a22badc1f1..069ee3bf32 100644 --- a/packages/api/session-controller/src/history.ts +++ b/packages/api/session-controller/src/history.ts @@ -364,7 +364,7 @@ function validateAddress( reason: 'unsupported', }) } - if (identity.mode !== address.mode) { + if (address.mode !== 'unknown' && identity.mode !== address.mode) { throw new RemoteError('subagent/unauthorized', 'subagent mode does not match the supplied address', { childSessionId: address.childSessionId, }) diff --git a/packages/api/session-controller/src/types.ts b/packages/api/session-controller/src/types.ts index 37294a6dbb..728df9e55b 100644 --- a/packages/api/session-controller/src/types.ts +++ b/packages/api/session-controller/src/types.ts @@ -407,7 +407,7 @@ export type SessionAddress = readonly kind: 'subagent' readonly parentSessionId: SessionId readonly childSessionId: SessionId - readonly mode: 'one-shot' | 'continuable' + readonly mode: 'one-shot' | 'continuable' | 'unknown' } /** One non-activating Session projection read. */ diff --git a/packages/api/session-controller/tests/manager.client.spec.ts b/packages/api/session-controller/tests/manager.client.spec.ts index b41ee673c8..55d182110a 100644 --- a/packages/api/session-controller/tests/manager.client.spec.ts +++ b/packages/api/session-controller/tests/manager.client.spec.ts @@ -3,8 +3,9 @@ * frame routing, and control baselines for uninstantiated sessions. */ -import { describe, expect, vi } from 'vitest' +import { describe, expect, onTestFinished, vi } from 'vitest' import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client' +import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client' import { SessionSeq } from '@deepseek-ai/dsh-session/types' import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' import type { SessionControlFrame } from '@deepseek-ai/dsh-api-session-controller/types' @@ -48,6 +49,318 @@ function makeManager( } describe('SessionManager instances', () => { + it.for(['event', 'list'] as const)( + 'forgets running when an unretained identity disappears through a %s', + async (source, { mock, remote }) => { + const manager = makeManager(mock, remote) + onTestFinished(() => manager.dispose()) + remote.session.list.mockResolvedValueOnce(ok({ items: [summary(S1, { blank: true })] })) + await manager.refreshList() + manager.handleSessionStatus(S1, true) + manager.handleSessionStatus(S1, false) + expect(manager.getListSnapshot().items[0]?.blank).toBe(false) + + if (source === 'event') manager.handleSessionRemoved(S1) + else { + remote.session.list.mockResolvedValueOnce(ok({ items: [] })) + await manager.refreshList() + } + expect(manager.getListSnapshot().items).toEqual([]) + manager.handleSessionAdded(summary(S1, { blank: true })) + + expect(manager.getListSnapshot().items[0]?.blank).toBe(true) + }, + ) + + it('retains acceptance while an unlisted Session remains resident, then forgets it on drop', async ({ mock, remote }) => { + const manager = makeManager(mock, remote) + onTestFinished(() => manager.dispose()) + manager.handleSessionAdded(summary(S1, { blank: true })) + const session = manager.get(S1) + await session.prompt([{ type: 'text', text: 'first' }], 'queue') + remote.session.list.mockResolvedValue(ok({ items: [] })) + + await manager.refreshList() + expect(manager.getListSnapshot().items).toEqual([]) + expect(session.getSnapshot().blank).toBe(false) + manager.handleSessionAdded(summary(S1, { blank: true })) + expect(manager.getListSnapshot().items[0]?.blank).toBe(false) + + await manager.refreshList() + await manager.drop(S1, session) + manager.handleSessionAdded(summary(S1, { blank: true })) + expect(manager.getListSnapshot().items[0]?.blank).toBe(true) + }) + + it('retains running for an unlisted child with only a retained address', async ({ mock, remote }) => { + const address: SubagentAddress = { parentSessionId: S1, childSessionId: S2, mode: 'continuable' } + const manager = makeManager(mock, remote) + onTestFinished(() => manager.dispose()) + manager.resolveTarget(address) + manager.handleSessionAdded(summary(S2, { blank: true })) + manager.handleSessionStatus(S2, true) + manager.handleSessionStatus(S2, false) + remote.session.list.mockResolvedValueOnce(ok({ items: [] })) + + await manager.refreshList() + expect(manager.getListSnapshot().items).toEqual([]) + manager.handleSessionAdded(summary(S2, { blank: true })) + + expect(manager.getListSnapshot().items[0]?.blank).toBe(false) + }) + + it('retains running for a row added during a pull whose baseline omits it', async ({ mock, remote }) => { + const manager = makeManager(mock, remote) + onTestFinished(() => manager.dispose()) + manager.handleSessionAdded(summary(S1, { blank: true })) + manager.handleSessionStatus(S1, true) + manager.handleSessionStatus(S1, false) + const response = Promise.withResolvers>>() + remote.session.list.mockReturnValueOnce(response.promise) + const refreshing = manager.refreshList() + manager.handleSessionAdded(summary(S1, { blank: true })) + response.resolve(ok({ items: [] })) + + await refreshing + + expect(manager.getListSnapshot().items[0]?.blank).toBe(false) + }) + + it.for(['result', 'transport'] as const)( + 'retains running observed before listing when the pull fails through %s', + async (source, { mock, remote }) => { + const manager = makeManager(mock, remote) + onTestFinished(() => manager.dispose()) + manager.handleSessionStatus(S1, true) + manager.handleSessionStatus(S1, false) + const error = new RemoteError('gateway/internal', 'list unavailable', {}) + if (source === 'result') remote.session.list.mockResolvedValueOnce(err(error)) + else remote.session.list.mockRejectedValueOnce(error) + + await manager.refreshList() + expect(manager.getListSnapshot().state).toBe('error') + manager.handleSessionAdded(summary(S1, { blank: true })) + + expect(manager.getListSnapshot().items[0]?.blank).toBe(false) + }, + ) + + it('accepts a later blank baseline without an acceptance or running observation', async ({ mock, remote }) => { + const manager = makeManager(mock, remote) + onTestFinished(() => manager.dispose()) + manager.handleSessionAdded(summary(S1, { blank: false })) + const session = manager.get(S1) + expect(session.getSnapshot().blank).toBe(false) + remote.session.list.mockResolvedValueOnce(ok({ items: [summary(S1, { blank: true })] })) + + await manager.refreshList() + + expect(manager.getListSnapshot().items[0]?.blank).toBe(true) + expect(session.getSnapshot().blank).toBe(true) + }) + + it('retains acceptance received before the first list response', async ({ mock, remote }) => { + const manager = makeManager(mock, remote) + onTestFinished(() => manager.dispose()) + const response = Promise.withResolvers>>() + remote.session.list.mockReturnValueOnce(response.promise) + const refreshing = manager.refreshList() + const session = manager.get(S1) + await session.prompt([{ type: 'text', text: 'first' }], 'queue') + expect(manager.getListSnapshot().items).toEqual([]) + response.resolve(ok({ items: [summary(S1, { blank: true })] })) + + await refreshing + + expect(manager.getListSnapshot().items[0]?.blank).toBe(false) + expect(session.getSnapshot().blank).toBe(false) + }) + + it('keeps a rejected first prompt blank across refresh and object replacement', async ({ mock, remote }) => { + const manager = makeManager(mock, remote) + onTestFinished(() => manager.dispose()) + manager.handleSessionAdded(summary(S1, { blank: true })) + remote.session.prompt.mockResolvedValueOnce(err(new RemoteError('session/agent-busy', 'busy', { reason: 'x' }))) + const session = manager.get(S1) + expect((await session.prompt([{ type: 'text', text: 'first' }], 'queue')).ok).toBe(false) + remote.session.list.mockResolvedValueOnce(ok({ items: [summary(S1, { blank: true })] })) + await manager.refreshList() + await manager.drop(S1, session) + + expect(manager.getListSnapshot().items[0]?.blank).toBe(true) + expect(manager.get(S1).getSnapshot().blank).toBe(true) + }) + + it('ignores running from a list response that predates removal, including after re-addition', async ({ mock, remote }) => { + for (const readdBeforeResponse of [false, true]) { + const manager = makeManager(mock, remote) + onTestFinished(() => manager.dispose()) + manager.handleSessionAdded(summary(S1, { blank: true })) + const response = Promise.withResolvers>>() + remote.session.list.mockReturnValueOnce(response.promise) + const refreshing = manager.refreshList() + manager.handleSessionRemoved(S1) + if (readdBeforeResponse) manager.handleSessionAdded(summary(S1, { blank: true })) + + response.resolve(ok({ items: [summary(S1, { blank: true, running: true })] })) + await refreshing + if (!readdBeforeResponse) { + expect(manager.getListSnapshot().items).toEqual([]) + manager.handleSessionAdded(summary(S1, { blank: true })) + } + + expect(manager.getListSnapshot().items[0]).toMatchObject({ blank: true, running: false }) + } + }) + + it('retains running observations after re-addition while an older list response is pending', async ({ mock, remote }) => { + const manager = makeManager(mock, remote) + onTestFinished(() => manager.dispose()) + manager.handleSessionAdded(summary(S1, { blank: true })) + const response = Promise.withResolvers>>() + remote.session.list.mockReturnValue(response.promise) + const refreshing = manager.refreshList() + manager.handleSessionRemoved(S1) + manager.handleSessionAdded(summary(S1, { blank: true })) + manager.handleSessionStatus(S1, true) + manager.handleSessionStatus(S1, false) + + response.resolve(ok({ items: [summary(S1, { blank: true })] })) + await refreshing + + expect(manager.getListSnapshot().items[0]).toMatchObject({ blank: false, running: false }) + }) + + it('applies late acceptance to a re-added list row without materializing its Session', async ({ mock, remote }) => { + const manager = makeManager(mock, remote) + onTestFinished(() => manager.dispose()) + manager.handleSessionAdded(summary(S1, { blank: true })) + const acceptance = Promise.withResolvers>>() + remote.session.prompt.mockReturnValue(acceptance.promise) + const session = manager.get(S1) + const sending = session.prompt([{ type: 'text', text: 'first' }], 'queue') + manager.handleSessionRemoved(S1) + await manager.drop(S1, session) + manager.handleSessionAdded(summary(S1, { blank: true })) + const getSession = vi.spyOn(manager, 'get') + onTestFinished(() => { getSession.mockRestore() }) + acceptance.resolve(ok({ accepted: true })) + await sending + expect(manager.getListSnapshot().items[0]?.blank).toBe(false) + expect(getSession).not.toHaveBeenCalled() + }) + + it('publishes a late acceptance to the replacement Session with the same id', async ({ mock, remote }) => { + const manager = makeManager(mock, remote) + onTestFinished(() => manager.dispose()) + manager.handleSessionAdded(summary(S1, { blank: true })) + const acceptance = Promise.withResolvers>>() + remote.session.prompt.mockReturnValue(acceptance.promise) + const session = manager.get(S1) + const sending = session.prompt([{ type: 'text', text: 'first' }], 'queue') + await manager.drop(S1, session) + const replacement = manager.get(S1) + expect(manager.getListSnapshot().items[0]?.blank).toBe(true) + expect(replacement.getSnapshot().blank).toBe(true) + acceptance.resolve(ok({ accepted: true })) + await sending + + expect(manager.getListSnapshot().items[0]?.blank).toBe(false) + expect(replacement.getSnapshot().blank).toBe(false) + await manager.drop(S1, session) + expect(manager.get(S1)).toBe(replacement) + }) + + it('keeps accepted presentation when its Session is rebuilt', async ({ mock, remote }) => { + const manager = makeManager(mock, remote) + onTestFinished(() => manager.dispose()) + manager.handleSessionAdded(summary(S1, { blank: true })) + const session = manager.get(S1) + await session.prompt([{ type: 'text', text: 'first' }], 'queue') + await manager.drop(S1, session) + + expect(manager.getListSnapshot().items[0]?.blank).toBe(false) + expect(manager.get(S1).getSnapshot().blank).toBe(false) + }) + + it('publishes acceptance from a nonblank sender after its list row is replaced', async ({ mock, remote }) => { + const manager = makeManager(mock, remote) + onTestFinished(() => manager.dispose()) + manager.handleSessionAdded(summary(S1, { blank: true })) + const sender = manager.get(S1) + await sender.prompt([{ type: 'text', text: 'first' }], 'queue') + const acceptance = Promise.withResolvers>>() + remote.session.prompt.mockReturnValueOnce(acceptance.promise) + const sending = sender.prompt([{ type: 'text', text: 'next' }], 'queue') + manager.handleSessionRemoved(S1) + await manager.drop(S1, sender) + manager.handleSessionAdded(summary(S1, { blank: true })) + const replacement = manager.get(S1) + expect(manager.getListSnapshot().items[0]?.blank).toBe(true) + expect(replacement.getSnapshot().blank).toBe(true) + + acceptance.resolve(ok({ accepted: true })) + await sending + + expect(manager.getListSnapshot().items[0]?.blank).toBe(false) + expect(replacement.getSnapshot().blank).toBe(false) + }) + + it('keeps observed running presentation across idle empty-history summaries', async ({ mock, remote }) => { + for (const source of ['addition', 'refresh'] as const) { + const manager = makeManager(mock, remote) + onTestFinished(() => manager.dispose()) + manager.handleSessionAdded(summary(S1, { blank: true })) + const session = manager.get(S1) + manager.handleSessionStatus(S1, true) + manager.handleSessionStatus(S1, false) + expect(session.getSnapshot().blank).toBe(false) + + if (source === 'addition') manager.handleSessionAdded(summary(S1, { blank: true })) + else { + remote.session.list.mockResolvedValueOnce(ok({ items: [summary(S1, { blank: true })] })) + await manager.refreshList() + } + + expect(manager.getListSnapshot().items[0]?.blank).toBe(false) + expect(session.getSnapshot().blank).toBe(false) + } + }) + + it('does not restore a removed row or publish after disposal when acceptance arrives late', async ({ mock, remote }) => { + for (const dispose of [false, true]) { + const manager = makeManager(mock, remote) + onTestFinished(() => manager.dispose()) + manager.handleSessionAdded(summary(S1, { blank: true })) + const acceptance = Promise.withResolvers>>() + remote.session.prompt.mockReturnValue(acceptance.promise) + const session = manager.get(S1) + const sending = session.prompt([{ type: 'text', text: 'first' }], 'queue') + manager.handleSessionRemoved(S1) + if (dispose) await manager.dispose() + else await manager.drop(S1, session) + const notify = vi.fn() + const unsubscribe = manager.subscribe(notify) + onTestFinished(unsubscribe) + acceptance.resolve(ok({ accepted: true })) + await sending + expect(manager.getListSnapshot().items).toEqual([]) + expect(notify).not.toHaveBeenCalled() + } + }) + + it('keeps accepted presentation across a later list refresh without a turn', async ({ mock, remote }) => { + const manager = makeManager(mock, remote) + onTestFinished(() => manager.dispose()) + remote.session.list.mockResolvedValue(ok({ items: [summary(S1, { blank: true })] as never[] })) + await manager.refreshList() + await manager.get(S1).prompt([{ type: 'text', text: 'first' }], 'queue') + expect(manager.getListSnapshot().items[0]?.blank).toBe(false) + + await manager.refreshList() + expect(manager.getListSnapshot().items[0]?.blank).toBe(false) + }) + it('lazily builds one resident instance per id and syncs the running bit from the list', async ({ mock, remote }) => { remote.session.list.mockResolvedValue(ok({ items: [summary(S1, { running: true })] as never[] })) const manager = makeManager(mock, remote) @@ -504,6 +817,25 @@ describe('subagent catalogs', () => { }) }) + it('resolves an unknown browsing mode from the child projection without changing parent membership', async ({ mock, start }) => { + mock.load(sessionWorld) + const client = await start() + const manager = new SessionManager(client.ctx.remote) + const entries = [{ id: S2, createdAt: 1, mode: 'unknown' as const }] + manager.handleControlFrame({ type: 'projection', sessionId: S1, key: 'subagentCatalog', seq: 0, value: entries }) + const address = manager.subagentAddress(S2)! + expect(address).toEqual({ parentSessionId: S1, childSessionId: S2, mode: 'unknown' }) + manager.resolveTarget(address) + const child = manager.get(S2) + expect(child.getSnapshot().subagent?.address.mode).toBe('unknown') + manager.handleControlFrame({ type: 'projection', sessionId: S2, key: 'subagent', seq: 0, + value: { seq: SessionSeq(0), mode: 'continuable', label: 'repaired child' } }) + await child.open() + expect(child.getSnapshot().subagent?.address).toEqual({ ...address, mode: 'continuable' }) + expect(manager.getListSnapshot().projectionsBySession[S1]?.values.subagentCatalog).toEqual(entries) + await manager.dispose() + }) + it('publishes pushed membership without a catalog request or an instantiated parent', ({ mock, remote }) => { const manager = makeManager(mock, remote) manager.handleSessionAdded(summary(S2, { origin: 'subagent', parentSessionId: S1, running: true })) @@ -876,6 +1208,32 @@ describe('remaining branches', () => { }) describe('connected generation', () => { + it('does not prune running observations from a superseded empty list response', async ({ mock, remote }) => { + const manager = makeManager(mock, remote) + onTestFinished(() => manager.dispose()) + const oldList = Promise.withResolvers>>() + const newList = Promise.withResolvers>>() + remote.session.list.mockReturnValueOnce(oldList.promise).mockReturnValueOnce(newList.promise) + const oldPull = manager.refreshList() + manager.handleSessionStatus(S1, true) + manager.handleSessionStatus(S1, false) + manager.handleConnected() + const newPull = manager.refreshList() + try { + oldList.resolve(ok({ items: [] })) + await oldPull + expect(manager.getListSnapshot().state).toBe('loading') + newList.resolve(ok({ items: [summary(S1, { blank: true })] })) + await newPull + + expect(manager.getListSnapshot().items[0]?.blank).toBe(false) + } finally { + oldList.resolve(ok({ items: [] })) + newList.resolve(ok({ items: [] })) + await Promise.all([oldPull, newPull]) + } + }) + it.for(['old-first', 'new-first'] as const)( 'ignores a previous generation list response (%s)', async (order, { mock, remote }) => { diff --git a/packages/api/session-controller/tests/session.client.spec.ts b/packages/api/session-controller/tests/session.client.spec.ts index fd49737fb1..0c93b71af4 100644 --- a/packages/api/session-controller/tests/session.client.spec.ts +++ b/packages/api/session-controller/tests/session.client.spec.ts @@ -625,6 +625,30 @@ describe('prompt and cancel errors', () => { expect(session.getSnapshot()).toMatchObject({ running: true, awaitingFirstTurn: false }) }) + it('does not await a first turn when the history already contains one', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) + session.handleBlank(false) + const inFlight = session.prompt([{ type: 'text', text: '继续' }], 'queue') + expect(session.getSnapshot()).toMatchObject({ blank: false, promptAttempted: true, awaitingFirstTurn: false }) + expect((await inFlight).ok).toBe(true) + expect(session.getSnapshot()).toMatchObject({ blank: false, awaitingFirstTurn: false }) + }) + + it('keeps an accepted first prompt converted when a later prompt is rejected', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) + session.handleBlank(true) + expect((await session.prompt([{ type: 'text', text: '第一次' }], 'queue')).ok).toBe(true) + session.handleRunning(true) + session.handleRunning(false) + mock.remote.session.prompt.mockResolvedValue(err(new RemoteError('session/agent-busy', 'busy', { reason: 'x' }))) + + expect((await session.prompt([{ type: 'text', text: '第二次' }], 'queue')).ok).toBe(false) + expect(session.getSnapshot()).toMatchObject({ + blank: false, running: false, awaitingFirstTurn: false, + promptError: { op: 'send', error: { code: 'session/agent-busy' } }, + }) + }) + it('keeps the attempted-first-prompt state when the Host rejects the prompt', async ({ mock, start }) => { const session = await sessionBench(mock, start, SID) session.handleBlank(true) diff --git a/packages/api/session-controller/tests/transport.host.spec.ts b/packages/api/session-controller/tests/transport.host.spec.ts index 04b43972e9..341aaf4e86 100644 --- a/packages/api/session-controller/tests/transport.host.spec.ts +++ b/packages/api/session-controller/tests/transport.host.spec.ts @@ -451,6 +451,16 @@ describe('SessionHistoryController', () => { }, signal)).resolves.toMatchObject({ records: [{ type: 'event', event: { type: 'subagent/descriptor' } }], }) + await expect(transport.page({ + address: { kind: 'subagent', parentSessionId, childSessionId, mode: 'unknown' }, + throughSeq: 0, + }, signal)).resolves.toMatchObject({ + records: [{ type: 'event', event: { type: 'subagent/descriptor' } }], + }) + await expect(transport.page({ + address: { kind: 'subagent', parentSessionId: SessionId('other-parent'), childSessionId, mode: 'unknown' }, + throughSeq: 0, + }, signal)).rejects.toMatchObject({ code: 'subagent/unauthorized' }) await expect(transport.page({ address: { kind: 'subagent', diff --git a/packages/boot/app-boot/README.i18n.yaml b/packages/boot/app-boot/README.i18n.yaml index aed223888a..98b11724ed 100644 --- a/packages/boot/app-boot/README.i18n.yaml +++ b/packages/boot/app-boot/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/boot/app-boot/README.md -README.md: 8439fb9ec1173ba24f9775ac476e64d23534a594 -README.zh.md: 0bb8c9819dacabed01905f6b7aff8c770ea3caae +README.md: 6f24c46485abe3761994ef7aacbea90840e01bfb +README.zh.md: b3a8aaa2746848bcc347291dad27f015e8a004e3 diff --git a/packages/boot/app-boot/README.md b/packages/boot/app-boot/README.md index 8439fb9ec1..6f24c46485 100644 --- a/packages/boot/app-boot/README.md +++ b/packages/boot/app-boot/README.md @@ -68,7 +68,7 @@ Before you boot, you can print the exact configuration the app will mount: the d ### Reading plugin display metadata -Use `readPluginMeta(specifier, parentURL)` or `ctx.pluginPackages.metaOf(specifier, parentURL)` to read installed package display text without importing or activating the plugin. Lookup uses the complete package specifier and the caller's resolution base, respecting Node exports. File paths and file URLs return no metadata without resolving resources. Missing locale fields fall back to the accessible `package.json` at that address; malformed metadata returns an `error` diagnostic. Results retain translations for Client-side language selection. See [Plugin display metadata](../../../docs/cookbook/adding-a-package.md#plugin-display-metadata) for the author format. +Use `readPluginMeta(specifier, parentURL)` or `ctx.pluginPackages.metaOf(specifier, parentURL)` to read installed package display text without importing or activating the plugin. Lookup uses the complete package specifier and the caller's resolution base, respecting Node exports. File paths and file URLs return no metadata without resolving resources. Missing locale fields fall back to the accessible `package.json` at that address; malformed metadata returns an `error` diagnostic. Results retain translations for Client-side language selection. The reader also loads `package.json.icon` as an image data URL, even when locale text is complete; an icon error preserves valid text alongside the diagnostic. See [Plugin display metadata](../../../docs/cookbook/adding-a-package.md#plugin-display-metadata) for the author format. ### Startup and reload failures diff --git a/packages/boot/app-boot/README.zh.md b/packages/boot/app-boot/README.zh.md index 0bb8c9819d..b3a8aaa274 100644 --- a/packages/boot/app-boot/README.zh.md +++ b/packages/boot/app-boot/README.zh.md @@ -68,7 +68,7 @@ profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`head ### 读取插件展示元信息 -使用 `readPluginMeta(specifier, parentURL)` 或 `ctx.pluginPackages.metaOf(specifier, parentURL)` 读取已安装包的展示文本,无需导入或激活插件。查询使用完整包标识与调用方的解析基准,并遵循 Node exports。文件路径与文件 URL 不解析资源,直接返回无元信息。缺失的 locale 字段回退到该地址下可访问的 `package.json`;格式错误的元信息返回 `error` 诊断。结果保留翻译,由 Client 选择语言。作者格式见[插件展示元信息](../../../docs/cookbook/adding-a-package.zh.md#plugin-display-metadata)。 +使用 `readPluginMeta(specifier, parentURL)` 或 `ctx.pluginPackages.metaOf(specifier, parentURL)` 读取已安装包的展示文本,无需导入或激活插件。查询使用完整包标识与调用方的解析基准,并遵循 Node exports。文件路径与文件 URL 不解析资源,直接返回无元信息。缺失的 locale 字段回退到该地址下可访问的 `package.json`;格式错误的元信息返回 `error` 诊断。结果保留翻译,由 Client 选择语言。即使 locale 文本完整,读取器也会将 `package.json.icon` 加载为图片 data URL;图标出错时,保留有效文本并附上诊断。作者格式见[插件展示元信息](../../../docs/cookbook/adding-a-package.zh.md#plugin-display-metadata)。 ### 启动与重载失败 diff --git a/packages/boot/app-boot/src/package-meta.ts b/packages/boot/app-boot/src/package-meta.ts index 434b233d1e..131082ce8a 100644 --- a/packages/boot/app-boot/src/package-meta.ts +++ b/packages/boot/app-boot/src/package-meta.ts @@ -1,7 +1,7 @@ -/** Read plugin display metadata through exported locale resources without evaluating plugin code. */ +/** Read plugin display text and icons through exported resources without evaluating plugin code. */ -import { readdirSync, readFileSync } from 'node:fs' -import { dirname } from 'node:path' +import { readdirSync, readFileSync, realpathSync, statSync } from 'node:fs' +import { dirname, extname, isAbsolute, relative, resolve, sep, win32 } from 'node:path' import { fileURLToPath } from 'node:url' import { ModuleLoader } from '@deepseek-ai/cordis-plugin-loader' import type { LocalizedText, PluginLocalizedMeta } from '@deepseek-ai/dsh-package-manifest' @@ -11,6 +11,35 @@ const LANGUAGE_ID = /^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$/u type DisplayFields = Record<'title' | 'description', string | undefined> +/** Maximum raw icon bytes admitted to plugin metadata. */ +const MAX_ICON_BYTES = 256 * 1024 +const ICON_MEDIA_TYPES = new Map([ + ['.svg', 'image/svg+xml'], ['.png', 'image/png'], ['.jpg', 'image/jpeg'], + ['.jpeg', 'image/jpeg'], ['.webp', 'image/webp'], +]) + +function iconOf(value: unknown, manifestPath: string): string | undefined { + const icon = textOf(value, `${manifestPath}: icon`) + if (icon === undefined) return undefined + if (isAbsolute(icon) || win32.isAbsolute(icon) || /^[A-Za-z][A-Za-z\d+.-]*:/u.test(icon)) { + throw new Error(`${manifestPath}: icon must be a relative file path`) + } + const mediaType = ICON_MEDIA_TYPES.get(extname(icon).toLowerCase()) + if (mediaType === undefined) throw new Error(`${manifestPath}: icon must be SVG, PNG, JPEG, or WebP`) + const directory = realpathSync(dirname(manifestPath)) + const file = realpathSync(resolve(directory, icon)) + const local = relative(directory, file) + if (local === '..' || local.startsWith(`..${sep}`) || isAbsolute(local)) { + throw new Error(`${manifestPath}: icon must remain inside its manifest directory`) + } + const stat = statSync(file) + if (!stat.isFile()) throw new Error(`${manifestPath}: icon must be a regular file`) + if (stat.size > MAX_ICON_BYTES) throw new Error(`${manifestPath}: icon exceeds 256 KiB`) + const bytes = readFileSync(file) + if (bytes.length > MAX_ICON_BYTES) throw new Error(`${manifestPath}: icon exceeds 256 KiB`) + return `data:${mediaType};base64,${bytes.toString('base64')}` +} + /** * Resolve a plugin resource through the active Node ESM resolver without evaluating it. * @param specifier - complete resource module specifier, including its locale filename. @@ -104,7 +133,9 @@ function localizedText( } /** - * Read meta.title and meta.description from a plugin's exported locale JSON files. + * Read localized display text and the icon declared in a plugin's exported package.json. + * Icons are manifest-relative SVG, PNG, JPEG, or WebP files of at most 256 KiB, + * contained in the manifest directory after realpath resolution. Icon failures retain display text. * Non-package specifiers are skipped without invoking the resource resolver. * Language files share the directory containing the resolved English resource; * each file is resolved through the complete plugin specifier before reading. @@ -112,25 +143,29 @@ function localizedText( * maps retain an English fallback, ultimately the full module specifier for titles and empty for descriptions. * @param specifier - configured plugin module name, including any package subpath. * @param parentURL - owning Loader tree's module-resolution base. - * @returns translated or fallback fields, a diagnostic, or undefined for non-package specifiers or absent display text. + * @returns display fields and any icon diagnostic, or undefined for non-package specifiers or absent metadata. */ export function readPluginMeta(specifier: string, parentURL: string): PluginLocalizedMeta | undefined { if (barePackageName(specifier) === undefined) return undefined try { const englishPath = optionalResourcePath(`${specifier}/locale/en.json`, parentURL) const dictionaries = englishPath === undefined ? new Map() : dictionariesOf(englishPath, specifier, parentURL) - const english = dictionaries.get('en') - const manifestPath = english?.title === undefined || english.description === undefined - ? optionalResourcePath(`${specifier}/package.json`, parentURL) - : undefined + const manifestPath = optionalResourcePath(`${specifier}/package.json`, parentURL) const manifest = manifestPath === undefined ? undefined : readObject(manifestPath) const title = localizedText('title', dictionaries, fallbackText(manifest?.name), specifier) const description = localizedText('description', dictionaries, fallbackText(manifest?.description), '') - if (title === undefined && description === undefined) return undefined - return { + const text = { ...title === undefined ? {} : { title }, ...description === undefined ? {} : { description }, } + let icon: string | undefined + try { + icon = manifestPath === undefined ? undefined : iconOf(manifest?.icon, manifestPath) + } catch (error) { + return { ...text, error: `Plugin metadata for ${specifier}: ${String(error)}` } + } + if (title === undefined && description === undefined && icon === undefined) return undefined + return { ...text, ...icon === undefined ? {} : { icon } } } catch (error) { return { error: `Plugin metadata for ${specifier}: ${String(error)}` } } diff --git a/packages/boot/app-boot/tests/package-meta.spec.ts b/packages/boot/app-boot/tests/package-meta.spec.ts index 07b9af374b..73484f7db9 100644 --- a/packages/boot/app-boot/tests/package-meta.spec.ts +++ b/packages/boot/app-boot/tests/package-meta.spec.ts @@ -1,6 +1,6 @@ /** Plugin locale resources resolve independently of entry execution and package display fields. */ -import fs, { mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs' +import fs, { mkdirSync, mkdtempSync, readdirSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs' import { syncBuiltinESMExports } from 'node:module' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' @@ -161,15 +161,122 @@ describe('plugin locale display metadata', () => { expect(readPluginMeta('localized', parentURL)?.error).toContain('meta.title must be a non-empty string') }) - it('does not inspect package fallback when both English fields are supplied', () => { - manifest({ './locale/*.json': './locale/*.json', './package.json': './fallback.json' }) - file(join(dir, 'fallback.json'), '{') + it('reads the manifest icon even when both English fields are supplied', () => { + manifest({ './locale/*.json': './locale/*.json', './package.json': './package.json' }, { icon: './art/team.svg' }) + file(join(dir, 'art', 'team.svg'), '') dictionary('en', { meta: { title: 'Plugin', description: 'Locale introduction' } }) expect(readPluginMeta('localized', parentURL)).toEqual({ title: { en: 'Plugin' }, description: { en: 'Locale introduction' }, + icon: `data:image/svg+xml;base64,${Buffer.from('').toString('base64')}`, }) }) + it.each([ + ['svg', 'image/svg+xml'], ['png', 'image/png'], ['jpg', 'image/jpeg'], + ['jpeg', 'image/jpeg'], ['webp', 'image/webp'], ['SVG', 'image/svg+xml'], + ])('encodes a package-local %s icon without executing the plugin', (extension, mediaType) => { + manifest({ '.': './index.js', './package.json': './package.json' }, { icon: `./icon.${extension}` }) + const bytes = Buffer.from([0, 1, 127, 128, 255]) + writeFileSync(join(dir, `icon.${extension}`), bytes) + expect(readPluginMeta('localized', parentURL)).toEqual({ + title: 'localized', icon: `data:${mediaType};base64,${bytes.toString('base64')}`, + }) + }) + + it('reads an icon-only subexport relative to its own manifest without inheriting the package icon', () => { + manifest({ './package.json': './package.json', './search/package.json': './search/manifest.json' }, { icon: './root.png' }) + file(join(dir, 'root.png'), 'root') + file(join(dir, 'search', 'manifest.json'), JSON.stringify({ icon: './icon.webp' })) + file(join(dir, 'search', 'icon.webp'), 'search') + expect(readPluginMeta('localized/search', parentURL)).toEqual({ + icon: `data:image/webp;base64,${Buffer.from('search').toString('base64')}`, + }) + expect(readPluginMeta('localized/review', parentURL)).toBeUndefined() + }) + + it.each([null, false, 1, {}, [], '', ' '])('retains localized text while rejecting malformed icon %j', (icon) => { + manifest({ './locale/*.json': './locale/*.json', './package.json': './package.json' }, { icon }) + dictionary('en', { meta: { title: 'Plugin', description: 'Introduction' } }) + dictionary('zh', { meta: { title: '插件' } }) + const { error, ...meta } = readPluginMeta('localized', parentURL)! + expect(meta).toEqual({ title: { en: 'Plugin', zh: '插件' }, description: { en: 'Introduction' } }) + expect(error).toContain('icon must be a non-empty string') + }) + + it.each(['/tmp/icon.svg', 'C:\\icon.png', 'C:icon.png', '\\\\server\\share\\icon.png', 'https://example.test/icon.svg', 'data:image/svg+xml,'])('rejects non-relative icon %s', (icon) => { + manifest({ './package.json': './package.json' }, { icon, description: 'Introduction' }) + const { error, ...meta } = readPluginMeta('localized', parentURL)! + expect(meta).toEqual({ title: 'localized', description: 'Introduction' }) + expect(error).toContain('icon must be a relative file path') + }) + + it.each(['icon.gif', 'icon.html', 'icon', 'icon.svg?query'])('rejects unsupported icon file %s', (icon) => { + manifest({ './package.json': './package.json' }, { icon }) + expect(readPluginMeta('localized', parentURL)?.error).toContain('icon must be SVG, PNG, JPEG, or WebP') + }) + + it('reports missing files, directories, and paths outside the manifest directory', () => { + manifest({ './package.json': './package.json' }, { icon: './missing.svg' }) + expect(readPluginMeta('localized', parentURL)?.error).toContain('missing.svg') + mkdirSync(join(dir, 'missing.svg')) + expect(readPluginMeta('localized', parentURL)?.error).toContain('icon must be a regular file') + manifest({ './package.json': './package.json' }, { icon: '../localized-other/icon.svg' }) + file(join(root, 'node_modules', 'localized-other', 'icon.svg'), 'outside') + expect(readPluginMeta('localized', parentURL)?.error).toContain('icon must remain inside its manifest directory') + }) + + it('accepts an icon at the byte limit and rejects one byte more', () => { + manifest({ './package.json': './package.json' }, { icon: './icon.png' }) + const bytes = Buffer.alloc(256 * 1024) + writeFileSync(join(dir, 'icon.png'), bytes) + expect(readPluginMeta('localized', parentURL)?.icon).toBe(`data:image/png;base64,${bytes.toString('base64')}`) + writeFileSync(join(dir, 'icon.png'), Buffer.alloc(bytes.length + 1)) + expect(readPluginMeta('localized', parentURL)?.error).toContain('icon exceeds 256 KiB') + }) + + it('rejects an icon that grows beyond the limit after its size check', () => { + manifest({ './package.json': './package.json' }, { icon: './icon.png' }) + const icon = join(dir, 'icon.png') + file(icon, 'small') + const original = fs.statSync + const stat = vi.spyOn(fs, 'statSync') + try { + stat.mockImplementation(new Proxy(original, { + apply(target, receiver: unknown, args: unknown[]): unknown { + const result: unknown = Reflect.apply(target, receiver, args) + if (String(args[0]).endsWith(join('localized', 'icon.png'))) writeFileSync(icon, Buffer.alloc(256 * 1024 + 1)) + return result + }, + })) + syncBuiltinESMExports() + expect(readPluginMeta('localized', parentURL)?.error).toContain('icon exceeds 256 KiB') + } finally { + stat.mockRestore() + syncBuiltinESMExports() + } + }) + + it('rejects icons reached through an external directory link and accepts internal links', () => { + manifest({ './package.json': './package.json' }, { icon: './art/icon.svg' }) + const outside = join(root, 'outside') + file(join(outside, 'icon.svg'), 'outside') + const link = join(dir, 'art') + symlinkSync(outside, link, 'junction') + try { + expect(readPluginMeta('localized', parentURL)?.error).toContain('icon must remain inside its manifest directory') + } finally { + unlinkSync(link) + } + const inside = join(dir, 'images') + file(join(inside, 'icon.svg'), 'inside') + symlinkSync(inside, link, 'junction') + try { + expect(readPluginMeta('localized', parentURL)?.icon).toBe(`data:image/svg+xml;base64,${Buffer.from('inside').toString('base64')}`) + } finally { + unlinkSync(link) + } + }) + it('keeps separate plugin exports independent even when their JavaScript entries share a directory', () => { manifest({ './search': './lib/search.js', './review': './lib/review.js', @@ -285,8 +392,9 @@ describe('plugin locale display metadata', () => { }) const specifier = 'localized/locale/en.json' expect(resolveSync.mock.calls).toEqual(version === 'v1' - ? [[specifier, parentURL, {}], [specifier, parentURL, {}]] - : [[parentURL, { specifier, attributes: {} }], [parentURL, { specifier, attributes: {} }]]) + ? [[specifier, parentURL, {}], [specifier, parentURL, {}], ['localized/package.json', parentURL, {}]] + : [[parentURL, { specifier, attributes: {} }], [parentURL, { specifier, attributes: {} }], + [parentURL, { specifier: 'localized/package.json', attributes: {} }]]) }) it('rejects case-equivalent language names in a directory listing', () => { diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 1f4d203152..72f81ef676 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -233,9 +233,10 @@ - id: ui-sidebar-documentpreview name: '@deepseek-ai/dsh-client-ui-sidebar-documentpreview' - # Sandboxed HTTP(S) Browser tabs, including loopback targets. + # Web profiles opt in; Desktop retains sandboxed HTTP(S) Browser tabs. - id: ui-sidebar-browser name: '@deepseek-ai/dsh-client-ui-sidebar-browser' + disabled: !!js "ctx.get('profileContext')?.name !== 'desktop'" - id: ui-sidebar-terminal name: '@deepseek-ai/dsh-client-ui-sidebar-terminal' diff --git a/packages/bundle/web-app/tests/browser-defaults.spec.ts b/packages/bundle/web-app/tests/browser-defaults.spec.ts new file mode 100644 index 0000000000..73ee9b1a82 --- /dev/null +++ b/packages/bundle/web-app/tests/browser-defaults.spec.ts @@ -0,0 +1,41 @@ +/** Profile defaults and explicit overrides for the shipped Browser entry. */ +import { fileURLToPath } from 'node:url' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import { applyEntryPatches } from '@deepseek-ai/cordis-plugin-include' +import { loadOverlayPatches } from '@deepseek-ai/dsh-app-boot' +import * as Browser from '@deepseek-ai/dsh-client-ui-sidebar-browser' +import { expect, it, onTestFinished } from 'vitest' + +it.each([ + { profile: 'web', override: undefined, disabled: true }, + { profile: 'custom-web', override: undefined, disabled: true }, + { profile: undefined, override: undefined, disabled: true }, + { profile: 'desktop', override: undefined, disabled: false }, + { profile: 'web', override: false, disabled: false }, + { profile: 'desktop', override: true, disabled: true }, +])('applies Browser availability for $profile with override $override', async ({ profile, override, disabled }) => { + const rows = loadOverlayPatches('browser-defaults', fileURLToPath(new URL('../cordis.patch.yml', import.meta.url))) + .flatMap(patch => patch.insert ?? []).filter(row => row.id === 'ui-sidebar-browser') + expect(rows).toHaveLength(1) + expect(rows[0]!.name).toBe('@deepseek-ai/dsh-client-ui-sidebar-browser') + const configured = applyEntryPatches(rows, override === undefined ? [] : [{ id: 'ui-sidebar-browser', disabled: override }], + (message) => { throw new Error(message) }) + const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) + if (profile !== undefined) { + ctx.provide('profileContext', { + name: profile, dir: '/profile', patchPath: '/profile/cordis.patch.yml', installAnchor: '/profile/package.json', + cwd: '/workspace', home: '/home', startedBundles: [], overlays: [], telemetryDisabledEnv: undefined, + }) + } + ctx.baseUrl = 'file:///' + await ctx.plugin(Loader).await() + // Keep the real companion in the source-plane loader without native package resolution. + ctx.loader.builtins.browser = Browser + await ctx.loader.root.update([{ ...configured[0]!, name: 'cordis:browser' }]) + await ctx.loader.await() + const entry = ctx.loader.resolve('ui-sidebar-browser') + expect(entry.disabled).toBe(disabled) + expect(entry.fiber !== undefined).toBe(!disabled) +}) diff --git a/packages/client/hmr/README.i18n.yaml b/packages/client/hmr/README.i18n.yaml index 07ad3d545d..f6861208ea 100644 --- a/packages/client/hmr/README.i18n.yaml +++ b/packages/client/hmr/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/hmr/README.md -README.md: e459da10332ffdb3af422b75f639ce25df512487 -README.zh.md: 54e2f3af63472dedbfc75c5b4400b5d93e14f311 +README.md: 5dae1463ceed315c3409bf97de80649f10e83fa8 +README.zh.md: a77befcf63cd78be66b7e5b636d86367af6fdcdf diff --git a/packages/client/hmr/README.md b/packages/client/hmr/README.md index e459da1033..5dae1463ce 100644 --- a/packages/client/hmr/README.md +++ b/packages/client/hmr/README.md @@ -29,7 +29,7 @@ The shipped Web composition mounts this transport for live plugin changes. Durin ### Starting the reload chain -Run `pnpm run dev:web` (or a watch process using the shared Client tsdown preset) against the same host; rebuilt plugins are then swapped into the running browser automatically, one at a time. The preset stamps `lib/client.js` after all package-local chunks are written, so a chunk-only rebuild also advances the package revision without Host-side chunk scanning. +Run `pnpm run dev:web`, which starts the host and the rebuild watchers together (`--no-serve` attaches only the watchers to a host started elsewhere, as does any watch process using the shared Client tsdown preset); rebuilt plugins are then swapped into the running browser automatically, one at a time. The preset stamps `lib/client.js` after all package-local chunks are written, so a chunk-only rebuild also advances the package revision without Host-side chunk scanning. ### What a reload does diff --git a/packages/client/hmr/README.zh.md b/packages/client/hmr/README.zh.md index 54e2f3af63..a77befcf63 100644 --- a/packages/client/hmr/README.zh.md +++ b/packages/client/hmr/README.zh.md @@ -29,7 +29,7 @@ kind: "package-reference" ### 启动重载链路 -对同一个宿主运行 `pnpm run dev:web`(或使用共享 Client tsdown 预设的 watch 进程);重建后的插件随后会被自动逐个替换进运行中的浏览器。该预设会在所有包内 chunk 写完后标记 `lib/client.js`,因此仅 chunk 发生重建也会推进包 revision,无需 Host 扫描 chunk。 +运行 `pnpm run dev:web`,它会同时启动宿主与重建 watcher(`--no-serve` 则只把 watcher 接到别处启动的宿主上,使用共享 Client tsdown 预设的任何 watch 进程亦然);重建后的插件随后会被自动逐个替换进运行中的浏览器。该预设会在所有包内 chunk 写完后标记 `lib/client.js`,因此仅 chunk 发生重建也会推进包 revision,无需 Host 扫描 chunk。 ### 一次重载做什么 diff --git a/packages/client/ui-agent-preset/README.i18n.yaml b/packages/client/ui-agent-preset/README.i18n.yaml index 2ed6513974..3b67b4ca8a 100644 --- a/packages/client/ui-agent-preset/README.i18n.yaml +++ b/packages/client/ui-agent-preset/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-agent-preset/README.md -README.md: c7a87da591c1cafc433fbb3bb5fa216d94ab85ef -README.zh.md: a54b47baed132e790fc4f44ad1155f48a19c430b +README.md: 5f2c9acaa5840e71d6ca237c5fc5eccb52724cda +README.zh.md: 5c61823c25f145c0e516f3f2a16c2de05c250ef1 diff --git a/packages/client/ui-agent-preset/README.md b/packages/client/ui-agent-preset/README.md index c7a87da591..5f2c9acaa5 100644 --- a/packages/client/ui-agent-preset/README.md +++ b/packages/client/ui-agent-preset/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Use this package to choose the agent preset for a new Web GUI session, see the active preset in the session header, and manage available presets in Settings. The Agent mode picker is shown by default; Settings can hide it without changing running or historical sessions. A preset is fixed when a session is created, so changing the selection or default affects only later sessions. If the deployment provides no presets, these controls stay hidden and every session uses the host composition. +Use this package to choose the agent preset for a new Web GUI session, see the active preset in the session header, and manage available presets in Settings. The Agent mode picker is shown by default while Developer tools is enabled; Settings can hide it without changing running or historical sessions. A preset is fixed when a session is created, so changing the selection or default affects only later sessions. If the deployment provides no presets, these controls stay hidden and every session uses the host composition. ## Table of Contents @@ -25,9 +25,9 @@ Use this package to choose the agent preset for a new Web GUI session, see the a ## Use this package -The new-session picker additionally requires Developer tools to be enabled in General Settings. This presentation preference does not alter the selected preset, saved default, Host composition, or the management section’s independent picker policy. +The new-session picker and the management section’s “Allow switching Agent modes” row are visible only while Developer tools is enabled in General Settings. This presentation preference does not alter the selected preset, saved default, Host composition, or the management section’s independent picker policy. -Mount this plugin alongside the settings and conversation packages; the management section then shows a visibility switch that is on by default. While it is off, the new-session chip is absent and the Host composes an unnamed session from the deployment default (`standard` in the shipped Web bundle). Turning it on restores the saved user default, or uses the deployment default when none has been saved, and carries that default to the current blank task; a chip pick itself is staged only once for the next blank session. Turning the picker off again returns the current blank task to the deployment default the same way and discards an unconsumed stage; started and historical sessions keep their labels, compositions, and recorded history. +Mount this plugin alongside the settings and conversation packages; once Developer tools is enabled, the management section shows a visibility switch that is on by default. While it is off, the new-session chip is absent and the Host composes an unnamed session from the deployment default (`standard` in the shipped Web bundle). Turning it on restores the saved user default, or uses the deployment default when none has been saved, and carries that default to the current blank task; a chip pick itself is staged only once for the next blank session. Turning the picker off again returns the current blank task to the deployment default the same way and discards an unconsumed stage; started and historical sessions keep their labels, compositions, and recorded history. ### Managing the roster diff --git a/packages/client/ui-agent-preset/README.zh.md b/packages/client/ui-agent-preset/README.zh.md index a54b47baed..5c61823c25 100644 --- a/packages/client/ui-agent-preset/README.zh.md +++ b/packages/client/ui-agent-preset/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -使用本包可以为新的 Web GUI 会话选择 agent preset、在会话标题中查看当前 preset,并在设置中管理可用 preset。Agent 模式选择器默认显示;设置可以隐藏它,而不会改变运行中或历史会话。preset 在会话创建时即固定,因此更改选择或默认值只影响此后创建的会话。如果部署未提供任何 preset,这些控件保持隐藏,每个会话都使用宿主组装。 +使用本包可以为新的 Web GUI 会话选择 agent preset、在会话标题中查看当前 preset,并在设置中管理可用 preset。Agent 模式选择器在通用设置开启开发者工具时默认显示;设置可以隐藏它,而不会改变运行中或历史会话。preset 在会话创建时即固定,因此更改选择或默认值只影响此后创建的会话。如果部署未提供任何 preset,这些控件保持隐藏,每个会话都使用宿主组装。 ## 目录 @@ -25,9 +25,9 @@ kind: "package-reference" ## 使用本包 -新会话选择器还要求在通用设置中开启开发者工具。此展示偏好不会改变已选预设、保存的默认值、Host 组合或管理区域独立的选择器策略。 +新会话选择器和管理区域中的「允许切换agent模式」整行设置仅在通用设置中开启开发者工具时显示。此展示偏好不会改变已选预设、保存的默认值、Host 组合或管理区域独立的选择器策略。 -与设置和对话包一起挂载本插件;管理分区随后显示一个默认开启的可见性开关。关闭期间,新建会话 chip 不出现,宿主会依据部署默认值(随附 Web bundle 中为 `standard`)组装未指名会话。开启时会恢复已保存的用户默认值;尚未保存时则使用部署默认值;该默认值会同时带到当前空白任务上。chip 中的选择本身只为下一个空白会话暂存一次。再次关闭选择器会以同样方式把当前空白任务带回部署默认值,并丢弃尚未使用的暂存选择;已开始及历史会话的标签、组装与已记录历史均保持不变。 +与设置和对话包一起挂载本插件;通用设置开启开发者工具后,管理分区会显示一个默认开启的可见性开关。关闭期间,新建会话 chip 不出现,宿主会依据部署默认值(随附 Web bundle 中为 `standard`)组装未指名会话。开启时会恢复已保存的用户默认值;尚未保存时则使用部署默认值;该默认值会同时带到当前空白任务上。chip 中的选择本身只为下一个空白会话暂存一次。再次关闭选择器会以同样方式把当前空白任务带回部署默认值,并丢弃尚未使用的暂存选择;已开始及历史会话的标签、组装与已记录历史均保持不变。 ### 管理名单 diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx index 5738a1fa36..d05fa305bf 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx @@ -16,7 +16,7 @@ import { Button, IconBrowseOutlineRegular, IconCopyOutlineRegular, IconFolderOpenOutlineRegular, IconPlusOutlineRegular, IconTrashOutlineRegular, Modal, Switch, Tag, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' -import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { ObservableSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import { draftBlocker, type AgentPresetSectionState } from './section-store.ts' import { presetDisplayText, type AgentPresetSettingsKey } from './locales.ts' @@ -27,6 +27,8 @@ export interface AgentPresetSectionInjected { hooks: { /** Page snapshot bound by the renderer as useAgentPresetSection. */ agentPresetSection: SnapshotStore + /** Shared preference controlling the picker-policy row. */ + developerTools: ObservableSnapshot } /** Read the roster; called once when the section first renders. */ load: () => Promise @@ -181,6 +183,7 @@ function CardDescription({ text }: { text: string }): ReactNode { export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode { const { useAgentPresetSection, t, load } = props const state = useAgentPresetSection(snapshot => snapshot) + const developerTools = props.useDeveloperTools(enabled => enabled) const viewedId = state.view?.id const viewedRow = viewedId === undefined ? undefined : state.rows.find(row => row.id === viewedId) const viewedTitle = state.view === null @@ -236,21 +239,23 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode {

{t('nav')}

{t('sectionIntro')}

-
-
- - {t('showPicker')} - {t('showPickerBeta')} - -

{t('showPickerDescription')}

+ {developerTools && ( +
+
+ + {t('showPicker')} + {t('showPickerBeta')} + +

{t('showPickerDescription')}

+
+ { void props.setPickerVisible(next) }} + />
- { void props.setPickerVisible(next) }} - /> -
+ )} {state.error === null ? null :

{state.error}

} {([['system', t('builtInGroup')], ['user', t('customGroup')]] as const).map(([trust, heading]) => { const group = state.rows diff --git a/packages/client/ui-agent-preset/src/client/index.ts b/packages/client/ui-agent-preset/src/client/index.ts index c335518aff..0838fd89aa 100644 --- a/packages/client/ui-agent-preset/src/client/index.ts +++ b/packages/client/ui-agent-preset/src/client/index.ts @@ -203,7 +203,7 @@ export function apply(ctx: ClientContext): void { } const sectionInjected = (): AgentPresetSectionInjected => ({ - hooks: { agentPresetSection: section.store }, + hooks: { agentPresetSection: section.store, developerTools: ctx.settingsScope.developerTools.enabled }, load: () => section.load(), view: (id: string) => section.view(id), closeView: () => { section.closeView() }, diff --git a/packages/client/ui-agent-preset/tests/section.client.spec.tsx b/packages/client/ui-agent-preset/tests/section.client.spec.tsx index d67dc98529..22de6a09cd 100644 --- a/packages/client/ui-agent-preset/tests/section.client.spec.tsx +++ b/packages/client/ui-agent-preset/tests/section.client.spec.tsx @@ -17,6 +17,8 @@ import { en } from '../src/client/locales.ts' afterEach(cleanup) +const translations: ReadonlyMap = new Map(Object.entries(en)) + const READY: AgentPresetSectionState = { status: 'ready', error: null, @@ -35,6 +37,10 @@ const READY: AgentPresetSectionState = { revealedPaths: {}, } +function unusedHook(): never { + throw new Error('This section does not read global slot sources') +} + /** * Render the section over a fixed snapshot, with every action a spy. * @param state - the snapshot to render. @@ -42,7 +48,7 @@ const READY: AgentPresetSectionState = { */ function renderSection( state: Partial = {}, - options: { creator?: boolean } = {}, + options: { creator?: boolean; developerTools?: boolean } = {}, ) { const store = createSnapshotStore({ ...READY, ...state }) const actions = { @@ -63,11 +69,18 @@ function renderSection( makeDefault: vi.fn(() => Promise.resolve()), setPickerVisible: vi.fn(() => Promise.resolve()), } - const props = { + const props: AgentPresetSectionProps = { ...actions, + usePanelInfo: unusedHook, + useSessions: unusedHook, + useSessionStatus: unusedHook, + useSessionRetainInfo: unusedHook, + useWorkspaces: unusedHook, + useResource: unusedHook, useAgentPresetSection: bindSnapshotSelector(store), - t: (key: keyof typeof en) => en[key], - } as unknown as AgentPresetSectionProps + useDeveloperTools: bindSnapshotSelector(createSnapshotStore(options.developerTools ?? true)), + t: key => translations.get(key) ?? key, + } render() return actions } @@ -82,6 +95,13 @@ function rowFor(id: string): HTMLElement { } describe('the preset list', () => { + it('hides the complete picker-policy row while developer tools are off', () => { + renderSection({}, { developerTools: false }) + expect(screen.queryByRole('switch', { name: en.showPicker })).toBeNull() + expect(screen.queryByText(en.showPickerDescription)).toBeNull() + expect(screen.queryByText(en.showPickerBeta)).toBeNull() + }) + it('reads the roster once when it first renders', async () => { const actions = renderSection() @@ -348,14 +368,8 @@ describe('the preset list', () => { }) it('renders nothing when the deployment composes no presets', () => { - const { container } = render(({ ...READY, status: 'unavailable', rows: [] })), - t: (key: keyof typeof en) => en[key], - load: vi.fn(() => Promise.resolve()), - } as unknown as AgentPresetSectionProps)} />) - - expect(container.firstChild).toBeNull() + renderSection({ status: 'unavailable', rows: [] }) + expect(screen.queryByRole('heading')).toBeNull() }) it('offers a retry when the roster could not be read', () => { diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index c42928d650..7d9e60da65 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-chat/README.md -README.md: b916802917bfdd406fb333174371507d466b8e19 -README.zh.md: 01de4d633cb1501a03200aa96f72e89622d0a7d8 +README.md: eea8eeda01c3d0bd30669cdf3743c64be0d76fbe +README.zh.md: 58fef8e8c47ae5f67c8ecbf614a36bbfbf2e20b3 diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index b916802917..eea8eeda01 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -32,7 +32,7 @@ File-mention providers receive the viewed Session ID with the closing-turn owner Chat supplies file and HTTP(S) navigation through one `MarkdownDelegateProvider` around its node list. Assistant Markdown file links open in the right Sidebar after the message settles, including references to unmodified files. Relative paths resolve in the viewed Session's workspace; absolute paths retain the same Session's filesystem access. `#L24` and `#L24-L30` navigate to the first specified line and reuse an existing file tab. Missing files show the preview's error state. -HTTP(S) links in Assistant Markdown open a new right-Sidebar Browser tab on ordinary clicks when that type is registered, or the system browser otherwise; modified clicks retain the native external-link behavior. Sent file references and skills confirmed by the message’s logged invocation also open in the right Sidebar. File paths use the viewed Session; skill names resolve through its current input-trigger source. Both use the prose file-link dotted underline on hover or focus. Sessions, directories, and command labels remain non-navigating references. +Settings → General → Open chat links in selects the destination for ordinary clicks on Chat HTTP(S) links: Built-in browser (default) opens a new right-Sidebar Browser tab, while New browser tab opens an external tab. The setting is shown only while the built-in browser is available. If the Sidebar Browser is not registered, both choices use the external browser; modified clicks retain native behavior. The `ui-chat.linkOpening` preference persists on loopback browsers and stays process-local when settings cannot persist writes. Sent file references and skills confirmed by the message’s logged invocation also open in the right Sidebar. File paths use the viewed Session; skill names resolve through its current input-trigger source. Both use the prose file-link dotted underline on hover or focus. Sessions, directories, and command labels remain non-navigating references. ## System prompt row diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index 01de4d633c..58fef8e8c4 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -32,7 +32,7 @@ kind: "package-reference" Chat 在节点列表外通过一个 `MarkdownDelegateProvider` 提供文件及 HTTP(S) 导航。Assistant Markdown 文件链接在消息落定后可于右侧栏打开,包括未修改文件的引用。相对路径基于当前查看的 Session 工作区解析;绝对路径仍使用同一 Session 的文件系统访问。`#L24` 和 `#L24-L30` 定位到指定起始行,并复用现有文件标签。文件缺失时显示预览错误状态。 -Assistant Markdown 中的 HTTP(S) 链接在普通点击时会在该类型已注册时打开新的右侧 Sidebar Browser tab,否则改用系统浏览器;带修饰键的点击保留原生外部链接行为。已发送的文件引用及消息日志确认调用的 skill 也可在右侧栏打开预览。文件路径使用当前查看的 Session;skill 名称由该 Session 当前的输入触发源解析。两者悬停或聚焦时均使用正文文件链接的虚线下划线。会话、目录和命令标签仍只作为引用展示。 +设置 → 通用设置 → 聊天链接打开方式控制普通点击 Chat HTTP(S) 链接时的目标:「内置浏览器」(默认)打开新的右侧 Sidebar Browser tab,「浏览器新标签页」打开外部标签页。该设置项仅在内置浏览器可用时显示。若 Sidebar Browser 未注册,两种选择均使用外部浏览器;带修饰键的点击保留原生行为。`ui-chat.linkOpening` 偏好在回环地址浏览器中持久化,设置无法持久化写入时仅在当前进程内生效。已发送的文件引用及消息日志确认调用的 skill 也可在右侧栏打开预览。文件路径使用当前查看的 Session;skill 名称由该 Session 当前的输入触发源解析。两者悬停或聚焦时均使用正文文件链接的虚线下划线。会话、目录和命令标签仍只作为引用展示。 ## 系统提示词行 diff --git a/packages/client/ui-chat/src/chat-settings.ts b/packages/client/ui-chat/src/chat-settings.ts index dbf9014320..021a2b0616 100644 --- a/packages/client/ui-chat/src/chat-settings.ts +++ b/packages/client/ui-chat/src/chat-settings.ts @@ -26,16 +26,25 @@ export type PerformanceUsageMode = typeof PERFORMANCE_USAGE_MODES[number] /** Preserve detailed accounting for users without an explicit preference. */ export const DEFAULT_PERFORMANCE_USAGE: PerformanceUsageMode = 'detailed' +/** Destinations for ordinary clicks on Chat HTTP(S) links. */ +export type LinkOpening = 'sidebar' | 'new-tab' + +/** Preserve the built-in browser for users without an explicit preference. */ +export const DEFAULT_LINK_OPENING: LinkOpening = 'sidebar' + /** Durable Chat section shared by the Host schema and browser scope. */ export interface ChatSettings { /** Presentation mode for completed Turn process content. */ transcriptView: TranscriptViewMode /** Detail level for composer statistics and completed-Turn usage. */ performanceUsage: PerformanceUsageMode + /** Default destination for Chat HTTP(S) links. */ + linkOpening: LinkOpening } /** Durable Chat schema; also the wire envelope the browser scope validates against. */ export const ChatSettingsSchema: z = z.object({ + linkOpening: z.union(['sidebar', 'new-tab']).default(DEFAULT_LINK_OPENING), performanceUsage: z.union([...PERFORMANCE_USAGE_MODES]).default(DEFAULT_PERFORMANCE_USAGE), [TRANSCRIPT_VIEW_FIELD]: z.union([...TRANSCRIPT_VIEW_MODES]).default(DEFAULT_TRANSCRIPT_VIEW_MODE), }) diff --git a/packages/client/ui-chat/src/client/apply.ts b/packages/client/ui-chat/src/client/apply.ts index 21a63ab782..079a5e2512 100644 --- a/packages/client/ui-chat/src/client/apply.ts +++ b/packages/client/ui-chat/src/client/apply.ts @@ -3,7 +3,7 @@ import type { Context } from '@deepseek-ai/cordis' import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type {} from '@deepseek-ai/dsh-api-remotes/client' import type { SessionBinding } from '@deepseek-ai/dsh-api-session-controller/client' -import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' +import { createSnapshotStore, type ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client' import type {} from '@deepseek-ai/dsh-client-ui-sidebar-browser/client' @@ -34,7 +34,8 @@ import { en, NS, zh } from './locale.ts' import { TranscriptViewRow, type TranscriptViewRowInjected } from './settings/TranscriptViewRow.tsx' import { createChatStore } from './stores.ts' import { TranscriptViewPolicy } from './transcript-view.ts' -import { CHAT_SETTINGS_NAMESPACE, type ChatSettings } from '../chat-settings.ts' +import { CHAT_SETTINGS_NAMESPACE, DEFAULT_LINK_OPENING, type ChatSettings } from '../chat-settings.ts' +import { LinkOpeningRow, type LinkOpeningRowInjected } from './settings/LinkOpeningRow.tsx' import { PerformanceUsageRow, type PerformanceUsageRowInjected } from './settings/PerformanceUsageRow.tsx' import { PerformanceUsagePolicy } from './performance-usage.ts' import { useTurnDataValue } from './chat/use-turn-data.ts' @@ -82,6 +83,33 @@ export function apply(ctx: Context): void { const chatStore = createChatStore() const chatScrollPositions = new Map() const chatSettings = ctx.settingsScope.bind({ namespace: CHAT_SETTINGS_NAMESPACE }) + const linkOpening = createSnapshotStore(chatSettings.getSnapshot().value?.linkOpening ?? DEFAULT_LINK_OPENING) + ctx.effect(() => chatSettings.subscribe(() => { + const accepted = chatSettings.getSnapshot().value?.linkOpening + if (accepted !== undefined) linkOpening.set(accepted) + })) + ctx.inject(['sidebarRightTabs'], (scope) => { + const tabs = scope.sidebarRightTabs + const browserAvailable: ObservableSnapshot = { + getSnapshot: () => tabs.get('browser') !== undefined, + subscribe: listener => tabs.subscribe(listener), + } + scope.slots.inject('settings.general.item', () => scope.slots.register({ + name: 'settings.general.item', + id: 'link-opening', + order: 14, + locale: NS, + inject: (): LinkOpeningRowInjected => ({ + hooks: { linkOpening, browserAvailable }, + setLinkOpening: (destination) => { + linkOpening.set(destination) + void chatSettings.set('linkOpening', destination).catch((_error: unknown) => { + // The local choice remains usable when persistence is unavailable. + }) + }, + }), + }, LinkOpeningRow)) + }) const transcriptView = new TranscriptViewPolicy(chatSettings) const performancePolicy = new PerformanceUsagePolicy(chatSettings) const performanceUsage = performancePolicy.mode @@ -158,7 +186,7 @@ export function apply(ctx: Context): void { ctx.get('inputTriggers')?.sessionOf(scope).openReference('skill', { ref: `/${name}` }) }, openExternalLink: (url) => { - if (ctx.get('sidebarRightTabs')?.get('browser') !== undefined) { + if (linkOpening.getSnapshot() === 'sidebar' && ctx.get('sidebarRightTabs')?.get('browser') !== undefined) { ctx.sidebarRight.openTab('browser', { params: { url } }) } else { window.open(url, '_blank', 'noopener,noreferrer') diff --git a/packages/client/ui-chat/src/client/contract/slots.ts b/packages/client/ui-chat/src/client/contract/slots.ts index d5b007b7cf..5e811b7963 100644 --- a/packages/client/ui-chat/src/client/contract/slots.ts +++ b/packages/client/ui-chat/src/client/contract/slots.ts @@ -150,7 +150,7 @@ export interface ChatViewInjected { } /** Open the current source file of a skill referenced by a sent message. */ openSkill: (name: string) => void - /** Open one HTTP(S) message link in a Sidebar Browser tab. */ + /** Open one HTTP(S) message link at the selected destination, using an external tab if Sidebar Browser is unavailable. */ openExternalLink: (url: string) => void openFile: (path: string, options?: OpenFileOptions) => Promise loadOlder: () => void diff --git a/packages/client/ui-chat/src/client/locale.ts b/packages/client/ui-chat/src/client/locale.ts index 344fef13d0..51d3adca00 100644 --- a/packages/client/ui-chat/src/client/locale.ts +++ b/packages/client/ui-chat/src/client/locale.ts @@ -31,6 +31,10 @@ export const zh = { 'settings.performance.description': '选择性能与用量信息展示的详细程度', 'settings.performance.compact': '简洁', 'settings.performance.detailed': '详细', + 'settings.links.title': '聊天链接打开方式', + 'settings.links.description': '选择聊天中 HTTP(S) 链接的打开位置', + 'settings.links.sidebar': '内置浏览器', + 'settings.links.newTab': '浏览器新标签页', 'settings.transcript.title': '对话显示', 'settings.transcript.description': '控制已完成轮次的过程内容', 'settings.transcript.normal': '标准', @@ -141,6 +145,10 @@ export const en = { 'settings.performance.description': 'Choose how much performance and usage information to show', 'settings.performance.compact': 'Compact', 'settings.performance.detailed': 'Detailed', + 'settings.links.title': 'Open chat links in', + 'settings.links.description': 'Choose where HTTP(S) links in chat open', + 'settings.links.sidebar': 'Built-in browser', + 'settings.links.newTab': 'New browser tab', 'settings.transcript.title': 'Conversation display', 'settings.transcript.description': 'Controls process content in completed turns', 'settings.transcript.normal': 'Normal', diff --git a/packages/client/ui-chat/src/client/settings/LinkOpeningRow.tsx b/packages/client/ui-chat/src/client/settings/LinkOpeningRow.tsx new file mode 100644 index 0000000000..b17a3e4929 --- /dev/null +++ b/packages/client/ui-chat/src/client/settings/LinkOpeningRow.tsx @@ -0,0 +1,47 @@ +/** General Settings row for Chat HTTP(S) link destinations. */ +import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' +import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { LinkOpening } from '../../chat-settings.ts' +import { PreferenceRow } from './PreferenceRow.tsx' + +/** Registration-side link-opening preference. */ +export interface LinkOpeningRowInjected { + hooks: { + /** Current destination, bound as useLinkOpening. */ + linkOpening: ObservableSnapshot + /** Whether the built-in browser is registered, bound as useBrowserAvailable. */ + browserAvailable: ObservableSnapshot + } + /** Change the default destination for Chat HTTP(S) links. */ + setLinkOpening: (destination: LinkOpening) => void +} + +/** Full Settings-row props. */ +export type LinkOpeningRowProps = + PropsRuntime<'settings.general.item'> + & PropsLocale<'chat'> + & InjectFace + +/** + * Render the link-opening destination selector. + * @param props - Composed Settings slot props. + * @returns The preference row. + */ +export function LinkOpeningRow({ useLinkOpening, useBrowserAvailable, setLinkOpening, t }: LinkOpeningRowProps) { + const destination = useLinkOpening(value => value) + const browserAvailable = useBrowserAvailable(value => value) + if (!browserAvailable) return null + return ( + { setLinkOpening(value as LinkOpening) }} + /> + ) +} diff --git a/packages/client/ui-chat/tests/apply-inject.client.spec.tsx b/packages/client/ui-chat/tests/apply-inject.client.spec.tsx index 7977740640..dff3bbe376 100644 --- a/packages/client/ui-chat/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-chat/tests/apply-inject.client.spec.tsx @@ -19,6 +19,8 @@ import { import { SessionSeq, type SessionId } from '@deepseek-ai/dsh-session/types' import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' import { createChatStore } from '../src/client/stores.ts' +import { CHAT_SETTINGS_NAMESPACE, type ChatSettings } from '../src/chat-settings.ts' +import type { LinkOpeningRowInjected } from '../src/client/settings/LinkOpeningRow.tsx' usePinnedBrowserLanguages('zh-CN') @@ -47,9 +49,16 @@ function sessionFakeFor() { } satisfies SessionBehaviorOverrides } -async function bench() { +async function bench(initialSettings?: ChatSettings, withBrowserRegistry = true) { const runtime = await SlotTestRuntime.create() - runtime.ctx.provide('settingsScope', { developerTools: { enabled: createSnapshotStore(true) }, bind: () => stubSettingsScope().scope } as never) + const chatSettings = stubSettingsScope() + if (initialSettings !== undefined) chatSettings.publish({ value: initialSettings }) + runtime.ctx.provide('settingsScope', { + developerTools: { enabled: createSnapshotStore(true) }, + bind: ({ namespace }: { namespace: string }) => namespace === CHAT_SETTINGS_NAMESPACE + ? chatSettings.scope + : stubSettingsScope().scope, + } as never) const layout = { closeRightbar: vi.fn(), openRightbar: vi.fn() } runtime.ctx.provide('layout', layout as never) const sidebarRight = { @@ -57,11 +66,13 @@ async function bench() { openTab: vi.fn<(kind: string, options?: unknown) => void>(), } runtime.ctx.provide('sidebarRight', sidebarRight as never) + const browserAvailable = createSnapshotStore(true) const sidebarRightTabs = { - get: vi.fn<(kind: string) => object | undefined>(() => ({})), + get: vi.fn<(kind: string) => object | undefined>(() => browserAvailable.getSnapshot() ? {} : undefined), + subscribe: (listener: () => void) => browserAvailable.subscribe(listener), register: vi.fn(() => () => {}), } - runtime.ctx.provide('sidebarRightTabs', sidebarRightTabs as never) + if (withBrowserRegistry) runtime.ctx.provide('sidebarRightTabs', sidebarRightTabs as never) runtime.ctx.provide('resources', { register: vi.fn(() => () => {}) } as never) const openWorkspacePath = vi.fn( () => Promise.resolve({ ok: true, value: { opened: true } }), @@ -88,9 +99,10 @@ async function bench() { runtime.slots.installLocale(locale) await runtime.root.declare({ 'main': { kind: 'keyed', scope: 'root' }, + 'settings.general.item': { kind: 'list', scope: 'root' }, }, (_props: { renderSlot?: unknown }) => null) await runtime.mount({ inject: [...injectConversation], apply: applyConversation }) - await runtime.mount({ inject: [...injectChat], apply: applyChat }) + const chat = await runtime.mount({ inject: [...injectChat], apply: applyChat }) runtime.renderRoot() const chatViewApi = (reference: SessionReference) => { @@ -104,7 +116,13 @@ async function bench() { return { instance, injected } } return { - runtime, layout, openWorkspacePath, sidebarRight, sidebarRightTabs, session, chatViewApi, rootReference, openSession, + get linkPreference() { + const row = runtime.slots.entries('settings.general.item').find(entry => entry.options.id === 'link-opening')! + const preference: object = row.inject!() + return preference as LinkOpeningRowInjected + }, + runtime, chat, chatSettings, browserAvailable, + layout, openWorkspacePath, sidebarRight, sidebarRightTabs, session, chatViewApi, rootReference, openSession, } } @@ -184,6 +202,106 @@ describe('Chat inject API', () => { } }) + it('applies restored and live link destinations without remounting the Chat view', async () => { + const settings: ChatSettings = { transcriptView: 'compact', performanceUsage: 'detailed', linkOpening: 'new-tab' } + const b = await bench(settings) + const open = vi.spyOn(window, 'open').mockImplementation(() => null) + try { + const { injected } = b.chatViewApi(b.rootReference) + const source = b.linkPreference.hooks.linkOpening + expect(source.getSnapshot()).toBe('new-tab') + injected.openExternalLink('https://example.test/restored') + expect(open).toHaveBeenLastCalledWith('https://example.test/restored', '_blank', 'noopener,noreferrer') + expect(b.sidebarRight.openTab).not.toHaveBeenCalled() + + b.chatSettings.publish({ value: { ...settings, linkOpening: 'sidebar' } }) + expect(source.getSnapshot()).toBe('sidebar') + injected.openExternalLink('http://example.test/live') + expect(b.sidebarRight.openTab).toHaveBeenLastCalledWith('browser', { params: { url: 'http://example.test/live' } }) + + b.linkPreference.setLinkOpening('new-tab') + expect(b.chatSettings.set).toHaveBeenCalledWith('linkOpening', 'new-tab') + injected.openExternalLink('http://example.test/selected') + expect(open).toHaveBeenLastCalledWith('http://example.test/selected', '_blank', 'noopener,noreferrer') + + await b.chat.dispose() + expect(b.runtime.slots.entries('settings.general.item').some(row => row.options.id === 'link-opening')).toBe(false) + b.chatSettings.publish({ value: { ...settings, linkOpening: 'sidebar' } }) + expect(source.getSnapshot()).toBe('new-tab') + } finally { + open.mockRestore() + await b.runtime.dispose() + } + }) + + it('follows browser registration and routes absent browsers externally', async () => { + const b = await bench() + const open = vi.spyOn(window, 'open').mockImplementation(() => null) + const available = b.linkPreference.hooks.browserAvailable + const changed = vi.fn() + const unsubscribe = available.subscribe(changed) + try { + const { injected } = b.chatViewApi(b.rootReference) + expect(available.getSnapshot()).toBe(true) + b.browserAvailable.set(false) + expect(available.getSnapshot()).toBe(false) + expect(changed).toHaveBeenCalledOnce() + injected.openExternalLink('https://example.test/disabled') + expect(open).toHaveBeenCalledWith('https://example.test/disabled', '_blank', 'noopener,noreferrer') + b.browserAvailable.set(true) + injected.openExternalLink('https://example.test/enabled') + expect(b.sidebarRight.openTab).toHaveBeenCalledWith('browser', { params: { url: 'https://example.test/enabled' } }) + expect(b.linkPreference.hooks.linkOpening.getSnapshot()).toBe('sidebar') + } finally { + unsubscribe() + open.mockRestore() + await b.runtime.dispose() + } + }) + + it('keeps Chat available while the optional tab registry appears and leaves', async () => { + const b = await bench(undefined, false) + try { + expect(b.runtime.slots.entries('conversation.view')).toHaveLength(1) + expect(b.runtime.slots.entries('settings.general.item').some(row => row.options.id === 'link-opening')).toBe(false) + const registry = await b.runtime.mount({ + apply(ctx) { ctx.provide('sidebarRightTabs', b.sidebarRightTabs as never) }, + }) + await vi.waitFor(() => { + expect(b.runtime.slots.entries('settings.general.item').some(row => row.options.id === 'link-opening')).toBe(true) + }) + await registry.dispose() + expect(b.runtime.slots.entries('settings.general.item').some(row => row.options.id === 'link-opening')).toBe(false) + expect(b.runtime.slots.entries('conversation.view')).toHaveLength(1) + } finally { + await b.runtime.dispose() + } + }) + + it('keeps local link choices usable when settings cannot persist', async () => { + const b = await bench() + const open = vi.spyOn(window, 'open').mockImplementation(() => null) + try { + const { injected } = b.chatViewApi(b.rootReference) + b.chatSettings.publish({ mode: 'memory', status: 'unavailable' }) + b.chatSettings.set.mockResolvedValueOnce(false) + b.linkPreference.setLinkOpening('new-tab') + b.chatSettings.publish({ value: undefined }) + injected.openExternalLink('https://example.test/local') + expect(open).toHaveBeenCalledWith('https://example.test/local', '_blank', 'noopener,noreferrer') + expect(b.sidebarRight.openTab).not.toHaveBeenCalled() + + b.chatSettings.set.mockRejectedValueOnce(new Error('settings unavailable')) + b.linkPreference.setLinkOpening('sidebar') + await Promise.resolve() + injected.openExternalLink('https://example.test/local-sidebar') + expect(b.sidebarRight.openTab).toHaveBeenCalledWith('browser', { params: { url: 'https://example.test/local-sidebar' } }) + } finally { + open.mockRestore() + await b.runtime.dispose() + } + }) + it('routes sent skill previews through the viewed Session source and tolerates an absent provider', async () => { const b = await bench() const { injected } = b.chatViewApi(b.rootReference) diff --git a/packages/client/ui-chat/tests/chat-apply.client.spec.tsx b/packages/client/ui-chat/tests/chat-apply.client.spec.tsx index 028752d24c..e085bba34e 100644 --- a/packages/client/ui-chat/tests/chat-apply.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-apply.client.spec.tsx @@ -47,7 +47,11 @@ async function bench() { } as never) runtime.ctx.provide('layout', { openRightbar: vi.fn(), closeRightbar: vi.fn() } as never) runtime.ctx.provide('sidebarRight', { openResource: vi.fn(), openTab: vi.fn() } as never) - runtime.ctx.provide('sidebarRightTabs', { register: vi.fn(() => () => {}) } as never) + runtime.ctx.provide('sidebarRightTabs', { + register: vi.fn(() => () => {}), + get: vi.fn(() => ({})), + subscribe: vi.fn(() => () => {}), + } as never) runtime.ctx.provide('resources', { register: vi.fn(() => () => {}) } as never) const openSession = vi.fn<(id: SessionId) => void>() runtime.ctx.provide('uiWorkspace', { @@ -94,7 +98,7 @@ describe('Chat apply wiring', () => { expect(b.runtime.slots.entries('conversation.composer.dock').map(row => row.options.id)) .toEqual(['stats']) expect(b.runtime.slots.entries('settings.general.item').map(row => row.options.id)) - .toEqual(['transcript-view', 'performance-usage', 'composer-enter']) + .toEqual(['transcript-view', 'performance-usage', 'link-opening', 'composer-enter']) await b.runtime.dispose() }) @@ -110,7 +114,7 @@ describe('Chat apply wiring', () => { expect(b.chatSettings.set).toHaveBeenCalledWith('transcriptView', 'normal') b.chatSettings.publish({ - status: 'ready', value: { transcriptView: 'compact', performanceUsage: 'detailed' }, revision: 1, writable: true, + status: 'ready', value: { linkOpening: 'sidebar', transcriptView: 'compact', performanceUsage: 'detailed' }, revision: 1, writable: true, }) expect(face.hooks.transcriptView.getSnapshot()).toBe('compact') await b.runtime.dispose() @@ -123,7 +127,7 @@ describe('Chat apply wiring', () => { expect(face.hooks.performanceUsage.getSnapshot()).toBe('detailed') face.setPerformanceUsage('compact') expect(b.chatSettings.set).toHaveBeenCalledWith('performanceUsage', 'compact') - b.chatSettings.publish({ value: { transcriptView: 'compact', performanceUsage: 'compact' } }) + b.chatSettings.publish({ value: { linkOpening: 'sidebar', transcriptView: 'compact', performanceUsage: 'compact' } }) expect(face.hooks.performanceUsage.getSnapshot()).toBe('compact') for (const entry of [ b.runtime.slots.entries('conversation.composer.dock').find(entry => entry.options.id === 'stats')!, diff --git a/packages/client/ui-chat/tests/chat-settings.client.spec.ts b/packages/client/ui-chat/tests/chat-settings.client.spec.ts index ad5ae6ab89..d2c146c40d 100644 --- a/packages/client/ui-chat/tests/chat-settings.client.spec.ts +++ b/packages/client/ui-chat/tests/chat-settings.client.spec.ts @@ -21,14 +21,20 @@ describe('ui-chat Host settings', () => { await fiber.await() const ns = CHAT_SETTINGS_NAMESPACE - expect(ctx.settings.get(ns)).toEqual({ transcriptView: DEFAULT_TRANSCRIPT_VIEW_MODE, performanceUsage: 'detailed' }) + expect(ctx.settings.get(ns)).toEqual({ transcriptView: DEFAULT_TRANSCRIPT_VIEW_MODE, performanceUsage: 'detailed', linkOpening: 'sidebar' }) await ctx.settings.update(ns, { transcriptView: 'normal' }) - expect(ctx.settings.get(ns)).toEqual({ transcriptView: 'normal', performanceUsage: 'detailed' }) + expect(ctx.settings.get(ns)).toEqual({ transcriptView: 'normal', performanceUsage: 'detailed', linkOpening: 'sidebar' }) await expect(ctx.settings.update(ns, { transcriptView: 'dense' })).rejects.toThrow() await ctx.settings.update(ns, { performanceUsage: 'compact' }) expect(ctx.settings.get(ns)).toMatchObject({ performanceUsage: 'compact' }) await expect(ctx.settings.update(ns, { performanceUsage: 'hidden' })).rejects.toThrow() + await ctx.settings.update(ns, { linkOpening: 'new-tab' }) + expect(ctx.settings.get(ns)).toMatchObject({ linkOpening: 'new-tab' }) + await ctx.settings.update(ns, { linkOpening: 'sidebar' }) + expect(ctx.settings.get(ns)).toMatchObject({ linkOpening: 'sidebar' }) + await expect(ctx.settings.update(ns, { linkOpening: 'popup' })).rejects.toThrow() + await fiber.dispose() expect(ctx.settings.describe().map(row => row.ns)).not.toContain(ns) }) diff --git a/packages/client/ui-chat/tests/performance-usage.client.spec.ts b/packages/client/ui-chat/tests/performance-usage.client.spec.ts index 00e862cfe6..54ddfdd1de 100644 --- a/packages/client/ui-chat/tests/performance-usage.client.spec.ts +++ b/packages/client/ui-chat/tests/performance-usage.client.spec.ts @@ -21,10 +21,10 @@ describe('PerformanceUsagePolicy', () => { it('adopts accepted Host settings at construction and after updates', () => { const host = stubSettingsScope() - host.publish({ value: { transcriptView: 'normal', performanceUsage: 'compact' } }) + host.publish({ value: { linkOpening: 'sidebar', transcriptView: 'normal', performanceUsage: 'compact' } }) const policy = new PerformanceUsagePolicy(host.scope) expect(policy.mode.getSnapshot()).toBe('compact') - host.publish({ value: { transcriptView: 'normal', performanceUsage: 'detailed' } }) + host.publish({ value: { linkOpening: 'sidebar', transcriptView: 'normal', performanceUsage: 'detailed' } }) expect(policy.mode.getSnapshot()).toBe('detailed') }) }) diff --git a/packages/client/ui-chat/tests/transcript-view-policy.client.spec.ts b/packages/client/ui-chat/tests/transcript-view-policy.client.spec.ts index 84717bb5c0..ec8595496a 100644 --- a/packages/client/ui-chat/tests/transcript-view-policy.client.spec.ts +++ b/packages/client/ui-chat/tests/transcript-view-policy.client.spec.ts @@ -30,18 +30,18 @@ describe('TranscriptViewPolicy', () => { const host = stubSettingsScope() const policy = new TranscriptViewPolicy(host.scope) - host.publish({ status: 'ready', value: { transcriptView: 'normal', performanceUsage: 'detailed' }, revision: 1, writable: true }) + host.publish({ status: 'ready', value: { linkOpening: 'sidebar', transcriptView: 'normal', performanceUsage: 'detailed' }, revision: 1, writable: true }) expect(policy.mode.getSnapshot()).toBe('normal') policy.setMode('normal') expect(host.set).not.toHaveBeenCalled() - host.publish({ value: { transcriptView: 'compact', performanceUsage: 'detailed' }, revision: 2 }) + host.publish({ value: { linkOpening: 'sidebar', transcriptView: 'compact', performanceUsage: 'detailed' }, revision: 2 }) expect(policy.mode.getSnapshot()).toBe('compact') }) it('adopts an accepted section standing at construction', () => { const host = stubSettingsScope() - host.publish({ status: 'ready', value: { transcriptView: 'normal', performanceUsage: 'detailed' }, revision: 1, writable: true }) + host.publish({ status: 'ready', value: { linkOpening: 'sidebar', transcriptView: 'normal', performanceUsage: 'detailed' }, revision: 1, writable: true }) expect(new TranscriptViewPolicy(host.scope).mode.getSnapshot()).toBe('normal') }) }) diff --git a/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx b/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx index ca5aeb1e00..82c928ac97 100644 --- a/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx +++ b/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' -import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client' import type { SessionStatusSnapshot } from '@deepseek-ai/dsh-client-ui-session/client' @@ -9,7 +9,8 @@ import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { TranscriptViewRow, type TranscriptViewRowProps } from '../src/client/settings/TranscriptViewRow.tsx' import { PerformanceUsageRow } from '../src/client/settings/PerformanceUsageRow.tsx' -import type { PerformanceUsageMode } from '../src/chat-settings.ts' +import type { LinkOpening, PerformanceUsageMode } from '../src/chat-settings.ts' +import { LinkOpeningRow } from '../src/client/settings/LinkOpeningRow.tsx' import { en, zh } from '../src/client/locale.ts' afterEach(cleanup) @@ -80,6 +81,50 @@ describe('TranscriptViewRow', () => { }) +describe('LinkOpeningRow', () => { + it('follows browser availability without clearing the saved destination', () => { + const b = mount() + const browserAvailable = createSnapshotStore(false) + const source = createSnapshotStore('new-tab') + render() + expect(screen.queryByText('Open chat links in')).toBeNull() + act(() => { browserAvailable.set(true) }) + expect(screen.getByRole('button', { name: 'New browser tab' })).toBeDefined() + act(() => { browserAvailable.set(false) }) + expect(screen.queryByText('Open chat links in')).toBeNull() + expect(source.getSnapshot()).toBe('new-tab') + }) + + it.each([ + [en, 'Open chat links in', 'Built-in browser', 'New browser tab'], + [zh, '聊天链接打开方式', '内置浏览器', '浏览器新标签页'], + ] as const)('selects either destination using localized labels (%s)', (dictionary, title, sidebar, newTab) => { + const b = mount('compact', dictionary) + const source = createSnapshotStore('sidebar') + const setLinkOpening = vi.fn((destination: LinkOpening) => { source.set(destination) }) + render() + expect(screen.getByText(title)).toBeDefined() + fireEvent.click(screen.getByRole('button', { name: sidebar })) + fireEvent.click(screen.getByRole('menuitem', { name: newTab })) + expect(setLinkOpening).toHaveBeenLastCalledWith('new-tab') + fireEvent.click(screen.getByRole('button', { name: newTab })) + fireEvent.click(screen.getByRole('menuitem', { name: sidebar })) + expect(setLinkOpening).toHaveBeenLastCalledWith('sidebar') + expect(screen.getByRole('button', { name: sidebar }).getAttribute('aria-expanded')).toBe('false') + expect(b.setTranscriptView).not.toHaveBeenCalled() + }) +}) + describe('PerformanceUsageRow', () => { it('selects compact statistics independently of conversation display', () => { const b = mount() diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 74cd3168df..b871029e8d 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: fef3a6551b517af2ac636687883864525aac70ee -README.zh.md: e9ab08c686420c38c11ca2bede2fd00099a247a5 +README.md: 16e821ed4caabae491d552a823bd69a2e976e2bd +README.zh.md: 7316b00c69ca74284b81678082d93e870dee6bbe diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index fef3a6551b..16e821ed4c 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -40,7 +40,7 @@ The shared image slot props keep display choices separate from durable reference The context-occupancy button shows a ring and percentage below the input card, after the Session statistics. Clicking it opens the token breakdown in a panel kept inside the viewport, including when no statistics are shown; the button stays hidden until context usage and capacity are available. -With Developer tools off, the shell exposes only Chat and hides the View tab bar. Turning it off while another View is active returns to Chat; stored View preferences and recorded Session data remain intact. Turning it on makes registered Views available again. View owners receive the available roster so their navigation actions can follow the same visibility. +With Developer tools off, the shell hides only Trajectory; other plugin-contributed Views remain available. The View tab bar is hidden when fewer than two Views are available. Turning Developer tools off while Trajectory is active returns to Chat; stored View preferences and recorded Session data remain intact. Turning it on makes Trajectory available again. View owners receive the available roster so their navigation actions can follow the same visibility. The composer registers the File command action and owns its label, availability, and native file-dialog callback. Menu availability and invocation both consult the mounted composer's current attachment-intake policy. Unmounting or locking the composer disables that action; disposing the plugin removes its registration. The callback binding stays inside the input module. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index e9ab08c686..7316b00c69 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -40,7 +40,7 @@ target package 通过 declaration merge 扩展 snapshot 与 Location data map, 上下文占用按钮在输入卡片下方、会话统计右侧显示圆环和百分比。点击按钮可在视口内的面板查看 token 构成,没有统计项时面板也不会越界;上下文用量和容量尚不可用时,按钮保持隐藏。 -关闭开发者工具时,外壳仅展示 Chat 并隐藏 View 标签栏。在其他 View 激活时关闭开关会返回 Chat;已保存的 View 偏好和 Session 记录保持不变。开启后,已注册的 View 恢复可用。View 所有者接收可用列表,使导航操作跟随相同的可见性。 +关闭开发者工具时,外壳仅隐藏 Trajectory;其他插件贡献的 View 仍然可用。可用 View 少于两个时隐藏 View 标签栏。在 Trajectory 激活时关闭开发者工具会返回 Chat;已保存的 View 偏好和 Session 记录保持不变。开启后,Trajectory 恢复可用。View 所有者接收可用列表,使导航操作跟随相同的可见性。 输入框注册「文件」命令动作,负责其标题、可用性和原生文件选择器回调。菜单可用性与实际调用都读取已挂载输入框当前的附件接收策略。输入框卸载或锁定后该动作不可用,插件 dispose(资源释放)时移除注册。回调绑定留在输入模块内部。 diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 3fef945990..07d754d873 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -35,7 +35,7 @@ import { ConversationHeader } from './skeleton/ConversationHeader.tsx' import { ConversationSession, ConversationSessionHeader } from './skeleton/ConversationSession.tsx' import { InputBar } from './skeleton/InputBar.tsx' import { todoDockEntry } from './skeleton/TodoPanel.tsx' -import { DEFAULT_VIEW_ID, resolveActiveView } from './view-selection.ts' +import { DEVELOPER_TOOLS_VIEW_ID, resolveActiveView } from './view-selection.ts' import { en, NS, zh, type ConversationKey } from './locales.ts' import { CONVERSATION_SETTINGS_NAMESPACE, type ConversationSettings } from '../submission-settings.ts' @@ -159,7 +159,7 @@ export function apply(ctx: Context, config: Config = Config({})): void { for (const entry of slots.entries('conversation.view')) { /* v8 ignore next -- list registration validates id at load. */ if (entry.options.id === undefined) continue - if (!ctx.settingsScope.developerTools.enabled.getSnapshot() && entry.options.id !== DEFAULT_VIEW_ID) continue + if (!ctx.settingsScope.developerTools.enabled.getSnapshot() && entry.options.id === DEVELOPER_TOOLS_VIEW_ID) continue tabs.push({ id: entry.options.id, label: resolveSlotLabel(entry.options.label) ?? entry.options.id, diff --git a/packages/client/ui-conversation/src/client/view-selection.ts b/packages/client/ui-conversation/src/client/view-selection.ts index 0d777c9b7d..6d0589759c 100644 --- a/packages/client/ui-conversation/src/client/view-selection.ts +++ b/packages/client/ui-conversation/src/client/view-selection.ts @@ -1,8 +1,11 @@ import type { ViewTab } from './contract/views.ts' -/** Chat remains available when developer tools are disabled. */ +/** Fallback View selected when no stored preference resolves to a registered View. */ export const DEFAULT_VIEW_ID = 'chat' +/** The one View the Developer tools preference gates; other Views stay registered by their owners. */ +export const DEVELOPER_TOOLS_VIEW_ID = 'trajectory' + /** * Resolve a preferred registered View, then Chat, without choosing another View. * @param tabs - currently registered Views. diff --git a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx index 6c12f28cfb..e90a11a163 100644 --- a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx @@ -201,24 +201,46 @@ describe('Conversation inject API', () => { const unsubscribe = source.subscribe(changed) expect(source.getSnapshot()).toBeUndefined() const removeDefinition = b.runtime.ctx.uiConversation.views.register({ - target: 'custom-inspector', + target: 'trajectory', toolCallFocus: callId => `tool:${callId}`, create: () => ({ empty: null, replace: () => null, apply: () => null }), }) expect(source.getSnapshot()).toBeUndefined() const removeView = b.slots.register( - { name: 'conversation.view', id: 'custom-inspector' }, (() => null) as never, + { name: 'conversation.view', id: 'trajectory' }, (() => null) as never, ) await b.runtime.flush() const inspect = source.getSnapshot()! expect(inspect).toBeTypeOf('function') expect(source.getSnapshot()).toBe(inspect) inspect('call-1') - expect(body.instance.store.getSnapshot().viewRequest).toEqual({ view: 'custom-inspector', focus: 'tool:call-1' }) + expect(body.instance.store.getSnapshot().viewRequest).toEqual({ view: 'trajectory', focus: 'tool:call-1' }) await b.runtime.ctx.settingsScope.developerTools.setEnabled(false) expect(source.getSnapshot()).toBeUndefined() await b.runtime.ctx.settingsScope.developerTools.setEnabled(true) expect(source.getSnapshot()).toBe(inspect) + // Trajectory still owns inspection while both Views are visible; hiding + // it must leave a third-party View's inspection capability reachable. + const removePipelineDefinition = b.runtime.ctx.uiConversation.views.register({ + target: 'pipeline', + toolCallFocus: callId => `stage:${callId}`, + create: () => ({ empty: null, replace: () => null, apply: () => null }), + }) + const removePipelineView = b.slots.register( + { name: 'conversation.view', id: 'pipeline' }, (() => null) as never, + ) + await b.runtime.flush() + expect(source.getSnapshot()).toBe(inspect) + await b.runtime.ctx.settingsScope.developerTools.setEnabled(false) + const pipelineInspect = source.getSnapshot()! + expect(pipelineInspect).toBeTypeOf('function') + pipelineInspect('call-2') + expect(body.instance.store.getSnapshot().viewRequest).toEqual({ view: 'pipeline', focus: 'stage:call-2' }) + await b.runtime.ctx.settingsScope.developerTools.setEnabled(true) + removePipelineView() + removePipelineDefinition() + await b.runtime.flush() + expect(source.getSnapshot()).toBe(inspect) removeView() await b.runtime.flush() expect(source.getSnapshot()).toBeUndefined() diff --git a/packages/client/ui-conversation/tests/apply-wiring.client.spec.tsx b/packages/client/ui-conversation/tests/apply-wiring.client.spec.tsx index 32e2c1dcac..aca7780d30 100644 --- a/packages/client/ui-conversation/tests/apply-wiring.client.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-wiring.client.spec.tsx @@ -44,18 +44,18 @@ function entry( } describe('target-neutral Conversation apply wiring', () => { - it('offers only Chat while developer tools are disabled and restores registered views when enabled', async () => { + it('hides only the Trajectory View while developer tools are disabled and restores it when enabled', async () => { const b = await bench() const header = b.runtime.slots.entries('conversation.session.header')[0]! const source = (header.inject!() as { hooks: { conversationViews: ObservableSnapshot } }).hooks.conversationViews - for (const id of ['chat', 'trajectory']) { + for (const id of ['chat', 'trajectory', 'probe']) { b.runtime.slots.register({ name: 'conversation.view', id, label: id }, (() => null) as never) } - await vi.waitFor(() => { expect(source.getSnapshot().map(tab => tab.id)).toEqual(['chat', 'trajectory']) }) + await vi.waitFor(() => { expect(source.getSnapshot().map(tab => tab.id)).toEqual(['chat', 'trajectory', 'probe']) }) b.developerTools.set(false) - expect(source.getSnapshot().map(tab => tab.id)).toEqual(['chat']) + expect(source.getSnapshot().map(tab => tab.id)).toEqual(['chat', 'probe']) b.developerTools.set(true) - expect(source.getSnapshot().map(tab => tab.id)).toEqual(['chat', 'trajectory']) + expect(source.getSnapshot().map(tab => tab.id)).toEqual(['chat', 'trajectory', 'probe']) await b.runtime.dispose() }) it('waits for the layout-owned conversation declaration before registering its subtree', async () => { diff --git a/packages/client/ui-plan/README.i18n.yaml b/packages/client/ui-plan/README.i18n.yaml index 74be8bbf76..24fc6a38eb 100644 --- a/packages/client/ui-plan/README.i18n.yaml +++ b/packages/client/ui-plan/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-plan/README.md -README.md: b6072cd7d75446a0a45abe4009273235e4ff4e1b -README.zh.md: 44b5ce85ccd6250f30904f80b01ee083f804f1ce +README.md: 96ecd3777ef0ce4ce6babd977eef055ad5c683ec +README.zh.md: fb6eb850b868913bdccec175500ff4e26d7a2157 diff --git a/packages/client/ui-plan/README.md b/packages/client/ui-plan/README.md index b6072cd7d7..96ecd3777e 100644 --- a/packages/client/ui-plan/README.md +++ b/packages/client/ui-plan/README.md @@ -51,7 +51,7 @@ Admission failures (`matched: false`, business errors, transport faults) surface The chip occupies the conversation-declared `conversation.input.plan` single seat; the node half is an empty apply (the roster row). Reads ride the generic projection pair through the standard-kit `useProjection`: the effective target is `pending ? !active : active` — a folded host value, not client optimism, so an arriving frame corrects the chip either way. The seat's injected face carries one verb, `exitPlanMode`, which executes `/plan off` through `ctx.remote.commands.execute` and maps admission failures to an inline error line. The placeholder and hint text live in ui-conversation's `conversation` locale namespace and are shared verbatim with the claimed `/plan` command hint. -Plan cards derive from native `tool/call` or PTC dispatch arguments through a Conversation Definition, with each invocation’s resolved Turn location. They contribute to the additive `conversation.chat.turnTail` list alongside file deliveries. The plan resource address identifies the invocation and its complete ordinary or direct-parent subagent Session address; its provider reads existing Session history, including older pages, without storing document text in sidebar layout. The question plugin owns the review action slot and supplies its request key, complete text, and optional invocation identity. The [decision](../../../.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.md) explains why review lifetime and document lifetime remain separate. +Plan cards derive from native `tool/call` or PTC dispatch arguments through a Conversation Definition, with each invocation’s resolved Turn location. They contribute to the additive `conversation.chat.turnTail` list alongside file deliveries. The plan resource address identifies the invocation and its complete ordinary or direct-parent subagent Session address; its provider reads existing Session history, including older pages, without storing document text in sidebar layout. The question plugin owns the review action slot and supplies its request key, complete text, and optional invocation identity. The [decision](../../../.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.md) explains why review lifetime and document lifetime remain separate. Subagent plan addresses also preserve unknown mode so history reads can resolve the child descriptor. diff --git a/packages/client/ui-plan/README.zh.md b/packages/client/ui-plan/README.zh.md index 44b5ce85cc..fb6eb850b8 100644 --- a/packages/client/ui-plan/README.zh.md +++ b/packages/client/ui-plan/README.zh.md @@ -51,7 +51,7 @@ kind: "package-reference" 徽章占据 conversation 声明的 `conversation.input.plan` 单实例座位;node 半部是空 apply(roster 行)。读取经 standard-kit 的 `useProjection` 走通用投影对:有效目标是 `pending ? !active : active`——折叠的宿主值而非客户端乐观态,因此到达的帧无论哪个方向都会纠正徽章。座位注入面携带一个动词 `exitPlanMode`,经 `ctx.remote.commands.execute` 执行 `/plan off`,并把准入失败映射为一行内联错误。placeholder 与提示文案位于 ui-conversation 的 `conversation` locale 命名空间,与已认领 `/plan` 命令的提示逐字共用。 -计划卡片通过 Conversation Definition 从原生 `tool/call` 或 PTC dispatch 参数派生,并使用每次调用已解析的回合位置。它们与文件产物一起贡献到可追加的 `conversation.chat.turnTail` 列表。计划资源地址标识调用及完整的普通会话地址或子会话直接父级地址;provider 读取已有会话历史及较早分页,不把正文存入侧边栏布局。提问插件拥有审批操作插槽,并提供请求键、完整正文和可选的调用标识。[决策记录](../../../.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.zh.md)说明审批与文档为何保持独立生命周期。 +计划卡片通过 Conversation Definition 从原生 `tool/call` 或 PTC dispatch 参数派生,并使用每次调用已解析的回合位置。它们与文件产物一起贡献到可追加的 `conversation.chat.turnTail` 列表。计划资源地址标识调用及完整的普通会话地址或子会话直接父级地址;provider 读取已有会话历史及较早分页,不把正文存入侧边栏布局。提问插件拥有审批操作插槽,并提供请求键、完整正文和可选的调用标识。[决策记录](../../../.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.zh.md)说明审批与文档为何保持独立生命周期。 子代理计划地址也保留未知模式,以便历史读取解析子 descriptor。 diff --git a/packages/client/ui-plan/src/client/plan.ts b/packages/client/ui-plan/src/client/plan.ts index e3dff30e29..d44b8f2dd9 100644 --- a/packages/client/ui-plan/src/client/plan.ts +++ b/packages/client/ui-plan/src/client/plan.ts @@ -74,7 +74,7 @@ export function parsePlanAddress(address: string): PlanAddress | undefined { if (parts.length === 2) return { session: { kind: 'session', sessionId: parts[0] as SessionId }, callId: parts[1] as ToolCallId, } - if (parts.length === 5 && parts[0] === 'subagent' && (parts[3] === 'one-shot' || parts[3] === 'continuable')) return { + if (parts.length === 5 && parts[0] === 'subagent' && (parts[3] === 'one-shot' || parts[3] === 'continuable' || parts[3] === 'unknown')) return { session: { kind: 'subagent', parentSessionId: parts[1] as SessionId, childSessionId: parts[2] as SessionId, mode: parts[3] }, callId: parts[4] as ToolCallId, } diff --git a/packages/client/ui-plan/tests/plan-resource.client.spec.ts b/packages/client/ui-plan/tests/plan-resource.client.spec.ts index a27b03c185..57be9acd36 100644 --- a/packages/client/ui-plan/tests/plan-resource.client.spec.ts +++ b/packages/client/ui-plan/tests/plan-resource.client.spec.ts @@ -36,7 +36,7 @@ describe('plan history resource', () => { expect(b.page).toHaveBeenCalledWith({ address: { kind: 'session', sessionId: 'session' }, throughSeq: 100, beforeSeq: 80 }, expect.any(AbortSignal)) expect(b.closed).toHaveBeenCalledOnce() }) - it.each(['one-shot', 'continuable'] as const)('restores a %s subagent plan with its complete parent address on every page', async (mode) => { + it.each(['one-shot', 'continuable', 'unknown'] as const)('restores a %s subagent plan with its complete parent address on every page', async (mode) => { const b = setup([{ type: 'event', event: { type: 'user/message', seq: 80, data: {} } }], true) const address = `dsh-resource://plan/subagent/parent/child/${mode}/call` const session = { kind: 'subagent', parentSessionId: 'parent', childSessionId: 'child', mode } diff --git a/packages/client/ui-plugin-manager/README.i18n.yaml b/packages/client/ui-plugin-manager/README.i18n.yaml index dfbe7fcedb..df24d45717 100644 --- a/packages/client/ui-plugin-manager/README.i18n.yaml +++ b/packages/client/ui-plugin-manager/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-plugin-manager/README.md -README.md: d7bcd2299d93dab90c8440565ac87112b37c9c10 -README.zh.md: 368058dabc27968b86088c34ee387dc5dd5bd364 +README.md: c04a614668d5afb2e09c1f923ac9be473a4aab5a +README.zh.md: f0b30c91cb87ce96349d92086673809139956e0a diff --git a/packages/client/ui-plugin-manager/README.md b/packages/client/ui-plugin-manager/README.md index d7bcd2299d..c04a614668 100644 --- a/packages/client/ui-plugin-manager/README.md +++ b/packages/client/ui-plugin-manager/README.md @@ -27,7 +27,7 @@ Use the **Plugins** entry in the Web sidebar to manage the profile's installed b Select **Plugins** in the sidebar. The page reads the inventory and the bundles through `api-remotes` when first opened; a Host without a managed profile shows the page as unavailable. **Official** comes first and lists the bundles the installation ships for switching on — off until switched on, without an uninstall, and tagged **Beta** where the feature is one — followed by the official plugins that registered a configuration page; **Installed** lists the bundles the profile holds. Cards are listed by name, so switching a bundle on or off does not move its card. A dependency without a bundle patch is not a plugin and is not listed unless the profile selects it, in which case it carries a problem tag. Global configuration remains in the Settings **Plugins** section. -Installed bundles and their plugin rows display their own title and description in the current UI language on cards and detail pages. Each field falls back from exported locale `meta` to the accessible `package.json` at that plugin address; the final title is the full package or module name, with no package description when both sources omit it. See [Plugin display metadata](../../../docs/cookbook/adding-a-package.md#plugin-display-metadata) for the author format. Installation previews still use registry or manifest information. +Installed bundles and their plugin rows display their own title and description in the current UI language on cards and detail pages. Each field falls back from exported locale `meta` to the accessible `package.json` at that plugin address; the final title is the full package or module name, with no package description when both sources omit it. See [Plugin display metadata](../../../docs/cookbook/adding-a-package.md#plugin-display-metadata) for the author format. Bundle cards, details, and component rows display the image declared by their own `package.json.icon`; absent or undecodable images retain the default artwork. Installation previews still use registry or manifest information. ### Installing a bundle diff --git a/packages/client/ui-plugin-manager/README.zh.md b/packages/client/ui-plugin-manager/README.zh.md index 368058dabc..f0b30c91cb 100644 --- a/packages/client/ui-plugin-manager/README.zh.md +++ b/packages/client/ui-plugin-manager/README.zh.md @@ -27,7 +27,7 @@ kind: "package-reference" 在侧栏选择**插件**。页面首次打开时通过 `api-remotes` 读取清单与组合包;没有受管 profile 的 Host 上页面显示为不可用。**官方**排在前面,列出安装随附、供开启的组合包——开启前保持关闭、没有卸载、属于 beta 功能的带 **Beta** 标签——其后是注册了配置页的官方插件;**已安装**列出 profile 持有的组合包。卡片按名称排序,启停组合包不会挪动它的卡片。没有组合包 patch 的依赖不是插件,除非 profile 选中了它才会带异常标签列出。全局配置仍在设置的**插件**分区中编辑。 -已安装的组合包及其插件行在卡片和详情页中,按当前界面语言显示各自的标题与描述。每个字段先读取导出的 locale `meta`,缺失时回退到该插件地址下可访问的 `package.json`;标题最终使用完整包名或模块名,两处都没有描述时不提供包描述。作者格式见[插件展示元信息](../../../docs/cookbook/adding-a-package.zh.md#plugin-display-metadata)。安装预览仍使用注册表或 manifest 信息。 +已安装的组合包及其插件行在卡片和详情页中,按当前界面语言显示各自的标题与描述。每个字段先读取导出的 locale `meta`,缺失时回退到该插件地址下可访问的 `package.json`;标题最终使用完整包名或模块名,两处都没有描述时不提供包描述。作者格式见[插件展示元信息](../../../docs/cookbook/adding-a-package.zh.md#plugin-display-metadata)。组合包卡片、详情和组件行显示各自 `package.json.icon` 声明的图片;未声明或无法解码时保留默认插画。安装预览仍使用注册表或 manifest 信息。 ### 安装一个组合包 diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css index 96286b3b16..a5f1da6197 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css @@ -202,6 +202,10 @@ color: var(--dsw-alias-label-secondary); } +.packageImage { + object-fit: contain; +} + .cardMain { flex: 1; min-width: 0; @@ -1112,13 +1116,13 @@ label.registryOption { line-height: 22px; } -/* The package name under the title: the exact spec that installs the package - elsewhere, where a title of its own or a shortened name hides it. */ +/* The package or row identity under the title: the exact spec that installs the + package elsewhere, where a title of its own or a shortened name hides it. */ .detailName { margin: 0; font-size: 12px; line-height: 18px; - color: var(--dsw-alias-label-caption); + color: var(--dsw-alias-label-tertiary); overflow-wrap: anywhere; } diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx index 5235d53cd0..5ef0212a3c 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx @@ -17,7 +17,7 @@ import { IconChevronRightOutlineRegular, IconCloseOutlineMedium, IconPlusOutlineRegular, IconRefreshOutlineRegular, IconTrashOutlineRegular, IconWarningOutlineRegular, Input, Modal, - PluginArtworkDefault, PluginArtworkLoop, PluginArtworkSearch, PluginArtworkSubagent, PluginArtworkTeam, PluginArtworkTerminal, + PluginArtworkDefault, PluginArtworkLoop, PluginArtworkSearch, PluginArtworkSubagent, PluginArtworkTerminal, StateDot, Switch, Tag, TerminalBlock, Toast, useAnchoredPosition, useDismissOnOutsidePointer, type IconProps, type StateDotState, type TerminalBlockLabels, } from '@deepseek-ai/dsh-client-ui-primitives' @@ -130,21 +130,23 @@ const ITEM_ARTWORK = new Map ReactNode>([ ['web-search', PluginArtworkSearch], ]) -/** The artwork of the official bundles with artwork of their own, by package name. */ -const PACKAGE_ARTWORK = new Map ReactNode>([ - ['@deepseek-ai/dsh-experimental-agent-team-profile', PluginArtworkTeam], -]) - /** An official plugin's card and page artwork; plugins without their own get the default. */ function itemArtwork(id: string): ReactNode { const Artwork = ITEM_ARTWORK.get(id) ?? PluginArtworkDefault return } -/** A package's card and page artwork; packages without their own get the default. */ -function packageArtwork(name: string): ReactNode { - const Artwork = PACKAGE_ARTWORK.get(name) ?? PluginArtworkDefault - return +/** Manifest images remain isolated from the page DOM; a failed decode keeps the position's default artwork. */ +function PackageArtwork({ src, row = false, size = row ? ROW_ARTWORK_SIZE : CARD_ARTWORK_SIZE }: { + readonly src: string | undefined + readonly row?: boolean + readonly size?: number +}): ReactNode { + const [failedSource, setFailedSource] = useState() + const Fallback = row ? PluginArtworkSubagent : PluginArtworkDefault + return src === undefined || src === failedSource + ? + : { setFailedSource(src) }} /> } /** A row's switch: locked, saying why, when the Host refuses to address the row through the profile patch. */ @@ -234,7 +236,7 @@ function RowsSection({ rows, t, resolveText, toggle, configure }: { {...row.phase === 'failed' ? { 'data-state': 'failed' } : row.enabled ? {} : { 'data-state': 'off' }} >
- +
{configure?.has(row) === true ? ( @@ -367,7 +369,7 @@ function PackageCard({ pkg, t, resolveText, busy, highlighted, onOpen, onSetEnab title={title} t={t} onOpen={onOpen} - icon={packageArtwork(pkg.name)} + icon={} tags={( <> {beta ? {t('statusBeta')} : null} @@ -439,7 +441,7 @@ function RowDetail({ pkg, row, t, resolveText, onBack, renderSlot }: { const key = rowConfigKey(pkg.name, row.rowId) return (
- } /> + } />

{rowTitle}

@@ -491,7 +493,7 @@ function PackageDetail({ crumbLabel={t('backToList')} crumbText={t('crumbRoot')} onBack={onBack} - icon={packageArtwork(pkg.name)} + icon={} actions={(
{pkg.installed diff --git a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx index 2226aa0476..d85336cf1a 100644 --- a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx +++ b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx @@ -285,6 +285,38 @@ describe('PluginManagerPage', () => { } }) + it('renders manifest icons for arbitrary bundles and rows, with decode fallback and source recovery', () => { + const icon = 'data:image/svg+xml;base64,PHN2Zy8+' + const updatedIcon = 'data:image/png;base64,cG5n' + const bundle = pkg({ meta: { icon }, rows: [row({ meta: { icon } }), row({ entryId: 'plain' as PluginEntryId, rowId: 'plain', moduleName: 'plain' })] }) + const { set } = renderTab({ packages: [bundle] }, { rows: new Set(['dsh-better-sidebar#sidebar']) }) + const image = () => document.querySelector('[data-plugin-package] img, [data-plugin-detail] img')! + expect(image().getAttribute('src')).toBe(icon) + expect(image().getAttribute('alt')).toBe('') + expect(image().width).toBe(36) + fireEvent.error(image()) + expect(document.querySelector('[data-plugin-package] img')).toBeNull() + expect(document.querySelector('[data-plugin-package] svg')).not.toBeNull() + set({ packages: [{ ...bundle, meta: { icon: updatedIcon } }] }) + expect(image().getAttribute('src')).toBe(updatedIcon) + set({ packages: [bundle] }) + expect(image().getAttribute('src')).toBe(icon) + fireEvent.click(screen.getByRole('button', { name: 'View dsh-better-sidebar' })) + expect(image().getAttribute('src')).toBe(icon) + const rowImage = document.querySelector('[data-plugin-row] img')! + expect(rowImage.getAttribute('src')).toBe(icon) + expect(rowImage.width).toBe(30) + expect(document.querySelector('[data-plugin-row="plain"] img')).toBeNull() + expect(document.querySelector('[data-plugin-row="plain"] svg')).not.toBeNull() + fireEvent.error(rowImage) + expect(document.querySelector('[data-plugin-row] img')).toBeNull() + expect(document.querySelector('[data-plugin-row] svg')).not.toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Configure dsh-better-sidebar' })) + const detailImage = document.querySelector('[data-plugin-row-detail] img')! + expect(detailImage.getAttribute('src')).toBe(icon) + expect(detailImage.width).toBe(36) + }) + it('shows metadata diagnostics without blocking management or displaying legacy descriptions', () => { const error = 'locale/zh.json: invalid title' const { actions, set, setLanguage } = renderTab({ diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 117f867484..42645dc258 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md -README.md: 7d8d1c333bed8b3e793f9dc22029d39d4d9882b2 -README.zh.md: 82e849d11343acaad1b8a09caadb5661aef94cbe +README.md: af770743768791f5778ba8dd41aa173757aa776e +README.zh.md: a28c1816cbb7aa985c22f5ab0040fc6e438d3524 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 7d8d1c333b..af77074376 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -57,7 +57,7 @@ Check this table before writing a control in a feature package. A plugin cannot | `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, `WebBlock` | The agent-output card matching each tool-result intent. | | `icons/*`, `FishLogo`, `BrandWordmark`, `ReferenceIconRegular`/`ReferenceIconMedium`, `LinkIconRegular`/`LinkIconMedium` | Glyphs and brand marks. Use `LinkIconMedium` for 14px clickable-link categories and known-site marks. | | `PermissionIconReadOnlyRegular`/`Medium`, `PermissionIconWorkspaceWriteRegular`/`Medium`, `PermissionIconFullAccessRegular`/`Medium` | Permission-mode glyphs for read-only, workspace-write, and full-access choices. | -| `PluginArtworkTeam`/`Terminal`/`Loop`/`Subagent`/`Search`/`Default` | Fixed-palette 36×36 plugin artwork for the plugin-management surfaces; `Default` marks plugins without artwork of their own. Def ids are per-instance, so the same artwork repeats safely on one page. | +| `PluginArtworkTerminal`/`Loop`/`Subagent`/`Search`/`Default` | Fixed-palette 36×36 plugin artwork for the plugin-management surfaces; `Default` marks plugins without artwork of their own. Def ids are per-instance, so the same artwork repeats safely on one page. | | `FileTypeIcon`, `classifyFileType`, `fileExtension` | A category-colored 28px file or folder glyph and the shared case-insensitive filename mapping behind it. Code and configuration files use detailed full-color technology glyphs; use `LinkIconMedium` for link-leading glyphs and image previews for image content. | | `languageForPath`, `CODE_HIGHLIGHT_EXTENSIONS`, `useCodeHighlighter` | The filename grammar selection and lazy line-token highlighter shared by code preview and diff review. | diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 82e849d113..a28c1816cb 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -57,7 +57,7 @@ kind: "package-library" | `TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock`、`WebBlock` | 与各类工具结果意图对应的 agent 输出卡片。 | | `icons/*`、`FishLogo`、`BrandWordmark`、`ReferenceIconRegular`/`ReferenceIconMedium`、`LinkIconRegular`/`LinkIconMedium` | 字形与品牌标识。`LinkIconMedium` 用于 14px 的可点击链接分类及已知站点标记。 | | `PermissionIconReadOnlyRegular`/`Medium`、`PermissionIconWorkspaceWriteRegular`/`Medium`、`PermissionIconFullAccessRegular`/`Medium` | 只读、工作区写入与完全访问选项使用的权限模式图形。 | -| `PluginArtworkTeam`/`Terminal`/`Loop`/`Subagent`/`Search`/`Default` | 插件管理界面使用的固定配色 36×36 插件插画;`Default` 用于没有自有插画的插件。def id 按实例生成,同一插画可在一页中安全重复。 | +| `PluginArtworkTerminal`/`Loop`/`Subagent`/`Search`/`Default` | 插件管理界面使用的固定配色 36×36 插件插画;`Default` 用于没有自有插画的插件。def id 按实例生成,同一插画可在一页中安全重复。 | | `FileTypeIcon`、`classifyFileType`、`fileExtension` | 按类别着色的 28px 文件或文件夹图形,以及它背后共享的不区分大小写文件名映射。代码与配置文件使用细分的全彩技术图形;链接前置图形使用 `LinkIconMedium`,图片内容使用图片预览。 | | `languageForPath`、`CODE_HIGHLIGHT_EXTENSIONS`、`useCodeHighlighter` | 代码预览与 diff review 共用的文件名 grammar 选择和惰性逐行 token 高亮。 | diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts index 2ad31f82b4..d98ad76158 100644 --- a/packages/client/ui-primitives/src/index.ts +++ b/packages/client/ui-primitives/src/index.ts @@ -83,5 +83,5 @@ export { extractMarkdownPlainText } from './markdown/plain-text.ts' export type { MarkdownPlainTextMode, MarkdownPlainTextOptions } from './markdown/plain-text.ts' export * from './icons/index.tsx' export { - PluginArtworkTeam, PluginArtworkTerminal, PluginArtworkLoop, PluginArtworkSubagent, PluginArtworkSearch, PluginArtworkDefault, + PluginArtworkTerminal, PluginArtworkLoop, PluginArtworkSubagent, PluginArtworkSearch, PluginArtworkDefault, } from './plugin-artwork.tsx' diff --git a/packages/client/ui-primitives/src/plugin-artwork.tsx b/packages/client/ui-primitives/src/plugin-artwork.tsx index ec54304d3c..c5a774ca81 100644 --- a/packages/client/ui-primitives/src/plugin-artwork.tsx +++ b/packages/client/ui-primitives/src/plugin-artwork.tsx @@ -12,25 +12,6 @@ import type { IconProps } from './icons/props.ts' */ const useArtworkId = (): string => `dsh_plugin_art_${useId().replaceAll(':', '')}` -/** Agent-team plugin artwork (four interlocking quadrants). */ -export const PluginArtworkTeam = ({ size = 36, className }: IconProps) => { - const uid = useArtworkId() - return ( - - - - - - - - - - - - - ) -} - /** Terminal plugin artwork (prompt chevron and cursor bar). */ export const PluginArtworkTerminal = ({ size = 36, className }: IconProps) => ( diff --git a/packages/client/ui-primitives/tests/plugin-artwork.client.spec.tsx b/packages/client/ui-primitives/tests/plugin-artwork.client.spec.tsx index 0df9c9a573..5db7f79cb8 100644 --- a/packages/client/ui-primitives/tests/plugin-artwork.client.spec.tsx +++ b/packages/client/ui-primitives/tests/plugin-artwork.client.spec.tsx @@ -2,13 +2,12 @@ import { cleanup, render } from '@testing-library/react' import { afterEach, describe, expect, it } from 'vitest' import { - PluginArtworkDefault, PluginArtworkLoop, PluginArtworkSearch, PluginArtworkSubagent, PluginArtworkTeam, PluginArtworkTerminal, + PluginArtworkDefault, PluginArtworkLoop, PluginArtworkSearch, PluginArtworkSubagent, PluginArtworkTerminal, } from '@deepseek-ai/dsh-client-ui-primitives' afterEach(cleanup) const artworks = { - PluginArtworkTeam, PluginArtworkTerminal, PluginArtworkLoop, PluginArtworkSubagent, diff --git a/packages/client/ui-settings-general/README.i18n.yaml b/packages/client/ui-settings-general/README.i18n.yaml index a485e99405..2f86bc5b7c 100644 --- a/packages/client/ui-settings-general/README.i18n.yaml +++ b/packages/client/ui-settings-general/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-settings-general/README.md -README.md: cf8bb5a75aa90842108646b57ceec67ada8ae711 -README.zh.md: 7df3eee13df7bd041cc5f09288d1b7fe28acded6 +README.md: ebdb22adc071b5df435fddd6620dd982d866aa36 +README.zh.md: 0bb188ea5024b14e68baa1a687a654ee8963f3d7 diff --git a/packages/client/ui-settings-general/README.md b/packages/client/ui-settings-general/README.md index cf8bb5a75a..ebdb22adc0 100644 --- a/packages/client/ui-settings-general/README.md +++ b/packages/client/ui-settings-general/README.md @@ -103,6 +103,7 @@ None; this package neither assembles nor sends a provider request. These limits define what the shell itself provides versus what features must supply; they are current package constraints. - **Additional General rows require their feature plugins** — the shell supplies Developer tools; feature plugins supply the remaining preferences. +- **The Windows caption badge keeps a side-opening bubble** — `DesktopUpdateBadge` occupies `sidebar.toggle.badge` in the caption and requests `side="right"`, so the Desktop-owned menu text can cover its bubble while the sidebar is collapsed on Windows; the sidebar toggle and New Session bubbles open below the caption instead (#4688). ### Dev Note diff --git a/packages/client/ui-settings-general/README.zh.md b/packages/client/ui-settings-general/README.zh.md index 7df3eee13d..0bb188ea50 100644 --- a/packages/client/ui-settings-general/README.zh.md +++ b/packages/client/ui-settings-general/README.zh.md @@ -103,6 +103,7 @@ kind: "package-reference" 这些限制说明外壳自身提供什么、功能包必须提供什么;它们是当前包约束。 - **额外的通用设置行需要相应功能插件**:外壳提供开发者工具;功能插件提供其他偏好。 +- **Windows 顶栏徽标的气泡仍向右展开**:`DesktopUpdateBadge` 占用顶栏的 `sidebar.toggle.badge` 且请求 `side="right"`,侧栏收起时其气泡会被 Desktop 菜单文字遮挡;侧栏开关与新建会话的气泡则改在顶栏下方展开(#4688)。 ### 开发备注 diff --git a/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css b/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css index bbf984fb20..5358b03f67 100644 --- a/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css +++ b/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css @@ -178,13 +178,20 @@ font-weight: 600; } +/* The Loader entry id keeps one size in the card subtitle and the row it expands into; + the collapsed subtitle stays on the quieter label tier, while the expanded row carries + the card's primary value. */ +.cardIdentity, +.entryValue { + font-family: var(--ds-font-family-code); + font-size: 12px; + line-height: 18px; +} + .cardIdentity { display: block; width: 100%; color: var(--dsw-alias-label-tertiary); - font-family: var(--ds-font-family-code); - font-size: 11px; - line-height: 16px; } .cardTrailing { @@ -383,9 +390,6 @@ display: block; overflow-wrap: anywhere; color: var(--dsw-alias-label-primary); - font-family: var(--ds-font-family-code); - font-size: 12px; - line-height: 18px; } .details { diff --git a/packages/client/ui-settings/README.i18n.yaml b/packages/client/ui-settings/README.i18n.yaml index 1b479691fd..8123e08a21 100644 --- a/packages/client/ui-settings/README.i18n.yaml +++ b/packages/client/ui-settings/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-settings/README.md -README.md: d70cb35878d77c1d3e928aa222a2c7e96874ea2c -README.zh.md: 87687ac3ccc7f19d7b34f5724eba260a02e92044 +README.md: d688442cb003f980b037354f76997c18a2ca0be2 +README.zh.md: 92e2026d04f188854fd6205fe4d35e8c9c8e57b2 diff --git a/packages/client/ui-settings/README.md b/packages/client/ui-settings/README.md index d70cb35878..d688442cb0 100644 --- a/packages/client/ui-settings/README.md +++ b/packages/client/ui-settings/README.md @@ -29,7 +29,7 @@ Feature plugins use this package to store and edit their preferences without re- ### Binding a namespace -`ctx.settingsScope.developerTools` owns the shared Web and desktop preference `ui-developer-tools.enabled`, defaulting to `false`. Its `enabled` observable publishes accepted choices and `setEnabled` uses the same ordered settings writes. Desktop and loopback Web persist to the Host document; remote Web keeps this choice in one browser-local observable until reload without issuing Host writes. This controls presentation and HTML preview permissions, not Host authorization or Session recording. +`ctx.settingsScope.developerTools` owns the shared Web and desktop preference `ui-developer-tools.enabled`, defaulting to `true`. Its `enabled` observable publishes accepted choices and `setEnabled` uses the same ordered settings writes. Desktop and loopback Web persist to the Host document; remote Web keeps this choice in one browser-local observable until reload without issuing Host writes. This controls presentation and HTML preview permissions, not Host authorization or Session recording. Host-backed clients keep developer features disabled until the first accepted schema-resolved value arrives; missing or failed initial responses do not enable them. Later refreshes retain the last accepted choice. A feature calls `ctx.settingsScope.bind(spec)` with a per-namespace spec and gets a scope derived from the shared document mirror. The scope snapshot carries the resolved section, composition `base`, raw `user`, revision, writability, and host/memory mode; a field is overridden when it is present in `user`, even when its value equals `base`, and `unset` clears that override. Writes go through the scope: `set` and `unset` submit one operation, while `mutate` submits several ordered operations atomically. These methods return `true` for Host acceptance and `false` for refusal or skipped writes; transport failures reject. Each write is fenced by the namespace revision as `expectedRevision`, so a concurrent write from another surface is refused instead of silently overwritten. A staged editor can supply the revision where its draft began as a fixed fence; otherwise the scope uses the latest queued or mirrored revision. @@ -53,7 +53,7 @@ The package realizes one ownership rule: the browser keeps one shared mirror of ### The describe mirror -The Host half registers the `ui-developer-tools` schema and its default-off value with settings. The Client plugin injects `remote` with its `settings` namespace, resolves Host persistence once from the fixed `remote.$host` facts, and owns the one `settings.describe` reader in the browser: a shared mirror refreshed on every forwarded `settings/document-updated` event and on `connection/reset` (the first connection included, closing the window where a commit lands between the eager read and the SSE subscription). Cross-namespace surfaces read it through `ctx.settingsScope.describe()`, a read/fold face (`getSnapshot`/`subscribe`/`ensure`, plus `acceptView` folding a write answer in). +The Host half registers the `ui-developer-tools` schema and its default-on value with settings. The Client plugin injects `remote` with its `settings` namespace, resolves Host persistence once from the fixed `remote.$host` facts, and owns the one `settings.describe` reader in the browser: a shared mirror refreshed on every forwarded `settings/document-updated` event and on `connection/reset` (the first connection included, closing the window where a commit lands between the eager read and the SSE subscription). Cross-namespace surfaces read it through `ctx.settingsScope.describe()`, a read/fold face (`getSnapshot`/`subscribe`/`ensure`, plus `acceptView` folding a write answer in). ### Scope derivation diff --git a/packages/client/ui-settings/README.zh.md b/packages/client/ui-settings/README.zh.md index 87687ac3cc..92e2026d04 100644 --- a/packages/client/ui-settings/README.zh.md +++ b/packages/client/ui-settings/README.zh.md @@ -29,7 +29,7 @@ kind: "package-reference" ### 绑定命名空间 -`ctx.settingsScope.developerTools` 管理 Web 和桌面端共享的偏好 `ui-developer-tools.enabled`,默认为 `false`。其 `enabled` 可观察值发布已接受的选择,`setEnabled` 使用相同的有序设置写入。桌面端和回环 Web 将设置持久化到 Host 文档;远程 Web 将此选择保存在单个浏览器本地可观察值中,刷新后重置,不发送 Host 写入。此设置控制界面展示和 HTML 预览权限,不控制 Host 授权或 Session 记录。 +`ctx.settingsScope.developerTools` 管理 Web 和桌面端共享的偏好 `ui-developer-tools.enabled`,默认为 `true`。其 `enabled` 可观察值发布已接受的选择,`setEnabled` 使用相同的有序设置写入。桌面端和回环 Web 将设置持久化到 Host 文档;远程 Web 将此选择保存在单个浏览器本地可观察值中,刷新后重置,不发送 Host 写入。此设置控制界面展示和 HTML 预览权限,不控制 Host 授权或 Session 记录。 使用 Host 偏好的客户端在首个经过 schema 解析并接受的值到达前保持开发者功能关闭;首次响应缺失或失败不会启用它们。后续刷新保留已接受的选择。 功能调用 `ctx.settingsScope.bind(spec)` 并传入按命名空间的 spec,得到一个由共享文档镜像派生的 scope。scope 快照携带解析后的分区、组合 `base`、原始 `user`、revision、可写性以及 host/内存模式;字段只要出现在 `user` 中即视为覆盖,即使其值与 `base` 相等,`unset` 会清除该覆盖。写入经 scope 进行:`set` 与 `unset` 提交一个操作,`mutate` 则原子提交多个有序操作。这些方法在 Host 接受时返回 `true`,拒绝或跳过写入时返回 `false`;传输失败会 reject。每次写入都以命名空间 revision 作为 `expectedRevision` 围栏,因此来自另一界面的并发写入会被拒绝,而不是被静默覆盖。暂存编辑器可以把开始草拟时读取的 revision 作为固定围栏传入;否则 scope 使用最新排队或镜像 revision。 @@ -53,7 +53,7 @@ kind: "package-reference" ### Describe 镜像 -Host 部分向 settings 注册 `ui-developer-tools` schema 及其默认关闭值。Client 插件注入 `remote` 及其 `settings` 命名空间,从固定的 `remote.$host` 事实一次性解析 Host 持久化模式,并持有浏览器中唯一的 `settings.describe` 读取方:一面共享镜像,在每次转发的 `settings/document-updated` 事件与 `connection/reset` 时刷新(首次连接也包含在内,关闭「提交落在急切读取与 SSE 订阅之间」的窗口)。跨命名空间表面通过 `ctx.settingsScope.describe()` 读它,这是一个读取/折叠面(`getSnapshot`/`subscribe`/`ensure`,另有把写应答折入的 `acceptView`)。 +Host 部分向 settings 注册 `ui-developer-tools` schema 及其默认开启值。Client 插件注入 `remote` 及其 `settings` 命名空间,从固定的 `remote.$host` 事实一次性解析 Host 持久化模式,并持有浏览器中唯一的 `settings.describe` 读取方:一面共享镜像,在每次转发的 `settings/document-updated` 事件与 `connection/reset` 时刷新(首次连接也包含在内,关闭「提交落在急切读取与 SSE 订阅之间」的窗口)。跨命名空间表面通过 `ctx.settingsScope.describe()` 读它,这是一个读取/折叠面(`getSnapshot`/`subscribe`/`ensure`,另有把写应答折入的 `acceptView`)。 ### Scope 派生 diff --git a/packages/client/ui-settings/src/client/developer-tools.ts b/packages/client/ui-settings/src/client/developer-tools.ts index 22e1e8de3d..907b32065a 100644 --- a/packages/client/ui-settings/src/client/developer-tools.ts +++ b/packages/client/ui-settings/src/client/developer-tools.ts @@ -3,11 +3,11 @@ import { createSnapshotStore, type ObservableSnapshot } from '@deepseek-ai/dsh-c import type { DeveloperToolsSettings } from '../developer-tools-settings.ts' import type { SettingsScope } from './settings-contract.ts' -/** Shared preference; no accepted value means developer tools are disabled. */ +/** Shared preference; Host-backed features stay disabled until an accepted value arrives. */ export class DeveloperToolsPreference { /** Accepted enablement, observable through renderer-bound hooks. */ readonly enabled: ObservableSnapshot - private readonly local = createSnapshotStore(false) + private readonly local = createSnapshotStore(true) /** * @param scope - settings-owned namespace controller. diff --git a/packages/client/ui-settings/src/developer-tools-settings.ts b/packages/client/ui-settings/src/developer-tools-settings.ts index e7a64e0e26..9b80e5d31e 100644 --- a/packages/client/ui-settings/src/developer-tools-settings.ts +++ b/packages/client/ui-settings/src/developer-tools-settings.ts @@ -10,7 +10,7 @@ export interface DeveloperToolsSettings { enabled: boolean } -/** New installations and missing values use the basic interface. */ +/** New installations and missing values enable the full interface. */ export const DeveloperToolsSettingsSchema: z = z.object({ - enabled: z.boolean().default(false), + enabled: z.boolean().default(true), }) diff --git a/packages/client/ui-settings/tests/developer-tools.client.spec.ts b/packages/client/ui-settings/tests/developer-tools.client.spec.ts index 3a68290c19..08d9e0aa47 100644 --- a/packages/client/ui-settings/tests/developer-tools.client.spec.ts +++ b/packages/client/ui-settings/tests/developer-tools.client.spec.ts @@ -39,6 +39,39 @@ describe('developer tools settings', () => { expect(ctx.settingsScope.developerTools.enabled.getSnapshot()).toBe(false) }) + it.each([false, true])('withholds features during a delayed Host read before accepting %s', async (enabled) => { + const ctx = new Context() + const accepted = { ok: true, value: { + writable: true, hasDocument: true, namespaces: [{ + ns: DEVELOPER_TOOLS_NAMESPACE, + schema: DeveloperToolsSettingsSchema.toJSON(), + value: { enabled }, revision: 1, applies: 'live', secrets: [], + }], + } } + const pending = Promise.withResolvers() + onTestFinished(async () => { pending.resolve(accepted); await ctx.fiber.dispose() }) + new TestRemote(ctx, { settings: { describe: () => pending.promise } }) + await ctx.plugin({ inject, apply: clientApply }).await() + const preference = ctx.settingsScope.developerTools + const changed = vi.fn() + const dispose = preference.enabled.subscribe(changed) + onTestFinished(dispose) + expect(preference.enabled.getSnapshot()).toBe(false) + pending.resolve(accepted) + await ctx.settingsScope.describe().ensure() + expect(preference.enabled.getSnapshot()).toBe(enabled) + expect(changed).toHaveBeenCalledTimes(enabled ? 1 : 0) + }) + + it('keeps features disabled when the initial Host read fails', async () => { + const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) + new TestRemote(ctx, { settings: { describe: () => Promise.reject(new Error('disconnected')) } }) + await ctx.plugin({ inject, apply: clientApply }).await() + await ctx.settingsScope.describe().ensure() + expect(ctx.settingsScope.developerTools.enabled.getSnapshot()).toBe(false) + }) + it('shares one remote-browser preference across consumers and disposes it with the plugin', async () => { const ctx = new Context() onTestFinished(() => ctx.fiber.dispose()) @@ -49,13 +82,14 @@ describe('developer tools settings', () => { await fiber.await() const preference = ctx.settingsScope.developerTools expect(fiber.ctx.settingsScope.developerTools.enabled).toBe(preference.enabled) + expect(preference.enabled.getSnapshot()).toBe(true) await preference.setEnabled(true) expect(fiber.ctx.settingsScope.developerTools.enabled.getSnapshot()).toBe(true) expect(describeCall).not.toHaveBeenCalled() await fiber.dispose() expect(ctx.get('settingsScope')).toBeUndefined() }) - it('defaults off, persists valid choices and removes its schema on disposal', async () => { + it('defaults on, keeps an explicit false, persists valid choices and removes its schema on disposal', async () => { const ctx = new Context() const provider = ctx.plugin(MemorySettings) onTestFinished(() => provider.dispose()) @@ -63,6 +97,8 @@ describe('developer tools settings', () => { const fiber = ctx.plugin({ apply }) onTestFinished(() => fiber.dispose()) await fiber.await() + expect(ctx.settings.get(DEVELOPER_TOOLS_NAMESPACE)).toEqual({ enabled: true }) + await ctx.settings.update(DEVELOPER_TOOLS_NAMESPACE, { enabled: false }) expect(ctx.settings.get(DEVELOPER_TOOLS_NAMESPACE)).toEqual({ enabled: false }) await ctx.settings.update(DEVELOPER_TOOLS_NAMESPACE, { enabled: true }) expect(ctx.settings.get(DEVELOPER_TOOLS_NAMESPACE)).toEqual({ enabled: true }) @@ -71,19 +107,19 @@ describe('developer tools settings', () => { expect(ctx.settings.describe().map(row => row.ns)).not.toContain(DEVELOPER_TOOLS_NAMESPACE) }) - it('stays off until accepted settings arrive and follows external changes', async () => { + it('stays off until accepted settings arrive, then follows a stored false and external changes', async () => { const host = stubSettingsScope() const preference = new DeveloperToolsPreference(host.scope) + expect(preference.enabled.getSnapshot()).toBe(false) const notify = vi.fn() const dispose = preference.enabled.subscribe(notify) + host.publish({ status: 'ready', value: { enabled: false } }) expect(preference.enabled.getSnapshot()).toBe(false) - host.publish({ status: 'ready', value: { enabled: true } }) + expect(notify).not.toHaveBeenCalled() + await preference.setEnabled(true) + expect(host.set).toHaveBeenCalledWith('enabled', true) + host.publish({ value: { enabled: true } }) expect(preference.enabled.getSnapshot()).toBe(true) - expect(notify).toHaveBeenCalledOnce() - await preference.setEnabled(false) - expect(host.set).toHaveBeenCalledWith('enabled', false) - host.publish({ value: { enabled: false } }) - expect(preference.enabled.getSnapshot()).toBe(false) dispose() }) }) @@ -94,13 +130,14 @@ it('keeps remote browser choices local and publishes only changed values', async const preference = new DeveloperToolsPreference(host.scope) const notify = vi.fn() const dispose = preference.enabled.subscribe(notify) - expect(preference.enabled.getSnapshot()).toBe(false) - await preference.setEnabled(true) expect(preference.enabled.getSnapshot()).toBe(true) - await preference.setEnabled(true) - expect(notify).toHaveBeenCalledOnce() await preference.setEnabled(false) expect(preference.enabled.getSnapshot()).toBe(false) + await preference.setEnabled(false) + expect(notify).toHaveBeenCalledOnce() + await preference.setEnabled(true) + expect(preference.enabled.getSnapshot()).toBe(true) + expect(notify).toHaveBeenCalledTimes(2) expect(host.set).not.toHaveBeenCalled() dispose() }) @@ -117,3 +154,10 @@ it('ignores host revisions that do not change enablement', () => { expect(notify).toHaveBeenCalledOnce() dispose() }) + +it('keeps an unavailable Host namespace disabled', () => { + const host = stubSettingsScope() + const preference = new DeveloperToolsPreference(host.scope) + host.publish({ status: 'unavailable' }) + expect(preference.enabled.getSnapshot()).toBe(false) +}) diff --git a/packages/client/ui-sidebar-browser/README.i18n.yaml b/packages/client/ui-sidebar-browser/README.i18n.yaml index 533b9d85e2..455edd84ea 100644 --- a/packages/client/ui-sidebar-browser/README.i18n.yaml +++ b/packages/client/ui-sidebar-browser/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-sidebar-browser/README.md -README.md: 61de40ef98c72501c20c56cffb1075322bcb4886 -README.zh.md: 8bdc88a85f2473e62c640e509db53577c1f2e6d4 +README.md: dcfaccdfb85cdada9300aadb2f6e9058dc5a95bc +README.zh.md: 8d5c34ab4c1b4d87e2a25912f38f102442f7a454 diff --git a/packages/client/ui-sidebar-browser/README.md b/packages/client/ui-sidebar-browser/README.md index 61de40ef98..dcfaccdfb8 100644 --- a/packages/client/ui-sidebar-browser/README.md +++ b/packages/client/ui-sidebar-browser/README.md @@ -25,7 +25,7 @@ Browse HTTP(S) pages, including loopback services, inside independent right-Side ## Use this package -The shipped Web and Desktop compositions already mount this package. Open **Browser** from the right-Sidebar guide, enter an HTTP(S) URL, or select an HTTP(S) link in Assistant Markdown. A host name without a scheme becomes HTTPS. Public and loopback targets use the same default sandbox. Each guide action or message-link activation creates another Browser tab. +Browser is disabled by default in Web profiles and enabled on Desktop. Enable the shipped entry through the Web profile patch to use it. Open **Browser** from the right-Sidebar guide and enter an HTTP(S) URL. Chat HTTP(S) links open here when the [link preference](../ui-chat/README.md) selects the built-in browser. A host name without a scheme becomes HTTPS. Public and loopback targets use the same default sandbox. Each guide action or delegated message-link activation creates another Browser tab. ### When to choose it @@ -33,11 +33,11 @@ Choose Browser for a Web page that should remain beside the current Session. Cho ### Minimal configuration -The package has no configuration. A custom Web composition mounts its Host companion; the Client loader then discovers the browser entry declared by the package manifest: +The package has no plugin configuration. A Web profile enables the shipped entry through its profile patch: ```yaml - id: ui-sidebar-browser - name: '@deepseek-ai/dsh-client-ui-sidebar-browser' + disabled: false ``` Client plugins can open a tab through `ctx.sidebarRight.openTab('browser', { params: { url } })`. The optional URL passes the same validation as address-bar input before navigation. diff --git a/packages/client/ui-sidebar-browser/README.zh.md b/packages/client/ui-sidebar-browser/README.zh.md index 8bdc88a85f..8d5c34ab4c 100644 --- a/packages/client/ui-sidebar-browser/README.zh.md +++ b/packages/client/ui-sidebar-browser/README.zh.md @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -随附的 Web 与 Desktop composition 已挂载本包。可以从右侧 Sidebar guide 打开 **浏览器**、输入 HTTP(S) URL,或点击 Assistant Markdown 中的 HTTP(S) 链接。不带 scheme 的主机名会补全为 HTTPS。公共目标与 loopback 目标使用相同的默认 sandbox。每次 guide 操作或消息链接操作都会创建一个新的 Browser tab。 +Browser 在 Web profile 中默认禁用,在 Desktop 中默认启用。Web 用户可通过 profile patch 启用随附条目。可以从右侧 Sidebar guide 打开 **浏览器**并输入 HTTP(S) URL。Chat 的[链接偏好](../ui-chat/README.zh.md)选择内置浏览器时,HTTP(S) 链接会在此打开。不带 scheme 的主机名会补全为 HTTPS。公共目标与 loopback 目标使用相同的默认 sandbox。每次 guide 操作或委托到此的消息链接操作都会创建一个新的 Browser tab。 ### 何时选择 @@ -33,11 +33,11 @@ kind: "package-reference" ### 最小配置 -本包没有配置字段。自定义 Web composition 挂载 Host companion;随后 Client loader 会发现 package manifest 声明的浏览器入口: +本包没有插件配置字段。Web profile 通过其 profile patch 启用随附条目: ```yaml - id: ui-sidebar-browser - name: '@deepseek-ai/dsh-client-ui-sidebar-browser' + disabled: false ``` Client 插件可以调用 `ctx.sidebarRight.openTab('browser', { params: { url } })` 打开 tab。可选 URL 会在导航前接受与地址栏输入相同的校验。 diff --git a/packages/client/ui-sidebar-documentpreview/tests/html-body.client.spec.tsx b/packages/client/ui-sidebar-documentpreview/tests/html-body.client.spec.tsx index be3a1a7869..b136e6791b 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/html-body.client.spec.tsx +++ b/packages/client/ui-sidebar-documentpreview/tests/html-body.client.spec.tsx @@ -3,6 +3,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, render, screen, waitFor } from '@testing-library/react' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import { bindSnapshotSelector, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' +import { DeveloperToolsPreference } from '@deepseek-ai/dsh-client-ui-settings/src/client/developer-tools.ts' +import type { DeveloperToolsSettings } from '@deepseek-ai/dsh-client-ui-settings/src/developer-tools-settings.ts' import type { Resources, ResourceSnapshot } from '@deepseek-ai/dsh-client-resources/client' import type { WorkspaceFileStat } from '@deepseek-ai/dsh-api-workspace-files/types' import type { SessionId } from '@deepseek-ai/dsh-session/types' @@ -153,6 +156,31 @@ describe('HtmlBody', () => { expect(screen.getByRole('alert').textContent).toBe(en.failed) }) + it('follows the shared developer-tools preference from loading through a stored false to enabled', async () => { + const host = stubSettingsScope() + const preference = new DeveloperToolsPreference(host.scope) + const readRelated = vi.fn().mockResolvedValue({ ok: true, value: { + absolutePath: '/workspace/asset.js', version: 'asset-v1', offset: 0, eof: true, + data: btoa('window.loaded = true'), + } }) + const scripted: HtmlBodyProps = { + ...props('

Preview

'), + useInteractivePreview: bindSnapshotSelector(preference.enabled), + readRelated, + } + const view = render() + expect(screen.getByTitle(en.frame).getAttribute('sandbox')).toBe('') + expect(readRelated).not.toHaveBeenCalled() + act(() => { host.publish({ status: 'ready', value: { enabled: false } }) }) + expect(screen.getByTitle(en.frame).getAttribute('sandbox')).toBe('') + expect(readRelated).not.toHaveBeenCalled() + act(() => { host.publish({ value: { enabled: true } }) }) + const advanced = await screen.findByTitle(en.frame) + expect(advanced.getAttribute('sandbox')).toBe('allow-scripts') + expect(readRelated).toHaveBeenCalledOnce() + view.unmount() + }) + it('renders a Blob iframe with only scripts allowed, keeping it mounted for unrelated props', async () => { const initial = props() const view = render() diff --git a/packages/client/ui-sidebar/README.i18n.yaml b/packages/client/ui-sidebar/README.i18n.yaml index c837437b74..72b5b20666 100644 --- a/packages/client/ui-sidebar/README.i18n.yaml +++ b/packages/client/ui-sidebar/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-sidebar/README.md -README.md: 1c5306b8b3efa5d7908cfd33206acefef20fc3a8 -README.zh.md: d72da040252f391bcc1468c9e29adf6a5ef85ef0 +README.md: 94bb3a70ca8c06b5838f41436bb0f2ba2751501e +README.zh.md: 86f54882ba1c96b9b50898993931757f7afef4e5 diff --git a/packages/client/ui-sidebar/README.md b/packages/client/ui-sidebar/README.md index 1c5306b8b3..94bb3a70ca 100644 --- a/packages/client/ui-sidebar/README.md +++ b/packages/client/ui-sidebar/README.md @@ -41,7 +41,7 @@ The top expand button hosts the optional, non-interactive `sidebar.toggle.badge` During a live collapse, the expanded content fades out at its current width, the upper controls share one fade and leftward translation into the 56px rail, and the layout's column slide ends the motion. A page that starts collapsed renders the rail statically, and reduced-motion mode disables both transitions. The bottom-pinned `sidebar.settings` control shares the fade timing but has no horizontal translation. -On Windows Electron, `html[data-windows-titlebar]` fixes the sidebar toggle in the caption's top-left corner in both states, aligned with New Session's left edge only when expanded. The expanded brand sits below the caption and above New Session, with 8px of extra space above that button. Collapsing hides the brand and sidebar content and places New Session between the sidebar toggle and the Desktop-owned menus. The sidebar sets the root `--dsh-windows-menu-start` to 84px when collapsed; the Desktop preload uses it to position its menu after New Session and defaults to 48px when expanded. Caption icon buttons use centered 16px glyphs in 28px circular controls and exclude themselves from the window drag region. +On Windows Electron, `html[data-windows-titlebar]` fixes the sidebar toggle in the caption's top-left corner in both states, aligned with New Session's left edge only when expanded. The expanded brand sits below the caption and above New Session, with 8px of extra space above that button. Collapsing hides the brand and sidebar content and places New Session between the sidebar toggle and the Desktop-owned menus. The sidebar sets the root `--dsh-windows-menu-start` to 84px when collapsed; the Desktop preload uses it to position its menu after New Session and defaults to 48px when expanded. Caption icon buttons use centered 16px glyphs in 28px circular controls and exclude themselves from the window drag region. The sidebar toggle and New Session bubbles open below the caption, where the Desktop-owned menu text cannot cover them; an occupying `sidebar.toggle.badge` chooses its own bubble side. ### macOS desktop diff --git a/packages/client/ui-sidebar/README.zh.md b/packages/client/ui-sidebar/README.zh.md index d72da04025..86f54882ba 100644 --- a/packages/client/ui-sidebar/README.zh.md +++ b/packages/client/ui-sidebar/README.zh.md @@ -41,7 +41,7 @@ dsh Web 客户端的侧边栏让用户识别当前构建、启动新会话、将 实时收起时,展开内容在当前宽度淡出,上方控件共用同一段透明度渐变,并向左平移进入 56px 轨道,由布局的栏滑动结束整段动画。页面初始即为收起状态时会静态渲染轨道;减少动态效果模式会禁用两段过渡。固定在底部的 `sidebar.settings` 控件共用相同的透明度渐变时序,但不发生横向位移。 -在 Windows Electron 中,`html[data-windows-titlebar]` 将两种状态下的侧栏开关固定在顶栏左上角,仅在展开态与新建会话按钮左边缘对齐。展开态品牌位于顶栏下方、新建会话按钮上方,按钮上方额外留出 8px。收起后,品牌和侧栏内容隐藏,新建会话按钮排在侧栏开关与 Desktop 菜单之间。侧栏在收起态将根元素的 `--dsh-windows-menu-start` 设为 84px;Desktop preload 使用它将菜单放在新建会话之后,展开态默认为 48px。顶栏图标按钮采用 28px 圆形控件中的居中 16px 图标,并从窗口拖拽区域中排除。 +在 Windows Electron 中,`html[data-windows-titlebar]` 将两种状态下的侧栏开关固定在顶栏左上角,仅在展开态与新建会话按钮左边缘对齐。展开态品牌位于顶栏下方、新建会话按钮上方,按钮上方额外留出 8px。收起后,品牌和侧栏内容隐藏,新建会话按钮排在侧栏开关与 Desktop 菜单之间。侧栏在收起态将根元素的 `--dsh-windows-menu-start` 设为 84px;Desktop preload 使用它将菜单放在新建会话之后,展开态默认为 48px。顶栏图标按钮采用 28px 圆形控件中的居中 16px 图标,并从窗口拖拽区域中排除。侧栏开关与新建会话的悬停提示在顶栏下方展开,Desktop 菜单文字不会盖住它们;占用 `sidebar.toggle.badge` 的控件自行决定气泡展开方向。 ### macOS 桌面 diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx index f58588feb1..12bcbe8ca8 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx @@ -107,6 +107,10 @@ export function SidebarRoot({ }, [collapsed]) const windowsTitlebar = document.documentElement.hasAttribute('data-windows-titlebar') const wide = windowsTitlebar ? !collapsed : !collapsed || !settled + // The Windows caption menus occupy the strip to the right of these controls + // (that is what --dsh-windows-menu-start reserves), so a right-side bubble + // lands under their text. Below the caption is the only clear side. + const captionTooltipSide = windowsTitlebar ? 'bottom' : 'right' // Freeze the content at its expanded width while it fades out (collapsed // && wide): the sliding column then clips it instead of reflowing it. The @@ -169,7 +173,7 @@ export function SidebarRoot({ // (the expand affordance, figma sidebar-hover flow). Expanded it is a plain // panel icon. const toggle = ( - +