diff --git a/.agents/notes/README.i18n.yaml b/.agents/notes/README.i18n.yaml index af337114a0..f6289cf96f 100644 --- a/.agents/notes/README.i18n.yaml +++ b/.agents/notes/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 .agents/notes/README.md -README.md: 8bf1adf875b54eb4580b4d189dfeed07c9f671c0 -README.zh.md: ff9bdb358d584d4f4ad0aea295c31d8d8fff9376 +README.md: 6872de0a1390fa5a1707b9f464c9e0edeb2da4be +README.zh.md: ced81f77fa904ad0b5cb5219383ef651d87392b8 diff --git a/.agents/notes/README.md b/.agents/notes/README.md index 8bf1adf875..6872de0a13 100644 --- a/.agents/notes/README.md +++ b/.agents/notes/README.md @@ -16,7 +16,7 @@ Every Agent Note has two axes, both encoded in its **path** — `{lifecycle}/{cl The date in the filename is when the topic was **first proposed** (per git history). Cross-references between Agent Notes use relative markdown links (`[topic](../../implemented/architecture/2026-…-….md)`) — never bare prose or numbers — so they are mechanically checkable and survive moves between folders. -The active lifecycle tree is the working inventory: browse its lifecycle/class folders or search the repository. Do not add a centralized `INDEX.md`; the [no-index Agent Note](implemented/process/2026-07-19-remove-generated-agent-note-index.md) owns the rationale. Low-future-value implemented records move to the separate frozen [`archived/`](archived/AGENTS.md) tree described below. +The active lifecycle tree is the working inventory: browse its lifecycle/class folders or search the repository. Do not add a centralized `INDEX.md`; the [no-index Agent Note](implemented/process/2026-07-19-remove-generated-agent-note-index.md) owns the rationale. Implemented records with historical decision value but little future guidance move to the separate frozen [`archived/`](archived/AGENTS.md) tree described below. ## Classification @@ -35,17 +35,17 @@ The `architecture` / `process` line: **architecture** is about the source we shi ## Archiving and deletion -Archive an implemented Agent Note when the shipped decision is complete and its rationale is unlikely to guide future work. Keep it active when its alternatives, ownership boundary, negative guarantee, durable or wire semantics, security rule, or reintroduction condition remains useful. Never archive a proposed note: reject an obsolete proposal. Keep a rejected note only while it prevents a plausible mistake; otherwise delete its English, Chinese, and sidecar files together. Use the calibrated [`dsh-archive-agent-notes`](../skills/dsh-archive-agent-notes/SKILL.md) workflow rather than word count, age, or a target quota. +Delete an implemented Agent Note that only describes small UI adjustments or purely mechanical changes; delete its English, Chinese, and sidecar files together and repair or remove inbound links. A local bug fix, performance change, new capability, or substantive behavior decision does not qualify merely because its implementation is small. Archive other implemented notes only when the shipped decision is complete, its rationale is unlikely to guide future work, and the record still has historical decision value. Keep it active when its alternatives, ownership boundary, negative guarantee, durable or wire semantics, security rule, or reintroduction condition remains useful. Never archive a proposed note: reject an obsolete proposal. Keep a rejected note only while it prevents a plausible mistake; otherwise delete its English, Chinese, and sidecar files together. Use the calibrated [`dsh-archive-agent-notes`](../skills/dsh-archive-agent-notes/SKILL.md) workflow rather than word count, age, or a target quota. -The archive is path-encoded as `archived/{class}/yyyy-mm-dd-topic-title.md`; `implemented` is deliberately absent because only implemented notes can enter it. An archival change moves the complete English/Chinese/sidecar triplet, retains `Status: implemented`, inserts the same `Archived: YYYY-MM-DD` line immediately below that status in both language files, re-records the sidecar, and repairs or deletes inbound links. These are the only permitted content changes during archival. +The archive is path-encoded as `archived/{class}/yyyy-mm-dd-topic-title.md`; `implemented` is deliberately absent because only implemented notes can enter it. An archival change moves the complete English/Chinese/sidecar triplet, retains `Status: implemented`, inserts the same `Archived: YYYY-MM-DD` line immediately below that status in both language files, re-records the sidecar, and repairs or deletes inbound links. These are the only permitted content changes during archival. Existing title punctuation, blank-line layout, and language-switcher wording do not block archival; preserve them with the body. Once sealed, every archived triplet is permanently frozen. Do not edit, translate, reformat, update, move, or delete it, and do not treat it as authority for current behavior. Documentation gates skip archived sources, including their outbound links; active prose may still link into an archived note when it intentionally cites history. [`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) enforces the closed class tree, complete triplets, archive metadata, sidecar hashes, and the append-only frozen-content manifest. The [archive-policy Agent Note](implemented/process/2026-07-26-frozen-agent-note-archive.md) owns the rationale. ## When to write one -Every non-trivial change MUST add or update at least one Agent Note in the same PR. A change is non-trivial when it alters behavior, architecture, a contract shared across files or packages, process or tooling, testing strategy, an on-disk, wire, or configuration format, or another decision a maintainer may reasonably revisit. A proposal for substantial future work starts in `proposed/`; a decision already made starts in `implemented/`. Pick the class folder that matches the decision (see [Classification](#classification)). +Add or update an Agent Note in the same PR only for lasting decision rationale that code, tests, and existing documentation do not explain. A proposal for substantial future work starts in `proposed/`; a decision already made starts in `implemented/`. Pick the class folder that matches the decision (see [Classification](#classification)). -Updating the Agent Note that already owns the decision satisfies the rule; do not create a duplicate. Only a purely mechanical or local edit with no change to behavior, contracts, structure, process, or rationale is exempt. An Agent Note is never edited into a *different decision*: supersede it with a new one, and keep both notes cross-linked unless the old note is later fully consolidated under the rule below. Editing an `implemented/` Agent Note to track where its existing decision lives is required, not forbidden; see [implemented/AGENTS.md](implemented/AGENTS.md). +Updating the Agent Note that already owns the decision satisfies the rule; do not create a duplicate. Mechanical or local edits, including local UI presentation and interaction changes, are exempt. An Agent Note is never edited into a *different decision*: supersede it with a new one, and keep both notes cross-linked unless the old note is later fully consolidated under the rule below. Editing an `implemented/` Agent Note to track where its existing decision lives is required, not forbidden; see [implemented/AGENTS.md](implemented/AGENTS.md). An implemented Agent Note that is fully superseded may be consolidated into the current owning note and deleted. Before deletion, the owner must preserve every unique rationale, alternative, consequence, required verification, and named coverage gap; repair every inbound link; and delete the Chinese counterpart and consistency record in the same change. Partial supersession does not qualify: keep both notes cross-linked and update every fact that remains current. Consolidation must not rewrite the old file into its opposite or rely on git history as the only copy of rationale. diff --git a/.agents/notes/README.zh.md b/.agents/notes/README.zh.md index ff9bdb358d..ced81f77fa 100644 --- a/.agents/notes/README.zh.md +++ b/.agents/notes/README.zh.md @@ -16,7 +16,7 @@ 文件名中的日期是该主题**首次提出**的时间(以 git 历史为准)。Agent Note 之间的交叉引用使用相对 Markdown 链接(`[topic](../../implemented/architecture/2026-…-….md)`),从不使用纯文字或编号,这样既可机械检查,也能在文件夹间移动时保持有效。 -活跃生命周期目录树就是工作清单:浏览其生命周期/类别文件夹,或搜索仓库即可。请勿添加集中式 `INDEX.md`;设计理由见[不设索引的 Agent Note](implemented/process/2026-07-19-remove-generated-agent-note-index.zh.md)。未来指导价值较低的已实施记录会移至下文所述、单独冻结的 [`archived/`](archived/AGENTS.md) 目录树。 +活跃生命周期目录树就是工作清单:浏览其生命周期/类别文件夹,或搜索仓库即可。请勿添加集中式 `INDEX.md`;设计理由见[不设索引的 Agent Note](implemented/process/2026-07-19-remove-generated-agent-note-index.zh.md)。具有历史决策价值但未来指导价值较低的已实施记录会移至下文所述、单独冻结的 [`archived/`](archived/AGENTS.md) 目录树。 @@ -37,9 +37,9 @@ ## 归档与删除 -当一份 implemented Agent Note 记录的交付决策已经完整落地,且其决策依据不太可能再指导未来工作时,将其归档。如果其中的备选方案、归属边界、否定性保证、持久化语义或协议语义、安全规则,或者重新引入条件仍有价值,则继续作为活跃记录保留。绝不归档 proposed Agent Note:过时的提案应转为 rejected。仅当 rejected Agent Note 仍能避免一种可能发生的错误时保留;否则一并删除其英文、中文和伴随记录文件。请使用经过校准的 [`dsh-archive-agent-notes`](../skills/dsh-archive-agent-notes/SKILL.md) 工作流,不要根据字数、存续时间或目标配额来判断。 +仅描述小型 UI 调整或纯机械变更的 implemented Agent Note 直接删除;一并删除英文、中文和伴随记录文件,并修复或移除入站链接。局部 bug 修复、性能变更、新能力或实质行为决策,不会仅因实现规模小就符合删除条件。其他已实施记录只有在交付决策完整落地、其依据不太可能再指导未来工作,但仍具有历史决策价值时才归档。如果其中的备选方案、归属边界、否定性保证、持久化语义或协议语义、安全规则,或者重新引入条件仍有价值,则继续作为活跃记录保留。绝不归档 proposed Agent Note:过时的提案应转为 rejected。仅当 rejected Agent Note 仍能避免一种可能发生的错误时保留;否则一并删除其英文、中文和伴随记录文件。请使用经过校准的 [`dsh-archive-agent-notes`](../skills/dsh-archive-agent-notes/SKILL.md) 工作流,不要根据字数、存续时间或目标配额来判断。 -归档路径编码为 `archived/{class}/yyyy-mm-dd-topic-title.md`;其中有意省略 `implemented`,因为只有 implemented Agent Note 可以进入归档。归档变更会移动完整的英文、中文和伴随记录三个文件,保留 `Status: implemented`,在两种语言的文件中紧接该状态行插入相同的 `Archived: YYYY-MM-DD` 行,重新记录伴随记录,并修复或删除入站链接。归档时只允许对内容做这些更改。 +归档路径编码为 `archived/{class}/yyyy-mm-dd-topic-title.md`;其中有意省略 `implemented`,因为只有 implemented Agent Note 可以进入归档。归档变更会移动完整的英文、中文和伴随记录三个文件,保留 `Status: implemented`,在两种语言的文件中紧接该状态行插入相同的 `Archived: YYYY-MM-DD` 行,重新记录伴随记录,并修复或删除入站链接。归档时只允许对内容做这些更改。 既有标题标点、空行布局和语言切换栏措辞不阻止归档;与正文一起保留。 封存后,每组归档文件都永久冻结。禁止编辑、翻译、重新格式化、更新、移动或删除,也不得将其视为当前行为的权威依据。文档门禁会跳过归档源文件,包括其中的出站链接;当活跃文档有意引用历史时,仍可链接到归档 Agent Note。[`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) 强制执行封闭的类别目录树、完整的三文件配对、归档元数据、伴随记录 hash,以及仅追加的冻结内容 manifest(元数据清单)。[归档政策 Agent Note](implemented/process/2026-07-26-frozen-agent-note-archive.zh.md) 记录了设计依据。 @@ -47,9 +47,9 @@ ## 何时需要写一份 -每个非平凡变更都必须在同一 PR(Pull Request)中新增或更新至少一份 Agent Note。如果变更修改了行为、架构、跨文件或跨包约定、流程或工具、测试策略、磁盘存储格式、协议格式(wire format)或配置格式,或者维护者可能合理重新审视的其他决策,就属于非平凡变更。对未来重大工作的提案从 `proposed/` 开始;已经做出的决策从 `implemented/` 开始。选择与决策匹配的类别文件夹(见[分类](#classification))。 +只有代码、测试和现有文档未能解释的决策理由具有长期价值时,才在同一 PR(Pull Request)中新增或更新 Agent Note。对未来重大工作的提案从 `proposed/` 开始;已经做出的决策从 `implemented/` 开始。选择与决策匹配的类别文件夹(见[分类](#classification))。 -更新已经拥有该决策的 Agent Note 即可满足规则;不要创建重复记录。只有不涉及行为、约定、结构、流程或理由变化的纯机械性或局部编辑才可豁免。Agent Note 永远不会被编辑为一个*不同的决策*:用新 Agent Note 取代旧记录,并让两个记录保持互相链接,除非后续依据下方规则完全合并旧记录。编辑 `implemented/` Agent Note 以跟踪其现有决策的所在位置是必需的,而非禁止的;见 [implemented/AGENTS.md](implemented/AGENTS.md)。 +更新已经拥有该决策的 Agent Note 即可满足规则;不要创建重复记录。机械性或局部编辑均可豁免,包括局部 UI 展示和交互调整。Agent Note 永远不会被编辑为一个*不同的决策*:用新 Agent Note 取代旧记录,并让两个记录保持互相链接,除非后续依据下方规则完全合并旧记录。编辑 `implemented/` Agent Note 以跟踪其现有决策的所在位置是必需的,而非禁止的;见 [implemented/AGENTS.md](implemented/AGENTS.md)。 被完全取代的 implemented Agent Note 可以合并到当前持有该决策的记录中,并删除原文件。删除前,当前记录必须保存所有独有的决策依据、备选方案、影响、必需的验证和明确指出的覆盖缺口;修复所有入站链接;并在同一变更中删除中文对侧文件和一致性记录。仅部分被取代的记录不符合此条件:保留两个记录并让它们互相链接,同时更新所有仍然适用的事实。合并不得将旧文件改写成与其相反的决策,也不得让 git 历史成为决策依据的唯一副本。 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 a7b0ac458f..e891710dea 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: 6126d06e48111a551a0dc57247659a3086986c66 -2026-07-25-web-client-session-scope-and-provide-channel.zh.md: e7bf4aa6952b4fd7b187c91e2ac5d64aac32cabb +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 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 6126d06e48..990a9b6eef 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 @@ -63,21 +63,21 @@ 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: `session.seq === 0` (zero log events = no user message yet). A live session reads `summarize()` straight from memory; a cold session is always `false` — the JSONL provider's lazy-create contract guarantees a never-appended session never enters `persistence.list()`, so blank never touches disk. +- 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 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. - 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. -- The residue ledger takes zero GC: after a refresh, blank sessions come back with the bit intact and are reused on the next same-workspace connect while they remain members, so the ordinary single-tab path keeps at most one per workspace; after a host restart, blanks leave no disk trace and simply evaporate; the extra empty shells from multi-tab races only become non-current hidden rows, digested by later reuse, with no coordination. +- 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. ### connectWorkspace: the sole entry point of New Session `workspaces.connectWorkspace(workspaceId): Promise` (owned by WorkspaceRuntime — it holds both the workspace canonical path and the sessions reference): -- The reuse arm: the list mirror is searched for `blank && cwd == workspace.path && sessionIds.includes(id)` — the host's own membership rule, never cwd alone. A cwd match without the account slot (a CLI/TUI session birthed at the host cwd, or a deleted/recreated registration) would open a session no grouping surface can show under this Workspace, so it falls through to the create arm instead (see the [membership reuse fix](../../archived/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.md)); a hit returns that id directly, creating nothing. -- The create arm: on a miss, `session.create({workspaceId})` returns the new id. +- The reuse arm: the list mirror is searched for `blank && cwd == workspace.path && sessionIds.includes(id)` — the host's own membership rule, never cwd alone. A cwd match without the account slot (a CLI/TUI session birthed at the host cwd, or a deleted/recreated registration) would open a session no grouping surface can show under this Workspace, so it falls through to the create arm instead (see the [membership reuse fix](../../archived/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.md)); a hit is adopted with explicit-id `session.create`, which acquires and retains the writer before returning. Only writer contention skips a candidate. +- The create arm: when no candidate can be acquired, `session.create({workspaceId})` returns the new id. - An unknown workspaceId fails loud (never silently creating somewhere else). - The resolution guarantee (one contract for both arms): when the promise resolves, the returned id is already in the list store. The view owner then retains it synchronously, so a draft mover can write text through that binding before history readiness without waiting for a notifier flush. - The caller takes the id and installs a `mainView` reference; sending the first prompt is an ordinary `session.prompt` — the Session already exists, a failure is an ordinary prompt failure, the draft text is still in the machine, and a retry is simply sending again. @@ -135,6 +135,6 @@ Blank Sessions retain the header's leading and corner slots so navigation contro - 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`), reclamation rides lazy persistence's existing contract (evaporation on restart), 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 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 e7bf4aa695..5c90611fab 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 @@ -63,21 +63,21 @@ Session 实例与 scope 同生命周期;catalog 只报告可发现性,不持 「实体化但无首条提示词」的会话经 summary 派生位 `blank` 治理(派生列而非 header 字段,SessionHeader 保持不可变): -- host 判据:`session.seq === 0`(零日志事件 = 尚无用户消息)。live 会话 `summarize()` 内存直读;cold 会话恒 `false`——JSONL provider 的 lazy-create 约定保证 never-appended 会话不进入 `persistence.list()`,所以 blank 从不落盘。 +- Host 判据是没有 `turn/start`;slash 命令与配置事件不会清除 `blank`。Live summary 使用 Session 投影。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。 - 列表纪律:store 保留全部行;Workspace browser 的分组、平铺、搜索和计数共用同一可见投影——所有非 blank 会话都显示,blank 会话只显示由 `mainView` 来源持有的一行,并强制标题为 `New Session`。切换 Workspace 后,旧 blank 实体仍在镜像中但从列表隐藏,目标 Workspace 的主 blank 显示;因此用户可见面全局至多一条 blank 行。 -- 残留账零 GC:刷新后 blank 会话带位回来,下次同 workspace 且仍为成员时复用,普通单端路径使每个 workspace 至多保留一个;host 重启后 blank 无盘痕自然蒸发;多 tab 竞态多出的空壳只会成为非 current 隐藏行,后续复用消化,不做协调。 +- 空白 Session 可以通过创建检查点与命令事件持久化。浏览器刷新与 Host 重启会在取得写锁后复用符合条件的空白会话,并跳过被占用的候选。旧日志保留在磁盘上,不自动清理。 ### connectWorkspace:New Session 的唯一入口 `workspaces.connectWorkspace(workspaceId): Promise`(归属 WorkspaceRuntime——它同时持有 workspace 规范 path 与 sessions 引用): -- 复用臂:list mirror 中找 `blank && cwd == workspace.path && sessionIds.includes(id)`——host 自己的成员规则,绝不只按 cwd。没有账户槽位的 cwd 匹配(CLI(命令行界面)/TUI 在 host cwd 创建的会话,或已删除/重建的注册)会打开一个任何分组表面都无法显示在该工作区下的会话,因此落到新建臂(见[成员复用修复](../../archived/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.md));命中直接返回该 id,不新建。 -- 新建臂:未命中则 `session.create({workspaceId})`,返回新 id。 +- 复用臂:list mirror 中找 `blank && cwd == workspace.path && sessionIds.includes(id)`——host 自己的成员规则,绝不只按 cwd。没有账户槽位的 cwd 匹配(CLI(命令行界面)/TUI 在 host cwd 创建的会话,或已删除/重建的注册)会打开一个任何分组表面都无法显示在该工作区下的会话,因此落到新建臂(见[成员复用修复](../../archived/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.md));命中后通过显式 ID 的 `session.create` 收养,在返回前取得并持续持有写锁。仅写锁争用会跳过候选。 +- 新建臂:没有可获取的候选时,`session.create({workspaceId})`,返回新 id。 - 未知 workspaceId fail loud(不静默创建到别处)。 - 解析保证(两臂同约定):promise resolve 时返回的 id 已在 list store。视图 owner 随后同步 retain,因此 draft 搬运方可以在历史就绪前通过该 binding 写入文本,无需等待 notifier flush。 - 调用方拿 id 安装一份 `mainView` reference;首条提示词发送就是普通 `session.prompt`——Session 本来就在,失败即普通提示词失败,draft 文本还在 machine 里,重试即再次发送。 @@ -135,6 +135,6 @@ blank Session 保留 header 的 leading 与 corner slot,让右侧栏展开入 - 插件获得与 host 同构的会话上下文:逐会话状态挂 actx、随 scope fiber 一次拆装,泄漏结构性不可能;双会话隔离由 scope filter 结构性保证。 - client 对象层收敛为 wire 镜像:会话身份、生命周期、能力判别全部以 host 实体为准——输入体系(下一层)面对的永远是「有真 Agent 的会话」,slash/skill 等提供方一律以 sessionId 直接寻址。 -- 空会话治理零专用机制:状态靠一个派生位,可见性靠统一列表投影(仅 current blank 以 `New Session` 展示),回收靠 lazy persistence 的既有约定(重启蒸发),常规上限靠同 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-09-client-conversation-node-assembly.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml index aad1a96be8..bb7be001de 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.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-09-client-conversation-node-assembly.md -2026-08-09-client-conversation-node-assembly.md: 43f9f0359822f7d7c5b52c09646fd3ffbf80f783 -2026-08-09-client-conversation-node-assembly.zh.md: d7475f5ff8218419743448d455d5d5ffe3cc4091 +2026-08-09-client-conversation-node-assembly.md: bb1d68cc045710d46a44ffb295c2307628338521 +2026-08-09-client-conversation-node-assembly.zh.md: 6291211d589b3389e7dc9a42d15fe6ac0d9e7ab9 diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md index 43f9f03598..bb1d68cc04 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md +++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md @@ -291,7 +291,7 @@ Page size, record packing, the number of history loads, and RAF coalescing affec Inbox demonstrates that every Event can be a start-only instantaneous-state Context; not every business requires a start/update pair. Reader links each next-step state to the prior same-kind Context instead of inventing a lifecycle ID for the entire Inbox. The state itself shares immutable pending splice nodes and one current claimed-batch Set, while unconsumed next-turn input remains outside Conversation because no Chat or Trajectory classification reads it. -Request Prompt demonstrates shared pure interpretation without shared target State: Chat and Trajectory call `inspectRequestPrompt()` from their own Definitions. The function canonicalizes the full header and classifies model-visible system/tool differences; each target then chooses its own output. Chat materializes a non-empty initial system field, a real system change, and each `series` snapshot that explicitly begins a message series or follows a surface replacement. An unchanged `resume` remains in Trajectory and reconstruction state but does not repeat the visible Chat row once its predecessor is loaded. Ordinary append-only later Turns do not write another unchanged header. The first header in a Step follows the provider envelope rather than the header Event position: step one uses the owning Turn start and later steps use their Step start, placing the system field before the request's user-role messages; a later header in the same Step stays at its own Event after the surface rewrite that began the new series. When the preceding header is outside a partial window, a non-`initial` header stays at its own Event and renders conservatively. Prepending an identical predecessor hides an unchanged resume without withdrawing its stable Node key; a real change remains visible. Every header is a full snapshot, so a first loaded `resume`, `change`, or `series` header can render its system field without fabricating a comparison to unloaded history ([resume presentation decision](../bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.md)). +Request Prompt demonstrates shared pure interpretation without shared target State: Chat and Trajectory call `inspectRequestPrompt()` from their own Definitions. The function canonicalizes the full header and classifies model-visible system/tool differences; each target then chooses its own output. Chat materializes a non-empty initial system field, a real system change, and each `series` snapshot that explicitly begins a message series or follows a surface replacement. Resume headers retain a Chat prompt card even when the system text is unchanged. Ordinary append-only later Turns do not write another unchanged header. The first header in a Step follows the provider envelope rather than the header Event position: step one uses the owning Turn start and later steps use their Step start, placing the system field before the request's user-role messages; a later header in the same Step stays at its own Event after the surface rewrite that began the new series. When the preceding header is outside a partial window, a non-`initial` header stays at its own Event and renders conservatively. Prepending an identical predecessor can hide an unchanged `change` header without withdrawing its stable Node key; resume and explicit series-start cards remain visible. Every header is a full snapshot, so a first loaded `resume`, `change`, or `series` header can render its system field without fabricating a comparison to unloaded history ([request-prompt Definition](../../../../packages/client/ui-chat/src/client/conversation-nodes/request-prompt.ts)). Retry, Assistant, and Turn Tail demonstrate independent claims on one Event. Each Definition updates only its own State and produces its own atomic Chat Node. diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md index d7475f5ff8..6291211d58 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md @@ -291,7 +291,7 @@ Chat `order` 的结构性变化仍可能重排当前可见 key;纯 data 更新 Inbox 展示了“每条 Event 都是一个 start-only 瞬间态 Context”,不是所有业务都需要 start/update 配对。每个 next-step state 通过 Reader 与前一个同 kind Context 形成连续 fold,而非给整个 Inbox 人工制造生命周期 ID。state 自身共享不可变 pending splice 节点和一个当前 claimed-batch Set;未消费的 next-turn input 不进入 Conversation,因为 Chat 与 Trajectory 都不读取它来分类。 -Request Prompt 展示了如何在不共享 target State 的前提下共用纯解释逻辑:Chat 与 Trajectory 各自在自己的 Definition 中调用 `inspectRequestPrompt()`。该函数规范化完整 header,并判定面向模型的 system/tool 差异;随后每个 target 自行选择产物。Chat 会物化非空的初始 system 字段、真实 system 变化,以及每个显式开启消息序列或紧随表层替换的 `series` 快照。未变化的 `resume` 会留在 Trajectory 和重建状态中,但前序 header 已加载时不会重复可见的 Chat 行。普通的仅追加后续 Turn 不会再次写入未变 header。一个 Step 中的首条 header 遵循提供方信封,而不是 header Event 位置:step one 使用所属 Turn start,后续 step 使用各自的 Step start,把 system 字段放到该请求的 user-role 消息之前;同一 Step 的后续 header 保留在开启新序列的表层改写之后。部分窗口未包含前序 header 时,非 `initial` header 会保留在自身 Event 并保守渲染。prepend 补入相同的前序 header 后,内容未变的 resume 会隐藏但不撤回其稳定 Node key;真实变化仍然可见。每条 header 都是完整快照,因此已加载窗口中的首条 `resume`、`change` 或 `series` header 无需凭空构造与未加载历史的比较,也能渲染其 system 字段([resume 展示决策](../bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.zh.md))。 +Request Prompt 展示了如何在不共享 target State 的前提下共用纯解释逻辑:Chat 与 Trajectory 各自在自己的 Definition 中调用 `inspectRequestPrompt()`。该函数规范化完整 header,并判定面向模型的 system/tool 差异;随后每个 target 自行选择产物。Chat 会物化非空的初始 system 字段、真实 system 变化,以及每个显式开启消息序列或紧随表层替换的 `series` 快照。即使 system 文本未变化,resume header 仍保留 Chat prompt 卡片。普通的仅追加后续 Turn 不会再次写入未变 header。一个 Step 中的首条 header 遵循提供方信封,而不是 header Event 位置:step one 使用所属 Turn start,后续 step 使用各自的 Step start,把 system 字段放到该请求的 user-role 消息之前;同一 Step 的后续 header 保留在开启新序列的表层改写之后。部分窗口未包含前序 header 时,非 `initial` header 会保留在自身 Event 并保守渲染。prepend 补入相同的前序 header 后,内容未变的 `change` header 可以隐藏,但不撤回其稳定 Node key;resume 和显式序列起点的卡片仍然可见。每条 header 都是完整快照,因此已加载窗口中的首条 `resume`、`change` 或 `series` header 无需凭空构造与未加载历史的比较,也能渲染其 system 字段([request-prompt Definition](../../../../packages/client/ui-chat/src/client/conversation-nodes/request-prompt.ts))。 Retry、Assistant 和 Turn Tail 展示了同一 Event 被多个 Definition 独立认领。每个 Definition 只更新自己的 State,最终分别生成原子 Chat Node。 diff --git a/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.i18n.yaml index 0ac7d7109d..b3d68505df 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.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-18-experimental-agent-teams-packages.md -2026-08-18-experimental-agent-teams-packages.md: 4a78a60c2ad7463c06b670e6ec664579ffd767b9 -2026-08-18-experimental-agent-teams-packages.zh.md: 8056cd8194695e8ce41acb359e51c040d4ed3aec +2026-08-18-experimental-agent-teams-packages.md: a1d29b5e96b185a5539d664e19f2f5cc342a6aba +2026-08-18-experimental-agent-teams-packages.zh.md: f4c4eb481ae6ad6319ddac50eda82fa9b9feebf7 diff --git a/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.md b/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.md index 4a78a60c2a..a1d29b5e96 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.md +++ b/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.md @@ -12,17 +12,17 @@ Moving the packages into product-role groups would remove their experimental nam ## Decision -`packages/experimental/agent-team`, `packages/experimental/tool-agent-team`, `packages/experimental/agent-team-profile`, `packages/experimental/client-ui-agent-team`, and `packages/experimental/agent-team-web-profile` are public workspace packages. They retain their existing `@deepseek-ai/dsh-experimental-*` names and join the dsh release family. The [publication denylist decision](../process/2026-09-12-experimental-publication-denylist.md) owns the public default and private exceptions; the [experimental package rules](../../../../packages/experimental/AGENTS.md) own dependency isolation and later promotion. +`packages/experimental/agent-team`, `packages/experimental/tool-agent-team`, `packages/experimental/agent-team-profile`, and `packages/experimental/client-ui-agent-team` are public workspace packages. They retain their existing `@deepseek-ai/dsh-experimental-*` names and join the dsh release family. The [publication denylist decision](../process/2026-09-12-experimental-publication-denylist.md) owns the public default and private exceptions; the [experimental package rules](../../../../packages/experimental/AGENTS.md) own dependency isolation and later promotion. -The dsh pack and publish set and the local baseline publisher include these five Agent Teams directories and the [Cua Driver providers](2026-09-12-computer-use-provider-registration.md). Workspace constraints require them to omit `private`, set `publishConfig.access` to `public`, and keep the experimental npm prefix. Release packages and apps outside the experimental group, plus the Python runtime, cannot name experimental packages in `dependencies`, `optionalDependencies`, or `peerDependencies`; experimental packages may depend on release packages and each other. +The dsh pack and publish set and the local baseline publisher include these four Agent Teams directories and the [Cua Driver providers](2026-09-12-computer-use-provider-registration.md). Workspace constraints require them to omit `private`, set `publishConfig.access` to `public`, and keep the experimental npm prefix. Release packages and apps outside the experimental group, plus the Python runtime, cannot name experimental packages in `dependencies`, `optionalDependencies`, or `peerDependencies`; experimental packages may depend on release packages and each other. The generic caller-reserved continuable child identity and selective direct-child drain remain in the stable Subagent service. They own Subagent identity and Activation lifecycle without importing or naming Agent Teams; the experimental Team service consumes them in the permitted direction. -The published Host-side Agent Teams profile bundle depends on the Team packages and applies after `dsh-base`. It inserts the Team rows and disables the global continuable-child controls whose model-visible names overlap the Team tools. The separate published Web profile applies after `dsh-web-app` and the Host profile; it inserts the Team UI, which mounts the Remote contribution generated by the Team package. Both layers remain opt-in and leave the shipped base, CLI, Web, and Python runtime dependency graphs unchanged. +The [single-bundle decision](2026-09-18-agent-teams-single-bundle.md) supersedes the separate Host and Web bundle composition. This note retains publication, dependency isolation, and promotion rationale. The current Team profile remains opt-in and disables the global continuable-child controls whose model-visible names overlap the Team tools; the [optional-bundle decision](../process/2026-09-15-shipped-optional-bundles.md) owns its inclusion in the installation. Profile startup resolves selected bundles before computing the [immutable profile resolution generation](2026-09-09-profile-resolution-generations.md). The generation retains installation-first precedence, traverses each explicit bundle root completely in profile order, and keeps pnpm-managed profile packages authoritative. Runtime mode enforces the result in memory; retained link and dual modes materialize the same result as shared and profile-owned projections. A private profile layer can therefore carry experimental plugin rows without adding those plugins to a release app, requiring profile users to install transitive packages directly, weakening packaged-runtime module identity, or changing another profile's resolution. -Experimental status changes compatibility and support expectations, not publication for these five packages. They retain the repository's ordinary documentation, invariant, lifecycle, security, unit, real-composition, and snapshot requirements. Promotion still requires review of the public contracts, limitations, test evidence, runtime dependents, and a named owner accepting stable-package obligations. +Experimental status changes compatibility and support expectations, not publication for these four packages. They retain the repository's ordinary documentation, invariant, lifecycle, security, unit, real-composition, and snapshot requirements. Promotion still requires review of the public contracts, limitations, test evidence, runtime dependents, and a named owner accepting stable-package obligations. ## Alternatives considered @@ -36,6 +36,6 @@ Experimental status changes compatibility and support expectations, not publicat ## Consequences -Agent Teams publishes as five installable tarballs in the dsh release family without changing package names or enabling Team in a shipped profile. Public availability does not make the packages stable or supported by default, and stable release packages cannot take runtime dependencies on them. +Agent Teams publishes as four installable tarballs in the dsh release family without changing package names or enabling Team in a shipped profile. Public availability does not make the packages stable or supported by default, and stable release packages cannot take runtime dependencies on them. The release family carries the experimental npm names. Promotion still creates path and npm-name churn as specified by the experimental package rules. diff --git a/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.zh.md b/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.zh.md index 8056cd8194..f4c4eb481a 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-18-experimental-agent-teams-packages.zh.md @@ -12,17 +12,17 @@ Agent Teams 的服务与工具约定仍在变化,但它需要使用真实 Sess ## 决策 -`packages/experimental/agent-team`、`packages/experimental/tool-agent-team`、`packages/experimental/agent-team-profile`、`packages/experimental/client-ui-agent-team` 与 `packages/experimental/agent-team-web-profile` 是公开 workspace 包。它们保留现有 `@deepseek-ai/dsh-experimental-*` 名称并加入 dsh 发布系列。[发布拒绝列表决策](../process/2026-09-12-experimental-publication-denylist.zh.md)负责默认公开与私有例外;[实验性包规则](../../../../packages/experimental/AGENTS.md)负责依赖隔离与后续 promotion。 +`packages/experimental/agent-team`、`packages/experimental/tool-agent-team`、`packages/experimental/agent-team-profile` 与 `packages/experimental/client-ui-agent-team` 是公开 workspace 包。它们保留现有 `@deepseek-ai/dsh-experimental-*` 名称并加入 dsh 发布系列。[发布拒绝列表决策](../process/2026-09-12-experimental-publication-denylist.zh.md)负责默认公开与私有例外;[实验性包规则](../../../../packages/experimental/AGENTS.md)负责依赖隔离与后续 promotion。 -dsh 打包与发布集合以及本地基线发布器包含这五个 Agent Teams 目录和 [Cua Driver 提供方](2026-09-12-computer-use-provider-registration.zh.md)。workspace 约束要求它们省略 `private`、将 `publishConfig.access` 设为 `public`,并保留实验性 npm 前缀。实验组之外的发布包、应用和 Python 运行时不能在 `dependencies`、`optionalDependencies` 或 `peerDependencies` 中引用实验包;实验包可以依赖发布包和彼此。 +dsh 打包与发布集合以及本地基线发布器包含这四个 Agent Teams 目录和 [Cua Driver 提供方](2026-09-12-computer-use-provider-registration.zh.md)。workspace 约束要求它们省略 `private`、将 `publishConfig.access` 设为 `public`,并保留实验性 npm 前缀。实验组之外的发布包、应用和 Python 运行时不能在 `dependencies`、`optionalDependencies` 或 `peerDependencies` 中引用实验包;实验包可以依赖发布包和彼此。 通用的调用方预留 continuable child 身份和精确 direct-child drain 仍属于稳定 Subagent 服务。它们负责 Subagent 身份与 Activation 生命周期,不 import 或命名 Agent Teams;实验性 Team 服务沿允许的方向消费这些能力。 -公开发布的 Host 侧 Agent Teams profile bundle 依赖 Team 包,并在 `dsh-base` 之后应用。它会插入 Team 配置行,并禁用模型可见名称与 Team 工具重叠的全局 continuable-child control。独立公开发布的 Web profile 在 `dsh-web-app` 与 Host profile 之后应用;它会插入 Team UI,后者挂载 Team package 生成的 Remote contribution。两个层都保持显式启用,不改变随附 base、CLI、Web 与 Python runtime 的依赖图。 +[单组合包决策](2026-09-18-agent-teams-single-bundle.zh.md)取代了独立 Host 与 Web 组合包的组合方式。本记录保留发布、依赖隔离与 promotion 的理由。当前 Team profile 仍须显式启用,并禁用模型可见名称与 Team 工具重叠的全局 continuable-child control;[可选组合包决策](../process/2026-09-15-shipped-optional-bundles.zh.md)负责其随安装交付的规则。 profile 启动会先解析所选 bundle,再计算[不可变 profile resolution generation](2026-09-09-profile-resolution-generations.zh.md)。generation 保留安装优先顺序,按 profile 顺序完整遍历每个显式 bundle 根,并让 pnpm 管理的 profile 包保持优先。runtime 模式在内存中强制该结果;保留的 link 与 dual 模式把同一结果物化为共享和 profile 自有投影。因此,私有 profile 层可以携带实验性 plugin 配置行,而无需把这些 plugin 加入发布 app、要求 profile 用户直接安装传递依赖、破坏 packaged-runtime 的模块身份,或改变其他 profile 的解析结果。 -对这五个包而言,实验性状态改变兼容性与支持预期,而不阻止发布。这些包仍须满足仓库的一般文档、不变式、生命周期、安全、单元测试、真实组合测试和快照要求。promotion 前仍须评审公开约定、限制、测试证据、运行时依赖方,并由一名具名 owner 接受稳定包义务。 +对这四个包而言,实验性状态改变兼容性与支持预期,而不阻止发布。这些包仍须满足仓库的一般文档、不变式、生命周期、安全、单元测试、真实组合测试和快照要求。promotion 前仍须评审公开约定、限制、测试证据、运行时依赖方,并由一名具名 owner 接受稳定包义务。 ## 曾考虑的替代方案 @@ -36,6 +36,6 @@ profile 启动会先解析所选 bundle,再计算[不可变 profile resolution ## 后果 -Agent Teams 会作为 dsh 发布系列中的五个可安装 tarball 发布,同时保持包名不变,也不会在随附 profile 中启用 Team。公开可用不代表这些包稳定或默认受支持,稳定发布包也不能对其建立运行时依赖。 +Agent Teams 会作为 dsh 发布系列中的四个可安装 tarball 发布,同时保持包名不变,也不会在随附 profile 中启用 Team。公开可用不代表这些包稳定或默认受支持,稳定发布包也不能对其建立运行时依赖。 发布系列保留实验性 npm 名称。promotion 仍会按照实验性包规则产生路径和 npm 名改动。 diff --git a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.i18n.yaml index fff8e98bc5..dcadb91f94 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.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-23-client-derived-tool-presentation.md -2026-08-23-client-derived-tool-presentation.md: 703960cb24f90eb678fda381f929efb3f94bbd28 -2026-08-23-client-derived-tool-presentation.zh.md: 7f161379553f3aca33c72c2edf93e114d27eb7f9 +2026-08-23-client-derived-tool-presentation.md: 931276fa6d4c0590746b6c59eeae9f998f05629c +2026-08-23-client-derived-tool-presentation.zh.md: b930f98da17a1cb6ecbf605e3a9f37255ba75fc9 diff --git a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md index 703960cb24..931276fa6d 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md +++ b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md @@ -22,7 +22,7 @@ The required result is one raw Session journal and one Client presentation owner ## Decision -The visual-equivalence requirements below exclude the separately approved [nested terminal-card fix](../bug-fix/2026-09-05-nested-terminal-cards.md); all other presentation and ownership constraints remain. +The visual-equivalence requirements below exclude the separately approved [nested terminal-card fix](../bug-fix/2026-09-05-nested-terminal-cards.md) and [compact tool details](2026-09-10-compact-tool-details.md); all other presentation and ownership constraints remain. The Session Remote journal sends only raw, validated, persistable Session events. `session.page` and `session.follow` do not parse tool arguments, query the Tools registry, restore a presenter scope, execute `presentCall` or `presentResult`, or construct or clone any tool view. @@ -51,7 +51,7 @@ The Host `ToolDefinition.presentCall`, `ToolDefinition.presentResult`, `ToolCall | Retained | the Session log format, Remote journal lifecycle, and Conversation identity/topology | | Retained | the existing keyed slot, Generic fallback, and Chat, Details, and Trajectory structure | | Forbidden | a new Client presenter service, parallel registry, or wire renderer id | -| Forbidden | new cards, visual redesign, interaction redesign, or PTC dispatch rich-card enhancements except the [nested terminal-card exception](../bug-fix/2026-09-05-nested-terminal-cards.md) | +| Forbidden | new cards, visual redesign, interaction redesign, or PTC dispatch rich-card enhancements except the [nested terminal-card exception](../bug-fix/2026-09-05-nested-terminal-cards.md) and [compact tool details](2026-09-10-compact-tool-details.md) | | Forbidden | compatibility dual-writing, version negotiation, or retention of the old `view` field | ## Terminology @@ -64,7 +64,7 @@ The Host `ToolDefinition.presentCall`, `ToolDefinition.presentResult`, `ToolCall **Client card model** means the pure props data under `ui-tool/src/client/tool/models/` consumed directly by `TerminalBlock`, `DiffBlock`, `ReadBlock`, `SearchBlock`, `WebBlock`, or `ToolRow`. -**Specialized card** means the structured terminal, diff, read, search, or web body. Titles, summaries, status dots, and ordinary IN/OUT text remain part of the generic tool row. +**Specialized card** means the structured terminal, diff, read, search, or web body. Titles, summaries, lifecycle styling, business glyphs, and ordinary IN/OUT text remain part of the generic tool row. **Equivalent** means that the same supported input produces the user-visible result and interaction pinned by the existing component, assembly, and browser evidence. It does not require the same intermediate TypeScript types or internal calls. @@ -301,7 +301,7 @@ The Client terminal model derives existing `TerminalBlock` props from the tool n | Input | Preserved result | |---|---| | running standard `bash`/`pwsh` foreground call | terminal prompt, description, cwd, and running state | -| successful standard foreground call | terminal output, exit code/signal, and success or failure status dot | +| successful standard foreground call | terminal output, exit code/signal, and settled state presentation | | `run_in_background:true` | Generic row and raw result | | tool execution error | Generic IN/OUT and error summary | | running persistent `bash`/`pwsh` | terminal prompt | diff --git a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md index 7f16137955..b930f98da1 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md @@ -22,7 +22,7 @@ Host presenter 与 Client keyed renderer 分担展示会形成对同一事件的 ## Decision -下述展示对等要求不包含已独立批准的[嵌套 terminal 卡片修复](../bug-fix/2026-09-05-nested-terminal-cards.zh.md);其他展示与所有权约束全部保留。 +下述展示对等要求不包含已独立批准的[嵌套 terminal 卡片修复](../bug-fix/2026-09-05-nested-terminal-cards.zh.md)和[紧凑工具详情](2026-09-10-compact-tool-details.zh.md);其他展示与所有权约束全部保留。 Session Remote journal 只下发原始、已验证、可持久化的 Session event。`session.page` 和 `session.follow` 不解析工具参数,不查询 Tools registry,不恢复 presenter scope,不执行 `presentCall`/`presentResult`,也不构造或克隆任何 tool view。 @@ -51,7 +51,7 @@ Host 的 `ToolDefinition.presentCall`、`ToolDefinition.presentResult`、`ToolCa | 保留 | Session 日志格式、Remote journal 生命周期与 Conversation identity/topology | | 保留 | 现有 keyed slot、Generic fallback、Chat、Details 与 Trajectory 结构 | | 禁止 | 新 Client presenter service、平行 registry 或 wire renderer id | -| 禁止 | 新卡片、视觉改版、交互改版或 PTC dispatch rich-card 增强,[嵌套 terminal 卡片例外](../bug-fix/2026-09-05-nested-terminal-cards.zh.md)除外 | +| 禁止 | 新卡片、视觉改版、交互改版或 PTC dispatch rich-card 增强,[嵌套 terminal 卡片例外](../bug-fix/2026-09-05-nested-terminal-cards.zh.md)和[紧凑工具详情](2026-09-10-compact-tool-details.zh.md)除外 | | 禁止 | 为兼容保留双写、版本协商或旧 `view` 字段 | ## 术语 @@ -64,7 +64,7 @@ Host 的 `ToolDefinition.presentCall`、`ToolDefinition.presentResult`、`ToolCa **Client card model**指 `ui-tool/src/client/tool/models/` 下直接供 `TerminalBlock`、`DiffBlock`、`ReadBlock`、`SearchBlock`、`WebBlock` 或 `ToolRow` 使用的纯 props 数据。 -**专用卡片**指 terminal、diff、read、search 与 web 的结构化正文;标题、摘要、状态点和普通 IN/OUT 文本仍属于通用工具行。 +**专用卡片**指 terminal、diff、read、search 与 web 的结构化正文;标题、摘要、生命周期样式、业务图形和普通 IN/OUT 文本仍属于通用工具行。 **对等**指同一受支持输入产生由现有组件、组装与浏览器证据固定的用户可见结果和交互,不要求相同的中间 TypeScript 类型或内部函数调用。 @@ -301,7 +301,7 @@ Client terminal model 从工具名称、调用参数、结果 content、error | 输入 | 保持的结果 | |---|---| | 标准 `bash`/`pwsh` 前台 running | terminal prompt、description、cwd、running 状态 | -| 标准前台 success | terminal output、exit code/signal、成功或失败状态点 | +| 标准前台 success | terminal output、exit code/signal 与结算状态呈现 | | `run_in_background:true` | Generic 行与原始结果 | | 工具执行 error | Generic IN/OUT 与错误摘要 | | persistent `bash`/`pwsh` running | terminal prompt | diff --git a/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.i18n.yaml index 88e2561815..af52bdcb23 100644 --- a/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.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-24-browser-token-authentication.md -2026-08-24-browser-token-authentication.md: cdfed1f250de6def39e386cf9fcd2e536c6c97e4 -2026-08-24-browser-token-authentication.zh.md: 29f3a6c0a2d83ed1c17b9c0bc5988c9472741f3f +2026-08-24-browser-token-authentication.md: 47d6afe1bf4cd8169eb4e8ec92a30bf0fee32c8a +2026-08-24-browser-token-authentication.zh.md: 13ecf55e41c7bb1455177bdd6a87a3a83d94ca6f diff --git a/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.md b/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.md index cdfed1f250..47d6afe1bf 100644 --- a/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.md +++ b/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.md @@ -12,9 +12,9 @@ The Web Host runs tool-capable Sessions with the current operating-system user's `dsh-client-connection` authenticates the complete Host API before dispatch. Every API Proxy method, Remote unary call, generic Connection channel, and Remote WebSocket stream requires the same browser session; endpoint ownership and method names do not alter authority. The existing Host/Origin checks run first and retain their DNS-rebinding and cross-site-request role, returning 403 when they fail. A trusted Host without a valid browser session receives 401. The browser-trust rules remain owned by the [carrier-level browser trust decision](2026-07-28-api-browser-trust-boundary.md). -Each Host process generates a random launch token, retained by the root application context across Connection hot reloads. `dsh-web-app` prints and opens the normal root URL with that token in the query once per process. `frontend-static` asks Connection to authorize index responses: only `GET /?token=...` exchanges the process token for a cookie, then redirects to clean `/`; the token is not accepted on API paths or in an Authorization header. An obsolete token paired with a valid cookie redirects to clean `/`. Missing and invalid credentials receive one minimal 401 response. Static non-index assets remain public. +Each Host process generates a random launch token, retained by the root application context across Connection hot reloads. `dsh-web-app` prints and opens the normal root URL with that token in the query once per process. `frontend-static` asks Connection to authorize index responses: only `GET /?token=...` exchanges the process token for a cookie, then redirects to clean `./`; the token is not accepted on API paths or in an Authorization header. An obsolete token paired with a valid cookie redirects to clean `./`. Missing and invalid credentials receive one minimal 401 response. Static non-index assets remain public. -The cookie is a signed, authority-bound bearer. Its deterministic name and signed payload both include the normalized hostname plus port, so one Harness home can run independent Web ports without cookie collisions. The payload carries safe-integer issue and expiry times under an absolute lifetime; `cookieMaxAgeDays` defaults to 30. The cookie is host-only, `Path=/`, `HttpOnly`, and `SameSite=Strict`. It omits `Secure` because the shipped server uses loopback HTTP. There is no logout operation or reverse-proxy-specific handling. +The cookie is a signed, authority-bound bearer. Its deterministic name and signed payload both include the normalized hostname plus port, so one Harness home can run independent Web ports without cookie collisions. The payload carries safe-integer issue and expiry times under an absolute lifetime; `cookieMaxAgeDays` defaults to 30. The cookie is host-only, `Path=/`, `HttpOnly`, and `SameSite=Strict`. It omits `Secure` because the shipped server uses loopback HTTP. There is no logout operation. The HMAC secret is a versioned `grant` record at `client-connection/browser-session` in `ctx.credentials`; the local provider stores it in `$DSH_HOME/.credentials.yaml`. Connection loads or creates the record during activation and retains the secret for synchronous request verification. An active Connection continues using its loaded secret if the durable record changes; the next activation loads the replacement or creates a missing record, so deleting the record and restarting the process revokes every existing cookie. Invalid owner payloads fail loud instead of being replaced. The launch token itself is never persisted and changes on every process start, while an unexpired cookie remains valid across restarts on the same authority. diff --git a/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.zh.md b/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.zh.md index 29f3a6c0a2..13ecf55e41 100644 --- a/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.zh.md @@ -12,9 +12,9 @@ Web Host 以当前操作系统用户的权限运行具有工具能力的 Session `dsh-client-connection` 在分发前认证完整 Host API。每个 API Proxy 方法、Remote 一元调用、通用 Connection channel 和 Remote WebSocket stream 都要求同一个浏览器会话;endpoint 所有权与方法名称不改变 authority。既有 Host/Origin 校验先执行,继续负责 DNS rebinding 和跨站请求防御,失败时返回 403。Host 可信但没有有效浏览器会话时返回 401。浏览器信任规则仍由[载体级浏览器信任决策](2026-07-28-api-browser-trust-boundary.zh.md)持有。 -每个 Host 进程生成随机启动令牌,并由应用根 context 跨 Connection 热重载保留。`dsh-web-app` 每个进程只打印并打开一次 query 中带该令牌的普通根 URL。`frontend-static` 请求 Connection 授权 index 响应:只有 `GET /?token=...` 会把进程令牌交换为 cookie,再重定向到干净的 `/`;API 路径和 Authorization header 都不接受该令牌。过时令牌如果同时带有有效 cookie,会重定向到干净的 `/`。缺失与无效凭据得到同一份最小 401 响应。非 index 静态资产保持公开。 +每个 Host 进程生成随机启动令牌,并由应用根 context 跨 Connection 热重载保留。`dsh-web-app` 每个进程只打印并打开一次 query 中带该令牌的普通根 URL。`frontend-static` 请求 Connection 授权 index 响应:只有 `GET /?token=...` 会把进程令牌交换为 cookie,再重定向到干净的 `./`;API 路径和 Authorization header 都不接受该令牌。过时令牌如果同时带有有效 cookie,会重定向到干净的 `./`。缺失与无效凭据得到同一份最小 401 响应。非 index 静态资产保持公开。 -cookie 是签名且绑定 authority 的 bearer。确定性名称与签名 payload 都包含规范化 hostname 和 port,因此同一 Harness home 可以在不同 Web port 运行而不发生 cookie 冲突。payload 在绝对有效期内携带安全整数形式的签发与过期时间;`cookieMaxAgeDays` 默认为 30。cookie 是 host-only、`Path=/`、`HttpOnly`、`SameSite=Strict`。随附服务器使用 loopback HTTP,因此不设置 `Secure`。这里没有 logout 操作或反向代理专用处理。 +cookie 是签名且绑定 authority 的 bearer。确定性名称与签名 payload 都包含规范化 hostname 和 port,因此同一 Harness home 可以在不同 Web port 运行而不发生 cookie 冲突。payload 在绝对有效期内携带安全整数形式的签发与过期时间;`cookieMaxAgeDays` 默认为 30。cookie 是 host-only、`Path=/`、`HttpOnly`、`SameSite=Strict`。随附服务器使用 loopback HTTP,因此不设置 `Secure`。这里没有 logout 操作。 HMAC 密钥是 `ctx.credentials` 中位于 `client-connection/browser-session` 的版本化 `grant` 记录;本地提供方将其存入 `$DSH_HOME/.credentials.yaml`。Connection 在激活期间加载或创建该记录,并保留密钥以同步校验请求。持久记录发生变化后,当前 Connection 继续使用已加载的密钥;下一次激活会加载替换记录或创建缺失记录,因此删除记录并重启进程会撤销全部既有 cookie。无效 owner payload 会明确失败,而不是被覆盖。启动令牌本身绝不持久化并在每次进程启动时变化;未过期 cookie 则能在相同 authority 上跨重启继续有效。 diff --git a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.i18n.yaml index 3c5fbdae42..2c2fc1b908 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.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-25-session-observations-and-projection-owned-client-state.md -2026-08-25-session-observations-and-projection-owned-client-state.md: ff5628c3051086159d532b71ca3e98c1b41a51f8 -2026-08-25-session-observations-and-projection-owned-client-state.zh.md: e433df28fec86564fe1c1e9e88686a5abe6b9549 +2026-08-25-session-observations-and-projection-owned-client-state.md: 0ff8cbf50c18285092432850ce5222430f5acf0d +2026-08-25-session-observations-and-projection-owned-client-state.zh.md: f3fca078d089a9ee1e329fdbb661653053053a91 diff --git a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md index ff5628c305..0ff8cbf50c 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md +++ b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md @@ -120,7 +120,7 @@ Client-local interaction state also remains local: loading and error status, an ### Domain applications -- **Title and list metadata.** Cached projection hints may render an existing title and determine blankness or recency. Missing hints leave those facts unknown; only the bounded small-log policy may resolve them during listing. +- **Title and list metadata.** Cached projection hints may render an existing title and determine blankness or recency. Missing hints leave those facts unknown; only the bounded small-log policy may resolve them during listing. The Client reconciles list rows with the current metadata projection: nonblank evidence excludes a Session from blank reuse, and the later prompt timestamp supplies recency. Projection stores outlive lazy Client Session instances, so instantiation reads retained metadata even before a list row is available. A stale list response cannot override a newer history or control projection. - **Model selection.** `model/selection` records a complete provider, model, and optional reasoning effort. `modelSelection` distinguishes the last request's route from a later selection pending consumption by a request header. - **Agent preset.** The projection initializes from immutable Session metadata and advances on preset-selection events. A missing or `null` value is not replaced with the deployment default for an existing Session. - **Subagent identity.** The `subagent` unit remains the sole descriptor interpreter. Listing obtains candidates from the shared corpus and resolves values through live state, projection cache, or an observation rather than scanning events itself. diff --git a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md index e433df28fe..f3fca078d0 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md @@ -120,7 +120,7 @@ Client 本地交互状态也继续留在本地:loading 和 error 状态、打 ### 领域应用 -- **Title 与 list metadata。** Cached projection hints 可以渲染已有 title,并判断 blankness 或 recency。Hints 缺失时这些事实保持未知;listing 期间只有有界小日志策略可以解析它们。 +- **Title 与 list metadata。** Cached projection hints 可以渲染已有 title,并判断 blankness 或 recency。Hints 缺失时这些事实保持未知;listing 期间只有有界小日志策略可以解析它们。Client 使用当前 metadata projection 对账列表行:非空白事实会将 Session 排除在空白复用之外,较晚的 prompt 时间用于最近活动排序。Projection store 的生命周期长于惰性创建的 Client Session 实例,因此实例化时即使尚无列表行,也会读取已保留的元数据。过期列表响应不能覆盖较新的 history 或 control projection。 - **Model selection。** `model/selection` 记录完整 provider、model 和可选 reasoning effort。`modelSelection` 区分上一请求使用的 route,以及等待 request header 消费的较晚 selection。 - **Agent preset。** Projection 从不可变 Session metadata 初始化,并随 preset-selection event 推进。对于现有 Session,缺失或 `null` 值不会替换成部署默认值。 - **Subagent identity。** `subagent` unit 仍是唯一 descriptor interpreter。Listing 从共享 corpus 获得 candidate,并通过 live state、projection cache 或 observation 解析值,不自行扫描 event。 diff --git a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.i18n.yaml index bb8c3f29df..d67dd0c16f 100644 --- a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.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-28-subprocess-native-containment.md -2026-08-28-subprocess-native-containment.md: 1b0fd162be77356001bcd9224bb2ee2889e6d6c0 -2026-08-28-subprocess-native-containment.zh.md: f33643f98a6ea6a90c0780432cbe11a35a868562 +2026-08-28-subprocess-native-containment.md: 64fd59e13ea38d7f6e066d5dadec4be830c9c0af +2026-08-28-subprocess-native-containment.zh.md: 59e51c5a5c19f55058eb3881928d4f218a22a59c diff --git a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md index 1b0fd162be..64fd59e13e 100644 --- a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md +++ b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md @@ -54,6 +54,8 @@ This note owns the current native-containment mechanism. It partially updates th ## Verification +The host-exit disposal fixture compares the provider's listener identities before and after disposal and verifies that an independently registered exit listener remains installed. Total process listener counts cannot establish provider cleanup because other process-lifetime handlers may register after provider construction. + - Provider and Linux protocol suites pin synchronous NUL rejection before launch side effects, strict request/error decoding, target cwd and complete environment restoration, private-variable collision, symlink-sensitive PATH traversal with preserved argv, close-on-exec removal for inherited stdio, pre-exec error ownership, failed-deep-probe retry plus successful-deep-probe caching with per-call manager checks, the three scope-establishment states including requested versus unexpected exits with an unconsumed request, `LoadState`/`ActiveState`/`TasksCurrent` parsing, releasing a leftover scope left active with no processes beside the live-client, unterminated, and unset-count cases, `reloading`, terminate wake-up with losing-delay cancellation, bounded established-scope backoff, and exactly-once PTY managed-owner cleanup. - Windows protocol and Win32 suites pin exactly two result branches, numeric-only target exits, ordinary-error start cancellation with raw parent-local reasons, the reduced `name`/`message`/`code`/`syscall`/`path` error record, the fixed `2`/`3`/`267` to `ENOENT`, `740` to `EACCES`, `5` to `EPERM`, `193` to `EFTYPE`, and remaining-code to `UNKNOWN` mapping, start delivery after runner spawn, empty-range settlement after pre-spawn failure, explicit ordinally sorted target environment blocks with `=C:` preservation and double-NUL termination, `uv_get_osfhandle()` carrier mapping and unsigned invalid-sentinel rejection, the null-device ignored-stdin carrier and piped non-ignored stdin, result-send and IPC-disconnect failures, direct-result latching before stdio settlement, active-process quiescence, and unique handle cleanup. - A keyless [`bash-startup-timeout`](../../../../snapshots/session/bash-startup-timeout/snapshot.yml) Session snapshot pins the model-facing timeout result. A Linux user-systemd fixture holds the launch request unconsumed at an input barrier and verifies cancellation plus range settlement. diff --git a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.zh.md b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.zh.md index f33643f98a..59e51c5a5c 100644 --- a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.zh.md @@ -54,6 +54,8 @@ selector 是 per-spawn locator 或 sentinel,不是凭据或持久格式。Linu ## Verification +宿主退出清理 fixture 对比 provider 在 disposal 前后的监听器身份,并验证独立注册的退出监听器仍然存在。进程监听器总数无法证明 provider 清理完成,因为其他进程生命周期处理器可能在 provider 构造之后注册。 + - provider 与 Linux 协议测试套件固定同步 NUL 拒绝发生在启动副作用之前、严格 request/error 解码、target cwd 与完整环境恢复、私有变量碰撞、保留 argv 且对 symlink 敏感的 PATH 遍历、为继承 stdio 清除 close-on-exec、pre-exec error ownership、失败深度 probe 重试与成功深度 probe 缓存及逐调用 manager 检查、三种 scope 建立状态(包括 request 未消费时的请求终止与意外退出)、`LoadState`/`ActiveState`/`TasksCurrent` 解析、释放被留在 active 且没有任何进程的遗留 scope(连同 client 仍存活、未请求终止与进程数未上报三种情形)、`reloading`、带未胜出 delay 取消的 terminate wake-up、建立后有上限的退避,以及 PTY managed-owner 恰好一次 cleanup。 - Windows 协议与 Win32 测试套件固定恰好两个 result 分支、只含数字的 target exit、使用普通 error 的 start cancellation 与 parent 原样保留的本地 reason、缩减到 `name`/`message`/`code`/`syscall`/`path` 的 error record、固定的 `2`/`3`/`267` 到 `ENOENT`、`740` 到 `EACCES`、`5` 到 `EPERM`、`193` 到 `EFTYPE` 及其余 code 到 `UNKNOWN` 的映射、runner spawn 后才发送 start、spawn 前 failure 的 empty-range settlement、按序数显式排序的 target 环境块及 `=C:` 保留和双 NUL 结尾、`uv_get_osfhandle()` carrier 映射与 unsigned invalid sentinel 拒绝、null-device ignored-stdin carrier 与非 ignore stdin pipe、result-send 与 IPC-disconnect failure、stdio settlement 前的 direct-result 锁存、active-process 完全停稳,以及唯一 handle cleanup。 - 无需密钥的 [`bash-startup-timeout`](../../../../snapshots/session/bash-startup-timeout/snapshot.yml) Session 快照固定模型可见的超时结果。Linux user-systemd fixture 通过输入屏障保持启动请求未消费,并验证取消与 range settlement。 diff --git a/.agents/notes/implemented/architecture/2026-09-05-shared-client-control-primitives.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-05-shared-client-control-primitives.i18n.yaml index 89a705592d..55f30d278a 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-shared-client-control-primitives.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-05-shared-client-control-primitives.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-09-05-shared-client-control-primitives.md -2026-09-05-shared-client-control-primitives.md: 14128497cf4152659a62136cbdeb74b92ee3fbf0 -2026-09-05-shared-client-control-primitives.zh.md: 66268b48f81887181c3981bf89ead30eb74250af +2026-09-05-shared-client-control-primitives.md: db8cb9a8bc515918867b27b216fe8edb50c392db +2026-09-05-shared-client-control-primitives.zh.md: 6d4b04a21bf224eaa32b5fbdfb2bf1c3a2cd804d diff --git a/.agents/notes/implemented/architecture/2026-09-05-shared-client-control-primitives.md b/.agents/notes/implemented/architecture/2026-09-05-shared-client-control-primitives.md index 14128497cf..db8cb9a8bc 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-shared-client-control-primitives.md +++ b/.agents/notes/implemented/architecture/2026-09-05-shared-client-control-primitives.md @@ -18,15 +18,15 @@ Neither half of the fix was available to an author. No checklist told them to lo `Switch` is the two-state toggle, at the 36×20 track and 16px thumb `ui-settings-plugins` established. `label` is required and has no default, so a render site cannot omit the accessible name; `title` carries a lock reason where a deployment disables the control. -`StateDotState` gains `idle`, a static grey dot in the same halo-and-core construction as `done`, `warning`, and `error`. The plugin inventory's `pending` and `unloading` phases mean no activity is in progress, and the four existing states had no member for that; without `idle` those two phases lose their marker entirely. The addition is safe for the eight packages already consuming `StateDot` because each of them produces a `StateDotState` from its own closed status union and none switches over `StateDotState` itself. +`StateDotState` includes `idle` for a tracked subject with no activity in progress. The plugin inventory's `pending` and `unloading` phases need that semantic member; without it those two phases lose their marker entirely. The five-state union remains safe for consumers because each package produces a `StateDotState` from its own closed status union and none switches over `StateDotState` itself. The current solid-dot palette and rotating `ongoing` loader are owned by the later [StateDot visual language decision](../feature/2026-09-17-state-dot-visual-language.md). -**The component catalog in the `ui-primitives` README is what makes the rule usable.** It lists every exported component with its purpose and the case it is wrong for, and it names the three pairs that are easy to confuse: `Tag` against `Pill`, `DisclosureRow` against card-shaped disclosure, and the package-internal `FoldToggle` against the exported surface. `Pill` is the selectable capsule button — it takes `active` and `onClick` and drives view switchers and filters; `Tag` is the read-only badge and takes neither. `Pill`'s own header comment previously advertised itself for badges, which contradicted the catalog, and no longer does. +**The component catalog in the `ui-primitives` README is what makes the rule usable.** It lists every exported component with its purpose and the case it is wrong for, and it names the three pairs that are easy to confuse: `Tag` against `Pill`, `DisclosureRow` against card-shaped disclosure, and the package-internal `FoldToggle` against the exported surface. `Pill` is the selectable capsule button — it takes `active` and `onClick` and drives view switchers and filters; `Tag` is the read-only badge and takes neither. The component comments use the same distinction. The rule is stated in [packages/client/AGENTS.md](../../../../packages/client/AGENTS.md) as the first step of the new-component checklist, and [docs/web-styling.md](../../../../docs/web-styling.md) points at the catalog so an author arriving from the styling side reaches it too. ## Finding the duplicates -A name-based search undercounts. `.badge`, `.tag`, `.chip`, and `.configTag` miss a capsule named for its role rather than its appearance — `PluginCard`'s unsaved marker is `.pending`, and its rule was byte-identical to the badge two files away. What finds them is the geometry: a CSS Modules rule carrying both `border-radius: 999px` and `padding: 1px 8px`. After this change that signature matches exactly two rules: `Tag` itself and the broken badge below. The unsaved marker keeps only a `flex: none` placement class, which the signature no longer matches. +A name-based search undercounts. `.badge`, `.tag`, `.chip`, and `.configTag` miss a capsule named for its role rather than its appearance — `PluginCard`'s unsaved marker is `.pending`. Geometry is the reliable probe: search CSS Modules rules carrying both `border-radius: 999px` and `padding: 1px 8px`. That signature belongs to `Tag` and the deliberately local broken badge below; the unsaved marker keeps only its `flex: none` placement class. ## What stays local @@ -34,7 +34,6 @@ A name-based search undercounts. `.badge`, `.tag`, `.chip`, and `.configTag` mis A mechanical search groups these controls with the promoted three. They stay in their own packages, because the grouping is superficial: - **`ui-trajectory`'s toolbar toggle** carries `role="switch"` but is an 88px labelled control with an inline track, and it currently renders `hidden`. It is not the same widget as `Switch`. -- **`ui-schedule`'s status dot** is a static blue dot for the next run that turns amber when overdue. `StateDot` has no static blue — its only blue is `ongoing`, an animated pixel matrix — and one animation per row in a schedule list would misstate the meaning as well as the appearance. - **`ui-plan`'s mode chip** and **`ui-conversation`'s `ReferenceChip`** are interactive: the first is a warning-toned button with hover, focus, disabled, and a close affordance; the second is a Lexical atom node with its own truncation. Neither is a read-only badge. - **`ui-trajectory`'s cell tag** and **`ui-user-questions`'s recommendation badge** use their own geometry — a 6px radius at table density, and a 6px radius at 600 weight on the sidebar accent. Forcing either into the capsule baseline would change a deliberate design, not an accidental one. - **`TerminalBlock`'s exit-status pill** stays a static `Pill`. It sits on a 24px command line at the pill's own geometry, and `Tag`'s 11px capsule would not fit that row. The read-only/selectable split is the usual guide, but size decides this one, and the catalog says so. @@ -52,7 +51,7 @@ A mechanical search groups these controls with the promoted three. They stay in **A two-axis `variant × tone` API for `Tag`.** Rejected. Three variants against six tones describes eighteen combinations of which eight ship, and it lets a caller request combinations with no defined appearance. The flat eight-member union maps each value to exactly one shipped appearance. -**Rendering no dot for `pending` and `unloading` instead of adding `idle`.** Rejected. Those rows show a grey dot today, and dropping it would remove information from the inventory in a change whose purpose is to consolidate presentation. +**Rendering no dot for `pending` and `unloading` instead of adding `idle`.** Rejected. Those phases need a compact inactive or transitional marker; dropping it would remove information from the inventory in a change whose purpose is to consolidate presentation. **Putting the reuse rule in the root `AGENTS.md`.** Rejected. The rule governs `packages/client` alone, and the root file sits exactly at its 1950-word ceiling in `scripts/doc-budgets.manifest.json`, so stating it there would have to displace an unrelated repository-wide rule. @@ -60,15 +59,15 @@ A mechanical search groups these controls with the promoted three. They stay in `Tag`, `Switch`, and the extended `StateDot` carry component specs in `packages/client/ui-primitives/tests`, inside the per-file 100% coverage gate. `StateDot`'s palette is pinned by reading its stylesheet: CSS Modules resolve to class-name maps in the component suites, so a state whose color rule is missing renders on the inherited color and no render assertion notices. -The migrated render sites keep their existing package specs unchanged. The web e2e goldens are ARIA snapshots, and the full replayed web suite passes without re-recording, because the migration preserves every role, accessible name, and state — `Switch` keeps `role="switch"` with `aria-checked`, and the inventory's phase dot keeps its `role="img"` name on a wrapper, since `StateDot` is `aria-hidden`. +The migrated render sites keep package specs for their role, accessible name, state mapping, and interaction. Web ARIA snapshots cover the assembled presentations — `Switch` keeps `role="switch"` with `aria-checked`, and the inventory's phase dot keeps its `role="img"` name on a wrapper because `StateDot` is `aria-hidden`. -That is also the limit of the automated evidence. No gate in this repository compares pixels, so the capsule geometry, the dot halo, and the font-weight change are verified by review against the light and dark screenshots in the pull request. +The component and stylesheet specs pin the capsule geometry, StateDot artwork, animation, tokens, and font weight; Web snapshots pin accessible structure. Manual visual inspection remains responsible for the rendered result across themes. ## Consequences - A new client control now has one place to check and one place to add, and the catalog makes the check a single file read rather than a `grep` over forty exports. - `TagTone` is eight members wide because eight appearances shipped. Adding a ninth requires a render site that needs it, not a symmetry argument. -- The plugin inventory's tags change from a 5px rectangle to a capsule, and the disabled tag's fill moves from `--dsw-alias-bg-layer-1` to the `neutral` tone's `--dsw-alias-bg-module-platform`, which is a visible grey where it used to be near-transparent. Its phase dots gain a halo and animate through `loading` and `unloading`. The unconfigured-secret badge in the plugin settings fields moves from 400 to the baseline 500 weight. These are deliberate visual changes, recorded here so a later reader does not treat them as regressions. -- The migration removes a literal `#b45309`. The plugin inventory's `conditional` tag read `var(--dsw-alias-state-warning-primary, #b45309)`, and no such alias exists — the real token is `--dsw-alias-state-warn-primary` — so both themes had been painting the hardcoded fallback that [docs/web-styling.md](../../../../docs/web-styling.md) forbids. +- The plugin inventory uses capsule tags; the disabled tag uses the `neutral` tone's visible `--dsw-alias-bg-module-platform` fill. Its phase markers use the shared `StateDot`, including the rotating loader for active transitions. The unconfigured-secret badge in the plugin settings fields uses the baseline 500 weight. +- The plugin inventory palette contains no literal `#b45309`. The conditional tag uses the real `--dsw-alias-state-warn-primary` token rather than the nonexistent `--dsw-alias-state-warning-primary` alias and its hardcoded fallback. - The rule cannot be checked mechanically. A future author can still copy a control, and only review will catch it. That is the accepted cost of not gating: the alternative rejects legitimate work, and the packages that stay local above are proof that legitimate work exists. -- `ui-primitives` grows two components that one package each consumes today. The switch in particular is a single-consumer primitive, promoted because it is a general control and because the plugin-management work already in flight will adopt it rather than adding two further copies. +- `Tag` and `Switch` are shared across settings and preset packages; future consumers extend these primitives only when an existing prop cannot express a deliberate difference. diff --git a/.agents/notes/implemented/architecture/2026-09-05-shared-client-control-primitives.zh.md b/.agents/notes/implemented/architecture/2026-09-05-shared-client-control-primitives.zh.md index 66268b48f8..6d4b04a21b 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-shared-client-control-primitives.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-05-shared-client-control-primitives.zh.md @@ -18,15 +18,15 @@ Status: implemented `Switch` 是双态开关,采用 `ui-settings-plugins` 确立的 36×20 轨道与 16px 滑块。`label` 必填且没有默认值,渲染点无法省略无障碍名称;`title` 在部署禁用该控件时承载锁定原因。 -`StateDotState` 增加 `idle`,一个静态灰点,与 `done`、`warning`、`error` 采用同样的光晕加实心核结构。插件清单的 `pending` 与 `unloading` 相位表示没有进行中的活动,原有四态没有对应成员;没有 `idle`,这两个相位会彻底失去标记。这次增补对已经消费 `StateDot` 的八个包是安全的:它们各自从自己的闭合状态联合**产生** `StateDotState`,没有任何一个对 `StateDotState` 本身做 switch。 +`StateDotState` 包含 `idle`,表示一个被跟踪的对象当前没有进行中的活动。插件清单的 `pending` 与 `unloading` 相位需要这个语义成员;没有 `idle`,这两个相位会彻底失去标记。五态联合对消费方保持安全,因为每个包都从自己的闭合状态联合**产生** `StateDotState`,没有任何一个对 `StateDotState` 本身做 switch。当前的纯色点配色与旋转 `ongoing` loading 由后续的 [StateDot 视觉语言决策](../feature/2026-09-17-state-dot-visual-language.zh.md)负责。 -**`ui-primitives` README 里的组件目录,是这条规则得以可用的前提。** 它列出每个导出组件的用途,以及它不适用的场合,并点名三组容易混淆的配对:`Tag` 与 `Pill`、`DisclosureRow` 与卡片式折叠、包内的 `FoldToggle` 与对外导出面。`Pill` 是可选中的胶囊按钮——它接受 `active` 与 `onClick`,用于视图切换与筛选器;`Tag` 是只读徽章,两者都不接受。`Pill` 自己的头注释此前把自身宣传为可用作徽章,与目录互相矛盾,现已改掉。 +**`ui-primitives` README 里的组件目录,是这条规则得以可用的前提。** 它列出每个导出组件的用途,以及它不适用的场合,并点名三组容易混淆的配对:`Tag` 与 `Pill`、`DisclosureRow` 与卡片式折叠、包内的 `FoldToggle` 与对外导出面。`Pill` 是可选中的胶囊按钮——它接受 `active` 与 `onClick`,用于视图切换与筛选器;`Tag` 是只读徽章,两者都不接受。组件注释使用同一套区分。 规则写在 [packages/client/AGENTS.md](../../../../packages/client/AGENTS.md) 新组件清单的第一步,[docs/web-styling.md](../../../../docs/web-styling.zh.md) 指向该目录,让从样式一侧进来的作者也能到达。 ## 如何找出重复 -按名字搜会漏。`.badge`、`.tag`、`.chip`、`.configTag` 找不到以角色而非外观命名的胶囊——`PluginCard` 的未保存标记叫 `.pending`,而它的规则与两个文件之外的徽章逐字节相同。能找出它们的是几何特征:同时带有 `border-radius: 999px` 与 `padding: 1px 8px` 的 CSS Modules 规则。本次改动后,这个特征恰好匹配两条规则:`Tag` 本身与下面那个损坏徽章。未保存标记只剩一个 `flex: none` 布局类,已不再匹配该特征。 +按名字搜会漏。`.badge`、`.tag`、`.chip`、`.configTag` 找不到以角色而非外观命名的胶囊——`PluginCard` 的未保存标记叫 `.pending`。几何特征才是可靠探针:搜索同时带有 `border-radius: 999px` 与 `padding: 1px 8px` 的 CSS Modules 规则。这个特征属于 `Tag` 与下面那个有意保留在原包的损坏徽章;未保存标记只保留自己的 `flex: none` 布局类。 ## 保留在原包的控件 @@ -34,7 +34,6 @@ Status: implemented 机械搜索会把下面这些控件归到被提升的那三类里。它们留在各自的包中,因为那种归并只是表面相似: - **`ui-trajectory` 的工具栏开关**带 `role="switch"`,但它是 88px 宽的带标签控件、轨道内联,而且当前渲染为 `hidden`。它和 `Switch` 不是同一个部件。 -- **`ui-schedule` 的状态点**是表示下次运行的静态蓝点,逾期转为琥珀色。`StateDot` 没有静态蓝——它唯一的蓝是 `ongoing`,一个动画像素方阵——日程列表里每行一个动画,既说错了含义也说错了观感。 - **`ui-plan` 的模式 chip** 与 **`ui-conversation` 的 `ReferenceChip`** 都是可交互的:前者是警告色调的按钮,带 hover、focus、disabled 与关闭操作;后者是带自有截断逻辑的 Lexical 原子节点。两者都不是只读徽章。 - **`ui-trajectory` 的单元格 tag** 与 **`ui-user-questions` 的推荐徽章**使用各自的几何——一个是表格密度下的 6px 圆角,一个是侧栏强调色上 600 字重的 6px 圆角。把它们塞进胶囊基准,改掉的是有意的设计,不是意外的分歧。 - **`TerminalBlock` 的退出状态胶囊**保持为静态 `Pill`。它落在 24px 的命令行上、用的是 pill 自己的几何,`Tag` 的 11px 胶囊放不进那一行。只读与可选中的区分是通常的判据,但这一处由尺寸决定,目录里也是这么写的。 @@ -52,7 +51,7 @@ Status: implemented **给 `Tag` 设计 `variant × tone` 双轴 API。** 否决。三个 variant 乘六个 tone 描述十八种组合,其中八种会发布,而且它允许调用方请求没有定义外观的组合。扁平的八成员联合把每个值映射到恰好一种已发布外观。 -**让 `pending` 与 `unloading` 不渲染点,而不是新增 `idle`。** 否决。这两行今天显示灰点,在一个目的是统一呈现的改动里把它删掉,等于从清单中拿走信息。 +**让 `pending` 与 `unloading` 不渲染点,而不是新增 `idle`。** 否决。这些阶段需要紧凑的未活动或过渡标记;在一个目的是统一呈现的改动里把它删掉,等于从清单中拿走信息。 **把复用规则写进根 `AGENTS.md`。** 否决。这条规则只管 `packages/client`,而根文件正好处在 `scripts/doc-budgets.manifest.json` 给它的 1950 词上限上,写在那里就必须挤掉一条不相干的仓库级规则。 @@ -60,15 +59,15 @@ Status: implemented `Tag`、`Switch` 与扩展后的 `StateDot` 在 `packages/client/ui-primitives/tests` 中各有组件测试,处于每文件 100% 覆盖率门禁之内。`StateDot` 的配色通过读取其样式表来钉住:CSS Modules 在组件测试里解析为类名映射,因此某个状态缺了配色规则时会落到继承色上,而任何渲染断言都不会察觉。 -迁移后的渲染点保留各自包内原有的测试,未作改动。web e2e 的 golden 是 ARIA 快照,完整的 replay web 套件无需重录即可通过,因为这次迁移保住了每一个 role、无障碍名称与状态——`Switch` 保留带 `aria-checked` 的 `role="switch"`,而由于 `StateDot` 是 `aria-hidden`,插件清单的相位点把 `role="img"` 名称保留在外层包裹元素上。 +迁移后的渲染点保留包级测试,覆盖 role、无障碍名称、状态映射与交互。Web ARIA 快照覆盖装配后的呈现——`Switch` 保留带 `aria-checked` 的 `role="switch"`,而由于 `StateDot` 是 `aria-hidden`,插件清单的相位点把 `role="img"` 名称保留在外层包裹元素上。 -这同时也是自动化证据的边界。本仓库没有任何门禁比较像素,因此胶囊几何、状态点光晕与字重变化,只能由评审对照 PR 中的明暗两套截图来确认。 +组件与样式表测试固定胶囊几何、StateDot 图稿、动画、token 与字重;Web 快照固定无障碍结构。明暗主题下的最终渲染仍由人工视觉检查负责。 ## Consequences - 新的客户端控件现在有一处可查、一处可加,而目录把这次查询变成读一个文件,而不是在四十多个导出上 `grep`。 - `TagTone` 有八个成员,因为发布了八种外观。加第九个需要一个真的需要它的渲染点,而不是一个对称性论证。 -- 插件清单的标签从 5px 矩形变为胶囊,其中"已停用"标签的底色从 `--dsw-alias-bg-layer-1` 变为 `neutral` 色调的 `--dsw-alias-bg-module-platform`,由近乎透明变成可见的灰底。相位点获得光晕,并在 `loading` 与 `unloading` 两个相位动画。插件设置字段中"未配置"徽章的字重从 400 改为基准的 500。这些都是有意的视觉变更,记录在此,以免后来的读者把它们当成回归。 -- 本次迁移移除了一个 `#b45309` 字面量。插件清单的 `conditional` 标签写的是 `var(--dsw-alias-state-warning-primary, #b45309)`,而这个别名并不存在——真正的 token 是 `--dsw-alias-state-warn-primary`——所以明暗两套主题一直在绘制那个被 [docs/web-styling.md](../../../../docs/web-styling.zh.md) 禁止的硬编码兜底色。 +- 插件清单使用胶囊标签;"已停用"标签使用 `neutral` 色调下可见的 `--dsw-alias-bg-module-platform` 填充。相位标记使用共享 `StateDot`,活动转换采用旋转 loading。插件设置字段中的"未配置"徽章使用基准 500 字重。 +- 插件清单调色板不含 `#b45309` 字面量。`conditional` 标签使用真实的 `--dsw-alias-state-warn-primary` token,而不是不存在的 `--dsw-alias-state-warning-primary` 别名及其硬编码兜底色。 - 这条规则无法被机械检查。将来的作者仍然可以复制一个控件,只有评审能拦住。这是不设门禁所接受的代价:另一条路会拒绝正当的工作,而上面那份保留在原包的清单就是正当工作确实存在的证据。 -- `ui-primitives` 多出两个各自只有一个消费方的组件。开关尤其如此,提升它是因为它是一个通用控件,也因为正在进行中的插件管理工作会采用它,而不是再加两份拷贝。 +- `Tag` 与 `Switch` 由多个设置和预设包共享;只有现有 prop 无法表达明确差异时,后续消费方才扩展这些原语。 diff --git a/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.i18n.yaml index 2ccfc07ac8..577fbe18d0 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.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-09-05-sidebar-tab-types-and-navigation.md -2026-09-05-sidebar-tab-types-and-navigation.md: 834a26e55f87e5db54d57f06fec592e4fdb9f61c -2026-09-05-sidebar-tab-types-and-navigation.zh.md: 9884db9eef49162e8883c390dd71bdbf9d45489f +2026-09-05-sidebar-tab-types-and-navigation.md: 599ce36ed4bb1dfc2cce063a461903e1a4aeb40e +2026-09-05-sidebar-tab-types-and-navigation.zh.md: e4e8de5a389fea77aae705d63cc4fb16f88af647 diff --git a/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.md b/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.md index 834a26e55f..599ce36ed4 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.md +++ b/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.md @@ -36,7 +36,7 @@ interface SidebarRightTabDefinition { `priority` is one of three literal bands, spelled as strings so that a type from another package needs no runtime import: `extension` is the band of a type from outside the product and the highest, so a type that declares nothing outranks every viewer shipped here; `builtin` is the ordinary band for shipped types; `fallback` is the plain-content position that anything more specific should beat, which VS Code's text editor holds implicitly and our text preview holds explicitly. `candidates(address)` returns every type whose globs match and whose `canOpen` does not veto, ranked by band, then by the length of the longest pattern that matched, then by registration order. `claim(address, kind?)` takes the best candidate, or the named kind's type in force when the caller overrides (its globs are not consulted; naming the type is the decision), and throws for an address nothing will open — a wiring mistake, not a user error. `get(kind)` returns the type in force; `entries()` and `guide()` list the types and their guide boxes in force; `subscribe` observes changes. -`title(address)`, `guide[].title()`, and optional `guide[].description()` are thunks read on every use, so a language change needs no re-registration. [Guide start page and stat pill refinements](../feature/2026-09-10-guide-start-page-and-stat-pill-refinements.md) owns the current description visibility and fallback-glyph rules. The registry itself is a plain object provided at `apply`'s top level **without** `Service.tracker`: a tracker would rebind `this.ctx` to the caller's context, and a cross-package `register()` would then add its effect to the caller's fiber while that fiber is the active scope, stalling the browser boot with no error. +`title(address)`, `guide[].title()`, and optional `guide[].description()` are thunks read on every use, so a language change needs no re-registration. The [Sidebar README](../../../../packages/client/ui-sidebar-right/README.md#the-guide) defines description visibility and fallback-glyph rules. The registry itself is a plain object provided at `apply`'s top level **without** `Service.tracker`: a tracker would rebind `this.ctx` to the caller's context, and a cross-package `register()` would then add its effect to the caller's fiber while that fiber is the active scope, stalling the browser boot with no error. ### Bodies and titles: keyed Slot seats under the definition's `id` diff --git a/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.zh.md b/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.zh.md index 9884db9eef..e4e8de5a38 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.zh.md @@ -36,7 +36,7 @@ interface SidebarRightTabDefinition { `priority` 是三个字面量档位之一,写成字符串,好让别的包的类型不需要任何运行时引入:`extension` 是来自产品之外的类型的档位也是最高档,所以什么都不声明的类型压过这里随包交付的每个查看器;`builtin` 是随包类型的常规档;`fallback` 是任何更具体的东西都应压过的纯内容位置,VS Code 的文本编辑器隐含地占据它,我们的文本预览明确地占据它。`candidates(address)` 返回 glob 命中且 `canOpen` 未否决的每个类型,按档位、再按命中的最长 pattern 长度、再按注册顺序排序。`claim(address, kind?)` 取最佳候选,或在调用方指定时取该 kind 生效的类型(不查它的 glob;点名即决定),对无人愿开的地址抛错——这是接线错误,不是用户错误。`get(kind)` 返回生效类型;`entries()` 与 `guide()` 列出生效类型及其引导入口;`subscribe` 观察变化。 -`title(address)`、`guide[].title()` 与可选的 `guide[].description()` 都是每次使用时重读的 thunk,语言切换无需重新注册。[引导起始页与统计 pill 的细化](../feature/2026-09-10-guide-start-page-and-stat-pill-refinements.zh.md)负责当前的描述显示与兜底图标规则。注册表本身是 `apply` 顶层提供的普通对象,**不带** `Service.tracker`:tracker 会把 `this.ctx` 重绑到调用方上下文,跨包 `register()` 就会在调用方 fiber 仍是活动作用域时往它上加 effect,浏览器启动会无声卡死。 +`title(address)`、`guide[].title()` 与可选的 `guide[].description()` 都是每次使用时重读的 thunk,语言切换无需重新注册。[Sidebar README](../../../../packages/client/ui-sidebar-right/README.zh.md#the-guide)定义描述显示与兜底图标规则。注册表本身是 `apply` 顶层提供的普通对象,**不带** `Service.tracker`:tracker 会把 `this.ctx` 重绑到调用方上下文,跨包 `register()` 就会在调用方 fiber 仍是活动作用域时往它上加 effect,浏览器启动会无声卡死。 ### 正文与标题:按定义 `id` keyed 的 Slot 坑位 diff --git a/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.i18n.yaml index 60e03dbff7..414fda0806 100644 --- a/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.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-09-08-document-preview-operations.md -2026-09-08-document-preview-operations.md: 980e2e443db13d6a956d554172bc765a5c11b435 -2026-09-08-document-preview-operations.zh.md: 364ef9ced6f8d98313f9bc287588c98daa408837 +2026-09-08-document-preview-operations.md: be3a835a6296cb28d9a90c5c5e8b9bf7367598c2 +2026-09-08-document-preview-operations.zh.md: efb87e16cc126001c1a6eb2bf938124d7e00044a diff --git a/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md b/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md index 980e2e443d..be3a835a62 100644 --- a/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md +++ b/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.md @@ -18,7 +18,7 @@ Readable files use `dsh-resource://file/session//`. The path ma [Document Preview](../../../../packages/client/ui-sidebar-documentpreview/README.md) owns format selection and loading policy. Metadata registers with `ctx.documentPreviews`; components register separately into the keyed `sidebar.right.tab.document` Slot. Extension registrations precede builtins, then longer suffixes and registration order decide. The toolbar lists supported alternatives and remembers a manual choice per tab. Plain text remains available for unknown extensions and text-compatible renderers. Registered binary suffixes determine text compatibility independently of loading mode; HTML and SVG remain outside those suffixes and retain source viewing. A single candidate renders no viewer control. Registered binary suffixes omit plain text; known binary suffixes without a renderer show an unsupported state without reading the file ([sidebar preview polish](../feature/2026-09-11-sidebar-document-preview-polish.md)). The child receives accumulated text or complete native bytes, the original resource address, and the standard `useResource` and `useTabInfo` hooks. Preview calls existing `read`, `readAll`, and `readRelated` through ordinary injection and decodes bytes in its own `rpc.ts`. Refresh remains per tab, with no resource reload, shared `changed` acknowledgement, extra resource wrapper, or content Session. -Markdown and code reuse the incremental primitives with cumulative paged text. HTML, PDF, and images read complete `Uint8Array` data; Host transport remains base64. Published buffers are borrowed read-only and never persist into layout or Session JSON. PDF.js runs in an owned Worker with version-matched bundled font and decoder data, and copies input before transfer to preserve Preview's retained buffer. HTML runs in a Blob iframe with `sandbox="allow-scripts"`, without same-origin, popup, form, download, or top-navigation privileges. The browser retains its normal external-network rules. Bounded static local JS/CSS reads stay in the parent; the opaque frame creates its own asset Blobs, because it cannot load parent-origin Blobs. PNG, JPEG, GIF, WebP, BMP, ICO, and SVG use image-specific Blob URLs in an `` static-image context. An image wider than the pane scales down to its width at its aspect ratio; a smaller image keeps its intrinsic CSS-pixel dimensions centred by auto margins, and a taller image extends the shared scroller's vertical range ([sidebar preview polish](../feature/2026-09-11-sidebar-document-preview-polish.md)). The renderer provides no zoom or drag-to-pan. SVG markup never enters the application DOM or an iframe, so scripts remain inert and cannot reach the parent page. Replacing HTML or an image revokes its root Blob URL. +Markdown and code reuse the incremental primitives with cumulative paged text. HTML, PDF, and images read complete `Uint8Array` data; Host transport remains base64. Published buffers are borrowed read-only and never persist into layout or Session JSON. PDF.js runs in an owned Worker with version-matched bundled font and decoder data, and copies input before transfer to preserve Preview's retained buffer. With [Developer tools](../feature/2026-09-17-developer-tools-settings.md) enabled, HTML runs in a Blob iframe with `sandbox="allow-scripts"`, without same-origin, popup, form, download, or top-navigation privileges. The browser retains its normal external-network rules. Bounded static local JS/CSS reads stay in the parent; the opaque frame creates its own asset Blobs, because it cannot load parent-origin Blobs. PNG, JPEG, GIF, WebP, BMP, ICO, and SVG use image-specific Blob URLs in an `` static-image context. An image wider than the pane scales down to its width at its aspect ratio; a smaller image keeps its intrinsic CSS-pixel dimensions centred by auto margins, and a taller image extends the shared scroller's vertical range ([sidebar preview polish](../feature/2026-09-11-sidebar-document-preview-polish.md)). The renderer provides no zoom or drag-to-pan. SVG markup never enters the application DOM or an iframe, so scripts remain inert and cannot reach the parent page. Replacing HTML or an image revokes its root Blob URL. PDF.js's official TextLayerBuilder owns selection boundaries and copy normalization over the width-fitted canvas, with shared page cleanup and a component-owned resize observer. Responsive sizing uses the CSS `scale` property independently of PDF.js's page rotation and translation transforms. Its end-of-content marker and stacking rules constrain selection in blank regions; line-break highlighting is suppressed. Per-page cancellation uses the builder's cleanup rather than aborting the first page's signal, because the official selection listeners are shared across pages. diff --git a/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.zh.md b/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.zh.md index 364ef9ced6..efb87e16cc 100644 --- a/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-08-document-preview-operations.zh.md @@ -18,7 +18,7 @@ Document Preview 将资源观察与内容读取分开。[资源模型](2026-09-0 [Document Preview](../../../../packages/client/ui-sidebar-documentpreview/README.zh.md) 负责格式选择和加载策略。元数据通过 `ctx.documentPreviews` 注册;组件单独注册到 keyed `sidebar.right.tab.document` Slot。扩展注册优先于内置注册,其次比较后缀长度和注册顺序。工具栏列出受支持的候选,按 tab 记住手动选择。未知扩展名及文本兼容的渲染器仍可使用纯文本。文本兼容性由注册的二进制后缀决定,与加载方式无关;HTML 和 SVG 不属于二进制后缀,保留源码查看。只有一个候选时不显示查看器控件。注册声明为二进制的后缀不提供纯文本;已知二进制后缀没有注册渲染器时不发起读取,并显示不支持预览的空态([侧边栏预览打磨](../feature/2026-09-11-sidebar-document-preview-polish.zh.md))。子组件收到累积文本或完整原生字节、原始资源地址,以及标准 `useResource` 和 `useTabInfo` 钩子。Preview 经普通注入调用既有 `read`、`readAll` 与 `readRelated`,在自己的 `rpc.ts` 解码字节。刷新仍按 tab 独立进行,不引入资源 reload、共享 `changed` 确认、额外资源包装层或内容 Session。 -Markdown 和代码通过累积的分页文本复用增量渲染原语。HTML、PDF 和图片读取完整 `Uint8Array` 数据;Host 传输保持 base64。发布后的缓冲区只读借用,绝不持久化进布局或 Session JSON。PDF.js 在自有 Worker 中运行,字体和解码数据以相同版本随包发布,转移输入前先复制,以保留 Preview 的缓冲区。HTML 在 Blob iframe 中运行,设置 `sandbox="allow-scripts"`,不授予同源、弹窗、表单、下载或顶层导航权限。浏览器保持正常的外部网络规则。有上限的静态本地 JS/CSS 读取由父页面负责;不透明源 iframe 创建自己的资源 Blob,因为它不能加载父源创建的 Blob。PNG、JPEG、GIF、WebP、BMP、ICO 和 SVG 使用图片专用 Blob URL,在 `` 静态图片上下文中渲染。比面板宽的图片按纵横比缩小到面板宽度;较小的图片保留固有 CSS 像素尺寸并由 auto margin 居中,较高的图片扩展共享滚动区的纵向范围([侧边栏预览打磨](../feature/2026-09-11-sidebar-document-preview-polish.zh.md))。渲染器不提供缩放或拖拽平移。SVG 标记绝不进入应用 DOM 或 iframe,因此脚本保持不可执行,也无法访问父页面。替换 HTML 或图片时会撤销其根 Blob URL。 +Markdown 和代码通过累积的分页文本复用增量渲染原语。HTML、PDF 和图片读取完整 `Uint8Array` 数据;Host 传输保持 base64。发布后的缓冲区只读借用,绝不持久化进布局或 Session JSON。PDF.js 在自有 Worker 中运行,字体和解码数据以相同版本随包发布,转移输入前先复制,以保留 Preview 的缓冲区。开启[开发者工具](../feature/2026-09-17-developer-tools-settings.zh.md)时,HTML 在 Blob iframe 中运行,设置 `sandbox="allow-scripts"`,不授予同源、弹窗、表单、下载或顶层导航权限。浏览器保持正常的外部网络规则。有上限的静态本地 JS/CSS 读取由父页面负责;不透明源 iframe 创建自己的资源 Blob,因为它不能加载父源创建的 Blob。PNG、JPEG、GIF、WebP、BMP、ICO 和 SVG 使用图片专用 Blob URL,在 `` 静态图片上下文中渲染。比面板宽的图片按纵横比缩小到面板宽度;较小的图片保留固有 CSS 像素尺寸并由 auto margin 居中,较高的图片扩展共享滚动区的纵向范围([侧边栏预览打磨](../feature/2026-09-11-sidebar-document-preview-polish.zh.md))。渲染器不提供缩放或拖拽平移。SVG 标记绝不进入应用 DOM 或 iframe,因此脚本保持不可执行,也无法访问父页面。替换 HTML 或图片时会撤销其根 Blob URL。 PDF.js 官方 TextLayerBuilder 在适配宽度的 canvas 上负责选择边界和复制规范化,共享页面清理,并使用由组件拥有的 resize observer。响应式尺寸适配使用独立的 CSS `scale` 属性,与 PDF.js 的页面旋转和平移变换组合。其内容结束标记和堆叠规则限制空白区域中的选择;换行高亮被抑制。逐页取消使用 builder 的清理操作,而不 abort 第一页的信号,因为官方选择监听器跨页面共享。 diff --git a/.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.i18n.yaml index aac049979c..c97659a425 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.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-09-09-profile-resolution-generations.md -2026-09-09-profile-resolution-generations.md: 09ea70e49a4c876eac88e58262644e76c058fca5 -2026-09-09-profile-resolution-generations.zh.md: 0f852e9d31ef4c362f341d6b8fb6d5560225240f +2026-09-09-profile-resolution-generations.md: ad6f05ba88b25ea52da97834cf4638c8feb961ba +2026-09-09-profile-resolution-generations.zh.md: ffa9abc5a6a04bc9553878946fa3fb422328312c diff --git a/.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.md b/.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.md index 09ea70e49a..ad6f05ba88 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.md +++ b/.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.md @@ -6,23 +6,23 @@ English | [中文](2026-09-09-profile-resolution-generations.zh.md) ## Problem -A profile loads plugin rows from its own package project, while Harness packages and packages carried by selected bundles can live outside that project's ordinary dependency tree. The current launcher bridges the trees by calculating package precedence at startup and materializing that result as shared symlinks, profile-owned links, or packaged-executable proxy packages. The files persist across processes and installations, require reconciliation and locking, expose generated proxy manifests to metadata readers, and cannot represent a process-local change atomically. +A profile loads plugin rows from its own package project, while Harness packages and packages carried by selected bundles can live outside that project's ordinary dependency tree. Bridging the trees through shared symlinks, profile-owned links, or packaged-executable proxy packages persists package selections across processes and installations. Those files require reconciliation and locking, expose generated proxy manifests to metadata readers, and cannot represent a process-local change atomically. The runtime design preserves the existing selection rules rather than introducing a second package policy. It covers imports performed by plugin modules as well as Loader row imports and works in the main thread and Harness-owned Workers. Generation replacement accepts only additive package sets and never mutates a live table entry by entry. ## Decision -Profile startup computes one immutable `ResolutionGeneration` from the same dependency traversal that supplies the disk module fallback. The launcher defaults to link mode and preserves the existing materialized lookup behavior. Internal callers and tests can select runtime mode, which installs the generation into Node's ESM and CommonJS resolvers, or dual mode, which materializes and verifies the same generation. `PluginPackages.replace()` publishes a complete additive successor with one reference replacement. +Profile startup computes one immutable `ResolutionGeneration` from the same dependency traversal that supplies the disk module fallback. The launcher defaults to runtime mode, which installs the generation into Node's ESM and CommonJS resolvers without materializing fallback links. Plain Node callers and tests can explicitly select link mode to materialize the generation or dual mode to materialize and verify it. `PluginPackages.replace()` publishes a complete additive successor with one reference replacement. ### One selection algorithm -The package traversal remains in `@deepseek-ai/dsh-app-boot` beside profile loading. The disk materializer and the runtime resolver consume one pure plan; neither owns a copy of the precedence algorithm. Ordinary Node callers can select link, dual, or runtime mode, while an omitted mode selects link. Packaged executables and the Electron Host select runtime mode because their dependency trees may live in a virtual filesystem; dual remains an internal comparison path. +The package traversal remains in `@deepseek-ai/dsh-app-boot` beside profile loading. The disk materializer and the runtime resolver consume one pure plan; neither owns a copy of the precedence algorithm. Ordinary Node callers can select link, dual, or runtime mode, while an omitted mode selects runtime. Packaged executables and the Electron Host select runtime mode because their dependency trees may live in a virtual filesystem; dual remains an internal comparison path. The installation manifest is the first root. Its graph traverses `dependencies` followed by `peerDependencies` breadth-first, resolving each edge from the manifest that declares it. The first installed package reached under a name owns that name. Selected bundle roots then run in profile order, with each earlier root's complete graph taking precedence over every later root. Names supplied by the installation are reserved, and bundle package roots themselves do not become plugin fallbacks. Missing declared packages are skipped as before. Profile-local and plugin-private `node_modules` entries stay outside the fallback entries, and Node checks them before the virtual fallback position. The generation records only installed direct profile package names for a no-I/O native fast path. Each fallback entry records the package name, version, selected lookup directory, declaring manifest anchor, and scope needed to rerun Node's native resolution from the selected package and validate that a successor preserves existing mappings. -The existing `healProfilesModuleFallback()` remains as the disk materializer for the same computed result, which permits direct comparison without rewriting the selection rules. The launcher uses it by default. Runtime mode computes the generation without materializing it, while dual mode materializes and installs that generation for comparison. +The existing `healProfilesModuleFallback()` remains as the disk materializer for the same computed result, which permits direct comparison without rewriting the selection rules. Named profile launches use it only in explicit link or dual mode. Runtime mode computes the generation without materializing it, while dual mode materializes and installs that generation for comparison. ### Immutable generations @@ -74,11 +74,11 @@ Legacy disk state remains available to link-only launches, old processes, and ro Link, dual, and runtime modes use the same generation schema and dependency-selection policy. Link mode persists the computed result, runtime mode installs it only in the process, and dual mode requires Node's materialized result to equal the generation route. -The `dsh` launcher selects link mode when an ordinary Node caller omits `resolutionMode`, so existing npm-installed profile startup keeps its filesystem behavior. A pkg executable always selects runtime mode, and the Electron Host installs its runtime generation before any profile row mounts. Tests and low-level embedders can still select runtime or dual explicitly. +The `dsh` launcher selects runtime mode when an ordinary Node caller omits `resolutionMode`. A pkg executable always selects runtime mode, and the Electron Host explicitly selects runtime mode in both development and packaged builds before any profile row mounts. Plain Node tests and low-level embedders can explicitly select link, dual, or runtime. Runtime mode requires a supported Node Internal loader interface and does not create, update, or retire fallback links. Dual mode retains link writes and fails when Node's disk result differs from the generation. Writable profile state and package-manager transactions remain outside the resolver. -Pkg and packaged Electron carriers force runtime resolution. The Electron Host runs through the Electron executable with `ELECTRON_RUN_AS_NODE=1`, reads its dsh tree from ASAR, and maps executable ASAR entries to electron-builder's unpacked tree. Neither carrier creates, updates, or removes legacy resolution links. +Pkg and Electron carriers force runtime resolution. The Electron Host runs through the Electron executable with `ELECTRON_RUN_AS_NODE=1`; packaged builds read the dsh tree from ASAR and map executable ASAR entries to electron-builder's unpacked tree. Their runtime resolvers do not create, update, or remove legacy resolution links. ### Performance and verification @@ -115,4 +115,4 @@ Behavior tests compare the runtime generation with the disk materializer over th ## Consequences -Runtime startup avoids disk mutation and proxy manifests while preserving the existing package-selection algorithm. It accepts the maintenance cost of Node Internal compatibility tests and an early, self-contained bootstrap in each owned Worker. Link remains the ordinary Node launcher default, dual keeps a migration comparison path, and pkg plus Electron carriers force runtime resolution without retiring old links. Generation replacement remains additive until the product owns module-cache invalidation and Worker restart. +Runtime startup avoids disk mutation and proxy manifests while preserving the existing package-selection algorithm. It accepts the maintenance cost of Node Internal compatibility tests and an early, self-contained bootstrap in each owned Worker. Runtime is the ordinary Node launcher default, link and dual remain explicit comparison options, and pkg plus Electron carriers force runtime resolution without the resolver retiring old links. Generation replacement remains additive until the product owns module-cache invalidation and Worker restart. diff --git a/.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.zh.md b/.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.zh.md index 0f852e9d31..ffa9abc5a6 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.zh.md @@ -6,23 +6,23 @@ Status: implemented ## Problem -profile 从自己的包项目加载插件配置项,而 Harness 包和所选 bundle 携带的包可能位于该项目普通依赖树之外。当前启动器在启动时计算包优先级,再将结果物化为共享 symlink、profile 自有链接或打包可执行文件的代理包。文件跨进程和安装版本持续存在,需要协调和锁来维护,并向元数据读取方暴露生成的代理 manifest,也无法原子表示进程内变更。 +profile 从自己的包项目加载插件配置项,而 Harness 包和所选 bundle 携带的包可能位于该项目普通依赖树之外。通过共享 symlink、profile 自有链接或打包可执行文件的代理包连接两棵依赖树,会让选包结果跨进程和安装版本持续存在。这些文件需要协调和锁来维护,并向元数据读取方暴露生成的代理 manifest,也无法原子表示进程内变更。 运行时设计保留现有选包规则,不另建一套包策略。它覆盖插件模块内部的 import 以及 Loader 配置项的 import,并在主线程和 Harness 自有 Worker 中工作。generation 替换只接受新增包的集合,不会逐项修改正在使用的表。 ## Decision -profile 启动从磁盘 module fallback 使用的同一套依赖遍历生成一个不可变 `ResolutionGeneration`。launcher 默认使用 link 模式,保留现有的物化查找行为。内部调用方和测试可以选择 runtime 模式,把 generation 安装到 Node 的 ESM 与 CommonJS resolver;也可以选择 dual 模式,同时物化并校验同一份 generation。`PluginPackages.replace()` 通过一次引用替换发布完整的新增型后继 generation。 +profile 启动从磁盘 module fallback 使用的同一套依赖遍历生成一个不可变 `ResolutionGeneration`。launcher 默认使用 runtime 模式,把 generation 安装到 Node 的 ESM 与 CommonJS 解析器,不物化 fallback 链接。普通 Node 调用方和测试可以显式选择 link 模式以物化 generation,或选择 dual 模式以物化并校验它。`PluginPackages.replace()` 通过一次引用替换发布完整的新增型后继 generation。 ### 唯一选包算法 -包遍历继续放在 `@deepseek-ai/dsh-app-boot` 的 profile 加载代码旁。磁盘 materializer 和运行时解析器消费同一个纯计划;两者都不持有另一份优先级算法。普通 Node 调用方可以选择 link、dual 或 runtime 模式,省略模式时使用 link。打包可执行文件与 Electron Host 会选择 runtime,因为其依赖树可能位于虚拟文件系统;dual 保留为内部对比路径。 +包遍历继续放在 `@deepseek-ai/dsh-app-boot` 的 profile 加载代码旁。磁盘 materializer 和运行时解析器消费同一个纯计划;两者都不持有另一份优先级算法。普通 Node 调用方可以选择 link、dual 或 runtime 模式,省略模式时使用 runtime。打包可执行文件与 Electron Host 会选择 runtime,因为其依赖树可能位于虚拟文件系统;dual 保留为内部对比路径。 安装 manifest 是第一个根。它按 BFS 依次遍历 `dependencies` 和 `peerDependencies`,每条边从声明它的 manifest 解析,同名包由第一次找到的已安装包占有。所选 bundle 随后按 profile 顺序逐根遍历;每个较早根的完整依赖图优先于所有较晚根。安装闭包中的名称被保留,bundle 包根本身不成为插件 fallback。与旧行为相同,已声明但未安装的包会被跳过。 profile 本地和插件私有 `node_modules` 不进入 fallback entries,由 Node 在虚拟 fallback 位置之前选择。generation 只记录已安装的 profile 直接包名用于 native 快速分流;每个 fallback 记录包名、版本、旧规则选中的查找目录、声明该边的 manifest 锚点和作用域,足以从选定包重新进入 Node 原生解析并验证换代保持既有映射。 -旧 `healProfilesModuleFallback()` 保留为相同纯计算结果的磁盘 materializer,便于直接比较并避免重写旧规则。launcher 默认调用它。runtime 模式只计算 generation 而不物化,dual 模式会物化并安装该 generation 进行比较。 +旧 `healProfilesModuleFallback()` 保留为相同纯计算结果的磁盘 materializer,便于直接比较并避免重写旧规则。具名 profile 启动只在显式 link 或 dual 模式下调用它。runtime 模式只计算 generation 而不物化,dual 模式会物化并安装该 generation 进行比较。 ### 不可变 generation @@ -74,11 +74,11 @@ runtime-only 启动流程不创建、更新或退休 symlink 和代理包。reso link、dual 与 runtime 模式使用同一种 generation schema 和依赖选择策略。link 模式持久化计算结果,runtime 模式只在进程内安装,dual 模式要求 Node 的磁盘结果与 generation 路由一致。 -普通 Node 调用方省略 `resolutionMode` 时,`dsh` launcher 选择 link 模式,因此既有 npm 安装的 profile 启动保留文件系统行为。pkg 可执行文件始终选择 runtime,Electron Host 则在挂载任何 profile 条目前安装 runtime generation。测试与底层嵌入方仍可显式选择 runtime 或 dual。 +普通 Node 调用方省略 `resolutionMode` 时,`dsh` launcher 选择 runtime 模式。pkg 可执行文件始终选择 runtime,Electron Host 在开发与打包构建中也会在挂载任何 profile 条目前显式选择 runtime。普通 Node 测试与底层嵌入方可以显式选择 link、dual 或 runtime。 runtime 模式要求受支持的 Node Internal loader 接口,并且不会创建、更新或退休 fallback 链接。dual 模式保留链接写入,并在 Node 的磁盘结果与 generation 不同时失败。可写 profile 状态和包管理器事务不属于 resolver。 -pkg 与打包 Electron 载体强制使用 runtime 解析。Electron Host 通过设置 `ELECTRON_RUN_AS_NODE=1` 的 Electron 可执行文件运行,从 ASAR 读取 dsh 依赖树,并把 ASAR 中的可执行条目映射到 electron-builder 的 unpacked 目录。两种载体都不会创建、更新或删除旧解析链接。 +pkg 与 Electron 载体强制使用 runtime 解析。Electron Host 通过设置 `ELECTRON_RUN_AS_NODE=1` 的 Electron 可执行文件运行;打包构建从 ASAR 读取 dsh 依赖树,并把 ASAR 中的可执行条目映射到 electron-builder 的 unpacked 目录。它们的运行时解析器不会创建、更新或删除旧解析链接。 ### 性能与验证 @@ -115,4 +115,4 @@ generation 构造发生在启动或显式更新阶段,不属于单次 resolve ## Consequences -runtime 启动避免磁盘修改和代理 manifest,同时保留既有选包算法。代价是持续维护 Node Internal 兼容测试,并在每个自有 Worker 中最早执行自包含 bootstrap。link 保持普通 Node launcher 的默认值,dual 保留迁移比较路径,pkg 与 Electron 载体则强制使用 runtime 且不退休旧链接。在产品拥有模块缓存失效和 Worker 重启前,generation 替换只能新增映射。 +runtime 启动避免磁盘修改和代理 manifest,同时保留既有选包算法。代价是持续维护 Node Internal 兼容测试,并在每个自有 Worker 中最早执行自包含 bootstrap。runtime 是普通 Node launcher 的默认值,link 与 dual 保留为显式对比选项;pkg 与 Electron 载体强制使用 runtime,解析器不退休旧链接。在产品拥有模块缓存失效和 Worker 重启前,generation 替换只能新增映射。 diff --git a/.agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.i18n.yaml index e4d4d80da4..97cdb07d3f 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.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-09-09-workspace-file-read-authority.md -2026-09-09-workspace-file-read-authority.md: 35ac15f5e463910be3b3a0cfe7b80dc69c129ad3 -2026-09-09-workspace-file-read-authority.zh.md: 559a59af0bfa988ff880fad22470397e4e74f296 +2026-09-09-workspace-file-read-authority.md: d1a5b9d9634381cb74b17651c67f2b701402ff1c +2026-09-09-workspace-file-read-authority.zh.md: f8b0fae4325a90548c6b9dc33ac46b17248b04e7 diff --git a/.agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.md b/.agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.md index 35ac15f5e4..d1a5b9d963 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.md +++ b/.agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.md @@ -14,7 +14,7 @@ Workspace Files serves both file content and workspace navigation. Applying work `list` and `changes` remain workspace-scoped because they expose workspace navigation and observation rather than a named file read. `list` rejects a directory outside the root, and `changes` filters observations through the backend's workspace-containment predicate. -`readRelated` resolves a relative path from the base file's directory. A `..` path may therefore read JavaScript or CSS outside the workspace when the Session backend permits it. Document Preview packages bounded, statically declared local scripts and stylesheets into an HTML Blob iframe with `sandbox="allow-scripts"`; the opaque origin blocks parent access, but the browser retains normal network access. This exposure is an intentional security trade-off for rendering static generated HTML. +`readRelated` resolves a relative path from the base file's directory. A `..` path may therefore read JavaScript or CSS outside the workspace when the Session backend permits it. With [Developer tools](../feature/2026-09-17-developer-tools-settings.md) enabled, Document Preview packages bounded, statically declared local scripts and stylesheets into an HTML Blob iframe with `sandbox="allow-scripts"`; the opaque origin blocks parent access, but the browser retains normal network access. This exposure is an intentional security trade-off for rendering static generated HTML. The [Workspace Files service](2026-09-05-workspace-files-service.md) owns paging, file checks, listing, and observation. [Document Preview](2026-09-08-document-preview-operations.md) owns which related files are packaged and the iframe sandbox. @@ -26,4 +26,4 @@ The [Workspace Files service](2026-09-05-workspace-files-service.md) owns paging ## Consequences -Any caller holding a valid Session file address can receive bytes from every regular file that the Session filesystem backend permits it to read, including files outside the workspace. A previewed HTML document can execute packaged local JavaScript and make network requests. Outside files do not produce `changes` frames, so their previews require explicit refresh to observe updates. +Any caller holding a valid Session file address can receive bytes from every regular file that the Session filesystem backend permits it to read, including files outside the workspace. With Developer tools enabled, a previewed HTML document can execute packaged local JavaScript and make network requests. Outside files do not produce `changes` frames, so their previews require explicit refresh to observe updates. diff --git a/.agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.zh.md b/.agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.zh.md index 559a59af0b..f8b0fae432 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-09-workspace-file-read-authority.zh.md @@ -14,7 +14,7 @@ Workspace Files 同时提供文件内容与工作区导航。对所有操作应 `list` 与 `changes` 仍限于工作区,因为它们暴露工作区导航和观察,而不是读取一个具名文件。`list` 拒绝根外目录,`changes` 通过后端的工作区包含判定过滤观察。 -`readRelated` 从基准文件所在目录解析相对路径。因此,只要 Session 后端允许,`..` 路径就可以读取工作区外的 JavaScript 或 CSS。Document Preview 把有界、静态声明的本地脚本与样式表打包进带 `sandbox="allow-scripts"` 的 HTML Blob iframe;不透明源阻止访问父应用,但浏览器保留正常网络访问。这种暴露是为渲染静态生成 HTML 而有意接受的安全取舍。 +`readRelated` 从基准文件所在目录解析相对路径。因此,只要 Session 后端允许,`..` 路径就可以读取工作区外的 JavaScript 或 CSS。开启[开发者工具](../feature/2026-09-17-developer-tools-settings.zh.md)时,Document Preview 把有界、静态声明的本地脚本与样式表打包进带 `sandbox="allow-scripts"` 的 HTML Blob iframe;不透明源阻止访问父应用,但浏览器保留正常网络访问。这种暴露是为渲染静态生成 HTML 而有意接受的安全取舍。 [Workspace Files 服务](2026-09-05-workspace-files-service.zh.md)负责分页、文件检查、列举和观察。[Document Preview](2026-09-08-document-preview-operations.zh.md)负责选择要打包的关联文件及 iframe sandbox。 @@ -26,4 +26,4 @@ Workspace Files 同时提供文件内容与工作区导航。对所有操作应 ## Consequences -持有有效 Session 文件地址的调用方可以接收 Session 文件系统后端允许读取的每个普通文件的字节,包括工作区外文件。预览的 HTML 文档可以执行已打包的本地 JavaScript,并发起网络请求。工作区外文件不会产生 `changes` 帧,因此其预览需要显式刷新才能观察更新。 +持有有效 Session 文件地址的调用方可以接收 Session 文件系统后端允许读取的每个普通文件的字节,包括工作区外文件。开启开发者工具时,预览的 HTML 文档可以执行已打包的本地 JavaScript,并发起网络请求。工作区外文件不会产生 `changes` 帧,因此其预览需要显式刷新才能观察更新。 diff --git a/.agents/notes/implemented/feature/2026-09-09-turn-duration-hour-unit.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-10-compact-tool-details.i18n.yaml similarity index 58% rename from .agents/notes/implemented/feature/2026-09-09-turn-duration-hour-unit.i18n.yaml rename to .agents/notes/implemented/architecture/2026-09-10-compact-tool-details.i18n.yaml index 209fc7e492..484c0c2a3b 100644 --- a/.agents/notes/implemented/feature/2026-09-09-turn-duration-hour-unit.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-10-compact-tool-details.i18n.yaml @@ -1,6 +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-09-turn-duration-hour-unit.md -2026-09-09-turn-duration-hour-unit.md: f0f5cb478dafa7938614bdeeec3a6de9f58aed8d -2026-09-09-turn-duration-hour-unit.zh.md: c9c94eef2a88a0f58d5f5a05332b4b5034ef977a +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-10-compact-tool-details.md +2026-09-10-compact-tool-details.md: 6c1f29c5554c74b01fc2a86c2dee3750a529a6b5 +2026-09-10-compact-tool-details.zh.md: d0822a48f100f9f21ef4706204087fcd1e627da4 diff --git a/.agents/notes/implemented/architecture/2026-09-10-compact-tool-details.md b/.agents/notes/implemented/architecture/2026-09-10-compact-tool-details.md new file mode 100644 index 0000000000..6c1f29c555 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-10-compact-tool-details.md @@ -0,0 +1,29 @@ +# Agent Note: Compact Tool Details from Recorded Results + +Status: implemented + +English | [中文](2026-09-10-compact-tool-details.zh.md) + +## Problem + +Goal, todo, and schedule results contain a small set of user-facing facts that take more effort to read as JSON. Dedicated expanded bodies need to fit the existing conversation rows without adding a second presentation registry or live state to historical calls. + +## Decision + +The `ui-tool` package registers goal and schedule toolviews through the existing keyed slot and shares one package-internal fields/list component with the todo view. Successful, recognized results display recorded values; incomplete calls, errors, malformed values, and unsupported result formats retain generic input/output. Schedule domain errors, including uncertain persistence and an unknown deletion target, retain their original result. + +Tool rows keep their disclosure and Inspect interaction. Checklist marks are static and have accessible status labels. Schedule times use absolute dates with the viewer's time zone; states come from the recorded result. Goal details distinguish an active goal awaiting continuation from an armed goal. These display values do not subscribe to the current goal, todo, or schedule projection. + +Todo changes compare each root or nested call with its preceding durable `todo/write`, so later writes cannot alter an earlier card. The conversation assembler repairs predecessors when older history loads. Until the predecessor is known, the card shows the submitted list without inferred additions or removals. + +This scoped visual change is an exception to the visual-equivalence requirement in [Client-derived tool presentation](2026-08-23-client-derived-tool-presentation.md). Its raw-event ownership, generic fallback, and single keyed registry remain authoritative. + +## Alternatives considered + +**A shared presenter framework:** the existing keyed slot already dispatches tools. Reusing only the detail body keeps the abstraction local until another package needs it. + +**A dashboard inside each card:** interactive controls, progress graphics, and live states add visual weight and can misrepresent what a historical call returned. Compact read-only fields and lists preserve the conversation's hierarchy. + +## Consequences + +Registered tools gain readable details without changing Host tools, Session events, or public Client exports. Tool-specific adapters interpret recorded JSON and text; unrecognized formats retain generic output. The authored tool-details Session replay covers their assembled rendering. diff --git a/.agents/notes/implemented/architecture/2026-09-10-compact-tool-details.zh.md b/.agents/notes/implemented/architecture/2026-09-10-compact-tool-details.zh.md new file mode 100644 index 0000000000..d0822a48f1 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-10-compact-tool-details.zh.md @@ -0,0 +1,29 @@ +# Agent Note: Compact Tool Details from Recorded Results + +Status: implemented + +[English](2026-09-10-compact-tool-details.md) | 中文 + +## 问题 + +目标、任务清单和定时任务的结果包含少量面向用户的事实,以 JSON 阅读时较费力。专用展开内容需要融入现有对话行,不能为历史调用新增一套呈现注册表或实时状态。 + +## 决策 + +`ui-tool` 包通过现有按键分发的插槽注册目标和定时任务视图,并与任务清单视图共用一个包内字段/列表组件。成功且可识别的结果显示记录值;未完成调用、错误、格式异常和不支持的结果格式保留通用输入/输出。定时任务领域错误(包括持久化不确定和删除目标不存在)保留原始结果。 + +工具行保留展开和 Inspect 交互。任务清单标记是静态的,并带有可访问的状态标签。定时任务使用查看者时区的绝对日期,状态取自记录的结果。目标详情区分等待继续的活动目标和已启动的目标。这些显示值不订阅当前目标、任务清单或定时任务投影。 + +任务清单的变化将每个根调用或嵌套调用与之前的持久化 `todo/write` 比较,因此后续写入不会改变较早的卡片。对话组装器在加载更早的历史时修复前序记录。前序记录尚不明确时,卡片展示提交的列表,不推断新增或删除。 + +这项有限的视觉调整是[客户端派生工具呈现](2026-08-23-client-derived-tool-presentation.zh.md)中视觉等价要求的例外。该决策中的原始事件所有权、通用回退和唯一按键注册表仍然有效。 + +## 考虑过的替代方案 + +**共享呈现框架:**现有按键插槽已负责工具分发。仅复用详情内容,可以在其他包出现需求前将抽象限制在包内。 + +**在每张卡片中嵌入仪表盘:**交互控件、进度图形和实时状态会增加视觉负担,也可能歪曲历史调用返回的事实。紧凑的只读字段和列表保留了对话的层级。 + +## 影响 + +已注册的工具获得可读详情,无需改变 Host 工具、Session 事件或公开的 Client 导出。工具专用适配器解读记录中的 JSON 和文本;未识别的格式保留通用输出。手写的 tool-details Session 回放覆盖这些工具组装后的渲染。 diff --git a/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.i18n.yaml index 5ab066c127..c27ef04676 100644 --- a/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.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-09-11-node-office-kit.md -2026-09-11-node-office-kit.md: d77d6c5392532f39a9993af60faad6474bc8f36c -2026-09-11-node-office-kit.zh.md: a0cbd22e4f06bd4f2878adf27852f9d2378b8b33 +2026-09-11-node-office-kit.md: 6cab947d7cb8d4c2c416a83f0b765813c7f98ea9 +2026-09-11-node-office-kit.zh.md: 8ed1a81922249cf0b9c1a74b06e5ce9d44696e0a diff --git a/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.md b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.md index d77d6c5392..6cab947d7c 100644 --- a/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.md +++ b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.md @@ -22,7 +22,7 @@ The kit owns default serif, sans-serif, and monospace preference groups, includi DSH exports raster images at configurable resolution, defaulting to 192 DPI for the shared PDF canvas's 96 CSS DPI at device-pixel ratio 2. Text and vectors remain scalable; explicit bookmark export preserves the engine default when JSON filter options replace it. Node WASM downscales images with LibreOffice's CPU filter. Native conversion uses its separate platform engine. -The [Office viewer](../../../../packages/client/ui-sidebar-documentpreview/README.md#office-preview) lives under Document Preview’s `client/office/` directory, alongside the loading lifecycle, PDF body, and reader types it uses. Keeping these components in one package removes an independent UI boot entry without creating cross-plugin runtime imports. Its bounded cache validates authorized source metadata, shares pending conversions between readers, cancels only when the final reader leaves, excludes failures, and clears on connection reset. Conversion starts when a user opens a preview. Missing declared font families accompany the PDF and appear in a dismissible notice above the Office scrollport; font-table inventories and unrelated engine defaults are not warnings. The shared preview entry’s `office` cache settings use the existing page-global injection channel because the module boot graph carries package identities, not Loader configuration. Reloading the page adopts updated YAML values. +The [Office viewer](../../../../packages/client/ui-sidebar-documentpreview/README.md#office-preview) lives under Document Preview’s `client/office/` directory, alongside the loading lifecycle, PDF body, and reader types it uses. Keeping these components in one package removes an independent UI boot entry without creating cross-plugin runtime imports. Its bounded cache validates authorized source metadata, shares pending conversions between readers, cancels only when the final reader leaves, excludes failures, and clears on connection reset. Conversion starts when a user opens a preview. Missing declared font families accompany the PDF and are available from the document toolbar’s warning icon; font-table inventories and unrelated engine defaults are not warnings. The shared preview entry’s `office` cache settings use the existing page-global injection channel because the module boot graph carries package identities, not Loader configuration. Reloading the page adopts updated YAML values. Ordinary file reads and Office responses share `documentFileBytes()`, which decodes into one typed byte buffer rather than materializing a JavaScript element array from the binary string. Element-array expansion can exhaust the browser heap for a PDF that the configured Host limits permit. A child-process regression checks byte equality within a fixed heap, while the built browser scenario verifies the transport and PDF Worker together. Cache byte limits do not bound transient transport or viewer memory. diff --git a/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.zh.md b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.zh.md index a0cbd22e4f..8ed1a81922 100644 --- a/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-11-node-office-kit.zh.md @@ -22,7 +22,7 @@ kit 维护 serif、sans-serif 和 monospace 的默认优先组,其中包含中 DSH 按可配置分辨率导出栅格图片,默认 192 DPI,对应共享 PDF 画布在设备像素比 2 时的 96 CSS DPI。文本与矢量仍可缩放;JSON 过滤选项替代隐式选项时,显式书签导出保留引擎默认行为。Node WASM 使用 LibreOffice 的 CPU 过滤器降采样图片。原生转换使用独立的平台引擎。 -[Office 查看器](../../../../packages/client/ui-sidebar-documentpreview/README.zh.md#office-preview)位于文档预览的 `client/office/` 目录,与其使用的加载生命周期、PDF 正文和读取器类型同属一个包。这些组件放在同一包中,既减少一个独立 UI 启动入口,也无需跨插件运行时导入。其有界缓存校验已授权的源元数据,在读取方之间共享待完成转换,仅在最后一个读取方离开时取消,不缓存失败,并在连接重置时清空。用户打开预览时才开始转换。缺失的已声明字体族随 PDF 返回,在 Office 滚动区上方显示可关闭的提示;字体表清单与无关的引擎默认字体不构成警告。共享预览入口的 `office` 缓存设置复用页面全局注入通道,因为模块启动图携带包标识而不传递 Loader 配置。重新加载页面后采用更新的 YAML 值。 +[Office 查看器](../../../../packages/client/ui-sidebar-documentpreview/README.zh.md#office-preview)位于文档预览的 `client/office/` 目录,与其使用的加载生命周期、PDF 正文和读取器类型同属一个包。这些组件放在同一包中,既减少一个独立 UI 启动入口,也无需跨插件运行时导入。其有界缓存校验已授权的源元数据,在读取方之间共享待完成转换,仅在最后一个读取方离开时取消,不缓存失败,并在连接重置时清空。用户打开预览时才开始转换。缺失的已声明字体族随 PDF 返回,可通过文档工具栏的警告图标查看;字体表清单与无关的引擎默认字体不构成警告。共享预览入口的 `office` 缓存设置复用页面全局注入通道,因为模块启动图携带包标识而不传递 Loader 配置。重新加载页面后采用更新的 YAML 值。 普通文件读取与 Office 响应共享 `documentFileBytes()`,解码使用一个类型化字节缓冲区,不将二进制字符串物化为 JavaScript 元素数组。即使 PDF 符合 Host 配置的大小限制,元素数组展开也可能耗尽浏览器堆内存。子进程回归测试在固定堆容量内检查字节一致性,构建后的浏览器场景则一起验证传输和 PDF Worker。缓存字节限制不约束临时传输或查看器内存。 diff --git a/.agents/notes/implemented/architecture/2026-09-14-web-document-relative-app-routes.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-14-web-document-relative-app-routes.i18n.yaml new file mode 100644 index 0000000000..8f06bf7290 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-14-web-document-relative-app-routes.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/architecture/2026-09-14-web-document-relative-app-routes.md +2026-09-14-web-document-relative-app-routes.md: fbf30253bbfcc23eb09847f93c1b1f047459d31b +2026-09-14-web-document-relative-app-routes.zh.md: c302ec88a4205239148132513d1ff2a4385033e8 diff --git a/.agents/notes/implemented/architecture/2026-09-14-web-document-relative-app-routes.md b/.agents/notes/implemented/architecture/2026-09-14-web-document-relative-app-routes.md new file mode 100644 index 0000000000..fbf30253bb --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-14-web-document-relative-app-routes.md @@ -0,0 +1,47 @@ +# Agent Note: Web app-owned routes are document-relative + +Status: implemented + +English | [中文](2026-09-14-web-document-relative-app-routes.zh.md) + +## Problem + +Every browser reference to a shell-owned route was origin-root absolute: `/api/...` RPC, `/plugins/??...` plugin bundles, the Remote stream mux, the HMR event stream, and the launch entry's token-cleanup redirect to `/`. One listener served behind a prefix-stripping proxy (a mount such as `https://host/tools/dsh/` that forwards `/tools/dsh/...` as `/...`) therefore saw those requests at the origin root, where no route answers them: the shell, its plugin bundles, and its streams all missed. A single bundle has to serve both the origin root and any mount without a second build. + +The mount cannot be recovered from the transport. A configured base URL makes it deployment input, forwarded-prefix inference makes a request header authoritative for routing, and response rewriting in the proxy reimplements the same fact in fragments. The one place that already knows the mount is the served document itself. + +## Decision + +The served HTTP shell owns its document base. Its index carries exactly one ``, spliced in after the opening head tag once the index taps have run, so it precedes the boot manifest, every plugin-resource row, and any markup a tap inserted. The base freezes the entry directory: `/mount/` and `/mount/index.html` are the supported entries, and a document URL that later moves (an in-page `pushState`) does not move the base. Nothing infers a prefix, so deep direct loads and reloads are not entry points; a bare mount still needs the proxy's own normalization to its slash form. + +Browser references to those routes are relative to that document — `api/x`, `plugins/x` — while server route keys stay absolute pathnames: the RPC channel key `/api`, the `webServer` registration paths, and the response tables keyed by the combo route `/plugins/??...&rev=...`. A shared constant that names a key (`*_PATH`, `*_ENDPOINT`) is stripped at the boundary (`KEY.slice(1)`) before browser code uses it, and the browser form lives beside it as `*_ROUTE`. + +The Host composes browser references in one place per producer. Module graph rows and batch descriptors carry the document-relative combo reference; the source-map trailer carries the combo query alone, because a script's source-map reference resolves against that script's own directory (`.../plugins/`) rather than the document. The response table stays keyed by the absolute route, which is also what a request reaches after the proxy strips its prefix. + +The launch entry follows the same rule. `authenticatedUrl` adds the process token to the caller's URL and keeps its authority and mount, and the token exchange redirects to `./`, which removes the query token while preserving the request URL's directory. The Host/Origin fence and the cookie rules are unchanged. + +Consumers that need an absolute URL resolve against `document.baseURI`: the Gateway when it constructs its WebSocket URL and selects `ws:` or `wss:`, and the Inspector when it locates its own bundle from the boot graph. + +Desktop is the other document owner. Its window loads `dsh-app://app/`, an origin that serves the same dist at that root and forwards every app-owned path to the shell-owned Host, so its document directory already is the root and no base row is injected for it; it names that Host origin for the Gateway through the transport's `streamBaseUrl` instead. Its resources live at that root, which is also why the served HTTP shell's `./` does not apply to it. + +## Alternatives considered + +**A configured base URL as the routing base (`publicUrl`).** Rejected: the document already knows its own URL, so this adds a second, drift-prone source for a fact the page can read, and it would have to be plumbed to every consumer instead of one shell decision. The advertised public URL remains an operator-facing print/open input, not a routing input. + +**Inferring the mount from a forwarded-prefix header.** Rejected: it makes a request header authoritative for routing, so any client that sets it could move app-owned routes, and the proxy contract becomes server-visible configuration. + +**A shared `Connection.resolveUrl` or routes base.** The HTTP carrier's document base and the worker tunnel's Host-root mapping differ. Resolving every target in Connection would prepend a static preview path to worker-local routes or lose a served mount. Each carrier resolves its own targets; UI resource consumers use the document base. + +**An absolute `/` redirect with the proxy rewriting `Location`.** Rejected: the browser already knows the request's directory, and a configured redirect target cannot be right for both the loopback entry and a proxy mount. + +## Consequences + +One bundle serves the origin root and any mount, and the served document is the single source of the mount. The costs are explicit: the supported entries are only the dist root and the configured index path, a bare mount depends on the proxy's slash normalization, and a reload of a deep in-page path re-enters at that path rather than the frozen one. + +Routes owned by feature packages (open-in-app, deliverables, session-log export, uploads, markdown media) follow the same rule through their own `*_ROUTE` constants; the static gate that keeps browser sources on this rule is recorded with those changes. + +## Testing + +The frontend-static real-composition test serves the index through the Loader and asserts one `` that precedes an injected plugin-resource row and tap markup. The client-modules node-half suite asserts that graph rows, batch descriptors, and the source-map trailer are document-relative while the response table still serves them by route, and the client test roster plus the assembled-boot fixture carry the same relative form. `browser-auth.host.spec.ts` covers the `./` redirect and the authority- and mount-preserving `authenticatedUrl`; `frontend-static.spec.ts` and `apps/cli/tests/web-auth.e2e.ts` run the exchange through a real Loader composition and the built CLI. + +`apps/web/tests/public-mount.e2e.ts` is the browser proof: a real Chromium reaches one listener through the prefix-stripping proxy, the mounted WebSocket streams both ways, a `pushState` to a deeper path leaves `document.baseURI` at the mount, and a document-relative `fetch('api/session/create')` still succeeds beneath it. diff --git a/.agents/notes/implemented/architecture/2026-09-14-web-document-relative-app-routes.zh.md b/.agents/notes/implemented/architecture/2026-09-14-web-document-relative-app-routes.zh.md new file mode 100644 index 0000000000..c302ec88a4 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-14-web-document-relative-app-routes.zh.md @@ -0,0 +1,47 @@ +# Agent Note: Web 应用自有路由相对文档寻址 + +Status: implemented + +[English](2026-09-14-web-document-relative-app-routes.md) | 中文 + +## 问题 + +浏览器对外壳自有路由的每一处引用都以源站根绝对形式书写:`/api/...` RPC、`/plugins/??...` 插件 bundle、Remote 流 mux、HMR 事件流,以及启动入口清理 token 时重定向到的 `/`。当一个监听端口位于剥离前缀的代理之后(例如 `https://host/tools/dsh/` 这样的挂载,把 `/tools/dsh/...` 转发为 `/...`),这些请求会落在源站根,那里没有任何路由应答:外壳、插件 bundle、流全部落空。同一份 bundle 必须同时服务源站根与任一挂载,而不能为此再构建一次。 + +挂载点无法从传输层恢复。配置 base URL 会把它变成部署输入,转发前缀推断会让请求头对路由具有权威性,代理内改写在碎片里重复同一事实。唯一已经知道挂载点的地方,就是所服务的文档自身。 + +## 决策 + +所服务的 HTTP 外壳拥有自己的文档 base。其 index 携带唯一的 ``,在原始 index 转换执行后拼接于起始 head 标签之后,因此它先于启动 manifest、每一行插件资源,以及任何转换插入的标记。该 base 冻结入口目录:`/mount/` 与 `/mount/index.html` 是受支持的入口,而文档 URL 之后移动(页面内 `pushState`)不会移动 base。系统不推断任何前缀,因此深层直接加载与重新加载不是入口;裸挂载仍需依赖代理自身的规范化补齐尾部斜杠。 + +浏览器对这些路由的引用相对该文档——`api/x`、`plugins/x`——而服务端路由键保持绝对路径名:RPC channel 键 `/api`、`webServer` 注册路径,以及以 combo 路由 `/plugins/??...&rev=...` 为键的响应表。命名路由键的共享常量(`*_PATH`、`*_ENDPOINT`)在边界处被剥去前导斜杠(`KEY.slice(1)`)后浏览器代码才使用,浏览器形式以 `*_ROUTE` 与之并列。 + +宿主在每个生产者里只在一处组合浏览器引用。模块启动图 row 与批次描述符携带文档相对的 combo 引用;source-map trailer 只携带 combo 查询,因为脚本的 source-map 引用相对该脚本自身目录(`.../plugins/`)解析,而非相对文档。响应表仍以绝对路由为键,而请求在代理剥去前缀后到达的也正是该路由。 + +启动入口遵循同一规则。`authenticatedUrl` 把进程 token 加到调用方的 URL 上并保留其 authority 与挂载;token 交换重定向到 `./`,移除 query token 的同时保留请求 URL 的目录。Host/Origin 栅栏与 cookie 规则不变。 + +需要绝对 URL 的消费方相对 `document.baseURI` 解析:Gateway 构造 WebSocket URL 并选择 `ws:` 或 `wss:` 时,以及 Inspector 从启动图定位自身 bundle 时。 + +Desktop 是另一个文档所有者。它的窗口加载 `dsh-app://app/`:该来源在根目录提供同一份 dist,并把其他所有应用路径转发给它拥有的 Host,因此它的文档目录本就是根目录,无需为它注入 base 行;它改为通过传输层的 `streamBaseUrl` 为 Gateway 指定该 Host origin。它的资源就位于该根目录,这也是所服务 HTTP 外壳的 `./` 不适用于它的原因。 + +## 曾考虑的替代方案 + +**把配置 base URL 当作路由 base(`publicUrl`)。** 否决:文档已经知道自己的 URL,因此这是为页面本可读取的事实引入第二个易漂移来源,而且必须把它接入每个消费方,而不是只做一次外壳决策。对外公告的公开 URL 仍是面向运维的打印/打开输入,不是路由输入。 + +**从转发前缀请求头推断挂载点。** 否决:这会让请求头对路由具有权威性,任何设置该头的客户端都能挪动应用自有路由,代理约定也会变成服务端可见的配置。 + +**共享 `Connection.resolveUrl` 或 routes base。** HTTP 载体的文档基准与 worker tunnel 的 Host 根路径映射不同。在 Connection 中统一解析所有目标,会把静态 preview 路径加到 worker 本地路由之前,或丢失所服务的挂载。各载体解析自己的目标;UI 资源消费方使用文档基准。 + +**保留绝对 `/` 重定向并让代理改写 `Location`。** 否决:浏览器已经知道请求所在目录,而配置出来的重定向目标无法同时适用于 loopback 入口与代理挂载。 + +## 后果 + +一份 bundle 服务源站根与任一挂载,所服务的文档是挂载点的唯一来源。代价是显式的:受支持的入口只有 dist 根与配置的 index 路径;裸挂载依赖代理的斜杠规范化;页面内深层路径重新加载会在该路径重新进入,而非冻结目录。 + +功能包自有的路由(open-in-app、deliverables、会话日志导出、上传、markdown 媒体)通过各自的 `*_ROUTE` 常量遵循同一规则;让浏览器源码持续遵守该规则的静态门禁随那些改动一并记录。 + +## 测试 + +frontend-static 的真实组合测试通过 Loader 提供 index,并断言唯一的 `` 先于注入的插件资源行与转换标记。client-modules node 半侧测试断言启动图 row、批次描述符与 source-map trailer 均为文档相对,而响应表仍按路由提供它们,客户端测试 roster 与 assembled-boot fixture 携带同一相对形式。`browser-auth.host.spec.ts` 覆盖 `./` 重定向与保留 authority 和挂载的 `authenticatedUrl`;`frontend-static.spec.ts` 与 `apps/cli/tests/web-auth.e2e.ts` 分别经真实 Loader 组合与构建后的 CLI 运行该交换。 + +`apps/web/tests/public-mount.e2e.ts` 是浏览器证据:真实 Chromium 经剥离前缀的代理到达同一监听端口,挂载下的 WebSocket 双向成流,`pushState` 到更深路径后 `document.baseURI` 仍停在挂载点,而文档相对的 `fetch('api/session/create')` 在其下依然成功。 diff --git a/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.i18n.yaml index 8ddfd2d49e..2f64b37f55 100644 --- a/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.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-09-15-bounded-office-conversion.md -2026-09-15-bounded-office-conversion.md: 78a2fc46d97500575262d2263bf5549037872264 -2026-09-15-bounded-office-conversion.zh.md: 88cb15c8cbf2200a9365b9a42cb5802c1bfb65d8 +2026-09-15-bounded-office-conversion.md: b15aa5421c575451ddb4a7bf91169bcb2fa38d3c +2026-09-15-bounded-office-conversion.zh.md: f23c38096764cf32207125cc0c3ebbdf1c04d009 diff --git a/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.md b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.md index 78a2fc46d9..b15aa5421c 100644 --- a/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.md +++ b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.md @@ -37,3 +37,7 @@ The cache is transient and cannot bypass source authorization. Oversized PDFs ca Office preview checks source authorization and versions through Workspace Files, then reads raw input with `fs.readBytes` within its conversion reservation. Office input limits govern that read. The Host conversion path avoids base64 source allocation; PDF responses encode only the converted output. Controlled source and engine completions verify pre-read admission, content joining, priority, cancellation isolation, delayed resource release, LRU/alias limits, stale versions, and converter replacement. Loader composition and native conversion checks exercise the shared provider independently of presentation consumers. + +## Related + +The [background scheduling proposal](../../proposed/simplification/2026-09-17-defer-office-background-scheduling.md) questions the need for speculative priority without a production caller; this note remains the authority for the implemented behavior. diff --git a/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.zh.md b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.zh.md index 88cb15c8cb..f23c380967 100644 --- a/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-15-bounded-office-conversion.zh.md @@ -37,3 +37,7 @@ Office 预览和显式文档检查可能请求相同转换。仅缓存已完成 Office 预览通过 Workspace Files 检查源文件授权与版本,再使用 `fs.readBytes` 在转换预留容量内读取原始输入。该读取受 Office 输入上限约束。Host 转换路径不分配 base64 源数据;PDF 响应只编码转换后的输出。 受控的源读取和引擎完成验证读取前准入、内容合并、优先级、取消隔离、延迟资源释放、LRU 与别名限额、过期版本及转换器替换。Loader 组合与原生转换检查独立于展示消费者验证共享提供方。 + +## 相关记录 + +[后台调度提案](../../proposed/simplification/2026-09-17-defer-office-background-scheduling.zh.md) 质疑没有生产调用方时保留推测性优先级的必要性;本记录仍定义已实现的行为。 diff --git a/.agents/notes/implemented/architecture/2026-09-16-desktop-policy-login-loading.md b/.agents/notes/implemented/architecture/2026-09-16-desktop-policy-login-loading.md deleted file mode 100644 index 26fd635c6c..0000000000 --- a/.agents/notes/implemented/architecture/2026-09-16-desktop-policy-login-loading.md +++ /dev/null @@ -1,37 +0,0 @@ -# Agent Note: Show a local document while the policy login page loads - -Status: implemented - -English | [中文](2026-09-16-desktop-policy-login-loading.zh.md) - -## Problem - -The test-deployment login window ([mandatory-update client](../feature/2026-09-11-desktop-mandatory-update-client.md)) navigated straight to the policy origin. Until that document committed and painted, the window was blank, and the remote page can take seconds to appear. A user pressed "Sign in with Feishu" and saw an empty window with no sign that anything was happening — while the window had to stay closable and must never cover a page that had become interactive. - -## Decision - -[policy-test-auth.ts](../../../../apps/desktop/src/policy-test-auth.ts) loads [renderer/policy-login-loading.html](../../../../apps/desktop/renderer/policy-login-loading.html) as the window's first document and requests the policy origin only after that document has committed. The placeholder is a packaged, self-contained file whose only text is the label the main process passes through `loadFile`'s query, so the copy stays in the shell locale dictionary (`policyLoginLoading`). The first committed remote document replaces it. - -The placeholder never coexists with the remote page, so there is no removal step and no timer: Chromium keeps the placeholder frame until the remote document's first frame is ready, and from then on the third-party page owns the window. A redirect chain keeps the placeholder until the last document commits. The window is created visible and closable as before. - -Two places recognize the placeholder's own load by file name and nothing else: the Session's `onBeforeRequest` filter, which cancels document requests outside the policy and Feishu origins, and the `did-fail-load` handler, which fails the login on a main-frame error. The `will-navigate` and `will-redirect` guards still require an allowed HTTPS origin, so the remote page cannot steer the window back to a local file. A placeholder that cannot load is not a login failure: the login request starts anyway and the window is blank exactly as it was before this change. - -## Alternatives considered - -**Overlay the window with a `WebContentsView` (or `BrowserView`) and remove it when the page is ready.** It would show feedback even earlier and can be removed at any chosen signal, but it needs a second renderer, a position that follows window resizes, and an explicit removal point. Every available removal signal is either late (`did-finish-load` still waits for subresources) or a guess (`dom-ready` can precede the first paint), and a view left in place covers an interactive page. - -**Inject an overlay element into the login page with a preload script.** The one approach the decision has to keep out: it puts a shell-owned DOM layer inside a third-party page, and it would give that page a preload bridge the isolated Session deliberately withholds (`webPreferences.preload` is unset). - -**Keep the window hidden until the remote page finishes loading.** It removes the blank flash by removing the feedback, and it delays the user's first view of the page until every subresource completes. - -**Show a native splash window, or the loading text in the window title.** A second window adds a focus and lifetime problem for a two-second wait, and a title is not visible feedback inside the window. - -**Reveal the page with a fixed delay.** A timer cannot distinguish a slow origin from a fast one, and it either covers a ready page or uncovers a blank one. - -## Consequences - -The login window gives immediate local feedback that cannot depend on the network: the placeholder's CSP sets `default-src 'none'`, so the document has no origin, font, image, or connection to reach. Because the placeholder is the window's document rather than a layer, nothing about it can survive into the third-party page, and a slow subresource on the login page cannot extend it. - -The placeholder shares the login Session, so its request passes through the same document filter; the filter's exemption is name-scoped and does not widen the allowed-origin check for navigation. The window title remains the shell-owned login title (the renderer prevents `page-title-updated`), so the placeholder contributes no copy of its own. - -[policy-test-auth.spec.ts](../../../../apps/desktop/tests/policy-test-auth.spec.ts) covers the order (placeholder first, remote page only after it settles), the filter's acceptance of the placeholder, a failed placeholder load that must not fail the login, and a window closed while the placeholder is loading that never starts the remote page. [policy-login-loading.spec.ts](../../../../apps/desktop/tests/policy-login-loading.spec.ts) loads the packaged document under jsdom and checks that it renders the label it is given, leaves the label empty without one, and forbids every network source. The placeholder's rendered frames and the first-paint timing of the substituted login page remain unverified against a real login window. diff --git a/.agents/notes/implemented/architecture/2026-09-16-desktop-policy-login-loading.zh.md b/.agents/notes/implemented/architecture/2026-09-16-desktop-policy-login-loading.zh.md deleted file mode 100644 index 16de25323a..0000000000 --- a/.agents/notes/implemented/architecture/2026-09-16-desktop-policy-login-loading.zh.md +++ /dev/null @@ -1,37 +0,0 @@ -# Agent Note: 策略登录页加载期间显示本地文档 - -Status: implemented - -[English](2026-09-16-desktop-policy-login-loading.md) | 中文 - -## 问题 - -测试部署的登录窗口([强制更新客户端](../feature/2026-09-11-desktop-mandatory-update-client.zh.md))直接导航到策略 origin。在该文档提交并绘制之前,窗口是空白的,而远端页面可能需要数秒才出现。用户点击“通过飞书登录”后只看到一个空窗口,没有任何正在进行的迹象——同时窗口必须保持可关闭,也绝不能遮挡已经可交互的页面。 - -## 决策 - -[policy-test-auth.ts](../../../../apps/desktop/src/policy-test-auth.ts)把 [renderer/policy-login-loading.html](../../../../apps/desktop/renderer/policy-login-loading.html) 作为窗口的首个文档载入,并在该文档提交之后才请求策略 origin。占位页是随包发布的、自包含的文件,其中唯一的文本是主进程通过 `loadFile` 的 query 传入的文案,因此文案仍由 shell 本地化字典(`policyLoginLoading`)拥有。首个提交的远端文档会替换它。 - -占位页从不与远端页面共存,因此没有移除步骤,也没有计时器:Chromium 会一直保留占位帧,直到远端文档的首帧就绪;此后窗口归第三方页面所有。重定向链会把占位页保持到最后一个文档提交。窗口仍与之前一样在创建时即显示且可关闭。 - -只有两处按文件名识别占位页自身的加载,不识别其他任何东西:Session 的 `onBeforeRequest` 过滤器(它会取消策略与飞书 origin 之外的文档请求)以及 `did-fail-load` 处理器(主框架出错时让登录失败)。`will-navigate` 与 `will-redirect` 守卫仍然要求允许的 HTTPS origin,因此远端页面无法把窗口引回本地文件。占位页无法载入不算登录失败:登录请求照常开始,窗口与本次改动之前一样保持空白。 - -## 考虑过的替代方案 - -**用 `WebContentsView`(或 `BrowserView`)覆盖窗口,并在页面就绪时移除。** 它能更早显示反馈,也可以在任选信号处移除,但需要第二个渲染器、一个随窗口尺寸变化的位置,以及一个明确的移除时机。可用的移除信号要么偏晚(`did-finish-load` 仍会等待子资源),要么只是猜测(`dom-ready` 可能早于首帧绘制);而一旦忘记移除,视图就会遮挡可交互的页面。 - -**通过 preload 脚本向登录页注入覆盖元素。** 这是决策必须排除的做法:它把 shell 拥有的 DOM 层放进第三方页面,并且会给该页面一个隔离 Session 刻意不提供的 preload 桥(`webPreferences.preload` 未设置)。 - -**在远端页面加载完成前保持窗口隐藏。** 它通过取消反馈来消除空白闪烁,并把用户第一次看到页面的时间推迟到所有子资源完成之后。 - -**使用原生启动窗口,或把加载文本放进窗口标题。** 为一个两秒的等待增加第二个窗口会带来焦点与生命周期问题,而标题不是窗口内的可见反馈。 - -**用固定延时揭示页面。** 计时器无法区分慢 origin 与快 origin,结果要么遮挡已就绪的页面,要么在空白页面上取消遮挡。 - -## 结果 - -登录窗口给出不依赖网络的即时本地反馈:占位页的 CSP 设置了 `default-src 'none'`,因此该文档没有任何 origin、字体、图片或连接可用。由于占位页是窗口的文档而不是覆盖层,它的任何部分都不会残留到第三方页面中,登录页上缓慢的子资源也无法延长它的存在。 - -占位页共享登录 Session,因此它的请求会经过同一个文档过滤器;该过滤器的豁免按文件名限定,不会放宽导航所用的允许 origin 检查。窗口标题仍是 shell 拥有的登录标题(渲染器阻止了 `page-title-updated`),因此占位页不贡献自己的文案。 - -[policy-test-auth.spec.ts](../../../../apps/desktop/tests/policy-test-auth.spec.ts)覆盖顺序(先占位页,只有它稳定后才请求远端页面)、过滤器对占位页的接受、占位页载入失败不得导致登录失败,以及占位页加载期间关闭窗口后绝不启动远端页面。[policy-login-loading.spec.ts](../../../../apps/desktop/tests/policy-login-loading.spec.ts)在 jsdom 下载入随包发布的文档,检查它渲染传入的文案、在没有文案时保持为空,并禁止一切网络来源。占位页的实际渲染帧与替代登录页的首帧时机仍未在真实登录窗口上验证。 diff --git a/.agents/notes/implemented/architecture/2026-09-16-nested-tooltip-suppression.md b/.agents/notes/implemented/architecture/2026-09-16-nested-tooltip-suppression.md deleted file mode 100644 index 1b06a3a1a4..0000000000 --- a/.agents/notes/implemented/architecture/2026-09-16-nested-tooltip-suppression.md +++ /dev/null @@ -1,35 +0,0 @@ -# Agent Note: Suppress an enclosing tooltip while a nested one is shown - -Status: implemented - -English | [中文](2026-09-16-nested-tooltip-suppression.zh.md) - -## Problem - -The collapsed sidebar renders its update badge through the `sidebar.toggle.badge` slot inside the expand button, and both the button and the badge attach a `Tooltip`. Hovering the badge showed two bubbles at once — the version notice and "Open sidebar" — because the badge is a DOM descendant of the button: the button's hover state stays true for as long as the pointer rests on the badge, and each tooltip correctly follows its own anchor. - -## Decision - -[Tooltip](../../../../packages/client/ui-primitives/src/Tooltip.tsx) provides a suppression setter through an internal `TooltipSuppression` context wrapped around its cloned anchor and its bubble. A nested tooltip consumes the nearest setter and announces whenever its own bubble becomes visible. An enclosing tooltip that receives a suppression claim keeps its hover/focus triggers and its measured position but does not render its bubble, so the enclosing bubble returns in the same commit that the nested one disappears. - -The nested tooltip announces synchronously inside `show()` and through a `visible` effect. The synchronous call keeps a nested pair that becomes visible in one React commit from painting both bubbles for a frame; the effect releases the claim on every other path that hides a bubble, including unmount and a `disabled` flip. - -No component in `ui-sidebar` or `ui-settings-general` changed: the badge already renders inside the toggle's anchor, so the shared primitive resolves the overlap for any future nested pair as well. - -## Alternatives considered - -**Disable the toggle tooltip whenever an update exists.** The simplest guard, and wrong: it removes "Open sidebar" from ordinary rail hovers for the whole time an update is available, including hovers that never touch the badge. - -**Disable the toggle tooltip while the pointer is over the badge.** Hover-scoped, but `Tooltip` clears its hover and focus triggers when it is disabled, and no new `mouseenter` fires on the button while the pointer moves from the badge onto the button's own area. The enclosing bubble would stay gone until the pointer left and re-entered the button. - -**Stop the badge's `mouseenter` from reaching the button.** It only helps a pointer that enters the badge directly; a pointer that enters the button first and then moves onto the badge already has the enclosing bubble visible, because the enclosing hover never ended. - -**Render the badge outside the toggle's anchor.** The slot is declared inside the toggle button, and the badge must sit on the button's corner; moving the anchor out of the button would either detach the bubble from the control or change the slot's declared placement. - -## Consequences - -The rule is a property of tooltip nesting, not of the update badge: any tooltip whose anchor contains another tooltip now yields the bubble to the innermost visible one. A tooltip with no nested tooltip is unaffected — it consumes a null context and never announces. - -The enclosing tooltip's bubble is withdrawn only while a descendant is visible; its trigger state survives, so no re-entry is needed to restore it. Suppression does not change what the anchors do: the button still toggles the sidebar and the badge remains a non-interactive marker. - -[Tooltip tests](../../../../packages/client/ui-primitives/tests/tooltip.client.spec.tsx) cover withdrawal, restoration when the nested anchor is left for the enclosing one with a real `relatedTarget`, and release when a shown nested tooltip unmounts. The [sidebar shell test](../../../../packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx) covers the product wiring: the rail badge replaces the toggle bubble after the toggle's own hover delay has already elapsed. diff --git a/.agents/notes/implemented/architecture/2026-09-16-nested-tooltip-suppression.zh.md b/.agents/notes/implemented/architecture/2026-09-16-nested-tooltip-suppression.zh.md deleted file mode 100644 index 96063f78b2..0000000000 --- a/.agents/notes/implemented/architecture/2026-09-16-nested-tooltip-suppression.zh.md +++ /dev/null @@ -1,35 +0,0 @@ -# Agent Note: 嵌套提示显示时抑制外层提示 - -Status: implemented - -[English](2026-09-16-nested-tooltip-suppression.md) | 中文 - -## 问题 - -侧边栏收起时,更新蓝点通过 `sidebar.toggle.badge` 插槽渲染在展开按钮内部,而按钮和蓝点各自挂了一个 `Tooltip`。hover 蓝点会同时出现两个气泡——版本提示和“打开侧边栏”——因为蓝点是按钮的 DOM 后代:只要指针停在蓝点上,按钮的 hover 状态就保持为真,而两个 tooltip 都在正确地跟随各自的 anchor。 - -## 决策 - -[Tooltip](../../../../packages/client/ui-primitives/src/Tooltip.tsx)通过内部 `TooltipSuppression` context 提供一个抑制 setter,并把该 context 包在克隆后的 anchor 与气泡外层。嵌套的 tooltip 消费最近的 setter,并在自己的气泡可见时上报。收到抑制声明后,外层 tooltip 保留 hover/focus 触发状态与已测量的位置,但不渲染自己的气泡,因此嵌套气泡消失的同一提交里外层气泡就会回来。 - -嵌套 tooltip 既在 `show()` 中同步上报,也通过 `visible` effect 上报。同步调用避免在同一 React 提交中同时可见的一对工具提示多绘制一帧的两个气泡;effect 则在其他所有隐藏气泡的路径上释放抑制,包括卸载和 `disabled` 翻转。 - -`ui-sidebar` 与 `ui-settings-general` 中的组件无需改动:蓝点本就渲染在 toggle 的 anchor 内,因此共享 primitive 也顺带解决了未来任何嵌套组合。 - -## 考虑过的替代方案 - -**只要存在更新就禁用 toggle 的 tooltip。** 这是最简单的防护,但也是错的:它会在更新可用的整个期间移除普通 rail hover 的“打开侧边栏”,包括根本不会碰到蓝点的 hover。 - -**指针位于蓝点上时禁用 toggle 的 tooltip。** 作用域正确,但 `Tooltip` 被禁用时会清空自己的 hover 与 focus 触发状态,而指针从蓝点移到按钮自身区域时按钮不会再触发新的 `mouseenter`。外层气泡会一直消失,直到指针离开按钮并重新进入。 - -**阻止蓝点的 `mouseenter` 传播到按钮。** 这只能帮助直接从蓝点进入的指针;先进入按钮再移到蓝点上的指针,其外层气泡早已可见,因为外层的 hover 从未结束过。 - -**把蓝点渲染到 toggle 的 anchor 之外。** 该插槽声明在 toggle 按钮内部,蓝点必须落在按钮角上;把 anchor 移出按钮要么让气泡脱离控件,要么改变插槽声明的放置位置。 - -## 结果 - -这条规则是 tooltip 嵌套的性质,而不是更新蓝点的性质:任何 anchor 内含另一个 tooltip 的 tooltip 现在都会把气泡让给最内层可见的那个。没有嵌套 tooltip 的 tooltip 不受影响——它消费到的是 null context,永远不会上报。 - -外层 tooltip 仅在子级可见期间收回气泡;它的触发状态得以保留,因此不需要重新进入即可恢复。抑制不改变 anchor 的行为:按钮仍然切换侧边栏,蓝点仍然是非交互标记。 - -[Tooltip 测试](../../../../packages/client/ui-primitives/tests/tooltip.client.spec.tsx)覆盖收回、用真实 `relatedTarget` 从嵌套 anchor 移到外层 anchor 时的恢复,以及已显示的嵌套 tooltip 卸载时的释放。[侧边栏 shell 测试](../../../../packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx)覆盖产品接线:在 toggle 自身的 hover 延迟已经过去之后,rail 蓝点气泡仍替换掉 toggle 气泡。 diff --git a/.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.i18n.yaml new file mode 100644 index 0000000000..758bca04da --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.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/architecture/2026-09-16-size-neutral-product-icon-weights.md +2026-09-16-size-neutral-product-icon-weights.md: 3850fcbddfef6d096f648d5b3efa73790f40f8c1 +2026-09-16-size-neutral-product-icon-weights.zh.md: 84031677e9b13b8dbf3b8e5d959359824e0c94fc diff --git a/.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.md b/.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.md new file mode 100644 index 0000000000..3850fcbddf --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.md @@ -0,0 +1,29 @@ +# Agent Note: Size-neutral product icon weights + +Status: implemented + +English | [中文](2026-09-16-size-neutral-product-icon-weights.zh.md) + +## Problem + +Shared product-icon exports encoded one historical render size in each symbol name even though every component accepted a `size` prop. Consumers also thickened selected icons through local CSS or separate drawings, so emphasis had no named library choice and could drift between features. + +## Decision + +Every shared product glyph exports a size-neutral `Regular` and `Medium` component. `Regular` renders the supplied one-pixel artwork, while `Medium` renders the same paths with a 1.3px inherited stroke; filled regions remain unchanged. The `size` prop controls rendered dimensions, and the former numeric suffix survives only as the default size when a glyph has no 16px source. + +`LinkIconRegular` and `LinkIconMedium`, `ReferenceIconRegular` and `ReferenceIconMedium`, and the three `PermissionIcon*Regular`/`PermissionIcon*Medium` pairs follow the same rule. Repository consumers use a named weight instead of a numeric export. + +Medium weight is reserved for deliberate emphasis: new-Session controls, Settings trigger and navigation icons, Appearance choices, the composer add button, and clickable artifact-link glyphs. Other existing consumers use Regular. The composer command menu uses `PermissionIconFullAccessRegular` for the permission command because that row names permission elevation rather than the current mode. + +## Alternatives considered + +**Keep pixel sizes in export names.** Rejected because the suffix duplicated the runtime `size` prop and created separate names for identical geometry. + +**Expose one component with a `weight` prop.** Rejected because call sites would hide the chosen weight inside props; explicit component names keep visual emphasis searchable and reviewable. + +**Override stroke width in consumer CSS.** Rejected because source SVG elements can carry their own stroke widths, and local overrides would recreate inconsistent weighting outside the library. + +## Consequences + +The icon API is intentionally breaking while pre-stable: every consumer names `Regular` or `Medium`, and former 14px call sites pass an explicit size when they share geometry with a 16px export. New product glyphs add both weights from one geometry definition. Fill-only artwork exposes both names for API consistency even when stroke weight does not change its appearance. The hierarchy choices above remain explicit at their render sites rather than becoming global size or opacity overrides. diff --git a/.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.zh.md b/.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.zh.md new file mode 100644 index 0000000000..84031677e9 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.zh.md @@ -0,0 +1,29 @@ +# Agent Note: 尺寸无关的产品图标线重 + +Status: implemented + +[English](2026-09-16-size-neutral-product-icon-weights.md) | 中文 + +## 问题 + +共享产品图标的导出名称包含一个历史渲染尺寸,但每个组件已经接受 `size` prop。消费方还会通过本地 CSS 或独立图稿加粗部分图标,视觉强调没有具名的图标库选项,因而可能在不同功能间漂移。 + +## 决策 + +每个共享产品图形都导出尺寸无关的 `Regular` 和 `Medium` 组件。`Regular` 渲染提供的 1px 图稿,`Medium` 使用相同路径并继承 1.3px 描边;填充区域保持不变。`size` prop 控制渲染尺寸;仅当图稿没有 16px 来源时,原数字后缀才作为默认尺寸保留。 + +`LinkIconRegular` 与 `LinkIconMedium`、`ReferenceIconRegular` 与 `ReferenceIconMedium`,以及三组 `PermissionIcon*Regular`/`PermissionIcon*Medium` 遵循同一规则。仓库内消费方使用具名线重,不再使用带数字的导出名。 + +Medium 线重只用于明确强调的场景:新建会话控件、设置入口与导航图标、外观选项、输入框加号按钮,以及可点击产物链接的图标。其他既有消费方使用 Regular。编辑器加号菜单中的权限命令使用 `PermissionIconFullAccessRegular`,因为该行表示权限提升,而不是当前权限模式。 + +## 考虑过的替代方案 + +**在导出名中保留像素尺寸。** 否决,因为后缀重复了运行时 `size` prop,并为相同几何创建了多个名称。 + +**只导出一个带 `weight` prop 的组件。** 否决,因为调用点会把线重选择隐藏在 prop 中;显式组件名便于搜索和审查视觉强调。 + +**在消费方 CSS 中覆盖描边宽度。** 否决,因为源 SVG 元素可能自带描边宽度,本地覆盖会在图标库外重新产生不一致线重。 + +## 后果 + +图标 API 在预稳定阶段有意进行破坏性调整:每个消费方明确选择 `Regular` 或 `Medium`;原 14px 调用点与 16px 导出共享几何时会显式传入尺寸。新增产品图形从同一份几何定义同时提供两种线重。仅填充的图稿也为 API 一致性导出两个名称,即使线重不会改变其外观。上述层级选择继续由对应渲染点显式声明,不会变成全局尺寸或透明度覆盖。 diff --git a/.agents/notes/implemented/architecture/2026-09-16-desktop-policy-login-loading.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-17-process-local-blank-sessions.i18n.yaml similarity index 56% rename from .agents/notes/implemented/architecture/2026-09-16-desktop-policy-login-loading.i18n.yaml rename to .agents/notes/implemented/architecture/2026-09-17-process-local-blank-sessions.i18n.yaml index b25a4b72b4..e89e510aea 100644 --- a/.agents/notes/implemented/architecture/2026-09-16-desktop-policy-login-loading.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-17-process-local-blank-sessions.i18n.yaml @@ -1,6 +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/architecture/2026-09-16-desktop-policy-login-loading.md -2026-09-16-desktop-policy-login-loading.md: 26fd635c6cc5b92cad8011919382a11497d2e835 -2026-09-16-desktop-policy-login-loading.zh.md: 16de25323afc971b3142d5359c769df50c08800d +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-17-process-local-blank-sessions.md +2026-09-17-process-local-blank-sessions.md: 58d222185ae67c1f70874c0d0a73910eaa1e0e5b +2026-09-17-process-local-blank-sessions.zh.md: 0f3fdf050931d11a80e92d98577ac8b42628bb60 diff --git a/.agents/notes/implemented/architecture/2026-09-17-process-local-blank-sessions.md b/.agents/notes/implemented/architecture/2026-09-17-process-local-blank-sessions.md new file mode 100644 index 0000000000..58d222185a --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-17-process-local-blank-sessions.md @@ -0,0 +1,35 @@ +# Agent Note: Writer acquisition for blank Session reuse + +Status: implemented + +English | [中文](2026-09-17-process-local-blank-sessions.zh.md) + +## Problem + +Two Hosts sharing Session storage can select the same blank Session when opening a Workspace. A blank Session already owns a live Agent, accepts slash commands, and can hold its writer lock after the creation checkpoint. An unowned persisted blank also carries command settings that remain useful after a Host restart. + +## Decision + +The Session catalog includes persisted blanks. Startup restoration selects the saved blank; ordinary Workspace navigation selects the first eligible unarchived member in catalog order. Explicit-id `session.create` resumes that Session and acquires its writer before opening history. Only `session/writer-held` triggers creation of a new Session. Other errors propagate. Concurrent Workspace connects in one Client share the acquisition and fallback. Later navigation cancels a pending startup selection. + +The Agent retains the acquired handle throughout its lifetime. Checking ownership and releasing a probe lock would leave a race before resume. No PID or process identity enters persisted data or the Remote schema. + +This decision extends the blank-reuse policy in the [Client Session scope decision](2026-07-25-web-client-session-scope-and-provide-channel.md); its real-Session slash support and Provider adoption rationale remain active. + +## Alternatives considered + +**Client-only drafts.** Slash commands and Session-scoped plugins require a real Session before the first prompt. Deferring creation requires a separate command lifecycle. + +**Release a live blank's writer lock.** Blank means no `turn/start`, not no events. A live Agent can still append command and configuration events, so releasing its lock permits competing writers. + +**Hide every cold blank.** This discards reusable command settings from navigation and creates another blank after every restart, even when the old writer is free. + +**Persist a creator PID.** Kernel writer ownership already arbitrates acquisition. PID reuse and stale creator records add no authority. + +## Consequences + +A Host can reuse its own live blank or reclaim an unowned persisted blank with its slash state. Separate Hosts create distinct blanks while existing writers remain held. A held selected blank causes fresh creation even when another blank is free; navigation does not search for another candidate. No cleanup is added. Opening the same established conversation from two Hosts can still encounter writer contention. Unknown projection hints remain visible without cold body scans; automatic reuse requires known blank metadata. A non-contention acquisition failure aborts New Session and is currently reported only to the console; hidden blanks have no recovery action in this flow. + +## Verification + +Navigation tests cover saved selection, single-candidate contention fallback, non-contention failures, overlapping acquisition, and superseding navigation. A shipped Web-composition test holds a real persistence writer, verifies fresh creation despite another free blank, and reclaims the released selected blank with its plan state. The fresh-round-trip recorded Session scenario reloads its blank before submitting the recorded prompt. diff --git a/.agents/notes/implemented/architecture/2026-09-17-process-local-blank-sessions.zh.md b/.agents/notes/implemented/architecture/2026-09-17-process-local-blank-sessions.zh.md new file mode 100644 index 0000000000..0f3fdf0509 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-17-process-local-blank-sessions.zh.md @@ -0,0 +1,35 @@ +# Agent Note: 复用空白 Session 时获取写锁 + +Status: implemented + +[English](2026-09-17-process-local-blank-sessions.md) | 中文 + +## Problem + +两个共享 Session 存储的 Host 在打开 Workspace 时可能选中同一个空白 Session。空白 Session 已有活动 Agent,可以接受 slash 命令,并且可能在创建检查点后持有写锁。无人占用的持久化空白会话也可能带有 Host 重启后仍有用的命令设置。 + +## Decision + +Session 列表包含持久化空白会话。启动恢复选择已保存的空白会话;普通 Workspace 导航按列表顺序选择第一个符合条件且未归档的成员。显式 ID 的 `session.create` 会恢复该 Session,并在打开历史前取得写锁。只有 `session/writer-held` 会触发新建 Session。其他错误返回调用方。同一 Client 的并发 Workspace 连接共享获取与回退过程。后续导航会取消尚未完成的启动选择。 + +Agent 在整个生命周期内持有取得的句柄。检查占用后释放探测锁,会在恢复前留下竞态。持久化数据与 Remote schema 均不引入 PID 或进程身份。 + +本决策扩展 [Client Session scope 决策](2026-07-25-web-client-session-scope-and-provide-channel.zh.md)中的空白会话复用策略;其真实 Session 支持 slash 以及 Provider 收养的理由仍然有效。 + +## Alternatives considered + +**纯客户端草稿。** Slash 命令与 Session 作用域插件在首条提示词之前就需要真实 Session。延迟创建需要单独的命令生命周期。 + +**释放活动空白会话的写锁。** Blank 表示没有 `turn/start`,并不表示没有事件。活动 Agent 仍可能追加命令与配置事件,因此释放其锁会允许多个写入方竞争。 + +**隐藏所有冷空白会话。** 这会让导航丢失可复用的命令设置,并且每次重启都创建另一空白会话,即使旧写锁已空闲。 + +**持久化创建者 PID。** 内核写锁已能仲裁获取操作。PID 复用与过时的创建者记录无法增加权威性。 + +## Consequences + +Host 可以复用自己的活动空白会话,也可以接管无人占用的持久化空白会话并保留其 slash 状态。现有写锁被持有时,不同 Host 创建不同的空白会话。所选空白会话被占用时,即使另有空闲空白会话也会新建;导航不再寻找其他候选。不增加清理逻辑。两个 Host 打开同一个已有对话时仍可能遇到写锁争用。未知投影提示仍可见且不扫描冷日志正文;自动复用要求已知的空白元数据。非锁争用的获取错误会中止新建会话,目前只报告到控制台;此流程没有针对隐藏空白会话的恢复操作。 + +## Verification + +导航测试覆盖已保存选择、单候选锁争用回退、其他错误、重叠获取以及后续导航替代。实际 Web 组合测试持有真实持久化写锁,验证另有空闲空白会话时仍新建,并接管已释放的所选空白会话及其 plan 状态。fresh-round-trip 的录制 Session 场景在提交已录制的提示词前刷新空白会话。 diff --git a/.agents/notes/implemented/architecture/2026-09-17-web-feature-routes-and-route-gate.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-17-web-feature-routes-and-route-gate.i18n.yaml new file mode 100644 index 0000000000..4531b00628 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-17-web-feature-routes-and-route-gate.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/architecture/2026-09-17-web-feature-routes-and-route-gate.md +2026-09-17-web-feature-routes-and-route-gate.md: 3939bdcd0c53e766827e8fcf05f64fb4d284c59b +2026-09-17-web-feature-routes-and-route-gate.zh.md: fb329c8fc1f93b9f979612b7163e838cc521428b diff --git a/.agents/notes/implemented/architecture/2026-09-17-web-feature-routes-and-route-gate.md b/.agents/notes/implemented/architecture/2026-09-17-web-feature-routes-and-route-gate.md new file mode 100644 index 0000000000..3939bdcd0c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-17-web-feature-routes-and-route-gate.md @@ -0,0 +1,31 @@ +# Agent Note: Feature routes follow the document base, and a gate keeps them there + +Status: implemented + +English | [中文](2026-09-17-web-feature-routes-and-route-gate.zh.md) + +## Problem + +[Web app-owned routes are document-relative](2026-09-14-web-document-relative-app-routes.md) makes the shell, its plugin bundles, and its streams reach one listener under any mount. Routes owned by feature packages still addressed the origin root: `/open-in-app/...`, the deliverables `present.*` and `changes.*` routes, the session-log export download, the upload worker's `/api/session/uploadFileBinary` post, and the `/api/file` route behind local markdown image paths. Under a prefix-stripping proxy each of those requests missed. Nothing stopped a new browser-face reference from binding the bundle to one mount again. + +## Decision + +Every feature producer keeps its absolute registration key and derives the browser form beside it: `OPEN_IN_APP_*_PATH`/`*_ROUTE`, `PRESENT_*_PATH`/`*_ROUTE`, `CHANGED_FILES_PATH` and `CHANGES_*_PATH`/`*_ROUTE`, `SESSION_LOG_EXPORT_PATH`/`_ROUTE`, `FILE_UPLOAD_PATH`/`_ROUTE`. Browser code addresses only the `*_ROUTE` form, and a Fetch-shaped carrier a page installs (`FileUploadFetch`, `RpcFetch`) receives that relative route and resolves it against its own base. + +Two consumers need an absolute URL and resolve against `document.baseURI`: the upload worker, whose own base is a `blob:` URL, and the markdown image vocabulary, which emits only absolute `http(s)`, `blob`, or `data` destinations. + +`verify-client-route-resolution` runs in `hygiene` and CI static checks over every browser source the client compiler face compiles. It governs request targets — the first argument of a request constructor, a `fetch`-shaped call, or a dynamic import; an assigned resource property; or a JSX `src`/`href` attribute — and rejects a root-absolute, protocol-relative, or absolute app route (`api`, `plugins`, `open-in-app` prefixes), and a relative app route resolved against a `location` read. A shared `*_PATH`/`*_ENDPOINT` key used as a request target must be stripped. The same pass checks the Host producers of browser references: a `url`, `src`, or `href` field stamped with a root-absolute app route is rejected, while route keys and response-table keys stay absolute and out of scope. + +## Alternatives considered + +**Banning every `location` read in browser code.** Rejected: identity comparisons, the preview's own query read, and the worker tunnel's host-relative mapping are all legitimate. The gate governs request targets, which is where a mount-bound route appears. + +**Rewriting feature responses in the proxy.** Rejected: it duplicates the mount in deployment configuration for each route the proxy does not know about, and the document already resolves relative references. + +## Consequences + +The gate reads inline literal fragments only, so a composed reference (`comboReference(...)`, `artifact.url.slice(1)`) is not traced; `packages/client/modules/tests/node-half.client.spec.ts` owns the regression signal for composed graph rows, batch descriptors, and the source-map trailer. Preview pages do not expose their worker's file API at the document URL, so markdown local-file image URLs cannot reach that Host. [Session prose local media display](../feature/2026-09-07-session-prose-local-media-display.md) owns the image vocabulary. + +## Testing + +The gate's own spec pins a negative control per rule: interpolated targets (`${origin}/api/file`), protocol-relative and absolute forms, an unstripped shared key, a relative route resolved against a `location` read, and root-absolute producer fields. Each feature package's specs assert the relative route its browser half sends; `markdown-images.e2e.ts` keeps the local-image path covered end to end. The discovery spec pins which client projects contribute browser sources: a DOM project the Host aggregate also compiles contributes its plain `src/` unless it has a `src/client` half. diff --git a/.agents/notes/implemented/architecture/2026-09-17-web-feature-routes-and-route-gate.zh.md b/.agents/notes/implemented/architecture/2026-09-17-web-feature-routes-and-route-gate.zh.md new file mode 100644 index 0000000000..fb329c8fc1 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-17-web-feature-routes-and-route-gate.zh.md @@ -0,0 +1,31 @@ +# Agent Note: 功能路由遵循文档 base,并由门禁维持 + +Status: implemented + +[English](2026-09-17-web-feature-routes-and-route-gate.md) | 中文 + +## 问题 + +[Web 应用自有路由相对文档寻址](2026-09-14-web-document-relative-app-routes.zh.md)让外壳、插件 bundle 与流在任一挂载下到达同一监听端口。功能包自有的路由仍寻址源站根:`/open-in-app/...`、deliverables 的 `present.*` 与 `changes.*` 路由、会话日志导出下载、上传 worker 的 `/api/session/uploadFileBinary` 提交,以及本地 markdown 图片路径背后的 `/api/file` 路由。在剥离前缀的代理之后,这些请求全部落空。也没有任何机制阻止新的浏览器侧引用再次把 bundle 绑定到单一挂载。 + +## 决策 + +每个功能生产者保留其绝对注册键,并在旁边派生浏览器形式:`OPEN_IN_APP_*_PATH`/`*_ROUTE`、`PRESENT_*_PATH`/`*_ROUTE`、`CHANGED_FILES_PATH` 与 `CHANGES_*_PATH`/`*_ROUTE`、`SESSION_LOG_EXPORT_PATH`/`_ROUTE`、`FILE_UPLOAD_PATH`/`_ROUTE`。浏览器代码只寻址 `*_ROUTE` 形式;页面安装的 Fetch 形载体(`FileUploadFetch`、`RpcFetch`)收到该相对路由,并相对自身基准解析。 + +两个消费方需要绝对 URL,并相对 `document.baseURI` 解析:上传 worker(其自身基准是 `blob:` URL)与 markdown 图片词汇表(只输出绝对的 `http(s)`、`blob` 或 `data` 目标)。 + +`verify-client-route-resolution` 在 `hygiene` 与 CI 静态检查中运行,覆盖 client 编译面编译的每个浏览器源文件。它治理请求目标——请求构造器、`fetch` 形调用或动态 import 的第一个参数;赋值的资源属性;JSX 的 `src`/`href` 属性——拒绝根绝对、协议相对或绝对的应用路由(`api`、`plugins`、`open-in-app` 前缀),以及相对 `location` 读取解析的相对应用路由。作为请求目标使用的共享 `*_PATH`/`*_ENDPOINT` 键必须先剥去前导斜杠。同一遍检查还覆盖宿主侧浏览器引用的生产者:以根绝对应用路由写入的 `url`、`src` 或 `href` 字段被拒绝,而路由键与响应表键保持绝对,不在范围内。 + +## 曾考虑的替代方案 + +**在浏览器代码中禁止一切 `location` 读取。** 否决:身份比较、preview 自身查询读取、worker tunnel 的宿主相对映射都属正当。门禁治理请求目标——绑定挂载点的路由真正出现的位置。 + +**在代理中改写功能响应。** 否决:这会为代理并不知晓的每条路由在部署配置里重复挂载点,而文档本就能解析相对引用。 + +## 后果 + +门禁只读取内联字面片段,因此组合出来的引用(`comboReference(...)`、`artifact.url.slice(1)`)不被追踪;组合而成的启动图 row、批次描述符与 source-map trailer 的回归信号由 `packages/client/modules/tests/node-half.client.spec.ts` 持有。Preview 页面不会在文档 URL 下暴露其 worker 的文件 API,因此 markdown 本地文件图片 URL 无法到达该 Host。[会话正文本地媒体展示](../feature/2026-09-07-session-prose-local-media-display.zh.md)持有图片词汇表。 + +## 测试 + +门禁自身的 spec 为每条规则固定了负向对照:插值目标(`${origin}/api/file`)、协议相对与绝对形式、未剥前导斜杠的共享键、相对路由相对 `location` 读取解析,以及生产者中根绝对的引用字段。各功能包的 spec 断言其浏览器半侧发出的相对路由;`markdown-images.e2e.ts` 端到端维持本地图片路径的覆盖。发现逻辑的 spec 固定了哪些 client 项目贡献浏览器源文件:同时被 Host 聚合编译的 DOM 项目,若没有 `src/client` 半边,则其整个 `src/` 都计入。 diff --git a/.agents/notes/implemented/architecture/2026-09-16-nested-tooltip-suppression.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-18-agent-teams-single-bundle.i18n.yaml similarity index 56% rename from .agents/notes/implemented/architecture/2026-09-16-nested-tooltip-suppression.i18n.yaml rename to .agents/notes/implemented/architecture/2026-09-18-agent-teams-single-bundle.i18n.yaml index 7079d4ec79..a005e6d029 100644 --- a/.agents/notes/implemented/architecture/2026-09-16-nested-tooltip-suppression.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-18-agent-teams-single-bundle.i18n.yaml @@ -1,6 +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/architecture/2026-09-16-nested-tooltip-suppression.md -2026-09-16-nested-tooltip-suppression.md: 1b06a3a1a4f754aa78e821ee8a1479dab19873ff -2026-09-16-nested-tooltip-suppression.zh.md: 96063f78b281deb3561045bb73fcffa64a2c1ef8 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-18-agent-teams-single-bundle.md +2026-09-18-agent-teams-single-bundle.md: 21878a8e481871f5b0eeebd8f1bea707411ff3b7 +2026-09-18-agent-teams-single-bundle.zh.md: 39c6bfb7b365a45ff98d29f91a4d7905b26db008 diff --git a/.agents/notes/implemented/architecture/2026-09-18-agent-teams-single-bundle.md b/.agents/notes/implemented/architecture/2026-09-18-agent-teams-single-bundle.md new file mode 100644 index 0000000000..21878a8e48 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-18-agent-teams-single-bundle.md @@ -0,0 +1,33 @@ +# Agent Note: Enable Agent Teams with one bundle + +Status: implemented + +English | [中文](2026-09-18-agent-teams-single-bundle.zh.md) + +## Problem + +Separate Agent Teams and Agent Teams Web switches require users to discover that tools and their browser controls need both selections. The package split exposes composition details in the plugin page without helping users choose a different Team capability. + +## Decision + +`@deepseek-ai/dsh-experimental-agent-team-profile` carries the Team service, tools, and browser UI in one optional bundle. Its patch retains the `agent-team`, `tool-agent-team`, and `ui-agent-team` row ids, so profile patches can still configure individual rows. The UI package has an inert Host entry; its browser entry mounts only in a Web Client, and headless does not start a Web server. + +The separate `@deepseek-ai/dsh-experimental-agent-team-web-profile` package is absent from the workspace and release family. The plugin page exposes one Team selection, disabled by default. + +This decision supersedes the separate Host/Web composition in the [package publication note](2026-08-18-experimental-agent-teams-packages.md) and the two Team selections in the [optional-bundle note](../process/2026-09-15-shipped-optional-bundles.md). Both remain active for publication, dependency isolation, promotion, and installation ownership. The [archived Web-controls note](../../archived/feature/2026-08-06-agent-teams-web.md) records the original split; its historical text remains frozen. + +## Alternatives considered + +**Keep two switches and explain their dependency.** Users still need two selections for one feature, and either selection alone leaves an incomplete browser experience. + +**Combine the entries in the generic profile loader or plugin manager.** Recognizing Team package names there would make shared loading or presentation code own feature-specific composition. The bundle patch already declares the required rows and dependencies. + +## Consequences + +Users enable tools and browser controls together. Separate installation-level selection of a UI-only Team bundle is unavailable; custom profiles still control individual patch rows. Headless installations include the UI dependency graph even though they do not mount its browser entry. + +Existing profiles that select the removed Web bundle have an upgrade compatibility gap: startup fails when that package cannot be resolved. Bundle composition provides no automatic rewrite of those saved selections. Compatibility handling remains separate from this composition decision. + +## Verification + +The plugin-manager browser test observes the Team controls appear after one switch, reads the roster and task board through Remote, and observes their removal after disabling the bundle. The Team-panel browser test covers task creation; the built headless CLI test covers delegation without a Web server. These scenarios do not cover upgrading a profile that still selects the removed Web bundle. diff --git a/.agents/notes/implemented/architecture/2026-09-18-agent-teams-single-bundle.zh.md b/.agents/notes/implemented/architecture/2026-09-18-agent-teams-single-bundle.zh.md new file mode 100644 index 0000000000..39c6bfb7b3 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-18-agent-teams-single-bundle.zh.md @@ -0,0 +1,33 @@ +# Agent Note: 通过一个组合包启用 Agent Teams + +Status: implemented + +[English](2026-09-18-agent-teams-single-bundle.md) | 中文 + +## 问题 + +独立的 Agent Teams 与 Agent Teams Web 开关要求用户自行发现:工具与浏览器控件需要同时选中两项。包的拆分把组合细节暴露在插件页上,却没有帮助用户选择不同的 Team 能力。 + +## 决策 + +`@deepseek-ai/dsh-experimental-agent-team-profile` 在一个可选组合包中携带 Team 服务、工具与浏览器 UI。其 patch 保留 `agent-team`、`tool-agent-team` 与 `ui-agent-team` 配置行 id,因此 profile patch 仍可单独配置各行。UI 包的 Host 入口不执行逻辑;其浏览器入口只在 Web 客户端中挂载,headless 不会启动 Web 服务。 + +独立的 `@deepseek-ai/dsh-experimental-agent-team-web-profile` 包不在 workspace 与发布系列中。插件页提供一个 Team 选项,默认关闭。 + +本决策取代[包发布记录](2026-08-18-experimental-agent-teams-packages.zh.md)中的独立 Host/Web 组合方式,以及[可选组合包记录](../process/2026-09-15-shipped-optional-bundles.zh.md)中的两个 Team 选项。两条记录仍负责发布、依赖隔离、promotion 与安装方所有权。[已归档的 Web 控件记录](../../archived/feature/2026-08-06-agent-teams-web.md)记载原始拆分方式,其历史文本保持冻结。 + +## 曾考虑的替代方案 + +**保留两个开关并解释依赖关系。** 用户仍须为一个功能选中两项,单独选中任意一项都不能提供完整的浏览器体验。 + +**在通用 profile loader 或插件管理器中合并入口。** 在这些位置识别 Team 包名会让共用的加载或展示代码负责功能特定的组合。组合包 patch 已能声明所需配置行与依赖。 + +## 后果 + +用户同时启用工具与浏览器控件。安装层面不再提供仅选择 Team UI 组合包的入口;自定义 profile 仍能单独控制 patch 配置行。headless 安装包含 UI 依赖图,但不会挂载其浏览器入口。 + +选中了已移除 Web 组合包的已有 profile 存在升级兼容性缺口:无法解析该包时,启动会失败。组合包本身不会自动改写这些已保存的选择。兼容性处理与本组合决策分开。 + +## 验证 + +插件管理器浏览器测试观察一次开关后 Team 控件出现,通过 Remote 读取成员列表与任务看板,并观察关闭组合包后控件移除。Team 面板浏览器测试覆盖任务创建;构建后的 headless CLI 测试覆盖无需 Web 服务的委派。这些场景不覆盖仍选中已移除 Web 组合包的 profile 升级。 diff --git a/.agents/notes/implemented/architecture/2026-09-18-chat-navigation-performance.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-18-chat-navigation-performance.i18n.yaml new file mode 100644 index 0000000000..143d9ccffe --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-18-chat-navigation-performance.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/architecture/2026-09-18-chat-navigation-performance.md +2026-09-18-chat-navigation-performance.md: d990c0cb9a351ea3b3224ce5feec912c176a5fc9 +2026-09-18-chat-navigation-performance.zh.md: 1a330d3b6bf6357199eb09ffc1cdb434cda1c1af diff --git a/.agents/notes/implemented/architecture/2026-09-18-chat-navigation-performance.md b/.agents/notes/implemented/architecture/2026-09-18-chat-navigation-performance.md new file mode 100644 index 0000000000..d990c0cb9a --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-18-chat-navigation-performance.md @@ -0,0 +1,59 @@ +# Agent Note: Bound turn navigation work and batch historical jumps + +Status: implemented + +English | [中文](2026-09-18-chat-navigation-performance.zh.md) + +## Problem + +A long Session makes three independent costs visible: a turn rail with one DOM mark per known turn, transcript navigation that rediscovers an already known landing, and historical jumps that publish every fetched page. Each intermediate prepend rebuilds growing conversation indexes and exposes another transcript update before the requested turn is available. Combining these concerns into one controller obscures which operations require DOM geometry and which only apply reading or paging policy. + +## Decision + +### Fixed-pitch virtual rail + +[TurnNavigator](../../../../packages/client/ui-chat/src/client/chat/TurnNavigator.tsx) uses TanStack React Virtual with a fixed 10px pitch. Only the visible range, overscan, and the focused mark with its neighbors remain mounted. The library owns the size container and absolute mark transforms. The full turn metadata remains resident; this is rail virtualization, not transcript virtualization. + +The rail uses ResizeObserver-delivered viewport dimensions and fixed item sizes. Its offset calculation clamps against cached geometry and invokes the supported `scrollBy` operation, avoiding the library's DOM-based maximum-offset calculation used by `scrollToIndex`. The first usable size places the latest committed active turn directly after the body has restored its reading state. A ref exposes independent activation and rail-only scrolling. Native pointer targets replace coordinate hit testing, and mark length changes use transforms. Layout containment belongs to the floating rail frame; it does not promise that browser geometry reads flush only that subtree. + +### Two-layer transcript navigation + +[ChatViewport](../../../../packages/client/ui-chat/src/client/chat/use-chat-viewport.ts) owns DOM reads, clamped writes, native events, and turn anchors. It returns the actual landing with available turn and semantic-position facts. Reading policy consumes those facts without another hit test; the viewport ignores its own unchanged scroll echoes and events from the nested rail. + +[ChatReading](../../../../packages/client/ui-chat/src/client/chat/use-chat-reading.ts) owns follow-tail policy, semantic restoration, and sampled reader movement. [ChatNavigation](../../../../packages/client/ui-chat/src/client/chat/use-chat-navigation.ts) owns loaded or unloaded jumps, paging anchors, replacement tasks, and fallback. [useChatScroll](../../../../packages/client/ui-chat/src/client/chat/use-chat-scroll.ts) supplies committed inputs and coordinates these owners. Timer and animation-frame completion invoke the owners directly instead of incrementing React tick state. The [pinned-delivery rule](../../../../packages/client/ui-chat/README.md#scroll-ownership) remains independently necessary: small reader gestures cannot be erased by intervening layout growth. + +### One publication per historical jump + +[Session.loadThrough](../../../../packages/api/session-controller/src/client/sessions/session.ts) retains accepted pages and a private paging cursor until its shared target is covered or loading ends. It reverses the page list and flattens it once, then publishes one ordered prepend. Live events continue through their normal publication path. A later page failure publishes the successful prefix once; stream or window replacement discards obsolete buffered pages. Single-page `loadOlder` remains immediate, and repeated jump requests retain the existing lowest-target policy. + +### Theoretical work model + +Let `T` be known turns, `V` mounted virtual marks, `P` fetched pages, `B` entries per page, and `W0` entries already loaded. These are work-count estimates, not timing measurements. + +| Operation | Previous work | Current work | +|---|---|---| +| Rail DOM and React mark traversal | `O(T)` | `O(V)`; turn metadata and initial index/cache construction remain `O(T)` | +| Active-turn index lookup with unchanged items | Linear search, `O(T)` | Map lookup, expected `O(1)` | +| Rail target-offset calculation | Synchronous DOM extent/viewport reads | Fixed arithmetic and cached sizes; no DOM extent read on this path | +| Known transcript jump | Landing followed by position rediscovery | Reuse the landing; no hit test, but existing anchor-selector and turn lookup costs remain | +| History-driven linear index passes | `O(P * W0 + B * P²)` | `O(W0 + B * P)` for one final pass, plus `O(B * P)` buffer flattening | +| History-driven sorting | Repeated sorting of each growing window | One final-window sort, `O((W0 + B * P) log(W0 + B * P))` | +| Historical prepend publications | `P` | One; live-event and loading-state updates remain independent | + +For illustration, 10,000 turns and about 50 mounted marks imply roughly 99.5% fewer rail marks, not a 200× end-to-end speedup. With 20 equal pages and a negligible initial window, a full linear pass after each page visits `210B` entries in aggregate; one final pass visits `20B`, about 10.5× less work for that pass. Page requests remain 20, and the final transcript still needs assembly, rendering, and layout. + +## Alternatives considered + +**Containment or React batching alone.** Containment does not remove repeated JavaScript work. Suppressing React renders after each published page still leaves conversation assembly and index rebuilds upstream. + +**Patch the virtualizer.** The supported callbacks and relative-scroll operation cover fixed-height placement without maintaining a dependency fork. The native scroll container still needs a total extent; removing that extent would remove native scrolling, not virtualize it. + +**One large scrolling controller.** It centralizes state but couples DOM mechanics to follow and paging policy. Separate owners allow business changes without copying geometry operations. + +**Virtualize the whole transcript.** That introduces renderer lifetime and semantic-anchor constraints outside this change. Batching the requested historical jump removes intermediate publications without changing transcript residency. + +## Consequences + +Loading a distant turn retains the current transcript until the batch lands, instead of revealing intermediate pages. Buffered pages add references proportional to newly fetched entries. Main-transcript geometry reads, semantic-anchor queries, ordinary native-scroll metrics, final layout, and center-width reflow during Sidebar animation remain; none is claimed eliminated by this change. Transform and color changes may still repaint, and assigning `scrollTop` may still deliver a native scroll event. + +[Rail tests](../../../../packages/client/ui-chat/tests/turn-navigator.client.spec.tsx) cover bounded mounting, first placement, fixed-size controls, and focus retention. [Chat tests](../../../../packages/client/ui-chat/tests/chat-view.client.spec.tsx) cover semantic restoration, follow attribution, own-scroll echoes, and navigation without hit testing. [Session tests](../../../../packages/api/session-controller/tests/session.client.spec.ts) cover atomic prepend publication with live delivery, failure, and replacement. [Browser navigation](../../../../apps/web/tests/chat-scroll-contract.e2e.ts) covers real scrolling and unloaded-turn landing. Functional evidence is separate from the theoretical estimates above. diff --git a/.agents/notes/implemented/architecture/2026-09-18-chat-navigation-performance.zh.md b/.agents/notes/implemented/architecture/2026-09-18-chat-navigation-performance.zh.md new file mode 100644 index 0000000000..1a330d3b6b --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-18-chat-navigation-performance.zh.md @@ -0,0 +1,59 @@ +# Agent Note: 限制轮次导航工作量并聚合历史跳转 + +Status: implemented + +[English](2026-09-18-chat-navigation-performance.md) | 中文 + +## Problem + +长会话会放大三类独立成本:轮次轨道为每个已知轮次挂载一个 DOM 刻度,正文导航重新查找已知落点,以及历史跳转逐页发布数据。每次中间前插都会重建不断增大的对话索引,并在目标轮次可用前再次更新正文。把这些职责放进一个控制器,会使需要 DOM 几何的操作与只负责阅读、分页策略的操作难以区分。 + +## Decision + +### 固定间距虚拟轨道 + +[TurnNavigator](../../../../packages/client/ui-chat/src/client/chat/TurnNavigator.tsx) 使用 TanStack React Virtual,间距固定为 10px。只保留可见范围、预加载项,以及键盘焦点刻度及其相邻项。库负责尺寸容器和绝对定位刻度的变换。全部轮次元数据仍驻留内存;这里虚拟化的是轨道,不是正文。 + +轨道采用 ResizeObserver 提供的视口尺寸和固定条目高度。偏移计算根据缓存几何限制范围,再调用库支持的 `scrollBy`,避开 `scrollToIndex` 使用的基于 DOM 的最大偏移计算。正文恢复阅读状态后,轨道在首次获得可用尺寸时直接定位到最新已提交的活跃轮次。ref 分别提供激活轮次和仅滚动轨道的操作。原生指针目标替代坐标命中测试,刻度长度通过变换改变。布局包含作用于悬浮轨道外框,但不承诺浏览器几何读取只刷新该子树。 + +### 两层正文导航 + +[ChatViewport](../../../../packages/client/ui-chat/src/client/chat/use-chat-viewport.ts) 负责 DOM 读取、限制范围后的写入、原生事件和轮次锚点。它返回实际落点及可用的轮次、语义位置信息。阅读策略直接消费这些信息,无需再次命中测试;视口会忽略自身位置未变的滚动回声,以及内嵌轨道发出的事件。 + +[ChatReading](../../../../packages/client/ui-chat/src/client/chat/use-chat-reading.ts) 负责跟随底部、语义恢复和读者移动采样。[ChatNavigation](../../../../packages/client/ui-chat/src/client/chat/use-chat-navigation.ts) 负责已加载或未加载轮次跳转、分页锚点、任务替换和回退。[useChatScroll](../../../../packages/client/ui-chat/src/client/chat/use-chat-scroll.ts) 提供已提交输入并协调各对象。定时器与动画帧完成时直接调用负责对象,不再递增 React tick 状态。[贴底事件规则](../../../../packages/client/ui-chat/README.zh.md#scroll-ownership) 仍有独立必要性:期间发生的布局增长不能抵消读者的小幅操作。 + +### 每次历史跳转统一发布 + +[Session.loadThrough](../../../../packages/api/session-controller/src/client/sessions/session.ts) 在共享目标被覆盖或加载结束前,保留已接受的页面和私有分页游标。它只反转一次页面列表并展开一次,然后发布一次有序前插。实时事件继续走正常发布路径。后续页面失败时,已成功取得的部分统一发布一次;流或窗口被替换时丢弃失效的暂存页面。单页 `loadOlder` 仍立即发布,重复跳转请求保留既有的最低目标策略。 + +### 理论工作量模型 + +设 `T` 为已知轮次数,`V` 为已挂载虚拟刻度数,`P` 为获取页数,`B` 为每页条目数,`W0` 为原先已加载条目数。以下估算工作量,不代表耗时测量。 + +| 操作 | 原有工作量 | 当前工作量 | +|---|---|---| +| 轨道 DOM 与 React 刻度遍历 | `O(T)` | `O(V)`;轮次元数据与初始索引、缓存构建仍为 `O(T)` | +| 条目未变时查找活跃轮次索引 | 线性搜索,`O(T)` | Map 查找,期望 `O(1)` | +| 轨道目标偏移计算 | 同步读取 DOM 总高度、视口尺寸 | 固定算术与缓存尺寸;此路径不读取 DOM 滚动总高度 | +| 已知正文轮次跳转 | 定位后重新查找位置 | 复用落点;不做命中测试,但既有锚点选择器与轮次查找成本仍在 | +| 历史驱动的线性索引遍历 | `O(P * W0 + B * P²)` | 最终一次遍历为 `O(W0 + B * P)`,另有 `O(B * P)` 暂存展开 | +| 历史驱动的排序 | 对每个不断增大的窗口重复排序 | 对最终窗口排序一次,`O((W0 + B * P) log(W0 + B * P))` | +| 历史前插发布次数 | `P` | 一次;实时事件与加载状态更新仍独立发生 | + +例如,10,000 个轮次只挂载约 50 个刻度,意味着轨道刻度约减少 99.5%,不代表端到端加速 200 倍。若获取 20 个等大页面且初始窗口可忽略,每页到达后做一次全量线性遍历,累计访问 `210B` 个条目;最终只遍历一次访问 `20B`,该遍历工作量约减少到原来的 1/10.5。分页请求仍为 20 次,最终正文仍需装配、渲染和布局。 + +## Alternatives considered + +**只加布局包含或 React 批处理。** 布局包含不会删除重复的 JavaScript 工作。仅抑制每页发布后的 React 渲染,也会保留上游对话装配与索引重建。 + +**修改虚拟化库。** 现有回调和相对滚动操作已能完成固定高度定位,无需维护依赖分支。原生滚动容器仍需总高度;删除总高度会使原生滚动失效,而不是实现虚拟化。 + +**单一大滚动控制器。** 它能集中状态,却把 DOM 操作与跟随、分页策略耦合。拆分负责对象后,业务变更无需复制几何操作。 + +**虚拟化整个正文。** 这会引入本次范围之外的 renderer 生命周期与语义锚点约束。聚合目标历史跳转即可删除中间发布,无需改变正文驻留方式。 + +## Consequences + +跳转远处轮次时,当前正文会保持到整批数据落地,不再逐页展示中间结果。暂存页面增加与新获取条目数成正比的引用。正文几何读取、语义锚点查询、普通原生滚动指标、最终布局,以及侧栏动画改变中间区域宽度时的重排仍然存在;本次不声称消除了这些成本。变换与颜色变化仍可能重绘,赋值 `scrollTop` 仍可能产生原生滚动事件。 + +[轨道测试](../../../../packages/client/ui-chat/tests/turn-navigator.client.spec.tsx) 覆盖有界挂载、首次定位、固定尺寸控制与焦点保留。[Chat 测试](../../../../packages/client/ui-chat/tests/chat-view.client.spec.tsx) 覆盖语义恢复、跟随归因、自身滚动回声与不做命中测试的导航。[Session 测试](../../../../packages/api/session-controller/tests/session.client.spec.ts) 覆盖实时事件并发下的原子前插发布、失败与替换。[浏览器导航](../../../../apps/web/tests/chat-scroll-contract.e2e.ts) 覆盖真实滚动与未加载轮次落点。功能证据与上述理论估算相互独立。 diff --git a/.agents/notes/implemented/architecture/2026-09-18-plugin-install-registries.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-18-plugin-install-registries.i18n.yaml new file mode 100644 index 0000000000..7ca268a8fc --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-18-plugin-install-registries.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/architecture/2026-09-18-plugin-install-registries.md +2026-09-18-plugin-install-registries.md: bee870a87b1f5427af8f05a32396bd52bd1524b7 +2026-09-18-plugin-install-registries.zh.md: 65fe35c78910d1cdf1ce45aea7d9fa7ae8617006 diff --git a/.agents/notes/implemented/architecture/2026-09-18-plugin-install-registries.md b/.agents/notes/implemented/architecture/2026-09-18-plugin-install-registries.md new file mode 100644 index 0000000000..bee870a87b --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-18-plugin-install-registries.md @@ -0,0 +1,45 @@ +# Agent Note: Plugin install registries + +Status: implemented + +English | [中文](2026-09-18-plugin-install-registries.zh.md) + +## Problem + +The install dialog handed the spec to `pnpm add` with no registry of its own, so pnpm resolved packages from whatever its configuration named, which for a person who never wrote an `.npmrc` is `registry.npmjs.org`. On a network that cannot reach it, every install failed with a network error after pnpm's own retries, which take over a minute for a registry that does not answer; the check before the install failed the same way, and nothing offered a mirror. A mirror is the ordinary answer in mainland China, but pnpm's `--registry` is a CLI flag the dialog could not pass, and an `.npmrc` is not something the dialog's audience edits. + +## Decision + +**The manager asks a plan of registries, not one.** `PluginManager.Config` gains `registry`, the registry asked first (absent: `null`, the one pnpm's own configuration names), and `fallbackRegistries`, the registries asked after it, defaulting to npmmirror. `registryPlan` resolves one operation's plan: the requested registry, else the configured first, then the rest of the configured set. A requested registry outside that set is asked alone: a private registry never falls through to a public one, where a name that exists on both would install someone else's package. Registries are compared in pnpm's form, lower-case host with a trailing slash. + +**pnpm's own registry is public only once read as such.** `null` names whatever the machine's `.npmrc` chain says, which may be a company registry. Before each plan the manager reads it through `pnpm config get registry` in the profile directory, and `null` joins the configured set only while that is npm's own registry or one of the configured fallbacks; otherwise it is asked alone, whether requested as `null` or by its URL, and a public registry asked instead never falls back into it. A registry pnpm already names is asked once rather than twice. Reading fails closed: an unreadable answer leaves `null` alone. `registries()` returns the reading as `resolved`, so the dialog labels the option by the host pnpm names when that is not npm's own. + +**Only a failure another registry can change sends the operation on.** `attributeFailure` lays `network` and `timeout`, and `not-found` and `no-matching-version`, at the registry, because a mirror lags the registry it copies and a package published minutes ago is not there yet; every other kind stops at once. A git spec and a tarball URL carry the `host` pnpm fetches them from, which no registry stands in for: a failure whose error line names that host is laid at the spec's host and stops, while a failure naming the registry, from which the spec's dependencies come, moves on; a warning line that merely links to the host does not count. The attribution reaches the caller as `ChangeResult.failedAt`, so the dialog words the failure and offers another registry from the Host's reading rather than its own guess. The classifier gains npm's `FETCH_ERROR` as a network failure; pnpm's `ERR_PNPM_NO_OFFLINE_META` is an offline cache miss no registry changes and stays unclassified. + +**The check decides which registry is alive; the install starts there.** `inspect` runs `pnpm view` with `--registry` and `--config.fetch-retries=0`, so a registry that does not answer is reported within `inspectTimeoutMs` — pnpm's own schedule waits ten seconds and then a minute between retries — and the next one is asked; a lookup killed at that bound is refused as a `network` problem like a refused connection. pnpm prints a `--json` refusal as `{ error: { code, message } }` on stdout with nothing on stderr, so the classifier reads both. The accepted answer names the `registry` that answered; the client passes it to `installBundle`, which starts there rather than at a registry the check already found dead. The install keeps pnpm's retry settings: it downloads tarballs, which a shorter timeout would cut on a slow but working network, and it moves on to the next registry only for a failure mid-run. `pnpm-workspace.yaml` is not written for this: `fetchRetries` and `fetchTimeout` there would bound the install's downloads as well as the check, and `--config.fetch-timeout` passed on the command line crashes pnpm 11.8, which reads it as a string. + +**Every attempt is visible.** `installBundle` announces `installing` once per registry with the attempt's registry, position, and the plan's length, restores the profile files between attempts and stops before starting a run on a signal the person already aborted, streams each run under its own job id with `--registry` on the command line, and reports the `registries` asked beside the last run's result. A refused check names the `registries` it asked too. The dialog offers the configured set with pnpm's own registry, titled with the host it names, and a typed address, starts from the registry the Host asks first until a choice is remembered in the browser's `localStorage`, says which registry is asked now while the Host moves on, badges each run's registry, names every registry asked when none could be reached, and names the spec's own host when the Host laid the failure there. It does not describe the plan: the order asked is the Host's, shown as it happens. A typed address is checked against the same `REGISTRY_URL` the Host validates with, published through the package's `./registry` entry for the browser. + +## Alternatives considered + +**Write the chosen registry into the profile's `.npmrc` or `pnpm-workspace.yaml`.** Rejected: the choice belongs to one install, not to every later pnpm command in the profile, and a file pnpm reads on every run would have to be rewritten and restored around each attempt. + +**Keep the last choice in Host settings.** Rejected for now: a settings namespace, its controller write path, and its catalog entry buy a choice shared across browsers and with the CLI, which `dsh plugin add --registry` already takes on its command line; the dialog's choice is passed to each call explicitly, so the Host holds no state for it and the browser remembers it. + +**Retry through every registry on any failure.** Rejected: a blocked build script, a full disk, or a package that declares no bundle fails the same way everywhere, and a git host that cannot be reached is not reached through any registry; retrying those costs the person a wait and hides the cause. + +**Fall back to the public registries after a private one.** Rejected: dependency confusion. A name that a private registry serves and a public one also holds would install the public package when the private registry is down. This holds for a private registry the machine's `.npmrc` names as much as for one typed into the dialog, which is why pnpm's own registry is read before it may join the plan. + +**Treat pnpm's own registry as public without reading it.** Rejected: the shipped configuration would then send every `.npmrc`-configured company registry's failures on to npmmirror. Reading it costs one `pnpm config get registry` per plan, about 170 ms. + +**Let the dialog describe the plan.** Rejected: a copy of the membership rule drifted at once — a typed mirror URL without its trailing slash read as a lone registry in the dialog while the Host normalized it into the set — and a sentence about fallbacks under the options was noise to the person choosing one. The dialog shows the order only as the Host asks it. + +**Bound the install's fetches like the check's.** Rejected: the check makes one small request and its liveness answer is what the plan needs; the install's tarballs on a slow but working connection need pnpm's defaults, and the registry the check found alive is the one the install starts at. + +## Consequences + +`Config` gains `registry` and `fallbackRegistries`; `inspect` takes `InspectOptions` before its signal and answers the registry that answered, the spec's own host, and the registries a refusal asked; `InstallBundleOptions`, `ChangeResult` (with `registries` and `failedAt`), `PluginInstallProgress`, and the `plugin_manager` tool carry the registry; `registries()` joins the Remote and answers `resolved`. `viewProfilePackage` takes a `registry` and never lets pnpm retry; `readProfileRegistry` asks pnpm what its configuration names. The package publishes `./registry` — `registryPlan`, `normalizeRegistry`, `REGISTRY_URL` — as a browser-safe entry the client bundle inlines; the dialog uses `REGISTRY_URL`. Every plan spends one pnpm process on the reading; a machine whose pnpm cannot be read gets no fallback. + +## Testing + +`packages/boot/plugin-manager/tests/registry.spec.ts` pins the plan, including a private and an unknown pnpm registry, and the attribution; `install-spec.spec.ts` pins the hosts the spec forms carry and the classifier's patterns; `manager.spec.ts` asks the registries in turn from the check and the install, reads a refusal printed on stdout, restores the files between attempts, stops at a git host and at a registry outside the configured set, reports a stop between attempts as cancelled, and refuses a registry that is not an http(s) URL at load; `operations.spec.ts` pins the lookup's arguments and the reading of pnpm's registry. `packages/client/ui-plugin-manager/tests` cover the store's choice, its memory across controllers, the attempts the Host announces, the return to the spec with the options open, and the page's picker, attempt copy, run badges, and failure sentences. diff --git a/.agents/notes/implemented/architecture/2026-09-18-plugin-install-registries.zh.md b/.agents/notes/implemented/architecture/2026-09-18-plugin-install-registries.zh.md new file mode 100644 index 0000000000..65fe35c789 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-18-plugin-install-registries.zh.md @@ -0,0 +1,45 @@ +# Agent Note: 插件安装的注册表 + +Status: implemented + +[English](2026-09-18-plugin-install-registries.md) | 中文 + +## Problem + +安装对话框把 spec 交给 `pnpm add` 时不指定注册表,pnpm 就从它自身配置指定的地方解析包;对于从未写过 `.npmrc` 的人,那就是 `registry.npmjs.org`。在连不上它的网络里,每次安装都在 pnpm 自身重试之后以网络错误告终——对不应答的注册表,这要一分多钟;安装前的检查也以同样方式失败,而没有任何地方提供镜像。镜像是中国大陆的常规答案,但 pnpm 的 `--registry` 是对话框传不了的命令行参数,而 `.npmrc` 不是对话框的受众会去编辑的东西。 + +## Decision + +**管理器询问的是一个注册表计划,不是一个注册表。** `PluginManager.Config` 新增 `registry`——首先询问的注册表(缺省为 `null`,即 pnpm 自身配置指定的那个)和 `fallbackRegistries`——随后依次询问的注册表,默认为 npmmirror。`registryPlan` 解出一次操作的计划:请求的注册表,否则配置的第一个,然后是配置集合中的其余。请求的注册表不在这个集合里时只问它自己:私有源永远不会落到公共源,否则两边都有的名字会装上别人的包。注册表按 pnpm 的形式比较:小写主机名加尾部斜杠。 + +**pnpm 自身的注册表只有读出来是公共的才算公共。** `null` 指向本机 `.npmrc` 链说的任何地方,可能是公司注册表。每次做计划前管理器在 profile 目录里用 `pnpm config get registry` 读出它,只有它是 npm 官方源或某个配置的备选源时 `null` 才进入配置集合;否则不论以 `null` 还是以它的 URL 请求都只问它自己,改问公共源时也绝不回落到它。pnpm 已经指向的注册表只问一次而不是两次。读取失败时保守处理:读不出来就让 `null` 只问自己。`registries()` 把读出的结果作为 `resolved` 返回,所以 pnpm 指向的不是 npm 官方源时,对话框按其主机名标注该选项。 + +**只有换一个注册表能改变的失败才让操作继续。** `attributeFailure` 把 `network`、`timeout` 归于注册表,把 `not-found`、`no-matching-version` 也归于注册表,因为镜像落后于它复制的注册表,几分钟前刚发布的包还没到那里;其他种类立即停止。git spec 和 tarball URL 携带 pnpm 拉取它们的 `host`,没有注册表能替代它:错误行点名那台主机的失败归于 spec 自身主机并停止,而点名注册表(spec 的依赖来自那里)的失败则继续;仅仅链接到该主机的警告行不算。归因以 `ChangeResult.failedAt` 交给调用方,所以对话框按 Host 的判断而不是自己的猜测来措辞并决定是否提供换源。分类器新增 npm 的 `FETCH_ERROR` 作为网络失败;pnpm 的 `ERR_PNPM_NO_OFFLINE_META` 是离线缓存缺失,换注册表改变不了,保持未分类。 + +**检查决定哪个注册表活着;安装从那里开始。** `inspect` 以 `--registry` 和 `--config.fetch-retries=0` 运行 `pnpm view`,所以不应答的注册表会在 `inspectTimeoutMs` 内报告——pnpm 自身的节奏是重试之间先等十秒再等一分钟——然后询问下一个;在这个上限被杀掉的查询与连接被拒一样作为 `network` 问题拒绝。pnpm 把 `--json` 的拒绝以 `{ error: { code, message } }` 打印在 stdout 而 stderr 为空,所以分类器两者都读。通过的答复带上作答的 `registry`;客户端把它传给 `installBundle`,安装从那里开始,而不是从检查已经发现死掉的注册表开始。安装保留 pnpm 的重试设置:它要下载 tarball,更短的超时会在慢但可用的网络上掐断下载,且它只在运行中途失败时才转问下一个注册表。为此不写 `pnpm-workspace.yaml`:那里的 `fetchRetries` 与 `fetchTimeout` 会同时约束安装的下载和检查,而命令行传入的 `--config.fetch-timeout` 会让 pnpm 11.8 崩溃,因为它被当作字符串读取。 + +**每次尝试都可见。** `installBundle` 每问一个注册表通告一次 `installing`,带上这次的注册表、序号和计划长度,两次尝试之间恢复 profile 文件,并在用户已经取消时不再以失效的信号启动下一次运行,每次运行以自己的 job id 流式输出且命令行带 `--registry`,并在最后一次运行的结果旁报告问过的 `registries`。被拒绝的检查同样列出它问过的 `registries`。对话框提供配置集合、pnpm 自身的注册表(标题带上它指向的主机名)和手动输入的地址,在浏览器 `localStorage` 记住选择之前从 Host 首先询问的注册表开始,在 Host 转问时说明现在问的是哪个注册表,给每次运行标上注册表,所有注册表都连不上时逐一列出,而 Host 把失败归于 spec 自身主机时则点名那台主机。它不描述计划:询问顺序是 Host 的,发生时才显示。手动输入的地址用 Host 校验所用的同一个 `REGISTRY_URL` 检查,该常量经包的 `./registry` 入口发布给浏览器。 + +## Alternatives considered + +**把所选注册表写进 profile 的 `.npmrc` 或 `pnpm-workspace.yaml`。** 否决:这个选择属于一次安装,不属于 profile 里之后的每条 pnpm 命令,而 pnpm 每次运行都读的文件得在每次尝试前后改写再恢复。 + +**把上次选择存进 Host 设置。** 暂时否决:一个设置命名空间、它的控制器写入路径和目录条目换来的是跨浏览器并与 CLI 共享的选择,而 `dsh plugin add --registry` 已经在命令行接受它;对话框的选择显式传给每次调用,Host 不为它保存状态,由浏览器记住。 + +**任何失败都换遍所有注册表。** 否决:被拦下的构建脚本、写满的磁盘、不声明组合包的包在哪里都同样失败,连不上的 git 主机经由任何注册表也连不上;重试这些只让人白等,还掩盖原因。 + +**私有源之后落到公共源。** 否决:依赖混淆。私有源提供而公共源也有的名字,会在私有源宕机时装上公共源的包。这对本机 `.npmrc` 指定的私有源和对话框里手填的私有源同样成立,所以 pnpm 自身的注册表要先读出来才可能进入计划。 + +**不读取就把 pnpm 自身的注册表当作公共源。** 否决:那样出厂配置会把每个 `.npmrc` 配置了公司注册表的用户的失败转到 npmmirror。读取的代价是每次计划一个 `pnpm config get registry` 进程,约 170 ms。 + +**让对话框描述计划。** 否决:成员规则的副本立刻就漂移了——手填没有尾斜杠的镜像 URL 在对话框里读作单独的注册表,而 Host 归一化后把它并入集合——而且选项下方一句关于备选的说明对正在选源的人只是噪音。对话框只在 Host 询问时按实际顺序显示。 + +**像检查一样限制安装的请求。** 否决:检查只发一个小请求,它的存活答复正是计划所需;安装在慢但可用的连接上下载 tarball 需要 pnpm 的默认值,而安装从检查发现活着的注册表开始。 + +## Consequences + +`Config` 新增 `registry` 与 `fallbackRegistries`;`inspect` 在 signal 之前接受 `InspectOptions`,并答复作答的注册表、spec 自身的主机和拒绝时问过的注册表;`InstallBundleOptions`、`ChangeResult`(含 `registries` 与 `failedAt`)、`PluginInstallProgress` 和 `plugin_manager` 工具携带注册表;`registries()` 加入 Remote 并答复 `resolved`。`viewProfilePackage` 接受 `registry` 且从不让 pnpm 重试;`readProfileRegistry` 向 pnpm 询问其配置指向哪里。包把 `./registry`——`registryPlan`、`normalizeRegistry`、`REGISTRY_URL`——作为浏览器安全的入口发布,客户端 bundle 内联它;对话框使用 `REGISTRY_URL`。每次计划花费一个 pnpm 进程做读取;pnpm 读不出来的机器没有备选。 + +## Testing + +`packages/boot/plugin-manager/tests/registry.spec.ts` 钉住计划(含私有与未知的 pnpm 注册表)与归因;`install-spec.spec.ts` 钉住各 spec 形式携带的主机和分类器的模式;`manager.spec.ts` 从检查和安装依次询问注册表、读取打印在 stdout 的拒绝、在尝试之间恢复文件、在 git 主机和配置集合之外的注册表处停下、把两次尝试之间的取消报告为已取消,并在加载时拒绝不是 http(s) URL 的注册表;`operations.spec.ts` 钉住查询的参数和 pnpm 注册表的读取。`packages/client/ui-plugin-manager/tests` 覆盖 store 的选择、跨控制器的记忆、Host 通告的尝试、带着展开选项回到 spec,以及页面的选择器、尝试文案、运行标记和失败句子。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.md b/.agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.md deleted file mode 100644 index de5e509c9e..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.md +++ /dev/null @@ -1,31 +0,0 @@ -# Agent Note: Resume headers do not repeat system prompts - -Status: implemented - -English | [中文](2026-09-03-resume-headers-do-not-repeat-system-prompts.zh.md) - -## Problem - -Forking a Session copies the source history into the child. The child's first model request then records a `request/header` with reason `resume`, even when its system field is identical to the preceding copied header. Chat treated every resume header as a new display point, so continuing the fork showed a second `System prompt` row and suggested that the system prompt had been injected twice. The provider request still carried the system field once; the duplicate existed only in Chat presentation. - -## Decision - -The durable resume header records the request boundary needed for exact Session reconstruction. Chat compares that full header with the preceding loaded Request Prompt and displays a non-empty system prompt only for the initial request, an explicit message-series start, or a real system-field change. An unchanged resume does not create a visible repetition. - -A partial history window may begin with a non-initial header and lack the predecessor needed for comparison. Chat renders that system prompt conservatively. If prepend later supplies an identical predecessor, the existing request-prompt Node becomes hidden instead of being withdrawn; its key and page-lifetime anchor stay stable. A different system field remains visible. - -Trajectory exposes every request header and its classified changes. Chat presentation does not alter provider requests, Session events, or reconstruction. - -## Alternatives considered - -**Omit unchanged resume headers from the Session log.** Rejected: resume is a real request boundary, and removing it would make exact reconstruction depend on process history that the durable log does not contain. - -**Special-case only forked Sessions.** Rejected: an ordinary process resume has the same presentation semantics, and the request headers already contain the system fields needed for a direct comparison. - -**Keep the duplicate row as a lifecycle marker.** Rejected: `System prompt` describes model-visible request content, so using it to mark a loop restart incorrectly implies another prompt injection. Request lifecycle evidence remains available in Trajectory. - -## Consequences - -Continuing a fork or resuming a process with an unchanged system field leaves one visible `System prompt` row for the current message series. Explicit series starts and real system changes still repeat the row. A partial window can initially show a conservative row and hide it after older history loads, while retaining the same materialized Node. - -The unit regression covers initial, series, unchanged resume, system-change, and prepend cases. The Web recorded-session scenario contains an unchanged resume header and asserts that the settled Chat renders exactly one `System prompt` control. diff --git a/.agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.zh.md b/.agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.zh.md deleted file mode 100644 index 008e25d8d5..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.zh.md +++ /dev/null @@ -1,31 +0,0 @@ -# Agent Note: Resume header 不重复系统提示词 - -Status: implemented - -[English](2026-09-03-resume-headers-do-not-repeat-system-prompts.md) | 中文 - -## 问题 - -fork Session 会把源会话历史复制到子会话。即使 system 字段与前一条被复制的 header 完全相同,子会话的第一个模型请求仍会记录一条 reason 为 `resume` 的 `request/header`。Chat 把每条 resume header 都视作新的展示点,因此继续 fork 会显示第二行`系统提示词`,让人误以为系统提示词被注入了两次。提供方请求实际仍只携带一次 system 字段;重复仅存在于 Chat 展示中。 - -## 决策 - -持久化 resume header 记录精确重建 Session 所需的请求边界。Chat 会把完整 header 与前一条已加载 Request Prompt 比较,只在初始请求、显式消息序列起点或真实 system 字段变化时显示非空系统提示词。内容未变的 resume 不创建可见的重复行。 - -部分历史窗口可能以非初始 header 开头,因缺少前序 header 而无法比较。Chat 会保守渲染该系统提示词。如果 prepend 随后补入相同的前序 header,既有 request-prompt Node 会转为隐藏而不是被撤回;其 key 和页面生命周期内的 anchor 保持稳定。不同的 system 字段仍然可见。 - -Trajectory 会展示每一条请求 header 及其变化分类。Chat 展示不会改变提供方请求、Session event 或重建行为。 - -## 考虑过的替代方案 - -**从 Session log 省略未变化的 resume header。** 否决:resume 是真实的请求边界,移除后精确重建将依赖持久日志未记录的进程历史。 - -**只对 fork Session 做特殊处理。** 否决:普通进程恢复具有相同的展示语义,请求 header 已经包含可直接比较的 system 字段。 - -**把重复行保留为生命周期标记。** 否决:`系统提示词`描述模型可见的请求内容,用它标记 loop 重启会错误暗示再次注入提示词。请求生命周期证据仍可在 Trajectory 中查看。 - -## 后果 - -继续 fork 或在 system 字段未变时恢复进程,当前消息序列只保留一行可见的`系统提示词`。显式序列起点与真实 system 变化仍会重复该行。部分窗口起初可以显示保守行,并在更早历史加载后将其隐藏,同时保留同一个已物化 Node。 - -单元回归覆盖初始请求、显式序列、未变化 resume、system 变化与 prepend 场景。Web 录制会话场景包含一条未变化的 resume header,并断言稳定后的 Chat 只渲染一个`系统提示词`控件。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-07-pinned-scroll-delivery-before-layout.md b/.agents/notes/implemented/bug-fix/2026-09-07-pinned-scroll-delivery-before-layout.md deleted file mode 100644 index f3b428dbe0..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-09-07-pinned-scroll-delivery-before-layout.md +++ /dev/null @@ -1,23 +0,0 @@ -# Agent Note: Settle pinned scroll deliveries before layout changes - -Status: implemented - -English | [中文](2026-09-07-pinned-scroll-delivery-before-layout.zh.md) - -## Problem - -A delayed scroll sample compares positions from different layouts. While Chat is pinned, a composer or transcript shrink can move the browser floor; subsequent growth can move the browser position again before `scrollend` or the sampling timer. Deferring follow during that interval leaves the observed-top ledger stale and can classify browser layout movement as reader input, disabling follow without a reader gesture. - -## Decision - -[ChatView](../../../../packages/client/ui-chat/src/client/chat/ChatView.tsx) uses the existing observed-top comparison to sample non-reader pinned scroll deliveries synchronously through the same sample operation that clears pending work. This releases layout follow before further growth. Genuine reader movement remains pending until the existing interval or `scrollend`, even within the follow threshold: growth must not erase small gestures before they accumulate into a scroll-away. Immediate pinned samples use scroll metrics, not semantic-row geometry. - -## Alternatives considered - -**Defer every delivery.** Coalescing reduces geometry work while reading history, but a pinned browser position and its floor must be attributed in the same layout. A longer timeout or retry cannot recover ownership once the stale comparison disarms it. - -**Sample every delivery synchronously.** This restores attribution but also repeats semantic-anchor and reading-line measurements throughout an away-reader scroll burst. Only pinned ownership needs the immediate path. - -## Consequences - -Pinned deliveries incur immediate scroll-metric reads. History reading retains its bounded sampling cadence, and explicit return-to-bottom deliveries clear any pending away sample. [Focused tests](../../../../packages/client/ui-chat/tests/chat-view.client.spec.tsx) cover shrink/regrowth before scrollend, observer growth without row measurements, repinning with a pending sample, timer and scrollend sampling, and unmount cancellation. The [keyless browser scenario](../../../../apps/web/tests/chat-scroll-contract.e2e.ts) covers pinned Send, real scroll-away input, streaming, and tool disclosure across the long transcript. diff --git a/.agents/notes/implemented/bug-fix/2026-09-07-pinned-scroll-delivery-before-layout.zh.md b/.agents/notes/implemented/bug-fix/2026-09-07-pinned-scroll-delivery-before-layout.zh.md deleted file mode 100644 index 9b200f4553..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-09-07-pinned-scroll-delivery-before-layout.zh.md +++ /dev/null @@ -1,23 +0,0 @@ -# Agent Note: 在布局变化前处理贴底滚动事件 - -Status: implemented - -[English](2026-09-07-pinned-scroll-delivery-before-layout.md) | 中文 - -## Problem - -延迟的滚动采样会比较来自不同布局的位置。Chat 贴底时,输入框或 transcript(文本记录)收缩可能改变浏览器底部位置;随后的增长又可能在 `scrollend` 或采样定时器触发前改变浏览器位置。在此期间推迟跟随会使已观察顶部位置记录过期,把浏览器布局移动误判为读者输入,在没有读者操作时关闭跟随。 - -## Decision - -[ChatView](../../../../packages/client/ui-chat/src/client/chat/ChatView.tsx) 使用现有的已观察顶部位置比较,通过同一个清除待处理工作的采样操作,同步采样非读者引起的贴底滚动事件。这会在后续增长前恢复布局跟随。真实读者移动即使位于跟随阈值内,也保持待处理直到现有周期或 `scrollend`:增长不能在小幅操作累积为离底滚动前将其抵消。立即执行的贴底采样只读取滚动指标,不读取语义行几何。 - -## Alternatives considered - -**延迟所有事件。** 合并采样减少阅读历史时的几何计算,但贴底浏览器位置及其底部必须在同一布局中完成归因。过期比较关闭跟随后,延长超时或重试都无法恢复归属。 - -**同步采样所有事件。** 这能恢复归因,却也会在离底读者连续滚动时重复测量语义锚点和阅读线。只有贴底归属需要立即处理。 - -## Consequences - -贴底事件会立即读取滚动指标。历史阅读保留有界采样节奏,显式回到底部的滚动事件会清除任何待处理的离底采样。[聚焦测试](../../../../packages/client/ui-chat/tests/chat-view.client.spec.tsx) 覆盖 scrollend 前的收缩与增长、无需行测量的观察器增长、存在待处理采样时重新贴底、定时器与 scrollend 采样,以及卸载取消。[无密钥浏览器场景](../../../../apps/web/tests/chat-scroll-contract.e2e.ts) 覆盖长 transcript 中贴底发送、真实离底输入、流式输出与工具详情展开。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-08-stable-room-reading-under-hidden-split-controls.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-08-stable-room-reading-under-hidden-split-controls.i18n.yaml deleted file mode 100644 index f6a8425e96..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-09-08-stable-room-reading-under-hidden-split-controls.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# 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-08-stable-room-reading-under-hidden-split-controls.md -2026-09-08-stable-room-reading-under-hidden-split-controls.md: c89c5328ee896e23ed24454c50223193c2d587ba -2026-09-08-stable-room-reading-under-hidden-split-controls.zh.md: edf9948e547b79b33165b9c7168238e6cf1d2685 diff --git a/.agents/notes/implemented/bug-fix/2026-09-08-stable-room-reading-under-hidden-split-controls.md b/.agents/notes/implemented/bug-fix/2026-09-08-stable-room-reading-under-hidden-split-controls.md deleted file mode 100644 index c89c5328ee..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-09-08-stable-room-reading-under-hidden-split-controls.md +++ /dev/null @@ -1,25 +0,0 @@ -# Agent Note: Keep the room reading independent of hidden split controls - -Status: implemented - -English | [中文](2026-09-08-stable-room-reading-under-hidden-split-controls.zh.md) - -## Problem - -The dockkit room rule measures each pane's tab strip after every commit to decide whether an equal split leaves two working halves. With `hideSplitWhenBlocked`, a width-blocked pane unmounts its split control — and the unmount changes the very strip the rule measured: the strip sheds the control's 28px box plus its 4px gap, the fixed part shrinks, and the same pane reads as fitting again. Remounting the control reverses the reading. Across a roughly 32px band of pane widths the two states alternate inside nested layout effects until React stops the update loop (error #185); the slot runtime catches the crash and unmounts the Sidebar's entry while the column still records itself expanded, so neither the panel nor the header's collapsed-only expand button renders. A grip drag on a squeezed viewport sweeps the panel through that band, which presented as the whole sidebar vanishing with no way back in. - -## Decision - -When the embedder hides blocked split controls, the room rule leaves the split control's footprint out of the strip's fixed part unconditionally, so the reading is the same whether the control is currently mounted or not. [`measurePaneFits`](../../../../packages/client/ui-dockkit/src/components/measure.ts) takes the embedder's `hideSplitWhenBlocked` choice, measures the rendered control's box plus the strip's column gap (`splitControlFootprint`), and passes it as [`PaneMeasure.splitControlWidth`](../../../../packages/client/ui-dockkit/src/engine/geometry.ts), which `halvesFit` subtracts from the fixed part. Excluding the footprint is also correct on its own terms: a half too narrow to split would hide its own control, so the footprint is not part of what a half must carry. Embedders that render blocked controls disabled pass nothing and keep the control in the fixed part, as before. - -## Alternatives considered - -**Hide only budget-blocked controls, render width-blocked ones disabled.** This is what the code did before `hideSplitAtCapacity` widened into `hideSplitWhenBlocked`: the budget is state-driven and cannot feed back through the measurement. It avoids the loop but forfeits the Sidebar's requested presentation — no disabled split control on panes that cannot split. - -**Debounce or freeze re-measurement during oscillation.** Damping hides the instability instead of removing it: the reading would still depend on the control's visibility, settle on an arbitrary one of the two states, and flip on the next resize. - -**Measure the control's footprint from a constant.** A hardcoded 32px drifts from the stylesheet; measuring the rendered control and the strip's real `column-gap` keeps the subtraction equal to what the strip actually sheds, which is the exact condition for a stable reading. - -## Consequences - -The room reading is a fixed point under control visibility, so `hideSplitWhenBlocked` embedders get hidden controls without feedback. Panes near the boundary now read as splittable slightly earlier than a disabled-control embedder would report, because the half being asked about would not carry the control. A [dockkit regression test](../../../../packages/client/ui-dockkit/tests/components.client.spec.tsx) emulates the strip shedding the control's footprint and fails with React's update-depth error on the unfixed code; a [Sidebar browser case](../../../../apps/web/tests/sidebar-right.e2e.ts) drags the panel grip past both clamps on a squeezed viewport and asserts the panel, its grip, and a clean console survive, because the crash surfaces only as a console error the scaffold tripwire does not watch. diff --git a/.agents/notes/implemented/bug-fix/2026-09-08-stable-room-reading-under-hidden-split-controls.zh.md b/.agents/notes/implemented/bug-fix/2026-09-08-stable-room-reading-under-hidden-split-controls.zh.md deleted file mode 100644 index edf9948e54..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-09-08-stable-room-reading-under-hidden-split-controls.zh.md +++ /dev/null @@ -1,25 +0,0 @@ -# Agent Note: Keep the room reading independent of hidden split controls - -Status: implemented - -[English](2026-09-08-stable-room-reading-under-hidden-split-controls.md) | 中文 - -## Problem - -dockkit 的空间规则在每次 commit 后测量各 pane 的标签条,判断等分后的两半是否仍可用。开启 `hideSplitWhenBlocked` 时,宽度不足的 pane 会卸载自己的分屏控件——而这次卸载恰恰改变了规则所测量的标签条:标签条少了控件的 28px 盒子加 4px 间距,固定部分随之变小,同一个 pane 又被读成"够宽"。控件重新挂载后读数再次反转。在约 32px 的 pane 宽度区间内,两种状态在嵌套 layout effect 中来回切换,直到 React 中止更新循环(错误 #185);slot 运行时捕获崩溃后卸载 Sidebar 的条目,而列状态仍记录为展开,于是面板和 header 上仅折叠时显示的展开按钮都不再渲染。在收窄的视口上拖动把手会让面板扫过该区间,表现为整个侧栏消失且无法再打开。 - -## Decision - -当嵌入方选择隐藏被阻止的分屏控件时,空间规则无条件将分屏控件的占位排除在标签条固定部分之外,使读数与控件当前是否挂载无关。[`measurePaneFits`](../../../../packages/client/ui-dockkit/src/components/measure.ts) 接收嵌入方的 `hideSplitWhenBlocked` 选择,测量已渲染控件的盒子加标签条的列间距(`splitControlFootprint`),并作为 [`PaneMeasure.splitControlWidth`](../../../../packages/client/ui-dockkit/src/engine/geometry.ts) 传入,由 `halvesFit` 从固定部分中减去。排除该占位本身也是正确的:窄到无法分屏的一半会隐藏自己的控件,所以这份占位并不属于一半必须承载的内容。将被阻止控件渲染为禁用态的嵌入方不传该值,控件照旧计入固定部分。 - -## Alternatives considered - -**只隐藏预算受限的控件,宽度受限的渲染为禁用态。** 这是 `hideSplitAtCapacity` 扩展为 `hideSplitWhenBlocked` 之前的做法:预算由状态驱动,不会经测量反馈回来。它避免了循环,但放弃了 Sidebar 想要的呈现——无法分屏的 pane 上不出现禁用的分屏控件。 - -**在振荡期间对重新测量做防抖或冻结。** 阻尼只是掩盖不稳定而非消除它:读数仍依赖控件的可见性,会任意停在两种状态之一,并在下次 resize 时再次翻转。 - -**用常量表示控件占位。** 硬编码的 32px 会与样式表漂移;测量实际渲染的控件和标签条真实的 `column-gap`,才能保证减去的量恰好等于标签条实际卸下的量,这正是读数稳定的确切条件。 - -## Consequences - -空间读数在控件可见性变化下是不动点,`hideSplitWhenBlocked` 的嵌入方获得隐藏控件的呈现且无反馈循环。临界宽度附近的 pane 会比禁用态嵌入方的报告稍早读成可分屏,因为被询问的那一半不会承载控件。[dockkit 回归测试](../../../../packages/client/ui-dockkit/tests/components.client.spec.tsx) 模拟标签条卸下控件占位的反馈,在未修复的代码上以 React 更新深度错误失败;[Sidebar 浏览器用例](../../../../apps/web/tests/sidebar-right.e2e.ts) 在收窄视口上把面板把手拖过两侧钳位,断言面板、把手与干净的 console 均存活——崩溃只以 console error 形式出现,而脚手架的 tripwire 不监听它。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-09-composer-placeholder-whitespace.md b/.agents/notes/implemented/bug-fix/2026-09-09-composer-placeholder-whitespace.md deleted file mode 100644 index c5d778f8fb..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-09-09-composer-placeholder-whitespace.md +++ /dev/null @@ -1,21 +0,0 @@ -# Agent Note: Composer placeholder emptiness - -Status: implemented - -English | [中文](2026-09-09-composer-placeholder-whitespace.zh.md) - -## Problem - -Sharing the whitespace-trimmed submission check with placeholder rendering leaves guidance drawn over a draft containing spaces. - -## Decision - -The Composer hides its placeholder whenever the raw draft is nonempty. Submission keeps its trimmed-content check. Attachments and claimed commands retain their existing placeholder suppression. - -## Alternatives considered - -**Reuse the submission check.** Whitespace has no sendable message content, but it occupies the editor and moves its caret. A shared check conflates these two states. - -## Consequences - -All placeholder variants, including queued-message steering guidance, disappear after whitespace input and return after deletion. A whitespace-only draft without attachments remains unsendable. [Component tests](../../../../packages/client/ui-conversation/tests/input-bar.client.spec.tsx) cover visibility, composition, rerendering and submission; the [browser regression](../../../../apps/web/tests/composer-placeholder.e2e.ts) checks keyboard and clipboard gestures against built UI. diff --git a/.agents/notes/implemented/bug-fix/2026-09-09-composer-placeholder-whitespace.zh.md b/.agents/notes/implemented/bug-fix/2026-09-09-composer-placeholder-whitespace.zh.md deleted file mode 100644 index b5f82bcd86..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-09-09-composer-placeholder-whitespace.zh.md +++ /dev/null @@ -1,21 +0,0 @@ -# Agent Note: Composer 占位提示的判空规则 - -Status: implemented - -[English](2026-09-09-composer-placeholder-whitespace.md) | 中文 - -## Problem - -占位提示复用去除首尾空白后的提交判断,会让提示文字覆盖已经包含空格的草稿。 - -## Decision - -原始草稿非空时,Composer 隐藏占位提示。提交仍检查去除首尾空白后的内容。附件和已认领指令沿用现有的占位提示隐藏规则。 - -## Alternatives considered - -**复用提交判断。** 空白字符没有可发送的消息内容,但会占据编辑器并移动光标。共用判断会混淆这两种状态。 - -## Consequences - -所有占位提示,包括排队消息的插话提示,都会在输入空白字符后隐藏,删除后恢复。没有附件的纯空白草稿仍无法发送。[组件测试](../../../../packages/client/ui-conversation/tests/input-bar.client.spec.tsx) 覆盖显示、输入法组合、重新渲染和提交;[浏览器回归](../../../../apps/web/tests/composer-placeholder.e2e.ts) 使用构建后的界面检查键盘和剪贴板操作。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-14-transcript-width-handle-layering.md b/.agents/notes/implemented/bug-fix/2026-09-14-transcript-width-handle-layering.md deleted file mode 100644 index 01d70e646e..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-09-14-transcript-width-handle-layering.md +++ /dev/null @@ -1,33 +0,0 @@ -# Agent Note: Transcript width handles stay behind chat content - -Status: implemented - -English | [中文](2026-09-14-transcript-width-handle-layering.zh.md) - -## Problem - -The Conversation shell renders each content-width handle as a full-height absolute strip beside the reading column. A high stacking level placed that strip above wide Markdown tables that legitimately overflow the column. The handle glow covered their content and its pointer target could replace their interaction target. - -## Decision - -The Conversation shell keeps width handles at level zero without creating a body-wide stacking context. Chat raises concrete table elements to level one, while the reading column, column-bounded tool cards, and table breakout wrapper create no new stacking level. A table therefore owns the gutter only where its painted box actually reaches it; a fitting four-column table leaves the adjacent gutter draggable. Fixed message tooltips remain outside the table stacking context and can still paint over the sticky composer. - -This rule relies on the supported Chromium engine not turning ChatView's inline-size query container into an intermediate stacking context. The same engine behavior already lets the back-to-bottom control outrank the sticky composer. The Chromium hit-test scenario pins that assumption; raising the whole Chat root would instead make its transparent full-width box reclaim the gutter. - -A width handle starts resizing only for the primary pointer button. Ordinary wheel input over the handle is normalized from pixel, computed line-height, or page units and forwarded to the direct sibling transcript scrollport, so hovering the gutter does not suspend reading. Ctrl+wheel is not forwarded because it represents browser zoom or a trackpad pinch gesture. - -Each side's hit strip is at most 10px wide. Its hover indicator uses the lower-contrast resting scrollbar tint and is a 2px line with a 16px solid center and 28px fades, for a 72px total visible length. - -Composer chrome retains its higher layer, so its full sticky footer band intentionally does not start a width resize. A handle whose pointer capture began above that band temporarily rises to level eight, keeping the indicator visible until release. A full-view composer takeover continues to hide the handles entirely. - -## Alternatives considered - -**Move the handles farther from the reading column.** Wide tables can use the available transcript width, so a fixed larger inset would only reduce the overlap for some window sizes and would make the handles harder to reach. - -**Disable the handles whenever a transcript contains wide content.** One wide row would remove resizing from the entire Session, including empty gutter beside unrelated rows. - -**Inspect the elements under the pointer in JavaScript.** Dynamic hit testing would duplicate browser stacking and pointer dispatch rules and could still disagree with new plugin renderers. - -## Consequences - -Visible Chat content owns pointer input only where its painted element extends into a width-handle strip, while bare gutter keeps a quieter resize affordance, primary-button drag, and transcript wheel scrolling. Unit tests pin the declared handle geometry, pointer-button behavior, direct scrollport lookup, zoom exclusion, and every DOM delta mode. Chromium `elementFromPoint` coverage proves an overflowing table wins the hit while a fitting `md-table-wide` wrapper does not, and a message-action test proves fixed tooltips still paint above the sticky composer. diff --git a/.agents/notes/implemented/bug-fix/2026-09-14-transcript-width-handle-layering.zh.md b/.agents/notes/implemented/bug-fix/2026-09-14-transcript-width-handle-layering.zh.md deleted file mode 100644 index c6e03ec577..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-09-14-transcript-width-handle-layering.zh.md +++ /dev/null @@ -1,33 +0,0 @@ -# Agent Note: 正文宽度拖拽条位于 Chat 内容下方 - -Status: implemented - -[English](2026-09-14-transcript-width-handle-layering.md) | 中文 - -## 问题 - -Conversation shell 将每个正文宽度拖拽条渲染为阅读列旁的全高绝对定位区域。较高的堆叠层级使该区域盖在会合理溢出阅读列的宽 Markdown 表格之上。拖拽条的高亮会遮住表格内容,其指针目标也可能取代表格自身的交互目标。 - -## 决策 - -Conversation shell 将宽度拖拽条保持在第零层,不创建覆盖整个 body 的堆叠上下文。Chat 只将具体的表格元素提到第一层,阅读列、限定在列内的工具卡片与表格 breakout 包装层都不创建新的堆叠层级。因此只有表格的可见盒子实际延伸进沟槽时才会占有指针;能容纳在正文中的四列表格不会阻断旁边的沟槽拖拽。消息的固定定位 Tooltip 保持在表格堆叠上下文之外,仍可绘制在粘滞 composer 上方。 - -该规则依赖交付的 Chromium 引擎不会把 ChatView 的 inline-size query container 变成中间堆叠上下文。back-to-bottom 控件能高于粘滞 composer 也已依赖相同的引擎行为。Chromium 命中测试固定这一假设;如果改为提高整个 Chat root,其透明全宽盒子反而会重新占用沟槽。 - -宽度拖拽条仅使用主指针按键开始调整。拖拽条上的普通滚轮输入会从像素、计算后的行高或页面单位规整为像素,再转发给直接相邻的 transcript scrollport,因此指针悬停在沟槽时不会中断阅读滚动。Ctrl+滚轮表示浏览器缩放或触控板捏合手势,不会被转发。 - -每侧的命中区域最宽为 10px。悬停指示线使用对比度更低的静止滚动条颜色,线宽 2px,中心实色段长 16px,两端各渐隐 28px,可见总长度为 72px。 - -Composer chrome 保留更高层级,因此完整的粘滞底部区带刻意不会开始宽度调整。如果拖拽条在进入该区带之前已获得指针捕获,它会临时提到第八层,使指示线在松开前保持可见。占据完整 View 的 composer takeover 仍会完全隐藏拖拽条。 - -## 考虑过的替代方案 - -**将拖拽条移到离阅读列更远的位置。** 宽表格可以使用 transcript 的可用宽度,因此固定增加间距只能减少部分窗口尺寸下的重叠,还会让拖拽条更难触达。 - -**只要 transcript 包含宽内容就禁用拖拽条。** 一行宽内容会让整个 Session 都无法调整宽度,包括与其他行相邻的空白沟槽。 - -**在 JavaScript 中检查指针下方的元素。** 动态命中测试会重复浏览器的堆叠与指针分发规则,并且仍可能与新的插件 renderer 不一致。 - -## 后果 - -可见 Chat 内容只在其可见元素实际延伸进宽度拖拽区域时拥有指针输入,裸露沟槽则保留更轻的宽度调整提示、主按键拖拽和 transcript 滚轮滚动。单元测试固定声明的拖拽条尺寸、指针按键行为、直接 scrollport 查找、缩放排除和每种 DOM delta mode。Chromium `elementFromPoint` 覆盖证明溢出表格获得指针命中,而可容纳的 `md-table-wide` 包装层不会;消息操作测试证明固定定位 Tooltip 仍绘制在粘滞 composer 上方。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-15-trajectory-attachment-presentation.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-15-trajectory-attachment-presentation.i18n.yaml new file mode 100644 index 0000000000..cc6eb7ff26 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-15-trajectory-attachment-presentation.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-trajectory-attachment-presentation.md +2026-09-15-trajectory-attachment-presentation.md: 03d737d67e626497bbcb957911b1578e0dadd5fb +2026-09-15-trajectory-attachment-presentation.zh.md: 55c63bf3d97de2e3e102e92730ce4d2637547d6b diff --git a/.agents/notes/implemented/bug-fix/2026-09-15-trajectory-attachment-presentation.md b/.agents/notes/implemented/bug-fix/2026-09-15-trajectory-attachment-presentation.md new file mode 100644 index 0000000000..03d737d67e --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-15-trajectory-attachment-presentation.md @@ -0,0 +1,55 @@ +# Agent Note: Consistent Trajectory attachment presentation + +Status: implemented + +English | [中文](2026-09-15-trajectory-attachment-presentation.zh.md) + +## Problem + +A user message can contain text, images, and ordinary files. Trajectory omitted image counts beside text, left ordinary files out of Preview, and mixed file JSON with rendered images in Raw. Readers could not identify the complete attachment set consistently across the ledger and inspector (Issue #4356). + +## Decision + +Trajectory presents every attachment occurrence while keeping visual inspection separate from inspection of recorded fields. + +| Location | Behavior | +|---|---| +| Ledger | Separate nonzero image and ordinary-file counts accompany the text excerpt, including messages without text. Each record stays on one compact line. | +| Summary and Preview | Message text precedes one ordered attachment list. Both tabs use the same renderer; Summary retains its compact spacing and scroll container. | +| Attachment list | Equal-width compact rows share spacing and corners. A file-type icon or contained image thumbnail accompanies the recorded name and available metadata. Repeated references retain their occurrence order. | +| Image interaction | The complete image fits inside a 48px square thumbnail without cropping. Activation opens the existing image viewer, with keyboard access. Loading and retry icons fit the thumbnail and expose localized tooltips and accessible names. | +| Raw | Image and file blocks use initially collapsed metadata disclosures with complete recorded fields. Original content-block order and unrendered text remain intact; generated display labels do not enter raw fields. | + +Recorded filenames take precedence over localized numbered image labels. Metadata comes from recorded fields or the filename extension; zero-byte sizes remain visible. Visually truncated names retain their complete accessible text and tooltip. Messages without text still expose all attachments inside the inspector's scroll area. + +### Ownership + +[ui-trajectory](../../../../packages/client/ui-trajectory/README.md) owns counts, ordered attachment projection, and inspector composition. Its view data carries typed image and file references separately from the serialized content used by Raw, so display code does not recover file metadata by parsing JSON. + +[ui-attachment](../../../../packages/client/ui-attachment/README.md) owns image loading and the viewer through the existing image slot. [ui-conversation](../../../../packages/client/ui-conversation/README.md) owns the per-session image cache: Chat and Trajectory share one authorized read and browser URL per attachment. Thumbnail sizing and optional display labels affect presentation only; loading still receives the original reference. Chat and tool-result gallery sizing retain their existing behavior. + +This decision uses existing durable attachment references without changing upload, storage, model requests, or the Session format. Ordinary files expose metadata without a content viewer or download action. Product labels use typed locale dictionaries, and styles use the existing semantic tokens. + +The [generic file upload decision](../feature/2026-08-26-generic-file-upload.md) owns upload, storage, admission, and ordered message attachments; this record owns Trajectory presentation. The [unreadable attachment quarantine proposal](../../proposed/bug-fix/2026-08-20-attachment-read-quarantine.md) concerns model-request recovery independently of thumbnail loading feedback. + +## Alternatives considered + +**Keep large inline images as the default inspector presentation.** This makes one image immediately readable, but a tall image displaces attachment names and message text in the narrow inspector. Compact rows with explicit image activation provide a consistent overview of mixed attachments. + +**Use two-column attachment cards.** A grid can make thumbnails more prominent on wide panels, but it leaves less room for long filenames and requires a different narrow-panel arrangement. A single equal-width list accommodates both panel widths. + +**Keep file JSON and image previews together in Raw.** This assigns different meanings to the same tab for two attachment kinds. Metadata disclosures let readers inspect recorded fields while Preview owns visual image presentation. + +## Consequences + +The shared attachment renderer keeps names, order, metadata, and image actions consistent between Summary and Preview. Grouping attachments after message text makes the complete set easy to scan; Raw preserves interleaving when readers need the original block sequence. Repeated references remain separate rows even when image loading shares a cache entry. + +Compact thumbnails require opening the viewer to read detailed diagrams. Missing metadata stays absent, and image-read failures retain a retry control without hiding ordinary files. Changes to the shared image slot also affect Chat and tool results, so their gallery behavior and cache reuse remain regression requirements. + +## Verification + +[Projection tests](../../../../packages/client/ui-trajectory/tests/layout.client.spec.tsx) cover mixed, attachment-only, empty-text, and repeated occurrences. [Inspector tests](../../../../packages/client/ui-trajectory/tests/table.client.spec.tsx) cover both locales, unnamed images, long filenames, zero-byte files, ordered lists, and complete Raw fields. [Image tests](../../../../packages/client/ui-attachment/tests/message-image.client.spec.tsx) cover thumbnail labels, loading, retry, and the existing viewer. + +The recorded [mixed-upload browser scenario](../../../../apps/web/tests/file-upload-round.e2e.ts) compares Summary and Preview, verifies keyboard activation and focus restoration, checks collapsed and expanded Raw content, and repeats inspection after reload. Its [Trajectory expectation](../../../../snapshots/web/file-upload-round/trajectory.expected.md) accompanies the unchanged Session fixture. The [assembled image-cache test](../../../../apps/web/tests/trajectory-image-display.expected.e2e.ts) also checks reuse between Chat and Trajectory. + +Layout acceptance covers narrow and wide panels, both themes and locales, complete thumbnail containment, and reachable final attachments. The [GIF workflow](../../../skills/record-browser-gif/SKILL.md) requires review evidence from a clean committed tree and one real-server, real-model session showing mixed submission, ledger counts, Summary, Preview image activation, and expanded Raw metadata. Issue #4356 and its linked PR track executed checks and demonstration evidence. diff --git a/.agents/notes/implemented/bug-fix/2026-09-15-trajectory-attachment-presentation.zh.md b/.agents/notes/implemented/bug-fix/2026-09-15-trajectory-attachment-presentation.zh.md new file mode 100644 index 0000000000..55c63bf3d9 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-15-trajectory-attachment-presentation.zh.md @@ -0,0 +1,55 @@ +# Agent Note: 统一 Trajectory 附件展示 + +Status: implemented + +[English](2026-09-15-trajectory-attachment-presentation.md) | 中文 + +## 问题 + +用户消息可以包含文本、图片和普通文件。Trajectory 曾在文本旁省略图片数量,在预览中遗漏普通文件,并在原始内容中混排文件 JSON 与渲染后的图片。读者无法在列表和详情面板中一致地确认完整附件集合(Issue #4356)。 + +## 决定 + +Trajectory 展示每次附件出现的记录,并分别提供视觉预览与已记录字段检查。 + +| 位置 | 行为 | +|---|---| +| 列表 | 文本摘要旁分别显示非零的图片数量与普通文件数量,无正文的消息也显示计数。每条记录保持为紧凑单行。 | +| 概述与预览 | 消息正文之后展示一个有序附件列表。两个标签页使用相同的渲染器;概述保留紧凑间距和滚动容器。 | +| 附件列表 | 等宽的紧凑行使用一致的间距和圆角。文件类型图标或完整缩放的图片缩略图旁展示已记录的名称与可用元数据。重复引用保留各次出现的顺序。 | +| 图片交互 | 图片完整缩放到 48px 方形缩略图内,不裁剪。激活时打开现有图片查看器,保留键盘访问。加载与重试图标适配缩略图尺寸,并提供本地化的工具提示和可访问名称。 | +| 原始内容 | 图片块和文件块使用默认收起的元数据折叠区,包含完整记录字段。原始内容块顺序与未渲染文本保持完整;生成的显示标签不进入原始字段。 | + +已记录文件名优先于本地化的编号图片标签。元数据来自记录字段或文件扩展名,零字节大小仍然可见。视觉上截断的名称保留完整的无障碍文本和工具提示。无正文的消息仍在详情面板滚动区域内展示全部附件。 + +### 职责 + +[ui-trajectory](../../../../packages/client/ui-trajectory/README.zh.md)负责计数、有序附件投影和详情面板组合。其视图数据将带类型的图片与文件引用和原始内容使用的序列化文本分别保存,展示代码因此无需解析 JSON 来恢复文件元数据。 + +[ui-attachment](../../../../packages/client/ui-attachment/README.zh.md)通过现有图片 slot 负责图片加载与查看器。[ui-conversation](../../../../packages/client/ui-conversation/README.zh.md)拥有会话级图片缓存:Chat 与 Trajectory 对每个附件共享一次授权读取和一个浏览器 URL。缩略图尺寸和可选显示标签只影响展示,加载仍接收原始引用。Chat 和工具结果图库保留现有的尺寸行为。 + +本决定使用现有持久附件引用,不改变上传、存储、模型请求或会话格式。普通文件展示元数据,不提供内容查看器或下载操作。产品标签使用带类型的本地化字典,样式使用现有语义 token。 + +[通用文件上传决定](../feature/2026-08-26-generic-file-upload.zh.md)负责上传、存储、准入和有序消息附件;本记录负责 Trajectory 展示。[不可读附件隔离提案](../../proposed/bug-fix/2026-08-20-attachment-read-quarantine.zh.md)处理模型请求恢复,与缩略图加载反馈相互独立。 + +## 考虑过的替代方案 + +**默认在详情面板中内联展示大图。** 这样可以立即阅读单张图片,但高图片会在窄面板中挤走附件名称和消息正文。紧凑行加显式图片激活入口能一致地概览混合附件。 + +**使用双列附件卡片。** 网格能在宽面板上突出缩略图,但会压缩长文件名空间,并要求窄面板采用不同排布。单列等宽列表适用于两种面板宽度。 + +**在原始内容中混排文件 JSON 和图片预览。** 这让同一个标签页对两种附件具有不同含义。元数据折叠区用于检查已记录字段,预览负责图片的视觉展示。 + +## 影响 + +共享附件渲染器使概述与预览中的名称、顺序、元数据和图片操作保持一致。将附件集中在消息正文之后便于浏览完整集合;需要原始块顺序时,原始内容保留交错关系。即使图片加载共享缓存项,重复引用仍各占一行。 + +紧凑缩略图需要打开查看器才能阅读详细图表。缺失的元数据保持缺失,图片读取失败保留重试控件,并且不隐藏普通文件。共享图片 slot 的修改也会影响 Chat 和工具结果,因此其图库行为和缓存复用仍需回归验证。 + +## 验证 + +[投影测试](../../../../packages/client/ui-trajectory/tests/layout.client.spec.tsx)覆盖混合附件、纯附件、空文本和重复引用。[详情面板测试](../../../../packages/client/ui-trajectory/tests/table.client.spec.tsx)覆盖两种语言、无名称图片、长文件名、零字节文件、有序列表和完整的原始字段。[图片测试](../../../../packages/client/ui-attachment/tests/message-image.client.spec.tsx)覆盖缩略图标签、加载、重试和现有查看器。 + +已录制的[混合上传浏览器场景](../../../../apps/web/tests/file-upload-round.e2e.ts)比较概述与预览,验证键盘激活和焦点恢复,检查原始内容收起与展开状态,并在刷新后重复检查。其 [Trajectory 预期输出](../../../../snapshots/web/file-upload-round/trajectory.expected.md)配合未改动的会话 fixture(测试前置数据)使用。[组装应用图片缓存测试](../../../../apps/web/tests/trajectory-image-display.expected.e2e.ts)也检查 Chat 与 Trajectory 之间的复用。 + +布局验收覆盖窄版与宽版面板、两种主题和语言、缩略图完整缩放及末尾附件可达性。[GIF 工作流](../../../skills/record-browser-gif/SKILL.md)要求评审证据基于干净的已提交代码树,在同一个真实服务、真实模型会话中展示混合提交、列表计数、概述、预览中的图片激活和展开的原始元数据。Issue #4356 及其关联 PR(Pull Request)记录已执行的检查和演示证据。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-17-thinking-markdown.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-17-thinking-markdown.i18n.yaml index f0a89cb78d..6377a233cb 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-17-thinking-markdown.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-09-17-thinking-markdown.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-17-thinking-markdown.md -2026-09-17-thinking-markdown.md: de49cb42ba537700f6584c9f4cef9a57fb2e3605 -2026-09-17-thinking-markdown.zh.md: e403630138f2f39aec0834e550d4d4a8860a67fe +2026-09-17-thinking-markdown.md: 6990fc6277d7d1c0a088a50cfb88694b2d9c55c7 +2026-09-17-thinking-markdown.zh.md: 963626088e50a44bcf76c7fc7ec3653e93fcd2c9 diff --git a/.agents/notes/implemented/bug-fix/2026-09-17-thinking-markdown.md b/.agents/notes/implemented/bug-fix/2026-09-17-thinking-markdown.md index de49cb42ba..6990fc6277 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-17-thinking-markdown.md +++ b/.agents/notes/implemented/bug-fix/2026-09-17-thinking-markdown.md @@ -10,9 +10,9 @@ Chat Thinking contains model-authored Markdown, but literal rendering exposes he ## Decision -[MarkdownText](../../../../packages/client/ui-primitives/src/markdown/MarkdownText.tsx) owns a compact presentation variant used by Chat Thinking and Trajectory thinking details. It keeps the secondary content font size, line height, and tertiary color throughout. Heading levels retain semantic elements but share the same 600 weight and size; paragraphs, lists, quotes, and code use compact spacing. Links keep a resting dotted underline with tertiary color as a secondary-content exception to the [shared link styling](../feature/2026-09-04-web-clickable-link-styles.md); code retains its monospace font and background. +[MarkdownText](../../../../packages/client/ui-primitives/src/markdown/MarkdownText.tsx) owns a compact presentation variant used by Chat Thinking and Trajectory thinking details. It keeps the secondary content font size, line height, and tertiary color throughout. Heading levels retain semantic elements but share the same 600 weight and size; paragraphs, lists, quotes, and code use compact spacing. Links keep a resting dotted underline with tertiary color as a secondary-content exception to the [shared link styling](../../../../docs/web-styling.md); code retains its monospace font and background. -Tables and formulas remain enabled through the existing parser. Their containers constrain horizontal overflow, and formula text inherits the secondary font size. Inline formulas retain native KaTeX baselines; enclosing text blocks own overflow so short formulas do not create scrollbars. Compact code banners stay in normal flow, keeping the [Thinking disclosure header](../feature/2026-08-03-web-sticky-collapsible-headers.md) above the scrolling content without a second sticky band. +Tables and formulas remain enabled through the existing parser. Their containers constrain horizontal overflow, and formula text inherits the secondary font size. Inline formulas retain native KaTeX baselines; enclosing text blocks own overflow so short formulas do not create scrollbars. Compact code banners stay in normal flow, keeping the [Thinking disclosure header](../../../../packages/client/ui-chat/src/client/chat/ReasoningRow.module.css) above the scrolling content without a second sticky band. Chat passes its running state to the existing incremental Markdown renderer. The collapsed summary remains a separate single-line text projection. The parser, frozen-block cache, stored reasoning, and Session format do not change. Trajectory pins Thinking to its inspector’s fixed 13px/20px tier, independent of Chat’s content-size setting. Its answer output retains its existing typography and its [Thinking disclosure behavior](../feature/2026-09-09-ptc-trajectory-code-inspection.md). diff --git a/.agents/notes/implemented/bug-fix/2026-09-17-thinking-markdown.zh.md b/.agents/notes/implemented/bug-fix/2026-09-17-thinking-markdown.zh.md index e403630138..963626088e 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-17-thinking-markdown.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-09-17-thinking-markdown.zh.md @@ -10,9 +10,9 @@ Chat Thinking 包含模型编写的 Markdown,但纯文本渲染直接显示标 ## 决策 -[MarkdownText](../../../../packages/client/ui-primitives/src/markdown/MarkdownText.tsx) 拥有紧凑展示变体,供 Chat Thinking 和 Trajectory 思考详情使用。内容沿用次级字号、行高和 tertiary 颜色。各级标题保留语义元素,但统一使用 600 字重和相同字号;段落、列表、引用和代码采用紧凑间距。链接保留默认点状下划线与 tertiary 颜色,作为[共享链接样式](../feature/2026-09-04-web-clickable-link-styles.zh.md)的次级内容例外;代码保留等宽字体和底色。 +[MarkdownText](../../../../packages/client/ui-primitives/src/markdown/MarkdownText.tsx) 拥有紧凑展示变体,供 Chat Thinking 和 Trajectory 思考详情使用。内容沿用次级字号、行高和 tertiary 颜色。各级标题保留语义元素,但统一使用 600 字重和相同字号;段落、列表、引用和代码采用紧凑间距。链接保留默认点状下划线与 tertiary 颜色,作为[共享链接样式](../../../../docs/web-styling.zh.md)的次级内容例外;代码保留等宽字体和底色。 -表格和公式继续通过既有解析器启用。其容器限制横向溢出,公式文本继承次级字号。行内公式保留 KaTeX 原生基线,由外层文本块承担溢出处理,短公式不会产生滚动条。紧凑代码栏保持正常文档流,让 [Thinking 折叠标题](../feature/2026-08-03-web-sticky-collapsible-headers.zh.md) 位于滚动内容上方,无需第二条 sticky 栏。 +表格和公式继续通过既有解析器启用。其容器限制横向溢出,公式文本继承次级字号。行内公式保留 KaTeX 原生基线,由外层文本块承担溢出处理,短公式不会产生滚动条。紧凑代码栏保持正常文档流,让 [Thinking 折叠标题](../../../../packages/client/ui-chat/src/client/chat/ReasoningRow.module.css) 位于滚动内容上方,无需第二条 sticky 栏。 Chat 将运行状态传给既有增量 Markdown 渲染器。折叠摘要仍是独立的单行文本投影。解析器、冻结块缓存、存储的推理内容和 Session 格式均保持不变。Trajectory 将 Thinking 固定在检查器的 13px/20px 层级,与 Chat 的内容字号设置无关。其回答输出保留既有排版及其 [Thinking 折叠行为](../feature/2026-09-09-ptc-trajectory-code-inspection.zh.md)。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-07-pinned-scroll-delivery-before-layout.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.i18n.yaml similarity index 54% rename from .agents/notes/implemented/bug-fix/2026-09-07-pinned-scroll-delivery-before-layout.i18n.yaml rename to .agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.i18n.yaml index 5df3dbdf86..0898823b35 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-07-pinned-scroll-delivery-before-layout.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.i18n.yaml @@ -1,6 +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-07-pinned-scroll-delivery-before-layout.md -2026-09-07-pinned-scroll-delivery-before-layout.md: f3b428dbe0d2fdc7cbaedf0383d2ab80ea1bfd45 -2026-09-07-pinned-scroll-delivery-before-layout.zh.md: 9b200f45535c67cfbbb75feaf3025c5a3b5c1fa2 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.md +2026-09-18-messages-input-history-compatibility.md: 1775276deec6180f1fd186b11cb7a757a3328a54 +2026-09-18-messages-input-history-compatibility.zh.md: 50367e33c29df0679a44aca1fe680d2bedc8c263 diff --git a/.agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.md b/.agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.md new file mode 100644 index 0000000000..1775276dee --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.md @@ -0,0 +1,31 @@ +# Agent Note: Replay assistant blocks in Messages user input + +Status: implemented + +English | [中文](2026-09-18-messages-input-history-compatibility.zh.md) + +## Problem + +Saved subagent settlement notices can contain child reasoning and tool calls in parent user messages. Chat Completions and pi-ai omit these blocks from user input, while rejecting them in Messages prevents every later request containing the same history. The [text-only settlement producer](../feature/2026-08-06-manager-owned-subagent-settlement-delivery.md) prevents new notices from carrying these blocks but cannot repair already recorded messages. + +## Decision + +The [Messages serializer](../../../../packages/llm/llm-deepseek/src/protocols/messages/serialize.ts) omits `reasoning` and `tool-call` blocks from user and tool-result input. This matches Chat Completions and pi-ai for these two block types, without changing Session records or converting child reasoning into parent user text. The [provider README](../../../../packages/llm/llm-deepseek/README.md#model-experience) owns the input rules, including empty user messages, empty tool results, and rejection of other unsupported blocks. + +This partially supersedes the settlement decision's rejection of serializer tolerance and the [Messages adapter decision](../feature/2026-09-07-deepseek-messages-adapter.md)'s input rejection. Notice construction still projects nonempty child text for every parent provider. Canonical child output and ordinary assistant reasoning and tool calls remain available to their existing consumers. The omission is based on input role and block type, not the notice source or creation date, so it also applies to newly supplied user and tool-result content. + +## Alternatives considered + +**Fix only the producer.** New notices are representable, but saved notices still block protocol switching and continuation. + +**Rewrite Session history or flatten reasoning into user text.** Rewriting removes original evidence; flattening sends child reasoning as parent input. Request-only omission preserves the durable record and existing input semantics. + +**Ignore every unsupported block.** Unknown plugin content has no Messages representation defined here. Compatibility for two known assistant block types does not establish one. + +**Move a system update to an earlier retained user turn.** When the corresponding user input is omitted, moving the update before an earlier assistant changes its position in the conversation and rewrites a previously sent prefix. The [documented in-history limitation](../../../../packages/llm/llm-deepseek/README.md#known-limitations-and-deferred-work) remains explicit rather than inventing a user message or relocating the update. + +## Consequences + +Saved notices with retained text can continue through Messages. The provider no longer diagnoses these two block types as user-input producer errors; the settlement producer remains responsible for text-only notices. This does not promise lossless provider input or compatibility for every historical message sequence. + +Verification covers user and tool-result filtering, unchanged durable content, HTTP continuation, and a [headless Session replay](../../../../snapshots/session/deepseek-messages-input-history/snapshot.yml). Serialization tests pin rejection of an in-history update when its user input is entirely omitted, both before another assistant and at request end, and acceptance when text or a tool result retains the corresponding turn. diff --git a/.agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.zh.md b/.agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.zh.md new file mode 100644 index 0000000000..50367e33c2 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.zh.md @@ -0,0 +1,31 @@ +# Agent Note: 回放 Messages 用户输入中的助手块 + +Status: implemented + +[English](2026-09-18-messages-input-history-compatibility.md) | 中文 + +## 问题 + +已保存的子 Agent 结算通知可能在父级用户消息中包含子级推理和工具调用。Chat Completions 与 pi-ai 会在用户输入中省略这些块,而 Messages 拒绝它们会阻断包含相同历史的所有后续请求。[仅生成文本的结算通知生产方](../feature/2026-08-06-manager-owned-subagent-settlement-delivery.zh.md)可以避免新通知携带这些块,但无法修复已经记录的消息。 + +## 决策 + +[Messages 序列化器](../../../../packages/llm/llm-deepseek/src/protocols/messages/serialize.ts)省略用户与工具结果输入中的 `reasoning` 和 `tool-call` 块。这两类块的处理与 Chat Completions、pi-ai 一致,不改变 Session 记录,也不把子级推理转换成父级用户文本。[提供方 README](../../../../packages/llm/llm-deepseek/README.zh.md#model-experience)统一说明输入规则,包括空用户消息、空工具结果,以及对其他不支持块的拒绝。 + +这部分取代了结算决策对序列化器容错的拒绝,以及 [Messages 适配器决策](../feature/2026-09-07-deepseek-messages-adapter.zh.md)中的输入拒绝规则。通知构建仍为每种父级提供方投影子级的非空文本。子级的规范输出以及普通助手推理和工具调用仍可供现有消费者使用。省略依据是输入角色和块类型,而不是通知来源或创建日期,因此也适用于新提供的用户和工具结果内容。 + +## 考虑过的替代方案 + +**只修复生产方。** 新通知可以表示,但已保存的通知仍会阻断协议切换和会话续接。 + +**改写 Session 历史或把推理压平为用户文本。** 改写会移除原始证据;压平会把子级推理作为父级输入发送。只在请求中省略可以保留持久化记录和现有输入语义。 + +**忽略所有不支持的块。** 此处没有定义未知插件内容的 Messages 表示方式。兼容两种已知助手块类型并不意味着其他类型也有对应表示。 + +**把 system 更新移到更早保留的用户轮次。** 对应用户输入被省略时,将更新移到更早的 assistant 之前,会改变它在对话中的位置,并改写已发送的前缀。保留明确记录的[历史内更新限制](../../../../packages/llm/llm-deepseek/README.zh.md#known-limitations-and-deferred-work),不编造用户消息或移动更新位置。 + +## 后果 + +保留文本的已保存通知可以通过 Messages 继续会话。提供方不再将这两种块类型诊断为用户输入生产方错误;结算通知生产方仍负责仅生成文本通知。这不保证提供方输入无损,也不保证兼容所有历史消息序列。 + +验证覆盖用户与工具结果过滤、持久化内容不变、HTTP 续接,以及 [headless Session 回放](../../../../snapshots/session/deepseek-messages-input-history/snapshot.yml)。序列化测试固定了历史内更新在对应用户输入被完全省略时的拒绝行为,覆盖下一个 assistant 之前和请求结束两种位置;文本或工具结果保留对应轮次时则可以通过。 diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml index cd51630220..e678639894 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.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-07-27-web-subagent-conversations.md -2026-07-27-web-subagent-conversations.md: d0713731de86df639a426ee56c39857c50af6bc7 -2026-07-27-web-subagent-conversations.zh.md: 0f4f6467484ee1caa3cdf28f44e016ff674ac1dc +2026-07-27-web-subagent-conversations.md: aacb52eca5680534b74abd42ca07c2b568c23e21 +2026-07-27-web-subagent-conversations.zh.md: 30c41f03703004e3175cee8fad9d11e0cf93f06a diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md index d0713731de..aacb52eca5 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md @@ -16,6 +16,8 @@ The UI must also preserve the membership, modes, and diagnostics of the [durable The Web product exposes the selected session's direct session-backed subagents from the current-title lineage region in the header. Users can lazily expand descendant catalogs and open either mode in the existing conversation region. A one-shot child is permanently read-only. A continuable child accepts human follow-ups only while its exact direct-parent Agent is live; otherwise its persisted transcript remains readable with a recovery explanation. +The same header row can open a child in the right Sidebar as `dsh-resource://chat/session/?parent=&mode=`. The open prefers a separate pane and falls back to the current pane when no split is available. The Sidebar tab renders the shared Conversation Component Factory with its width controls omitted, so main and embedded conversations share one assembly without sharing layout chrome. + Every opened child carries a catalog-derived address `{ parentSessionId, childSessionId, mode }`. The mode-bearing address, not lineage or the coarse origin marker, selects dedicated history and prompt transports. History reads the persisted session without activation. A continuable prompt carries Queue or Steer delivery through `subagent.prompt` and succeeds at inbox acceptance with `{ messageId }`; it does not expose an Activation, wait for completion, or return an outcome. Adjacent-Agent model messages use the separately owned fixed-Steer operation. The generic Host domain preserves the same ownership boundary. `session.history` and the source side of `session.fork` read an attached Session or inspect persistence without acquiring an Agent; history folds cold projection values from that exact inspected prefix, while a fork publishes an ordinary independent session. Generic Agent-bound session, command, and goal routes return `agent-busy` for session-backed subagents, as do explicit-id `session.create` adoption and attached-only queue controls. The denial classifier accepts the coarse `origin` marker, a `subagent/descriptor` in the session's own suffix, or exact live runtime ownership by the parent; these signals only prevent generic ownership and never replace catalog mode or direct-parent authorization. @@ -37,7 +39,7 @@ The Figma [subagent list](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Ha ## Product contract -The descendant-count control is absent only when a complete empty direct-catalog response agrees with the session-summary projection that no subagent descendants are known. On an ordinary session, a slash separates the current title from that count control. Every subagent breadcrumb combines its compact 12px title with a fixed double chevron: the current breadcrumb uses primary color and weight 500, while ancestors use tertiary color and weight 400. Hovering a combined control for 150ms opens its direct-parent catalog, with a short crossing grace for the portaled menu; ArrowDown remains the keyboard entry path. Clicking an ancestor cancels pending hover, closes an open catalog, and only navigates upward. Each direct-parent catalog enables sibling switching and bolds its selected row; its label overrides the session-summary title, and every missing switcher catalog refreshes automatically. Only the current subagent appends its own descendant-count control. Long titles truncate before the fixed chevron. The count trigger counts every known session-summary descendant reached through an uninterrupted `origin: 'subagent'` lineage, stops at ordinary forks, and shows ongoing activity when any counted descendant is running. Because ordinary sidebar rows hide subagent-origin sessions, the Workspace browser indexes the same uninterrupted lineage onto each visible ordinary row: any running descendant supplies its blue activity indicator and exact count in hover and assistive text without describing an idle parent as running. An ordinary fork starts a separate aggregation subtree. Pending interaction outranks parent running; either remains primary while descendant activity becomes a second hover and assistive status. With neither present, descendant activity outranks an unviewed completion reminder, which returns after the last running descendant stops. Every healthy direct-catalog row carries a read-time `hasChildren` hint derived only from direct lineage headers with durable `origin: 'subagent'`; normal healthy and diagnostic subagent candidates carry that marker, while ordinary forks do not. This lookahead reads no descendant event log, and the descriptor-backed catalog loaded after disclosure remains authoritative. When summaries establish descendants before that catalog exists or after a stale empty response, the action stays visible and exposes only disabled loading rows until opening it refreshes the catalog; summary-only rows never grant navigation. The UI omits disclosure for a known leaf before interaction; the hint does not promise that the child will remain a leaf. While an expanded direct catalog is loading, known lineage reserves one disabled loading row per direct descendant without recursively fetching descendant catalogs. The tree then presents continuable and one-shot rows, falling back to the session id when an optional one-shot label is absent. Corrupt, unsupported, and unavailable candidates remain visible as disabled diagnostic rows. +The descendant-count control is absent only when a complete empty direct-catalog response agrees with the session-summary projection that no subagent descendants are known. On an ordinary session, a slash separates the current title from that count control. Every subagent breadcrumb combines its compact 12px title with a fixed double chevron: the current breadcrumb uses primary color and weight 500, while ancestors use tertiary color and weight 400. Hovering a combined control for 150ms opens its direct-parent catalog, with a short crossing grace for the portaled menu; ArrowDown remains the keyboard entry path. Clicking an ancestor cancels pending hover, closes an open catalog, and only navigates upward. Each direct-parent catalog enables sibling switching and bolds its selected row; its label overrides the session-summary title, and every missing switcher catalog refreshes automatically. Only the current subagent appends its own descendant-count control. Long titles truncate before the fixed chevron. The count trigger counts every known session-summary descendant reached through an uninterrupted `origin: 'subagent'` lineage, stops at ordinary forks, and shows ongoing activity when any counted descendant is running. Because ordinary sidebar rows hide subagent-origin sessions, the Workspace browser indexes the same uninterrupted lineage onto each visible ordinary row: any running descendant supplies the shared tertiary-grey ongoing loader and exact count in hover and assistive text without describing an idle parent as running. An ordinary fork starts a separate aggregation subtree. Pending interaction outranks parent running; either remains primary while descendant activity becomes a second hover and assistive status. An ordinary Workspace row with a pending approval, plan review, or question replaces its relative time with the compact label `Approval`, `Plan review`, or `Answer`; hover and assistive details retain the full status and relative time. With neither primary state present, descendant activity outranks an unviewed completion reminder, which returns after the last running descendant stops. Every healthy direct-catalog row carries a read-time `hasChildren` hint derived only from direct lineage headers with durable `origin: 'subagent'`; normal healthy and diagnostic subagent candidates carry that marker, while ordinary forks do not. This lookahead reads no descendant event log, and the descriptor-backed catalog loaded after disclosure remains authoritative. When summaries establish descendants before that catalog exists or after a stale empty response, the action stays visible and exposes only disabled loading rows until opening it refreshes the catalog; summary-only rows never grant navigation. The UI omits disclosure for a known leaf before interaction; the hint does not promise that the child will remain a leaf. While an expanded direct catalog is loading, known lineage reserves one disabled loading row per direct descendant without recursively fetching descendant catalogs. The tree then presents continuable and one-shot rows, falling back to the session id when an optional one-shot label is absent. Corrupt, unsupported, and unavailable candidates remain visible as disabled diagnostic rows. `running` means the exact child Agent driver is draining work at the Host sampling boundary; `inactive` means that driver is idle or absent. The UI does not translate either value into success, failure, cancellation, completeness, or resumability. `subagent.list` supplies the current driver-status baseline, `host/session-status` updates known activity in place, request-local replay prevents an older in-flight list response from overwriting a newer transition, and `host/session-removed` returns a known row to `inactive`; reconnect reads a fresh baseline. A `host/session-added` frame for a direct subagent immediately flips any loaded parent row to `hasChildren: true`, and that positive hint survives an older in-flight catalog response; membership, labels, mode, diagnostics, and the authoritative snapshot still require a debounced `subagent.list` refresh while the affected branch is open. A prompt response remains delivery-time authority. diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md index 0f4f646748..30c41f0370 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md @@ -16,6 +16,8 @@ UI 还必须保留[持久化目录](../../archived/feature/2026-07-22-durable-su Web 产品通过页头的当前 title 谱系区域公开选中会话中由会话支撑的直接 subagent。用户可以懒加载展开后代目录,并在现有对话区域中打开任一 mode。one-shot child 永久只读。可继续 child 只有在其确切直接 parent agent 存活时才接受用户后续消息;否则,其持久化 transcript 仍然可读,并附带恢复说明。 +同一个页头行还可以把 child 作为 `dsh-resource://chat/session/?parent=&mode=` 在右侧 Sidebar 打开。打开时优先使用独立分栏;无法分栏时回退到当前分栏。Sidebar tab 使用共享 Conversation Component Factory 渲染并省略宽度控制,因此主对话与嵌入式对话共用一套组装,而不共享布局界面。 + 每个打开的 child 都携带目录派生地址 `{ parentSessionId, childSessionId, mode }`。选择专用历史与提示词传输的是包含 mode 的地址,而不是谱系或粗粒度 origin 标记。历史操作会从持久化存储读取会话,而不触发激活。可继续提示词通过 `subagent.prompt` 携带 Queue 或 Steer 投递,并在 inbox 接受消息时以 `{ messageId }` 成功返回;它不会公开 Activation、等待完成或返回结果。相邻 Agent 的模型消息使用单独拥有的固定 Steer 操作。 通用 Host 领域遵守同一所有权边界。`session.history` 与 `session.fork` 的源端会读取已附加 Session 或检查持久化存储,而不获取 Agent;history 从所检查的确切前缀归并冷态投影值,fork 则发布一个普通的独立会话。绑定到 Agent 的通用会话、命令与目标路由会对由会话支撑的 subagent 返回 `agent-busy`;显式 id 的 `session.create` 接纳与仅针对已附加会话的队列控件亦然。拒绝分类器接受粗粒度 `origin` 标记、会话自身后缀中的 `subagent/descriptor`,或 parent 对其确切的存活运行时所有权;这些信号只会阻止通用路径取得所有权,绝不取代目录 mode 或直接 parent 授权。 @@ -37,7 +39,7 @@ Figma 中的 [subagent 列表](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5 ## 产品约定 -只有当完整的直接目录空响应与会话摘要投影相符,二者均表明没有已知的 subagent 后代时,才不显示后代数量控件。普通会话会用斜杠分隔当前 title 和该数量控件。每一级 subagent 面包屑都将紧凑的 12px title 与固定显示的双向箭头组合成一个控件:当前面包屑使用主标签颜色和 500 字重,祖先则使用三级标签颜色和 400 字重。悬停组合控件 150ms 后会打开其直接 parent 目录;浮层菜单具有短暂的跨越宽限时间,ArrowDown 保留为键盘入口。点击祖先会取消待触发的悬停、关闭已打开的目录,并且只向上导航。每个直接 parent 目录都可切换 sibling,并会加粗其选中行;目录 label 优先于会话摘要 title,缺失的每个切换器目录都会自动刷新。只有当前 subagent 会追加自己的后代数量控件。过长的 title 会在固定箭头之前截断。数量触发器会统计经不间断的 `origin: 'subagent'` 谱系可达的每个已知会话摘要后代,在普通 fork 处停止,并在任一计入统计的后代处于 `running` 时显示活动仍在进行。由于普通侧边栏行会隐藏 origin 为 subagent 的会话,Workspace 浏览器会在每个可见的普通行上索引同一条不间断谱系:任何运行中的后代都会让该行显示蓝色活动指示器,并在悬停与无障碍文本中给出确切数量,同时不会把空闲 parent 描述为正在运行。普通 fork 会开启单独的聚合子树。待处理交互优先于 parent 的运行中状态;二者无论哪一项存在都会保持为主要状态,而后代活动则成为悬停与无障碍状态中的第二项。两者均不存在时,后代活动优先于未查看的完成提醒;最后一个运行中的后代停止后,该提醒会恢复。每个健康的直接目录行都携带读取时的 `hasChildren` 提示,该值只根据持久化 `origin: 'subagent'` 的直接谱系 header 派生;正常的健康与 diagnostic subagent 候选都会携带该标记,而普通 fork 不会。该预查不读取任何后代事件日志,展开后仍以描述符支撑的目录为权威依据。当摘要在该目录尚不存在时或在一次陈旧的空响应后确认已有后代时,该操作会保持可见,并且在打开它以刷新目录之前仅显示禁用的加载行;仅由摘要支撑的行绝不会提供导航能力。UI 会在交互前就省略已知叶子节点的展开控件;该提示不承诺 child 会一直是叶子。已展开的直接目录加载期间,已知谱系会为每个直接后代预留一行禁用的加载行,而不会递归获取后代目录。随后树会呈现可继续与 one-shot 行;one-shot 的可选 label 缺失时,回退到其会话 id。损坏、不受支持或不可用的候选仍以禁用的 diagnostic 行显示。 +只有当完整的直接目录空响应与会话摘要投影相符,二者均表明没有已知的 subagent 后代时,才不显示后代数量控件。普通会话会用斜杠分隔当前 title 和该数量控件。每一级 subagent 面包屑都将紧凑的 12px title 与固定显示的双向箭头组合成一个控件:当前面包屑使用主标签颜色和 500 字重,祖先则使用三级标签颜色和 400 字重。悬停组合控件 150ms 后会打开其直接 parent 目录;浮层菜单具有短暂的跨越宽限时间,ArrowDown 保留为键盘入口。点击祖先会取消待触发的悬停、关闭已打开的目录,并且只向上导航。每个直接 parent 目录都可切换 sibling,并会加粗其选中行;目录 label 优先于会话摘要 title,缺失的每个切换器目录都会自动刷新。只有当前 subagent 会追加自己的后代数量控件。过长的 title 会在固定箭头之前截断。数量触发器会统计经不间断的 `origin: 'subagent'` 谱系可达的每个已知会话摘要后代,在普通 fork 处停止,并在任一计入统计的后代处于 `running` 时显示活动仍在进行。由于普通侧边栏行会隐藏 origin 为 subagent 的会话,Workspace 浏览器会在每个可见的普通行上索引同一条不间断谱系:任何运行中的后代都会让该行显示共享的三级灰色 ongoing loading,并在悬停与无障碍文本中给出确切数量,同时不会把空闲 parent 描述为正在运行。普通 fork 会开启单独的聚合子树。待处理交互优先于 parent 的运行中状态;二者无论哪一项存在都会保持为主要状态,而后代活动则成为悬停与无障碍状态中的第二项。普通 Workspace 行存在待处理的审批、计划审阅或问题时,会用紧凑的「待批准」「计划待审」或「待回答」替换相对时间;悬停与无障碍详情仍保留完整状态和相对时间。两者均不存在时,后代活动优先于未查看的完成提醒;最后一个运行中的后代停止后,该提醒会恢复。每个健康的直接目录行都携带读取时的 `hasChildren` 提示,该值只根据持久化 `origin: 'subagent'` 的直接谱系 header 派生;正常的健康与 diagnostic subagent 候选都会携带该标记,而普通 fork 不会。该预查不读取任何后代事件日志,展开后仍以描述符支撑的目录为权威依据。当摘要在该目录尚不存在时或在一次陈旧的空响应后确认已有后代时,该操作会保持可见,并且在打开它以刷新目录之前仅显示禁用的加载行;仅由摘要支撑的行绝不会提供导航能力。UI 会在交互前就省略已知叶子节点的展开控件;该提示不承诺 child 会一直是叶子。已展开的直接目录加载期间,已知谱系会为每个直接后代预留一行禁用的加载行,而不会递归获取后代目录。随后树会呈现可继续与 one-shot 行;one-shot 的可选 label 缺失时,回退到其会话 id。损坏、不受支持或不可用的候选仍以禁用的 diagnostic 行显示。 `running` 表示在 Host 采样边界,确切 child Agent driver 正在处理工作;`inactive` 表示该 driver 空闲或不存在。UI 不会把任一值解释为成功、失败、取消、完成状态或可恢复性。`subagent.list` 提供当前 driver 状态基线,`host/session-status` 会就地更新已知活动状态,请求内回放会阻止更早发起但尚未完成的列表响应覆盖较新的状态转换,`host/session-removed` 则会使已知行恢复为 `inactive`;重连时会读取新的基线。直接 subagent 的 `host/session-added` 帧会立即把任何已加载的 parent 行翻转为 `hasChildren: true`,并使这项正向提示不被更早发起但尚未完成的目录响应覆盖;受影响分支打开期间,成员、label、mode、diagnostic 与权威快照仍需要通过去抖动的 `subagent.list` 刷新来更新。消息投递时仍以提示词响应为权威依据。 diff --git a/.agents/notes/implemented/feature/2026-08-03-web-sticky-collapsible-headers.i18n.yaml b/.agents/notes/implemented/feature/2026-08-03-web-sticky-collapsible-headers.i18n.yaml deleted file mode 100644 index 9ae953b44a..0000000000 --- a/.agents/notes/implemented/feature/2026-08-03-web-sticky-collapsible-headers.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# 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-08-03-web-sticky-collapsible-headers.md -2026-08-03-web-sticky-collapsible-headers.md: 5bbe91681e656a8278b9d65f12e20a4d9dc779d0 -2026-08-03-web-sticky-collapsible-headers.zh.md: c75846fa7f5ca1088dab530b63243553e50f9579 diff --git a/.agents/notes/implemented/feature/2026-08-03-web-sticky-collapsible-headers.md b/.agents/notes/implemented/feature/2026-08-03-web-sticky-collapsible-headers.md deleted file mode 100644 index 5bbe91681e..0000000000 --- a/.agents/notes/implemented/feature/2026-08-03-web-sticky-collapsible-headers.md +++ /dev/null @@ -1,41 +0,0 @@ -# Agent Note: Web sticky collapsible headers — Think and compaction toggles pin while scrolling - -Status: implemented - -English | [中文](2026-08-03-web-sticky-collapsible-headers.zh.md) - -## Problem - -Two conversation blocks render their expanded body uncapped, flowing with the page instead of scrolling inside a bounded surface: the Web Think row (`.thinkBody`) and the compaction marker (`.compactionBody`). Every other tool row caps its body and scrolls it inside its own card, so its disclosure header stays visible. The two uncapped blocks do not. A long chain of thought or a long compaction summary carries its own disclosure header off the top of the viewport, so a reader who wants to collapse the block again must scroll the full body back up to reach the toggle. - -## Decision - -The disclosure header of each uncapped block sticks to the conversation scroll container's top while the block is open. The header remains in normal flow when collapsed, so a collapsed block scrolls away like any other row. - -Both blocks already scroll against the shared conversation scroll container (`[data-conversation-scroll]`), not an inner box, so `position: sticky; top: 0` on the header pins it against that container. A base-token background masks the prose that scrolls under the pinned header. - -The pinned header's stacking rank differs by block, because their bodies differ. The Think body uses [compact Markdown](../bug-fix/2026-09-17-thinking-markdown.md) with code banners in normal flow, so `z-index: 1` suffices. The compaction body renders markdown, and a fenced code block in the summary pins its own banner at `z-index: 6` (`packages/client/ui-primitives/src/markdown/CodeBlock.module.css`) with a Copy control inside it; the compaction header therefore uses `z-index: 7` and holds that banner below its own band, so the header never covers the Copy control and the banner never covers the toggle. The band's height is one component-local measurement (`--dsh-compaction-header-height` on `.compactionRow`) shared by the toggle's `height` and the banner's `top`; the banner rule wins over CodeBlock's `top: 0` by specificity, because the two sheets load in their own packages. The pinned header also overrides its hover fill to the opaque `--dsw-alias-interactive-bg-hover-solid` token and squares its corners for as long as the row is open: the default translucent hover token would let the scrolling prose show through the moment the pointer lands on the toggle, and the base 6px radius would leave the same prose visible at the corners. `:hover` raises the rule's specificity above the later base hover rule, so declaration order does not decide the winner. Two other elements take `z-index: 7` to clear the same code banners: the composer seat and the turn-navigation rail slot (`TurnNavigator.module.css`, ui-chat). Their order among equal ranks is stated once, in the composer seat's comment (`packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css`). - -The rules are scoped so only the two uncapped blocks are affected; the capped tool rows keep their existing behavior, since stacking sticky headers across a run of tool rows would pile them at the top. The Think rule is `packages/client/ui-chat/src/client/chat/ReasoningRow.module.css` `.root[data-expanded] [data-open] [data-disclosure-row]` — gated on `DisclosureRow`'s `data-open` so a collapsed Think row never sticks, and scoped under the Think row's own root so no tool-call variant is touched. The compaction rule is `packages/client/ui-chat/src/client/chat/MessageItem.module.css` `.compactionRow:has(.compactionBody) .compactionButton` — the body sibling exists in the DOM only while open, so `:has()` gates the stick on the open state. - -No session, wire, durable event, or model-visible contract changes; this is a presentation-only CSS change owned by the existing components. - -## Alternatives considered - -**Cap the two bodies with `max-height` + internal scroll, matching the tool cards.** Rejected: the Think body is deliberately uncapped so reasoning reads as ordinary message prose ([web-thinking-tail-scroll](../../archived/feature/2026-08-02-web-thinking-tail-scroll.md) and the `.thinkBody` comment own that intent), and the compaction summary is a reading surface. An inner scrollport introduces nested scrolling — the wheel switches from page to box under the cursor — and compresses long technical prose into a small window that is worse to read. Sticky headers keep the flowing-prose reading model and still keep the toggle reachable. - -**Add sticky headers to every collapsible row for consistency.** Rejected: the capped tool rows already keep their header visible because their body scrolls internally, so they have no problem to solve. Making their headers sticky against the page would stack one pinned header per open row at the top of the viewport during a scroll through a run of tool calls, which is visual noise, not consistency. - -**Offset the summary's code banner from `CodeBlock` rather than from the compaction rule.** Rejected: `CodeBlock` could read a property such as `--dsl-code-block-banner-top` that this consumer sets, which would leave the code block the owner of its own geometry and avoid a `:has()` selector reaching into another package's DOM. It also routes every consumer's layout through a property only this block sets, and the offset is this block's presentation concern rather than the code block's: the local rule states the offset beside the toggle that creates it, and `[data-code-block-banner]` is the hook `CodeBlock` already publishes for owner styling. - -**A shared sticky rule on the `DisclosureRow` primitive.** Rejected: `DisclosureRow` backs Think, every tool-call variant, and the context-injection row; a rule there would hit the capped rows too. The behavior belongs only to the uncapped consumers, so each scopes the rule to its own block. - -## Consequences - -The collapse toggle for a long Think block or compaction summary stays reachable without scrolling the body back to its start, while both bodies keep flowing as page prose. The change is CSS-only: no timer, subscription, durable state, DOM structure change, or transport traffic. The compaction rule uses the CSS `:has()` selector, supported across the browsers the Web UI targets. - -## Testing - -The unit specs pin the DOM anchors the selectors key on: `packages/client/ui-chat/tests/reasoning-row.client.spec.tsx` asserts an open Think row nests `[data-disclosure-row]` under `[data-variant='think'][data-expanded] [data-open]` and that a collapsed row has no `[data-open]`; `packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx` asserts the compaction body appears under `.compactionRow` only while open. `packages/client/ui-chat/tests/sticky-header-styles.client.spec.ts` reads the rules as CSS text and pins the declarations the pinning depends on: `position: sticky`, `top: 0`, the square corners, the `z-index` rank of each side, the single measurement shared by the toggle's height and the code banner's offset, and the opaque hover token, because jsdom computes no sticky layout and the render specs cannot fail on a changed declaration. - -The real-browser evidence is two keyless Chromium e2e paths. `apps/web/tests/lifecycle-chrome.e2e.ts` expands the settled turn's process row, opens the Think row, and asserts its header computes `position: sticky`, `top: 0px` (and is not sticky while collapsed); that fixture's recorded reasoning is a single line, too short to overflow, so it proves only that the CSS resolves onto the Think header. `apps/web/tests/seeded-history.e2e.ts` carries the pinned-while-scrolling evidence: it seeds a compaction whose summary length this suite controls (a fenced code block plus 40 list items), shrinks the viewport to force overflow, scrolls the marker into its pinned state, and asserts the header computes `position: sticky`, `top: 0px`, a `z-index` greater than the code block banner's, that it holds at the scrollport top after the scroll, that its own center is the topmost hit-tested element (the toggle stays clickable), and that its hover fill stays fully opaque (alpha 1). The same case scrolls the summary's code banner into its own stuck position and asserts the banner stops below the toggle's band and that the banner's Copy control owns its center, so the offset cannot regress into a covered control. The case also writes a keyless geometry golden (`snapshots/web/seeded-history/sticky-geometry.expected.md`) fixing these platform-independent semantic facts, since this is a user-visible CSS behavior that changes no DOM and no accessible name, so the aria goldens cannot capture it. The PR demo GIF, recorded against a real server and a real model round, carries the visual evidence that the pinned header stays at the top while the body scrolls. diff --git a/.agents/notes/implemented/feature/2026-08-03-web-sticky-collapsible-headers.zh.md b/.agents/notes/implemented/feature/2026-08-03-web-sticky-collapsible-headers.zh.md deleted file mode 100644 index c75846fa7f..0000000000 --- a/.agents/notes/implemented/feature/2026-08-03-web-sticky-collapsible-headers.zh.md +++ /dev/null @@ -1,41 +0,0 @@ -# Agent Note:Web 可折叠块的钉住标题 —— Think 与压缩标记的折叠按钮在滚动时钉住 - -Status: implemented - -[English](2026-08-03-web-sticky-collapsible-headers.md) | 中文 - -## 问题 - -会话里有两个块的展开正文不封顶,随整页滚动,而不是在有界的表面内部滚动:Web Think 行(`.thinkBody`)和压缩标记(`.compactionBody`)。其他每个工具行都给正文封顶并在自己的卡片内部滚动,所以折叠标题始终可见。这两个不封顶的块做不到。一段很长的思维链或很长的压缩摘要会把自己的折叠标题顶出视口上方,想再次折叠该块的读者必须把整段正文滚回顶部才能够到折叠按钮。 - -## 决策 - -每个不封顶块的折叠标题在块展开时钉在会话滚动容器的顶部。折叠时标题保持在正常文档流中,所以折叠的块会像其他行一样滚走。 - -这两个块本来就是相对共享的会话滚动容器(`[data-conversation-scroll]`)滚动,而非某个内层框,所以在标题上加 `position: sticky; top: 0` 就把它钉在该容器上。一个 base token 背景遮住在钉住的标题下方滚过的正文。 - -钉住的标题的层叠级别按块而异,因为两者正文不同。Think 正文使用[紧凑 Markdown](../bug-fix/2026-09-17-thinking-markdown.zh.md),代码栏处于正常文档流中,`z-index: 1` 就够。压缩正文渲染 markdown,摘要里的围栏代码块会把自己的 banner 钉在 `z-index: 6`(`packages/client/ui-primitives/src/markdown/CodeBlock.module.css`),banner 里带一个 Copy 控件;因此压缩标题用 `z-index: 7`,并让那个 banner 停在自己的标题带下方,这样标题永远不会盖住 Copy 控件,banner 也不会盖住折叠按钮。标题带高度是一个组件局部量(`.compactionRow` 上的 `--dsh-compaction-header-height`),由折叠按钮的 `height` 与 banner 的 `top` 共用;banner 规则靠特异性压过 CodeBlock 的 `top: 0`,因为两张样式表分属不同的包。钉住的标题还把 hover 底覆盖为不透明的 `--dsw-alias-interactive-bg-hover-solid` token,并在整行展开期间都保持直角:默认的半透明 hover token 会在指针落到折叠按钮准备折叠的瞬间让滚动的正文透出,而基础的 6px 圆角会让同样的正文在四角露出来。`:hover` 把该规则的特异性抬到文件更靠后的 hover 基础规则之上,所以胜负不由声明顺序决定。另外还有两个元素为了避开同一批代码 banner 取 `z-index: 7`:输入框座与轮次导航轨道槽(`TurnNavigator.module.css`,ui-chat)。同级之间的先后顺序只写在一处:`packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css` 中输入框座的注释。 - -规则被限定作用域,只影响这两个不封顶的块;封顶的工具行保持原有行为,因为让一连串工具行的 sticky 标题层层堆叠会把它们全挤在顶部。Think 规则是 `packages/client/ui-chat/src/client/chat/ReasoningRow.module.css` 的 `.root[data-expanded] [data-open] [data-disclosure-row]`,用 `DisclosureRow` 的 `data-open` 门控,折叠的 Think 行绝不钉住,并限定在 Think 行自己的 root 之下,不触及任何工具调用 variant。压缩规则是 `packages/client/ui-chat/src/client/chat/MessageItem.module.css` 的 `.compactionRow:has(.compactionBody) .compactionButton`,正文兄弟节点只在展开时存在于 DOM,所以 `:has()` 就以展开状态门控钉住。 - -不改动任何 session、wire、durable event 或 model-visible 契约;这是一处纯展示层的 CSS 改动,由既有组件拥有。 - -## 曾考虑的替代方案 - -**用 `max-height` 加内部滚动给这两个正文封顶,与工具卡片一致。** 否决:Think 正文是刻意不封顶的,好让推理读起来像普通消息正文([web-thinking-tail-scroll](../../archived/feature/2026-08-02-web-thinking-tail-scroll.md) 和 `.thinkBody` 注释拥有这一意图),压缩摘要是一个阅读表面。内层滚动框会引入嵌套滚动,滚轮在光标下从整页切换到框内,并把很长的技术性正文压进一个更难读的小窗口。钉住标题保留了流式正文的阅读模型,同时让折叠按钮依然够得着。 - -**为一致性给每个可折叠行都加钉住标题。** 否决:封顶的工具行因为正文在内部滚动,标题本来就一直可见,没有需要解决的问题。让它们的标题相对整页钉住,会在滚过一连串工具调用时把每个展开行各自钉住的标题堆叠在视口顶部,这是视觉噪音,不是一致性。 - -**把摘要内代码栏的偏移交给 `CodeBlock` 承担。** 否决:可以让 `CodeBlock` 读取使用方设置的 `--dsl-code-block-banner-top` 之类的属性,这样代码块继续拥有自己的几何,也避免用 `:has()` 选择器伸进另一个包的 DOM。代价是把所有使用方的布局都接到一个只有这个块会设置的属性上,而这段偏移是这个块的呈现问题、不是代码块的:局部规则把偏移写在造成它的折叠按钮旁边,而 `[data-code-block-banner]` 本来就是 `CodeBlock` 为使用者样式发布的钩子。 - -**在 `DisclosureRow` 基元上加一条共享的 sticky 规则。** 否决:`DisclosureRow` 支撑 Think、每个工具调用 variant 以及 context-injection 行;在那里加规则会一并命中封顶行。该行为只属于不封顶的消费者,所以各自把规则限定在自己的块上。 - -## 后果 - -很长的 Think 块或压缩摘要的折叠按钮无需把正文滚回起点就能够到,同时两个正文都保持作为整页正文流动。改动是纯 CSS:没有计时器、订阅、durable state、DOM 结构改动或传输流量。压缩规则使用 CSS `:has()` 选择器,Web UI 所面向的各浏览器均支持。 - -## 测试 - -单元测试钉住选择器所依赖的 DOM 锚点:`packages/client/ui-chat/tests/reasoning-row.client.spec.tsx` 断言展开的 Think 行在 `[data-variant='think'][data-expanded] [data-open]` 之下嵌套了 `[data-disclosure-row]`,且折叠行没有 `[data-open]`;`packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx` 断言压缩正文只在展开时出现在 `.compactionRow` 之下。`packages/client/ui-chat/tests/sticky-header-styles.client.spec.ts` 把这两条规则当作 CSS 文本读取,逐条固定钉住所依赖的声明:`position: sticky`、`top: 0`、直角圆角、两侧各自的 `z-index` 级别、折叠按钮高度与代码 banner 偏移共用的那一个量,以及不透明的 hover token——因为 jsdom 不计算 sticky 布局,渲染类测试也无法在某条声明被改动时变红。 - -真实浏览器证据由两条 keyless Chromium e2e 路径承载。`apps/web/tests/lifecycle-chrome.e2e.ts` 展开已结束轮次的 process 行、展开 Think 行,并断言其标题计算出 `position: sticky`、`top: 0px`(折叠时非 sticky);该 fixture 录制的 reasoning 只有一行,太短不足以溢出,所以它只证明 CSS 解析到了 Think 标题。`apps/web/tests/seeded-history.e2e.ts` 承载「钉住态随滚动」的证据:它种入一个摘要长度由本套件控制的压缩(一个围栏代码块加 40 个列表项),把视口压小以强制溢出,滚动到 marker 的钉住态,断言标题计算出 `position: sticky`、`top: 0px`、`z-index` 大于代码块 banner、滚动后仍停在滚动口顶边、其自身中心是命中测试命中的最上层元素(折叠按钮保持可点击),以及其 hover 底保持完全不透明(alpha 1)。同一用例还把摘要里的代码 banner 滚到它自己的钉住位置,断言 banner 停在标题带下方、且 banner 的 Copy 控件在它的中心点命中自身,使这条偏移不会退化成控件被盖住。同一用例还写出一份 keyless 几何 golden(`snapshots/web/seeded-history/sticky-geometry.expected.md`),把这些与平台无关的语义事实固定下来,因为这是一处用户可见、但不改动 DOM 与无障碍名称的 CSS 行为,无障碍 golden 捕获不到它。PR demo GIF 用真实服务器加真实模型轮次录制,承载视觉证据:钉住的标题在正文滚动时停留在顶部。 diff --git a/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml b/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml index ab88dedfc2..b0104c7e52 100644 --- a/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-05-agent-teams.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-08-05-agent-teams.md -2026-08-05-agent-teams.md: bdf646db9b1617ded7f50f4145887f34a52ae83c -2026-08-05-agent-teams.zh.md: 5386044edb57dad9fabf114d565808c735846464 +2026-08-05-agent-teams.md: d108d4f9833aca2a24c84d18d895e7ec63c0bfc2 +2026-08-05-agent-teams.zh.md: abf2ce3716e44effc8e34c0911cd9561d815aa08 diff --git a/.agents/notes/implemented/feature/2026-08-05-agent-teams.md b/.agents/notes/implemented/feature/2026-08-05-agent-teams.md index bdf646db9b..d108d4f983 100644 --- a/.agents/notes/implemented/feature/2026-08-05-agent-teams.md +++ b/.agents/notes/implemented/feature/2026-08-05-agent-teams.md @@ -40,7 +40,7 @@ Fresh children have no inherited conversation. Fork children capture the Lead's Peer communication is a Lead-log mailbox. `team/message/queued` is appended and flushed before delivery. The target message carries the stable message id and sender identity in both durable source metadata and a short model-visible prefix. A target receipt is acknowledged with `team/message/delivered` only after its pending inbox item or recorded user message is flushed. Immediate admission is serialized per target in queued-log order, recovery retries queued-minus-delivered in the same order, and delivery folds live or persisted target inbox/history state before cold resume. Every current-version Team payload is runtime-validated before entering replay state. The Team runtime tracks dispatch and asynchronous acknowledgement work from synchronous admission until settlement; disposal closes admission and awaits both before removing the service. Current waiters wake only after the owning Team event flush succeeds. -`send_message` always attempts Steer delivery. A running target receives the message at the nearest step boundary, an idle target starts a turn, and an inactive teammate cold-resumes. Success means the message is already durable even when temporary delivery failure leaves it queued. The mechanism provides process-local retry and target-Session de-duplication, not a cross-process exactly-once claim. The [Team Steer messaging decision](../../archived/simplification/2026-08-30-team-send-message-steer.md) owns the single-tool scheduling rationale. +`send_message` always attempts Steer delivery. A running target receives the message at the nearest step boundary; an inactive target starts a turn if loaded or cold-resumes otherwise. Success means the message is already durable even when temporary delivery failure leaves it queued. The mechanism provides process-local retry and target-Session de-duplication, not a cross-process exactly-once claim. The [Team Steer messaging decision](../../archived/simplification/2026-08-30-team-send-message-steer.md) owns the single-tool scheduling rationale. Shared tasks are complete snapshots with Team-local ids and monotonic revisions. Every mutation carries `expectedRevision`. Any member creates, reads, or claims a ready unowned task; the owner or Lead edits and transitions it, while only the Lead assigns another member. Numeric task ids remain within the safe-integer allocation range, and exhaustion fails without reusing an id. Dependencies must name non-deleted tasks and form a complete DAG. Deleted tasks are retained tombstones. `writeScopes` are normalized path prefixes that produce overlap diagnostics but never block claim or authorize a write. @@ -72,6 +72,8 @@ Worktree isolation is not a harness runtime behavior. A deployment or prompt may Package tests cover identity, name and authority checks, provider selection, reserved-id persistence collisions, child-before-Lead flush ordering, durable provisioning failure and pending-inbox JSONL reconciliation, concurrent target-local ordering, pending/history de-duplication, mailbox limits, post-flush notification, bounded disposal with in-flight creation and dispatch cancellation, failed-member cleanup, task CAS and DAG validation, write-scope warnings, wait cancellation/timeout, inbox-preserving interruption, ordinary-fork isolation, legacy-control shadowing, compact declared-schema result rendering, and scoped registration HMR at per-file 100% coverage. A keyless product snapshot loads the Agent Teams profile bundle through `dsh --profile headless` and pins its complete model-visible tool list, Team policy, and durable workflow projection for two teammates, dependent tasks, peer delivery, waiting, completion, and aggregation. A CLI e2e reuses the same deterministic adapter and verifies normal process exit with persisted Team and child logs. +Model-facing member identities and availability follow the [tool projection decision](../simplification/2026-09-15-model-agent-availability-and-team-targets.md); service residency and durable identity remain distinct. + ## Consequences The Lead Session grows with whole task/member snapshots and mailbox acknowledgements. This favors independently inspectable recovery over compact deltas; configured task and pending-mail bounds cap active state, while deleted and delivered history remains append-only until broader Session retention applies. diff --git a/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md b/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md index 5386044edb..abf2ce3716 100644 --- a/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md +++ b/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md @@ -40,7 +40,7 @@ fresh child 不继承对话。fork child 只捕获一次 Lead 已完成 turn 前 Peer 通讯使用 Lead 日志 mailbox。投递前先追加并 flush `team/message/queued`。target message 会在持久 source metadata 与短模型可见前缀中同时携带稳定 message id 和 sender identity。只有 pending inbox 条目或已记录用户消息完成 flush,Lead 日志才写入 `team/message/delivered` acknowledgement。即时准入按 target 和 queued 日志顺序串行化,恢复按同一顺序重试 queued-minus-delivered,并在冷恢复前折叠 live 或 persisted target 的 inbox/历史状态。每个当前版本 Team payload 都会经过运行时验证后才进入 replay state。Team runtime 从同步准入到 settlement 全程跟踪 dispatch 与异步 acknowledgement 工作;dispose 会关闭准入,并在移除服务前等待两者。当前 waiter 只在所属 Team event flush 成功后被唤醒。 -`send_message` 始终尝试 Steer 投递。running target 在最近的步骤边界收到消息,idle target 启动一个轮次,inactive teammate 则冷恢复。即使临时投递失败让消息保持 queued,成功也表示消息已经持久化。该机制提供进程内重试与 target Session 去重,不宣称跨进程 exactly-once。[Team Steer 消息决策](../../archived/simplification/2026-08-30-team-send-message-steer.md)负责单工具调度的理由。 +`send_message` 始终尝试 Steer 投递。running target 在最近的步骤边界收到消息,inactive target 在已加载时启动一个轮次,否则冷恢复。即使临时投递失败让消息保持 queued,成功也表示消息已经持久化。该机制提供进程内重试与 target Session 去重,不宣称跨进程 exactly-once。[Team Steer 消息决策](../../archived/simplification/2026-08-30-team-send-message-steer.md)负责单工具调度的理由。 共享 task 是带 Team-local id 与单调 revision 的完整快照。每次变更都携带 `expectedRevision`。任意 member 可以创建、读取或 claim ready 且无 owner 的任务;Owner 或 Lead 可以编辑和转换;只有 Lead 可以分配给另一个 member。数字 task id 保持在安全整数分配范围内;该范围耗尽时会失败,不会复用 id。依赖必须指向未删除任务,并形成完整 DAG。删除任务保留为 tombstone。`writeScopes` 是规范化路径前缀,只产生重叠诊断,绝不会阻止 claim 或授予写权限。 @@ -72,6 +72,8 @@ Worktree isolation 不是 harness runtime 行为。deployment 或 prompt 可以 Package test 以逐文件 100% coverage 覆盖身份、名字与权限检查、provider 选择、预留 id 持久化冲突、child-before-Lead flush 顺序、持久 provisioning 失败与 pending-inbox JSONL 对账、target-local 并发顺序、pending/history 去重、mailbox 限额、flush 后 notification、取消在途创建与 dispatch 的有界 dispose、failed member cleanup、task CAS 与 DAG 校验、write-scope warning、wait cancel/timeout、保留 inbox 的 interrupt、普通 fork 隔离、旧 control shadowing、声明 schema 的紧凑结果渲染与 scoped registration HMR。一条 keyless 产品快照会通过 `dsh --profile headless` 加载 Agent Teams profile bundle,并为两个 teammate、依赖任务、peer 投递、等待、完成和汇总固定完整的面向模型工具列表、Team policy 与持久 workflow 投影。CLI e2e 会复用同一个确定性 adapter,并验证带持久 Team 与 child 日志的正常退出。 +模型可见的成员身份与可用状态遵循[工具投影决策](../simplification/2026-09-15-model-agent-availability-and-team-targets.zh.md);服务驻留状态和持久身份仍保持区分。 + ## Consequences Lead Session 会随着完整 task/member 快照与 mailbox acknowledgement 增长。该设计用可独立检查的恢复能力换取更紧凑的 delta;配置的 task 与 pending-mail 限额限制 active state,而 deleted 与 delivered 历史会保持 append-only,直到更广泛的 Session retention 生效。 diff --git a/.agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.i18n.yaml b/.agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.i18n.yaml index c79578f47b..a8b84e3d55 100644 --- a/.agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.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-08-06-manager-owned-subagent-settlement-delivery.md -2026-08-06-manager-owned-subagent-settlement-delivery.md: 5553d4c06a6776f0ef025e19ae7f4f67fe277a56 -2026-08-06-manager-owned-subagent-settlement-delivery.zh.md: 6d7ee756ad531e123508e8667adc4e5f99c2b9ea +2026-08-06-manager-owned-subagent-settlement-delivery.md: 7f6cfc90862b5bb32368b4b0e1fbe068ba0cbf66 +2026-08-06-manager-owned-subagent-settlement-delivery.zh.md: 23c5506f79d4d95308b0533e8d507ac856c4d85e diff --git a/.agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.md b/.agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.md index 5553d4c06a..7f6cfc9086 100644 --- a/.agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.md +++ b/.agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.md @@ -90,7 +90,7 @@ The refusal and interruption wordings are pinned verbatim in unit tests rather t **Filter the canonical child output.** Removing nontext blocks in `AssistantOutputFold` would discard assistant content needed by SDK and UI consumers. Only the parent notice requires a text projection. -**Relax the Messages serializer.** Accepting or silently discarding invalid user-role blocks would conceal the producer's role conversion error. Notice construction supplies content that every parent provider can represent while protocol validation remains strict. +**Rely on serializer tolerance for notice construction.** Notice construction supplies text that every parent provider can represent; adapter filtering cannot replace that producer responsibility. The [saved-input compatibility decision](../bug-fix/2026-09-18-messages-input-history-compatibility.md) supersedes the rejection of Messages input filtering for already recorded notices, which a producer fix cannot change. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.zh.md b/.agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.zh.md index 6d7ee756ad..23c5506f79 100644 --- a/.agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.zh.md +++ b/.agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.zh.md @@ -90,7 +90,7 @@ Status: implemented **过滤规范的子级输出。** 在 `AssistantOutputFold` 中删除非文本块,会丢弃 SDK 和 UI 消费方需要的 assistant 内容。只有父级通知需要文本投影。 -**放宽 Messages 序列化器。** 接受或静默丢弃用户角色中的无效块,会掩盖生产方的角色转换错误。通知构建提供每种父级提供方都能表示的内容,协议校验仍保持严格。 +**依赖序列化器容错来构建通知。** 通知构建提供每种父级提供方都能表示的文本;适配器过滤不能替代生产方的责任。[已保存输入兼容决策](../bug-fix/2026-09-18-messages-input-history-compatibility.zh.md)取代了对已记录通知进行 Messages 输入过滤的拒绝,因为生产方修复无法改变这些通知。 ## 后果 diff --git a/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.i18n.yaml b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.i18n.yaml index 56fb8a76d2..d5f37e0569 100644 --- a/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.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-08-26-generic-file-upload.md -2026-08-26-generic-file-upload.md: 6d0d2901b14c44930b4342292d317ebe03705730 -2026-08-26-generic-file-upload.zh.md: 2d78811c24ba1a801d43f21f4e63db5646b0431b +2026-08-26-generic-file-upload.md: ddaf92a396ade59d65a81300531be9bce2aeeaba +2026-08-26-generic-file-upload.zh.md: 27a73536ec3632f40ee49a59d932406e77b5591d diff --git a/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.md b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.md index 6d0d2901b1..ddaf92a396 100644 --- a/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.md +++ b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.md @@ -48,3 +48,7 @@ Default mode, active Plan Mode, and an active goal share ordinary queue, follow- ## Testing Unit and integration coverage pins verbatim and streamed storage, name sanitization, a declared 2.19 GiB request passing through the streaming bridge without aggregate buffering, raw and RPC wire admission, Blob and transferable `ReadableStream` carriers, bounded background-upload concurrency, progress and cancellation, cross-Session upload residency, command file-receipt submission without a second byte read, native and PTC projection under read-only and workspace-write permissions, upload staging, ordered queue and steer submission, queue-to-steer conversion, pending echo retirement and failure recovery, active plan and goal intake, `/plan` and `/goal` mixed attachments, subagent refusal, composer and Chat attachment layouts, and Trajectory file summaries. The keyless `file-upload-round` snapshot records the browser upload, model file read, and rendered answer. + +## Related + +The [Trajectory attachment presentation decision](../bug-fix/2026-09-15-trajectory-attachment-presentation.md) owns ledger counts and inspector attachment layouts. diff --git a/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.zh.md b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.zh.md index 2d78811c24..27a73536ec 100644 --- a/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.zh.md +++ b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.zh.md @@ -48,3 +48,7 @@ Status: implemented ## 测试 单元与集成覆盖原样与流式存储、文件名清洗、声明为 2.19 GiB 的请求通过流式 bridge 且不发生总量缓冲、原始字节与 RPC wire 准入、Blob 与可转移 `ReadableStream` 载体、后台上传并发上限、进度与取消、跨 Session 上传常驻、命令通过文件凭证提交且不再次读取字节、read-only 与 workspace-write 权限下的 native 和 PTC 投影、上传暂存、有序 queue 与 steer 提交、队列转 steer、pending 回显退休与失败恢复、active plan 和 goal 的附件入口、`/plan` 与 `/goal` 混合附件、子代理拒绝、composer 与 Chat 附件布局,以及 Trajectory 文件摘要。keyless `file-upload-round` 快照记录浏览器上传、模型读取文件与答案呈现。 + +## 相关记录 + +[Trajectory 附件展示决定](../bug-fix/2026-09-15-trajectory-attachment-presentation.zh.md)负责列表计数与详情面板附件布局。 diff --git a/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.md b/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.md deleted file mode 100644 index c4e506294c..0000000000 --- a/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.md +++ /dev/null @@ -1,35 +0,0 @@ -# Agent Note: Web clickable-link language — link alias, dotted hover underline, category glyphs - -Status: implemented - -English | [中文](2026-09-04-web-clickable-link-styles.zh.md) - -## Problem - -Clickable artifact links in the chat transcript wore four different costumes: markdown anchors and prose file mentions were business-primary blue with a solid hover underline, web search/fetch links matched that pair, produced-file chips were grey pills (label-secondary text on interactive-bg-hover, 96px max width), and workflow member links carried a resting solid underline. Nothing marked what a link opens (browser, host app, Finder, in-app view), and link color was coupled to `--dsw-alias-state-business-primary`, which also drives focus rings and state dots, so tuning link color risked unrelated surfaces. - -## Decision - -One link language across the transcript's clickable-link surfaces — markdown anchors (including reference links, mailto, and URL-promoted inline code), prose file mentions, web search source links and the fetch URL, produced-file chips, and workflow member links: - -- Color comes through a dedicated `--dsw-alias-link` alias in `design-platform.css` (light `deepseek-500`, dark `deepseek-400`), decoupled from `state-business-primary`; links render at `font-weight: 500` with no underline at rest and `underline dotted` at 3px offset on hover/focus. -- A leading category glyph — the new `LinkIcon` in ui-primitives with kinds `url`, `folder`, `code`, `image`, `document`, and `other` (paper) — renders `currentColor` only; the `url` glyph is the globe, or the destination site's own mark when its host is a known site ([known-site link marks](2026-09-16-known-site-link-marks.md)); `classifyLinkPath` folds the [shared detailed file-type classification](2026-09-08-shared-file-type-icons.md) into those six link categories, and code, web, and data extensions share the code glyph by design. Two anchor shapes carry no glyph: workflow member links (an in-app member view fits no file or URL category) and anchors wrapping only images (a badge or thumbnail — a dangling globe beside the picture leads no text). Inline glyphs sit at 1.1em with a −0.25em baseline offset; the flex-centered produced-file glyphs instead nudge 1.2px down because the 22px text box carries its glyphs below box center. -- Produced-file chips drop the grey pill and the 96px cap: plain link-blue text at natural width that shrinks with ellipsis only when the row overflows; the container-query bands still budget 96px per chip when choosing how many chips to show. -- [Compact Thinking Markdown](../bug-fix/2026-09-17-thinking-markdown.md) retains tertiary text color and a resting dotted underline to preserve secondary emphasis. -- Deliberately untouched: ToolRow's grey dotted file links, and the grey "Show in folder" action (it gains the folder glyph but keeps its grey style). -- In the same pass, the inline-code chip tint moved from `neutral-bluish-100` to `neutral-50` (dark: `neutral-800`) and gained a 0.5px l1 border. - -Coverage: a LinkIcon unit spec (one distinct glyph per kind, classification table), refreshed markdown-dom fixtures, and the `clickable-links-gallery` web e2e — one settled keyless turn rendering every clickable link form — registered in the host compiler face (`tsconfig.host.json`) like its other scaffold-importing siblings. - -## Alternatives considered - -- **Colored Word/Excel/PPT/PDF link glyphs.** Rejected: fixed brand fills break the icon set's currentColor-only rule, so those extensions fold into the single outline `document` glyph. The larger file-card primitive uses distinct current-color silhouettes instead. -- **Per-extension link icons.** Collapsed to six categories: more glyphs than the eye can parse at 14px adds noise. The 28px `FileTypeIcon` owns the more detailed file identities, and per-site marks live behind the same `url` category ([known-site link marks](2026-09-16-known-site-link-marks.md)). -- **Keeping links on `state-business-primary`.** Darker link blues (blue-600/650/700 were auditioned and reverted) would have dragged focus rings and state dots along; the dedicated alias localizes any future tuning to one line. -- **Glyphs on ToolRow path links.** Rejected: tool rows keep their quieter grey dotted affordance, and leading glyphs there would stack icons in already dense rows. - -## Consequences - -- A new clickable artifact surface should consume `--dsw-alias-link` and the LinkIcon vocabulary rather than introduce another color or underline form; the rule lives in [docs/web-styling.md](../../../../docs/web-styling.md). -- Long produced-file names take their natural width; when a row overflows, flex shrinks all chips proportionally, so several long names shrink together instead of the last one yielding first. -- mailto links currently share the `url` globe; a distinct mail category is a one-line addition if ever wanted. diff --git a/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.zh.md b/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.zh.md deleted file mode 100644 index 01258674b4..0000000000 --- a/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.zh.md +++ /dev/null @@ -1,35 +0,0 @@ -# Agent Note: Web 可点击链接语言——链接别名、hover 点状下划线、分类图标 - -Status: implemented - -[English](2026-09-04-web-clickable-link-styles.md) | 中文 - -## 问题 - -会话记录里的可点击产物链接有四套互不一致的样式:Markdown 锚点和正文文件引用是 business-primary 蓝加 hover 实线下划线,网页搜索/抓取链接与之相同,产物 chips 是灰色药丸(interactive-bg-hover 底上的 label-secondary 文字,96px 最大宽度),workflow 成员链接则带常驻实线下划线。没有任何标记说明链接点开后去哪儿(浏览器、宿主应用、Finder、应用内视图),而且链接颜色耦合在 `--dsw-alias-state-business-primary` 上——它同时驱动焦点环和状态点,调链接色会波及无关表面。 - -## 决策 - -会话记录的可点击链接表面——Markdown 锚点(含引用式链接、mailto、被提升为链接的 inline code)、正文文件引用、网页搜索来源链接与抓取 URL、产物 chips、workflow 成员链接——统一为一套链接语言: - -- 颜色经由 `design-platform.css` 中专用的 `--dsw-alias-link` 别名(亮色 `deepseek-500`,暗色 `deepseek-400`),与 `state-business-primary` 解耦;链接以 `font-weight: 500` 呈现,默认无下划线,hover/focus 时为 3px offset 的 `underline dotted`。 -- 前置分类图标——ui-primitives 新增的 `LinkIcon`,kind 为 `url`、`folder`、`code`、`image`、`document`、`other`(纸张)——只渲染 `currentColor`;`url` 图形为地球,目的地主机属于已知站点时则为该站点自己的标记([已知站点链接标记](2026-09-16-known-site-link-marks.zh.md));`classifyLinkPath` 把[共享精细文件类型分类](2026-09-08-shared-file-type-icons.zh.md)折叠进这六种链接类别,代码、网页、数据扩展名按设计共用 code 图形。两类锚点不带图标:workflow 成员链接(应用内成员视图不属于任何文件或 URL 类别)和只包图片的锚点(徽章或缩略图——图片旁悬着的地球没有可引导的文字)。行内图标为 1.1em、基线偏移 −0.25em;flex 居中的产物图标则下移 1.2px,因为 22px 文字盒的字形低于盒中心。 -- 产物 chips 去掉灰色药丸和 96px 上限:纯链接蓝文字按自然宽度展示,仅当整行溢出时才收缩出省略号;容器查询档位在决定展示几个 chip 时仍按每个 96px 预算。 -- [紧凑 Thinking Markdown](../bug-fix/2026-09-17-thinking-markdown.zh.md) 保留 tertiary 文字色和默认点状下划线,以保持次级强调。 -- 刻意不动:ToolRow 的灰色点线文件链接,以及灰色的「在文件夹中显示」操作(它获得文件夹图标但保持灰色样式)。 -- 同一批次中,inline code 底色从 `neutral-bluish-100` 换到 `neutral-50`(暗色:`neutral-800`),并新增 0.5px l1 描边。 - -覆盖:LinkIcon 单测(每个 kind 一个独立图形、分类表)、刷新后的 markdown-dom 夹具,以及 `clickable-links-gallery` web e2e——一个 settled 的 keyless 回合渲染全部可点击链接形态——像其他引 scaffold 的同类一样注册进 host 编译面(`tsconfig.host.json`)。 - -## 备选方案 - -- **彩色 Word/Excel/PPT/PDF 链接图形。** 否决:固定品牌填充违反图标集 currentColor-only 规则,因此这些扩展名并入单一的 outline `document` 图形。较大的文件卡片 primitive 改用各自不同的 current-color 轮廓。 -- **每个扩展名一个链接图标。** 收敛为六个类别:14px 下超出肉眼可分辨数量的图形只会增加噪音。28px 的 `FileTypeIcon` 拥有更精细的文件身份;按站点标记位于同一 `url` 类别之下([已知站点链接标记](2026-09-16-known-site-link-marks.zh.md))。 -- **链接继续用 `state-business-primary`。** 更深的链接蓝(试过 blue-600/650/700 又回退)会连带焦点环和状态点;专用别名把未来的调色收敛到一行。 -- **给 ToolRow 路径链接加图形。** 否决:工具行保持更安静的灰色点线示能,在已经很密的行里加前置图形会造成图标堆叠。 - -## 后果 - -- 新的可点击产物表面应消费 `--dsw-alias-link` 和 LinkIcon 词汇,而不是引入另一种颜色或下划线形态;规则记录在 [docs/web-styling.md](../../../../docs/web-styling.zh.md)。 -- 长产物文件名按自然宽度展示;整行溢出时 flex 按比例收缩所有 chip,几个长名字一起收缩,而不是最后一个先让位。 -- mailto 链接目前共用 `url` 地球图形;若将来需要独立的邮件类别,一行即可加上。 diff --git a/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.i18n.yaml b/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.i18n.yaml index a2f18c11cb..702e3bf1fc 100644 --- a/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.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-05-sidebar-text-preview-and-file-tree.md -2026-09-05-sidebar-text-preview-and-file-tree.md: 0142b325004a1fc018e944ed908d600e75f0ab0d -2026-09-05-sidebar-text-preview-and-file-tree.zh.md: d40ca8c790c9a7208a2243422c0973fc293a5915 +2026-09-05-sidebar-text-preview-and-file-tree.md: 8ddb1a336307011d8f6ce9bbe927f00642a83e60 +2026-09-05-sidebar-text-preview-and-file-tree.zh.md: 294a2b456008837882f7f82e55e8e9e435254554 diff --git a/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.md b/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.md index 0142b32500..8ddb1a3363 100644 --- a/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.md +++ b/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.md @@ -20,7 +20,7 @@ Three tab types ship with the Sidebar: the **guide** (`ui-sidebar-right`), the * The guide is what a pane shows before it holds content. Its registration is `{ id: '@deepseek-ai/dsh-client-ui-sidebar-right/guide', kind: 'guide', priority: 'builtin', title }` with no `patterns`: a guide views nothing, so it is opened by kind through `openTab` and recorded under the page address `sidebar://guide`, which is the registry's bookkeeping and never composed by a caller. The tab's title is `开始` / `Start`, captured into the layout record when the pane is seeded, so a later language change relabels the type and not tabs already open. -The body projects every registered type's `guide[]` in `order` through the registry's observable `guide()` list, so a type registering later appears without the guide knowing it. [Guide start page and stat pill refinements](2026-09-10-guide-start-page-and-stat-pill-refinements.md) owns the compass, optional descriptions, fallback glyph, and current capsule layout. Picking a capsule calls `tabActions.openTab(entry.kind, { replaceTab: true })`: the picked type opens in the guide's own tab, and the guide is gone. The guide is a doorway, not a page that stays open beside what it opened. +The body projects every registered type's `guide[]` in `order` through the registry's observable `guide()` list, so a type registering later appears without the guide knowing it. The [Sidebar README](../../../../packages/client/ui-sidebar-right/README.md#the-guide) describes the compass, optional descriptions, fallback glyph, and current capsule layout. Picking a capsule calls `tabActions.openTab(entry.kind, { replaceTab: true })`: the picked type opens in the guide's own tab, and the guide is gone. The guide is a doorway, not a page that stays open beside what it opened. The body is also the replacement seam. It renders the `sidebar.right.tab.guide` chain with the shipped guide as the chain's fallback, so a product that registers its own entry takes the whole body, and with no entry, or every entry declining, the shipped guide draws. Because the shipped guide is the fallback and not a chain entry, there is always exactly one body and it cannot be outvoted by accident. @@ -48,7 +48,7 @@ A failed read keeps content already shown and adds a localized failure with a re ### The file tree -`files` is a page type, not a viewer: it claims no address. Its registration is `{ kind: 'files', id: '@deepseek-ai/dsh-client-ui-sidebar-files', priority: 'builtin', title, guide: [{ order: 10, title, description, icon: FolderSheetGlyph }] }` — no `patterns`, because nothing navigates *to* a file tree by address; the guide's entry box opens the type itself. `FolderSheetGlyph` adapts the shared coloured folder sheet to the guide's requested glyph size; the [guide refinement](2026-09-10-guide-start-page-and-stat-pill-refinements.md) owns that presentation choice. `id` is the implementation's identity in the Tab system and doubles as the `key` of the body seat `sidebar.right.pane.tab`, so the same string names the type and the component that draws it. `register()` returns a disposer and goes through `ctx.effect`, as every registration does. +`files` is a page type, not a viewer: it claims no address. Its registration is `{ kind: 'files', id: '@deepseek-ai/dsh-client-ui-sidebar-files', priority: 'builtin', title, guide: [{ order: 10, title, description, icon: FolderSheetGlyph }] }` — no `patterns`, because nothing navigates *to* a file tree by address; the guide's entry box opens the type itself. `FolderSheetGlyph` adapts the shared coloured folder sheet to the guide's requested glyph size; the [Sidebar README](../../../../packages/client/ui-sidebar-right/README.md#the-guide) describes that presentation rule. `id` is the implementation's identity in the Tab system and doubles as the `key` of the body seat `sidebar.right.pane.tab`, so the same string names the type and the component that draws it. `register()` returns a disposer and goes through `ctx.effect`, as every registration does. The root is the session's working directory as the Host reports it in the session list (`useSessions().byId[sessionId].cwd`), labelled by `workspaceTitleOf` from `dsh-util-workspace-path` — the final non-empty path segment — with the root string itself as the label when the path is separator-only. A session without a working directory shows one line (`noWorkspace`) and issues no request. There is no root chooser and no way to browse upward: the Host's `list` refuses paths outside the Session's workspace root, so the one directory the client can list is the one it shows. diff --git a/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.zh.md b/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.zh.md index d40ca8c790..294a2b4560 100644 --- a/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.zh.md +++ b/.agents/notes/implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.zh.md @@ -20,7 +20,7 @@ Sidebar 随包交付三个 tab 类型:**引导页**(`ui-sidebar-right`)、 引导页是 pane 承载内容之前显示的东西。它的注册定义是 `{ id: '@deepseek-ai/dsh-client-ui-sidebar-right/guide', kind: 'guide', priority: 'builtin', title }`,没有 `patterns`:引导页不查看任何东西,所以经 `openTab` 按 kind 打开,并记在页地址 `sidebar://guide` 之下——那是注册表自己的记账,调用方从不拼它。tab 标题是 `开始` / `Start`,在 pane 播种时捕获进布局记录,于是之后切换语言只重标类型,不改已开着的 tab。 -正文按 `order` 从每个已注册类型的 `guide[]` 投影入口,并通过注册表可观察的 `guide()` 列表更新,因此后注册的类型无需引导页感知即可出现。[引导起始页与统计 pill 的细化](2026-09-10-guide-start-page-and-stat-pill-refinements.zh.md)负责罗盘、可选描述、兜底图标和当前胶囊布局。点选胶囊会调用 `tabActions.openTab(entry.kind, { replaceTab: true })`:被选的类型在引导页自己的 tab 里打开,引导页随之消失。引导页是一扇门,不是留在被打开者旁边的一页。 +正文按 `order` 从每个已注册类型的 `guide[]` 投影入口,并通过注册表可观察的 `guide()` 列表更新,因此后注册的类型无需引导页感知即可出现。[Sidebar README](../../../../packages/client/ui-sidebar-right/README.zh.md#the-guide)说明罗盘、可选描述、兜底图标和当前胶囊布局。点选胶囊会调用 `tabActions.openTab(entry.kind, { replaceTab: true })`:被选的类型在引导页自己的 tab 里打开,引导页随之消失。引导页是一扇门,不是留在被打开者旁边的一页。 体同时也是替换接缝。它渲染 `sidebar.right.tab.guide` 链,并以随包交付的引导页作为链的 fallback,于是注册了自己入口的产品接管整个体,而没有入口、或每个入口都拒绝时,随包交付的引导页照常绘制。因为随包交付的引导页是 fallback 而不是链上的一个入口,所以永远恰有一个体,也不可能被意外投掉。 @@ -48,7 +48,7 @@ store 是 Slot 标准件:每会话一个独占实例,按 tab id 分桶,持 ### 文件树 -`files` 是页类型,不是查看器:它不认领任何地址。注册定义是 `{ kind: 'files', id: '@deepseek-ai/dsh-client-ui-sidebar-files', priority: 'builtin', title, guide: [{ order: 10, title, description, icon: FolderSheetGlyph }] }`——没有 `patterns`,因为没有谁按地址导航*到*一棵文件树;引导页的入口框打开的是类型本身。`FolderSheetGlyph` 把共享的彩色文件夹页适配到引导页请求的图标尺寸;[引导页细化记录](2026-09-10-guide-start-page-and-stat-pill-refinements.zh.md)负责这项展示选择。`id` 是这个实现在 Tab 系统里的唯一键,同时也是体坑位 `sidebar.right.pane.tab` 的 `key`,于是同一个串既命名类型也命名画它的组件。`register()` 返回 disposer 并经 `ctx.effect` 注册,与所有注册一致。 +`files` 是页类型,不是查看器:它不认领任何地址。注册定义是 `{ kind: 'files', id: '@deepseek-ai/dsh-client-ui-sidebar-files', priority: 'builtin', title, guide: [{ order: 10, title, description, icon: FolderSheetGlyph }] }`——没有 `patterns`,因为没有谁按地址导航*到*一棵文件树;引导页的入口框打开的是类型本身。`FolderSheetGlyph` 把共享的彩色文件夹页适配到引导页请求的图标尺寸;[Sidebar README](../../../../packages/client/ui-sidebar-right/README.zh.md#the-guide)说明这项展示规则。`id` 是这个实现在 Tab 系统里的唯一键,同时也是体坑位 `sidebar.right.pane.tab` 的 `key`,于是同一个串既命名类型也命名画它的组件。`register()` 返回 disposer 并经 `ctx.effect` 注册,与所有注册一致。 根是 Host 在会话列表里上报的会话工作目录(`useSessions().byId[sessionId].cwd`),标签由 `dsh-util-workspace-path` 的 `workspaceTitleOf` 给出——路径最后一个非空段——路径只有分隔符时用根串本身作标签。没有工作目录的会话只显示一行(`noWorkspace`),不发请求。没有根选择器,也不能往上浏览:Host 的 `list` 拒绝会话工作区根之外的路径,所以客户端能列的那一个目录就是它显示的目录。 diff --git a/.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.i18n.yaml b/.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.i18n.yaml index a84c4d396a..d4ef512e91 100644 --- a/.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.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-07-composer-session-stats-pills.md -2026-09-07-composer-session-stats-pills.md: 22f78dfdde854baaf1ced8c20c55042952df8c26 -2026-09-07-composer-session-stats-pills.zh.md: 6b71c6e7cf03cbd32fa4da9edf38f6aa4814310c +2026-09-07-composer-session-stats-pills.md: 50d3d3294ed012773657ba9849e45835c361d8be +2026-09-07-composer-session-stats-pills.zh.md: 97b91b601e62e8b06ab13a764babb4dd3b25bfce diff --git a/.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.md b/.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.md index 22f78dfdde..50d3d3294e 100644 --- a/.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.md +++ b/.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.md @@ -10,9 +10,12 @@ The session stats strip under the composer (`StatsLine`, ui-chat, mounted on `co ## Decision +The [performance and usage preference](2026-09-16-performance-usage-preference.md) governs statistic visibility and removes the completed-turn elapsed-time action. + `StatsPills` (packages/client/ui-chat/src/client/chat/StatsPills.tsx) replaces `StatsLine` on the same `conversation.composer.dock` slot; the losing variant is deleted, its shared helpers (`deriveStats`, `formatDuration`, `cacheHitPercent`, `billedInputTokens`) absorbed into the new module, and the dead `stats.llm`, `stats.toolCall`, `stats.ttftAverage`, `stats.tokensPerSecond`, and `stats.tokens` locale keys removed. -- **Two icon pills, two dialogs.** A gauge pill (new `IconGaugeOutline16`, dial center optically dropped to y=8.75 because the bottom-open arc reads high) shows `{turns} 轮 {steps} 步` plus output TPS and click-opens the 会话统计 dialog (LLM time, tool time, average TTFT, TPS); a log with no timed figure would open an empty dialog, so that pill renders as a static reading instead of a button. A database pill (`IconDatabaseOutline16`) shows the compact billed total plus cache-hit share and click-opens the Token 用量 dialog (cache hit, uncached input, cache read, output, and cache write when non-zero — exact counts). Both dialogs wear the shared `stat-dialog` module (portal panel, anchored placement, outside-dismiss, optionally externally owned open state) extracted for exactly this two-consumer split; the pills row owns one exclusive open slot, so opening either dialog closes the other, and each button carries an explicit `aria-label` that separates with ` · ` the segments the aria-hidden sep glyph joins visually. [Guide start page and stat pill refinements](2026-09-10-guide-start-page-and-stat-pill-refinements.md) owns the zero cache-write omission. +- **Two icon pills, two dialogs.** A gauge pill (new `IconGaugeOutlineRegular`, dial center optically dropped to y=8.75 because the bottom-open arc reads high) shows `{turns} 轮 {steps} 步` plus output TPS and click-opens the 会话统计 dialog (LLM time, tool time, average TTFT, TPS); a log with no timed figure would open an empty dialog, so that pill renders as a static reading instead of a button. A database pill (`IconDatabaseOutlineRegular`) shows the compact billed total plus cache-hit share and click-opens the Token 用量 dialog (cache hit, uncached input, cache read, output, and cache write when non-zero — exact counts). Both dialogs wear the shared `stat-dialog` module (portal panel, anchored placement, outside-dismiss, optionally externally owned open state) extracted for exactly this two-consumer split; the pills row owns one exclusive open slot, so opening either dialog closes the other, and each button carries an explicit `aria-label` that separates with ` · ` the segments the aria-hidden sep glyph joins visually. [StatsPills](../../../../packages/client/ui-chat/src/client/chat/StatsPills.tsx) owns the zero cache-write omission. +- **Shared information tier.** Composer stats and per-turn tail metadata use the secondary font size minus one pixel. Assistant-tail metadata uses the action icons' tertiary label color and starts after an extra 8px separation from the action cluster. - **Data sourcing is unchanged in architecture.** Counts and times prefer the durable `sessionStats` projection with the window fold as the assembly-without-the-unit fallback ([whole-session counts](../../archived/bug-fix/2026-08-12-full-session-turn-step-counts.md)); token figures ride `tokenUsage` only, so an absent projection drops the usage pill rather than showing window-derived billing. Cache writes stay in the billed total and the cache-hit denominator ([projection decision](../architecture/2026-07-29-projected-token-usage-and-request-context.md)). Context occupancy remains owned by ui-conversation's `ContextMeter`, with its ring and percentage after the two stats pills below the input card. The common dock groups statistics together and leaves the toolbar for input actions; ui-chat does not import the context component. The context panel renders through a portal and uses ui-primitives for viewport-clamped positioning and outside-pointer dismissal, including when the statistics contribution is absent. - **Render discipline.** The row folds settled nodes only (`chat.legacy.nodes` identity), so streaming chunk frames cause zero rerenders — pinned by a render-count unit test. A session with no closed step and no billed tokens renders nothing. - **The composer owns dock spacing.** `InputBar` places slot contributions and `ContextMeter` in one centered flex row, supplies 4px above the dock and 4px below it even when the slot has no visible contribution. The hero keeps no bottom padding and hides its empty dock. `StatsPills` supplies shrinkable time and billing content; it does not claim the full row width or add outer padding. diff --git a/.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.zh.md b/.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.zh.md index 6b71c6e7cf..97b91b601e 100644 --- a/.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.zh.md +++ b/.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.zh.md @@ -10,9 +10,12 @@ Status: implemented ## 决定 +[性能与用量偏好](2026-09-16-performance-usage-preference.zh.md)管理统计可见性,并移除已完成轮次的耗时操作。 + `StatsPills`(packages/client/ui-chat/src/client/chat/StatsPills.tsx)在同一 `conversation.composer.dock` 插槽上取代 `StatsLine`;落选变体已删除,其共享工具函数(`deriveStats`、`formatDuration`、`cacheHitPercent`、`billedInputTokens`)并入新模块,废弃的 `stats.llm`、`stats.toolCall`、`stats.ttftAverage`、`stats.tokensPerSecond`、`stats.tokens` 文案键一并移除。 -- **两个图标 pill、两个弹层。** 仪表盘 pill(新增 `IconGaugeOutline16`,因下开口圆弧视觉偏高而把表盘中心光学下移到 y=8.75)展示 `{turns} 轮 {steps} 步` 加输出 TPS,点击打开「会话统计」弹层(模型用时、工具调用用时、首 token 平均、输出速度);日志里没有任何计时数字时弹层会是空的,此时该 pill 渲染为静态读数而非按钮。数据库 pill(`IconDatabaseOutline16`)展示紧凑计费总量加缓存命中率,点击打开「Token 用量」弹层(缓存命中、未缓存输入、缓存读取、输出,以及非零时的缓存写入——精确计数)。两个弹层共用为这两处消费者抽出的 `stat-dialog` 模块(portal 面板、锚定定位、点击外部关闭、可选的外部持有开合状态);pill 行持有唯一的互斥开合槽位,打开任一弹层即关闭另一个,且每个按钮携带显式 `aria-label`,用 ` · ` 分隔 aria-hidden 分隔符在视觉上连接的两段文本。[引导起始页与统计 pill 的细化](2026-09-10-guide-start-page-and-stat-pill-refinements.zh.md)负责缓存写入为零时省略该行的规则。 +- **两个图标 pill、两个弹层。** 仪表盘 pill(新增 `IconGaugeOutlineRegular`,因下开口圆弧视觉偏高而把表盘中心光学下移到 y=8.75)展示 `{turns} 轮 {steps} 步` 加输出 TPS,点击打开「会话统计」弹层(模型用时、工具调用用时、首 token 平均、输出速度);日志里没有任何计时数字时弹层会是空的,此时该 pill 渲染为静态读数而非按钮。数据库 pill(`IconDatabaseOutlineRegular`)展示紧凑计费总量加缓存命中率,点击打开「Token 用量」弹层(缓存命中、未缓存输入、缓存读取、输出,以及非零时的缓存写入——精确计数)。两个弹层共用为这两处消费者抽出的 `stat-dialog` 模块(portal 面板、锚定定位、点击外部关闭、可选的外部持有开合状态);pill 行持有唯一的互斥开合槽位,打开任一弹层即关闭另一个,且每个按钮携带显式 `aria-label`,用 ` · ` 分隔 aria-hidden 分隔符在视觉上连接的两段文本。[StatsPills](../../../../packages/client/ui-chat/src/client/chat/StatsPills.tsx)负责缓存写入为零时省略该行的规则。 +- **统一信息层级。** 输入框底部统计与单轮回答末尾信息都使用次级字号减 1px。回答末尾信息使用与操作图标一致的 tertiary 文字颜色,并与操作图标组额外拉开 8px。 - **数据来源架构不变。** 计数与用时优先读取持久的 `sessionStats` 投影,窗口折叠仅作无该单元装配时的回退([全会话计数](../../archived/bug-fix/2026-08-12-full-session-turn-step-counts.md));token 数字只走 `tokenUsage`,投影缺席时直接不渲染用量 pill,而非展示窗口推算的计费。缓存写入仍计入计费总量与缓存命中分母([投影决定](../architecture/2026-07-29-projected-token-usage-and-request-context.zh.md))。上下文占用仍归 ui-conversation 的 `ContextMeter` 所有,在输入卡片下方、两个统计 pill 之后显示圆环和百分比。共用 dock 将统计信息放在一起,工具栏保留给输入操作;ui-chat 不导入上下文组件。上下文面板通过 portal 渲染,复用 ui-primitives 的视口内定位与外部指针关闭工具,统计贡献项缺席时也不会越界。 - **渲染纪律。** 该行只折叠已定稿节点(`chat.legacy.nodes` 身份),流式 chunk 帧零重渲染——由渲染计数单测钉住。无已完成步且无计费 token 的会话什么都不渲染。 - **输入框负责 dock 间距。** `InputBar` 将 slot 贡献项和 `ContextMeter` 放在同一个居中的 flex 行中,在 dock 上下各提供 4px 留白,即使 slot 没有可见贡献项也保持该间距。hero 保持无底部留白,并隐藏空 dock。`StatsPills` 提供可收缩的时间与计费内容,不占满整行宽度,也不添加外部 padding。 diff --git a/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.i18n.yaml b/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.i18n.yaml index ddba9ebf1e..961abe5984 100644 --- a/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.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-07-deepseek-messages-adapter.md -2026-09-07-deepseek-messages-adapter.md: c667ec3afdd9956282a532e748ad2e2488d26f5b -2026-09-07-deepseek-messages-adapter.zh.md: 0e3425e41d8a9585a1638eedeefcf6ead76a5a78 +2026-09-07-deepseek-messages-adapter.md: caec59feb3a84140427bd3ae8b738274c3bc5c47 +2026-09-07-deepseek-messages-adapter.zh.md: 030c91cb9a5f566ce09892bb054b63b1d243f6bb diff --git a/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.md b/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.md index c667ec3afd..caec59feb3 100644 --- a/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.md +++ b/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.md @@ -36,6 +36,6 @@ Both transports use the existing [request-extension registry](../architecture/20 ## Consequences -The package owns wire validation, stop-reason mapping, cancellation, and error classification, so protocol changes require adapter maintenance. Unsupported content and incomplete streams fail explicitly. The existing retry consumer owns retries; the existing assembler drops incomplete tool calls at the output limit. Messages defaults cover the shared base, Web, and standalone first-party compositions. Protocol changes preserve the provider id and saved model selections, but an explicit endpoint override must support the selected protocol. +The package owns wire validation, stop-reason mapping, cancellation, and error classification, so protocol changes require adapter maintenance. User and tool-result input follows the [saved-input compatibility rule](../bug-fix/2026-09-18-messages-input-history-compatibility.md); other unsupported content and incomplete streams fail explicitly. The existing retry consumer owns retries; the existing assembler drops incomplete tool calls at the output limit. Messages defaults cover the shared base, Web, and standalone first-party compositions. Protocol changes preserve the provider id and saved model selections, but an explicit endpoint override must support the selected protocol. Verification covers wire fixtures, real Loader composition, per-file unit coverage, [recorded Session replay](../../../../snapshots/session/deepseek-messages-replay/snapshot.yml) with [unknown replay versions](../../../../snapshots/session/deepseek-messages-degraded-replay/snapshot.yml), a Web Messages Session replay, and credential-gated text, thinking, tool continuation, image, and cancellation requests. Live gateway checks establish compatibility with the configured gateway; they do not establish compatibility with every Anthropic proxy. diff --git a/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.zh.md b/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.zh.md index 0e3425e41d..030c91cb9a 100644 --- a/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.zh.md +++ b/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.zh.md @@ -36,6 +36,6 @@ Web 始终显示 DeepSeek,不提供协议选择器。两个协议共用 `baseU ## 结果 -该包负责协议校验、停止原因映射、取消和错误分类,因此协议变化需要维护适配器。不支持的内容和不完整的流会明确报错。现有重试消费者负责重试;现有装配器在输出达到上限时丢弃未完成的工具调用。Messages 默认配置覆盖共享 base、Web 和独立的官方组合。协议变化保留 provider id 与已保存的模型选择,但显式端点覆盖必须支持所选协议。 +该包负责协议校验、停止原因映射、取消和错误分类,因此协议变化需要维护适配器。用户与工具结果输入遵循[已保存输入兼容规则](../bug-fix/2026-09-18-messages-input-history-compatibility.zh.md);其他不支持的内容和不完整的流会明确报错。现有重试消费者负责重试;现有装配器在输出达到上限时丢弃未完成的工具调用。Messages 默认配置覆盖共享 base、Web 和独立的官方组合。协议变化保留 provider id 与已保存的模型选择,但显式端点覆盖必须支持所选协议。 验证覆盖协议夹具、真实 Loader 组合、逐文件单元覆盖率、[已记录 Session 回放](../../../../snapshots/session/deepseek-messages-replay/snapshot.yml)与[未知回放版本](../../../../snapshots/session/deepseek-messages-degraded-replay/snapshot.yml),Web Messages Session 回放,以及凭证控制的文本、思考、工具续接、图片和取消请求。真实网关检查证明与已配置网关的兼容性,不能证明与所有 Anthropic 代理兼容。 diff --git a/.agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.i18n.yaml b/.agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.i18n.yaml index c52f0a47a2..daa947a20c 100644 --- a/.agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.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-07-session-prose-local-media-display.md -2026-09-07-session-prose-local-media-display.md: ed740acd0d0d6eaf7f8834dc8d6280a33305aecd -2026-09-07-session-prose-local-media-display.zh.md: de8a7f99d7aabc4474f525f9f37f50a465a80840 +2026-09-07-session-prose-local-media-display.md: fe769d1cbe0b54eed355a7e699bf853a497820cb +2026-09-07-session-prose-local-media-display.zh.md: 2cfff4016f42a13d94a1097602ff2dadbd28d2a7 diff --git a/.agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.md b/.agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.md index ed740acd0d..fe769d1cbe 100644 --- a/.agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.md +++ b/.agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.md @@ -14,7 +14,7 @@ Local media paths in Session prose render through a same-origin file route. This `ui-primitives` owns the `MarkdownPathImages` vocabulary on `MarkdownText`. Like `fileMentions`, it applies only after a message settles so frozen streaming blocks cannot cache a vocabulary handler. The settled pass rewrites image destinations outside the remote-URL allowlist and emits only absolute `http(s)`, `blob`, or `data` results. Without a vocabulary, local destinations retain inert alt text. Failed loads replace the image with authored alt text, or its original destination when alt is empty; a different source can load again. -`ui-chat` supplies a page-stable `localPathMediaUrl` vocabulary through `AssistantMarkdown`. It maps absolute POSIX paths to `/api/file?path=…` on the page's origin. Relative and protocol-relative paths, Windows-style paths, and non-HTTP page transports such as Electron `file://` remain inert. +`ui-chat` supplies a page-stable `localPathMediaUrl` vocabulary through `AssistantMarkdown`. It maps absolute POSIX paths to `api/file?path=…` resolved against `document.baseURI`, so the request stays under whatever mount the served document came from. Relative and protocol-relative paths, Windows-style paths, and non-HTTP page transports such as Electron `file://` remain inert. `session-controller` owns the `SessionMediaReferences` contribution beside `SessionFileReferences`. It registers through `connection.fetch`, which applies the same browser authentication and trust checks as `/api` RPC. The fixed same-origin endpoint gives the synchronous renderer a stable URL without an asynchronous capability negotiation. diff --git a/.agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.zh.md b/.agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.zh.md index de8a7f99d7..2cfff4016f 100644 --- a/.agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.zh.md +++ b/.agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.zh.md @@ -14,7 +14,7 @@ Session 正文中的本地媒体路径通过同源文件路由渲染。本记录 `ui-primitives` 拥有 `MarkdownText` 上的 `MarkdownPathImages` 词表。与 `fileMentions` 一样,它只在消息稳定后生效,使冻结的流式块无法缓存词表处理函数。稳定渲染过程重写远程 URL 白名单之外的图片目标,并只输出绝对 `http(s)`、`blob` 或 `data` 结果。没有词表时,本地目标保留静态 alt 文本。加载失败会把图片替换为作者提供的 alt 文本;alt 为空时显示原始目标路径;不同来源仍可重新加载。 -`ui-chat` 通过 `AssistantMarkdown` 提供页面稳定的 `localPathMediaUrl` 词表。它把绝对 POSIX 路径映射到页面同源的 `/api/file?path=…`。相对路径、协议相对路径、Windows 风格路径,以及 Electron `file://` 等非 HTTP 页面传输保持静态回退。 +`ui-chat` 通过 `AssistantMarkdown` 提供页面稳定的 `localPathMediaUrl` 词表。它把绝对 POSIX 路径映射到相对 `document.baseURI` 解析的 `api/file?path=…`,因此请求始终停留在所服务文档来源的那个挂载之下。相对路径、协议相对路径、Windows 风格路径,以及 Electron `file://` 等非 HTTP 页面传输保持静态回退。 `session-controller` 在 `SessionFileReferences` 旁拥有 `SessionMediaReferences` 贡献。它通过 `connection.fetch` 注册;该通道执行与 `/api` RPC 相同的浏览器鉴权和信任检查。固定同源端点让同步渲染器获得稳定 URL,无需异步能力协商。 diff --git a/.agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.i18n.yaml b/.agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.i18n.yaml index a42e0ee0bd..93a617d08b 100644 --- a/.agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.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-08-composer-menu-sections-and-localized-rows.md -2026-09-08-composer-menu-sections-and-localized-rows.md: 3cccb3c038ae4c358860f09533e966e9121b987e -2026-09-08-composer-menu-sections-and-localized-rows.zh.md: 31c879b17ac6788bfade50a3245903c3e2e56224 +2026-09-08-composer-menu-sections-and-localized-rows.md: 50b97ce2c7e59d53bf5607fef74a799a359d27c8 +2026-09-08-composer-menu-sections-and-localized-rows.zh.md: 92bcb04bfe9b4f7b5581c80d7c0b843f98096e52 diff --git a/.agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.md b/.agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.md index 3cccb3c038..50b97ce2c7 100644 --- a/.agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.md +++ b/.agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.md @@ -18,9 +18,9 @@ A menu pick fills the locale's claim token: under Chinese, picking Plan fills `/ The File row is an `action` contribution: a bare invocation consumes the trigger token and runs a client callback without submitting a message. Conversation owns that registration, its live availability, and the hidden file input. The menu replaces the separate paperclip button; the `+` button's accessible name and tooltip read "Add files or run commands". -`ui-input-trigger` renders the new row anatomy: `InputTriggerCandidate.label` is the title and a second search key of the shared `rankByName`, the name renders as a trailing alias when the label differs from it, `icon` accepts an icon component beside the reference glyph tokens, and the description is right-aligned. `ui-primitives` gains the Plan glyph from the design doc, a static ring for Compact, and the permission shield contour. +`ui-input-trigger` renders the new row anatomy: `InputTriggerCandidate.label` is the title and a second search key of the shared `rankByName`, the name renders as a trailing alias when the label differs from it, `icon` accepts an icon component beside the reference glyph tokens, and the description is right-aligned. Aliases and descriptions use a font one pixel below the primary labels. `ui-primitives` gains the Plan glyph from the design doc, a static ring for Compact, and the permission shield contour. -The menu uses a 400 px border-box height cap, which fits both headings and the eight built-in rows before the viewport clamp reduces it. A real overflow keeps a 10 px draggable WebKit rail around a 4 px visible thumb, insets the track from the rounded ends, and shows a bottom fade until the viewport reaches the final row; Firefox keeps its standard thin scrollbar. +The menu uses a 400 px border-box height cap, which fits both headings and the eight built-in rows before the viewport clamp reduces it. A real overflow keeps a 6 px draggable WebKit rail around a 2 px visible thumb, insets the track from the rounded ends, and shows a bottom fade until the viewport reaches the final row; Firefox keeps its standard thin scrollbar. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.zh.md b/.agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.zh.md index 31c879b17a..92bcb04bfe 100644 --- a/.agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.zh.md +++ b/.agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.zh.md @@ -18,9 +18,9 @@ composer 的 `+` 按钮与键入的 `/` 按宿主注册顺序把每个命令列 「文件」行是一个 `action` 贡献项:裸调用消费触发 token 后运行客户端回调,不提交消息。Conversation 负责该注册、实时可用性和隐藏文件输入框。菜单取代单独的回形针按钮,`+` 按钮的无障碍名称与提示为「添加文件或调用指令」。 -`ui-input-trigger` 渲染新的行结构:`InputTriggerCandidate.label` 是标题,也是共享 `rankByName` 的第二个搜索键;label 与名字不同时名字渲染为尾随别名;`icon` 在引用图标 token 之外接受图标组件;说明右对齐。`ui-primitives` 新增设计稿给出的计划图标、压缩用的静态环形,以及权限盾形轮廓。 +`ui-input-trigger` 渲染新的行结构:`InputTriggerCandidate.label` 是标题,也是共享 `rankByName` 的第二个搜索键;label 与名字不同时名字渲染为尾随别名;`icon` 在引用图标 token 之外接受图标组件;说明右对齐。别名与说明使用比主标签小 1px 的字号。`ui-primitives` 新增设计稿给出的计划图标、压缩用的静态环形,以及权限盾形轮廓。 -菜单采用 400 px 的 border-box 高度上限,在视口限制缩小高度前可容纳两个小节标题与八个内置入口。内容确实溢出时,WebKit 系浏览器用 10 px 的可拖动区域承载 4 px 的可见滑块,轨道避开圆角两端,视口抵达最后一行前显示底部渐隐;Firefox 保留标准细滚动条。 +菜单采用 400 px 的 border-box 高度上限,在视口限制缩小高度前可容纳两个小节标题与八个内置入口。内容确实溢出时,WebKit 系浏览器用 6 px 的可拖动区域承载 2 px 的可见滑块,轨道避开圆角两端,视口抵达最后一行前显示底部渐隐;Firefox 保留标准细滚动条。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.i18n.yaml b/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.i18n.yaml index 16c17f3405..c18c6397ed 100644 --- a/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.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-08-feedback-dialog-and-categories.md -2026-09-08-feedback-dialog-and-categories.md: ee656946a9e5d2921a02a1b642041e5965661f31 -2026-09-08-feedback-dialog-and-categories.zh.md: ef2b2d4670aad12398125925dd1e1a7e7c10a5f0 +2026-09-08-feedback-dialog-and-categories.md: 1d544e6ef978c10008ac62768b6faaf9eaaf8b73 +2026-09-08-feedback-dialog-and-categories.zh.md: 6e58820bf308a3a9f7d15b64027f90e7e6771bd4 diff --git a/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.md b/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.md index ee656946a9..1d544e6ef9 100644 --- a/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.md +++ b/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.md @@ -14,10 +14,14 @@ The Web client had two disconnected feedback paths with no visible outcome. `/fe `ui-message-feedback` becomes the Web feedback surface. A per-session `FeedbackSurface` owns the message-feedback controller, a `FeedbackDialogController` for the draft, the submission, and the toast sequence, and the routing between them: a message target puts its selected judgment with the dialog's category and note through the message controller, while the Session target records through `ctx.remote.sessionFeedback`. A `FeedbackDialog` entry of `conversation.input.overlay` renders the Modal and Toast primitives from the dialog store. A decoration on the Host's `feedback` command opens the dialog for the Session from a menu pick or a bare Enter while `/feedback ` still reaches the Host; it uses the `action` kind in `CommandUiSpec`, which consumes the trigger token and runs a client callback without submitting anything. The later [symmetric message feedback submission](2026-09-10-symmetric-message-feedback-submission.md) decision owns the rating entry rule: either unrecorded rating opens the dialog, while clicking the recorded rating retracts it. The note popover, `clearNote`, and `clear` remain absent because the dialog is the only note editor. +The Session Header's more-actions menu also opens the Session dialog through the `feedbackUi.openSession` Client service. The menu remains owned by `session-log-export`, while the existing per-session feedback controller owns the draft and submission. An observable tracks the optional feedback plugin's availability, so unloading feedback removes only its menu row and leaves export usable. Opening or dismissing the form writes no feedback event; Submit records one Session remark. + The dialog is the shared Modal card at the design's width; the design's checkbox for including the conversation log is not built, because the log travels with every feedback event and is not optional. An oversized description still fails on submit with `note-too-large`; the dialog stays open with its draft and a warning toast presents the localized failure. ## Alternatives considered +**A child slot for Header menu actions.** A slot would let each plugin own its menu row, but requires a shared menu-item API beyond the feedback entry requested in #4445. The optional `feedbackUi` service keeps this change local while leaving dialog state and submission in the feedback plugin. + **Encode the category into the note text.** A prefix in free text is not filterable without parsing and would leak into the verbatim note that telemetry uploads; a durable id in the payload is what a consumer can group by. **Submit the dialog through the command plane as `/feedback `.** The command rejects empty text, cannot carry a category, and writes an acknowledgement row the design replaces with a toast; the Remote records the same event with neither constraint. diff --git a/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.zh.md b/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.zh.md index ef2b2d4670..6e58820bf3 100644 --- a/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.zh.md +++ b/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.zh.md @@ -14,10 +14,14 @@ Web 客户端有两条互不相连的反馈路径,且都没有可见结果。` `ui-message-feedback` 成为 Web 反馈界面。每个 Session 一个 `FeedbackSurface`,拥有消息反馈控制器、负责草稿、提交与 toast 序号的 `FeedbackDialogController`,以及两者之间的路由:消息目标经消息控制器 put 一条带弹窗分类与备注的所选评分,Session 目标经 `ctx.remote.sessionFeedback` 记录。`conversation.input.overlay` 的 `FeedbackDialog` 条目从弹窗 store 渲染 Modal 与 Toast 基元。宿主 `feedback` 命令上的装饰让菜单选中或不带参数的回车为 Session 打开弹窗,而 `/feedback ` 仍到达宿主;它使用 `CommandUiSpec` 中的 `action` 种类:裸调用消费触发 token 后运行一个客户端回调,不提交任何内容。后续的[对称消息反馈提交](2026-09-10-symmetric-message-feedback-submission.zh.md)决策拥有评分入口规则:任一未记录的评分都会打开弹窗,再次点击已记录的评分则撤回。备注浮层、`clearNote` 与 `clear` 继续保持移除,因为弹窗是唯一的备注编辑器。 +会话标题栏的更多操作菜单也通过 `feedbackUi.openSession` Client 服务打开 Session 弹窗。菜单仍由 `session-log-export` 持有,现有的逐 Session 反馈控制器持有草稿与提交。一个可观察值跟踪可选反馈插件的可用状态,因此卸载反馈插件只移除其菜单行,导出仍然可用。打开或关闭表单不写反馈事件;点击提交才记录一条 Session 反馈。 + 弹窗是共用的 Modal 卡片,宽度按设计稿;设计稿里「包括当前对话的日志」复选框不做,因为日志随每个反馈事件一起投递,不是可选项。超长描述仍在提交时以 `note-too-large` 失败;弹窗保留草稿并通过警告 toast 展示本地化错误。 ## 考虑过的替代方案 +**为标题栏菜单动作声明子 slot。** slot 可以让每个插件持有自己的菜单行,但需要共享菜单项 API,超出 #4445 所需反馈入口的范围。可选的 `feedbackUi` 服务将改动限制在局部,同时让弹窗状态与提交继续由反馈插件持有。 + **把分类编进备注文本。** 自由文本里的前缀不解析就无法过滤,还会混进遥测上传的原样备注;载荷里的持久 id 才是消费方能分组的东西。 **让弹窗经命令平面以 `/feedback ` 提交。** 命令拒绝空文本、带不了分类,还会写一行设计稿已用 toast 取代的确认;Remote 记录同一个事件且没有这两个约束。 diff --git a/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.i18n.yaml b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.i18n.yaml index 0458b32e17..057e138853 100644 --- a/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.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-08-present-workspace-source-files.md -2026-09-08-present-workspace-source-files.md: e1bc2bbc56d423741ca53550af2879fed3f01ecf -2026-09-08-present-workspace-source-files.zh.md: e4f21172b36a5fef50e44a79adca71011ee90a3b +2026-09-08-present-workspace-source-files.md: 52cc3e9cdbcd30d378117a327b86062523a2cb3e +2026-09-08-present-workspace-source-files.zh.md: 99b7b9df1d9ca2da2175df1ae7e9d8acbb5c5fdd diff --git a/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md index e1bc2bbc56..52cc3e9cdb 100644 --- a/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md +++ b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md @@ -12,7 +12,7 @@ Users need to open and edit the files produced in their workspace, including she The [present tool](../../../../packages/deliverables/tool-present/README.md) declares existing regular source files under the [Session filesystem access policy](2026-09-09-present-filesystem-access.md). It records paths and optional descriptions without reading or copying contents. The [deliverables plugin](../../../../packages/client/ui-deliverables/README.md) opens current workspace sources in the Host's default application. Edits are visible on the next open; deletion or movement makes the declaration unavailable. File-content preservation and copy-on-write storage are deferred until a persistence design owns them. -The tool description requires `present` after writing a file the user asked to receive and before the final response, including files created through Bash or code execution. A prose path reference does not replace the call. The recorded [SVG delivery scenario](../../../../snapshots/web/present-svg/snapshot.yml) uses a user request that does not name `present`, and checks the resulting file, delivery event, and card. Its UI snapshot covers the expanded Chat transcript; navigation and composer controls belong to their own scenarios, so unrelated chrome changes cannot invalidate file-delivery expectations. +The [tool description](../../../../packages/deliverables/tool-present/README.md#model-experience) guides the model's choice of files for explicit delivery. A declaration remains distinct from a prose file reference. The recorded [SVG delivery scenario](../../../../snapshots/web/present-svg/snapshot.yml) covers the resulting file, delivery event, and card. New recordings explicitly request a separate file card; replay preserves the recorded request. This scenario verifies explicit delivery, not a requirement to call `present` for every SVG. Its UI snapshot covers the expanded Chat transcript; navigation and composer controls belong to their own scenarios, so unrelated chrome changes cannot invalidate file-delivery expectations. The tool remains an ordinary package with shared filesystem and tool error classes. Its pure type entry owns the delivery event without importing Host code into the browser. The `standard`, `ptc`, and `cordis` presets mount it; `minimal` retains its two tools. Each plugin instance correlates its executions with successful final `tools/result` notifications before appending `deliverables/presented`. Native and nested calls share this rule. A later enclosing program failure does not revoke a completed nested declaration; blocked results publish none, and same-name scoped replacements cannot publish another instance's results. diff --git a/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.zh.md b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.zh.md index e4f21172b3..99b7b9df1d 100644 --- a/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.zh.md +++ b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.zh.md @@ -12,7 +12,7 @@ Status: implemented [present 工具](../../../../packages/deliverables/tool-present/README.zh.md)声明交付[Session 文件系统访问策略](2026-09-09-present-filesystem-access.zh.md)允许的已有普通源文件。它记录路径和可选说明,不读取或复制内容。[交付插件](../../../../packages/client/ui-deliverables/README.zh.md)使用 Host 默认应用打开当前工作区源文件。下次打开会看到编辑后的内容;删除或移动文件会使声明不可用。文件内容保留与写时复制存储延期到有持久化设计负责时实现。 -工具说明要求在写好用户要求接收的文件后、最终回复前调用 `present`,包括通过 Bash 或代码执行创建的文件。正文中的路径引用不能替代调用。录制的 [SVG 交付场景](../../../../snapshots/web/present-svg/snapshot.yml)使用未提及 `present` 的用户请求,检查生成文件、交付事件和卡片。其 UI 快照覆盖展开后的 Chat 对话内容;导航和输入框控件由各自场景负责,避免无关界面改动使文件交付预期失效。 +[工具说明](../../../../packages/deliverables/tool-present/README.zh.md#model-experience)指导模型选择需要显式交付的文件。交付声明与正文中的文件引用各自独立。录制的 [SVG 交付场景](../../../../snapshots/web/present-svg/snapshot.yml)覆盖生成文件、交付事件和卡片。新录制明确请求独立文件卡片,回放保留录制时的请求。该场景验证显式交付,不要求所有 SVG 都调用 `present`。其 UI 快照覆盖展开后的 Chat 对话内容;导航和输入框控件由各自场景负责,避免无关界面改动使文件交付预期失效。 工具保持为普通包,共享文件系统和工具错误类型。其纯类型入口拥有交付事件,不向浏览器导入 Host 代码。`standard`、`ptc` 与 `cordis` preset 挂载工具;`minimal` 保持两个工具。每个插件实例将其执行与成功的最终 `tools/result` 通知关联,再追加 `deliverables/presented`。原生与嵌套调用遵循同一规则。外层程序随后失败不会撤销已完成的嵌套声明;被阻止的结果不发布声明,同名作用域替换也不能发布其他实例的结果。 diff --git a/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.i18n.yaml b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.i18n.yaml index bf3876efca..35f8f4dc56 100644 --- a/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.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-08-shared-file-type-icons.md -2026-09-08-shared-file-type-icons.md: 1e3fe7267961a8525bbcdf946eacaa933bcf3e4f -2026-09-08-shared-file-type-icons.zh.md: fd1f38ebb33f80c73d17cc48f6f9640c309f97f2 +2026-09-08-shared-file-type-icons.md: 5dbe0ade14c15e1d509f56eba6e0266c61089c46 +2026-09-08-shared-file-type-icons.zh.md: 9dbccdf10f188b30dcbbbe11d45b238af42ca112 diff --git a/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.md b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.md index 1e3fe72679..5dbe0ade14 100644 --- a/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.md +++ b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.md @@ -6,25 +6,25 @@ English | [中文](2026-09-08-shared-file-type-icons.zh.md) ## Problem -Client feature plugins can share React components only through `@deepseek-ai/dsh-client-ui-primitives`, but file cards had no shared file-type presentation. `LinkIcon` owned the only extension table and deliberately collapsed paths into six link categories, while attachment cards, sent-message attachments, queued files, and workspace file rows used one generic document glyph. Two of those consumers also carried separate extension-display helpers. Adding a precise file icon anywhere else would have required another extension table or a runtime import between feature plugins. +Client feature plugins can share React components only through `@deepseek-ai/dsh-client-ui-primitives`, but file cards had no shared file-type presentation. The package-owned classifier provides the only extension table and deliberately folds detailed file types into six link categories for `LinkIconMedium`, while attachment cards, sent-message attachments, queued files, and workspace file rows use detailed file glyphs. Adding a precise file icon anywhere else would require another extension table or a runtime import between feature plugins. ## Decision `ui-primitives` owns one Cordis-free file classification and rendering API. `fileExtension(path)` applies the shared basename and final-dot semantics to either path separator. `classifyFileType(path)` matches without case sensitivity and returns the closed `FileType` union: the traditional `code`, `excel`, `folder`, `html`, `image`, `markdown`, `other`, `pdf`, `ppt`, `video`, and `word` categories plus the detailed `CodeFileType` categories rendered by `CodeFileIcon`. Exact filenames, filename prefixes, filename suffixes, optional project context, and extensions run in that order. The path classifier returns every member except `folder`; callers use the explicit `FileTypeIcon` `kind` override when they know an entry is a directory. Unknown extensions, missing extensions, and trailing dots resolve to `other`, except the shared table recognizes named files such as `Dockerfile`, `Makefile`, `package.json`, `.gitignore`, `README`, and `CHANGELOG`. -`FileTypeIcon` accepts a path, shared `IconProps`, the explicit `kind`, and an optional project-file snapshot. Traditional file types render the supplied 28px document and folder contours as inline SVG. Excel, Markdown, PDF, PPT, and Word foreground marks scale to 122% around their visual center; the remaining marked traditional glyphs use 112%, while the file body and folded corner retain their source geometry and the generic file has no invented center mark. The sheet is a solid category color, the foreground mark and ordinary folded corner are white, and the generic file has a darker grey corner. CSS assigns the supplied category palette through static design tokens: DeepSeek blue for code/HTML/Markdown, the lighter DeepSeek blue for Word, green for Excel, two amber steps for folder/PPT, red for PDF, and neutral grey for unknown files. Image and video share the supplied violet through a component-local variable because the design platform has no matching violet token. A caller may override a traditional sheet through `--dsh-file-type-icon-color`. +`FileTypeIcon` accepts a path, shared `IconProps`, the explicit `kind`, and an optional project-file snapshot. Traditional file types render the supplied 28px document and folder contours as inline SVG. The spreadsheet glyph uses the supplied unscaled 1.2px table grid; Markdown, PDF, PPT, and Word foreground marks scale to 122% around their visual center, and the remaining marked traditional glyphs use 112%. The file body and folded corner retain their source geometry, and the generic file has no invented center mark. The sheet is a solid category color, the foreground mark and ordinary folded corner are white, and the generic file has a darker grey corner. CSS assigns the supplied category palette through static design tokens: DeepSeek blue for code/HTML/Markdown, the lighter DeepSeek blue for Word, green for spreadsheets, two amber steps for folder/PPT, red for PDF, and neutral grey for unknown files. Image and video share the supplied violet through a component-local variable because the design platform has no matching violet token. A caller may override a traditional sheet through `--dsh-file-type-icon-color`. -Recognized code and configuration files render the corresponding 20px square artwork scaled to the requested icon size. The embedded static table contains exactly the 48 established `CodeFileType` entries; archive-only artwork does not add a category, and an adjacent manifest records the responsible design owner and source digests. Tests reject scripts, event attributes, external references, and duplicate ids in that table. `CodeFileIcon` replaces local SVG ids with a per-component prefix before insertion so repeated gradients and clip paths remain independent. These technology marks retain their embedded multicolor fills and are the explicit exception to the ordinary current-color icon rule. The map selects React before TypeScript/JavaScript, Angular filename suffixes before their base extension, Docker/Node/Git/Make/CMake by filename rules, and Flutter only when the optional project snapshot contains a `pubspec.yaml` whose text includes `flutter:`. Markdown and SVG remain owned by the traditional Markdown and image categories. CSV and TSV use the code glyph in file cards, rows, and preview titles; their clickable links also use code. Both `.env` and names ending in `.env` use the environment glyph. Every traditional and technology SVG is `aria-hidden`, and the card, row, or button that owns the file identity supplies the accessible name. +Recognized code and configuration files render the corresponding 20px square artwork scaled to the requested icon size. The embedded static table contains exactly the 48 established `CodeFileType` entries; archive-only artwork does not add a category, and an adjacent manifest records the responsible design owner and source digests. Tests reject scripts, event attributes, external references, and duplicate ids in that table. `CodeFileIcon` replaces local SVG ids with a per-component prefix before insertion so repeated gradients and clip paths remain independent. These technology marks retain their embedded multicolor fills and are the explicit exception to the ordinary current-color icon rule. The map selects React before TypeScript/JavaScript, Angular filename suffixes before their base extension, Docker/Node/Git/Make/CMake by filename rules, and Flutter only when the optional project snapshot contains a `pubspec.yaml` whose text includes `flutter:`. Markdown and SVG remain owned by the traditional Markdown and image categories. CSV, TSV, Excel workbooks and templates, OpenDocument spreadsheets, and Numbers use the spreadsheet glyph in file cards, rows, and preview titles; their clickable links use the document category. Both `.env` and names ending in `.env` use the environment glyph. Every traditional and technology SVG is `aria-hidden`, and the card, row, or button that owns the file identity supplies the accessible name. -`LinkIcon` delegates extension classification to `classifyFileType` and folds the detailed result into its existing link vocabulary: code and HTML use `code`, images use `image`, PDF/Word/Excel/PPT use `document`, and Markdown/video/unknown files use `other`. Extensionless names remain `other` in link contexts, so the 14px clickable-link appearance defined by the [clickable-link decision](2026-09-04-web-clickable-link-styles.md) does not change. +`LinkIconMedium` delegates extension classification to `classifyFileType` and folds the detailed result into its existing link vocabulary: code and HTML use `code`, images use `image`, PDF/Word/Excel/PPT use `document`, and Markdown/video/unknown files use `other`. Extensionless names remain `other` in link contexts, so the 14px clickable-link appearance defined by the [link styling rules](../../../../docs/web-styling.md) does not change. -Attachment upload cards, sent-message file cards, queued-file rows, and workspace file rows render `FileTypeIcon`. The Files tab title renders its explicit `folder` kind at 16px. Explicit delivery cards use `FileTypeIcon` at 20px and `fileExtension` for their fallback metadata. The two metadata rows use `fileExtension` rather than local parsers; a leading-dot basename such as `.env` therefore displays `ENV`, while an absent or trailing suffix displays no extension label. Image content continues to render as a preview rather than a file-type glyph, and produced-file links and Markdown file mentions continue to use `LinkIcon` because they are link surfaces. +Attachment upload cards, sent-message file cards, queued-file rows, and workspace file rows render `FileTypeIcon`. The Files tab title renders its explicit `folder` kind at 16px. Explicit delivery cards use `FileTypeIcon` at 20px and `fileExtension` for their fallback metadata. The two metadata rows use `fileExtension` rather than local parsers; a leading-dot basename such as `.env` therefore displays `ENV`, while an absent or trailing suffix displays no extension label. Image content continues to render as a preview rather than a file-type glyph, and produced-file links and Markdown file mentions continue to use `LinkIconMedium` because they are link surfaces. ## Alternatives considered -**Use `LinkIcon` for every file surface.** Rejected. Its six categories and 14px outline drawings communicate link destinations at text size; a 28px file card has room for the supplied HTML, Markdown, PDF, Word, Excel, PPT, and video identities. +**Use `LinkIconMedium` for every file surface.** Rejected. Its six categories and 14px outline drawings communicate link destinations at text size; a 28px file card has room for the supplied HTML, Markdown, PDF, Word, Excel, PPT, and video identities. -**Keep a second extension table beside `LinkIcon`.** Rejected. The same path could drift to different categories as either list grows. One detailed table plus an explicit detailed-to-link adapter preserves both consumers' semantics. +**Keep a second extension table beside `LinkIconMedium`.** Rejected. The same path could drift to different categories as either list grows. One detailed table plus an explicit detailed-to-link adapter preserves both consumers' semantics. **Put the supplied traditional-file colors directly in each SVG path.** Rejected. Traditional SVG geometry stays reusable and follows the icon set's `currentColor` rule; the component stylesheet owns the default category palette, and render sites retain one CSS-variable override instead of rewriting path fills. The technology artwork is exempt because its embedded multicolor marks identify the language or tool rather than decorating a generic file silhouette. @@ -32,7 +32,7 @@ Attachment upload cards, sent-message file cards, queued-file rows, and workspac ## Testing -The `ui-primitives` specs cover case-insensitive suffixes, both path separators, leading-dot files, missing and trailing suffixes, common named files, unknown fallback, all detailed code mappings, rule priority, Flutter context, the exact 48-key artwork set, rejected archive-only categories, static-markup safety, instance-safe SVG ids and references, `aria-hidden`, sizing/class forwarding, distinct artwork, the 112% and 122% foreground-mark transforms, and the traditional solid-sheet/contrast-mark layers without literal SVG colors. A stylesheet spec pins every traditional category-to-color mapping, the caller override, and the local violet value. The existing `LinkIcon` classification table pins its coarse output, including `Makefile` remaining `other`. Attachment, chat, queue, and sidebar component suites exercise the migrated render paths; their accessibility output does not change because the glyphs remain decorative. +The `ui-primitives` specs cover case-insensitive suffixes, both path separators, leading-dot files, missing and trailing suffixes, common named files, unknown fallback, all detailed code mappings, rule priority, Flutter context, the exact 48-key artwork set, rejected archive-only categories, static-markup safety, instance-safe SVG ids and references, `aria-hidden`, sizing/class forwarding, distinct artwork, the supplied spreadsheet grid, the 112% and 122% foreground-mark transforms, and the traditional solid-sheet/contrast-mark layers without literal SVG colors. A stylesheet spec pins every traditional category-to-color mapping, the caller override, and the local violet value. The `LinkIconMedium` classification table pins its coarse output, including `Makefile` remaining `other`. Attachment, chat, queue, and sidebar component suites exercise the migrated render paths; their accessibility output does not change because the glyphs remain decorative. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.zh.md b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.zh.md index fd1f38ebb3..9dbccdf10f 100644 --- a/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.zh.md +++ b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.zh.md @@ -6,25 +6,25 @@ Status: implemented ## 问题 -客户端功能插件只能通过 `@deepseek-ai/dsh-client-ui-primitives` 共享 React 组件,但文件卡片没有共享的文件类型呈现。`LinkIcon` 拥有唯一一份扩展名表,并有意把路径折叠为六种链接类别;附件卡片、已发送消息附件、排队文件和工作区文件行则统一使用一个通用文档图形。其中两个消费方还各自带着一份扩展名展示辅助函数。在其他位置增加精细文件图标,需要再复制一份扩展名表或让功能插件之间产生运行时 import。 +客户端功能插件只能通过 `@deepseek-ai/dsh-client-ui-primitives` 共享 React 组件,但文件卡片没有共享的文件类型呈现。本包拥有唯一一份扩展名分类表,并为 `LinkIconMedium` 将细分文件类型有意折叠为六种链接类别;附件卡片、已发送消息附件、排队文件和工作区文件行使用细分文件图形。在其他位置增加精细文件图标,需要再复制一份扩展名表或让功能插件之间产生运行时 import。 ## 决策 `ui-primitives` 统一拥有一套 Cordis-free 的文件分类与渲染 API。`fileExtension(path)` 对两种路径分隔符采用共享的 basename 与最终点号语义。`classifyFileType(path)` 不区分大小写,并返回闭合的 `FileType` 联合:传统的 `code`、`excel`、`folder`、`html`、`image`、`markdown`、`other`、`pdf`、`ppt`、`video`、`word` 类别,以及由 `CodeFileIcon` 渲染的细分 `CodeFileType` 集合。解析按完整文件名、文件名前缀、文件名后缀、可选项目上下文、扩展名的顺序执行。路径分类器返回 `folder` 之外的全部成员;调用方确认条目是目录时,通过 `FileTypeIcon` 的显式 `kind` 覆盖指定目录。未知扩展名、无扩展名和末尾点号回退到 `other`,但共享表识别 `Dockerfile`、`Makefile`、`package.json`、`.gitignore`、`README`、`CHANGELOG` 等具名文件。 -`FileTypeIcon` 接受路径、共享 `IconProps`、显式 `kind`和可选的项目文件快照。传统文件类型把所提供的 28px 文档与文件夹轮廓渲染为 inline SVG。Excel、Markdown、PDF、PPT、Word 的前景标记围绕自身视觉中心缩放至 122%,其余带标记的传统图形使用 112%;文件底板与折角保持源图几何,通用文件不凭空增加中心标记。底板使用实色分类颜色,前景标记与普通折角使用白色,通用文件使用较深的灰色折角。CSS 通过静态设计 token 分配所提供的分类调色板:code/HTML/Markdown 使用 DeepSeek 蓝,Word 使用较浅的 DeepSeek 蓝,Excel 使用绿色,folder/PPT 使用两档琥珀色,PDF 使用红色,未知文件使用中性灰。image 与 video 通过组件本地变量共用所提供的紫色,因为设计平台没有匹配的紫色 token。调用方可通过 `--dsh-file-type-icon-color` 覆盖传统底板颜色。 +`FileTypeIcon` 接受路径、共享 `IconProps`、显式 `kind`和可选的项目文件快照。传统文件类型把所提供的 28px 文档与文件夹轮廓渲染为 inline SVG。表格图形使用所提供的未缩放 1.2px 表格网格;Markdown、PDF、PPT、Word 的前景标记围绕自身视觉中心缩放至 122%,其余带标记的传统图形使用 112%。文件底板与折角保持源图几何,通用文件不凭空增加中心标记。底板使用实色分类颜色,前景标记与普通折角使用白色,通用文件使用较深的灰色折角。CSS 通过静态设计 token 分配所提供的分类调色板:code/HTML/Markdown 使用 DeepSeek 蓝,Word 使用较浅的 DeepSeek 蓝,表格使用绿色,folder/PPT 使用两档琥珀色,PDF 使用红色,未知文件使用中性灰。image 与 video 通过组件本地变量共用所提供的紫色,因为设计平台没有匹配的紫色 token。调用方可通过 `--dsh-file-type-icon-color` 覆盖传统底板颜色。 -已识别的代码与配置文件把对应的 20px 方形图稿缩放到请求的图标尺寸。内嵌静态表只包含现有 48 个 `CodeFileType` 条目;资源包中额外的图稿不会新增类别,相邻 manifest 记录负责的设计归属方与来源摘要。测试会拒绝该表中的脚本、事件属性、外部引用与重复 id。`CodeFileIcon` 在插入前为本地 SVG id 加上组件实例前缀,使重复渐变与裁剪路径互不干扰。这些技术标记保留自身内嵌的多色填充,是普通 current-color 图标规则的明确例外。映射让 React 优先于 TypeScript/JavaScript、Angular 文件名后缀优先于基础扩展名,并按文件名识别 Docker/Node/Git/Make/CMake;只有可选项目快照包含内容带 `flutter:` 的 `pubspec.yaml` 时才选择 Flutter。Markdown 与 SVG 仍由传统 Markdown 和图片类别拥有。CSV 和 TSV 在文件卡片、文件行及预览标题中使用 code 图标,其可点击链接也使用 code。`.env` 和以 `.env` 结尾的文件名均使用环境配置图标。所有传统与技术 SVG 都是 `aria-hidden` 的,拥有文件身份的卡片、行或按钮提供无障碍名称。 +已识别的代码与配置文件把对应的 20px 方形图稿缩放到请求的图标尺寸。内嵌静态表只包含现有 48 个 `CodeFileType` 条目;资源包中额外的图稿不会新增类别,相邻 manifest 记录负责的设计归属方与来源摘要。测试会拒绝该表中的脚本、事件属性、外部引用与重复 id。`CodeFileIcon` 在插入前为本地 SVG id 加上组件实例前缀,使重复渐变与裁剪路径互不干扰。这些技术标记保留自身内嵌的多色填充,是普通 current-color 图标规则的明确例外。映射让 React 优先于 TypeScript/JavaScript、Angular 文件名后缀优先于基础扩展名,并按文件名识别 Docker/Node/Git/Make/CMake;只有可选项目快照包含内容带 `flutter:` 的 `pubspec.yaml` 时才选择 Flutter。Markdown 与 SVG 仍由传统 Markdown 和图片类别拥有。CSV、TSV、Excel 工作簿与模板、OpenDocument 表格和 Numbers 在文件卡片、文件行及预览标题中使用表格图标,其可点击链接使用文档类别。`.env` 和以 `.env` 结尾的文件名均使用环境配置图标。所有传统与技术 SVG 都是 `aria-hidden` 的,拥有文件身份的卡片、行或按钮提供无障碍名称。 -`LinkIcon` 委托 `classifyFileType` 做扩展名分类,再把精细结果折叠进原有链接词汇:code 与 HTML 使用 `code`,图片使用 `image`,PDF/Word/Excel/PPT 使用 `document`,Markdown、video 与未知文件使用 `other`。无扩展名文件在链接语境中仍是 `other`,因此[可点击链接决策](2026-09-04-web-clickable-link-styles.zh.md)定义的 14px 外观不变。 +`LinkIconMedium` 委托 `classifyFileType` 做扩展名分类,再把精细结果折叠进原有链接词汇:code 与 HTML 使用 `code`,图片使用 `image`,PDF/Word/Excel/PPT 使用 `document`,Markdown、video 与未知文件使用 `other`。无扩展名文件在链接语境中仍是 `other`,因此[链接样式规则](../../../../docs/web-styling.zh.md)定义的 14px 外观不变。 -附件上传卡片、已发送消息文件卡片、排队文件行和工作区文件行渲染 `FileTypeIcon`。Files 标签页标题使用显式的 `folder` 类别,尺寸为 16px。显式交付卡片使用 20px 的 `FileTypeIcon`,并通过 `fileExtension` 提供默认元数据。两处元数据行使用 `fileExtension`,不再保留本地解析器;`.env` 这样的前导点 basename 会显示 `ENV`,无后缀或末尾点号则不显示扩展名 label。图片内容继续渲染为预览而不是文件类型图形,产物文件链接与 Markdown 文件提及继续使用 `LinkIcon`,因为它们属于链接表面。 +附件上传卡片、已发送消息文件卡片、排队文件行和工作区文件行渲染 `FileTypeIcon`。Files 标签页标题使用显式的 `folder` 类别,尺寸为 16px。显式交付卡片使用 20px 的 `FileTypeIcon`,并通过 `fileExtension` 提供默认元数据。两处元数据行统一使用 `fileExtension`;`.env` 这样的前导点 basename 会显示 `ENV`,无后缀或末尾点号则不显示扩展名 label。图片内容继续渲染为预览而不是文件类型图形,产物文件链接与 Markdown 文件提及继续使用 `LinkIconMedium`,因为它们属于链接表面。 ## 备选方案 -**所有文件表面都使用 `LinkIcon`。** 否决。它的六种类别和 14px outline 图形用于在文字尺寸下表达链接目标;28px 文件卡片有空间表达所提供的 HTML、Markdown、PDF、Word、Excel、PPT 与 video 身份。 +**所有文件表面都使用 `LinkIconMedium`。** 否决。它的六种类别和 14px outline 图形用于在文字尺寸下表达链接目标;28px 文件卡片有空间表达所提供的 HTML、Markdown、PDF、Word、Excel、PPT 与 video 身份。 -**在 `LinkIcon` 旁保留第二份扩展名表。** 否决。任一列表增长后,同一路径可能漂移到不同类别。一份精细表加一个显式的精细到链接适配,能同时保住两类消费方的语义。 +**在 `LinkIconMedium` 旁保留第二份扩展名表。** 否决。任一列表增长后,同一路径可能漂移到不同类别。一份精细表加一个显式的精细到链接适配,能同时保住两类消费方的语义。 **把所提供的传统文件颜色直接写入每一条 SVG path。** 否决。传统 SVG 几何保持可复用,并遵守图标集的 `currentColor` 规则;组件样式表拥有默认分类调色板,渲染点只需一个 CSS 变量即可覆盖颜色,不需要重写 path 填充。技术图稿是例外,因为其内嵌的多色标记用于识别语言或工具,而不是装饰通用文件轮廓。 @@ -32,7 +32,7 @@ Status: implemented ## 测试 -`ui-primitives` 测试覆盖不区分大小写的后缀、两种路径分隔符、前导点文件、无后缀与末尾点号、常见具名文件、未知回退、全部细分代码映射、规则优先级、Flutter 上下文、精确的 48 键图稿集、被拒绝的资源包额外类别、静态 markup 安全性、实例安全的 SVG id 与引用、`aria-hidden`、尺寸/class 转发、不同图稿、112% 与 122% 前景标记变换,以及不含 SVG 字面颜色的传统实色底板/对比标记层。样式表测试钉住每一项传统类别到颜色的映射、调用方覆盖变量与本地紫色值。既有 `LinkIcon` 分类表钉住它的粗粒度输出,包括 `Makefile` 仍为 `other`。附件、聊天、队列和侧边栏组件测试覆盖迁移后的渲染路径;由于图形仍是装饰性的,其无障碍输出不变。 +`ui-primitives` 测试覆盖不区分大小写的后缀、两种路径分隔符、前导点文件、无后缀与末尾点号、常见具名文件、未知回退、全部细分代码映射、规则优先级、Flutter 上下文、精确的 48 键图稿集、被拒绝的资源包额外类别、静态 markup 安全性、实例安全的 SVG id 与引用、`aria-hidden`、尺寸/class 转发、不同图稿、所提供的表格网格、112% 与 122% 前景标记变换,以及不含 SVG 字面颜色的传统实色底板/对比标记层。样式表测试钉住每一项传统类别到颜色的映射、调用方覆盖变量与本地紫色值。`LinkIconMedium` 分类表钉住它的粗粒度输出,包括 `Makefile` 仍为 `other`。附件、聊天、队列和侧边栏组件测试覆盖迁移后的渲染路径;由于图形仍是装饰性的,其无障碍输出不变。 ## 后果 diff --git a/.agents/notes/implemented/feature/2026-09-09-turn-duration-hour-unit.md b/.agents/notes/implemented/feature/2026-09-09-turn-duration-hour-unit.md deleted file mode 100644 index f0f5cb478d..0000000000 --- a/.agents/notes/implemented/feature/2026-09-09-turn-duration-hour-unit.md +++ /dev/null @@ -1,29 +0,0 @@ -# Agent Note: Turn duration labels gain an hour unit - -Status: implemented - -English | [中文](2026-09-09-turn-duration-hour-unit.zh.md) - -## Problem - -The Web chat's turn duration labels counted minutes without bound. `formatRunDuration` in [message-chrome.ts](../../../../packages/client/ui-chat/src/client/chat/message-chrome.ts) split elapsed milliseconds into seconds and minutes only, so a turn that ran for 90 minutes read `90分05秒` / `90m 05s` in all three places sharing the formatter: the `Deep diving...` running clock, the settled `Ran for {duration}` footer, and the turn-time dialog's total. The archived [turn run time decision](../../archived/feature/2026-08-03-web-turn-run-time.md) fixed the clock's anchor and the shared whole-second floor; it left the formatter at two units, which stops reading correctly once a turn crosses an hour. - -## Decision - -`formatRunDuration` carries an hour branch: elapsed time at or above 3600 seconds renders through `duration.hours` with zero-padded minutes and seconds — `1小时05分03秒` / `1h 05m 03s` — while everything below an hour keeps the existing second and minute branches unchanged. Hours appear only at or above 3600 seconds, so 3599 seconds still reads `59分59秒` and `60分00秒` never appears. Seconds are retained rather than dropped once hours appear, because the running clock ticks every second and a `1小时05分` label would sit still for a minute at a time. Negatives still clamp to zero and partial seconds still floor. `duration.hours` joins both dictionaries in [locale.ts](../../../../packages/client/ui-chat/src/client/locale.ts), and `RunDurationTranslate` widens to three keys. - -The change is confined to the turn formatter. `StatsPills.formatDuration` — the session-wide aggregate pill reading `45.2s` / `2m42s` — keeps its own two-unit format. - -## Alternatives considered - -**Pair hours with minutes only.** Dropping seconds at the hour boundary matches the `ui-jobs` job-duration format and keeps the label short. It loses the second-level figure from the settled footer, and it makes the live clock look frozen: the label would change once a minute while the turn is still running. - -**Three units while running, two once settled.** Rejected because both readings come from one function by design — the archived decision pins that — so the same turn would report different precision before and after it settles. - -**Change the aggregate pill in the same change.** `StatsPills.formatDuration` measures session-wide aggregates — LLM time, tool time, and average TTFT — from projections rather than one turn's boundaries. It can exceed an hour too, but folding it in would mix two independent formatters and their tests into one change. - -**Leave minutes unbounded.** `90分05秒` is technically correct and costs nothing to keep, but it is the reading that prompted this change and grows harder to parse the longer a turn runs. - -## Consequences - -Long turns now read in hours without new session events or new timing state; the labels remain derived from the logged `turn/start` and `turn/end` boundaries and keep the 15-second clock delay. The unit spec covers the 3599-second and 3600-second boundary plus the English template. No recorded-session snapshot changes: every shipped `Deep diving...` expectation is captured before the clock appears. diff --git a/.agents/notes/implemented/feature/2026-09-09-turn-duration-hour-unit.zh.md b/.agents/notes/implemented/feature/2026-09-09-turn-duration-hour-unit.zh.md deleted file mode 100644 index c9c94eef2a..0000000000 --- a/.agents/notes/implemented/feature/2026-09-09-turn-duration-hour-unit.zh.md +++ /dev/null @@ -1,29 +0,0 @@ -# Agent Note: 回合耗时标签增加小时单位 - -Status: implemented - -[English](2026-09-09-turn-duration-hour-unit.md) | 中文 - -## 问题 - -Web 聊天的回合耗时标签分钟数无界增长。[message-chrome.ts](../../../../packages/client/ui-chat/src/client/chat/message-chrome.ts) 里的 `formatRunDuration` 只把毫秒拆成秒和分两级,于是一个跑了 90 分钟的回合在共用一个格式化器的三处都读作 `90分05秒` / `90m 05s`:`Deep diving...` 后的实时计时、收尾的 `Ran for {duration}` 页脚,以及耗时详情弹窗里的总用时。已归档的[回合运行时长决策](../../archived/feature/2026-08-03-web-turn-run-time.md)确定了计时锚点和共用的整秒向下取整;它把格式化器留在两级单位,一旦回合跨过一小时就不再正确。 - -## 决定 - -`formatRunDuration` 增加小时分支:总时长达到或超过 3600 秒时经 `duration.hours` 渲染,分和秒补零——`1小时05分03秒` / `1h 05m 03s`——不足一小时的时长保持原有的秒、分两级不变。小时只在达到或超过 3600 秒时出现,因此 3599 秒仍读作 `59分59秒`,不会出现 `60分00秒`。出现小时后保留秒而不丢弃,因为实时计时每秒都在走,`1小时05分` 这样的标签会整整一分钟静止不动。负值仍然钳到 0,不足一秒仍然向下取整。[locale.ts](../../../../packages/client/ui-chat/src/client/locale.ts) 的两套字典都新增 `duration.hours`,`RunDurationTranslate` 扩为三个键。 - -改动只限于回合计时器。`StatsPills.formatDuration`——会话级聚合胶囊,读作 `45.2s` / `2m42s`——保留自己的两级格式。 - -## 考虑过的替代方案 - -**小时只与分配对。** 在小时边界丢掉秒与 `ui-jobs` 的任务时长格式一致,标签也更短。代价是收尾页脚失去秒级数字,并且实时计时看起来像卡住了:回合还在跑,标签却每分钟才变一次。 - -**运行中三单位、收尾两单位。** 拒绝,因为两处读数按设计来自同一个函数——归档决策锁定了这一点——同一个回合会在结束前后报告不同的精度。 - -**在同一次改动里改聚合胶囊。** `StatsPills.formatDuration` 统计的是来自投影的会话级聚合值——LLM 耗时、工具耗时与平均 TTFT——而不是单个回合的边界。它同样可能超过一小时,但把它折进来会让两个独立的格式化器及其测试混进同一次改动。 - -**保留分钟无界。** `90分05秒` 在技术上没错,保留也不花成本,但它正是引发这次改动的读数,而且回合跑得越久越难解析。 - -## 后果 - -长回合现在按小时展示,没有新增会话事件或计时状态;标签仍由日志中的 `turn/start` 与 `turn/end` 边界派生,并保留 15 秒后才出现计时的延迟。单元 spec 覆盖 3599 秒与 3600 秒的边界以及英文模板。录制会话快照没有变化:所有已发布的 `Deep diving...` 期望都在计时出现之前就被捕获。 diff --git a/.agents/notes/implemented/feature/2026-09-10-connection-indicator-refinements.i18n.yaml b/.agents/notes/implemented/feature/2026-09-10-connection-indicator-refinements.i18n.yaml deleted file mode 100644 index 7d4eb35fa1..0000000000 --- a/.agents/notes/implemented/feature/2026-09-10-connection-indicator-refinements.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# 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-10-connection-indicator-refinements.md -2026-09-10-connection-indicator-refinements.md: 3b6b4c7fd0962edbb87be17c2fa45eded46ee86b -2026-09-10-connection-indicator-refinements.zh.md: 0bd5dc3e0b5b69cf2d9491e2d3ce5711c96898f5 diff --git a/.agents/notes/implemented/feature/2026-09-10-connection-indicator-refinements.md b/.agents/notes/implemented/feature/2026-09-10-connection-indicator-refinements.md deleted file mode 100644 index 3b6b4c7fd0..0000000000 --- a/.agents/notes/implemented/feature/2026-09-10-connection-indicator-refinements.md +++ /dev/null @@ -1,29 +0,0 @@ -# Agent Note: Connection indicator state and interaction refinements - -Status: implemented - -English | [中文](2026-09-10-connection-indicator-refinements.zh.md) - -## Problem - -The sidebar connection pill hid its affordance behind a hover swap: outage and retry-attempt states replaced their label with **Reconnect now** on hover or focus, so every state had to reserve the widest supplied label to keep the control from resizing. A retry that resolved in under a second flickered the connecting pill in and out, and state changes and unmounts jumped with no transition. - -## Decision - -**The disconnected pill shows its action statically.** [ConnectionIndicator.tsx](../../../../packages/client/ui-primitives/src/ConnectionIndicator.tsx) renders a permanent retry glyph (`IconRefreshOutline14`) beside the outage copy (`连接异常,刷新重试` / `Disconnected`; the Chinese copy also names the retry action); clicking the pill still reconnects immediately. The hover label swap and the hidden widest-label size-reservation spans are gone, so the pill sizes to its current label. The connecting state shows a rotating-arc spinner instead of the exclamation glyph. Appearance and removal fade over 150ms — swaps between visible states replace content in place: `EXIT_MS` delays unmount to match the stylesheet's `.leaving` transition, and `prefers-reduced-motion` disables every animation and transition. Chrome settles at 28px height, 8px horizontal padding, 4px icon gap, 13px radius, and a 1px border of the label color at 20% alpha. - -**The shell owns attempt pacing.** [SettingsRoot.tsx](../../../../packages/client/ui-settings-general/src/client/SettingsRoot.tsx) keeps the connecting pill visible for at least `CONNECTING_MIN_VISIBLE_MS` (800ms) so sub-second retries do not flicker; every attempt, manual or automatic, reads the one label `重新连接中` (`connection.connecting`). The two-second recovery confirmation (`RECOVERY_CONFIRMATION_MS`) starts when the recovered pill becomes visible, so a hold that delays its appearance never shortens the confirmation. Both timings are built-in presentation constants of their owners, not configuration. - -## Alternatives considered - -**Animating width changes.** A FLIP-style measured pixel transition (remember the old width, pin it, transition to the new measurement) needs a layout effect and imperative style writes; the fade-only change reads calm enough without them. - -**Swapping to the retry glyph only on hover.** Showing the retry glyph permanently states the affordance without requiring any pointer interaction, matching the static label; a hover cross-fade adds interaction-dependent state and conveys nothing extra. - -**Scaling on enter/exit.** A 0.98 scale beside the opacity fades reads as jitter at 12px text, so only opacity animates. - -**Naming manual and automatic attempts differently.** `ConnectionController.emitState` deduplicates repeated `connecting` states across backoff attempts, so the shell cannot observe attempt boundaries: a shell-held manual-retry flag either flips the label mid-hold or sticks across later automatic attempts. Distinguishing the copy correctly requires the connection layer to expose the attempt origin, which this change does not need — both attempt kinds read the same label. - -## Consequences - -`ConnectionIndicator`'s `reconnectLabel` prop and its size-reservation spans are removed from the pre-stable API; the sole consumer (`ui-settings-general`) is updated in the same change. `settings-root.client.spec.tsx` pins the 800ms hold, the single attempt label held steady through the hold, and the visibility-based confirmation window; `atoms.client.spec.tsx` pins the exit-duration unmount; `lifecycle-chrome.e2e.ts` and its ARIA golden replay the recovery flow in a real browser. Both packages' READMEs restate the interaction. diff --git a/.agents/notes/implemented/feature/2026-09-10-connection-indicator-refinements.zh.md b/.agents/notes/implemented/feature/2026-09-10-connection-indicator-refinements.zh.md deleted file mode 100644 index 0bd5dc3e0b..0000000000 --- a/.agents/notes/implemented/feature/2026-09-10-connection-indicator-refinements.zh.md +++ /dev/null @@ -1,29 +0,0 @@ -# Agent Note: 连接指示器状态与交互细化 - -Status: implemented - -[English](2026-09-10-connection-indicator-refinements.md) | 中文 - -## Problem - -侧边栏连接药丸把操作提示藏在悬停切换里:断连与重试状态在悬停或聚焦时把文案替换为**立即重连**,因此每个状态都要为最宽的 label 预留空间以避免控件变形。一次不到一秒就恢复的重试会让连接中药丸闪现闪没,状态切换和消失也没有任何过渡、十分突兀。 - -## Decision - -**断连药丸静态地展示其动作。** [ConnectionIndicator.tsx](../../../../packages/client/ui-primitives/src/ConnectionIndicator.tsx) 在断连文案旁常驻渲染重试图形(`IconRefreshOutline14`),文案为 `连接异常,刷新重试` / `Disconnected`(中文文案同时点明重试动作);点击药丸仍会立即重连。悬停换文案和隐藏的最宽 label 占位 span 全部移除,药丸宽度随当前 label 自适应。连接中状态改用旋转圆弧 spinner 取代感叹号图形。出现与移除以 150ms 淡入淡出——可见状态之间的切换则原地替换内容:`EXIT_MS` 延迟卸载以匹配样式表的 `.leaving` 过渡,`prefers-reduced-motion` 会禁用全部动画与过渡。外观定为高 28px、水平内边距 8px、图标间距 4px、圆角 13px,以及 label 颜色 20% 透明度的 1px 边框。 - -**外壳拥有尝试节奏。** [SettingsRoot.tsx](../../../../packages/client/ui-settings-general/src/client/SettingsRoot.tsx) 让连接中药丸至少可见 `CONNECTING_MIN_VISIBLE_MS`(800ms),亚秒级重试不再闪动;无论手动还是自动,每次尝试都显示同一个文案`重新连接中`(`connection.connecting`)。2 秒恢复确认(`RECOVERY_CONFIRMATION_MS`)从恢复药丸实际可见时起算,驻留推迟其出现也不会缩短确认时长。两个时长是各自持有方的内置展示常量,不是配置。 - -## Alternatives considered - -**给宽度变化加动画。** FLIP 式的像素测量过渡(记住旧宽度、钉住、过渡到新测量值)需要一个 layout effect 和命令式样式写入;纯淡入淡出已足够平静,无需这些。 - -**仅在悬停时切换为重试图形。** 常驻显示重试图形无需任何指针交互就说明了操作,与静态文案一致;悬停交叉渐变引入依赖交互的状态却不传达更多信息。 - -**进出场缩放。** 0.98 的缩放叠加在透明度淡入淡出上,在 12px 文字上读起来像抖动,因此只保留透明度。 - -**手动与自动尝试使用不同命名。** `ConnectionController.emitState` 会去重退避尝试之间重复的 `connecting` 状态,外壳观察不到尝试边界:外壳自持的手动重试标志要么在驻留中途翻转文案,要么在后续自动尝试中一直滞留。要正确区分文案需要连接层暴露尝试来源,而本次变更并不需要——两种尝试显示同一文案。 - -## Consequences - -`ConnectionIndicator` 的 `reconnectLabel` prop 及其占位 span 从 pre-stable API 中移除;唯一消费者(`ui-settings-general`)在同一变更中更新。`settings-root.client.spec.tsx` 固定 800ms 驻留、驻留期间保持不变的单一尝试文案,以及按可见时刻起算的确认窗口;`atoms.client.spec.tsx` 固定退出时长后的卸载;`lifecycle-chrome.e2e.ts` 及其 ARIA golden 在真实浏览器中回放恢复流程。两个包的 README 重述了该交互。 diff --git a/.agents/notes/implemented/feature/2026-09-10-guide-start-page-and-stat-pill-refinements.i18n.yaml b/.agents/notes/implemented/feature/2026-09-10-guide-start-page-and-stat-pill-refinements.i18n.yaml deleted file mode 100644 index dd2b16d82c..0000000000 --- a/.agents/notes/implemented/feature/2026-09-10-guide-start-page-and-stat-pill-refinements.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# 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-10-guide-start-page-and-stat-pill-refinements.md -2026-09-10-guide-start-page-and-stat-pill-refinements.md: 987e46fd597b11046843a943c60187fdb1a769d0 -2026-09-10-guide-start-page-and-stat-pill-refinements.zh.md: 590f18a2963507a8918a0aca30997aac23c6e477 diff --git a/.agents/notes/implemented/feature/2026-09-10-guide-start-page-and-stat-pill-refinements.md b/.agents/notes/implemented/feature/2026-09-10-guide-start-page-and-stat-pill-refinements.md deleted file mode 100644 index 987e46fd59..0000000000 --- a/.agents/notes/implemented/feature/2026-09-10-guide-start-page-and-stat-pill-refinements.md +++ /dev/null @@ -1,29 +0,0 @@ -# Agent Note: Guide start page and stat pill refinements - -Status: implemented - -English | [中文](2026-09-10-guide-start-page-and-stat-pill-refinements.zh.md) - -## Problem - -The right Sidebar's guide tab was a bare list of entry capsules: no visual anchor above them, a capsule could only say its title, and an entry whose type registered no glyph rendered with no icon at all, so a mixed list read as broken rather than sparse. Separately, the session token-usage dialog under the composer printed a `Cache write 0 tok` row for sessions that never wrote cache. - -## Decision - -**The guide is a compass over self-describing capsules.** This refines the guide contracts in [Right Sidebar tab types and navigation](../architecture/2026-09-05-sidebar-tab-types-and-navigation.md) and [Sidebar text preview and file tree](2026-09-05-sidebar-text-preview-and-file-tree.md). [GuideBody.tsx](../../../../packages/client/ui-sidebar-right/src/client/tabs/guide/GuideBody.tsx) draws a muted 56px compass hero over the entry capsules and no heading, as a browser start page shows its doors without a caption. [`SidebarRightGuideEntry`](../../../../packages/client/ui-sidebar-right/src/client/tab-registry.ts) gains an optional thunked `description` — read fresh on every render like `title`, so language changes need no re-registration. A capsule shows its description under the title only while the guide lists at most `MAX_DESCRIBED_ENTRIES` (4) entries; a longer list drops every description to stay light, so a type must stand on its title. The glyph rides the capsule's height: 22px beside a bare title, 26px beside two lines. - -**Icon-less entries fall back to a shipped cube placeholder.** The fallback is decided at the render site (`entry.icon ?? CubeGlyph`), not at registration, so every contributor — builtin or extension — gets it uniformly and a chain replacement of the body replaces the rule with it. `CubeGlyph` lives beside `CompassGlyph` in [GuideTitle.tsx](../../../../packages/client/ui-sidebar-right/src/client/tabs/guide/GuideTitle.tsx): an isometric box in 1.1px straight strokes with rounded joins on `currentColor`, drawn on `--dsw-alias-label-tertiary` — one step quieter than a registered glyph's ink — to mark the slot as unclaimed. The files type registers a description and the shared folder glyph in [definition.tsx](../../../../packages/client/ui-sidebar-files/src/client/definition.tsx). - -**The session usage dialog drops the zero cache-write row.** This refines [Composer session stats](2026-09-07-composer-session-stats-pills.md). [StatsPills.tsx](../../../../packages/client/ui-chat/src/client/chat/StatsPills.tsx) renders the `Cache write` row only when `cacheWriteTokens !== 0`, as the per-turn panel already drops its absent optional fields; the always-present buckets (input, cache read, output) keep their rows. - -## Alternatives considered - -**Register the cube in `ui-primitives`.** Its `icons/index.tsx` is the imported figma `ic_ds_*` set, and the cube has a single consumer; `CompassGlyph` set the precedent of package-local guide glyphs. - -**Default the icon at registration time.** A `?? default` inside the registry would hide the fallback from the body and make "registered no glyph" undetectable, losing the quieter placeholder ink; explicit render-site fallback keeps registrations honest. - -**Show `Cache write 0`.** A session on a provider that never writes cache would carry the row forever; zero here means "not a thing", not a measurement. - -## Consequences - -`description` is new pre-stable registry API; every consumer was updated (the files entry registers one). The 4-entry threshold is a shipped constant of the guide body, not configuration. Guide-body specs cover the placeholder (size and ink), the description threshold on both sides, and the registered-glyph path; chat-stats specs cover the dropped and present cache-write row. The `ui-sidebar-right` and `ui-sidebar-files` READMEs restate the guide rules. diff --git a/.agents/notes/implemented/feature/2026-09-10-guide-start-page-and-stat-pill-refinements.zh.md b/.agents/notes/implemented/feature/2026-09-10-guide-start-page-and-stat-pill-refinements.zh.md deleted file mode 100644 index 590f18a296..0000000000 --- a/.agents/notes/implemented/feature/2026-09-10-guide-start-page-and-stat-pill-refinements.zh.md +++ /dev/null @@ -1,29 +0,0 @@ -# Agent Note: 引导起始页与统计 pill 的细化 - -Status: implemented - -[English](2026-09-10-guide-start-page-and-stat-pill-refinements.md) | 中文 - -## Problem - -右侧边栏的引导 tab 只是一列光秃秃的入口胶囊:上方没有视觉锚点,胶囊只能显示标题,而类型没注册图标的入口干脆不画图标,混合列表看起来像坏了而不是稀疏。另外,输入框下方的会话 token 用量弹窗会给从未写入缓存的会话打印一行 `Cache write 0 tok`。 - -## Decision - -**引导页是罗盘压着能自我说明的胶囊。** 本决议细化[右侧 Sidebar tab 类型与导航](../architecture/2026-09-05-sidebar-tab-types-and-navigation.zh.md)和 [Sidebar 文本预览与文件树](2026-09-05-sidebar-text-preview-and-file-tree.zh.md)中的引导页约定。[GuideBody.tsx](../../../../packages/client/ui-sidebar-right/src/client/tabs/guide/GuideBody.tsx) 在入口胶囊上方画一枚弱化的 56px 罗盘,不加标题,如同浏览器起始页不给它的入口配说明文字。[`SidebarRightGuideEntry`](../../../../packages/client/ui-sidebar-right/src/client/tab-registry.ts) 新增可选的 thunk 化 `description`——与 `title` 一样每次渲染重新读取,语言切换无需重新注册。仅当引导页列出的入口不超过 `MAX_DESCRIBED_ENTRIES`(4)个时,胶囊才在标题下显示描述;更长的列表去掉所有描述以保持轻盈,所以类型必须靠标题立得住。图标随胶囊高度变化:单行标题旁 22px,两行旁 26px。 - -**没有图标的入口回退到内置的立方体占位符。** 回退在渲染点决定(`entry.icon ?? CubeGlyph`)而不在注册时,因此每个贡献者——内置或扩展——得到统一的占位符,链式替换 body 时规则随之整体替换。`CubeGlyph` 与 `CompassGlyph` 一起放在 [GuideTitle.tsx](../../../../packages/client/ui-sidebar-right/src/client/tabs/guide/GuideTitle.tsx):一只等距视角的盒子,1.1px 直线描边、圆角拼接、走 `currentColor`,用 `--dsw-alias-label-tertiary` 着色——比注册图标的墨色浅一档——标记这个槽位无人认领。files 类型在 [definition.tsx](../../../../packages/client/ui-sidebar-files/src/client/definition.tsx) 注册了描述和共享的文件夹图标。 - -**会话用量弹窗去掉为零的缓存写入行。** 本决议细化[输入框下的会话统计](2026-09-07-composer-session-stats-pills.zh.md)。[StatsPills.tsx](../../../../packages/client/ui-chat/src/client/chat/StatsPills.tsx) 仅当 `cacheWriteTokens !== 0` 时渲染 `Cache write` 行,与 per-turn 面板去掉缺失可选字段的做法一致;始终存在的桶(输入、缓存读取、输出)保留各自的行。 - -## Alternatives considered - -**把立方体注册进 `ui-primitives`。** 其 `icons/index.tsx` 是导入的 figma `ic_ds_*` 集合,而立方体只有一个消费者;`CompassGlyph` 已开了引导图标包内自持的先例。 - -**在注册时默认图标。** 注册表内部的 `?? default` 会对 body 隐藏回退,让"没注册图标"无法辨认,浅色占位墨色随之丢失;显式的渲染点回退让注册保持诚实。 - -**显示 `Cache write 0`。** 用不写缓存的 provider 的会话会永远挂着这一行;这里的零意味着"没有这回事",不是一次测量。 - -## Consequences - -`description` 是新的 pre-stable 注册表 API;所有消费者已同步更新(files 入口注册了一个)。4 个入口的阈值是引导 body 的内置常量,不是配置。guide-body 用例覆盖占位符(尺寸与墨色)、描述阈值两侧和已注册图标路径;chat-stats 用例覆盖缓存写入行的去除与保留。`ui-sidebar-right` 与 `ui-sidebar-files` 的 README 重述了引导页规则。 diff --git a/.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.i18n.yaml b/.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.i18n.yaml index 46cf21a20b..f24e9aa057 100644 --- a/.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.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-13-macos-hidden-titlebar-vibrancy.md -2026-09-13-macos-hidden-titlebar-vibrancy.md: 1bc531953cedac8633c4baa6da45aa9bcd77a728 -2026-09-13-macos-hidden-titlebar-vibrancy.zh.md: bbc593badbabdccaa037cca836a9554929d614bc +2026-09-13-macos-hidden-titlebar-vibrancy.md: 99ac240698fb229f9a0d66a8000251058b52491e +2026-09-13-macos-hidden-titlebar-vibrancy.zh.md: 26bbb616485e49de2f70a3d142ea84dfd5bd7cb9 diff --git a/.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.md b/.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.md index 1bc531953c..99ac240698 100644 --- a/.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.md +++ b/.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.md @@ -18,7 +18,7 @@ Every macOS web-side adjustment keys off `html[data-platform='darwin']`, which o **Native theme sync.** The vibrancy material follows `nativeTheme.themeSource`, which otherwise tracks the OS appearance and diverges from the app's own theme preference. The ui-theme boot script and ui-layout `ThemePresenter` publish `html[data-ds-theme-source]` (`light`, `dark`, or `system`; a fixed preference, including registered theme ids, publishes its resolved scheme). The app preload observes the attribute and forwards it over `dsh-desktop:native-theme-set`; main validates the value and the sender (the main window's WebContents, including its local static Web document) and assigns `nativeTheme.themeSource`. Publishing the preference rather than the resolved scheme preserves OS-follow while the preference is `system`. -**Sidebar top strip and full hide.** On darwin the sidebar opens with a 52px top strip that clears the traffic lights, carries the collapse toggle, and is a window drag region (`-webkit-app-region: drag`; the toggle opts out). Collapsing the sidebar hides the column entirely — `computeColumns` takes an explicit `collapsedWidth` and the AppFrame passes 0 on darwin desktop — instead of the 56px rail the other platforms keep. The reopen affordances move into the conversation header: a new single, session-scoped slot `conversation.session.header.leading` sits before the breadcrumbs, and ui-sidebar registers `HeaderLeadingControls` (open sidebar + New Session, reusing the shell's inject face and locale) into it. Visibility is pure CSS against the AppFrame-published `data-sidebar-collapsed` attribute; no collapse-state pipe is added. The blank-session header keeps the leading seat mounted on darwin so a hidden sidebar always has a reopen control on screen. +**Sidebar top strip and full hide.** On darwin the sidebar opens with a 52px top strip that clears the traffic lights, carries the collapse toggle, and is a window drag region (`-webkit-app-region: drag`; the toggle opts out). Collapsing the sidebar hides the column entirely — `computeColumns` takes an explicit `collapsedWidth` and the AppFrame passes 0 on darwin desktop — instead of the 56px rail the other platforms keep. The reopen affordances move into the conversation header: a single, root-scoped slot `conversation.header.leading` sits before the breadcrumbs, and ui-sidebar registers `HeaderLeadingControls` (open sidebar + New Session, reusing the shell's inject face and locale) into it. Visibility is pure CSS against the AppFrame-published `data-sidebar-collapsed` attribute; no collapse-state pipe is added. The resident header keeps the leading seat mounted on darwin with or without a selected Session so a hidden sidebar always has a reopen control on screen. **Drag regions.** The conversation title row is a drag region on darwin with every interactive descendant opted out. Electron computes drag regions from window geometry in DOM order, not stacking: an overlay covering the title band must subtract itself or the band underneath keeps taking the pointer. The right sidebar's fullscreen panel therefore sets `-webkit-app-region: no-drag` on its whole box and restores drag on its tab strips' blank runs. @@ -40,10 +40,10 @@ Every macOS web-side adjustment keys off `html[data-platform='darwin']`, which o - The macOS window gains a translucent sidebar and hidden titlebar at zero cost to other platforms: every rule is scoped to `[data-platform='darwin']`, which only the Electron preload sets. - The vibrancy material follows the app theme, including third-party registered themes (their resolved scheme). Screenshots and screen recordings differ from the flat web rendering. -- `conversation.session.header.leading` is a public slot in the client catalog; any package can occupy the seat, and ui-sidebar's occupant assumes it may render into it whenever the platform matches. +- `conversation.header.leading` is a public slot in the client catalog; any package can occupy the seat, and ui-sidebar's occupant assumes it may render into it whenever the platform matches. - Drag-region geometry is a window-global invariant: any future overlay that covers the title band on darwin must subtract itself with `-webkit-app-region: no-drag` or its controls become unclickable. - The transparent window plus vibrancy is accepted to look different in screen sharing and may flash on startup; the transparent `backgroundColor` mitigates the flash. ## Testing -ui-theme boot and ui-layout presenter specs pin the `data-ds-theme-source` publication and disposal. The ui-sidebar apply spec pins the leading-seat registration (component, locale, shared inject face) and its removal on teardown. The ui-conversation skeleton spec pins the leading slot's render call in the active-phase header. The ui-theme corner-shape and full-round style gates cover the new stylesheet. +ui-theme boot and ui-layout presenter specs pin the `data-ds-theme-source` publication and disposal. The ui-sidebar apply spec pins the leading-seat registration (component, locale, shared inject face) and its removal on teardown. The ui-conversation skeleton spec pins the leading slot's render call with and without a selected Session. The ui-theme corner-shape and full-round style gates cover the new stylesheet. diff --git a/.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.zh.md b/.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.zh.md index bbc593badb..26bbb61648 100644 --- a/.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.zh.md +++ b/.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.zh.md @@ -18,7 +18,7 @@ Electron 主进程在 darwin 上以 `titleBarStyle: 'hiddenInset'`、`trafficLig **原生主题同步。** 毛玻璃材质跟随 `nativeTheme.themeSource`,后者默认跟踪系统外观,会与应用自身的主题偏好背离。ui-theme 引导脚本与 ui-layout 的 `ThemePresenter` 发布 `html[data-ds-theme-source]`(`light`、`dark` 或 `system`;固定偏好——包括注册主题 id——发布其解析后的配色)。应用 preload 观察该属性并经 `dsh-desktop:native-theme-set` 转发;主进程校验取值与发送者(主窗口的 WebContents,包括其本地静态 Web 文档)后赋给 `nativeTheme.themeSource`。发布偏好而非解析值,可在偏好为 `system` 时保留跟随系统。 -**侧边栏顶部条与完全隐藏。** darwin 上侧边栏展开时有一条 52px 的顶部条,避开红绿灯、承载收起按钮,并作为窗口拖拽区(`-webkit-app-region: drag`;按钮退出拖拽)。收起侧边栏时整列隐藏——`computeColumns` 接受显式 `collapsedWidth`,AppFrame 在 darwin 桌面传 0——而非其他平台保留的 56px rail。重新打开的入口移入会话头部:新增 single、session 作用域的 slot `conversation.session.header.leading` 位于面包屑之前,ui-sidebar 向其注册 `HeaderLeadingControls`(打开侧边栏 + 新会话,复用 shell 的 inject face 与 locale)。显隐纯由 CSS 依据 AppFrame 发布的 `data-sidebar-collapsed` 属性控制;不新增收起状态管道。darwin 上空白会话的头部保持 leading 座挂载,确保侧边栏隐藏时屏幕上始终有重新打开的控件。 +**侧边栏顶部条与完全隐藏。** darwin 上侧边栏展开时有一条 52px 的顶部条,避开红绿灯、承载收起按钮,并作为窗口拖拽区(`-webkit-app-region: drag`;按钮退出拖拽)。收起侧边栏时整列隐藏——`computeColumns` 接受显式 `collapsedWidth`,AppFrame 在 darwin 桌面传 0——而非其他平台保留的 56px rail。重新打开的入口移入会话头部:single、root 作用域的 slot `conversation.header.leading` 位于面包屑之前,ui-sidebar 向其注册 `HeaderLeadingControls`(打开侧边栏 + 新会话,复用 shell 的 inject face 与 locale)。显隐纯由 CSS 依据 AppFrame 发布的 `data-sidebar-collapsed` 属性控制;不新增收起状态管道。darwin 上常驻头部在选中或未选中 Session 时均保持 leading 座挂载,确保侧边栏隐藏时屏幕上始终有重新打开的控件。 **拖拽区。** darwin 上会话标题行是拖拽区,所有可交互后代退出。Electron 按 DOM 顺序以窗口几何计算拖拽区,不看层叠:覆盖标题带的浮层必须自行减除,否则下层区域仍会截获指针。因此右侧边栏的全屏面板对整个盒子设 `-webkit-app-region: no-drag`,再在其 tab 条空白处恢复拖拽。 @@ -40,10 +40,10 @@ Electron 主进程在 darwin 上以 `titleBarStyle: 'hiddenInset'`、`trafficLig - macOS 窗口获得半透明侧边栏与隐藏标题栏,对其他平台零成本:所有规则限定在 `[data-platform='darwin']` 下,该属性仅由 Electron preload 设置。 - 毛玻璃材质跟随应用主题,含第三方注册主题(取其解析配色)。截图与录屏与纯 Web 的平面渲染不同。 -- `conversation.session.header.leading` 是客户端 catalog 中的公开 slot;任何包都可占用该座位,ui-sidebar 的占用者假定平台匹配时随时可能渲染。 +- `conversation.header.leading` 是客户端 catalog 中的公开 slot;任何包都可占用该座位,ui-sidebar 的占用者假定平台匹配时随时可能渲染。 - 拖拽区几何是窗口级全局不变量:今后任何在 darwin 上覆盖标题带的浮层必须用 `-webkit-app-region: no-drag` 自行减除,否则其控件不可点击。 - 接受透明窗口 + 毛玻璃在屏幕共享中呈现不同、启动可能闪烁;透明 `backgroundColor` 缓解闪烁。 ## Testing -ui-theme 引导与 ui-layout presenter 测试钉住 `data-ds-theme-source` 的发布与清除。ui-sidebar apply 测试钉住 leading 座注册(组件、locale、共享 inject face)及 teardown 移除。ui-conversation skeleton 测试钉住 active 阶段头部对 leading slot 的渲染调用。ui-theme 的 corner-shape 与 full-round 样式门覆盖新样式表。 +ui-theme 引导与 ui-layout presenter 测试钉住 `data-ds-theme-source` 的发布与清除。ui-sidebar apply 测试钉住 leading 座注册(组件、locale、共享 inject face)及 teardown 移除。ui-conversation skeleton 测试钉住 选中或未选中 Session 时对 leading slot 的渲染调用。ui-theme 的 corner-shape 与 full-round 样式门覆盖新样式表。 diff --git a/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.i18n.yaml b/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.i18n.yaml index 4ed53fa01a..50233385bf 100644 --- a/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.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-14-desktop-primary-runtime.md -2026-09-14-desktop-primary-runtime.md: d3bb7d173450d4c27af4b5b65e16f33cc285605a -2026-09-14-desktop-primary-runtime.zh.md: 2df6effdccc09a488e322bfe6f144e18b101369c +2026-09-14-desktop-primary-runtime.md: 51bbaac89c5c2ec9a3a016030e7b320d2b5566b8 +2026-09-14-desktop-primary-runtime.zh.md: 4f27d61b5e713f66e2f478894dbd547cd7ecfff4 diff --git a/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.md b/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.md index d3bb7d1734..51bbaac89c 100644 --- a/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.md +++ b/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.md @@ -18,7 +18,7 @@ Node downloads and hash-verifies the complete locked wheel set and unpacks libra macOS grants `com.apple.security.cs.allow-jit` only to the standalone Node executable. Hardened-runtime signing without that entitlement prevents V8 from allocating its code region. Interpreter and library smoke checks run after signing as well as after staging cleanup; a valid signature alone does not establish executable behavior. -Windows signed packaging separates materialization from execution with a supervised primary-runtime signing stage. PE inspection excludes foreign-platform Node addons and refuses directory links. Valid vendor signatures remain intact; only unsigned files receive the configured EV signature. Invalid existing signatures fail before hardware access, and each new signature is checked for validity, timestamp and certificate identity before the next file. Electron-builder's copy-time signing hook preserves runtime executables only after exact-byte and signature verification; the same serial queue rejects later tasks if preservation fails. The existing per-user interlock, serialized signer and redacted journal own hardware calls; no failure permits a retry or later stage. Runtime execution receives no signing credentials and follows complete verification. Development and unsigned preparation retain native smoke without automatic hardware access. +Windows signed packaging separates materialization from execution with supervised signing stages for the primary runtime and application production dependencies. PE inspection identifies content independently of extensions, excludes non-PE foreign-platform Node addons and refuses directory links. Public-key verification uses batches of 32 files with at most four processes and drains each batch before returning failure; hardware operations remain serial. Valid vendor signatures remain intact; only unsigned files receive the configured EV signature. Invalid existing signatures fail before hardware access, and each new signature is checked for validity, timestamp and certificate identity before the next file. Electron-builder's copy-time signing hook preserves runtime executables only after exact-byte and signature verification; the same serial queue rejects later tasks if preservation fails. The existing per-user interlock, serialized signer and redacted journal own hardware calls; hardware failures prohibit retries and later stages. Public timestamp attempts follow the [signature completion policy](../process/2026-09-17-windows-signature-completion.md). Runtime execution receives no signing credentials and follows complete verification. Development and unsigned preparation retain native smoke without automatic hardware access. Runtime inventories record signed bytes. Electron-builder unpacks detected dependency PE files and preserves signed copies. Final signature verification and ASAR payload and Host smoke use a private native cache before release completion. Desktop ZIP extraction pins `extract-zip` to `yauzl` 3.4.0 through a scoped dependency override. The 2.x reader can leave large deflate entries unfinished on Node 26 ([upstream issue](https://github.com/thejoshwolfe/yauzl/issues/176)); retaining the existing extractor preserves its path validation and wheel-entry checks. The development launcher uses top-level await so unfinished preparation cannot exit successfully. A large compressed wheel regression checks the complete extracted bytes. @@ -30,8 +30,12 @@ Desktop ZIP extraction pins `extract-zip` to `yauzl` 3.4.0 through a scoped depe **Independent updates and version-named directories.** Runtime releases are coupled to Desktop, and the requested installation location is stable. +**Multi-file SignTool requests.** A software-certificate probe with a missing first file still signs the second file before returning an error. One process cannot guarantee stopping hardware operations at the first failure; token signing remains one file per supervised request. + **Signing only the interpreter or bypassing native smoke.** Windows code integrity also evaluates DLLs and Python extensions. A signed launcher cannot make an unsigned extension load, and skipping execution would hide unusable installed dependencies. Preserving valid upstream signatures avoids unnecessary hardware operations and retains upstream attribution. +Dependency manifests use electron-builder's own cleanup before inventory sealing. Its archive writer otherwise changes package metadata after signing; pre-applying that transformation keeps the final archive verifiable without replacing the sealed inventory after packaging. + ## Consequences The application carries additional native files and replaces the complete managed payload on upgrade. Running interpreters can prevent replacement on Windows. Native build smoke, install/reuse/recovery tests and a keyless tool-error session cover distinct installation and model-output paths; macOS signing uses the existing native-runtime signer. Interpreter archives and Python wheels are hash-pinned, and licenses remain with their distributions. diff --git a/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.zh.md b/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.zh.md index 2df6effdcc..4f27d61b5e 100644 --- a/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.zh.md +++ b/.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.zh.md @@ -18,7 +18,7 @@ Node 下载并校验完整锁定 wheel 集的哈希,将库文件解压到 site macOS 仅向独立 Node 可执行文件授予 `com.apple.security.cs.allow-jit`。缺少此权限的强化运行时签名会阻止 V8 分配代码区域。解释器和库的 smoke 检查在签名后以及暂存清理后执行;签名有效本身不能证明程序可运行。 -Windows 签名打包通过受监督的第一方运行时签名阶段,将文件准备与执行分开。PE 检查排除其他平台的 Node 插件,并拒绝目录链接。有效的上游签名保持不变;仅未签名文件使用配置的 EV 证书签名。已有签名无效时,在访问硬件前失败;每个新签名通过有效性、时间戳和证书身份检查后,才处理下一个文件。electron-builder 复制阶段的签名钩子仅在逐字节比对和验签通过后保留运行时可执行文件;保留验证失败时,同一串行队列拒绝后续任务。硬件调用复用现有的用户级互锁、串行签名器和脱敏日志;任何失败都不允许重试或继续后续阶段。运行时执行不接收签名凭据,并在完整验签后进行。开发和未签名准备保留本机 smoke,不自动访问硬件。 +Windows 签名打包通过受监督的签名阶段覆盖第一方运行时和应用生产依赖,将文件准备与执行分开。PE 检查独立于扩展名识别文件内容,排除其他平台的非 PE Node 原生插件,并拒绝目录链接。公钥验签每个进程处理最多 32 个文件,同时最多运行四个进程,返回失败前等待当前批次全部结束;硬件操作仍串行执行。有效的上游签名保持不变;仅未签名文件使用配置的 EV 证书签名。已有签名无效时,在访问硬件前失败;每个新签名通过有效性、时间戳和证书身份检查后,才处理下一个文件。electron-builder 复制阶段的签名钩子仅在逐字节比对和验签通过后保留运行时可执行文件;保留验证失败时,同一串行队列拒绝后续任务。硬件调用复用现有的用户级互锁、串行签名器和脱敏日志;硬件失败不允许重试或继续后续阶段。公开时间戳尝试遵循[签名完成规则](../process/2026-09-17-windows-signature-completion.zh.md)。运行时执行不接收签名凭据,并在完整验签后进行。开发和未签名准备保留本机 smoke,不自动访问硬件。运行时清单记录签名后的字节。electron-builder 将检测到的依赖 PE 文件放在归档外,并保留已签名的副本。发布完成前,最终签名检查以及 ASAR 载荷和 Host 冒烟检查使用私有原生缓存。 Desktop ZIP 解压通过定向依赖覆盖为 `extract-zip` 固定 `yauzl` 3.4.0。2.x 读取器在 Node 26 上可能无法完成较大 deflate 条目的读取([上游问题](https://github.com/thejoshwolfe/yauzl/issues/176));保留现有解压器可保留其路径校验和 wheel 条目检查。开发启动器使用顶层 await,避免准备未完成却成功退出。大压缩 wheel 回归测试检查完整的解压字节。 @@ -30,8 +30,12 @@ Desktop ZIP 解压通过定向依赖覆盖为 `extract-zip` 固定 `yauzl` 3.4.0 **独立更新和版本目录。** Runtime 发布与 Desktop 绑定,且请求的安装位置固定。 +**多文件 SignTool 请求。** 软件证书探针在第一个文件不存在时,仍会先给第二个文件签名,再返回错误。单个进程无法保证首个失败后停止硬件操作;令牌签名仍按每个受监督请求处理一个文件。 + **仅签名解释器或跳过本机 smoke。** Windows 代码完整性也检查 DLL 和 Python 扩展。启动程序有签名不能让未签名扩展被加载,跳过执行会隐藏安装后不可用的依赖。保留有效上游签名既减少硬件操作,也保留上游归属信息。 +依赖清单在记录完整性清单前使用 electron-builder 自身的清理逻辑。否则其归档写入器会在签名后修改包元数据;提前应用该转换可让最终归档通过校验,无需在打包后替换已封装的完整性清单。 + ## Consequences 应用携带额外的原生文件,并在升级时替换完整受管产物。Windows 上运行中的解释器可能阻止替换。本机构建 smoke、安装与复用及恢复测试、无密钥工具错误会话分别覆盖安装和模型输出路径;macOS 签名复用现有原生 Runtime 签名器。解释器压缩包和 Python wheel 固定哈希,许可证随各分发包保留。 diff --git a/.agents/notes/implemented/feature/2026-09-15-docs-platform-command-tabs.i18n.yaml b/.agents/notes/implemented/feature/2026-09-15-docs-platform-command-tabs.i18n.yaml new file mode 100644 index 0000000000..f6c0f88653 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-15-docs-platform-command-tabs.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-15-docs-platform-command-tabs.md +2026-09-15-docs-platform-command-tabs.md: 4d44d1191a7ffc1601e15aa4d9f119a90e06b0dd +2026-09-15-docs-platform-command-tabs.zh.md: 450765b52d1507fa9b9e6a20a1854e6861655838 diff --git a/.agents/notes/implemented/feature/2026-09-15-docs-platform-command-tabs.md b/.agents/notes/implemented/feature/2026-09-15-docs-platform-command-tabs.md new file mode 100644 index 0000000000..4d44d1191a --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-15-docs-platform-command-tabs.md @@ -0,0 +1,31 @@ +# Agent Note: Platform command tabs in the Python SDK guide + +Status: implemented + +English | [中文](2026-09-15-docs-platform-command-tabs.zh.md) + +## Problem + +The [Python SDK guide](../../../../docs/user/guide/python-sdk.md) gives different commands for POSIX shells and Windows PowerShell. Showing both versions in sequence makes readers skip an alternative at each step and can make alternative commands look cumulative. + +## Decision + +Each of the four platform-dependent steps uses VitePress's native `code-group`: Linux/macOS first, Windows PowerShell second. Each group selects independently and starts on Linux/macOS. Credential setup and running the example remain separate sequential steps. Shared Python and YAML examples stay directly visible. + +Both languages retain every command in canonical Markdown. The documentation projector preserves the groups and their code in the website's raw Markdown. Explicit anchors before each group preserve the former platform heading targets; following a platform anchor locates the group without selecting a tab. + +The theme adds a visible keyboard focus outline to native radio labels and displays both command blocks in search excerpts, whose tab controls are unavailable. A Markdown renderer adapter makes each native tab strip a form with submission prevented. Local search copies a section's rendered HTML, including radio names; separate form owners stop those copies from clearing the page's selection. VitePress owns selection, syntax highlighting, and copying the active code block. MPA builds omit those client handlers, so build-only styles display both command blocks and hide the tab strip and copy buttons. There is no shared selection state or stored preference. + +## Alternatives considered + +**Keep vertically repeated platform sections.** Both versions are immediately visible, but the repeated alternatives interrupt the guide's ordered steps. + +**Build a custom tab component with shared or persistent selection.** A Windows reader could select once, but shared state couples independent examples and introduces selection and lifecycle code beyond the required presentation. Native groups keep the interaction local. + +## Consequences + +SPA readers see one command version per step while MPA, repository, and raw-Markdown readers retain both. Windows readers select PowerShell in each group. Existing platform links still reach the corresponding step, although the platform names leave the page outline. + +Verification covers command preservation, bilingual pairing, projected and raw Markdown, and actual browser switching, copying, keyboard focus, search, themes, and narrow layouts. Framework-owned interaction is exercised on the running site; session replay is inapplicable to this documentation-only presentation. + +The focused renderer regression checks radio ownership when local search duplicates a section. The adapter depends on VitePress's native tab-strip markup and fails the build if that markup changes; a framework upgrade must retain the search isolation and browser checks. diff --git a/.agents/notes/implemented/feature/2026-09-15-docs-platform-command-tabs.zh.md b/.agents/notes/implemented/feature/2026-09-15-docs-platform-command-tabs.zh.md new file mode 100644 index 0000000000..450765b52d --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-15-docs-platform-command-tabs.zh.md @@ -0,0 +1,31 @@ +# Agent Note: Python SDK 指南的平台命令标签 + +Status: implemented + +[English](2026-09-15-docs-platform-command-tabs.md) | 中文 + +## 问题 + +[Python SDK 指南](../../../../docs/user/guide/python-sdk.zh.md)分别提供 POSIX shell 和 Windows PowerShell 命令。连续显示两个版本使读者在每一步都要跳过另一平台,也容易把二选一的命令理解为需要依次执行。 + +## 决策 + +四个平台相关步骤分别使用 VitePress 原生 `code-group`:Linux/macOS 在前,Windows PowerShell 在后。各组独立选择,初始均显示 Linux/macOS。配置凭据和运行示例仍是两个顺序步骤。共用的 Python 和 YAML 示例保持直接可见。 + +两种语言均在规范 Markdown 中保留每条命令。文档投影器在网站原始 Markdown 中保留分组及代码。各组前的显式锚点保留原有平台标题的链接目标;平台锚点定位到分组,但不选择标签。 + +主题为原生单选按钮标签补充可见的键盘焦点轮廓,并在无法操作标签的搜索摘要中显示两个命令块。Markdown 渲染适配器将各原生标签栏设为禁止提交的表单。本地搜索会复制章节渲染后的 HTML,其中包含单选按钮名称;独立的所属表单可避免副本清除页面的选择。VitePress 负责选择、语法高亮和复制当前代码块。MPA 构建不包含这些客户端处理器,因此构建专用样式显示两个命令块,并隐藏标签栏与复制按钮。不引入共享选择状态或持久化偏好。 + +## 考虑过的替代方案 + +**保留纵向重复的平台章节。** 两个版本都立即可见,但重复的备选命令会打断指南的顺序步骤。 + +**自建标签组件并共享或持久化选择。** Windows 读者只需选择一次,但共享状态会耦合独立示例,并引入超出所需展示功能的选择与生命周期代码。原生分组将交互限定在各组内。 + +## 影响 + +SPA 读者每一步只看到一个命令版本,MPA、仓库和原始 Markdown 读者仍能获取两个版本。Windows 读者需要在各组中选择 PowerShell。原有平台链接仍到达对应步骤,但平台名称不再出现在页面大纲中。 + +验证覆盖命令保留、双语配对、投影和原始 Markdown,以及真实浏览器中的切换、复制、键盘焦点、搜索、主题和窄屏布局。框架负责的交互在运行中的站点验收;会话回放不适用于这项纯文档展示变更。 + +聚焦的渲染回归验证本地搜索复制章节时的单选按钮归属。适配器依赖 VitePress 原生标签栏的 HTML,结构变化时会使构建失败;框架升级必须保留搜索隔离和浏览器检查。 diff --git a/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.i18n.yaml b/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.i18n.yaml new file mode 100644 index 0000000000..182ff386d9 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.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-15-markdown-file-preview-links.md +2026-09-15-markdown-file-preview-links.md: d9d535afc5b005ae453bd7623e05c411e4d6932b +2026-09-15-markdown-file-preview-links.zh.md: 63c18e284b785ed5686721a0b04cf73c78c0ceb3 diff --git a/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.md b/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.md new file mode 100644 index 0000000000..d9d535afc5 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.md @@ -0,0 +1,33 @@ +# Agent Note: Markdown file preview links + +Status: implemented + +English | [中文](2026-09-15-markdown-file-preview-links.zh.md) + +## Problem + +Assistant explanations link to existing source files that the turn does not modify or deliver. Restricting clickable references to produced-file mentions prevents readers from opening those sources beside the answer. + +## Decision + +Settled Assistant Markdown passes explicit local link destinations to the Chat file opener through `MarkdownDelegateProvider`. The provider owns both file and HTTP(S) navigation callbacks; nested link components read its current callbacks without adding navigation props to intermediate renderers. The renderer recognizes absolute and workspace-relative paths, decodes percent escapes once, and separates `#L24` or `#L24-L30` into a first-line navigation request. A file control preserves the authored label and shows a file-category icon. It never navigates the browser to the authored path. + +The existing [Sidebar navigation](../architecture/2026-09-05-sidebar-tab-types-and-navigation.md) owns Session addressing, tab reuse, and preview selection. The Host file service retains access checks and missing-file errors. Inline-code produced-file matching remains independent. External URLs retain their protocol allowlist; queries, fragment-only destinations, unsupported fragments, malformed escapes, and invalid line ranges remain inert. + +The Web file-reference prompt asks for a link on every existing-file mention outside commands, configuration expressions, and code blocks, including repeats and tables. Names default to a basename or clear alias with minimal disambiguating parents. Precise labels use `filename:24` or `filename:24–30`, while destinations keep the parser's `#L24` or `#L24-L30` syntax. The renderer preserves model-authored text; it does not rewrite labels to enforce the prompt. + +## Alternatives considered + +**Relative browser anchors.** They navigate the application URL instead of requesting Session file content. + +**Require a produced-file entry.** This excludes ordinary read-only explanations. + +**Add a preview service.** Chat already supplies the required opener and line parameter. + +**Thread navigation callbacks through renderer props.** Every new navigation capability would enlarge unrelated intermediate interfaces. Reading the scoped delegate at link components also lets cached Markdown observe handler changes. + +**Filename-first or display-only prompt variants.** The three-variant development comparison favored A for occurrence-level link coverage and the user preferred its output. B produced more answers with no missing links, so these observations do not establish a universal winner. Visible `#L` suffixes were rejected in favor of the familiar colon notation; retaining anchor syntax in destinations preserves existing navigation. + +## Consequences + +Source references need no new Session event. The static Web guidance is logged through the existing system-message mechanism. Links become active when the message settles. A range selects its first line; the preview does not highlight a multi-line selection. Unit tests cover destination parsing, shared file and HTTP(S) routing, nested scopes, and callback replacement or removal; the keyless `markdown-file-links` Web snapshot covers file content, colon labels, line navigation, and tab reuse through the shipped composition. The package tests check the guidance in every Web prompt sidecar. diff --git a/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.zh.md b/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.zh.md new file mode 100644 index 0000000000..63c18e284b --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.zh.md @@ -0,0 +1,33 @@ +# Agent Note: Markdown 文件预览链接 + +Status: implemented + +[English](2026-09-15-markdown-file-preview-links.md) | 中文 + +## Problem + +Assistant 讲解会链接到本轮未修改或交付的现有源码文件。若仅允许产出文件提及成为可点击引用,读者就无法在回答旁打开这些源码。 + +## Decision + +落定后的 Assistant Markdown 通过 `MarkdownDelegateProvider` 将显式本地链接目标传给 Chat 文件打开器。Provider 同时拥有文件及 HTTP(S) 导航回调;内部链接组件读取其当前回调,中间渲染器无需增加导航 props。渲染器识别绝对路径和工作区相对路径,对百分号转义解码一次,并将 `#L24` 或 `#L24-L30` 分离为起始行导航请求。文件控件保留原始标签并显示文件类别图标,绝不让浏览器导航到原始路径。 + +现有[侧栏导航](../architecture/2026-09-05-sidebar-tab-types-and-navigation.zh.md)负责 Session 寻址、标签复用及预览选择。Host 文件服务保留访问检查和文件缺失错误。行内代码的产出文件匹配保持独立。外部 URL 保留协议允许列表;查询串、仅有片段的目标、不支持的片段、错误转义及无效行号范围均不可点击。 + +Web 文件引用提示词要求链接命令、配置表达式和代码块以外的每次现有文件提及,包括重复提及和表格。名称默认使用 basename 或清楚的别名,仅添加消除歧义所需的父目录。精确标签使用 `filename:24` 或 `filename:24–30`,目标仍使用解析器支持的 `#L24` 或 `#L24-L30` 语法。渲染器保留模型原文,不通过重写标签强制满足提示词。 + +## Alternatives considered + +**相对浏览器链接。** 这会跳转应用 URL,而非请求 Session 文件内容。 + +**要求产出文件条目。** 这会排除普通只读讲解。 + +**增加预览服务。** Chat 已提供所需打开器和行号参数。 + +**通过渲染器 props 逐层传递导航回调。** 每种新增导航能力都会扩大无关的中间接口。链接组件读取作用域内的 delegate,也让缓存的 Markdown 能响应回调变化。 + +**文件名优先或仅规范显示的提示词。** 三版开发对照中 A 的逐次引用链接覆盖率较高,用户也更偏好其输出。B 完全没有漏链的回答更多,因此这些观察不能证明某版普遍更优。用户选择熟悉的冒号格式,放弃显示 `#L` 后缀;目标保留锚点语法以维持现有导航。 + +## Consequences + +源码引用无需新增 Session 事件。静态 Web 指导通过现有系统消息机制记录。链接在消息落定后可用。范围定位到起始行;预览不会高亮多行选区。单元测试覆盖目标解析、共享的文件与 HTTP(S) 路由、嵌套作用域及回调替换或移除;无密钥的 `markdown-file-links` Web 快照通过正式组合覆盖文件内容、冒号标签、行号导航和标签复用。包测试校验每份 Web 提示词 sidecar 中的指导。 diff --git a/.agents/notes/implemented/feature/2026-09-16-known-site-link-marks.md b/.agents/notes/implemented/feature/2026-09-16-known-site-link-marks.md deleted file mode 100644 index 9d029af06f..0000000000 --- a/.agents/notes/implemented/feature/2026-09-16-known-site-link-marks.md +++ /dev/null @@ -1,38 +0,0 @@ -# Agent Note: Known-site link marks - -Status: implemented - -English | [中文](2026-09-16-known-site-link-marks.zh.md) - -## Problem - -Every transcribed anchor led with the same globe, so a transcript full of GitHub, npm, and documentation links gave no signal about where a link goes until the reader parsed its label. The [clickable-link vocabulary](2026-09-04-web-clickable-link-styles.md) reserved that leading seat for one category glyph and left per-site marks as a possible later extension of the `url` category. - -## Decision - -`LinkIcon` takes an optional `href`. For the `url` category, a destination whose host is a well-known site draws that site's own mark; every other `url` destination keeps the globe, and the file categories ignore `href` because their destination is a path, not a site. - -`SiteGlyph.tsx` in ui-primitives owns the mapping: forty host suffixes resolve to thirty-four marks. The developer sites a transcript usually cites: GitHub (`github.com`, `github.io`, `raw.githubusercontent.com`), GitLab, npm, PyPI, Stack Overflow, MDN, Wikipedia, Hacker News (`news.ycombinator.com`), YouTube (`youtube.com`, `youtu.be`), X (`x.com`, `twitter.com`), Bilibili, Zhihu, Juejin, CSDN. The mainstream sites a general audience links: search (Google, Baidu, DuckDuckGo), video and audio (TikTok, Netflix, Spotify), social and messaging (Facebook, Instagram, Reddit, Telegram with its `t.me` short links, WhatsApp with `wa.me`, WeChat via `weixin.qq.com`, QQ, Weibo), shopping (Taobao, AliExpress, eBay), reference and community (Quora, V2EX), and Apple. A host matches a suffix when it equals it or is a subdomain of it, and the longest matching suffix wins, so `gist.github.com` and `en.wikipedia.org` need no entry while `weixin.qq.com` keeps WeChat rather than the QQ mark its `qq.com` suffix would also select. Only absolute `http:` and `https:` destinations can match; anything else falls back to the globe. - -The artwork is the [Simple Icons](https://simpleicons.org) set (CC0-1.0) consumed as a devDependency of ui-primitives, so adding a site is one host-map entry and the artwork version comes from the lockfile rather than a copied path. Each mark renders as that set's single path on its 24-unit viewBox, inset to a 28-unit box so it keeps the ~8% margin the 20-unit `ic_ds_*` glyphs draw inside their own box, and takes the link's `currentColor` rather than the brand fill the set records. Every mark is `aria-hidden`; the anchor's own text remains the accessible name. - -Taking `currentColor` is deliberate for a functional link glyph rather than brand presentation: YouTube, X, Instagram, and other brands publish guidelines against recoloring their marks, and the first alternative below weighs the fixed-brand-fill option. CC0-1.0 covers the path data; every mark remains its owner's trademark, and the generated third-party notices cover the package rather than this artwork. - -Two consumers pass their destination: the markdown renderer's `renderSafeLink`, which covers authored anchors, reference links, and URL-promoted inline code, and the web card's source and fetch links. Both already hold the sanitized destination. - -## Alternatives considered - -- **Fetching each site's favicon** from the site itself or a favicon service. This covers arbitrary sites, but rendering a transcript would issue network requests to every linked host, which discloses reading activity and turns a text render into a network operation; it also fails offline and behind a strict `img-src` policy. Rejected: a fixed local vocabulary keeps rendering deterministic and private, and the globe remains the honest fallback. -- **Filling the mark with the site's brand color.** Rejected: the link glyphs are `currentColor`-only so they follow the link alias, dark mode, and hover; a fixed fill would be the first exception and would fight the link's own hover color. -- **Copying the path data into this module.** Rejected: a third-party artwork set that changes upstream is exactly what a pinned dependency tracks; fourteen embedded copies would leave no update path. -- **Adding site values to `LinkIconKind`.** Rejected: the kind is the category the consumer states, while the site is derived from the destination; folding both into one union would make every consumer spell out a site it does not know. -- **A monogram tile for every unmapped host.** Rejected as noise: a generated letter capsule claims a site identity without carrying one, and 14px leaves no room for readable initials. - -## Consequences - -- Adding a site needs a host-map entry in `SiteGlyph.tsx` and, to be checked, a URL in the `link-icon` spec: that spec lists a representative URL per mapped site, requires thirty-four distinct marks across them, and pins the GitHub, npm, YouTube, Wikipedia, X, Telegram, and WhatsApp aliases. A dropped or duplicated entry fails the count, while a site added to the map alone stays uncovered by the spec. -- The vocabulary stays bounded by category rather than by demand: each site costs one map entry, one upstream mark, and one spec URL, and the thirty-four marks add roughly 23 KB of path data to the source, which the shell build reduces to a few KB in the entry chunk after tree-shaking, minification, and compression. -- `simple-icons` enters the browser build graph as a development dependency of ui-primitives; the shell build tree-shakes the imported marks instead of the complete set. -- Unknown hosts, non-http schemes, unparseable destinations, and file categories keep the existing globe or category glyph; the `mailto` link in a transcript still shows the globe. -- The vocabulary is deliberately finite. Sites such as `example.com` never get a mark from this mechanism; recognizing them would require the network fetching the alternatives reject. -- Coverage: the LinkIcon spec covers the aliases, the fallback branches, and the sizing seat; the markdown spec pins one known and one unknown anchor; the web-card spec pins a source and a fetch link against the same marks. diff --git a/.agents/notes/implemented/feature/2026-09-16-known-site-link-marks.zh.md b/.agents/notes/implemented/feature/2026-09-16-known-site-link-marks.zh.md deleted file mode 100644 index 2c7630d122..0000000000 --- a/.agents/notes/implemented/feature/2026-09-16-known-site-link-marks.zh.md +++ /dev/null @@ -1,38 +0,0 @@ -# Agent Note: 已知站点链接标记 - -Status: implemented - -[English](2026-09-16-known-site-link-marks.md) | 中文 - -## Problem - -转写内容中的每个锚点都以同一个地球图形开头,因此满是 GitHub、npm 与文档链接的对话在读者解析标签之前,无法从图形看出链接去向。[可点击链接词汇](2026-09-04-web-clickable-link-styles.zh.md) 把该前置位置留给一个分类图形,并把按站点区分的标记留作 `url` 类别日后可能的扩展。 - -## Decision - -`LinkIcon` 接受可选的 `href`。对于 `url` 类别,目的地主机属于已知站点时改画该站点自己的标记;其余 `url` 目的地仍使用地球,文件类别忽略 `href`,因为它们的目的地是路径而不是站点。 - -ui-primitives 的 `SiteGlyph.tsx` 持有该映射:四十个主机后缀解析为三十四个标记。转写内容常引用的开发者站点:GitHub(`github.com`、`github.io`、`raw.githubusercontent.com`)、GitLab、npm、PyPI、Stack Overflow、MDN、Wikipedia、Hacker News(`news.ycombinator.com`)、YouTube(`youtube.com`、`youtu.be`)、X(`x.com`、`twitter.com`)、Bilibili、知乎、掘金与 CSDN。普通用户常访问的主流站点:搜索(Google、百度、DuckDuckGo)、视频与音频(TikTok、Netflix、Spotify)、社交与通讯(Facebook、Instagram、Reddit、Telegram 及 `t.me` 短链、WhatsApp 及 `wa.me`、`weixin.qq.com` 上的微信、QQ、微博)、购物(淘宝、速卖通、eBay)、参考资料与社区(Quora、V2EX)以及 Apple。主机等于某后缀或为其子域即匹配,且最长匹配后缀优先,因此 `gist.github.com` 与 `en.wikipedia.org` 无需单独登记,而 `weixin.qq.com` 得到微信标记而不是其 `qq.com` 后缀同样会选中的 QQ 标记。只有绝对 `http:` 与 `https:` 目的地能够匹配,其余一律回退到地球。 - -图形取自 [Simple Icons](https://simpleicons.org) 图标集(CC0-1.0),以 ui-primitives 的 devDependency 形式引入,因此新增站点只是主机映射中的一条记录,图形版本由 lockfile 固定,而不是复制路径数据。每个标记以该图标集的单条 path 绘制在其 24 单位 viewBox 上,并内缩到 28 单位盒中,以保留 20 单位 `ic_ds_*` 图形在自身盒内约 8% 的留白;填充使用链接的 `currentColor`,而不是图标集记录的品牌填充色。所有标记均为 `aria-hidden`,锚点自身文本仍是可访问名称。 - -对功能性链接图形采用 `currentColor` 是刻意的取舍,而非品牌呈现:YouTube、X、Instagram 等品牌公布的规范禁止为其标记改色,下方第一个备选方案比较了固定品牌填充的做法。CC0-1.0 覆盖路径数据;每个标记仍属其所有者的商标,生成的第三方声明覆盖该 npm 包本身,而不是这份图形。 - -两处消费者传入自己的目的地:markdown 渲染器的 `renderSafeLink`(覆盖作者书写的锚点、引用式链接与提升为链接的行内代码)以及 web 卡片的来源与抓取链接。两者本已持有经安全校验的目的地。 - -## Alternatives considered - -- **抓取各站点的 favicon**,无论取自站点本身还是 favicon 服务。这能覆盖任意站点,但渲染一份转写内容会向每个被链接的主机发起网络请求,泄露阅读行为并把文本渲染变成网络操作;它在离线与严格 `img-src` 策略下同样失败。拒绝:固定的本地词汇表让渲染保持确定与私密,地球仍是诚实的回退。 -- **以站点品牌色填充标记。** 拒绝:链接图形只使用 `currentColor`,以跟随链接 alias、暗色模式与 hover;固定填充会成为首个例外,并与链接自身的 hover 颜色冲突。 -- **把路径数据复制进本模块。** 拒绝:上游会持续更新的第三方图形集正是需要固定版本依赖来跟踪的内容;十四个内嵌副本没有更新路径。 -- **把站点值加进 `LinkIconKind`。** 拒绝:kind 是消费者陈述的分类,而站点由目的地推导;把两者折进同一联合类型会迫使每个消费者写出自己并不知晓的站点。 -- **为每个未映射主机生成首字母方块。** 以噪音为由拒绝:自动生成的字母胶囊宣称了一种站点身份却没有承载它,14px 也没有留下可读字母的空间。 - -## Consequences - -- 新增站点需要在 `SiteGlyph.tsx` 的主机映射中加一条记录,并在 `link-icon` spec 中补一个 URL 才会被检查:该 spec 为每个已映射站点列出一个代表 URL、要求它们共产生三十四个互不相同的标记,并固定 GitHub、npm、YouTube、Wikipedia、X、Telegram 与 WhatsApp 的别名。记录丢失或重复会让数量断言失败,而只改主机映射、不补 spec URL 的新站点仍未被 spec 覆盖。 -- 词汇表按类别而非按需求增长:每个站点需要一条主机映射、一个上游标记与一个 spec URL;这三十四个标记在源码中增加约 23 KB 路径数据,经 shell 构建的 tree-shake、压缩与 gzip 后进入入口 chunk 的只有几 KB。 -- `simple-icons` 作为 ui-primitives 的开发依赖进入浏览器构建图;shell 构建会 tree-shake 所导入的标记,而不是整套图标。 -- 未收录主机、非 http scheme、无法解析的目的地与文件类别都保留原有的地球或分类图形;转写内容中的 `mailto` 链接仍显示地球。 -- 该词汇表刻意有限。像 `example.com` 这样的站点不会由此机制获得标记;要识别它们就需要被上述备选方案拒绝的网络抓取。 -- 覆盖:LinkIcon spec 覆盖别名、各回退分支与尺寸座位;markdown spec 固定一个已知锚点与一个未知锚点;web 卡片 spec 用同一批标记固定一个来源链接与一个抓取链接。 diff --git a/.agents/notes/implemented/feature/2026-09-16-open-in-default-app-for-sidebar-files.i18n.yaml b/.agents/notes/implemented/feature/2026-09-16-open-in-default-app-for-sidebar-files.i18n.yaml new file mode 100644 index 0000000000..7614cd0318 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-16-open-in-default-app-for-sidebar-files.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-16-open-in-default-app-for-sidebar-files.md +2026-09-16-open-in-default-app-for-sidebar-files.md: fa7506be77e8a62e5dfd97b59de6ebc4be510ae0 +2026-09-16-open-in-default-app-for-sidebar-files.zh.md: af124182dc1b38658ff55736277e499e963a6281 diff --git a/.agents/notes/implemented/feature/2026-09-16-open-in-default-app-for-sidebar-files.md b/.agents/notes/implemented/feature/2026-09-16-open-in-default-app-for-sidebar-files.md new file mode 100644 index 0000000000..fa7506be77 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-16-open-in-default-app-for-sidebar-files.md @@ -0,0 +1,29 @@ +# Agent Note: Open Sidebar-previewed files in the default application + +Status: implemented + +English | [中文](2026-09-16-open-in-default-app-for-sidebar-files.zh.md) + +## Problem + +Opening a file in its Host default application or showing it in the file manager existed only on the delivery cards, through routes and a fetch controller private to `ui-deliverables` and addressed by Session, declaration-event sequence, and file index. The right Sidebar's document preview offered no native handoff: a video, an archive, an office document, a file the reader rejected as non-text, or an oversized file dead-ended on an explanation, with Retry as the only control even where a second read could not help (issue #3932). Those files have no delivery event to address, so the existing plumbing could not serve a second surface. A first implementation (PR #4120, by Yifffan) added a new `ui-open-locally` package with its own Host routes and moved the delivery cards onto it; product review asked instead that file opening live in the existing open-in-app feature and accepted an intermediate state for this iteration. + +## Decision + +[ui-open-in-app](../../../../packages/client/ui-open-in-app/README.md) owns the browser controls. The document preview of [ui-sidebar-documentpreview](../../../../packages/client/ui-sidebar-documentpreview/README.md) declares two Session-scoped list child slots whose owner props carry the file’s absolute execution-environment path, rendered only once the file's metadata reports that path: `sidebar.right.tab.document.actions` after the header's own controls, in every state that shows the header, and `sidebar.right.tab.document.unpreviewable` where Retry would stand. The preview classifies an empty-state read failure: `not-text` and `too-large` are readable files the preview cannot render and offer the unpreviewable slot; `not-found` and `not-regular-file` offer only their explanation; every other failure keeps Retry because a second read may resolve it. The unsupported-suffix empty state, which never reads, offers the same slot. + +ui-open-in-app fills both slots: an "Open ▾" split button whose main button opens the file in the default application and whose menu adds the file-manager reveal, and an "Open in default app" empty-state button. Neither control renders until the Host answered that a desktop exists. The controls call the Session Remote the Host already publishes, `session.canOpenWorkspacePath` once per page and `session.openWorkspacePath` per gesture with the absolute path and, for reveal, `action: 'reveal'`; no new Host route exists. Both controls share one gesture hook: only the control the user pressed disables while its call settles, a failed call resolves as a failure kind, and that control announces it once through a transient toast, so no failure state persists on a control. + +## Alternatives considered + +**A new `ui-open-locally` package with its own Host routes** (PR #4120) is where the slot pair, the failure classification, the split-button design, and the toast rule come from. It was set aside because the repository already has an open-in-app feature and a second package for opening things locally duplicates it; the shared Session Remote owns verification of the Host path through the composed filesystem. + +**Host routes in `dsh-host-open-in-app`** serving desktop facts and a path open would let the Host name the file manager and distinguish a missing file from a failed launch. They would make the Host half depend on `sessionController`, `workspaceFiles`, and `fs` for two calls the Session Remote already publishes, so the Remote is used directly and the reveal label stays generic. + +**Moving the delivery cards onto these controls now** would repeat the largest part of PR #4120, including the cross-plugin runtime import its review rejected and the recorded delivery scenarios it had to migrate. The cards keep their own routes this iteration. + +## Consequences + +Every previewed file with a verified Host mapping can be opened locally from the Sidebar, including files that were never delivered. Paths without that mapping are refused before native opening. The delivery cards and the preview run two openers over one Host capability; unifying them, and deciding per surface between the full application list and the default application only, is the next step the product direction names. The reveal menu item says "Show file location" on every platform because the Session Remote reports no file manager. Default-application open carries the Host's file-association execution semantics for what it opens; the guard is the explicit user gesture on a file the Session can already read, and no new Host authority was granted. + +Unit tests cover the failure classification, the preview's rendering of each recourse and its withholding of the seats before a Host path is known, the path controller's desktop read and gesture outcomes, both controls' visibility, busy isolation, and toast, and the plugin's registration and fiber-disposal removal. The keyless Web document-preview scenario drives the unsupported-suffix controls against a stubbed opener on POSIX hosts, and the seeded-history file-preview golden pins the header control. diff --git a/.agents/notes/implemented/feature/2026-09-16-open-in-default-app-for-sidebar-files.zh.md b/.agents/notes/implemented/feature/2026-09-16-open-in-default-app-for-sidebar-files.zh.md new file mode 100644 index 0000000000..af124182dc --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-16-open-in-default-app-for-sidebar-files.zh.md @@ -0,0 +1,29 @@ +# Agent Note:用默认应用打开 Sidebar 预览的文件 + +Status: implemented + +[English](2026-09-16-open-in-default-app-for-sidebar-files.md) | 中文 + +## 问题 + +用 Host 默认应用打开文件、或在文件管理器中显示文件,此前只存在于交付卡片上,走的是 `ui-deliverables` 私有的路由与 fetch 控制器,按 Session、声明事件序号和文件下标定位。右侧 Sidebar 的文档预览没有任何本地交接:视频、压缩包、office 文档、被读取器判为非文本的文件、过大的文件,都停在一行说明上,唯一的控件是重试,哪怕再读一次也无济于事(issue #3932)。这些文件没有交付事件可供定位,所以现有管线无法服务第二个界面。第一版实现(PR #4120,作者 Yifffan)新增了 `ui-open-locally` 包,自带 Host 路由,并把交付卡片也迁了过去;产品评审要求改为把文件打开放进现有的 open-in-app 功能,并接受本期先落一个中间态。 + +## 决定 + +[ui-open-in-app](../../../../packages/client/ui-open-in-app/README.zh.md) 拥有浏览器侧的控件。[ui-sidebar-documentpreview](../../../../packages/client/ui-sidebar-documentpreview/README.zh.md) 的文档预览声明两个 Session 作用域的 list 子 slot,owner props 携带文件在执行环境中的绝对路径,只在文件元数据报出该路径后渲染:`sidebar.right.tab.document.actions` 在每个显示头部的状态里跟在头部自有控件之后,`sidebar.right.tab.document.unpreviewable` 占据原本放重试的位置。预览对空态的读取失败分类:`not-text` 与 `too-large` 是可读但预览无法渲染的文件,提供 unpreviewable slot;`not-found` 与 `not-regular-file` 只给说明;其余失败保留重试,因为再读一次可能解决。不支持后缀的空态不发起读取,提供同一个 slot。 + +ui-open-in-app 填满这两个 slot:一个「打开 ▾」分体按钮,主按钮用默认应用打开文件,菜单再加文件管理器定位;一个空态的「用默认应用打开」按钮。Host 回答有桌面之前,两个控件都不渲染。控件调用 Host 早已发布的 Session Remote:每页调用一次 `session.canOpenWorkspacePath`,每个手势调用一次 `session.openWorkspacePath`,带上绝对路径,定位时再带 `action: 'reveal'`;没有新增任何 Host 路由。两个控件共用一个手势 hook:只有用户按下的那个控件在调用结算期间禁用,失败的调用解析为一个失败种类,由该控件通过短暂 toast 提示一次,控件上不残留失败状态。 + +## 考虑过的备选 + +**新建自带 Host 路由的 `ui-open-locally` 包**(PR #4120)是这对 slot、失败分类、分体按钮设计和 toast 规则的来源。放弃它是因为仓库已经有 open-in-app 功能,再来一个负责本地打开的包属于重复;共享的 Session Remote 负责通过当前文件系统验证 Host 路径。 + +**在 `dsh-host-open-in-app` 里加 Host 路由**提供桌面信息和按路径打开,能让 Host 报出文件管理器的名字,并区分文件不存在和启动失败。但这会让 Host 半边为了 Session Remote 已经发布的两个调用而依赖 `sessionController`、`workspaceFiles` 与 `fs`,所以直接使用 Remote,定位标签保持通用说法。 + +**现在就把交付卡片迁到这些控件上**会重做 PR #4120 里最大的那部分,包括评审否决的跨插件运行时导入,以及它不得不迁移的交付录制场景。本期卡片保留自己的路由。 + +## 后果 + +每个有经过验证的 Host 映射的预览文件都能从 Sidebar 本地打开,包括从未交付过的文件。没有这种映射的路径会在原生打开前被拒绝。交付卡片和预览在同一项 Host 能力上跑着两套打开器;把它们统一起来,并按界面决定展示完整应用列表还是只展示默认应用,是产品方向点名的下一步。定位菜单项在所有平台都写「显示文件位置」,因为 Session Remote 不报告文件管理器。用默认应用打开带有 Host 文件关联的执行语义;护栏是用户对 Session 已可读文件的明确手势,没有授予任何新的 Host 权限。 + +单元测试覆盖失败分类、预览对每种出路的渲染以及在 Host 路径未知时对座位的保留、路径控制器的桌面读取与手势结果、两个控件的可见性、忙碌隔离与 toast,以及插件的注册与 fiber 释放时的移除。无密钥的 Web 文档预览场景在 POSIX 主机上用桩打开器驱动不支持后缀的控件,seeded-history 的文件预览 golden 固定住头部控件。 diff --git a/.agents/notes/implemented/feature/2026-09-16-performance-usage-preference.i18n.yaml b/.agents/notes/implemented/feature/2026-09-16-performance-usage-preference.i18n.yaml new file mode 100644 index 0000000000..2da6cd2bfa --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-16-performance-usage-preference.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-16-performance-usage-preference.md +2026-09-16-performance-usage-preference.md: 36ab30561fccc6c824ee8483f5a533a9178f11c5 +2026-09-16-performance-usage-preference.zh.md: ecfd19c41c861116d18829b355bf8c8df4ea929a diff --git a/.agents/notes/implemented/feature/2026-09-16-performance-usage-preference.md b/.agents/notes/implemented/feature/2026-09-16-performance-usage-preference.md new file mode 100644 index 0000000000..36ab30561f --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-16-performance-usage-preference.md @@ -0,0 +1,31 @@ +# Agent Note: Performance and usage detail preference + +Status: implemented + +English | [中文](2026-09-16-performance-usage-preference.zh.md) + +## Problem + +Always-visible counts and accounting controls add density to Chat even when readers only need output speed and cache effectiveness. + +## Decision + +Chat owns `ui-chat.performanceUsage` in its existing settings namespace. General Settings offers Compact and Detailed, with Detailed as the default to preserve access to accounting. Both the composer and turn-tail renderer consume the same accepted settings source through framework-bound hooks. Host settings own persistence, validation, and failed-write recovery. + +Compact retains only available output speed and cache-hit percentage under the composer. These readings are plain text with no statistics dialog. Detailed retains session statistic dialogs and per-Turn usage. Neither mode renders the elapsed-time action in the completed-turn footer. This preference changes presentation, never telemetry collection, accounting, or Session events. + +The [composer statistics decision](2026-09-07-composer-session-stats-pills.md) remains authoritative for accounting sources, dialog exclusivity, and composer spacing in Detailed mode. + +The requested footer design prioritizes message actions and token accounting in both modes. Completed turns therefore expose no per-turn elapsed-time control; Detailed restores accounting detail, not that control. The running clock and session-wide timing statistics remain independent of this choice. + +## Alternatives considered + +**Default to Compact.** This would hide accounting controls for existing users without an explicit choice. The request defines both modes but no default, so Detailed preserves their access. + +**Filter telemetry when Compact is selected.** This would discard information needed after switching to Detailed or reopening history. A display preference must not change recorded data. + +## Consequences + +Unavailable speed or cache-hit data is omitted rather than replaced with invented values. The settings scope provides one accepted value to all consumers without duplicating subscription state. Component coverage checks mode-specific visibility; the [recorded-session browser scenario](../../../../apps/web/tests/message-actions.e2e.ts) exercises selection, reload persistence, and Compact output. + +On non-loopback browsers, the preference remains process-local because the settings scope cannot persist writes. Explicit selections update every consumer immediately; accepted Host settings reconcile the live value on loopback browsers. diff --git a/.agents/notes/implemented/feature/2026-09-16-performance-usage-preference.zh.md b/.agents/notes/implemented/feature/2026-09-16-performance-usage-preference.zh.md new file mode 100644 index 0000000000..ecfd19c41c --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-16-performance-usage-preference.zh.md @@ -0,0 +1,31 @@ +# Agent Note:性能与用量详细程度偏好 + +Status: implemented + +[English](2026-09-16-performance-usage-preference.md) | 中文 + +## Problem + +当读者只需要输出速度和缓存效果时,始终显示的计数和记账控件会增加聊天界面的密度。 + +## Decision + +Chat 在既有设置命名空间中拥有 `ui-chat.performanceUsage`。通用设置提供简洁和详细选项,默认详细以保留记账入口。输入框和轮次页脚渲染器通过框架绑定的 Hook 消费同一个已接受设置源。Host 设置负责持久化、校验和写入失败恢复。 + +简洁模式仅在输入框下方保留可用的输出速度和缓存命中率。这些读数为纯文本,不提供统计卡片。详细模式保留会话统计卡片和每轮用量。两种模式均不渲染已完成轮次页脚中的耗时操作。该偏好只改变展示,不改变遥测收集、记账或 Session 事件。 + +[输入框统计决策](2026-09-07-composer-session-stats-pills.zh.md)仍负责详细模式中的记账来源、卡片互斥和输入框间距。 + +所要求的页脚设计在两种模式下均优先展示消息操作和 token 记账。因此,已完成轮次不提供单轮耗时控件;详细模式恢复记账详情,不恢复该控件。运行中的时钟和会话级计时统计独立于此选择。 + +## Alternatives considered + +**默认简洁。** 这会在已有用户未明确选择时隐藏记账控件。需求定义了两种模式但未指定默认值,因此详细模式保留既有入口。 + +**选择简洁时过滤遥测。** 这会丢弃切换回详细模式或重新打开历史时需要的信息。展示偏好不能改变记录数据。 + +## Consequences + +不可用的速度或缓存命中数据会被省略,不填入虚构值。设置作用域为所有消费者提供同一个已接受值,无需复制订阅状态。组件覆盖检查不同模式的可见性;[录制会话浏览器场景](../../../../apps/web/tests/message-actions.e2e.ts)验证选择、刷新持久化和简洁模式输出。 + +在非回环地址浏览器中,设置作用域无法持久化写入,因此该偏好仅在当前进程内生效。明确选择会立即更新所有使用方;回环地址浏览器收到 Host 已接受的设置后会同步当前值。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-14-transcript-width-handle-layering.i18n.yaml b/.agents/notes/implemented/feature/2026-09-17-compact-translucent-menu-surfaces.i18n.yaml similarity index 55% rename from .agents/notes/implemented/bug-fix/2026-09-14-transcript-width-handle-layering.i18n.yaml rename to .agents/notes/implemented/feature/2026-09-17-compact-translucent-menu-surfaces.i18n.yaml index 003ab9ba65..cf44de1cb6 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-14-transcript-width-handle-layering.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-17-compact-translucent-menu-surfaces.i18n.yaml @@ -1,6 +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-14-transcript-width-handle-layering.md -2026-09-14-transcript-width-handle-layering.md: 01d70e646e874d8266633597f1b510ad73fc2287 -2026-09-14-transcript-width-handle-layering.zh.md: c6e03ec5775256f5aac87d2f7c1ed66a12cf0c2a +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-17-compact-translucent-menu-surfaces.md +2026-09-17-compact-translucent-menu-surfaces.md: 08fcb2d851e4c75ce9069fc797e8dbcb070acc5b +2026-09-17-compact-translucent-menu-surfaces.zh.md: 8e13634995d46b7414f17bbb51443df85cb3ac3c diff --git a/.agents/notes/implemented/feature/2026-09-17-compact-translucent-menu-surfaces.md b/.agents/notes/implemented/feature/2026-09-17-compact-translucent-menu-surfaces.md new file mode 100644 index 0000000000..08fcb2d851 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-17-compact-translucent-menu-surfaces.md @@ -0,0 +1,29 @@ +# Agent Note: Compact translucent menu surfaces + +Status: implemented + +English | [中文](2026-09-17-compact-translucent-menu-surfaces.zh.md) + +## Problem + +Shared dropdowns and feature-owned menu panels used the same elevated color token but retained unrelated padding, row height, corner radius, and scrollbar geometry. The opaque menu fill also made nested and portalled surfaces read as solid cards rather than one elevation family, while narrowing individual menus locally would keep those differences distributed across packages. + +## Decision + +`ui-theme` defines `--dsw-specific-menu` as a translucent light or dark fill and defines `--dsw-menu-backdrop-filter` as `blur(40px) saturate(150%)`. Every elevated package surface that paints the menu fill also applies that backdrop filter, uses `border: 0`, and takes an elevation shadow with its rebindable hairline stroke. A surface containing fixed-position overlays paints the fill and filter on an isolated background pseudo-element, because a filtered ancestor would otherwise change those overlays' containing block. Descendant sticky rows may repaint the inherited fill without another filter. Browsers without backdrop filtering still render the theme-owned translucent fill. + +The shared `Menu` and the composer input-trigger menu use the same compact baseline: a 16px outer radius, 3px frame padding, 34px ordinary rows, 13px primary text on a 20px line, 6px icon/text gaps, and 8px row radii. Dense and compact variants reduce from that baseline instead of retaining the former ordinary geometry. The composer menu keeps its feature-owned grouping and 400px height cap; its aliases and descriptions use 12px text on an 18px line. + +The global WebKit scrollbar width is 5px. The composer menu overrides it with a 6px draggable rail and a 2px visible thumb through the existing scrollbar geometry variables; Firefox continues to use its standard thin scrollbar path. Track insets and elevated l2 thumb colors remain surface-owned rebindings. + +## Alternatives considered + +**Keep an opaque shared menu fill.** Rejected because every consumer would need a separate translucent override to share one material, recreating the theme branch outside `ui-theme`. + +**Compact only the composer command menu.** Rejected because the shared `Menu` renders the same action, settings, and navigation vocabulary; keeping its former 40px rows would preserve two densities for the same control family. + +**Use the 6px padded rail for every scrollbar.** Rejected because ordinary scroll regions need the direct 5px thumb, while the command menu needs a wider hit area around a visually quieter thumb. The existing geometry variables express that difference without another scrollbar implementation. + +## Consequences + +Elevated menu-fill consumers must pair the fill and backdrop-filter tokens in the same rule, either on the surface or on its isolated background pseudo-element; the stylesheet gate rejects a material layer that omits the filter. Shared menu geometry changes affect every `Menu` render site, while the composer menu retains its own content and interaction rules. Browser and component snapshots cover assembled structure, and theme specs pin the tokens, compact metrics, scrollbar geometry, and complete menu-filter pairing. diff --git a/.agents/notes/implemented/feature/2026-09-17-compact-translucent-menu-surfaces.zh.md b/.agents/notes/implemented/feature/2026-09-17-compact-translucent-menu-surfaces.zh.md new file mode 100644 index 0000000000..8e13634995 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-17-compact-translucent-menu-surfaces.zh.md @@ -0,0 +1,29 @@ +# Agent Note: 紧凑半透明菜单表面 + +Status: implemented + +[English](2026-09-17-compact-translucent-menu-surfaces.md) | 中文 + +## Problem + +共享下拉菜单与功能包自有菜单面板使用同一个高层级颜色 token,却保留了不同的内边距、行高、圆角和滚动条几何。不透明菜单填充也让嵌套与 portal 表面像彼此独立的实色卡片;如果只在各功能菜单中局部收紧尺寸,这些差异仍会分散在多个包中。 + +## Decision + +`ui-theme` 将 `--dsw-specific-menu` 定义为明暗主题各自的半透明填充,并将 `--dsw-menu-backdrop-filter` 定义为 `blur(40px) saturate(150%)`。每个绘制菜单填充的包级高层级表面同时应用该 backdrop filter、设置 `border: 0`,并使用带可重绑发丝描边的 elevation 投影。包含 fixed 定位浮层的表面把填充与滤镜绘制在隔离的背景伪元素上,因为带滤镜的祖先会改变这些浮层的包含块。后代 sticky 行可以再次绘制继承的填充,无需重复 filter。不支持 backdrop filtering 的浏览器仍渲染主题持有的半透明填充。 + +共享 `Menu` 与 composer 输入触发菜单使用同一套紧凑基准:16px 外圆角、3px 边框内距、34px 普通行、13px 主文字配 20px 行高、6px 图文间距,以及 8px 行圆角。dense 与 compact 变体在该基准上继续缩小,不再保留旧的普通几何。composer 菜单保留其功能包持有的分组与 400px 高度上限;别名和说明使用 12px 字号与 18px 行高。 + +WebKit 系全局滚动条宽度为 5px。composer 菜单通过既有滚动条几何变量覆盖为 6px 可拖动轨道与 2px 可见滑块;Firefox 继续走标准 thin scrollbar 路径。轨道内缩与高层级 l2 滑块颜色仍由表面自行重绑。 + +## Alternatives considered + +**保留不透明的共享菜单填充。** 否决,因为每个消费方都需要单独覆盖成半透明,导致同一种材质再次在 `ui-theme` 之外分叉。 + +**只收紧 composer 指令菜单。** 否决,因为共享 `Menu` 渲染的是同一套操作、设置与导航词汇;保留原 40px 行会让同一个控件家族继续存在两种密度。 + +**所有滚动条都使用 6px 带内边距轨道。** 否决,因为普通滚动区域需要直接的 5px 滑块,而指令菜单需要在更安静的可见滑块外保留更宽点击区域。现有几何变量可以表达该差异,无需第二套滚动条实现。 + +## Consequences + +高层级菜单填充消费方必须在同一规则中同时使用填充与 backdrop-filter token,规则可以属于表面本身,也可以属于其隔离的背景伪元素;样式表门禁会拒绝漏掉 filter 的材质层。共享菜单几何影响每个 `Menu` 渲染点,composer 菜单仍保留自身内容与交互规则。浏览器与组件快照覆盖装配结构,主题测试固定 token、紧凑尺寸、滚动条几何与完整的菜单 filter 配对。 diff --git a/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.i18n.yaml b/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.i18n.yaml similarity index 57% rename from .agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.i18n.yaml rename to .agents/notes/implemented/feature/2026-09-17-developer-tools-settings.i18n.yaml index 6009b3c1ad..69db5487b8 100644 --- a/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.i18n.yaml @@ -1,6 +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-04-web-clickable-link-styles.md -2026-09-04-web-clickable-link-styles.md: c4e506294c87c6b959cb449403edce09daa11062 -2026-09-04-web-clickable-link-styles.zh.md: 01258674b4b8194ad5e0597cde61cc2186406d49 +# 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 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 new file mode 100644 index 0000000000..e76cb7f367 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.md @@ -0,0 +1,29 @@ +# Agent Note: Shared developer-tool settings + +Status: implemented + +English | [中文](2026-09-17-developer-tools-settings.zh.md) + +## Problem + +Diagnostic views, preset selection and changed-file summaries add complexity to routine tasks. Scripted HTML also grants capabilities that a basic document preview does not need. Desktop and Web share these renderers, so independent switches would let the same setting produce different results across clients. + +## 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. + +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. + +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. + +## Alternatives considered + +**Limit the switch to desktop.** The selected requirement contains no desktop-only restriction, and the affected UI is shared. A desktop check would leave the Web renderer with inconsistent visibility and preview behavior. + +**Hide controls without changing the active View or iframe.** Previously selected diagnostic content and already-running scripts would survive the toggle. Filtering the available View roster and remounting the HTML context enforce the selected presentation immediately. + +## Consequences + +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. 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 new file mode 100644 index 0000000000..5a7d6d59db --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-17-developer-tools-settings.zh.md @@ -0,0 +1,29 @@ +# Agent Note: 共享开发者工具设置 + +Status: implemented + +[English](2026-09-17-developer-tools-settings.md) | 中文 + +## Problem + +诊断视图、预设选择和改动文件摘要增加了日常任务的复杂度。支持脚本的 HTML 还授予了基础文档预览不需要的能力。桌面端和 Web 共享这些渲染器,分别设置开关会让同一设置在不同客户端产生不同结果。 + +## Decision + +设置域内的 `ctx.settingsScope.developerTools` 偏好对象绑定命名空间 scope,提供一个已接受的 `ui-developer-tools.enabled` 偏好,默认关闭。Web 和桌面端的通用设置提供同一个开关。现有设置传输负责验证、有序写入、持久化和恢复;回环 Web 和桌面端跟随 Host 接受的状态。远程 Web 持有一个所有消费者共享的浏览器本地可观察值,刷新后重置,不发送 Host 写入。 + +关闭时仅展示 Chat,在没有可见 View 声明工具调用检查能力时隐藏 Inspect 操作,移除会话 View 标签栏,隐藏新会话预设入口,并省略改动文件卡片及其摘要读取。在其他 View 中关闭开关会激活 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 读取访问。 + +## Alternatives considered + +**仅在桌面端提供开关。** 所选需求没有仅限桌面的约束,相关 UI 也是共享的。桌面端条件判断会让 Web 渲染器的展示和预览行为不一致。 + +**隐藏控件但不改变激活的 View 或 iframe。** 之前选择的诊断内容和已经运行的脚本会在切换后继续存在。过滤可用 View 并重新挂载 HTML 上下文,可以立即执行所选展示策略。 + +## Consequences + +基础 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 执行尚未验证。 diff --git a/.agents/notes/implemented/feature/2026-09-16-known-site-link-marks.i18n.yaml b/.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.i18n.yaml similarity index 59% rename from .agents/notes/implemented/feature/2026-09-16-known-site-link-marks.i18n.yaml rename to .agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.i18n.yaml index 513a59328a..2a7ae98b1e 100644 --- a/.agents/notes/implemented/feature/2026-09-16-known-site-link-marks.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.i18n.yaml @@ -1,6 +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-16-known-site-link-marks.md -2026-09-16-known-site-link-marks.md: 9d029af06f9ca0f3fa8290112c7023ec1b9e0b84 -2026-09-16-known-site-link-marks.zh.md: 2c7630d122d1576ba41cb653cf0b86e9c5a2ddb4 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.md +2026-09-17-persistent-plan-cards.md: 6959cf39f4e47dbd552ee14af7c1ebc09b52bef6 +2026-09-17-persistent-plan-cards.zh.md: 0c40cb5caf4b77f5d4b5c30cead5567f21b45cfd diff --git a/.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.md b/.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.md new file mode 100644 index 0000000000..6959cf39f4 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.md @@ -0,0 +1,31 @@ +# Agent Note: Persistent plan cards and sidebar reading + +Status: implemented + +English | [中文](2026-09-17-persistent-plan-cards.zh.md) + +## Problem + +A plan review occupies the composer only until the user answers or dismisses it. A completed Turn also folds its tool calls into the process disclosure. Neither lifetime gives a submitted plan a discoverable place to read while implementation continues. + +## Decision + +`ui-plan` accumulates each Turn’s `exit_plan_mode` submissions, including native calls and PTC dispatches. Each invocation contributes a card to the completed Turn’s final artifact area. The `conversation.chat.turnTail` list admits file deliveries and plan cards together. Review dismissal, refusal, and approval do not delete the recorded plans. + +The review intent may carry the tool-call identity. The question plugin declares an action slot, and the plan plugin contributes an opener there and on the historical card. When an invocation exists, both open the resource identified by its complete Session address and invocation. Subagent addresses retain the direct parent, child, and mode for snapshot reads, pagination, and reload. Sidebar layout retains that address; the resource provider reads the existing Session history, paging backwards when the invocation is older than the opening window. Its temporary follow closes after the opening snapshot. + +A review without an invocation opens a temporary address keyed by browser lifetime, Session, and pending request. The sidebar’s existing navigation parameters hold its Markdown without adding another document store or persisting text in layout. Closing and reopening from the pending card supplies the text again; restored tabs without navigation parameters report an expired preview. + +The document is read-only. The pending review shows its status, View full plan link, title, two-line plain-text summary, and Request changes / Approve buttons; the complete text opens automatically in the sidebar. A transient Session-scoped store records each automatically opened invocation or unlogged request, so review remounts preserve manual closure. Historical cards never trigger automatic navigation. The cards use the file-delivery treatment with a Markdown icon, plan title, and Open action. Request changes cancels the pending wait and returns the composer for feedback. Approval stays in the pending-question owner; reopening a historical document does not revive a settled review or authorize implementation. Different submissions remain distinct even when they share a heading. + +## Alternatives considered + +**Keep only the pending review or an expandable tool row.** Both hide the document behind unrelated interaction or process state. The user needs a persistent artifact entry. + +**Write a Markdown file or persist the document in sidebar layout.** The tool arguments already own the exact submitted text. Another durable copy would need synchronization and could disagree with the reviewed plan. The [plan-state decision](../simplification/2026-07-22-plan-specific-collaboration-state.md) remains the authority for that ownership. + +**Select a single owner for the Turn tail.** Plans and file deliveries can occur in the same Turn. An additive list lets both plugins contribute without either knowing the other’s data or rendering. + +## Consequences + +Logged plans survive review closure and browser reload without a new Session event or file. Complete plan arguments retain a card even when the tool rejects the call, including calls outside plan mode; the card does not certify approval or execution. Reads of old plans may require several history pages. The provider validates saved addresses and logged arguments, and reports unavailable history explicitly. The recorded Web plan-review scenario covers both entry points, repeated opening, process collapse, approval, and reload; focused tests cover multiple submissions, PTC deduplication, cropped history, and resource failures. diff --git a/.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.zh.md b/.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.zh.md new file mode 100644 index 0000000000..0c40cb5caf --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.zh.md @@ -0,0 +1,31 @@ +# Agent Note: 常驻计划卡片与侧边栏阅读 + +Status: implemented + +[English](2026-09-17-persistent-plan-cards.md) | 中文 + +## Problem + +计划审批只在用户回答或关闭之前占据编辑器。已完成的回合也会把工具调用折叠到运行过程里。这两种生命周期都无法在实施继续时,为已提交的计划提供容易找到的阅读入口。 + +## Decision + +`ui-plan` 汇总每个回合的 `exit_plan_mode` 提交,覆盖原生调用和 PTC dispatch。每次调用在已完成回合末尾的产物区域贡献一张卡片。`conversation.chat.turnTail` 列表同时容纳文件产物和计划卡片。关闭、拒绝或批准审批都不会删除已记录的计划。 + +审批意图可以携带工具调用标识。提问插件声明操作插槽,计划插件在该插槽和历史卡片中提供打开入口。存在调用标识时,两者都打开由完整会话地址和调用标识确定的资源。子会话地址保留直接父级、子会话及运行模式,用于快照读取、分页和刷新恢复。侧边栏布局保留该地址;资源 provider 读取已有会话历史,当调用早于初始窗口时向前分页。临时 follow 在取得初始快照后关闭。 + +没有调用标识的审批打开临时地址,由浏览器生命周期、会话和待处理请求共同标识。侧边栏已有的导航参数保存 Markdown,无需额外的文档 store,也不把正文持久化到布局中。关闭后从待处理卡片重新打开会再次提供正文;恢复的标签页若缺少导航参数,则显示预览已失效。 + +文档只读。待处理审批显示状态、“查看全文”链接、标题、两行纯文本摘要,以及“要求修改”和“同意执行”按钮;完整正文自动在侧边栏打开。临时的会话级 store 记录每个已自动打开的调用或无调用标识的请求,让审批组件重新挂载时仍保留用户的关闭选择。历史卡片不会触发自动导航。卡片采用文件产物样式,包含 Markdown 图标、计划标题和“打开”操作。“要求修改”取消待处理等待并恢复编辑器,供用户输入反馈。审批仍由待处理提问的属主负责;重新打开历史文档不会恢复已结束的审批,也不会授权实施。即使标题相同,不同提交仍彼此独立。 + +## Alternatives considered + +**只保留待处理审批或可展开的工具行。** 两者都会让文档受其他交互或运行过程状态控制。用户需要常驻的产物入口。 + +**写入 Markdown 文件或在侧边栏布局中保存正文。** 工具参数已经拥有准确的提交文本。另一份持久副本需要同步,且可能与审阅的计划不一致。[计划状态决策](../simplification/2026-07-22-plan-specific-collaboration-state.zh.md)仍是这项所有权的权威记录。 + +**只选择一个回合末尾区域的属主。** 计划和文件产物可能出现在同一回合。可追加列表让两个插件都能贡献内容,无需彼此了解数据或渲染。 + +## Consequences + +已记录计划在审批关闭和浏览器刷新后仍可阅读,无需新增会话事件或文件。即使工具拒绝调用,包括在计划模式之外调用,完整的计划参数也会保留卡片;卡片不代表批准或执行成功。读取旧计划可能需要多个历史分页。provider 校验保存的地址和调用参数,并明确报告历史不可用。已有 Web 计划审批录制场景覆盖两个入口、重复打开、运行过程折叠、批准和刷新;定向测试覆盖多次提交、PTC 去重、裁剪历史及资源读取失败。 diff --git a/.agents/notes/implemented/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.i18n.yaml b/.agents/notes/implemented/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.i18n.yaml new file mode 100644 index 0000000000..70ee32d07a --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.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-17-sidebar-file-and-directory-auto-refresh.md +2026-09-17-sidebar-file-and-directory-auto-refresh.md: f33be6a8d45756d3bac83d0a6e21a4110543e3b2 +2026-09-17-sidebar-file-and-directory-auto-refresh.zh.md: 88fe6e7abb4027ec8b5c4fab4f1ad07477aef6fa diff --git a/.agents/notes/implemented/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.md b/.agents/notes/implemented/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.md new file mode 100644 index 0000000000..f33be6a8d4 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.md @@ -0,0 +1,345 @@ +# Agent Note: On-demand watching and automatic refresh for Sidebar previews and file trees + +Status: implemented + +English | [中文](2026-09-17-sidebar-file-and-directory-auto-refresh.zh.md) + +## Problem + +The Sidebar's Document Preview and Files panels need to reflect current disk state. Changes come from Harness file tools, shell commands, user editors, and other processes; observing only Harness file operations is insufficient. + +The starting implementation has both read paths but no complete automatic-refresh path. + +| Object | Starting behavior | Gap | +|---|---|---| +| File Resource | `ResourceProvider.open()` supplies initial metadata and later changes; `ResourceRegistry` owns subscriptions and retention; components read through `useResource` | File changes do not come from an OS watcher | +| Document Preview | `TextPreview` compares Resource and content versions and provides reload for three loading modes | A change only prompts the user to reload | +| Workspace file events | `WorkspaceFiles.changes()` forwards the current Session's `fs/observed`; the Client filters by file | The Host does not know which paths are followed; external editors and shell writes produce no such events | +| Files panel | `filesFace` lists directories; `createFilesStore` retains loaded levels and expansion state | Creation, deletion, and renaming require manual refresh; reopening an expanded directory can show old cached entries | + +`Resource` holds file metadata, not preview contents. Document Preview or its selected renderer reads the contents. Metadata updates and content reloads therefore remain separate responsibilities: the generic Resource acquires no content cache, and renderers do not directly manage filesystem watchers. + +## Decision + +On-demand observation connects the existing FS, Workspace file stream, Resource, and Sidebar components. Files and directories share OS watching and the existing Remote stream transport, but consume them differently. + +- Document Preview follows the current file. Its Resource publishes new metadata, and the preview invokes its existing reload. The HTML root and referenced CSS/JS files join one `ResourceGroup`; any member change invalidates the whole HTML preview. +- Files manages reads and watchers through a directory-node tree. Each open node loads and watches only its direct entries. The Client loads and subscribes to exactly the subtree levels it opens, without traversing unopened descendants. +- Collapsing a directory releases that directory's watch and those of its hidden descendants. Reopening subscribes and reads again; the old cache is not treated as current. +- Watches carry only metadata or invalidation. Contents still use the existing file readers; directory contents still use `list()`. +- Local Chokidar watches use OS events, without polling the whole Workspace or recursively scanning all descendants by default. +- Both panels retain manual refresh and automatic-refresh functionality, but temporarily hide the automatic-refresh icon button. State and toggle logic remain; new Tabs default to enabled. Hiding the control does not stop observation or automatic refresh. + +Notifications invoke existing reload/list operations; a failed reload does not retain previous preview content. The following sections describe the implementation's responsibilities, data flow, and verification. + +## Implementation and responsibilities + +### FS and Host + +| Class or type | File | Responsibility | +|---|---|---| +| `FileSystem` | [fs/src/index.ts](../../../../packages/fs/fs/src/index.ts) | Declare abstract single-target `watch(target, changed, signal)`: a file observes itself; a directory observes its direct entries. Resolve with an async close function once ready. Unsupported providers explicitly throw, without a new FS error code | +| `FsTarget` | [fs/src/types.ts](../../../../packages/fs/fs/src/types.ts) | Reuse the existing target type, without another FS watch-event system or upper-layer parsing of `targetKey` | +| `LocalFileSystem` | [fs-local/src/index.ts](../../../../packages/fs/fs-local/src/index.ts) | Watches a file's parent with target-only filtering, or the directory itself for directory targets; awaits Chokidar readiness and closure; owns the Chokidar dependency | +| `SandboxedFileSystem` | [fs-sandbox/src/index.ts](../../../../packages/fs/fs-sandbox/src/index.ts) | Inherit local read-only observation without duplicating the watcher; retain its write and edit policy checks | +| `SshFileSystem` | [fs-ssh/src/index.ts](../../../../packages/ssh/fs-ssh/src/index.ts) | `watch()` explicitly throws an ordinary exception; the Host converts it to a `workspace-file/watch-unsupported` `RemoteError`, without importing or checking an `FsError` runtime class across packages or passing remote `processPath()` values to local Chokidar | +| `WorkspaceFiles` | [workspace-files/src/index.ts](../../../../packages/api/workspace-files/src/index.ts) | Use `changes(scope, path, signal)` with only the target path. Host stat determines whether directory containment applies, including after type changes; ordinary files retain file-read authority | +| `WorkspaceChangeFeed` | [workspace-files/src/changes.ts](../../../../packages/api/workspace-files/src/changes.ts) | Establish the target watch in the existing follow path and emit readiness and changes. Retain the queue and operation-observation input; filter by target before emission without rewriting dispatch | +| `ChangeFollower` | [workspace-files/src/changes.ts](../../../../packages/api/workspace-files/src/changes.ts) | Receive operation observations and OS notifications; handle watch errors, close independently of generator pulls, and await watcher closure | +| `WorkspaceFileWatchFrame` | [workspace-files/src/types.ts](../../../../packages/api/workspace-files/src/types.ts) | Reuse `ready`/`change` frames for a path-only request, without a request kind or separate directory frame format | + +### Client and Sidebar + +| Class, function, or type | File | Responsibility | +|---|---|---| +| `ChangeFeed`, `SessionFeed`, `Follower` | [workspace-files/src/client/change-feed.ts](../../../../packages/api/workspace-files/src/client/change-feed.ts) | Key streams by Session and target path; reuse readiness, path binding, cancellation, and reconnect logic without reorganizing classes just to rename them | +| `createFileResourceProvider` | [workspace-files/src/client/provider.ts](../../../../packages/api/workspace-files/src/client/provider.ts) | Pass the address's Session and path to the target stream; publish complete metadata initially and after notifications, rather than replacing `version` while retaining stale `bytes` | +| `ResourceRegistry`, `ResourceProvider`, `UseResource` | [Resource definitions](../../../../packages/client/resources/src/client/contract.ts), [ResourceRegistry](../../../../packages/client/resources/src/client/resources.ts) | Keep existing streaming, retention counts, and subscriptions; add neither a second `onChange` nor a generic content-reload API | +| `TextPreview` | [TextPreview.tsx](../../../../packages/client/ui-sidebar-documentpreview/src/client/TextPreview.tsx) | Invoke existing reload when a Resource or group changes and automatic refresh is enabled; retain errors and manual refresh, with the separate automatic-refresh button temporarily hidden | +| `textFace`, `TabReads`, `createTextStore` | [Preview face](../../../../packages/client/ui-sidebar-documentpreview/src/client/face.ts), [Preview store](../../../../packages/client/ui-sidebar-documentpreview/src/client/store.ts) | Own one group per Tab; add default-enabled `autoRefresh` and pending-refresh flag `resourcesDirty`; reuse content-read generations and `loadRevision` | +| Package-private `ResourceGroup` | [resource-group.ts](../../../../packages/client/ui-sidebar-documentpreview/src/client/document/resource-group.ts) | Observes members through existing `resources.source(address)`; `add` joins a resource, `set` reconciles full membership, and `close` releases all members without changing the generic Resource service | +| `DocumentBodyOwner`, `HtmlBody`, `createReadHtmlRelative` | [Renderer inputs](../../../../packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts), [HTML body](../../../../packages/client/ui-sidebar-documentpreview/src/client/html/HtmlBody.tsx), [Related-file reader](../../../../packages/client/ui-sidebar-documentpreview/src/client/html/read-relative.ts) | Renderers declare read members through `addResource` and publish the current dependency list through `setResources`; HTML adds CSS/JS resources from Host-reported paths after each read returns and releases unreferenced members after parsing | +| Document renderers | [DocumentContent](../../../../packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts) and renderer directories in the same package | Do not watch directly; keep consuming text, bytes, or renderer revisions; `failed()` ends renderer loading without recording a successful version. Check cancellation of previous work, release of old objects, and retention of existing view preferences during refresh | +| `filesFace`, `FilesInjected` | [Files face](../../../../packages/client/ui-sidebar-files/src/client/face.ts) | Remain the existing business entry point, directly owning each Tab's root node; bind directory reads and change streams and forward expand, collapse, refresh, and automatic-refresh operations without a new `FilesController` | +| Package-private `DirectoryNode` | [directory-node.ts](../../../../packages/client/ui-sidebar-files/src/client/directory-node.ts) | Represents one open directory, owning its stream, read state, and open children; handles local refresh, expansion, recursive closure, and propagation of automatic-refresh state; `setExpanded` updates pending restoration from the latest expansion preferences | +| `createFilesStore`, `FilesTabState`, `LevelState` | [Files store](../../../../packages/client/ui-sidebar-files/src/client/store.ts) | Retain expansion, scrolling, and displayed directory data; distinguish initial loading from refresh over existing content to avoid unmounting subtrees on every background read; hold no watcher or AbortController | +| `FilesBody`, `Level`, `Entry` | [FilesBody.tsx](../../../../packages/client/ui-sidebar-files/src/client/FilesBody.tsx) | Delegate expansion, collapse, refresh, and panel unmount to injected callbacks; render node results without scanning the entire tree into a watch set, using existing framework hooks and store reads | +| Files registration | [Files client/index.ts](../../../../packages/client/ui-sidebar-files/src/client/index.ts) | Add a directory-change callback to the existing `filesFace`; keep async effects in injection, without importing another feature plugin's runtime exports | + +## Watch requests and notifications + +### One stream per explicit target + +Each request names one target path. The Host determines its current type through `stat`; the Client supplies no file/directory kind. + +```text +changes(sessionId, path, signal) +changes(scope, path, signal) +``` + +The Host method still receives resolved Session context through `WorkspaceFileScope`. The Client supplies a Session id; it cannot forge the Host's working directory or permissions. + +The Host checks Workspace containment whenever the current target is a directory, including when an absent or ordinary-file target becomes a directory. Ordinary files outside the Workspace remain readable and watchable through the selected FS. + +| Notification | Contents | Consumer action | +|---|---|---| +| Ready | This generation's target watch is established | Perform initial or reconnect `stat()`/`list()` and consume changes queued during the read | +| File present or updated | Existing path and version notification; Client stats again | Update Resource path, version, and size from the complete result; reread contents when automatic refresh is enabled | +| File absent | Target path and absence state | Resource reports unavailability; preview retains loaded content and shows the error | +| Directory invalidated | `change` frame in that directory's target stream | Call only that directory's `list()` again, without deduplicating by directory version or refreshing the entire tree | +| Watch failure | Initialization failure reports `workspace-file/watch-unsupported`; runtime errors end the watch | The Client handles failure where it consumes the watch; ordinary reads and manual refresh remain available, without reporting file deletion | + +Notifications need neither file contents, complete directory listings, nor per-change patches. Paths identify filenames; current stat supplies size. Modification time used only for freshness remains part of the backend version token, without UI token parsing or another timestamp comparison. + +Resource path, version, and size come from the same stat. Keep the existing small notification followed by Client stat instead of expanding the event protocol to save one request. Directory invalidation cannot depend only on directory mtime: a direct child's size or type can change `list()` output, so relevant child events invalidate that level. + +### OS watching and access scope + +- `FileSystem.watch()` accepts a target resolved by its provider; the FS provider owns the meaning of local or remote paths. +- File watching retains file-read access rules. Existing previews may read some paths outside the Workspace through the selected FS; directory-tree containment must not be imposed on those files. +- Directory watching retains `list()`'s Workspace scope and path checks, including after target type changes, without widening browsing authority. +- File watching covers in-place writes, replacement by a temporary file, deletion, and recreation at the same path while the parent directory remains. The local provider watches that parent directly, filters entries and events to the target file, and waits for the parent watcher to be ready before reporting readiness, including when the file does not yet exist. +- A directory watch observes itself and direct entries, without recursively opening unexpanded children. Creation, deletion, renaming, type changes, and visible metadata changes of direct entries invalidate that level. +- Local watching uses OS events. Deployment controls such as write-stability time or event-coalescing windows, if needed, belong in their owner's configuration rather than scattered component constants. +- OS watching drives UI refresh only. It does not fabricate an agent's authoritative `fs/observed` read observation or change the observed-version policy for editing. + +## Complete file-preview data flow + +```text +Files.openResource / file.link + → Sidebar.openTab + → TabDomain → Resource.pin + → ResourceRegistry → createFileResourceProvider.open + → changes(sessionId, path) + → WorkspaceFiles → FsTarget + → WorkspaceChangeFeed → FileSystem.watch + → LocalFileSystem → Chokidar + → ready → Client.stat → ResourceSnapshot + +Editor / Shell / file tool → disk + → Chokidar → LocalFileSystem + → WorkspaceChangeFeed → FileSystem.stat + → Remote.change + → Client.stat → ResourceSnapshot + → ResourceGroup → TextPreview + → reloadPages / reloadAll / prepareRenderer + → DocumentContent / renderer revision + → renderer +``` + +Preview content reads may run in parallel with metadata initialization. ResourceGroup uses each member's first metadata as a baseline and forwards only later changes, without first-read version reconciliation. + +| Loading mode | Existing refresh entry | Result | +|---|---|---| +| `text-pages` | `reloadPages()` | Retire the old content generation and reread text pages without mixing versions | +| `bytes-complete` | `reloadAll()` | Reread complete bytes for PDF, HTML, image, and other byte consumers | +| `renderer` | `prepareRenderer(..., reload = true)` | Increment `loadRevision`; the renderer cancels its previous load and requests again. Office failures end loading through `failed()`, allowing later file changes to retry | + +Ordinary renderers need neither `watch()` nor a new reload interface. Changes to existing `DocumentContent` text, bytes, and revisions already drive reloading. + +## HTML ResourceGroup + +An HTML preview's group contains the HTML root and CSS/JS files actually read by the existing packer. Every member is an ordinary file Resource, with no separate dependency watcher or file type. The group is package-private and reuses Resource subscriptions and retention counts. + +```text +index.html Resource ─┐ +theme.css Resource ──┼→ ResourceGroup → resourcesDirty → autoRefresh → HTML reload +page.js Resource ───┘ +``` + +- `addResource(address)` joins a dependency after `readRelated` returns its Host-resolved `absolutePath`. Existing members do not resubscribe, and no content-read version is passed or retained. +- `setResources(addresses)` commits the parsed dependency list. The preview owner always retains the root and releases other members absent from the new list. +- CSS/JS references use the HTML's Session and Host path resolution. A failed read also retains a string `error.details.path` supplied by the Host; without that path, the Client does not guess one. +- A CSS or JS change reloads the complete HTML and creates new iframe content even when the HTML source itself is unchanged. +- Missing dependencies with a Host-reported path remain members. Parsing failures retain already discovered dependencies so their later changes can trigger another load. +- A new member's first metadata establishes a baseline without invalidating the preview. Only subsequent metadata changes notify; initial reads have no additional version reconciliation. +- Group changes set only `resourcesDirty` to `true`. Refresh clears it; another change during reading sets it again. No notification counter or separate read generation is added. +- Closing the preview Tab releases the whole group. Disabling automatic refresh only stops automatic rereads; the group continues observing and recording stale state. + +This version covers direct stylesheet links and classic JS scripts already supported by the interactive HTML packer. It does not add CSS `@import`, ES module graph, or runtime network dependency discovery. A newly opened static safe preview reads no related files and observes only the root. Switching interactive HTML to static preview releases dependency watches and retains the root Resource. + +## Automatic-refresh controls + +Document Preview and Files each retain their existing reload button. The separate automatic-refresh icon control is temporarily hidden; its state, toggle logic, copy, and styles remain. New Tabs still default to `autoRefresh: true`. Hiding the control does not change its state or stop observation. + +| State or action | Behavior | +|---|---| +| Automatic refresh enabled | The control is highlighted, uses the existing pause icon, and offers “Disable auto refresh”; changes refresh the corresponding preview or directory node | +| Disable | The control shows the play icon and offers “Enable auto refresh”; observation continues without automatically replacing displayed content | +| Reenable | Refresh once immediately if changes occurred while paused, then resume automatic refresh | +| Original reload button | Read again regardless of automatic-refresh state, without changing that state | +| Expand a new directory while paused | The new directory still loads initially and subscribes; the switch controls only subsequent automatic rereads | + +Pause and play use existing icons; manual reload retains its circular arrow. No new icon system is added. The button exposes state through `aria-pressed`, with its name and tooltip in the existing locale dictionaries. + +## Complete Files-panel data flow + +```text +Files.open + → FilesBody → Session.cwd + Tab + → filesFace.start → DirectoryNode.open + → changes(sessionId, path) + → Host.watch(depth: 0) → ready + → DirectoryNode → list(sessionId, path) + → createFilesStore.levels[path] + → Level / Entry + +Entry.expand + → filesFace.toggle + → DirectoryNode.expand → child.open + → child.watch → ready → child.list + → createFilesStore.expanded + levels[path] + +Process → create / remove / rename + → Host.change(directory) + → DirectoryNode.refresh → list(path) + → createFilesStore.levels[path] + → DirectoryNode.children ↔ list.entries + → DirectoryNode.collapse → child.close +``` + +File previews and the directory tree are independent consumers. A preview can refresh without an open Files panel; Files can update entries without any file preview. Neither a Resource for the whole tree nor a new directory Resource protocol is needed. + +The directory panel obtains invalidations and listings through injected Remote callbacks and writes displayed values through its own store. Components retain their existing read interfaces and do not directly hold Chokidar, Remote iterators, or low-level observation objects. + +## Directory-node tree and lifecycle + +The Files runtime is itself an on-demand directory tree. It does not first construct the whole Workspace tree or separately derive a flat watcher set. Existing `filesFace` owns each Tab's root; `DirectoryNode` owns reads, observation, and release recursively, without another controller class. + +| Node responsibility | Behavior | +|---|---| +| Directory identity | Hold this directory's path and Session/Tab ownership; filesystem roots and Windows drive roots are valid roots, and listings contain only direct entries | +| Directory watch | One open node holds one target stream; notifications invalidate only this node's listing | +| Directory read | Retain the active list generation and pending rereads through read completion; publish results through store actions | +| Open children | Hold child `DirectoryNode` objects by direct-child path; unexpanded directories remain listing entries without active nodes | +| Expand | A directory-row action creates or reuses its child, which subscribes and reads; restoration opens only directories still present in the listing | +| Collapse | Remove and close the active child; recursively close its descendants and release its watch and requests | +| Listing update | Retain open children still present; close deleted, renamed, or file-replaced branches without reopening every node | +| Close tree | Closing the root recursively releases the active tree, without traversing another global watcher registry | + +`createFilesStore.expanded` retains user expansion preferences, but is not another watcher registry. Closing a parent cannot retain active descendant nodes merely because their preferences remain. Reopening first reads the parent, then progressively restores still-present children. Expand and collapse actions update the store and pending restoration even when the parent node is not yet active; restored descendants follow the latest preferences instead of reopening a collapsed branch. + +```text +workspace/ watch +├── src/ open watch +│ ├── components/ open watch +│ └── internal/ closed - +└── assets/ closed - + +collapse(src/): +workspace/ watch +├── src/ close(src/, components/) +└── assets/ - +``` + +The parent's watch still detects deletion, renaming, or replacement of a collapsed child, but not changes deep inside it. Listing again on expansion discovers changes made while it was not watched. + +| User action or lifecycle event | File preview | Files panel | +|---|---|---| +| First open | Subscribe to the file Resource and read contents | Open the root node, which subscribes and lists | +| Expand child | No effect | The parent opens the child; the child subscribes and reads direct entries, progressively restoring still-present descendants from preferences | +| Collapse child | Open file previews keep their watches | Release that directory and its hidden descendants; expansion preferences and displayed cache may remain | +| Switch Tab or unmount panel | The Tab pin retains file metadata; an unmounted body does not reread, and refreshes on return according to pending Resource changes and toggle state | Retain expanded nodes and watchers for the Tab lifetime; do not introduce another panel-visibility lifetime | +| Manual refresh | Reuse the current preview reload without rebuilding the Resource system | Recursively refresh open children from the root, without entering collapsed directories or rebuilding all watchers | +| Last holder closes | Resource abort ends the file stream; the Host awaits watcher closure | Tab closure ends every directory stream, cancels reads, and forgets Tab state | +| Connection recovers | Stat again after the new stream's ready frame, discovering changes even without event replay | List every still-watched directory after its new stream's ready frame | + +Files watches follow Tab lifetimes. Collapse closes the corresponding subtree; Tab closure releases the root and its entire active tree. Temporarily hiding the panel does not rebuild nodes, keeping implementation within the existing entry point and directory objects. The store retains expansion preferences; nodes use them for progressive restoration only when reopened, without a second global expansion set. + +`ResourceRegistry` continues sharing identical complete file Resource addresses. Parent-child ownership prevents duplicate active branches inside one Tab. This version adds no global watcher deduplication across Sessions, windows, or path aliases. Ordinary file rows do not each subscribe to file watches; only files opened as preview Resources acquire independent file observation. + +## User flows + +### Edit a previewed file + +1. The user opens Markdown, code, an image, PDF, HTML, or Office file from Files. +2. The user saves it in an external editor or rewrites it through a shell command. +3. Automatic refresh is enabled by default, so Document Preview displays new content. Disabling it retains current content until manual reload or reenabling. +4. Renderer selection and wrapping preferences remain. Text scrolling uses existing restoration; shorter content limits the available position. +5. Exact position restoration for PDF, HTML, and images is not an additional promise. Existing renderer behavior remains, without a cross-format location system for automatic refresh. + +### Browse changing directories + +1. The user opens Files; the root lists and begins observation. +2. The user expands `src` and `src/components`; both start their own watchers. +3. An external tool creates a file in `src/components`; only that level refreshes, without collapsing or reloading other directories. +4. The user collapses `src`; its own watch and the `components` watch close, while the root remains watched. +5. External changes continue while collapsed. Reopening `src` rereads it and progressively restores valid descendant expansion, rather than remaining on old cached entries. + +### Deletion, renaming, and failure + +- Deleting a file retains existing preview content with an unavailable notice; recreating its original path while its parent remains resumes reading. Renaming means disappearance at the old path and appearance at a new one, without rewriting the preview address from inode identity. +- Deleting or renaming a directory refreshes its parent, removes the old row, and releases the old subtree. A new directory appears under its new name without transferring old-path expansion preferences. +- Automatic and manual refresh invoke the same existing reload, retaining its clearing, loading, and error presentation. No old-byte or old-iframe retention branch is added. Directory refresh retains the existing listing cache to avoid rebuilding active subtrees. +- When a backend cannot watch, the Host converts initialization failure into a `workspace-file/watch-unsupported` `RemoteError`. The Client ends observation while continuing ordinary reads and manual refresh, without polling. External SSH filesystem changes do not automatically update previews. + +## Readiness, races, and refresh coalescing + +Observation, reads, and presentation need a few explicit ordering rules, not disk transactions or an operation log. + +- Establish the watch before sending ready, then obtain initial stat/list. Do not discard changes arriving during a read; reread when necessary to converge on current state. +- Events mean that a target may have changed, not exact replay of a user operation. Duplicate Chokidar events and consecutive writes may coalesce; files deduplicate versions, and directories coalesce pending refreshes. +- Each directory has at most one active list. Further invalidation during that request or its completion marks a pending reread, which runs afterwards rather than building an arbitrary queue of refresh jobs. +- File refresh reuses read generations and renderer revisions. Consecutive notifications must not let old requests overwrite new content or endlessly reload an already handled version. +- Background directory refresh retains the displayed listing without a display-unused `refreshing` flag. Only a directory without displayable entries enters `loading`, avoiding subtree flicker and repeated watcher reconstruction. +- After collapse, deletion, or Tab closure, late cancelled reads cannot recreate state or restore watchers. Cancellation closes the Host watcher even while the generator is suspended at a yielded frame; stream and plugin teardown await closure. +- Temporary file disappearance while its parent remains does not end observation. Ordinary file changes and watcher failures remain distinct; same-path file watches cover atomic saves and delete/recreate. +- Existing file reads are not transaction snapshots: writes can occur between stat and content reading. Later notifications trigger refresh; automatic refresh does not promise an atomic snapshot for every read. + +## Alternatives considered + +**Another Resource `onChange` and content reload.** Existing `open()`, `source()`, and `useResource` already express change, while the generic Resource does not own contents. Duplicate interfaces would make metadata, contents, and renderers each manage refresh state. + +**Recursive watching of the whole Workspace.** Opening one file or root directory should not watch every deep directory. Watching the current file and expanded directories ties resource use to what the user has actually opened. + +**Keep Session broadcasts and add path registration/unregistration.** The Host needs explicit targets, but not another path set maintained through additional commands. One target per stream directly reuses cancellation, reconnect, and ending semantics. + +**Push contents or complete listings.** This mixes observation with reads and duplicates pagination, byte limits, formats, and entry caps. Small notifications followed by existing `read()`/`list()` calls are more direct. + +**Apply directory-row additions and removals from Client events.** Filesystem events can coalesce or repeat, while listings own sorting, types, and entry limits. Relisting the affected level retains existing semantics without a directory-patch merge algorithm. + +**Call Chokidar directly in the Workspace API.** The API does not own the execution world; SSH paths may exist only remotely. The actual FS provider must implement watching. + +## Verification + +The owning FS, Workspace API, preview, and Files tests exercise watch lifetimes, metadata updates, and automatic refresh. The [Web document-preview test](../../../../apps/web/tests/document-preview.e2e.ts) exercises previews through the shipped Web composition. Session event logs, persistence formats, and model input are unchanged. + +| Scenario | Verified behavior | +|---|---| +| External in-place writes, atomic replacement, shell writes | Open previews refresh without depending on `fs/observed` | +| Text, complete bytes, renderer-owned loading | All three modes refresh; old requests cannot overwrite new results, and Office failures allow a later file change to retry | +| First member metadata | Establish a baseline without an extra reload or first-read version reconciliation | +| Only CSS/JS changes, with unchanged HTML root | The group reloads the complete HTML | +| HTML removes a dependency reference | Updating membership releases the unreferenced Resource | +| Disable automatic refresh, modify, then reload or reenable | Content remains while paused and catches up on manual action or reenabling; the controls remain independent | +| Root's direct entries are created, deleted, or renamed | The Files root level updates automatically, including when the workspace is a filesystem or Windows drive root | +| An expanded child changes | Refresh only its level while preserving other levels and expansion preferences | +| Unexpanded deep directories | No deep watcher or recursive traversal | +| Collapse a parent | Release its watch and hidden descendants; an independently open file preview keeps watching | +| Collapse or reexpand a deep descendant during ancestor restoration | Record the latest preference immediately; restoration opens only branches still expanded | +| Reopen, return to Files, or reconnect | Obtain current listings without depending on replay of missed events | +| Delete/recreate a file, or replace a child directory with a file | File watches recover at the same path while the parent remains; directory listings release invalid subtrees | +| Rapid writes, duplicate notifications, changes during reads or their completion | Converge on current state with finite rereads, without endless refresh | +| Close Tab, unload plugin, cancel initialization | Close all owned watches even when no further frame is requested; late notifications and reads no longer write state | +| Unsupported providers or out-of-workspace directories | Report unavailable observation or access errors explicitly, without watching the wrong execution world or widening directory authority | + +Controlled streams and deferred reads verify readiness, cancellation, and expansion during restoration. Real local-watch tests use temporary directories, wait for readiness before acting, and finish on observable state rather than fixed sleeps. The Web preview test and owner-local UI tests cover user-visible behavior; executed command results belong in the PR. + +## Consequences + +- Automatic watch recovery after a parent directory is deleted and recreated on Linux is deferred. Same-path file recreation while the parent remains stays supported. +- OS file events are not durable messages. Disconnects and process restarts may lose notifications, so every stream generation obtains current state after readiness. +- Open files and expanded directories increase watcher and stream counts. The first version controls this through on-demand lifetimes, without an advance cross-consumer deduplication service. +- Automatic refresh may interrupt reading, especially HTML interactions and Office conversion. View preferences and coalescing remain, but arbitrary embedded-document runtime state is not preserved. +- HTML CSS/JS members come from the existing parser. Deeper dependencies, image dependencies, and runtime loading are not additional parsing work in this change. +- SSH OS watching needs its own remote implementation strategy. This interface declares unsupported operation honestly, without local watchers or implicit polling masquerading as remote support. + +## Relationship to existing Agent Notes + +Automatic refresh and OS watching retain the Resource model, file-read authorization, and renderer ownership. The following records retain independent rationale; this note owns target-scoped observation, automatic reload, and directory-watch lifetimes. + +- [Client Resource model](../../implemented/architecture/2026-09-05-client-resource-model.md): retain addresses, providers, subscriptions, and lifetimes; existing streams carry the new behavior. +- [Workspace file service](../../implemented/architecture/2026-09-05-workspace-files-service.md): read and listing responsibilities remain; target-scoped OS observation replaces the Session-scoped operation-only stream. +- [Document preview operations](../../implemented/architecture/2026-09-08-document-preview-operations.md): content-loading ownership and all three modes remain; automatic refresh uses their existing reload paths. +- [Workspace file-read authority](../../implemented/architecture/2026-09-09-workspace-file-read-authority.md): retain distinct access scopes for file reading and directory browsing; target watches follow their respective rules. +- [Sidebar text preview and file tree](../../implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.md): per-Tab expansion, navigation, and scrolling remain; open nodes own automatic directory invalidation and watching. diff --git a/.agents/notes/implemented/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.zh.md b/.agents/notes/implemented/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.zh.md new file mode 100644 index 0000000000..88fe6e7abb --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-17-sidebar-file-and-directory-auto-refresh.zh.md @@ -0,0 +1,345 @@ +# Agent Note: Sidebar 文件预览与目录树按需监听并自动刷新 + +Status: implemented + +[English](2026-09-17-sidebar-file-and-directory-auto-refresh.md) | 中文 + +## 问题 + +Sidebar 的 Document Preview 和 Files 面板都需要反映磁盘上的最新状态。变化来源包括 Harness 文件工具、Shell 命令、用户编辑器,以及其他进程;只监听 Harness 自己的文件操作不够。 + +起始实现已有两条读取路径,但缺少完整的自动刷新链路。 + +| 对象 | 起点行为 | 缺口 | +|---|---|---| +| 文件 Resource | `ResourceProvider.open()` 提供初始元数据和后续变化,`ResourceRegistry` 管理订阅及持有期,组件通过 `useResource` 读取 | 文件变化源不是系统级 watch | +| Document Preview | `TextPreview` 已比较 Resource 版本与正文版本,并提供三种加载模式的 reload | 检测到变化后仅提示用户点击重新加载 | +| Workspace 文件事件 | `WorkspaceFiles.changes()` 转发当前 Session 的 `fs/observed`,客户端按文件过滤 | Host 不知道具体关注哪些路径;外部编辑器和 Shell 修改不会产生这类事件 | +| Files 面板 | `filesFace` 读取目录,`createFilesStore` 保存已加载层级及展开状态 | 新增、删除、重命名后需要手动刷新;展开过的目录再次打开可能直接展示旧缓存 | + +`Resource` 当前承载文件元数据,不持有预览正文。正文由 Document Preview 或它选择的渲染器读取。因此,“元数据更新”和“重新加载正文”应保持各自的职责,不把正文缓存搬进通用 Resource,也不要求每个渲染器直接接触文件系统监听。 + +## 决策 + +按需监听连接现有 FS、Workspace 文件流、Resource 和 Sidebar 组件。文件与目录共用系统监听能力和现有 Remote 流传输,但消费方式不同。 + +- Document Preview:监听当前文件,Resource 发布新元数据,预览自动调用已有 reload。HTML 的根文件和引用的 CSS/JS 都加入同一个 `ResourceGroup`,任一成员变化都使整个 HTML 预览失效。 +- Files 面板:以目录节点树管理读取和监听。每个已打开的目录节点只加载并监听自己的直接子项;Client 打开多少层子树,就逐层加载并订阅多少层,不提前遍历未打开的后代。 +- 折叠目录时释放该目录及其不可见后代的监听;再次展开时重新订阅并读取,不能把折叠前缓存当作最新结果。 +- 监听只传元数据或失效通知;正文仍走现有文件读取接口,目录内容仍走 `list()`。 +- 使用本地 Chokidar 系统监听,不定时轮询整个 Workspace,不默认递归扫描所有后代。 +- 保留两个面板的手动刷新入口和自动刷新开关功能,但暂时隐藏开关的 icon 按钮。开关状态及切换逻辑不变,新 Tab 默认开启;隐藏入口不关闭监控或自动刷新。 + +变化通知触发现有 reload/list;刷新失败后不保留旧预览内容。下面说明实现对象的职责、数据流及验证。 + +## 实现对象与职责 + +### FS 与 Host + +| 类或类型 | 所在文件 | 职责 | +|---|---|---| +| `FileSystem` | [fs/src/index.ts](../../../../packages/fs/fs/src/index.ts) | 声明抽象的单目标 `watch(target, changed, signal)` 方法;文件观察自身,目录观察直接子项;就绪后返回异步关闭函数,不支持的提供方显式抛异常,不新增 FS 错误码 | +| `FsTarget` | [fs/src/types.ts](../../../../packages/fs/fs/src/types.ts) | 沿用现有目标类型,不单独增加 FS 监听事件体系,也不让上层解析 `targetKey` | +| `LocalFileSystem` | [fs-local/src/index.ts](../../../../packages/fs/fs-local/src/index.ts) | 文件监听其父目录并过滤到目标,目录监听自身;等待 Chokidar 就绪和关闭;依赖声明在 `fs-local` | +| `SandboxedFileSystem` | [fs-sandbox/src/index.ts](../../../../packages/fs/fs-sandbox/src/index.ts) | 继承本地只读监听能力,不复制 watcher 实现;保留自身对写入、编辑的策略检查 | +| `SshFileSystem` | [fs-ssh/src/index.ts](../../../../packages/ssh/fs-ssh/src/index.ts) | `watch()` 显式抛出普通异常;Host 转成 `workspace-file/watch-unsupported` 的 `RemoteError`,不跨包导入或判断 `FsError` 实体,也不把远端 `processPath()` 传给本机 Chokidar | +| `WorkspaceFiles` | [workspace-files/src/index.ts](../../../../packages/api/workspace-files/src/index.ts) | 使用仅携带目标路径的 `changes(scope, path, signal)`;Host stat 决定是否执行目录包含检查,目标类型变化后也检查,普通文件沿用文件读取权限 | +| `WorkspaceChangeFeed` | [workspace-files/src/changes.ts](../../../../packages/api/workspace-files/src/changes.ts) | 在现有 follow 中建立目标 watch,发送就绪和变化;保留原队列与操作观察入口,出流前过滤目标,不重写整套分发逻辑 | +| `ChangeFollower` | [workspace-files/src/changes.ts](../../../../packages/api/workspace-files/src/changes.ts) | 接收操作观察与 OS 通知,处理监听错误;关闭独立于生成器拉取进度,并等待 watcher 关闭完成 | +| `WorkspaceFileWatchFrame` | [workspace-files/src/types.ts](../../../../packages/api/workspace-files/src/types.ts) | 仅传路径的请求沿用 `ready`/`change` 帧,不增加请求 kind 或独立目录帧格式 | + +### Client 与 Sidebar + +| 类、函数或类型 | 所在文件 | 职责 | +|---|---|---| +| `ChangeFeed`、`SessionFeed`、`Follower` | [workspace-files/src/client/change-feed.ts](../../../../packages/api/workspace-files/src/client/change-feed.ts) | 将流的键改为 Session 加目标路径,继续复用既有就绪、路径绑定、取消和重连逻辑;此版不为了命名重排类结构 | +| `createFileResourceProvider` | [workspace-files/src/client/provider.ts](../../../../packages/api/workspace-files/src/client/provider.ts) | 将文件地址中的 Session 与路径传给目标流;首次读取及后续通知都发布完整元数据,不能只换 `version` 而继续沿用旧 `bytes` | +| `ResourceRegistry`、`ResourceProvider`、`UseResource` | [Resource 定义](../../../../packages/client/resources/src/client/contract.ts)、[ResourceRegistry](../../../../packages/client/resources/src/client/resources.ts) | 保持现有流、持有计数和订阅模型;不新增第二套 `onChange`,不为此增加通用正文 reload API | +| `TextPreview` | [TextPreview.tsx](../../../../packages/client/ui-sidebar-documentpreview/src/client/TextPreview.tsx) | Resource 或 group 变化且开关开启时自动触发现有 reload;保留错误状态与手动刷新,独立自动刷新按钮暂时隐藏 | +| `textFace`、`TabReads`、`createTextStore` | [预览 face](../../../../packages/client/ui-sidebar-documentpreview/src/client/face.ts)、[预览存储](../../../../packages/client/ui-sidebar-documentpreview/src/client/store.ts) | 每个 Tab 持有一个 group,补充默认开启的 `autoRefresh` 和表示待刷新的 `resourcesDirty`;复用正文读取代次与 `loadRevision` | +| 包内 `ResourceGroup` | [resource-group.ts](../../../../packages/client/ui-sidebar-documentpreview/src/client/document/resource-group.ts) | 通过现有 `resources.source(address)` 观察成员;`add` 加入资源,`set` 更新完整成员列表,`close` 释放全部成员,不改变通用 Resource 服务 | +| `DocumentBodyOwner`、`HtmlBody`、`createReadHtmlRelative` | [渲染器输入](../../../../packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts)、[HTML body](../../../../packages/client/ui-sidebar-documentpreview/src/client/html/HtmlBody.tsx)、[关联文件读取](../../../../packages/client/ui-sidebar-documentpreview/src/client/html/read-relative.ts) | 渲染器通过 `addResource` 声明读取到的成员,通过 `setResources` 提交本次依赖列表;HTML 在每次读取返回后按 Host 报告的路径加入 CSS/JS 资源,解析结束后释放不再引用的成员 | +| 各文档渲染器 | [DocumentContent](../../../../packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts) 及同包各渲染器目录 | 不接 watch;继续消费文本、字节或 renderer revision;`failed()` 结束渲染器加载但不记录成功版本。检查刷新时取消旧任务、释放旧对象及保留现有视图偏好 | +| `filesFace`、`FilesInjected` | [Files face](../../../../packages/client/ui-sidebar-files/src/client/face.ts) | 继续作为已有业务入口,直接持有各 Tab 根节点;绑定目录读取和变化流,转发展开、折叠、刷新及自动刷新开关,不新增 `FilesController` | +| 包内 `DirectoryNode` | [directory-node.ts](../../../../packages/client/ui-sidebar-files/src/client/directory-node.ts) | 表示一个已打开的目录,拥有本目录变化流、读取状态和已打开子节点;负责局部刷新、展开、递归关闭,以及向子节点传递自动刷新状态;`setExpanded` 按最新展开偏好更新待恢复节点 | +| `createFilesStore`、`FilesTabState`、`LevelState` | [Files store](../../../../packages/client/ui-sidebar-files/src/client/store.ts) | 保留展开、滚动和目录显示数据;区分首次加载与已有内容上的刷新,避免每次后台重读都把子树卸载;不保存 watcher 或 AbortController | +| `FilesBody`、`Level`、`Entry` | [FilesBody.tsx](../../../../packages/client/ui-sidebar-files/src/client/FilesBody.tsx) | 将展开、折叠、刷新和面板卸载交给注入回调;按节点展示结果渲染,不扫描整棵树生成监听集合,展示仍只读已有框架钩子与存储 | +| Files 注册入口 | [Files client/index.ts](../../../../packages/client/ui-sidebar-files/src/client/index.ts) | 给现有 `filesFace` 增加目录变化流回调;异步副作用留在注入实现,不引入另一个功能插件的运行时导出 | + +## 监听接口与事件内容 + +### 一条流对应一个明确目标 + +每个请求指定一个目标路径;Host 通过 `stat` 确定当前实际类型,Client 不传文件/目录 kind。 + +```text +changes(sessionId, path, signal) +changes(scope, path, signal) +``` + +Host 方法仍通过 `WorkspaceFileScope` 接收解析后的 Session 上下文。Client 传 Session id,不能自行伪造 Host 的工作目录或权限。 + +当前目标是目录时,Host 就检查 Workspace 包含关系;原本缺失或为普通文件的目标变成目录后也执行检查。Workspace 外的普通文件仍可通过所选 FS 读取与监听。 + +| 通知 | 内容 | 消费方动作 | +|---|---|---| +| 就绪 | 当前一代目标监听已经建立 | 执行首次或重连后的 `stat()`/`list()`,接收读取期间排队的变化 | +| 文件存在或更新 | 沿用路径和版本通知;Client 收到后重新 stat | 用完整结果更新 Resource 的路径、版本和大小;自动刷新开启时重新读取正文 | +| 文件缺失 | 目标路径和缺失状态 | Resource 报告不可用,预览保留已加载内容并显示错误 | +| 目录失效 | 目录目标流中的 `change` 帧 | 只重新调用这个目录的 `list()`,不按目录版本去重,也不刷新整棵树 | +| 监听失败 | 初始化失败报告 `workspace-file/watch-unsupported`;运行期错误结束监听 | 客户端在监听接入处处理失败,普通读取与手动刷新仍可用;不伪装成文件删除 | + +不用把文件正文、完整目录列表或每个变化的补丁塞进通知。文件名由路径表达;文件大小来自当前 stat;修改时间若只为判断变化,继续由后端版本 token 表达,不要求 UI 解析 token 或另外引入时间戳比较。 + +Resource 发布的路径、版本和大小来自同一次 stat。先沿用现有小通知,再由 Client stat,避免为了减少一次请求扩大事件协议。目录变化不应只比较目录自身 mtime:直接子文件的大小或类型变化也可能影响 `list()` 返回值,应按相关子项事件使目录列表失效。 + +### 系统监听与访问范围 + +- `FileSystem.watch()` 接受本提供方解析出的目标;本机或远端路径的含义由 FS 提供方负责。 +- 文件监听保持文件读取的访问规则。现有预览允许通过所选 FS 读取部分 Workspace 外的路径,不能把目录树的范围限制顺手强加给这些文件。 +- 目录监听保持 `list()` 的 Workspace 范围及路径检查,目标类型变化后也执行检查,不因新增 watch 扩大目录浏览权限。 +- 父目录保持存在时,文件监听覆盖原地写入、临时文件替换、删除和同一路径重建。本地提供方直接监听父目录,将条目和事件过滤到目标文件,等待父目录 watcher 就绪后才报告就绪,目标文件尚不存在时也适用。 +- 目录监听只关注该目录自身和直接子项,不递归打开尚未展开的子目录;直接子项的创建、删除、重命名、类型或可见元数据变化都使这一层失效。 +- 本地实现采用系统事件模式;若需要调整写入稳定时间、事件合并窗口等部署参数,应由拥有它们的配置声明,不散落在组件里。 +- 系统 watch 只驱动 UI 刷新,不把外部磁盘变化伪装成某个 agent 已读取文件的 `fs/observed`,不改变文件编辑的已观察版本策略。 + +## 文件预览的完整数据流 + +```text +Files.openResource / file.link + → Sidebar.openTab + → TabDomain → Resource.pin + → ResourceRegistry → createFileResourceProvider.open + → changes(sessionId, path) + → WorkspaceFiles → FsTarget + → WorkspaceChangeFeed → FileSystem.watch + → LocalFileSystem → Chokidar + → ready → Client.stat → ResourceSnapshot + +Editor / Shell / file tool → disk + → Chokidar → LocalFileSystem + → WorkspaceChangeFeed → FileSystem.stat + → Remote.change + → Client.stat → ResourceSnapshot + → ResourceGroup → TextPreview + → reloadPages / reloadAll / prepareRenderer + → DocumentContent / renderer revision + → renderer +``` + +预览正文可以与元数据初始化并行读取。ResourceGroup 以各成员首次元数据为基线,只转发后续变化,不做首读版本对账。 + +| 加载模式 | 已有刷新入口 | 刷新结果 | +|---|---|---| +| `text-pages` | `reloadPages()` | 废弃旧正文代次,重新读取文本页;不能混合不同版本的页 | +| `bytes-complete` | `reloadAll()` | 重新读取完整字节,PDF、HTML、图片等消费新字节 | +| `renderer` | `prepareRenderer(..., reload = true)` | 增加 `loadRevision`,渲染器取消旧加载并重新请求;Office 失败通过 `failed()` 结束加载,后续文件变化可重试 | + +普通渲染器不需要添加 `watch()` 或新的 reload 接口。已有 `DocumentContent` 中的文本、字节和 revision 变化已经能驱动重新加载。 + +## HTML 的 ResourceGroup + +一个 HTML 预览的 group 包含 HTML 根文件,以及现有 HTML 打包器实际读取的 CSS/JS 文件。成员都是普通文件 Resource,不为依赖另建 watcher 或额外文件类型。group 是预览包内对象,复用 Resource 已有的订阅和持有计数。 + +```text +index.html Resource ─┐ +theme.css Resource ──┼→ ResourceGroup → resourcesDirty → autoRefresh → HTML reload +page.js Resource ───┘ +``` + +- `addResource(address)` 在 `readRelated` 返回 Host 解析的 `absolutePath` 后将依赖加入 group;已有成员不重复订阅,不传递或保存正文读取版本。 +- `setResources(addresses)` 提交本次解析出的依赖列表;预览拥有者始终保留根文件,其余未再出现的成员释放订阅。 +- CSS/JS 引用使用 HTML 所属 Session,由 Host 解析路径。读取失败时也保留 Host 提供的字符串 `error.details.path`;没有该路径时,Client 不自行猜测。 +- HTML 本身没有改变,但 CSS 或 JS 改变时,也重新加载整个 HTML,生成新的 iframe 内容。 +- 缺失依赖有 Host 报告的路径时仍保留为成员;解析失败时保留已经发现的依赖,后续成员变化可以再次触发加载。 +- 新成员的首次元数据仅建立基线,不使预览失效;只有后续元数据变化才通知,首次读取不另做版本对账。 +- group 变化只把 `resourcesDirty` 置为 `true`;开始刷新时清除,读取期间再次变化则重新置位。不累计次数,也不增加另一套读取代次。 +- 关闭预览 Tab 时释放整个 group。关闭自动刷新仅停止自动重读,group 继续观察并记录陈旧状态。 + +此版覆盖交互式 HTML 打包器现有支持的直接 stylesheet 链接和普通 JS 脚本,不顺手增加 CSS `@import`、ES module 图或运行时网络请求的依赖发现。新打开的静态安全预览不读取关联文件,只观察根文件。从交互式 HTML 切换到静态预览时释放依赖监听,只保留根 Resource。 + +## 自动刷新开关 + +Document Preview 和 Files 各自保留原 reload 按钮。独立自动刷新开关的 icon 入口暂时隐藏,状态、切换逻辑、文案和样式保留;新 Tab 仍默认 `autoRefresh: true`。隐藏不改变开关状态,也不停止观察。 + +| 状态或操作 | 行为 | +|---|---| +| 自动刷新开启 | 开关高亮,使用已有暂停图标,提示“关闭自动刷新”;变化自动刷新对应预览或目录节点 | +| 点击关闭 | 开关显示播放图标,提示“开启自动刷新”;继续观察变化,但不自动替换当前内容 | +| 点击重新开启 | 若暂停期间已有变化,立即补一次刷新;之后继续自动刷新 | +| 点击原 reload | 无论开关状态如何,都主动读取一次,不改变开关状态 | +| 自动刷新关闭时展开新目录 | 新目录仍正常首次加载并订阅;开关只控制后续变化引起的自动重读 | + +暂停/播放使用现有图标,手动 reload 保留环形箭头,不新增图标系统。按钮通过 `aria-pressed` 表达状态,名称和提示走现有 locale 字典。 + +## Files 面板的完整数据流 + +```text +Files.open + → FilesBody → Session.cwd + Tab + → filesFace.start → DirectoryNode.open + → changes(sessionId, path) + → Host.watch(depth: 0) → ready + → DirectoryNode → list(sessionId, path) + → createFilesStore.levels[path] + → Level / Entry + +Entry.expand + → filesFace.toggle + → DirectoryNode.expand → child.open + → child.watch → ready → child.list + → createFilesStore.expanded + levels[path] + +Process → create / remove / rename + → Host.change(directory) + → DirectoryNode.refresh → list(path) + → createFilesStore.levels[path] + → DirectoryNode.children ↔ list.entries + → DirectoryNode.collapse → child.close +``` + +文件预览和目录树是两条独立的消费路径。预览文件可以在 Files 面板没有打开时自动刷新;Files 也可以在没有任何文件预览时自动更新目录项。无需把整棵目录树包装为一个 Resource,也不为此新增目录 Resource 协议。 + +目录面板直接通过注入的 Remote 业务回调取得失效通知和列表结果,显示值统一写回自己的存储;不绕过组件现有的读取方式,不让组件直接持有 Chokidar、Remote 迭代器或底层观察对象。 + +## 目录节点树与生命周期 + +Files 的运行时对象本身就是一棵按需打开的目录树,不先建立整棵 Workspace 树,也不另算一个扁平的 watcher 集合。现有 `filesFace` 持有各 Tab 根节点,读取、监听和释放逐层交给 `DirectoryNode`,不另抽控制器类。 + +| 节点职责 | 具体行为 | +|---|---| +| 本目录身份 | 持有该目录路径和所属 Session/Tab,文件系统根与 Windows 盘根均可作为树根,列表只包含直接子项 | +| 本目录监听 | 一个打开的节点持有一个目标变化流;收到通知只使本节点列表失效 | +| 本目录读取 | 持有有效 list 请求的代次,待补读状态持续到读取收尾后,结果通过存储 actions 发布 | +| 已打开子目录 | 按直接子目录路径持有子 `DirectoryNode`;未展开目录只是列表中的目录项,不创建活动子节点 | +| 展开 | 目录行触发展开时创建或复用对应子节点,再由子节点建立自己的监听与读取;恢复展开偏好时只打开列表中仍存在的目录 | +| 折叠 | 关闭并移除对应活动子节点;子节点递归关闭后代,然后释放自己的监听和请求 | +| 列表更新 | 保留仍存在的已打开子节点,关闭已删除、重命名或变为文件的分支;不重新打开所有节点 | +| 整树关闭 | 对根节点执行关闭,自然递归释放整棵活动子树,不另遍历全局监听注册表 | + +`createFilesStore.expanded` 保存用户的展开偏好,但不是另一份监听注册表。父目录关闭后,即使存储保留了后代展开偏好,也不能继续持有这些后代的活动节点。重新打开时先读取父目录,再对仍存在且需要恢复的子目录逐层建立节点。即使父节点尚未创建,展开和折叠也会更新存储与待恢复状态;后代恢复时遵循最新偏好,不会重新打开已折叠的分支。 + +```text +workspace/ watch +├── src/ open watch +│ ├── components/ open watch +│ └── internal/ closed - +└── assets/ closed - + +collapse(src/): +workspace/ watch +├── src/ close(src/, components/) +└── assets/ - +``` + +父目录的监听仍能发现折叠目录自身被删除、重命名或替换,但不负责发现其深层内容变化。再次展开时重新 list,补上未监听期间的变化。 + +| 用户操作或生命周期事件 | 文件预览 | Files 面板 | +|---|---|---| +| 首次打开 | 打开文件 Resource 监听并读取正文 | 打开根节点,由根节点建立监听并读取列表 | +| 展开子目录 | 无影响 | 父节点打开对应子节点;子节点监听并读取直接子项,按偏好逐层恢复仍存在的后代 | +| 折叠子目录 | 已打开的文件预览继续监听 | 释放该目录及其当前不可见后代的监听;可保留展开偏好和显示缓存 | +| 切走 Tab 或面板卸载 | Tab pin 继续持有文件元数据流;正文组件不在时不主动重读,切回来按待处理 Resource 变化与开关状态补刷新 | 此版沿用 Tab 生命周期,保留已展开目录节点和监听;不另外引入面板显隐生命周期 | +| 手动刷新 | 重用当前预览 reload,不重建整套 Resource | 从根节点递归刷新已打开子节点,不进入折叠目录,也不重建全部 watcher | +| 关闭最后一个持有者 | Resource abort,文件流结束,Host 等待 watcher 关闭 | Tab 关闭时终止全部目录流、取消读取并删除 Tab 状态 | +| 连接恢复 | 新流 ready 后重新 stat,即使断线期间没有可回放事件也能发现变化 | 新流 ready 后重新 list 所有仍需观察的目录 | + +Files 监听跟随 Tab 生命周期;折叠关闭对应子树,关闭 Tab 则释放根节点与整棵活动树。暂时隐藏面板不额外拆除和重建节点,这使改动集中在现有入口和目录对象。存储保留展开偏好,节点只在重新展开时用这份偏好逐层恢复,不维护第二份全局展开集合。 + +同一个完整文件 Resource 地址的共享继续由 `ResourceRegistry` 完成。目录节点的父子所有权保证一个 Tab 内同一分支不会重复打开;第一版不增加跨 Session、跨窗口或路径别名的全局 watcher 去重系统。目录下的普通文件项不各自订阅文件 watch,只有真正打开为预览 Resource 的文件才取得独立文件监听。 + +## 用户可见动线 + +### 编辑已预览的文件 + +1. 用户从 Files 打开 Markdown、代码、图片、PDF、HTML 或 Office 文件。 +2. 用户在外部编辑器保存,或 Shell 命令改写该文件。 +3. 自动刷新默认开启,Document Preview 自动显示新内容;用户关闭开关后可保持当前内容,手动 reload 或重新开启时再读取。 +4. 当前渲染器选择和换行偏好保留;文本滚动位置按现有机制恢复,内容缩短时只能落在新内容允许的位置。 +5. PDF、HTML 和图片的精确阅读位置恢复不是本方案额外承诺;沿用各渲染器现有能力,避免为自动刷新加入一套跨格式定位系统。 + +### 浏览正在变化的目录 + +1. 用户打开 Files,根目录列表加载并开始监听。 +2. 用户展开 `src` 和 `src/components`,两个子目录分别开始监听。 +3. 外部工具在 `src/components` 创建文件,只有这一层列表刷新,其他目录不折叠、不重新加载。 +4. 用户折叠 `src`,其自身和 `components` 的监听关闭,根目录继续监听。 +5. 折叠期间外部工具继续修改内容;用户再次展开 `src` 时重新读取,依次恢复仍有效的子目录展开状态,不停留在旧缓存。 + +### 删除、重命名和失败 + +- 文件删除后,已有预览内容保留并显示不可用提示;父目录保持存在时,原路径重建后自动恢复读取。重命名视为旧路径消失和新路径出现,不根据 inode 自动改写预览地址。 +- 目录删除或重命名后,父目录刷新移除旧行,并释放旧子树监听。新目录按新名称显示,不擅自转移旧路径的展开偏好。 +- 自动刷新和手动刷新调用同一套已有 reload,沿用清空旧结果、加载及错误展示行为;不增加保留旧字节或旧 iframe 的分支。目录刷新保留现有列表缓存,避免重建活动子树。 +- 后端不支持 watch 时,Host 把初始化异常转成 `workspace-file/watch-unsupported` 的 `RemoteError`;客户端结束监听,继续普通读取与手动刷新,不增加轮询。SSH 的外部磁盘变化不会自动更新预览。 + +## 就绪、竞态与刷新合并 + +监听、读取与展示只需要处理少数明确的顺序问题,不扩展成磁盘事务或操作日志。 + +- 先建立 watch,再发送 ready,然后取得初始 stat/list。读取过程中到达的变化不能丢弃;必要时再读一次,使最终结果收敛到最新状态。 +- 事件表示“目标可能变化”,不是一次用户操作的精确回放。Chokidar 的重复通知和连续写入可以合并;文件按版本去重,目录按待刷新状态合并。 +- 同一目录最多有一次有效 list 在途;在途或读取收尾期间再次失效,只记录需要补读,随后读取最新状态,不累计任意长度的刷新任务队列。 +- 文件自动刷新复用读取代次和 renderer revision。连续通知不能让旧请求覆盖新内容,也不能因同一已处理版本而无限 reload。 +- 目录后台刷新保留当前列表,不另存没有显示用途的 `refreshing` 标记;只有没有可显示列表时才进入 `loading`,避免子树闪烁和监听反复重建。 +- 目录折叠、删除或 Tab 关闭后,已取消读取的晚到结果不得重新创建状态或恢复监听。即使生成器停在已产出的帧上,取消也会关闭 Host watcher;流与插件拆除等待关闭完成。 +- 父目录保持存在时,文件短暂消失不结束监听;正常文件变化与监听自身失败分别处理。同路径文件监听覆盖原子保存与删除重建。 +- 原有文件读取并非事务快照,stat 与正文读取之间仍可能发生写入;后续通知触发刷新,自动刷新不保证每次读取都获得原子快照。 + +## 不采用的替代方案 + +**在 Resource 上再加一套 `onChange` 和正文 reload。** 现有 `open()`、`source()`、`useResource` 已经表达变化,正文又不归通用 Resource 所有。重复接口会使元数据、正文和渲染器分别维护刷新状态。 + +**整个 Workspace 递归 watch。** 打开一个文件或一个根目录不应导致监听所有深层目录。按当前文件和展开目录取得监听,资源使用量与用户实际打开的内容相关。 + +**保留 Session 广播流,再增加路径注册/退订协议。** Host 需要知道具体目标,但不必再维护一个由额外命令修改的路径集合。一目标一流可以直接复用现有取消、重连和结束语义。 + +**直接下发正文或完整目录列表。** 这会混合监听与内容读取,还要重复分页、字节限制、文件格式和目录条目限制。小通知配合现有 `read()`/`list()` 更直接。 + +**根据变化事件在客户端增删目录行。** 文件系统通知可能合并或重复,目录列表还有排序、类型和条目上限。重新 list 受影响的一层即可保持现有语义,不需要维护目录增量合并算法。 + +**在 Workspace API 中直接调用 Chokidar。** API 不拥有执行世界,SSH 路径可能只在远端存在。watch 必须由实际 FS 提供方实现。 + +## 验证 + +FS、Workspace API、预览和 Files 各自的测试覆盖监听生命周期、元数据更新和自动刷新。[Web 文档预览测试](../../../../apps/web/tests/document-preview.e2e.ts) 从正式 Web 组合验证文件预览。会话事件日志、持久化格式和模型输入均不变。 + +| 场景 | 已验证行为 | +|---|---| +| 外部原地写入、原子替换、Shell 写入 | 打开的文件预览自动更新,不依赖 `fs/observed` | +| 文本、完整字节、自主加载渲染器 | 三种模式都刷新,旧请求不能覆盖新结果,Office 失败后允许后续文件变化重试 | +| 成员首次元数据 | 仅建立基线,不额外 reload,也不做首读版本对账 | +| HTML 根文件不变,仅 CSS/JS 改变 | group 触发整个 HTML 重新加载 | +| HTML 删除一条依赖引用 | 本次 group 更新后释放不再引用的成员 | +| 关闭自动刷新后修改,再手动刷新或重新开启 | 暂停期间保持内容,手动操作或重新开启后补刷新;按钮职责独立 | +| 根目录直接子项新增、删除、重命名 | Files 根层自动更新,工作区为文件系统根或 Windows 盘根时也适用 | +| 已展开子目录变化 | 只刷新对应目录层,保留其他层与展开偏好 | +| 未展开的深层目录 | 不创建深层 watcher,不递归遍历 | +| 折叠父目录 | 释放它和不可见后代的监听;文件预览若仍打开则独立继续监听 | +| 祖先恢复期间折叠或重新展开深层后代 | 立即记录最新偏好,恢复时只打开仍需展开的分支 | +| 重新展开、切回 Files、连接恢复 | 重新读取当前列表,不依赖错过事件的回放 | +| 文件删除后重建,或子目录替换为文件 | 父目录保持存在时,文件监听在同一路径恢复;目录列表释放失效子树 | +| 快速写入、重复通知、读取中或收尾时再修改 | 有限次补读并收敛到最新状态,不无限刷新 | +| 关闭 Tab、卸载插件、取消初始化 | 即使不再拉取下一帧,所属监听也完全关闭;晚到通知和读取不再写入状态 | +| 不支持监听的提供方、越界目录请求 | 明确报告支持状态或访问错误,不观察错误执行世界或扩大目录权限 | + +可控事件源和延迟读取验证就绪、取消及恢复期间的展开操作。真实本地 watch 测试使用临时目录,等待 watcher 就绪后再操作,通过观察到的状态结束,不靠固定 sleep 猜测事件时机。Web 预览测试和包内 UI 测试覆盖用户可见行为;命令执行结果记录在 PR 中。 + +## 影响 + +- Linux 上父目录删除并重建后的自动监听恢复仍延期;父目录保持存在时,同路径文件重建仍受支持。 +- OS 文件事件不是持久消息,断线或进程重启可能丢通知,因此每一代流就绪后都要重新取得当前状态。 +- 多个已打开文件和展开目录会增加 watcher 与流数量;第一版用按需生命周期控制数量,不提前加入复杂的跨消费者去重服务。 +- 自动刷新可能打断阅读,尤其是 HTML 内部交互和 Office 转换。保留视图偏好、合并连续变化,但不承诺保存任意嵌入文档的运行状态。 +- HTML 的 CSS/JS 通过现有解析器进入 group;更深依赖、图片依赖和运行时动态加载不作为这次修改的额外解析工程。 +- SSH 远端系统监听需要独立的远端实现策略;这里要求接口如实表达不支持,不使用本机 watcher 或隐式轮询冒充远端能力。 + +## 与已有 Agent Note 的关系 + +自动刷新和系统监听保留已有 Resource 模型、文件读取授权和渲染器职责。以下记录仍有独立的设计理由;本文负责目标级监听、自动 reload 和目录监听生命周期。 + +- [Client Resource 模型](../../implemented/architecture/2026-09-05-client-resource-model.zh.md):保留地址、提供方、订阅和持有期;新增行为由已有流承载。 +- [Workspace 文件服务](../../implemented/architecture/2026-09-05-workspace-files-service.zh.md):读取与列表接口职责不变;目标级系统监听替代仅转发操作观察的 Session 流。 +- [文档预览操作](../../implemented/architecture/2026-09-08-document-preview-operations.zh.md):正文加载所有权和三种加载方式不变;自动刷新使用其现有 reload 路径。 +- [Workspace 文件读取授权](../../implemented/architecture/2026-09-09-workspace-file-read-authority.zh.md):保留文件读取与目录浏览的不同访问范围;目标 watch 分别遵循对应规则。 +- [Sidebar 文本预览与文件树](../../implemented/feature/2026-09-05-sidebar-text-preview-and-file-tree.zh.md):按 Tab 的展开、导航和滚动状态不变;已打开节点负责目录自动失效与监听。 diff --git a/.agents/notes/implemented/feature/2026-09-17-state-dot-visual-language.i18n.yaml b/.agents/notes/implemented/feature/2026-09-17-state-dot-visual-language.i18n.yaml new file mode 100644 index 0000000000..d86602a99e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-17-state-dot-visual-language.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-17-state-dot-visual-language.md +2026-09-17-state-dot-visual-language.md: 751abbaa33919721c7e71738ad9b6bed744fcad7 +2026-09-17-state-dot-visual-language.zh.md: 9746ec9af79e15fc41ea8e6601cc52c1cfafba02 diff --git a/.agents/notes/implemented/feature/2026-09-17-state-dot-visual-language.md b/.agents/notes/implemented/feature/2026-09-17-state-dot-visual-language.md new file mode 100644 index 0000000000..751abbaa33 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-17-state-dot-visual-language.md @@ -0,0 +1,31 @@ +# Agent Note: StateDot solid marks and loading spinner + +Status: implemented + +English | [中文](2026-09-17-state-dot-visual-language.zh.md) + +## Problem + +`StateDot` mixed two drawing languages: settled states used a translucent halo around a solid core, while `ongoing` used an eight-cell pixel chase. + +## Decision + +`idle`, `done`, `warning`, and `error` render as one solid 6px circle inside the existing 10px layout slot, with no halo. `idle` uses the neutral `--dsw-alias-state-idle-primary` token, `done` remains success green, `warning` remains amber, and `error` remains red. + +`ongoing` is the sole non-dot member. Its default edge is 14px, while solid states retain their 10px layout slot. It renders a 25%-opacity complete ring behind a grey arc using the tertiary label token. The glyph rotates continuously over 1.5 seconds while the arc grows from 12 to 24 dash units around its center and returns to 12; the arc offset is zero at both ends, so the browser does not reset a second circular movement at the loop boundary. Reduced-motion environments retain an intermediate static arc. The explicit size override, `data-state`, and `aria-hidden` behavior do not change. + +Converted compact status-only presentations use this shared mapping instead of local dots or spinners: waiting or blocked is `warning`, active work is `ongoing`, successful completion is `done`, failure is `error`, and inactive or not-yet-started work is `idle`. Tool rows whose leading slot is a business icon retain the ordinary glyph in every lifecycle state; their collapsed summary turns red for failure and amber for `stopped`, preserving any tool-specific interruption text. The framework-free boot page remains separate because its arc reports aggregate loader progress before React and the shared primitive are available. + +## Alternatives considered + +**Keep the pixel chase.** Rejected because its block animation did not share the circular language of the settled markers and read as a decorative activity glyph rather than a conventional loading state. + +**Fill the complete 10px slot.** Rejected because the former visible core was 6px; retaining that diameter preserves row density while removing only the halo. + +## Testing + +The component specs pin the four solid-state elements, the two-circle loading artwork, both animation tracks, both size paths, the no-halo stylesheet, and every state token. Tool, Bash, and Skill row specs pin the retained business glyph plus visible red failure and amber interruption summaries. Workspace, Job, Workflow, Subagent, Deliverables, terminal, plugin, Schedule, Todo, Team, approval, document-preview, Trajectory, and connection suites pin their state mappings and accessible labels. + +## Consequences + +Consumers keep their accessibility labels while status-only presentations share one visual vocabulary. Every settled marker becomes visually quieter and more compact, completion remains green, and every active marker uses the same tertiary-grey rotating loader. Action glyphs such as reconnect and business glyphs such as file or tool types remain available when the icon itself communicates the operation rather than only its state. diff --git a/.agents/notes/implemented/feature/2026-09-17-state-dot-visual-language.zh.md b/.agents/notes/implemented/feature/2026-09-17-state-dot-visual-language.zh.md new file mode 100644 index 0000000000..9746ec9af7 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-17-state-dot-visual-language.zh.md @@ -0,0 +1,31 @@ +# Agent Note: StateDot 纯色标记与旋转 loading + +Status: implemented + +[English](2026-09-17-state-dot-visual-language.md) | 中文 + +## 问题 + +`StateDot` 混用了两套绘制语言:已结算状态在实心核外带半透明光晕,`ongoing` 则使用八格像素追逐动画。 + +## 决策 + +`idle`、`done`、`warning` 与 `error` 在既有 10px 布局槽内渲染一个 6px 纯色圆点,不再带光晕。`idle` 使用中性的 `--dsw-alias-state-idle-primary` token,`done` 保持成功绿色,`warning` 保持琥珀色,`error` 保持红色。 + +`ongoing` 是唯一不是圆点的成员。它的默认边长为 14px,纯色状态仍保留 10px 布局槽。它在透明度为 25% 的完整圆环上方,使用 tertiary label token 渲染灰色圆弧。图形以 1.5 秒周期持续旋转,圆弧围绕中心从 12 个 dash 单位增长到 24 个、再回到 12 个;圆弧偏移在首尾都为零,因此浏览器不会在循环边界重置第二段圆周运动。减少动态效果的环境保留中间长度的静态圆弧。显式 size 覆盖、`data-state` 与 `aria-hidden` 行为均不改变。 + +已转换的紧凑纯状态展示使用这套共享映射,不再自绘圆点或 spinner:等待或阻塞为 `warning`,活动工作为 `ongoing`,成功完成为 `done`,失败为 `error`,未活动或尚未开始为 `idle`。前置槽为业务图标的工具行在所有生命周期状态中都保留普通图标;收起摘要在失败时变红,在 `stopped` 时变为琥珀色并保留工具自有的中断文本。无框架的启动页保持独立,因为它会在 React 与共享原语可用前用圆弧表达 Loader 总体进度。 + +## 考虑过的替代方案 + +**保留像素追逐动画。** 否决,因为方块动画没有沿用已结算标记的圆形语言,看起来更像装饰性活动图形,而不是常规 loading 状态。 + +**填满完整 10px 槽位。** 否决,因为原来的可见实心核就是 6px;保留该直径只移除光晕,同时维持行密度。 + +## 测试 + +组件测试固定四种纯色状态元素、双圆环 loading 图稿、两条动画轨道、两条尺寸路径、无光晕样式和每个状态 token。Tool、Bash 与 Skill 行测试固定保留的业务图形,以及可见的红色失败摘要与琥珀色中断摘要。Workspace、Job、Workflow、Subagent、Deliverables、终端、插件、Schedule、Todo、Team、审批、文档预览、Trajectory 与连接测试固定各自的状态映射和无障碍 label。 + +## 后果 + +消费方保留无障碍 label,纯状态展示共享同一套视觉词汇。所有已结算标记在视觉上更安静、更紧凑,完成态保持绿色,所有活动态使用同一种 tertiary 灰色旋转 loading。重连等动作图标和文件或工具类型等业务图标在图形本身表达操作而不只表达状态时继续保留。 diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml index b6d13dd1aa..23d245c8c7 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.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-07-26-ci-failover-runbook.md -2026-07-26-ci-failover-runbook.md: 10123fe1999c0ad03f977e1cc7c69d788a679c2b -2026-07-26-ci-failover-runbook.zh.md: cff99e6f644bf945f8366c2a723b32222ab902b4 +2026-07-26-ci-failover-runbook.md: c2fd2fa6cf673ec4d202702c16a9a85a3a609b8d +2026-07-26-ci-failover-runbook.zh.md: 7fa5887bb552afd98cebd7502202fb5e9f37b1ee diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md index 10123fe199..c2fd2fa6cf 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md @@ -32,7 +32,7 @@ The two switches are independent: flip only the one whose platform is degraded. 1. Repository **Settings → Secrets and variables → Actions → Variables → New repository variable**: name `DSH_CI_FAILOVER_LINUX` (Linux pool outage) or `DSH_CI_FAILOVER_WINDOWS` (Windows pool outage), value `selfhosted`. 2. Retrigger the required jobs so they re-resolve their pool. Jobs already **queued** for the hosted labels do not retarget and cannot be re-run in place, so for the documented indefinite-queue outage, cancel the stuck run and re-run all jobs, or push a new commit; "Re-run failed jobs" only helps once a job has actually failed rather than queued. -3. That is the entire switch. Under the `selfhosted` Linux failover value the workflow also drops `DSH_SNAPSHOT_MAX_CONCURRENCY` to 12 for the shared VM and skips the hosted-path pnpm cache restores because the VM's persistent store serves warm installs. Coverage uses the same four single-worker instrumented partitions and two exempt workers on both Linux pools. The Windows switch has no concurrency or cache branches; it only retargets the native Windows jobs' pool. +3. That is the entire switch. Under the `selfhosted` Linux failover value the workflow also drops `DSH_SNAPSHOT_MAX_CONCURRENCY` to 12 for the shared VM and skips the hosted-path pnpm cache restores because the VM's persistent store serves warm installs. Self-hosted coverage uses four single-worker instrumented partitions and two exempt workers; isolated hosted runners derive worker defaults from available CPUs. The Windows switch selects the native Windows pool and retains explicit worker limits on shared self-hosted runners. **Dependabot exception.** Both switches' `selfhosted` legs deliberately exclude `dependabot[bot]`: under self-hosted failover, Dependabot PRs stay queued for the hosted pool rather than executing dependency-supplied code on the persistent VMs. A Dependabot PR that remains queued during an outage is expected behavior, not a failed switch; it completes when the hosted pool recovers. The `blacksmith` value's branches carry no such exclusion, because Blacksmith runners are ephemeral (see the [blacksmith failover leg note](2026-09-09-blacksmith-failover-leg.md)). diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md index cff99e6f64..7fa5887bb5 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md @@ -32,7 +32,7 @@ Status: implemented 1. 仓库 **Settings → Secrets and variables → Actions → Variables → New repository variable**:名称 `DSH_CI_FAILOVER_LINUX`(Linux 池故障)或 `DSH_CI_FAILOVER_WINDOWS`(Windows 池故障),值 `selfhosted`。 2. 重新触发必需作业,使其重新解析运行器池。已经为托管标签**排队**的作业不会重定向,也无法原地 re-run,因此对于本手册所述的无限排队故障,应取消卡住的运行并 re-run all jobs,或推送一个新提交;“Re-run failed jobs”只有在作业真正失败(而非仍在排队)时才有用。 -3. 切换到此完成。在 `selfhosted` 的 Linux 故障切换取值下,工作流还会把 `DSH_SNAPSHOT_MAX_CONCURRENCY` 降为 12,以限制共享虚拟机上的争抢,并跳过托管路径的 pnpm 缓存恢复,因为虚拟机的持久 store 会直接提供热安装。覆盖率在两个 Linux 池上都使用 4 个单 worker 插桩分区与 2 个豁免 worker。Windows 开关没有并发或缓存分支;它只重定向原生 Windows 作业的运行器池。 +3. 切换到此完成。在 `selfhosted` 的 Linux 故障切换取值下,工作流还会把 `DSH_SNAPSHOT_MAX_CONCURRENCY` 降为 12,以限制共享虚拟机上的争抢,并跳过托管路径的 pnpm 缓存恢复,因为虚拟机的持久 store 会直接提供热安装。自托管覆盖率使用 4 个单 worker 插桩分区与 2 个豁免 worker;独占的托管运行器根据可用 CPU 计算默认 worker 数。Windows 开关选择原生 Windows 运行器池,并在共享的自托管运行器上保留显式 worker 限制。 **Dependabot 例外。**两个开关的 `selfhosted` 腿都刻意排除 `dependabot[bot]`:自托管故障切换期间,Dependabot 拉取请求继续在托管池排队,而不是把依赖项提供的代码放到持久化虚拟机上执行。故障期间 Dependabot PR 持续排队是预期行为而非切换失败;托管池恢复后它会自行完成。`blacksmith` 取值下的分支不带此类排除,因为 Blacksmith 运行器是临时的(见 [blacksmith 故障切换支路笔记](2026-09-09-blacksmith-failover-leg.zh.md))。 diff --git a/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.i18n.yaml index 5b23d66658..c8bfb9d43c 100644 --- a/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.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-07-26-frozen-agent-note-archive.md -2026-07-26-frozen-agent-note-archive.md: 0c139c4a5d892de5bdace76b4935edf32586c4c1 -2026-07-26-frozen-agent-note-archive.zh.md: 2c2751c56a9b20423b00eac7a90c02286ce3e8b0 +2026-07-26-frozen-agent-note-archive.md: e9ecfa78f53a5912608201654e2c56e399defccd +2026-07-26-frozen-agent-note-archive.zh.md: 9a3ce2e6e21864f2754f627be6f7576bfc1f9076 diff --git a/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.md b/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.md index 0c139c4a5d..e9ecfa78f5 100644 --- a/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.md +++ b/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.md @@ -10,9 +10,9 @@ Implemented Agent Notes are maintained as current decision records, so every pat ## Decision -Only implemented Agent Notes can be archived. An implemented note moves when its shipped decision is complete and its rationale, alternatives, consequences, negative guarantees, and reintroduction conditions are unlikely to guide future work. Foundational boundaries, durable and wire semantics, security rules, recurring design temptations, and unresolved reintroduction conditions remain active regardless of age or word count. Proposed notes never enter the archive; an obsolete proposal becomes rejected. A rejected note remains only while it prevents a tempting, meaningful mistake and is otherwise deleted as a complete triplet. +Implemented notes that only describe small UI adjustments or purely mechanical changes are deleted as complete triplets, with inbound links repaired or removed. A small implementation can still carry lasting naming, compatibility, ownership, or behavior decisions; those records remain eligible for retention. Only other implemented Agent Notes can be archived: the shipped decision is complete, its rationale is unlikely to guide future work, and it still has historical decision value. Foundational boundaries, durable and wire semantics, security rules, recurring design temptations, and unresolved reintroduction conditions remain active regardless of age or word count. Proposed notes never enter the archive; an obsolete proposal becomes rejected. A rejected note remains only while it prevents a tempting, meaningful mistake and is otherwise deleted as a complete triplet. -The archive uses `.agents/notes/archived/{kind}/yyyy-mm-dd-topic.md`; the redundant `implemented` segment is absent. The archival change moves the complete English, Chinese, and consistency-sidecar triplet, leaves `Status: implemented` intact, and inserts `Archived: YYYY-MM-DD` immediately below it in both language files. Relocation, that metadata line, the corresponding sidecar re-record, and mechanical inbound-link repair are the only permitted archival edits. +The archive uses `.agents/notes/archived/{kind}/yyyy-mm-dd-topic.md`; the redundant `implemented` segment is absent. The archival change moves the complete English, Chinese, and consistency-sidecar triplet, leaves `Status: implemented` intact, and inserts `Archived: YYYY-MM-DD` immediately below it in both language files. Relocation, that metadata line, the corresponding sidecar re-record, and mechanical inbound-link repair are the only permitted archival edits. Archival admission checks lifecycle metadata and completeness, not title punctuation, blank-line layout, or language-switcher wording; requiring those repairs would rewrite the historical record. The root `.rgignore` excludes the archive from searches that traverse a parent directory. Historical queries name the archive directory explicitly, so intentional access remains available without mixing frozen facts into active decision discovery. @@ -42,4 +42,4 @@ Supersession is checked while a new Agent Note is being written, not deferred to ## Consequences -The active corpus becomes a set of decisions expected to influence future work, while low-value implemented history remains explicitly searchable and linkable without consuming maintenance attention or appearing in parent-directory searches. Writing a new note includes a scoped supersession check, so replacement decisions cannot silently leave redundant active records behind. Rejected clutter can disappear when it no longer protects a meaningful choice, and proposed work cannot quietly evade a verdict through archival. The archive adds a manifest, a dedicated verifier, and an explicit one-time metadata step. Archived facts and outbound links can become stale by design, so readers and agents must treat active code and documentation as authority and cite an archived note only as history. +The active corpus becomes a set of decisions expected to influence future work, while substantive historical decisions with little future guidance remain explicitly searchable and linkable without consuming maintenance attention or appearing in parent-directory searches. Writing a new note includes a scoped supersession check, so replacement decisions cannot silently leave redundant active records behind. Rejected clutter can disappear when it no longer protects a meaningful choice, and proposed work cannot quietly evade a verdict through archival. The archive adds a manifest, a dedicated verifier, and an explicit one-time metadata step. Archived facts and outbound links can become stale by design, so readers and agents must treat active code and documentation as authority and cite an archived note only as history. diff --git a/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.zh.md b/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.zh.md index 2c2751c56a..9a3ce2e6e2 100644 --- a/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.zh.md +++ b/.agents/notes/implemented/process/2026-07-26-frozen-agent-note-archive.zh.md @@ -10,9 +10,9 @@ implemented Agent Note 作为当前决策记录持续维护,因此活跃记录 ## 决策 -只有 implemented Agent Note 可以归档。当一份已实施记录的交付决策已经完整落地,且其决策依据、备选方案、后果、否定性保证和重新引入条件不太可能再指导未来工作时,将其移入归档。基础性边界、持久语义与协议语义、安全规则、反复出现且看似诱人的设计选择和尚未解决的重新引入条件,无论记录的存续时间或字数如何,都继续作为活跃记录保留。proposed Agent Note 绝不进入归档;过时的提案应转为 rejected。仅当 rejected Agent Note 仍能避免一种诱人且影响重大的错误时保留,否则将其三个配对文件完整删除。 +仅描述小型 UI 调整或纯机械变更的 implemented Agent Note 会作为完整的三文件配对删除,并修复或移除入站链接。小规模实现仍可能承载长期有效的命名、兼容性、归属或行为决策;此类记录仍可保留。只有其他 implemented Agent Note 可以归档:交付决策已经完整落地,其依据不太可能再指导未来工作,但仍具有历史决策价值。基础性边界、持久语义与协议语义、安全规则、反复出现且看似诱人的设计选择和尚未解决的重新引入条件,无论记录的存续时间或字数如何,都继续作为活跃记录保留。proposed Agent Note 绝不进入归档;过时的提案应转为 rejected。仅当 rejected Agent Note 仍能避免一种诱人且影响重大的错误时保留,否则将其三个配对文件完整删除。 -归档路径为 `.agents/notes/archived/{kind}/yyyy-mm-dd-topic.md`,其中省略了冗余的 `implemented` 层级。归档变更会移动完整的英文、中文和一致性伴随记录三个文件,保留 `Status: implemented`,并在两种语言的文件中紧接该状态行插入 `Archived: YYYY-MM-DD`。归档时只允许做文件迁移、添加该元数据行、相应地重新记录伴随记录,以及机械修复入站链接。 +归档路径为 `.agents/notes/archived/{kind}/yyyy-mm-dd-topic.md`,其中省略了冗余的 `implemented` 层级。归档变更会移动完整的英文、中文和一致性伴随记录三个文件,保留 `Status: implemented`,并在两种语言的文件中紧接该状态行插入 `Archived: YYYY-MM-DD`。归档时只允许做文件迁移、添加该元数据行、相应地重新记录伴随记录,以及机械修复入站链接。 归档准入检查生命周期元数据与完整性,不检查标题标点、空行布局或语言切换栏措辞;要求修复这些格式会改写历史记录。 根目录的 `.rgignore` 会将归档目录排除在从上层目录开始的搜索之外。查找历史内容时会显式指定归档目录,因此仍可按需访问,同时不会把冻结事实混入对活跃决策的检索。 @@ -42,4 +42,4 @@ implemented Agent Note 作为当前决策记录持续维护,因此活跃记录 ## 后果 -活跃记录集合由预计仍会影响未来工作的决策组成;未来指导价值较低的实施历史仍可显式搜索和链接,却不再消耗维护精力,也不会出现在从上层目录开始的搜索中。编写新记录时会包含一项范围明确的取代关系检查,因此取代既有决策的新决策无法悄然留下冗余的活跃记录。当被否决的记录不再保护有意义的选择时,可以清除这类杂项;提案也无法通过归档悄悄逃避明确结论。归档机制增加一份 manifest、一个专用校验器和一个显式的一次性元数据步骤。归档中的事实和出站链接可以按设计逐渐陈旧,因此读者和 agent(智能体)必须以活跃代码与文档为权威依据,并且仅将归档 Agent Note 作为历史引用。 +活跃记录集合由预计仍会影响未来工作的决策组成;未来指导价值较低但具有实质内容的历史决策仍可显式搜索和链接,却不再消耗维护精力,也不会出现在从上层目录开始的搜索中。编写新记录时会包含一项范围明确的取代关系检查,因此取代既有决策的新决策无法悄然留下冗余的活跃记录。当被否决的记录不再保护有意义的选择时,可以清除这类杂项;提案也无法通过归档悄悄逃避明确结论。归档机制增加一份 manifest、一个专用校验器和一个显式的一次性元数据步骤。归档中的事实和出站链接可以按设计逐渐陈旧,因此读者和 agent(智能体)必须以活跃代码与文档为权威依据,并且仅将归档 Agent Note 作为历史引用。 diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml index a542599011..7ad3923251 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.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-08-08-native-windows-pull-request-ci.md -2026-08-08-native-windows-pull-request-ci.md: e4fc7cab8c274148191632e8cc125ac75f2ec1d5 -2026-08-08-native-windows-pull-request-ci.zh.md: 27ad602c3748f2920e6b63f271a6a38b11c0ab77 +2026-08-08-native-windows-pull-request-ci.md: 64c4366ee8d60dd0137e6e4eb5473c0450204efe +2026-08-08-native-windows-pull-request-ci.zh.md: 48725cdc58ab7b8db22461da64236c6de3bb3027 diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md index e4fc7cab8c..64c4366ee8 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md @@ -14,11 +14,11 @@ A coverage audit found that stale branch state had restored temporary exclusions The master-only `windows` job in [ci-master.yml](../../../../.github/workflows/ci-master.yml) runs `windows node 24 / wine` on `ubuntu-latest`. It retains the checksum-verified Windows Node, Wine apt and pnpm caches, a hoisted install confined to a workspace snapshot, and the [shared Wine gate script](../../../../scripts/wine-windows-gates.sh) that runs the workspace build and production site. Node distribution transfers use bounded retries; when nodejs.org stalls on the large archive, a range-capable transport mirror resumes the same bytes, but nodejs.org remains the version and SHA-256 authority and the archive is never promoted before that checksum passes. Wine is outside the PR aggregate under the [master-only platform policy](2026-09-06-master-only-platform-ci.md). The [archived Wine experiment](../../archived/process/2026-07-27-wine-windows-gates-experiment.md) preserves its measured trade-offs, while this note owns the current dual topology. -Every pull request also starts four independent native jobs on the organization-owned `dsh-windows-2025-16core` runner: `windows-build`, `windows-coverage`, `windows-native-tests`, and `windows-observational`. Each job enables Developer Mode for workspace symlinks, provisions the repository-pinned pnpm through `pnpm/action-setup`, performs an immutable install without a transferred store archive, and runs its inventory under native PowerShell. The Windows failover variable (`DSH_CI_FAILOVER_WINDOWS`) retargets all four jobs to the in-house pool under `selfhosted` and to Blacksmith's Windows runners under `blacksmith` (see the [blacksmith failover leg note](2026-09-09-blacksmith-failover-leg.md)). Per-job deadlines range from 60 to 120 minutes and bound stuck work without treating a performance target as a correctness deadline. +Every pull request starts three native jobs: `windows-build`, `windows-coverage`, and `windows-native-tests`. Each job enables Developer Mode for workspace symlinks, provisions the repository-pinned pnpm through `pnpm/action-setup`, performs an immutable install without a transferred store archive, and runs its inventory under native PowerShell. The Windows failover variable (`DSH_CI_FAILOVER_WINDOWS`) selects the organization-owned `dsh-windows-2025-16core` runner by default, the in-house pool under `selfhosted`, or Blacksmith runners under `blacksmith` (see the [blacksmith failover leg note](2026-09-09-blacksmith-failover-leg.md)). Blacksmith build and coverage jobs request 16 vCPU; file-serial native tests request 2 vCPU. Per-job deadlines range from 60 to 120 minutes. -`windows-build` and `windows-native-tests` are dependencies of `all checks passed`; their workspace-build and targeted native-process results are blocking. `windows-coverage` remains an ordinary job but is absent from aggregate `needs`, so its 100%-per-file result stays red and visible without delaying the required verdict. `windows-observational` is also absent from aggregate `needs` and uses `continue-on-error` because Linux owns the blocking static, documentation, package, and built-artifact verdicts. +`windows-build` and `windows-native-tests` are dependencies of `all checks passed`; their workspace-build and targeted native-process results are blocking. After its blocking build and production-site checks succeed, `windows-build` runs `check:ci:windows-observational-ready` in the same workspace. This step uses `continue-on-error`, disables fail-fast, and has a 15-minute timeout; failures produce a workflow warning because Linux owns the blocking static, documentation, package, and built-artifact verdicts. The required verdict waits for the step to finish but does not require its diagnostics to pass. `windows-coverage` remains outside aggregate `needs`, so its 100%-per-file result stays red and visible without delaying the verdict. -`windows-coverage` runs [in-job partitioned coverage](2026-08-18-in-job-partitioned-coverage.md) without a preceding build, matching the Linux coverage lane: four single-worker instrumented shards beside a two-worker exempt-heavy gate; workspace imports resolve to `src` through the tsconfig paths map, and the lib-consuming suites self-skip on unbuilt checkouts. Both coverage gates raise Vitest's per-test, expect.poll, and hook budgets through `DSH_COVERAGE_TEST_TIMEOUT_MS=90000`. `windows-observational` owns its own workspace build and production-site validation, starts the independent static gates together, and caps `publint` at eight workers. Its built-bin smoke starts only after every other observational gate settles; the smoke's `needs` edge still requires a successful build, while its `after` edges preserve the diagnostic after another gate fails. This keeps bounded real-application startup measurements from competing with tool-catalog, NodeNext, package, and documentation processes. The script-only translation-pairing merge suite runs in the exempt-heavy gate because it imports only `scripts/` sources and child processes; V8 instrumentation contributes no threshold coverage there but magnifies Git-process latency. Lefthook concurrency fixtures retain their outcomes with 30-second case budgets and a 10-second process-ready probe, while the installer allows five seconds for a preempted lock owner to publish its record after exclusive creation. Directory-picker composition gives its debounced config write an explicit 15-second poll budget; workspace-context composition fixtures use a test-owned signal without an unrelated one-second deadline. The LSP sources and the ACL-sandbox sources remain in the Windows denominator: stub-based failure-path suites carry every in-process ACL-sandbox file to 100%, and only the runner entry stays excluded — it executes exclusively as a spawned child outside the instrumented run, its behavior pinned end-to-end by the runner suite. Narrow annotated V8 ignores cover only unreachable branches (peer-platform arms and lifecycle-unreachable guards), with their behavior tests retained on the owning platform. +`windows-coverage` runs [in-job partitioned coverage](2026-08-18-in-job-partitioned-coverage.md) without a preceding build, matching the Linux coverage lane: four single-worker instrumented shards beside a two-worker exempt-heavy gate; workspace imports resolve to `src` through the tsconfig paths map, and the lib-consuming suites self-skip on unbuilt checkouts. Both coverage gates raise Vitest's per-test, expect.poll, and hook budgets through `DSH_COVERAGE_TEST_TIMEOUT_MS=90000`. The observational step reuses the successful build, retains the distinct MPA documentation build, starts independent static gates together, and uses CI worker defaults for `publint`, with an eight-worker override on shared self-hosted runners. Its built-bin smoke starts only after every other observational gate settles, including failed gates. The ready aggregate requires a successful prior workspace build; it removes only that build and its internal dependency edges from the shared internal inventory. This keeps bounded real-application startup measurements from competing with tool-catalog, NodeNext, package, and documentation processes. The script-only translation-pairing merge suite runs in the exempt-heavy gate because it imports only `scripts/` sources and child processes; V8 instrumentation contributes no threshold coverage there but magnifies Git-process latency. Lefthook concurrency fixtures retain their outcomes with 30-second case budgets and a 10-second process-ready probe, while the installer allows five seconds for a preempted lock owner to publish its record after exclusive creation. Directory-picker composition gives its debounced config write an explicit 15-second poll budget; workspace-context composition fixtures use a test-owned signal without an unrelated one-second deadline. The LSP sources and the ACL-sandbox sources remain in the Windows denominator: stub-based failure-path suites carry every in-process ACL-sandbox file to 100%, and only the runner entry stays excluded — it executes exclusively as a spawned child outside the instrumented run, its behavior pinned end-to-end by the runner suite. Narrow annotated V8 ignores cover only unreachable branches (peer-platform arms and lifecycle-unreachable guards), with their behavior tests retained on the owning platform. The 16-core allocation is the measured capacity point for this inventory. Six-worker coverage trials produced complete passes in 6 minutes 27 seconds and 7 minutes 50 seconds, while exact-head trials with four, three, and two concurrent workers inside one instrumented Vitest process exposed unreliable fixtures and worker exits. Separate single-worker child processes retain process isolation. Historical sixteen-shard samples reduced instrumented coverage to 112.66–122.01 seconds. The pull-request coverage job schedules four instrumented children plus two exempt workers without a preceding build, while the self-hosted complete reference runs its unsharded coverage gates serially with one worker. A six-partition pull-request profile creates enough process and type-aware lint contention to violate bounded test deadlines. Sixteen instrumented shards plus two exempt workers would exceed a 16-core allocation before system overhead. A 32-core comparison reduced aggregate gate time by only 1.47 seconds and still triggered the CJS-lexer fatal inside a fork worker, so additional cores did not provide a reliable wall-clock improvement. @@ -40,7 +40,7 @@ Shiki disables lazy TextMate-regex compilation and warms each boot grammar befor **Run only Wine on pull requests.** Wine reaches blocking win32 toolchain branches quickly, but can report green while a real NT, NTFS, PowerShell, process, or addon contract is broken. -**Mark every non-blocking native job `continue-on-error`.** The observational job uses this setting because Linux owns its blocking verdict. Coverage remains an ordinary job outside aggregate `needs`, so a threshold failure stays visibly red without blocking the aggregate. +**Mark every non-blocking native job `continue-on-error`.** Only the observational step uses this setting because Linux owns its blocking verdict. Its containing build job must still fail when the blocking build or production site fails. Coverage remains an ordinary job outside aggregate `needs`, so a threshold failure stays visibly red without blocking the aggregate. **Exclude unsupported-looking files or weaken Windows fixtures.** Rejected because the affected LSP, watcher, persistence, client, and process behavior is supported. Peer-platform branches are marked narrowly; portable outcomes stay in the denominator and are exercised through host-realistic fixtures. @@ -50,8 +50,8 @@ Shiki disables lazy TextMate-regex compilation and warms each boot grammar befor ## Consequences -Wine provides post-merge toolchain evidence. Native coverage and observational results can still be pending or red when `all checks passed` turns green, so branch protection consumes the targeted native build and process checks while reviewers and follow-up automation consume the remaining native results. +Wine provides post-merge toolchain evidence. Native coverage may still be pending or red when `all checks passed` turns green. Observational diagnostics finish inside the required build job. A diagnostic failure leaves the PR check successful; the failed step, workflow warning, and run summary expose the failure. -Every pull request nevertheless receives a real NT kernel, NTFS, PowerShell, Windows process, native addon, and supported-source coverage signal. The native jobs duplicate setup across the build, coverage, and observational workspaces and repeat builds in the build and observational ones, but they lower each job's process count and expose path, watcher, lifecycle, and fixture defects hidden by the compatibility lane. +Every pull request receives a real NT kernel, NTFS, PowerShell, Windows process, native addon, and supported-source coverage signal. Observational checks reuse the successful build workspace, avoiding a second checkout, dependency installation, and workspace build. Their sequential position extends the build job and can delay the required verdict; it also separates diagnostic processes from compilation. The ready and master aggregates share one internal diagnostic inventory; master retains its own workspace build and full checks. Maintainers must preserve two intentional execution topologies: the Wine snapshot uses Linux installation plus a hoisted layout to reach win32 binaries, while the native jobs use separate immutable workspaces on the organization-owned 16-core Windows runner. A failure unique to either topology must be classified against that boundary rather than weakened or silently skipped. diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md index 27ad602c37..48725cdc58 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md @@ -14,11 +14,11 @@ Wine 在 Linux 内核与区分大小写的 ext4 之上采用 hoisted 依赖布 [ci-master.yml](../../../../.github/workflows/ci-master.yml) 中仅 master 触发的 `windows` 作业在 `ubuntu-latest` 上运行 `windows node 24 / wine`。它保留经过校验和验证的 Windows Node、Wine apt 与 pnpm 缓存、仅限工作区快照的 hoisted 安装,以及运行工作区构建与生产网站的[共享 Wine 门禁脚本](../../../../scripts/wine-windows-gates.sh)。Node 分发文件传输采用有界重试;nodejs.org 的大文件传输停滞时,由支持范围请求的传输镜像续传相同字节,但版本和 SHA-256 权威仍属于 nodejs.org,归档通过该校验前绝不会投入使用。根据[仅 master 平台策略](2026-09-06-master-only-platform-ci.zh.md),Wine 不参与 PR 聚合。[已归档的 Wine 实验](../../archived/process/2026-07-27-wine-windows-gates-experiment.md)保留其实测取舍,而本文负责当前双通道拓扑。 -每个拉取请求还会在组织自有的 `dsh-windows-2025-16core` 运行器上启动 4 个相互独立的原生作业:`windows-build`、`windows-coverage`、`windows-native-tests` 与 `windows-observational`。每个作业都会为工作区符号链接启用开发人员模式,通过 `pnpm/action-setup` 提供仓库固定版本的 pnpm,在不传输 store 归档的情况下执行不可变安装,并在原生 PowerShell 下运行自己的清单。Windows 故障切换变量(`DSH_CI_FAILOVER_WINDOWS`)在 `selfhosted` 下把这 4 个作业全部重定向到公司内部运行器池,在 `blacksmith` 下重定向到 Blacksmith 的 Windows 运行器(见 [blacksmith 故障切换支路笔记](2026-09-09-blacksmith-failover-leg.zh.md))。各作业采用 60 至 120 分钟的截止时间,以约束卡住的工作,同时不把性能目标当作正确性截止时间。 +每个拉取请求都会启动三个原生作业:`windows-build`、`windows-coverage` 和 `windows-native-tests`。每个作业都会为 workspace 符号链接启用开发者模式,通过 `pnpm/action-setup` 提供仓库固定版本的 pnpm,在不传输 store 归档的情况下执行不可变安装,并在原生 PowerShell 下运行自己的清单。Windows 故障切换变量(`DSH_CI_FAILOVER_WINDOWS`)默认选择组织自有的 `dsh-windows-2025-16core` 运行器,在 `selfhosted` 下选择公司内部运行器池,在 `blacksmith` 下选择 Blacksmith 运行器(见 [blacksmith 故障切换支路笔记](2026-09-09-blacksmith-failover-leg.zh.md))。Blacksmith 构建和覆盖率作业请求 16 vCPU;逐文件串行的原生测试请求 2 vCPU。各作业的截止时间为 60 至 120 分钟。 -`windows-build` 与 `windows-native-tests` 是 `all checks passed` 的依赖项;其工作区构建和定向原生进程结果具有阻断性。`windows-coverage` 仍是常规作业,但不在聚合流程的 `needs` 中,因此逐文件 100% 覆盖率结果会保持红灯并可见,却不会延迟必需判定。`windows-observational` 同样不在聚合流程的 `needs` 中,并使用 `continue-on-error`,因为静态检查、文档、包与构建产物的阻断性判定由 Linux 负责。 +`windows-build` 与 `windows-native-tests` 是 `all checks passed` 的依赖项;其工作区构建和定向原生进程结果具有阻断性。阻断性构建和生产网站检查成功后,`windows-build` 在同一工作区运行 `check:ci:windows-observational-ready`。该步骤使用 `continue-on-error`、关闭 fail-fast,并设置 15 分钟超时;失败时会产生工作流警告,因为静态检查、文档、包与构建产物的阻断性判定由 Linux 负责。必需判定会等待该步骤结束,但不要求其诊断通过。`windows-coverage` 仍在聚合流程的 `needs` 之外,因此逐文件 100% 覆盖率结果会保持红灯并可见,却不会延迟判定。 -`windows-coverage` 与 Linux 覆盖率通道一致,不先构建工作区就运行[job 内分区覆盖率](2026-08-18-in-job-partitioned-coverage.zh.md):4 个单 worker 插桩分片与一个双 worker 的豁免重型门禁并行运行;工作区导入通过 tsconfig paths 映射解析到 `src`,消费构建产物的套件在未构建的检出上会自跳。两项覆盖率门禁都通过 `DSH_COVERAGE_TEST_TIMEOUT_MS=90000` 提高 Vitest 的单测试、expect.poll 与 hook 预算。`windows-observational` 拥有自己的工作区构建和生产网站验证,会一起启动相互独立的静态门禁,并将 `publint` 限制为最多 8 个 worker。其 built-bin 冒烟测试只在其他所有观测性门禁结算后启动;冒烟测试的 `needs` 边仍要求构建成功,而 `after` 边会在其他门禁失败后保留这项诊断。这可避免有界的真实应用启动测量与 tool-catalog、NodeNext、包及文档进程争抢资源。translation-pairing 合并套件只导入 `scripts/` 源码和子进程,因此放入豁免重型套件门禁;V8 插桩不会为它贡献任何阈值覆盖率,却会放大 Git 进程延迟。Lefthook 并发 fixture 保留原有结果,采用 30 秒单用例预算与 10 秒进程就绪探测;安装器则允许被抢占的 lock 持有者在独占创建后用 5 秒发布记录。directory-picker 组合为防抖配置写入提供显式的 15 秒轮询预算;workspace-context 组合 fixture 使用测试自有、没有无关 1 秒截止时间的信号。LSP 源码与 ACL 沙箱源码仍计入 Windows 分母:基于 stub 的失败路径套件把每个进程内 ACL 沙箱文件都带到 100%,只有 runner 入口保持排除——它只作为 spawn 出的子进程在插桩运行之外执行,其行为由 runner 套件端到端钉住。窄范围且带注释的 V8 ignore 只覆盖不可达分支(另一平台专属分支、生命周期内不可达的防御守卫),其行为测试仍保留在所属平台。 +`windows-coverage` 与 Linux 覆盖率通道一致,不先构建工作区就运行[job 内分区覆盖率](2026-08-18-in-job-partitioned-coverage.zh.md):4 个单 worker 插桩分片与一个双 worker 的豁免重型门禁并行运行;工作区导入通过 tsconfig paths 映射解析到 `src`,消费构建产物的套件在未构建的检出上会自跳。两项覆盖率门禁都通过 `DSH_COVERAGE_TEST_TIMEOUT_MS=90000` 提高 Vitest 的单测试、expect.poll 与 hook 预算。观测步骤复用成功的构建,保留独立的 MPA 文档构建,一起启动相互独立的静态门禁,并让 `publint` 使用 CI worker 默认值,在共享自托管运行器上覆盖为 8 个 worker。其 built-bin 冒烟测试只在其他所有观测性门禁结算后启动,包括失败的门禁。ready 聚合要求此前已成功构建工作区;它仅从内部共用清单中移除该构建及其内部依赖边。这可避免有界的真实应用启动测量与 tool-catalog、NodeNext、包及文档进程争抢资源。translation-pairing 合并套件只导入 `scripts/` 源码和子进程,因此放入豁免重型套件门禁;V8 插桩不会为它贡献任何阈值覆盖率,却会放大 Git 进程延迟。Lefthook 并发 fixture 保留原有结果,采用 30 秒单用例预算与 10 秒进程就绪探测;安装器则允许被抢占的 lock 持有者在独占创建后用 5 秒发布记录。directory-picker 组合为防抖配置写入提供显式的 15 秒轮询预算;workspace-context 组合 fixture 使用测试自有、没有无关 1 秒截止时间的信号。LSP 源码与 ACL 沙箱源码仍计入 Windows 分母:基于 stub 的失败路径套件把每个进程内 ACL 沙箱文件都带到 100%,只有 runner 入口保持排除——它只作为 spawn 出的子进程在插桩运行之外执行,其行为由 runner 套件端到端钉住。窄范围且带注释的 V8 ignore 只覆盖不可达分支(另一平台专属分支、生命周期内不可达的防御守卫),其行为测试仍保留在所属平台。 16 核配置是这项清单经实测选定的容量规格。使用 6 个 coverage worker 的试验分别以 6 分 27 秒和 7 分 50 秒跑出完整通过结果,而在单个插桩 Vitest 进程内使用 4 个、3 个和 2 个并发 worker 的分支头精确试验暴露出不稳定的 fixture 与 worker 退出。相互独立的单 worker 子进程保留进程隔离。历史上的 16 分片样本把插桩覆盖率缩短到 112.66–122.01 秒。拉取请求覆盖率作业会在没有前置构建的情况下调度 4 个插桩子进程和 2 个豁免 worker,而自托管完整参考流程会用 1 个 worker 串行运行未分片的覆盖率门禁。拉取请求若采用 6 分片配置,就会产生足以违反有界测试截止时间的进程与类型感知 lint 争用。16 个插桩分片加 2 个豁免 worker 会在计入系统开销前就超过 16 核分配。32 核对比仅将聚合门禁时间缩短 1.47 秒,且仍在 fork worker 内触发 CJS lexer 致命故障,因此增加核心数没有带来可靠的墙钟时间改善。 @@ -40,7 +40,7 @@ Shiki 会禁用 TextMate 正则的延迟编译,并在用户内容进入保持 **只在拉取请求上运行 Wine。** Wine 能快速触达阻断性 win32 工具链分支,但即使真实 NT、NTFS、PowerShell、进程或原生插件约定已经损坏,也可能报告绿灯。 -**将每个非阻断原生作业都标记为 `continue-on-error`。** 观测性作业采用该设置,因为它的阻断性判定由 Linux 负责。覆盖率仍是聚合流程 `needs` 之外的常规作业,因此阈值失败会保持明显红灯,却不会阻断聚合流程。 +**将每个非阻断原生作业都标记为 `continue-on-error`。** 只有观测步骤采用该设置,因为它的阻断性判定由 Linux 负责。包含该步骤的构建作业仍必须在阻断性构建或生产网站失败时失败。覆盖率仍是聚合流程 `needs` 之外的常规作业,因此阈值失败会保持明显红灯,却不会阻断聚合流程。 **排除看似不受支持的文件或削弱 Windows fixture。** 不予采纳,因为受影响的 LSP、watcher、持久化、客户端与进程行为均受支持。仅适用于另一平台的分支采用窄范围标注;可移植结果继续计入分母,并通过符合真实宿主行为的 fixture 验证。 @@ -50,8 +50,8 @@ Shiki 会禁用 TextMate 正则的延迟编译,并在用户内容进入保持 ## 后果 -Wine 提供合并后的工具链证据。`all checks passed` 变绿时,原生覆盖率与观测性结果仍可能处于待处理或红灯状态,因此分支保护采用定向原生构建和进程检查,而评审者和后续自动化采用其余原生结果。 +Wine 提供合并后的工具链证据。`all checks passed` 变绿时,原生覆盖率仍可能处于待处理或红灯状态。观测诊断在必需的构建作业内完成。诊断失败时,PR 检查仍显示成功;失败步骤、工作流警告和运行摘要会展示该失败。 -尽管如此,每个拉取请求都会获得真实 NT 内核、NTFS、PowerShell、Windows 进程、原生插件和受支持源码覆盖率信号。原生作业会在构建、覆盖率与观测性工作区中重复设置流程,并在构建与观测性工作区中重复构建,但它们会降低每个作业的进程数,并暴露兼容性通道掩盖的路径、watcher、生命周期与 fixture 缺陷。 +每个拉取请求都会获得真实 NT 内核、NTFS、PowerShell、Windows 进程、原生插件和受支持源码覆盖率信号。观测检查复用成功构建的工作区,省去第二次检出、依赖安装和工作区构建。顺序执行会延长构建作业,并可能延迟必需判定;它也让诊断进程与编译分开运行。ready 与 master 聚合共用一份内部诊断清单;master 保留自己的工作区构建和完整检查。 维护者必须保留两种有意设计的执行拓扑:Wine 快照使用 Linux 安装加 hoisted 布局来触达 win32 二进制文件,而原生作业在组织自有的 16 核 Windows 运行器上使用相互独立的不可变工作区。任一拓扑独有的失败都必须依据该边界分类,不得削弱或静默跳过。 diff --git a/.agents/notes/implemented/process/2026-09-07-issue-policy-module-ownership.i18n.yaml b/.agents/notes/implemented/process/2026-09-07-issue-policy-module-ownership.i18n.yaml deleted file mode 100644 index 37bf7d8e0c..0000000000 --- a/.agents/notes/implemented/process/2026-09-07-issue-policy-module-ownership.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# 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/process/2026-09-07-issue-policy-module-ownership.md -2026-09-07-issue-policy-module-ownership.md: 696343ddffa3f6a6e411330d038bb4a470ce78d8 -2026-09-07-issue-policy-module-ownership.zh.md: abe539a5baed35cf2f356194eab8e53d61b67314 diff --git a/.agents/notes/implemented/process/2026-09-07-issue-policy-module-ownership.md b/.agents/notes/implemented/process/2026-09-07-issue-policy-module-ownership.md deleted file mode 100644 index 696343ddff..0000000000 --- a/.agents/notes/implemented/process/2026-09-07-issue-policy-module-ownership.md +++ /dev/null @@ -1,29 +0,0 @@ -# Agent Note: Issue policy module ownership - -Status: implemented - -English | [中文](2026-09-07-issue-policy-module-ownership.zh.md) - -## Problem - -Issue validation and Project lifecycle processing use the same reference parser, metadata rules, and GitHub reads, but they make different decisions and perform different writes. Keeping those responsibilities in the command entry makes it harder to reuse policy rules without also depending on event dispatch and output handling. - -## Decision - -Issue management separates pure decisions, GitHub access, pull-request evaluation, lifecycle mutations, and command dispatch into owner modules. [The owner reference](../../../../.github/issue-management/README.md#module-ownership) maps those responsibilities to source files. Callers and tests import the module that owns the operation rather than using the command entry as an export collection. - -The separation preserves policy results, diagnostics, credentials, API requests, lifecycle mutations, and workflow entry commands. [Selective evaluation](2026-09-07-selective-issue-policy-evaluation.md) remains the independent owner of eligibility, Project-read selection, and event scheduling; this decision does not redefine those behaviors. - -## Alternatives considered - -**Keep one command module.** A single file avoids imports between local owners, but ties reusable decisions and GitHub access to command handling. Separate owners let PR validation and lifecycle processing share their existing rules and reads without sharing command dispatch. - -## Consequences - -Maintainers can locate a rule, network operation, or event handler by responsibility. Reuse occurs through ordinary local ESM imports, not a new package or plugin API. The added modules introduce imports that must stay coordinated when a shared function changes. - -Module separation does not grant stronger credentials, change check authority, serialize Project mutations, or prevent a future behavior regression. Existing lifecycle races and field-management limitations remain documented by their behavior owners. - -## Verification - -[Policy tests](../../../../.github/issue-management/policy.test.mjs) exercise the owner modules and check observable policy and lifecycle behavior. [Workflow tests](../../../../scripts/ci-workflow.spec.ts) cover the command wiring and workflow declarations. Their evidence concerns the checked implementation, not a guarantee that later edits preserve behavior. diff --git a/.agents/notes/implemented/process/2026-09-07-issue-policy-module-ownership.zh.md b/.agents/notes/implemented/process/2026-09-07-issue-policy-module-ownership.zh.md deleted file mode 100644 index abe539a5ba..0000000000 --- a/.agents/notes/implemented/process/2026-09-07-issue-policy-module-ownership.zh.md +++ /dev/null @@ -1,29 +0,0 @@ -# Agent Note: Issue 策略模块归属 - -Status: implemented - -[English](2026-09-07-issue-policy-module-ownership.md) | 中文 - -## 问题 - -Issue 校验与 Project 生命周期处理使用相同的引用解析器、元数据规则和 GitHub 读取,但各自作出不同决策、执行不同写入。将这些职责放在命令入口中,会使复用策略规则时更难摆脱对事件分派与输出处理的依赖。 - -## 决策 - -Issue 管理将纯决策、GitHub 访问、PR(Pull Request)求值、生命周期 mutation 和命令分派分配给各自的所属模块。[所属参考文档](../../../../.github/issue-management/README.zh.md#module-ownership)将这些职责对应到源文件。调用方和测试直接导入拥有该操作的模块,不把命令入口用作导出集合。 - -此职责分离保留策略结果、诊断、凭据、API 请求、生命周期 mutation 和工作流入口命令。[选择性求值](2026-09-07-selective-issue-policy-evaluation.zh.md)仍独立拥有强制范围、Project 读取选择和事件调度规则;本决策不重新定义这些行为。 - -## 考虑过的替代方案 - -**保留单一命令模块。** 单文件可以避免本地所属模块之间的导入,但会把可复用的决策与 GitHub 访问绑定到命令处理。独立的所属模块让 PR 校验和生命周期处理可以共享既有规则与读取,而无需共享命令分派。 - -## 影响 - -维护者可以按职责定位规则、网络操作或事件处理器。复用通过普通的本地 ESM 导入完成,而不是新增包或插件 API。新增模块引入的导入关系需要在共享函数变更时同步维护。 - -模块分离不会授予更强的凭据、更改检查权威来源、串行化 Project mutation,也不能防止未来的行为回归。既有生命周期竞态与字段管理限制仍由各自的行为文档记录。 - -## 验证 - -[策略测试](../../../../.github/issue-management/policy.test.mjs)运行所属模块,并检查可观察的策略与生命周期行为。[工作流测试](../../../../scripts/ci-workflow.spec.ts)覆盖命令接线与工作流声明。这些证据针对被检查的实现,不保证后续编辑仍保留行为。 diff --git a/.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.i18n.yaml b/.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.i18n.yaml index 384d675f4c..bfd50afb64 100644 --- a/.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.i18n.yaml +++ b/.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.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-09-blacksmith-failover-leg.md -2026-09-09-blacksmith-failover-leg.md: d4be1ad25c6c5b66e295439d4609726c8918e137 -2026-09-09-blacksmith-failover-leg.zh.md: cd6e74aefba2cba9481fa1ae9510bb1ce020325d +2026-09-09-blacksmith-failover-leg.md: 4fb4ed4ccb500887622200e01310230393b3de12 +2026-09-09-blacksmith-failover-leg.zh.md: c2c4ee76a3fcd8c9f16c635ac81bc987d40a04e2 diff --git a/.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.md b/.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.md index d4be1ad25c..4fb4ed4ccb 100644 --- a/.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.md +++ b/.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.md @@ -10,9 +10,9 @@ The [failover runbook](2026-07-26-ci-failover-runbook.md) routes degraded enterp ## Decision -`DSH_CI_FAILOVER_LINUX` and `DSH_CI_FAILOVER_WINDOWS` additionally accept the value `blacksmith`. When any repository writer sets it, the platform's participating jobs retarget onto Blacksmith's hosted runners at the vCPU size matching the pool each job replaces; any other value (or unset) keeps today's runner, and `selfhosted` keeps routing to the in-house pools. Like the existing values this is writer-manageable repository state — no merge, re-run the affected jobs — and it costs money only while set, which is why it is an explicit outage or experiment choice, never the default. +`DSH_CI_FAILOVER_LINUX` and `DSH_CI_FAILOVER_WINDOWS` additionally accept the value `blacksmith`. When any repository writer sets it, the platform's participating jobs retarget onto Blacksmith's hosted runners at each job's configured vCPU size; any other value (or unset) keeps today's runner, and `selfhosted` keeps routing to the in-house pools. Like the existing values this is writer-manageable repository state — no merge, re-run the affected jobs — and it costs money only while set, which is why it is an explicit outage or experiment choice, never the default. -Participating jobs under `blacksmith`: the three enterprise Linux workers, the `node-compat` legs, the `all-checks-passed` verdict, and the four Windows lanes in [ci.yml](../../../../.github/workflows/ci.yml) (16-core pools map to `blacksmith-16vcpu-ubuntu-2404` and `blacksmith-16vcpu-windows-2025`, standard-hosted legs to `blacksmith-4vcpu-ubuntu-2404`); `expected-filenames`; the sandbox bwrap leg; and the two manual benchmark matrices, whose 4-32 vCPU tiers map to Blacksmith equivalents while the 64/96-core rows keep the fleet labels because Blacksmith offers no such tier. The blacksmith branches carry no Dependabot exclusion: Blacksmith runners are ephemeral, so the persistent-VM rationale that keeps Dependabot on the hosted pool does not apply. The python-runtime builder stays hosted in full: its PR and master callers (`ci.yml`'s and `ci-master.yml`'s `python-runtime` jobs) pass real-API credentials (`DEEPSEEK_API_KEY_EXTERNAL`) into the reusable workflow, its macOS and arm64 single-exe legs need GitHub's images, and its wheel output feeds the PyPI publication chain. The docs-pages workflow is a tag-dispatched release publication, so it stays hosted too. +Participating jobs under `blacksmith`: the three enterprise Linux workers, the `node-compat` legs, the `all-checks-passed` verdict, and the three Windows jobs in [ci.yml](../../../../.github/workflows/ci.yml), using the per-job runner sizes declared there; `expected-filenames`; the sandbox bwrap leg; and the two manual benchmark matrices, whose 4-32 vCPU tiers map to Blacksmith equivalents while the 64/96-core rows keep the fleet labels because Blacksmith offers no such tier. The blacksmith branches carry no Dependabot exclusion: Blacksmith runners are ephemeral, so the persistent-VM rationale that keeps Dependabot on the hosted pool does not apply. The python-runtime builder stays hosted in full: its PR and master callers (`ci.yml`'s and `ci-master.yml`'s `python-runtime` jobs) pass real-API credentials (`DEEPSEEK_API_KEY_EXTERNAL`) into the reusable workflow, its macOS and arm64 single-exe legs need GitHub's images, and its wheel output feeds the PyPI publication chain. The docs-pages workflow is a tag-dispatched release publication, so it stays hosted too. Everything else keeps its own runner under every value: jobs pinned to standard hosted runners by workflow spec (`node-24-bench`, `python-sdk`, the master Wine gate, `release-publish` and `release-vendor-publish`, the Cloudflare preview, the weighted-approval pair); jobs that hold credentials (the real-API e2e lanes, the issue App-token jobs, the npm and PyPI publishers); jobs that need a host capability Blacksmith lacks (the Landlock legs and their pack verification, the Seatbelt leg, the linux-arm64 single-exe leg); and the release-shaped chains — the npm publishers, the PyPI publishers, the node-addon-system release chain, and the Pages deployment workflow — which stay entirely hosted because their artifacts feed published registry output. The release rehearsal jobs in `release.yml` and `release-vendor.yml` (`dependencies`, `pack`) keep their existing pre-change routing unchanged: they carry only the pre-existing `selfhosted` branch, so under `blacksmith` they fall back to their hosted `ubuntu-24.04` default exactly as under an unset variable, and they gain no `blacksmith` branch. diff --git a/.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.zh.md b/.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.zh.md index cd6e74aefb..c2c4ee76a3 100644 --- a/.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.zh.md +++ b/.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.zh.md @@ -10,9 +10,9 @@ Status: implemented ## 决策 -`DSH_CI_FAILOVER_LINUX` 与 `DSH_CI_FAILOVER_WINDOWS` 额外接受值 `blacksmith`。任何仓库写者设置它时,该平台参与切换的作业按与被替换池匹配的 vCPU 档位重定向到 Blacksmith 托管运行器;任何其它值(或未设置)保持今天的运行器,`selfhosted` 继续路由到自有池。与既有取值一样,这是写者可管理的仓库状态——无需合并,重跑受影响的作业即可——并且只在设置期间产生费用,这正是它作为明确的故障或实验选择、而非默认的原因。 +`DSH_CI_FAILOVER_LINUX` 与 `DSH_CI_FAILOVER_WINDOWS` 额外接受值 `blacksmith`。任何仓库写者设置它时,该平台参与切换的作业按各作业配置的 vCPU 档位重定向到 Blacksmith 托管运行器;任何其它值(或未设置)保持今天的运行器,`selfhosted` 继续路由到自有池。与既有取值一样,这是写者可管理的仓库状态——无需合并,重跑受影响的作业即可——并且只在设置期间产生费用,这正是它作为明确的故障或实验选择、而非默认的原因。 -`blacksmith` 下参与切换的作业:[ci.yml](../../../../.github/workflows/ci.yml) 中三个企业级 Linux 工作作业、`node-compat` 各腿、`all-checks-passed` 判定作业与四条 Windows 通道(16 核池映射到 `blacksmith-16vcpu-ubuntu-2404` 与 `blacksmith-16vcpu-windows-2025`,标准托管腿映射到 `blacksmith-4vcpu-ubuntu-2404`);`expected-filenames`;sandbox 的 bwrap 腿;以及两个手动基准矩阵——其 4-32 vCPU 档位映射到 Blacksmith 等价档,64/96 核行保留自有池标签,因为 Blacksmith 没有对应档位。blacksmith 分支不带 Dependabot 排除:Blacksmith 运行器是临时的,因此把 Dependabot 留在托管池的持久虚拟机理由不适用。python-runtime 构建器整体保持托管:其 PR 与 master 调用方(`ci.yml` 与 `ci-master.yml` 的 `python-runtime` 作业)会把真实 API 凭据(`DEEPSEEK_API_KEY_EXTERNAL`)传入该可复用工作流,其 macOS 与 arm64 单文件分支需要 GitHub 镜像,且其 wheel 产物供给 PyPI 发布链。docs-pages 工作流是 tag 派发的发布,因此同样保持托管。 +`blacksmith` 下参与切换的作业:[ci.yml](../../../../.github/workflows/ci.yml) 中三个企业级 Linux 工作作业、`node-compat` 各腿、`all-checks-passed` 判定作业与三个 Windows 作业,使用该工作流为各作业声明的运行器规格;`expected-filenames`;sandbox 的 bwrap 腿;以及两个手动基准矩阵——其 4-32 vCPU 档位映射到 Blacksmith 等价档,64/96 核行保留自有池标签,因为 Blacksmith 没有对应档位。blacksmith 分支不带 Dependabot 排除:Blacksmith 运行器是临时的,因此把 Dependabot 留在托管池的持久虚拟机理由不适用。python-runtime 构建器整体保持托管:其 PR 与 master 调用方(`ci.yml` 与 `ci-master.yml` 的 `python-runtime` 作业)会把真实 API 凭据(`DEEPSEEK_API_KEY_EXTERNAL`)传入该可复用工作流,其 macOS 与 arm64 单文件分支需要 GitHub 镜像,且其 wheel 产物供给 PyPI 发布链。docs-pages 工作流是 tag 派发的发布,因此同样保持托管。 其余作业在任何取值下都保留自己的运行器:被 workflow spec 钉死在标准托管运行器上的作业(`node-24-bench`、`python-sdk`、master 的 Wine 门禁、`release-publish` 与 `release-vendor-publish`、Cloudflare preview、weighted-approval 一对);持有凭据的作业(真实 API 的 e2e 通道、issue App token 作业、npm 与 PyPI 发布作业);需要 Blacksmith 缺失的宿主能力的作业(Landlock 各腿及其打包校验、Seatbelt 腿、linux-arm64 单文件分支);以及发布形态的链——npm 发布、PyPI 发布、node-addon-system 发布链与 Pages 部署工作流——它们整体保持托管,因为其产物供给已发布的 registry 输出。`release.yml` 与 `release-vendor.yml` 中的发布演练作业(`dependencies`、`pack`)保持改动前既有的路由不变:它们只带既有的 `selfhosted` 分支,因此在 `blacksmith` 取值下与变量未设置时一样回落到托管的 `ubuntu-24.04` 默认,并不新增 `blacksmith` 分支。 diff --git a/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.i18n.yaml b/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.i18n.yaml index 2b32ebfee1..2c5a3f8554 100644 --- a/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.i18n.yaml +++ b/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.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-15-shipped-optional-bundles.md -2026-09-15-shipped-optional-bundles.md: 40ef69d3880f8d1c925c1e3c423e59a628f22e09 -2026-09-15-shipped-optional-bundles.zh.md: adf2b979b9d23b7510c077f3633e2d030732a7b4 +2026-09-15-shipped-optional-bundles.md: c9f560f5c07ec6fb18030b77a0d1f53bd27dc045 +2026-09-15-shipped-optional-bundles.zh.md: e91b6580e29cd7ae142c9c61ed6aac7e5e1476eb diff --git a/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.md b/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.md index 40ef69d388..c9f560f5c0 100644 --- a/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.md +++ b/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.md @@ -14,7 +14,7 @@ The launcher names in `OPTIONAL_BUNDLES` (`packages/boot/app-boot/src/profile.ts Default-product isolation keeps its rules with one declared exception: an optional bundle's dependency graph is outside the default product. The static gate skips the `dependencies` edge from `@deepseek-ai/dsh` to a listed bundle and still rejects a runtime import, a shipped composition, a preset, or a default template that names it, an experimental dependency the list does not name, and a listed name that is not a runtime dependency or not a bundle. The workspace-constraints check accepts the same `dependencies` edges and no other runtime section, and the packed-install release check skips them from the installed entry package while requiring each listed bundle to be installed. -Agent Teams ships this way, as `@deepseek-ai/dsh-experimental-agent-team-profile` and `@deepseek-ai/dsh-experimental-agent-team-web-profile`. Auto review is a published experimental package the page's install guide names as its example, not an optional bundle. +The [single-bundle decision](../architecture/2026-09-18-agent-teams-single-bundle.md) supersedes the two Agent Teams selections with one `@deepseek-ai/dsh-experimental-agent-team-profile` selection. This note retains the installation-owned optional-bundle policy. Auto review is a published experimental package the page's install guide names as its example, not an optional bundle. ## Alternatives considered @@ -26,4 +26,4 @@ Agent Teams ships this way, as `@deepseek-ai/dsh-experimental-agent-team-profile ## Consequences -Optional bundles are downloaded with the product and stay inactive until selected; the runtime isolation smokes still observe no experimental module in a default composition. Switching on an optional Web layer loads its client plugin through the live client module graph. A bundle that needs a companion layer, such as the Agent Teams Web layer over its Host layer, says so in its description; the manager does not select companions automatically. +Optional bundles are downloaded with the product and stay inactive until selected; the runtime isolation smokes still observe no experimental module in a default composition. Switching on an optional Web layer loads its client plugin through the live client module graph. diff --git a/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.zh.md b/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.zh.md index adf2b979b9..e91b6580e2 100644 --- a/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.zh.md +++ b/.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.zh.md @@ -14,7 +14,7 @@ Web 插件页只管理用户装进 profile 的组合包。像 Agent Teams 这样 默认产品隔离的规则保持不变,只声明一个例外:可选组合包的依赖图在默认产品之外。静态门禁跳过从 `@deepseek-ai/dsh` 到列表中组合包的 `dependencies` 边,仍然拒绝运行时 import、随附组合、preset 或默认模板对它的引用,拒绝列表没有点名的实验依赖,也拒绝不是运行时依赖或不是组合包的列表项。workspace 约束检查接受同样的 `dependencies` 边而不接受其他运行时依赖段;发布时的 packed-install 检查对已安装入口包跳过这些边,并要求列表中的每个组合包都已安装。 -Agent Teams 以这种方式交付,即 `@deepseek-ai/dsh-experimental-agent-team-profile` 与 `@deepseek-ai/dsh-experimental-agent-team-web-profile`。Auto review 是一个已发布的实验包,插件页的安装引导拿它作示例,它不是可选组合包。 +[单组合包决策](../architecture/2026-09-18-agent-teams-single-bundle.zh.md)将两个 Agent Teams 选项替换为一个 `@deepseek-ai/dsh-experimental-agent-team-profile` 选项。本记录保留由安装方决定可选组合包的策略。Auto review 是一个已发布的实验包,插件页的安装引导拿它作示例,它不是可选组合包。 ## 考虑过的替代方案 @@ -26,4 +26,4 @@ Agent Teams 以这种方式交付,即 `@deepseek-ai/dsh-experimental-agent-tea ## 影响 -可选组合包随产品一起下载,选中前保持不活动;运行时隔离 smoke 在默认组合中仍观察不到任何实验模块。开启一个可选 Web 层会通过实时客户端模块图加载它的客户端插件。需要配套层的组合包,例如 Agent Teams Web 层依赖其 Host 层,会在描述里说明;管理器不会自动选中配套层。 +可选组合包随产品一起下载,选中前保持不活动;运行时隔离 smoke 在默认组合中仍观察不到任何实验模块。开启一个可选 Web 层会通过实时客户端模块图加载它的客户端插件。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-09-composer-placeholder-whitespace.i18n.yaml b/.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.i18n.yaml similarity index 55% rename from .agents/notes/implemented/bug-fix/2026-09-09-composer-placeholder-whitespace.i18n.yaml rename to .agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.i18n.yaml index c971fb1bfd..b2f71a9f4d 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-09-composer-placeholder-whitespace.i18n.yaml +++ b/.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.i18n.yaml @@ -1,6 +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-09-composer-placeholder-whitespace.md -2026-09-09-composer-placeholder-whitespace.md: c5d778f8fb1fdfc742636819a9ff27ddeeb0234e -2026-09-09-composer-placeholder-whitespace.zh.md: b5f82bcd86f8567976bba96095d4de9d42bae9b1 +# 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 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 new file mode 100644 index 0000000000..911e027719 --- /dev/null +++ b/.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.md @@ -0,0 +1,47 @@ +# Agent Note: Windows runtime signature cache + +Status: implemented + +English | [中文](2026-09-17-windows-runtime-signature-cache.zh.md) + +## Problem + +Windows packaging reconstructs unsigned native dependencies on each build. Signing unchanged Python and LibreOffice files repeats hardware-token and timestamp operations. Starting PowerShell for each public-key inspection also repeats process initialization. + +## Decision + +Cache complete signed runtime files by original content, public certificate, SignTool bytes, signing-script bytes and cache format. Keep valid vendor signatures. Restore a cache hit through a private staged copy only after checking the input record, signed-file digest, Windows trust, timestamp and configured certificate; reject invalid entries without automatic repair or hardware fallback. Publish complete entries atomically, and retain an existing valid entry when concurrent publishers collide. + +Share the cache only within the current Windows account. Require a fixed local drive, current-account ownership and a private destination DACL; reject linked or redirected paths. Programs running as that account remain trusted. Explicit migration copies complete integrity-checked entries without modifying its source or replacing an existing valid entry. Different timestamp bytes at the same input/policy key do not require replacement; every actual restoration still verifies Windows trust. + +The default cache lives beside the account's existing signing state under the user profile. AppData is unsuitable for this default because an MSIX launcher can redirect new files into its private package storage, separating otherwise identical worktrees launched from different programs. A validated explicit directory remains available for isolated build storage. + +Serialize each complete signing stage and cache-maintenance operation with an exclusive Windows file handle. The lock covers preflight, primary-runtime signing, application-runtime signing and artifact creation separately, leaving compilation and preparation concurrent. A waiter rechecks the cache after acquisition. Stage ownership and the retained hardware-attempt interlock have different lifetimes: closing the stage handle releases contention, while hardware failures and interruptions retain the existing attempt evidence and never authorize an automatic retry. + +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. + +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 + +**Multi-file hardware signing.** The primary-runtime decision records that SignTool can continue after the first file fails. Reducing repeated hardware work through caching preserves the existing stop behavior. + +**Detached signature extraction and attachment.** Complete-file caching avoids adding PE-signature manipulation to packaging. Its additional disk use is measurable and confined to build storage. + +**Whole signed-runtime artifacts only.** They can also avoid extraction and copying, but require assembly and dependency-graph identities. Per-file caching can reuse unchanged native dependencies when application code or one dependency changes. + +**Per-entry locks only.** They avoid duplicate signatures for one key but do not coordinate different files, preflight or installer signing against the same hardware token. Stage-wide ownership covers those calls without holding a lock during unrelated compilation. + +**Cache-age expiry.** Age alone does not establish that a timestamped signature is invalid. Rechecking current Windows trust preserves the validation requirement without periodically repeating unchanged hardware signatures. + +## Consequences + +Repeated builds with unchanged native inputs avoid hardware calls for cached files while retaining preflight and final artifact signing. Changed content or signing policy misses the cache. Corrupt, incomplete, untrusted or incorrectly timestamped entries fail without replacing the target or retrying hardware. Concurrent publication and interrupted processes cannot expose incomplete entries or damage another build's files. + +Complete signed Windows x64 builds on one host measure an empty shared cache and reuse from a separate fresh worktree: hardware calls fall from 379 to 17, elapsed time from 37:03 to 13:45, and all 362 runtime entries hit. Cache restoration takes 132 seconds, including 130 seconds of current trust verification. Both builds pass final runtime smoke and signed-artifact checks. Downloads are prepared before timing; each scenario is one complete run. A separate controlled native-file change produces miss/hit/miss with two hardware calls; it tests invalidation, not a dependency-version upgrade. Installer execution and update qualification remain separate. + +## Risks + +The cache consumes local disk and depends on the build account controlling its files and records. It is not a distribution format for untrusted remote caches. Signature validation still depends on Windows trust services; cache hits do not authorize clearing a signing interlock or retrying a failed token operation. A timestamp-service failure can interrupt a cold build even after a successful preflight. + +No automatic capacity eviction runs. Explicit maintenance waits for stage ownership and retires complete entries before deleting their files; interrupted staging directories remain distinguishable from published entries. Runtime summaries count actual publications separately from retained collisions, and report cache verification time separately from signing time. They do not measure the entire package's verification cost. 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 new file mode 100644 index 0000000000..bb8cdd6bdc --- /dev/null +++ b/.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.zh.md @@ -0,0 +1,47 @@ +# Agent Note: Windows 运行时签名缓存 + +Status: implemented + +[English](2026-09-17-windows-runtime-signature-cache.md) | 中文 + +## Problem + +Windows 打包每次都会重新生成未签名的原生依赖。为未变化的 Python 和 LibreOffice 文件签名会重复执行硬件令牌操作和时间戳请求。每个公钥验签任务单独启动 PowerShell,也会重复进程初始化。 + +## Decision + +按原始内容、公开证书、SignTool 字节、签名脚本字节和缓存格式缓存完整已签名运行时文件。保留有效厂商签名。缓存命中后,通过私有暂存副本核对输入记录、已签名文件摘要、Windows 信任、时间戳和配置的证书,再恢复目标文件;无效缓存项直接失败,不自动修复或回退到硬件签名。完整缓存项原子发布,并发发布冲突时保留已有有效项。 + +缓存仅在当前 Windows 账户内共享。要求固定本地磁盘、当前账户所有权及私有目标目录 DACL;拒绝带链接或被重定向的路径。同账户运行的程序仍受信任。显式迁移复制通过完整性检查的完整缓存项,不修改源目录,也不替换已有有效项。同一输入和策略键下的时间戳字节不同,不构成替换理由;每次实际恢复仍验证 Windows 信任。 + +默认缓存位于用户目录下,与账户已有签名状态放在一起。AppData 不适合作为此默认位置,因为 MSIX 启动器可能将新文件重定向到私有包存储,使不同程序启动的相同 worktree 使用不同缓存。独立构建存储仍可指定经过验证的显式目录。 + +通过 Windows 独占文件句柄串行执行每个完整签名阶段及缓存维护操作。锁分别覆盖预检、主要运行时签名、应用运行时签名及产物生成,编译和准备仍可并行。等待者在获取锁后重新检查缓存。阶段所有权与持久保留的硬件尝试互锁具有不同生命周期:关闭阶段句柄释放竞争锁,而硬件失败或中断保留现有尝试证据,绝不构成自动重试授权。 + +公钥验签按每批 32 个文件分组,最多同时运行四个进程。要求每个请求路径都有一条顺序对应的结果,并等待失败批次中的所有进程退出。每次硬件签名后仍立即验签,通过后才开始下一个签名请求。 + +[主要运行时决策](../feature/2026-09-14-desktop-primary-runtime.zh.md) 继续定义运行时内容、厂商签名保留和执行检查。[发布决策](../architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md) 继续定义发布身份和签名。两者均未被取代。缓存必须保留受监督预检、每用户签名互锁、单文件硬件调用、已签名内容清单和最终打包运行时验证。 + +## Alternatives considered + +**多文件硬件签名。** 主要运行时决策记录了 SignTool 在首个文件失败后仍继续处理的行为。通过缓存减少重复硬件操作,可以保留现有停止语义。 + +**分离签名的提取与附加。** 缓存完整文件可以避免给打包流程新增 PE 签名处理代码。其额外磁盘占用可测量,且限于构建存储。 + +**仅复用整个已签名运行时制品。** 该方案还可以省去解压和复制,但需要标识组装流程及依赖图。逐文件缓存可以在应用代码或单个依赖变化时复用其余未变化的原生依赖。 + +**仅使用逐项锁。** 它能避免同一键的重复签名,却无法协调不同文件、预检或安装器签名对同一硬件令牌的访问。阶段级所有权覆盖这些调用,不在无关编译期间持锁。 + +**按缓存年龄过期。** 年龄本身不能说明带时间戳的签名无效。重新检查当前 Windows 信任可以保留验证要求,同时避免周期性重复未变化的硬件签名。 + +## Consequences + +原生输入未变化的重复构建不再为缓存覆盖的文件调用硬件签名,但仍执行预检和最终制品签名。内容或签名策略变化时缓存未命中。损坏、不完整、不可信或时间戳不合格的缓存项必须失败,不替换目标文件,也不重试硬件。并发发布和进程中断不能暴露不完整缓存项或损坏其他构建的文件。 + +同一主机上的完整 Windows x64 签名构建测量空共享缓存与另一个全新 worktree 的复用:硬件调用从 379 次降至 17 次,耗时从 37:03 降至 13:45,362 个运行时缓存项全部命中。缓存恢复耗时 132 秒,其中当前信任验证占 130 秒。两轮均通过最终运行时 smoke 及签名产物检查。下载内容在计时前准备好;每种场景均为一次完整运行。另一次受控原生文件变更产生未命中、命中、未命中的结果,合计两次硬件调用;它验证缓存失效,不代表依赖版本升级。安装器执行和更新验收仍需单独进行。 + +## Risks + +缓存占用本地磁盘,并依赖构建账户控制缓存文件及记录。它不是用于不可信远程缓存的分发格式。签名验证仍依赖 Windows 信任服务;缓存命中不构成清除签名互锁或重试失败令牌操作的授权。即使预检成功,时间戳服务故障仍可能中断冷构建。 + +不执行自动容量淘汰。显式维护等待阶段所有权,在删除文件前先将完整缓存项移出已发布名称;中断的暂存目录仍可与已发布项区分。运行时摘要区分实际发布和冲突保留,并将缓存验证耗时与签名耗时分别记录;它不测量整个安装包的验证成本。 diff --git a/.agents/notes/implemented/process/2026-09-17-windows-signature-completion.i18n.yaml b/.agents/notes/implemented/process/2026-09-17-windows-signature-completion.i18n.yaml new file mode 100644 index 0000000000..c8a392f733 --- /dev/null +++ b/.agents/notes/implemented/process/2026-09-17-windows-signature-completion.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/process/2026-09-17-windows-signature-completion.md +2026-09-17-windows-signature-completion.md: e9c81531633a41c87ea10c9e4af2999923aa4470 +2026-09-17-windows-signature-completion.zh.md: 3142749db309b638676c171d4b1da8bd34729fe4 diff --git a/.agents/notes/implemented/process/2026-09-17-windows-signature-completion.md b/.agents/notes/implemented/process/2026-09-17-windows-signature-completion.md new file mode 100644 index 0000000000..e9c8153163 --- /dev/null +++ b/.agents/notes/implemented/process/2026-09-17-windows-signature-completion.md @@ -0,0 +1,35 @@ +# Agent Note: Separate Windows hardware signing from timestamp completion + +Status: implemented + +English | [中文](2026-09-17-windows-signature-completion.zh.md) + +## Problem + +A combined SignTool command reports timestamp failure as signing failure. Retrying it repeats hardware access, while treating every timestamp error as an uncertain private-key outcome requires administrator recovery. Public timestamp-only requests can fail independently of the token. + +## Decision + +Sign a private PE copy once without a timestamp. Release the hardware interlock only after successful execution and Windows verification of the configured primary signature. Hardware or primary-verification failures retain the interlock; no path automatically recovers it. Appended signatures are unsupported. + +Timestamp fresh copies of the frozen signed file without signing credentials. Only normal failure or warning exits permit another attempt: at most three calls, with one- and two-second delays. Launch failures, uncertain termination, storage changes and verification failures stop immediately. The preflight's overall 60-second deadline includes these operations and does not guarantee three full subprocess deadlines. + +Require Windows trust, timestamp and certificate identity. Remove unsigned signature attributes from private copies with the configured SignTool and compare their complete normalized bytes. A removal warning is accepted only when all bytes remain unchanged. Certificate equality alone cannot exclude different content signed by the same certificate; full-file equality avoids a custom PE or ASN.1 canonicalizer. + +SignTool uses short filenames with the original extension in a private temporary directory. Its longest generated path is checked before hardware access, and the journal records the original-to-staged mapping. The target may occupy another volume: copy verified bytes into a private directory beside it, compare hashes, recheck the original and rename within that volume. Verify committed bytes afterward. The build account must own trusted storage and exclusive target access. + +Exhausted timestamp attempts stop packaging and queued work without another hardware call. Failure evidence is copied to the supervised run before staging cleanup; evidence or audit failure preserves staging. Final artifact verification and release completion remain mandatory. The [primary-runtime decision](../feature/2026-09-14-desktop-primary-runtime.md) owns runtime contents and vendor signatures. + +## Alternatives considered + +**Retry combined signing or classify diagnostic text.** Exit codes and localized text cannot establish the private-key outcome. Separate operations avoid repeated hardware access. + +**Reuse failed timestamp output.** A failed process may partially modify its file. Every attempt starts with identical verified primary-signature bytes. + +**Stage beside each target for all operations.** Nested dependencies can exceed SignTool's path limit even when the original fits. Short tool paths and same-volume publication address separate requirements. + +## Consequences + +Fault tests cover lock retention, isolated timestamp retries, queue rejection, credential exclusion, substituted content, changed targets, long paths and evidence failures. Public-only qualification covers EXE, DLL, PYD and NODE files, including failure followed by success and cross-volume publication. A real supervised signing preflight has passed; complete installer comparisons remain separate qualification. This decision does not identify or repair the timestamp service's network failures. + +DigiCert [supports separate signing and timestamping](https://knowledge.digicert.com/solution/troubleshooting-timestamping-problems). Microsoft documents [SignTool commands and exit codes](https://learn.microsoft.com/en-us/windows/win32/seccrypto/signtool). The configured tool and normalization implementation participate in signature-cache identity. diff --git a/.agents/notes/implemented/process/2026-09-17-windows-signature-completion.zh.md b/.agents/notes/implemented/process/2026-09-17-windows-signature-completion.zh.md new file mode 100644 index 0000000000..3142749db3 --- /dev/null +++ b/.agents/notes/implemented/process/2026-09-17-windows-signature-completion.zh.md @@ -0,0 +1,35 @@ +# Agent Note:分离 Windows 硬件签名与时间戳处理 + +Status: implemented + +[English](2026-09-17-windows-signature-completion.md) | 中文 + +## 问题 + +合并的 SignTool 命令把时间戳失败报告为签名失败。重试会重复访问硬件,而把所有时间戳错误都视为私钥操作结果不确定,又需要管理员恢复。公开时间戳请求可以在不使用令牌的情况下独立失败。 + +## 决策 + +对私有 PE 副本仅签名一次,不请求时间戳。只有命令成功且 Windows 验证配置证书的主签名后,才释放硬件互锁。硬件或主签名验证失败保留互锁;任何路径都不自动恢复互锁。不支持追加签名。 + +从固定的已签名文件生成新副本并请求时间戳,不传递签名凭据。只有正常退出但返回失败或警告时才允许再次尝试:最多调用三次,间隔为一秒和两秒。启动失败、终止状态不确定、存储变化和验签失败会立即停止。预检整体 60 秒期限包含这些操作,不保证每次子进程都能获得完整期限。 + +要求 Windows 信任、时间戳和证书身份均通过验证。使用配置的 SignTool 删除私有副本的未签名属性,再比较完整的规范化字节。删除警告仅在所有字节未变时接受。仅比较证书无法排除由同一证书签署的不同内容;全文件相等检查避免自行实现 PE 或 ASN.1 规范化器。 + +SignTool 在私有临时目录中使用保留原扩展名的短文件名。访问硬件前检查生成的最长路径,日志记录原文件与暂存文件的对应关系。目标可以位于另一个卷:将已验证字节复制到目标旁的私有目录,比较摘要,重新检查原文件,再在同一卷内重命名。随后验证提交后的字节。构建账户必须拥有受信任的存储和目标独占访问权。 + +时间戳尝试耗尽后,停止打包和排队任务,不再次调用硬件。清理暂存目录前,将失败证据复制到受监督运行目录;证据保存或审计失败时保留暂存目录。最终产物验签和发布完成检查仍是必需步骤。[第一方运行时决策](../feature/2026-09-14-desktop-primary-runtime.zh.md)负责运行时内容与上游签名。 + +## 考虑过的替代方案 + +**重试合并签名命令或按诊断文本分类。** 退出码和本地化文本无法确定私钥操作结果。分离操作避免重复访问硬件。 + +**复用失败的时间戳输出。** 失败进程可能已部分修改文件。每次尝试均从相同的已验证主签名字节开始。 + +**所有操作都在目标旁暂存。** 即使原路径未超限,嵌套依赖的暂存路径也可能超出 SignTool 限制。短工具路径和同卷发布分别满足不同要求。 + +## 影响 + +故障测试覆盖互锁保留、独立时间戳重试、队列拒绝、凭据隔离、内容替换、目标变化、长路径和证据保存失败。仅使用公开操作的验收覆盖 EXE、DLL、PYD 和 NODE 文件,包括先失败后成功及跨卷发布。真实受监督签名预检已通过;完整安装包对比仍需单独验收。此决策不确定或修复时间戳服务的网络故障。 + +DigiCert [支持分开签名与添加时间戳](https://knowledge.digicert.com/solution/troubleshooting-timestamping-problems)。Microsoft 记录了 [SignTool 命令及退出码](https://learn.microsoft.com/en-us/windows/win32/seccrypto/signtool)。配置的工具和规范化实现均参与签名缓存身份计算。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.i18n.yaml b/.agents/notes/implemented/simplification/2026-09-15-model-agent-availability-and-team-targets.i18n.yaml similarity index 52% rename from .agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.i18n.yaml rename to .agents/notes/implemented/simplification/2026-09-15-model-agent-availability-and-team-targets.i18n.yaml index d7871098ba..165022b7dc 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-03-resume-headers-do-not-repeat-system-prompts.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-09-15-model-agent-availability-and-team-targets.i18n.yaml @@ -1,6 +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-03-resume-headers-do-not-repeat-system-prompts.md -2026-09-03-resume-headers-do-not-repeat-system-prompts.md: de5e509c9e57af35bc36c24004457f02b63fd99d -2026-09-03-resume-headers-do-not-repeat-system-prompts.zh.md: 008e25d8d507aac8fbaec7230bad433732f0efd0 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-09-15-model-agent-availability-and-team-targets.md +2026-09-15-model-agent-availability-and-team-targets.md: e1f7f95cfa333be6d05677b4b19775c744f2817c +2026-09-15-model-agent-availability-and-team-targets.zh.md: 0447d7d4059eb75d1fb9169389cd83a54292aadf diff --git a/.agents/notes/implemented/simplification/2026-09-15-model-agent-availability-and-team-targets.md b/.agents/notes/implemented/simplification/2026-09-15-model-agent-availability-and-team-targets.md new file mode 100644 index 0000000000..e1f7f95cfa --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-09-15-model-agent-availability-and-team-targets.md @@ -0,0 +1,29 @@ +# Agent Note: Model agent availability and Team targets + +Status: implemented + +English | [中文](2026-09-15-model-agent-availability-and-team-targets.zh.md) + +## Problem + +Team operations accept member names, but returning both names and Session UUIDs invites the model to choose an unusable address. Loaded and stored agents also have distinct internal statuses despite requiring the same model action to continue work. + +## Decision + +The Team tool adapter returns `target` from the existing member name and omits member Session IDs from creation and listing results. Messaging, interruption, and task assignment consume that same name. Task `ownerName`, task IDs, and message IDs retain their existing meanings. Internal services, Web views, and persisted Team records retain Session identity. + +Normal subagent tool results and Team service views project loaded-between-turns and stored agents as `inactive`. Only an executing turn is `running`; Team retains `provisioning` and `failed` for member creation, and ordinary subagent diagnostic rows retain their read-failure reasons. Interruption projects its previous status by the same rule. Availability does not report task outcomes or waiting dependencies. + +The Team service owns roster and interruption availability for both tools and Web. The ordinary subagent projection belongs to its list tool. Runtime loading, cold resume, interruption authority, and mailbox delivery retain their existing behavior. Ordinary subagents continue to use `agent_id`; stable subagent names and historical-data handling are deferred. No persistence type or Session format changes. + +## Alternatives considered + +**Add stable names to ordinary subagents now.** This requires a durable naming policy and explicit handling of historical children without names. It is independent of removing misleading Team identities and model status distinctions. + +**Merge internal runtime states.** Residency determines whether delivery starts a loaded agent or restores one from storage. Underlying Agent status and registry presence retain that distinction; Team views expose only turn availability. + +## Consequences + +Models can copy a Team result's target directly into applicable operations. They lose member UUIDs and residency details from these tool results; neither is needed for Team addressing. Ordinary subagent and Team addressing remain different until a separate naming decision. + +Focused service and tool tests cover every status projection, returned-target operations, diagnostic rows, and live versus stored children. Recorded headless Team and SDK subagent scenarios pin the assembled model output and schemas. The [Agent Teams decision](../feature/2026-08-05-agent-teams.md) continues to own roster durability, scoping, and authority. diff --git a/.agents/notes/implemented/simplification/2026-09-15-model-agent-availability-and-team-targets.zh.md b/.agents/notes/implemented/simplification/2026-09-15-model-agent-availability-and-team-targets.zh.md new file mode 100644 index 0000000000..0447d7d405 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-09-15-model-agent-availability-and-team-targets.zh.md @@ -0,0 +1,29 @@ +# Agent Note: 模型可见的 Agent 可用状态与 Team 目标 + +Status: implemented + +[English](2026-09-15-model-agent-availability-and-team-targets.md) | 中文 + +## 问题 + +Team 操作接受成员名称,但同时返回名称和 Session UUID 容易使模型选择不可用的地址。已加载与仅存储的 agent 也有不同的内部状态,尽管模型继续其工作时采取相同操作。 + +## 决策 + +Team 工具适配器从现有成员名称生成 `target`,并从创建和列表结果中省略成员 Session ID。消息、中断和任务分配使用同一名称。任务的 `ownerName`、任务 ID 和消息 ID 保持现有含义。内部服务、Web 视图和持久化 Team 记录保留 Session 身份。 + +普通 subagent 工具结果与 Team 服务视图把已加载但处于轮次之间的 agent 和仅存储的 agent 都投影为 `inactive`。只有正在执行轮次时才是 `running`;Team 保留表示成员创建状态的 `provisioning` 与 `failed`,普通 subagent 的诊断行保留读取失败原因。中断结果按同一规则投影之前的状态。可用状态不报告任务结果或等待依赖。 + +Team 服务为工具和 Web 统一提供 roster 与中断的可用状态。普通 subagent 的投影归其列表工具所有。运行时加载、冷恢复、中断权限和邮箱投递保持现有行为。普通 subagent 继续使用 `agent_id`;稳定的 subagent 名称和历史数据处理暂缓。不修改持久化类型或 Session 格式。 + +## 考虑过的替代方案 + +**立即为普通 subagent 增加稳定名称。** 这需要持久化命名策略,以及对历史无名称子级的明确处理。它与移除误导性的 Team 身份和模型状态区别相互独立。 + +**合并内部运行状态。** 驻留状态决定投递是启动已加载的 agent,还是从存储恢复。底层 Agent 状态与注册表中的存在性保留该区别;Team 视图仅暴露轮次可用状态。 + +## 影响 + +模型可以把 Team 结果的 target 直接复制到适用操作。这些工具结果不再提供成员 UUID 和驻留细节;Team 寻址不需要它们。在另行决定命名方案之前,普通 subagent 与 Team 的寻址方式仍不同。 + +定向服务与工具测试覆盖所有状态投影、返回目标的操作、诊断行和在线与仅存储子级。录制的 headless Team 与 SDK subagent 场景固定组装后的模型输出和 schema。[Agent Teams 决策](../feature/2026-08-05-agent-teams.zh.md)继续拥有 roster 持久化、作用域和权限规则。 diff --git a/.agents/notes/proposed/simplification/2026-09-17-defer-office-background-scheduling.i18n.yaml b/.agents/notes/proposed/simplification/2026-09-17-defer-office-background-scheduling.i18n.yaml new file mode 100644 index 0000000000..35fe5b54d9 --- /dev/null +++ b/.agents/notes/proposed/simplification/2026-09-17-defer-office-background-scheduling.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/proposed/simplification/2026-09-17-defer-office-background-scheduling.md +2026-09-17-defer-office-background-scheduling.md: 82311022a015671b9fb4cf3a0f3eba2714f325cd +2026-09-17-defer-office-background-scheduling.zh.md: ada0146f204fa40e71d48251c231c1b68c5fa5a1 diff --git a/.agents/notes/proposed/simplification/2026-09-17-defer-office-background-scheduling.md b/.agents/notes/proposed/simplification/2026-09-17-defer-office-background-scheduling.md new file mode 100644 index 0000000000..82311022a0 --- /dev/null +++ b/.agents/notes/proposed/simplification/2026-09-17-defer-office-background-scheduling.md @@ -0,0 +1,40 @@ +# Agent Note: Defer Office background scheduling until a consumer needs it + +Status: proposed + +English | [中文](2026-09-17-defer-office-background-scheduling.zh.md) + +## Problem + +The [conversion queue](../../../../packages/document/office-to-pdf/src/queue.ts) implements foreground promotion, demotion after reader cancellation, eviction of queued background work, a reserved foreground reader allowance, and separate background concurrency accounting. These mechanisms protect foreground conversion from speculative prewarming; ordinary conversion still needs the queue's general resource limits. + +The [Office preview](../../../../packages/client/ui-sidebar-documentpreview/src/client/office/index.ts) calls its cache without a priority argument, so the [Client cache](../../../../packages/client/ui-sidebar-documentpreview/src/client/office/cache.ts) forwards the default foreground priority to the Host. Repository searches for `officeToPdf`, `OfficeToPdfRequest`, `OfficeToPdfPriority`, and `maxBackgroundConversions` find no production background request. The [conversion smoke](../../../../packages/bundle/web-app/tests/document-conversion.e2e.ts) also submits foreground requests. Background requests appear in queue, Remote, and Client-cache tests. The exposed API and Client priority plumbing alone do not establish which scheduling policy prewarming requires. The open delivery prewarming PR #4064 already provides a concrete prewarming consumer, but its implementation calls the older `documentRender` API without priority. Removing scheduling must first be reconciled with that consumer; absence from master is insufficient evidence to proceed. + +The [bounded conversion decision](../../implemented/architecture/2026-09-15-bounded-office-conversion.md) deliberately reserves capacity for foreground consumers. That remains the correct rule if speculation exists, but the current caller inventory does not justify maintaining the speculative request class and its lifecycle interactions. + +## Proposal + +Remove `OfficeToPdfPriority`, request `priority`, and configuration `maxBackgroundConversions`. Remove reader/job priority fields, promotion and demotion, speculative eviction, foreground reader reservation, and the queue's background counter. Remove Client-cache priority plumbing and update the Remote signature together. Admit the oldest queued job whose source reservation fits whenever conversion capacity is available; reject a new distinct job when the metadata queue is full. Continue joining an existing source job subject to the shared reader limit. + +Keep the provider, reusable converters, deferred authorized reads, all remaining resource limits, source-version checks, content sharing, bounded result cache, independent reader cancellation, and teardown that waits for actual work to settle. This proposal changes admission policy, not conversion ownership or engine selection. + +Update the request/config definitions, package README pair, subsystem and generated catalogs, and conversion fixtures together. Delete priority-only assertions; retain resource and cancellation regressions embedded in mixed priority tests by expressing them with ordinary requests. The existing bounded conversion note remains active because its cache, authorization, and lifetime rationale still applies; acceptance would replace only its scheduling policy. + +## Alternatives considered + +**Keep priority for a future prewarm consumer.** This avoids a later API change, but retains scheduling states and configuration before a product consumer can define their required behavior. Reintroduce priority with a concrete prewarming caller and measured foreground contention. + +**Disable background conversions by default.** This avoids speculative execution but retains the public request class, configuration, and all scheduling branches. + +**Remove the whole shared provider or cache.** That exceeds the evidence: independent conversion and bounded sharing remain coherent capabilities, and the Loader smoke exercises them without presentation plugins. + +## Acceptance criteria + +- Resolve the scheduling needs of delivery prewarming PR #4064 before implementation, then repeat the caller search. Retain priority if that consumer needs foreground protection; do not remove it merely because the consumer has not merged. +- Priority types, fields, configuration, and scheduling branches disappear from maintained source, tests, documentation, and generated catalogs. Decision records may describe the removed policy. +- Focused queue/provider tests preserve bounded admission, source/digest sharing, cancellation isolation, output ownership, and delayed capacity release. The real conversion Loader smoke still passes. +- Verify the foreground Office preview path; update its recorded-session snapshot if affected. Run the relevant type checks, generated-reference checks, documentation checks, and lint. + +## Risks + +Out-of-tree callers can use the exposed pre-stable API even though repository searches find no background consumer. Removing priority and its config field requires callers to update; this proposal offers no compatibility alias. Speculative callers would lose foreground protection and must stop prewarming until an explicit scheduling requirement is implemented. No runtime behavior changes until this proposal is accepted and implemented. diff --git a/.agents/notes/proposed/simplification/2026-09-17-defer-office-background-scheduling.zh.md b/.agents/notes/proposed/simplification/2026-09-17-defer-office-background-scheduling.zh.md new file mode 100644 index 0000000000..ada0146f20 --- /dev/null +++ b/.agents/notes/proposed/simplification/2026-09-17-defer-office-background-scheduling.zh.md @@ -0,0 +1,40 @@ +# Agent Note: 待调用方需要时再引入 Office 后台调度 + +Status: proposed + +[English](2026-09-17-defer-office-background-scheduling.md) | 中文 + +## 问题 + +[转换队列](../../../../packages/document/office-to-pdf/src/queue.ts) 实现了前台优先级提升、读取方取消后的降级、排队后台任务的驱逐、预留前台读取名额,以及独立的后台并发计数。这些机制保护前台转换不受推测性预热影响;普通转换仍需要队列的通用资源限制。 + +[Office 预览](../../../../packages/client/ui-sidebar-documentpreview/src/client/office/index.ts) 调用缓存时未传优先级,因此[Client 缓存](../../../../packages/client/ui-sidebar-documentpreview/src/client/office/cache.ts) 向 Host 转发默认前台优先级。在仓库中搜索 `officeToPdf`、`OfficeToPdfRequest`、`OfficeToPdfPriority` 和 `maxBackgroundConversions`,未发现生产后台请求。[转换冒烟测试](../../../../packages/bundle/web-app/tests/document-conversion.e2e.ts) 也提交前台请求。后台请求出现在队列、Remote 和 Client 缓存测试中。仅凭暴露 API 和 Client 优先级传递逻辑,无法确定预热需要哪种调度策略。开放的交付文件预热 PR #4064已经提供具体预热消费者,但其实现仍调用不含优先级的旧 `documentRender` API。删除调度前必须先与该消费者协调;尚未进入 master 不足以支持直接实施。 + +[有界转换决策](../../implemented/architecture/2026-09-15-bounded-office-conversion.zh.md) 有意为前台消费者预留容量。存在推测性任务时,这条规则仍然正确,但当前调用方清单不足以支持维护推测性请求类别及其生命周期交互。 + +## 提案 + +移除 `OfficeToPdfPriority`、请求中的 `priority` 和配置 `maxBackgroundConversions`。删除读取方及任务的优先级字段、升降级、推测性任务驱逐、前台读取名额预留,以及队列的后台计数器。同时删除 Client 缓存的优先级传递逻辑并更新 Remote 签名。在转换容量可用时,接纳源预留容量足够的最早排队任务;元数据队列满时拒绝新的独立任务。仍允许在共享读取方数量限制内加入已有源任务。 + +保留提供者、可复用转换器、延迟授权读取、其余所有资源限制、源版本校验、内容共享、有界结果缓存、读取方独立取消,以及等待实际工作结束的清理。这项提案改变接纳策略,不改变转换所有权或引擎选择。 + +同步更新请求及配置定义、包 README 双语文档、子系统与生成目录,以及转换 fixture(测试前置数据)。删除仅针对优先级的断言;将混合优先级测试中的资源与取消回归改写为普通请求,并保留覆盖。现有有界转换记录继续有效,因为其缓存、授权和生命周期理由仍然成立;采纳本提案只会替换其中的调度策略。 + +## 考虑过的替代方案 + +**为未来的预热调用方保留优先级。** 这可以避免后续 API 变更,但在产品调用方能够定义所需行为之前,就保留了调度状态与配置。应在有具体预热调用方和实测前台资源争用时重新引入优先级。 + +**默认禁用后台转换。** 这可以避免推测性执行,但仍保留公开请求类别、配置及全部调度分支。 + +**删除整个共享提供者或缓存。** 这超出了证据支持的范围:独立转换和有界共享仍是完整的能力,Loader 冒烟测试也在不依赖展示插件的情况下验证它们。 + +## 验收标准 + +- 实施前先明确交付文件预热 PR #4064 的调度需求,再重复调用方搜索。若该消费者需要前台保护,则保留优先级;不能仅因消费者尚未合入而删除。 +- 从维护中的源码、测试、文档和生成目录移除优先级类型、字段、配置及调度分支。决策记录可以描述被移除的策略。 +- 聚焦队列及提供者的测试保留有界接纳、源及摘要共享、取消隔离、输出所有权和延迟容量释放覆盖。真实转换的 Loader 冒烟测试仍然通过。 +- 验证前台 Office 预览路径;若受影响,同步更新其记录会话快照。运行相关类型检查、生成引用检查、文档检查及 lint。 + +## 风险 + +仓库外调用方可能使用已暴露的稳定前 API,即使仓库搜索未发现后台消费者。移除优先级及其配置字段要求调用方更新;本提案不提供兼容别名。推测性调用方会失去前台保护,必须停止预热,直到明确的调度需求得到实现。本提案被接受并实现之前,运行行为不变。 diff --git a/.agents/notes/rejected/process/2026-08-04-artifact-first-npm-baseline-publication.i18n.yaml b/.agents/notes/rejected/process/2026-08-04-artifact-first-npm-baseline-publication.i18n.yaml index 785297bf28..c0c61084e3 100644 --- a/.agents/notes/rejected/process/2026-08-04-artifact-first-npm-baseline-publication.i18n.yaml +++ b/.agents/notes/rejected/process/2026-08-04-artifact-first-npm-baseline-publication.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/rejected/process/2026-08-04-artifact-first-npm-baseline-publication.md -2026-08-04-artifact-first-npm-baseline-publication.md: ab4171c60dea47dbdf8fbd7b9d32a41abf36407d -2026-08-04-artifact-first-npm-baseline-publication.zh.md: 8b1faf75e1ecb616d2895b4f3d123fff1f37fbf6 +2026-08-04-artifact-first-npm-baseline-publication.md: 1c317176521afd2e11707f954520039a3f229a44 +2026-08-04-artifact-first-npm-baseline-publication.zh.md: c2822ed0b572b60fe23cab7c5dc1ebfbe02230f7 diff --git a/.agents/notes/rejected/process/2026-08-04-artifact-first-npm-baseline-publication.md b/.agents/notes/rejected/process/2026-08-04-artifact-first-npm-baseline-publication.md index ab4171c60d..1c31717652 100644 --- a/.agents/notes/rejected/process/2026-08-04-artifact-first-npm-baseline-publication.md +++ b/.agents/notes/rejected/process/2026-08-04-artifact-first-npm-baseline-publication.md @@ -18,7 +18,7 @@ The publication flow uses an immutable release bundle as its boundary. The pack The target set contains only `@deepseek-ai/*` workspace packages discovered from `packages/*/*/package.json` and `apps/*/package.json`. The root project, `website/`, vendor, Python, and native workspaces are outside this NPM baseline. Discovery must reject duplicate names, mixed base versions, an unexpected publication privacy state, and unknown packages in the bundle instead of relying on another hand-maintained package-name list. -The prerelease version consists of the package stable base version, a second-precision UTC timestamp captured when the command starts, and the target commit's 10-character short SHA: `--`. The dist-tag is derived as `dev-`. For example, base `0.0.1`, time `2026-08-04T00:32:00Z`, and commit `909292dd7b` produce version `0.0.1-20260804003200-909292dd7b` and tag `dev-0.0.1`. Retrying one release bundle must retain its version and manifest; repacking creates a version from the new command start time. +The prerelease version consists of the package stable base version, a second-precision UTC timestamp captured when the command starts, and the target commit's 10-character short SHA: `--`. The dist-tag is derived as `dev-`. For example, base `0.0.1`, time `2026-08-04T00:32:00Z`, and short SHA `` produce version `0.0.1-20260804003200-` and tag `dev-0.0.1`. Retrying one release bundle must retain its version and manifest; repacking creates a version from the new command start time. The pack phase runs in this order: diff --git a/.agents/notes/rejected/process/2026-08-04-artifact-first-npm-baseline-publication.zh.md b/.agents/notes/rejected/process/2026-08-04-artifact-first-npm-baseline-publication.zh.md index 8b1faf75e1..c2822ed0b5 100644 --- a/.agents/notes/rejected/process/2026-08-04-artifact-first-npm-baseline-publication.zh.md +++ b/.agents/notes/rejected/process/2026-08-04-artifact-first-npm-baseline-publication.zh.md @@ -18,7 +18,7 @@ monorepo 中可运行的源码并不能证明发布后的包可运行。workspac 目标集合只包含 `packages/*/*/package.json` 与 `apps/*/package.json` 中命名为 `@deepseek-ai/*` 的 workspace 包。根项目、`website/`、vendor、Python 与 native workspace 不属于该 NPM 基线。发现机制必须拒绝重复包名、不同基础版本、意外的 `private` 发布状态以及集合中的未知包,而不是维护另一份手工包名列表。 -预发布版本由包的稳定基础版本、命令启动时精确到秒的 UTC 时间戳和目标 commit 的 10 位短 SHA 组成:`--`。dist-tag 由基础版本派生为 `dev-`。例如,基础版本 `0.0.1`、时间 `2026-08-04T00:32:00Z` 和 commit `909292dd7b` 生成版本 `0.0.1-20260804003200-909292dd7b` 与 tag `dev-0.0.1`。同一 release bundle 的重试必须沿用原版本和 manifest;重新 pack 会按新的命令启动时间生成新版本。 +预发布版本由包的稳定基础版本、命令启动时精确到秒的 UTC 时间戳和目标 commit 的 10 位短 SHA 组成:`--`。dist-tag 由基础版本派生为 `dev-`。例如,基础版本 `0.0.1`、时间 `2026-08-04T00:32:00Z` 和短 SHA `` 生成版本 `0.0.1-20260804003200-` 与 tag `dev-0.0.1`。同一 release bundle 的重试必须沿用原版本和 manifest;重新 pack 会按新的命令启动时间生成新版本。 pack 阶段按以下顺序执行: diff --git a/.agents/skills/dsh-archive-agent-notes/SKILL.md b/.agents/skills/dsh-archive-agent-notes/SKILL.md index ef20de8097..6052cbf1fc 100644 --- a/.agents/skills/dsh-archive-agent-notes/SKILL.md +++ b/.agents/skills/dsh-archive-agent-notes/SKILL.md @@ -1,6 +1,6 @@ --- name: dsh-archive-agent-notes -description: Use when adding, auditing, pruning, archiving, restoring, or reviewing Agent Notes in deepseek-harness; checks every new note for superseded active records, classifies implemented notes by future decision value, deletes rejected notes that no longer prevent a tempting fallacy, and applies the frozen archived/{kind} triplet and manifest rules. +description: Use when adding, auditing, pruning, archiving, restoring, or reviewing Agent Notes in deepseek-harness; checks every new note for superseded active records, deletes small UI and purely mechanical records, classifies other implemented notes by future decision value, deletes rejected notes that no longer prevent a tempting fallacy, and applies the frozen archived/{kind} triplet and manifest rules. --- # Archive DeepSeek Harness Agent Notes @@ -13,14 +13,15 @@ Read [the Agent Note rules](../../notes/README.md), [the archive instructions](. ## Check supersession when adding a note -Every new Agent Note triggers a scoped audit of active notes covering the same decision, mechanism, or rejected alternative. Classify each full or partial supersession while writing the new note: archive qualifying implemented triplets in the same PR, retain and cross-link partial supersessions or independently useful rationale, reject obsolete proposals, and delete rejected notes that no longer prevent a plausible mistake. Apply the Agent Note consolidation rule when the new owner absorbs every unique proposition; do not defer a known match to a later corpus audit. +Before drafting, apply the [creation criteria](../../notes/README.md#when-to-write-one). Every new Agent Note triggers a scoped audit of active notes covering the same decision, mechanism, or rejected alternative. Classify each full or partial supersession while writing the new note: archive qualifying implemented triplets in the same PR, retain and cross-link partial supersessions or independently useful rationale, reject obsolete proposals, and delete rejected notes that no longer prevent a plausible mistake. Apply the Agent Note consolidation rule when the new owner absorbs every unique proposition; do not defer a known match to a later corpus audit. ## Classify by future value Apply these lifecycle-specific outcomes: -- **Implemented — keep active:** retain a note when its rationale, alternatives, negative guarantees, durable/wire semantics, ownership boundary, security rule, or reintroduction condition is likely to guide a future change. Length does not matter. -- **Implemented — archive:** archive a note when the shipped decision is complete and its body is unlikely to guide future work, such as one-off UI chrome, a narrow adapter, a minor closed bug, superseded implementation detail, or process history whose current behavior is obvious elsewhere. +- **Implemented — delete:** delete complete triplets that only describe small UI adjustments or purely mechanical changes, and repair or remove inbound links. Local bug fixes, performance changes, new capabilities, and substantive behavior or ownership decisions do not qualify merely because they are implemented. Apply this criterion before keep/archive classification. +- **Implemented — keep active:** retain a note when its rationale, alternatives, negative guarantees, durable/wire semantics, ownership boundary, security rule, or reintroduction condition is likely to guide a future change. Classify the decision, not the mechanics of its implementation: a mechanical rename or type extraction can still record lasting naming, compatibility, or ownership rules. Length does not matter. +- **Implemented — archive:** archive a substantive historical decision when it is complete and unlikely to guide future work, but its historical rationale still warrants preservation. Do not archive records that meet the direct-deletion criterion. - **Proposed — never archive:** keep a live proposal active; if it is no longer worth pursuing, reject it with an honest reason and satisfy the rejected lifecycle format. - **Rejected — keep only as a guardrail:** retain a rejection only when the losing proposal remains a tempting, meaningful mistake and the note explains why it loses. - **Rejected — delete:** delete the whole triplet when the rejected idea is obsolete, superseded, no longer plausible, or unlikely to prevent re-litigation. Repair or delete inbound links. @@ -31,11 +32,10 @@ Do not archive toward a quota. Inspect every note in scope, classify analogous g These examples set the bar; the word counts demonstrate that size is not the test. -Archive implemented notes such as: +Delete implemented notes such as: - collapsed sidebar control rail — 533 words: closed, minor UI behavior; -- Commander argument adapter — 1,498 words: substantial implementation detail with little future design leverage; -- documentation graph atlas — 920 words: completed documentation machinery whose current generators are authoritative. +- issue-policy module ownership — completed function relocation and import rewiring with unchanged behavior. Keep implemented notes such as: @@ -55,7 +55,7 @@ For rejected notes: 1. Move the complete `foo.md`, `foo.zh.md`, and `foo.i18n.yaml` triplet from `implemented//` to `archived//`; `implemented` is deliberately absent from the archive path. 2. Make no body edits. Insert only `Archived: YYYY-MM-DD` immediately below `Status: implemented` in both language files, using the archival date and the same value on both sides. -3. Re-record the sidecar hashes mechanically for the two metadata-only edits. Do not translate, reformat, update facts, or repair links inside the note. +3. Re-record the sidecar hashes mechanically for the two metadata-only edits. Do not translate, reformat, update facts, or repair links inside the note. Existing title punctuation, blank-line layout, and language-switcher wording are preserved, not prerequisites for archival. 4. Search for inbound links from active prose. Redirect them to current authority, retarget them to the archived path only when the historical snapshot is intentionally cited, or delete them. Never verify or repair links out of the archived note. 5. Run `pnpm run verify-archived-agent-notes --write`. Its append-only mode first proves every existing seal still matches, then adds only the new triplet hashes. Run the normal verifier afterward. @@ -65,4 +65,4 @@ After the triplet is sealed, never edit, move, translate, reformat, or delete it Run the archive verifier's focused test, `pnpm run verify-archived-agent-notes`, `pnpm run doc-sync`, `pnpm run lint`, and `git diff --check`; select any additional evidence through [dsh-pre-push-checks](../dsh-pre-push-checks/SKILL.md). -Report active implemented notes kept, implemented notes archived, rejected notes kept/deleted, proposed notes rejected if any, and every genuinely borderline case with its word count and chosen outcome. Do not claim archived outbound links are valid: the archive verifier intentionally never checks them. +Report active implemented notes kept, implemented notes deleted or archived, rejected notes kept/deleted, proposed notes rejected if any, and every genuinely borderline case with its word count and chosen outcome. Do not claim archived outbound links are valid: the archive verifier intentionally never checks them. diff --git a/.agents/skills/dsh-find-simplifications/SKILL.md b/.agents/skills/dsh-find-simplifications/SKILL.md index 6ee736796c..bce148ad18 100644 --- a/.agents/skills/dsh-find-simplifications/SKILL.md +++ b/.agents/skills/dsh-find-simplifications/SKILL.md @@ -93,7 +93,7 @@ Reject or downgrade a candidate when: Audit the Agent Note tree when the user asks to reduce or coalesce it, or when the simplification being implemented makes an owning note obsolete. Do not expand every code-simplification survey into a repository-wide note audit. -Use [`dsh-archive-agent-notes`](../dsh-archive-agent-notes/SKILL.md) for retention judgment and archive mechanics. Low-future-value implemented notes move as frozen triplets to `archived/{kind}`; proposed notes are never archived; rejected notes that no longer prevent a tempting mistake are deleted. Do not edit an archived note while simplifying current prose or code. +Use [`dsh-archive-agent-notes`](../dsh-archive-agent-notes/SKILL.md) for retention judgment and archive mechanics. Implemented notes that only describe small UI adjustments or purely mechanical changes are deleted directly; other completed notes with historical decision value but little future guidance move as frozen triplets to `archived/{kind}`; proposed notes are never archived; rejected notes that no longer prevent a tempting mistake are deleted. Do not edit an archived note while simplifying current prose or code. Follow the deletion rule in the [Agent Note rules](../../notes/README.md#when-to-write-one); do not duplicate or weaken it here. For each candidate chain: diff --git a/.github/issue-management/README.i18n.yaml b/.github/issue-management/README.i18n.yaml index 7d31f8418d..beaf888252 100644 --- a/.github/issue-management/README.i18n.yaml +++ b/.github/issue-management/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 .github/issue-management/README.md -README.md: a936b9358810fd3a8b8104609a5523e305e1aaa5 -README.zh.md: df743cac581c2125df9ac9e76f04d2528204eb74 +README.md: 49e91859f4b22fda13d0e93c6e6ea492272f6951 +README.zh.md: 8424f90cd2e3a59ba1212f64f3f1081a984b55f8 diff --git a/.github/issue-management/README.md b/.github/issue-management/README.md index a936b93588..49e91859f4 100644 --- a/.github/issue-management/README.md +++ b/.github/issue-management/README.md @@ -61,7 +61,7 @@ Lifecycle processing is event-driven, not a reconciler. Omitted events do not re ## Module ownership -Maintainers reuse the owning module directly; [policy.mjs](policy.mjs) only reads the event file, dispatches commands, and reports command failures. The [module-ownership decision](../../.agents/notes/implemented/process/2026-09-07-issue-policy-module-ownership.md) explains this separation. +Maintainers reuse the owning module directly; [policy.mjs](policy.mjs) only reads the event file, dispatches commands, and reports command failures.
Implementation owners diff --git a/.github/issue-management/README.zh.md b/.github/issue-management/README.zh.md index df743cac58..8424f90cd2 100644 --- a/.github/issue-management/README.zh.md +++ b/.github/issue-management/README.zh.md @@ -61,7 +61,7 @@ PR 打开时,工作流按配置时区中的 PR 创建日期,为每个被引 ## 模块归属 -维护者直接复用所属模块;[policy.mjs](policy.mjs)仅负责读取事件文件、分派命令并报告命令失败。[模块归属决策](../../.agents/notes/implemented/process/2026-09-07-issue-policy-module-ownership.zh.md)解释此职责分离。 +维护者直接复用所属模块;[policy.mjs](policy.mjs)仅负责读取事件文件、分派命令并报告命令失败。
实现职责 diff --git a/.github/workflows/build-exe-for-python-sdk.yml b/.github/workflows/build-exe-for-python-sdk.yml index 5042778b72..e53d524ead 100644 --- a/.github/workflows/build-exe-for-python-sdk.yml +++ b/.github/workflows/build-exe-for-python-sdk.yml @@ -152,14 +152,15 @@ jobs: if-no-files-found: error retention-days: 7 + # Blacksmith x64 CI builds use 2 vCPU for compilation, bundling, and wheel smokes. build: needs: [plan, sdk-wheel] name: ${{ matrix.target }} runs-on: >- ${{ inputs.ci && !inputs.release && matrix.target == 'node24-linux-x64' - && vars.DSH_CI_FAILOVER_LINUX == 'blacksmith' && 'blacksmith-16vcpu-ubuntu-2404' + && vars.DSH_CI_FAILOVER_LINUX == 'blacksmith' && 'blacksmith-2vcpu-ubuntu-2404' || inputs.ci && !inputs.release && matrix.target == 'node24-win-x64' - && vars.DSH_CI_FAILOVER_WINDOWS == 'blacksmith' && 'blacksmith-16vcpu-windows-2025' + && vars.DSH_CI_FAILOVER_WINDOWS == 'blacksmith' && 'blacksmith-2vcpu-windows-2025' || matrix.runner }} timeout-minutes: 45 strategy: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2139231c06..32b58e9bc1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,7 +33,7 @@ jobs: # vm-backup pool and re-running the failed jobs is the entire switch — # see .agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md. # Setting the variable to 'blacksmith' instead routes the same jobs onto - # Blacksmith's hosted runners at the matching vCPU size (see + # Blacksmith's hosted runners sized per workload (see # .agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.md). The # in-house pool's readiness is re-proven on every master push by the # serial-linux-selfhosted standby lane in ci-master.yml. The Windows failover @@ -42,14 +42,15 @@ jobs: node-24: if: github.event_name == 'pull_request' runs-on: >- - ${{ vars.DSH_CI_FAILOVER_LINUX == 'blacksmith' && 'blacksmith-16vcpu-ubuntu-2404' + ${{ vars.DSH_CI_FAILOVER_LINUX == 'blacksmith' && 'blacksmith-8vcpu-ubuntu-2404' || vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && fromJSON('["self-hosted", "linux", "x64", "vm-backup"]') || 'dsh-ubuntu-24-04-16core' }} name: node 24 / static env: - DSH_GATE_CONCURRENCY: '8' + # Shared hosts expose host CPUs, not an isolated allocation. + DSH_GATE_CONCURRENCY: ${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '8' || '' }} # Stop the aggregate at the first blocking gate failure so a red run # does not keep burning enterprise runner time on the remaining gates. DSH_GATE_FAIL_FAST: '1' @@ -116,11 +117,10 @@ jobs: || 'dsh-ubuntu-24-04-16core' }} name: node 24 / coverage env: - # Partitioning replaces the instrumented share; this budget gives the - # exempt-heavy gate two workers on both hosted and failover runners. - DSH_COVERAGE_MAX_WORKERS: '6' - DSH_COVERAGE_PARTITIONS: '4' - DSH_GATE_CONCURRENCY: '3' + # Shared hosts expose host CPUs, not an isolated allocation. + DSH_COVERAGE_MAX_WORKERS: ${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '6' || '' }} + DSH_COVERAGE_PARTITIONS: ${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '4' || '' }} + DSH_GATE_CONCURRENCY: ${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '3' || '' }} # Managed-scope teardown cases run past the default per-test budget when # this lane's partitions, workers, and sibling gates share one host: the # disposal cases in subprocess-local and bash-sandbox exceeded 5000ms on @@ -246,17 +246,17 @@ jobs: || 'dsh-ubuntu-24-04-16core' }} name: node 24 / snapshots and artifacts env: - DSH_GATE_CONCURRENCY: '10' + # Shared hosts expose host CPUs, not an isolated allocation. + DSH_GATE_CONCURRENCY: ${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '10' || '' }} + DSH_OXLINT_THREADS: ${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '8' || '' }} + DSH_PUBLINT_CONCURRENCY: ${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '8' || '' }} + DSH_WEB_SNAPSHOT_WORKERS: ${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '6' || '' }} + DSH_SNAPSHOT_MAX_CONCURRENCY: ${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '12' || '' }} DSH_NODE_COMPAT_SKIP_TYPECHECK: '1' - DSH_OXLINT_THREADS: '8' - DSH_PUBLINT_CONCURRENCY: '8' - DSH_WEB_SNAPSHOT_WORKERS: '6' # A failing gate aborts its running siblings: a failing build stops the # independent Node compatibility smoke, and a failing reader (e.g. # publint) stops the remaining artifact consumers. DSH_GATE_FAIL_FAST: '1' - # Failover halves snapshot concurrency for the shared 64-core VM. - DSH_SNAPSHOT_MAX_CONCURRENCY: ${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '12' || '32' }} steps: # Runner cleanup owns tool caches and fixtures, including cancelled runs. - name: Use runner-owned temporary storage @@ -491,12 +491,12 @@ jobs: secrets: DEEPSEEK_API_KEY_EXTERNAL: ${{ secrets.DEEPSEEK_API_KEY_EXTERNAL }} - # Every pull request also gets real Windows-kernel signals. The former - # monolithic windows-native job is split into smaller jobs so one slow - # coverage gate does not hold up build/static results, while the total - # per-job process count stays lower. Observational checks are non-blocking. + # Native builds and process tests block pull requests independently of coverage. + # Observational checks reuse the successful build and remain non-blocking. # Dependabot PRs are excluded from the self-hosted pool and stay queued for # the hosted runner. + # Blacksmith uses 2 vCPU for file-serial native tests and 16 for coverage + # and the shared build/observational workspace. windows-build: if: github.event_name == 'pull_request' runs-on: >- @@ -505,7 +505,7 @@ jobs: && github.event.pull_request.user.login != 'dependabot[bot]' && fromJSON('["self-hosted", "dsh-win-ci", "windows"]') || 'dsh-windows-2025-16core' }} - name: windows node 24 / build + name: windows node 24 / build & observational timeout-minutes: 60 env: # A failing build or site aborts the sibling gate on the same runner. @@ -541,6 +541,23 @@ jobs: - name: Run blocking Windows builds shell: pwsh run: pnpm run check:ci:windows-blocking + - name: Run Windows observational gates on built artifacts + id: observational + continue-on-error: true + timeout-minutes: 15 + shell: pwsh + env: + DSH_GATE_FAIL_FAST: '' + DSH_PUBLINT_CONCURRENCY: ${{ vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '8' || '' }} + run: pnpm run check:ci:windows-observational-ready + - name: Report Windows observational failures + continue-on-error: true + if: steps.observational.outcome == 'failure' + shell: pwsh + run: | + $message = 'Windows observational checks failed; see the observational step for diagnostics.' + Write-Output "::warning::$message" + $message | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 -Append windows-coverage: if: github.event_name == 'pull_request' @@ -553,10 +570,11 @@ jobs: name: windows node 24 / coverage timeout-minutes: 120 env: - DSH_COVERAGE_MAX_WORKERS: '6' - DSH_COVERAGE_PARTITIONS: '4' + # Shared hosts expose host CPUs, not an isolated allocation. + DSH_COVERAGE_MAX_WORKERS: ${{ vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '6' || '' }} + DSH_COVERAGE_PARTITIONS: ${{ vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '4' || '' }} + DSH_GATE_CONCURRENCY: ${{ vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '3' || '' }} DSH_COVERAGE_TEST_TIMEOUT_MS: '90000' - DSH_GATE_CONCURRENCY: '3' # A gate failure aborts the sibling gate instead of waiting out its # multi-minute instrumented run. DSH_GATE_FAIL_FAST: '1' @@ -622,7 +640,7 @@ jobs: windows-native-tests: if: github.event_name == 'pull_request' runs-on: >- - ${{ vars.DSH_CI_FAILOVER_WINDOWS == 'blacksmith' && 'blacksmith-16vcpu-windows-2025' + ${{ vars.DSH_CI_FAILOVER_WINDOWS == 'blacksmith' && 'blacksmith-2vcpu-windows-2025' || vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && fromJSON('["self-hosted", "dsh-win-ci", "windows"]') @@ -668,51 +686,6 @@ jobs: packages/workflow/tool-ralph/tests/integration.spec.ts packages/subprocess/subprocess-local/tests/process-exit.spec.ts - windows-observational: - if: github.event_name == 'pull_request' - continue-on-error: true - runs-on: >- - ${{ vars.DSH_CI_FAILOVER_WINDOWS == 'blacksmith' && 'blacksmith-16vcpu-windows-2025' - || vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted' - && github.event.pull_request.user.login != 'dependabot[bot]' - && fromJSON('["self-hosted", "dsh-win-ci", "windows"]') - || 'dsh-windows-2025-16core' }} - name: windows node 24 / observational - timeout-minutes: 60 - env: - DSH_PUBLINT_CONCURRENCY: '8' - steps: - - uses: actions/checkout@v6 - with: - persist-credentials: false - - name: Enable Developer Mode (symlink support) - shell: pwsh - run: >- - reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock" - /t REG_DWORD /f /v "AllowDevelopmentWithoutDevLicense" /d "1" - - uses: pnpm/action-setup@v4 - with: - dest: ${{ runner.temp }}/setup-pnpm-js-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} - - uses: actions/setup-node@v6 - with: - node-version: ${{ env.PRIMARY_NODE_VERSION }} - - name: Install (immutable) - shell: pwsh - # See 2026-08-30-windows-refs-store-block-clone-install for the - # ReFS block-clone rationale; detect the workspace filesystem and - # pass --package-import-method=clone only on ReFS. - run: >- - $drive = (Split-Path -Qualifier $env:GITHUB_WORKSPACE).TrimEnd(':'); - $fs = (Get-Volume -DriveLetter $drive).FileSystem; - if ($fs -eq 'ReFS') { - corepack pnpm install --frozen-lockfile --package-import-method=clone - } else { - pnpm install --frozen-lockfile - } - - name: Run Windows observational gates - shell: pwsh - run: pnpm run check:ci:windows-observational - # Single stable required check for branch protection: require "all checks # passed" instead of enumerating matrix legs whose names change as lanes and # node versions evolve. Every blocking job in THIS workflow must be listed in diff --git a/AGENTS.md b/AGENTS.md index 15e2ec41f4..ce14413b4c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -147,7 +147,7 @@ Real-API tests/demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, and - **Ban `prove` + `nance`** ([rule](.agents/notes/implemented/process/2026-08-26-ban-ambiguous-origin-label.md)). - **Prefer symmetry for parallel values**; unexplained asymmetry usually signals a missed extraction. - **Tests describe behavior, not correctness.** Change obsolete behavior with its tests; explain why in the PR. -- **Non-trivial changes MUST include an Agent Note in the same PR;** only mechanical/local edits are exempt ([scope](.agents/notes/README.md#when-to-write-one)). Archived notes are frozen: never edit or treat them as current authority ([archive policy](.agents/notes/README.md#archiving-and-deletion)). +- **Create Agent Notes only for durable decision rationale;** mechanical/local edits are exempt, including local UI changes ([scope](.agents/notes/README.md#when-to-write-one)). Archived notes are frozen: never edit or treat them as current authority ([archive policy](.agents/notes/README.md#archiving-and-deletion)). - **Client UI copy is locale-owned.** Route product text through typed dictionaries and `t` or localized primitive props; `verify-client-ui-i18n` rejects hardcoded copy ([decision](.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md)). - **Testing policy** — [docs/testing.md](docs/testing.md). Every non-trivial model- or product-user-visible change updates a keyless recorded-session snapshot; [snapshot ownership](snapshots/AGENTS.md) reserves the top-level tree for session-driven cases and keeps other expected output owner-local. Fixtures replay on macOS/Linux; fix fixtures, not normalizers. - **Design each tool's UI presentation up front.** Host presenters stay pure; Web cards derive from raw events and persisted result metadata ([cookbook](docs/cookbook/adding-a-tool.md)). diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 7eb0c36e18..ec698dca33 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -76,6 +76,7 @@ External packages installed for runtime use or distributed inside the prebuilt b | [`commander`](https://github.com/tj/commander.js) | MIT | | [`compression`](https://github.com/expressjs/compression) | MIT | | [`diff`](https://github.com/kpdecker/jsdiff) | BSD-3-Clause | +| [`dompurify`](https://github.com/cure53/DOMPurify) | (MPL-2.0 OR Apache-2.0) | | [`electron-updater`](https://github.com/electron-userland/electron-builder) | MIT | | [`eventsource-parser`](https://github.com/rexxars/eventsource-parser) | MIT | | [`execa`](https://github.com/sindresorhus/execa) | MIT | diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index 1914398ea8..026b49d50b 100644 --- a/apps/cli/README.i18n.yaml +++ b/apps/cli/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/README.md -README.md: 68500e54372d16a9ead8e548eed5a7e4836be3fb -README.zh.md: a1002812c3782f898d89793d89a50a1ab4ea4e1e +README.md: 46c6a66336839db443a825c66ebfd71dcce398af +README.zh.md: 14838114b04977e7668e33479d1ba84a8ddc8434 diff --git a/apps/cli/README.md b/apps/cli/README.md index 68500e5437..46c6a66336 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -55,6 +55,6 @@ The [CLI behavior reference](reference/README.md) owns exact layer precedence, f Production runs require built package and frontend artifacts. From the repository root, run `pnpm run build` separately, then use `pnpm dsh ` to run the TypeScript entry and forward every argument; the [source-execution reference](reference/README.md#source-execution) owns the module-resolution contract. -The `@deepseek-ai/dsh/profile-boot` export provides the shared profile lifecycle to the Desktop host. A resolved application profile supplies its own installation anchor and profile-local module fallback while retaining the Harness home patch, proxy environment, telemetry switch, patch reload, and bounded shutdown. +The `@deepseek-ai/dsh/profile-boot` export provides the shared profile lifecycle to the Desktop host. A resolved application profile supplies its own installation anchor for runtime package resolution while retaining the Harness home patch, proxy environment, telemetry switch, patch reload, and bounded shutdown. The [Web failure matrix](tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts) runs the built CLI through startup failures and native configuration HMR with `awaitWriteFinish` enabled in `test:expected`. It verifies authenticated HTTP responses, diagnostics, recovery, process exits, and disposal without model API calls; the [startup acceptance](tests/profiles/web/tests/web-best-effort-startup.expected.e2e.ts) also covers the shipped required Web dependencies and port conflicts. diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index a1002812c3..14838114b0 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -55,6 +55,6 @@ profile 目录包含一个 `package.json`,其中记录树外插件依赖,以 生产运行需要已构建的包与前端产物。请在仓库根目录单独运行 `pnpm run build`,然后使用 `pnpm dsh ` 运行 TypeScript 入口并转发所有参数;模块解析约定以[源码执行参考](reference/README.zh.md#source-execution)为准。 -`@deepseek-ai/dsh/profile-boot` 导出向 Desktop Host 提供共享 profile 生命周期。已解析的应用 profile 指定自己的安装锚点和 profile 内模块补全,同时沿用 Harness home patch、代理环境、遥测开关、patch 热重载和有界关闭。 +`@deepseek-ai/dsh/profile-boot` 导出向 Desktop Host 提供共享 profile 生命周期。已解析的应用 profile 为运行时包解析指定自己的安装锚点,同时沿用 Harness home patch、代理环境、遥测开关、patch 热重载和有界关闭。 [Web 失败矩阵](tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts)在 `test:expected` 中通过构建后的 CLI 验证启动失败与启用 `awaitWriteFinish` 的原生配置 HMR。它不调用模型 API,而是检查经过认证的 HTTP 响应、诊断、恢复、进程退出与 dispose;[启动验收测试](tests/profiles/web/tests/web-best-effort-startup.expected.e2e.ts)还覆盖随附 Web 的必需依赖与端口冲突。 diff --git a/apps/cli/package.json b/apps/cli/package.json index 5204f55a29..b3cff4af5d 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh", "description": "dsh CLI: profile launch, plugin management, and configuration inspection", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, @@ -48,7 +48,6 @@ "@deepseek-ai/dsh-compaction-tool-result-pruner": "workspace:^", "@deepseek-ai/dsh-cordis-client-runner": "workspace:^", "@deepseek-ai/dsh-experimental-agent-team-profile": "workspace:^", - "@deepseek-ai/dsh-experimental-agent-team-web-profile": "workspace:^", "@deepseek-ai/dsh-fs-local": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-goal-round-driver": "workspace:^", diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index f5eb1cde22..b2068897e1 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: 6429b2872ec393304a7d3a76afd8194cb74d7698 -README.zh.md: c9d9bee8ad84e3fb0c3f4d82b295c743f5a5ad99 +README.md: 9ff0dce30f6de58c7b84f54186f7780900b319fc +README.zh.md: e1ed9a9165b993cb4ea8a82af6641d5befd77a07 diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index 6429b2872e..9ff0dce30f 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -8,7 +8,7 @@ This reference defines the profile, plugin-management, and config-dump command m `dsh ` abbreviates `dsh --profile ` and boots the profile at `$DSH_HOME/profiles/`. The shorthand name must immediately follow `dsh`; `plugin` remains the plugin-management command, so boot a profile with that name using `dsh --profile plugin`. The effective tree is composed over an empty root by applying, in order: each bundle patch named in the profile manifest's `dsh.profile.bundles` list, the profile's own `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml` (machine-local preferences shared by every profile, so it outranks the per-profile layer), and each `--patch ` overlay in argv order. Later layers win per row; a patch replaces the targeted row's complete `config` value rather than deep-merging keys, and may insert new rows. The final YAML composition controls whether `dsh-hmr` watches configuration; without HMR, changes require restart. A parse, schema, resolution, or plugin boot failure is reported and exits nonzero. SIGINT and SIGTERM dispose the mounted root before exit. -Bundle names resolve from the dsh installation first, then from the profile directory. In-box bundles (`@deepseek-ai/dsh-base`, `@deepseek-ai/dsh-web-app`, `@deepseek-ai/dsh-headless`, `@deepseek-ai/dsh-sdk-app`, `@deepseek-ai/dsh-sdk-minimal`, `@deepseek-ai/dsh-acp-app`) therefore always come from the same installation as the running `dsh`; out-of-tree bundles come from the profile's pnpm-managed `node_modules`. Before mounting rows, the launcher traverses the installation and selected bundles in that order and materializes the resulting fallback links. The internal runtime and dual modes consume the same immutable generation in tests without changing the CLI's link-mode behavior. Profile-installed packages keep native priority in every mode. +Bundle names resolve from the dsh installation first, then from the profile directory. In-box bundles (`@deepseek-ai/dsh-base`, `@deepseek-ai/dsh-web-app`, `@deepseek-ai/dsh-headless`, `@deepseek-ai/dsh-sdk-app`, `@deepseek-ai/dsh-sdk-minimal`, `@deepseek-ai/dsh-acp-app`) therefore always come from the same installation as the running `dsh`; out-of-tree bundles come from the profile's pnpm-managed `node_modules`. Before mounting rows, the launcher traverses the installation and selected bundles in that order and installs the resulting immutable generation into Node's runtime resolvers. Startup creates no shared or profile-owned fallback links. Profile-installed packages keep native priority; profile initialization and package-manager writes remain separate from runtime resolution. The `web`, `headless`, `sdk`, `sdk-minimal`, and `acp` profiles auto-initialize from shipped templates on first use (`web`: base + web-app with live patches; `headless`: base + headless with startup-only patches; `sdk`: base + sdk-app with startup-only patches; `sdk-minimal`: its standalone bundle with startup-only patches; `acp`: base + acp-app with startup-only patches). Any other missing profile fails loudly with a hint to run `dsh plugin --profile add `. diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md index c9d9bee8ad..e1ed9a9165 100644 --- a/apps/cli/reference/README.zh.md +++ b/apps/cli/reference/README.zh.md @@ -10,7 +10,7 @@ `dsh ` 是 `dsh --profile ` 的简写,启动位于 `$DSH_HOME/profiles/` 的 profile。简写中的名称必须紧跟 `dsh`;`plugin` 仍为插件管理命令,因此启动同名 profile 时须使用 `dsh --profile plugin`。生效配置树以空根节点为起点,依次叠加 profile manifest(元数据清单)的 `dsh.profile.bundles` 列表中指定的各组合包 patch、profile 自身的 `cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml`(这是各 profile 共享的机器本地偏好,因此优先于逐 profile 配置层),以及按 argv 顺序指定的各个 `--patch ` 覆盖层。对同一配置行,后应用的层优先。patch 会替换目标行的整个 `config` 值,而不是深度合并其中的键;patch 也可以插入新行。最终 YAML 组合决定是否由 `dsh-hmr` 监视配置;未启用 HMR 时,更改需要重启。配置解析、schema 校验、模块解析或插件启动失败时,系统会报告错误并以非零状态退出。收到 SIGINT 或 SIGTERM 时,挂载的根节点会先 dispose(资源释放)再退出。 -组合包名称先从 dsh 安装目录解析,再从 profile 目录解析。因此,内置组合包(`@deepseek-ai/dsh-base`、`@deepseek-ai/dsh-web-app`、`@deepseek-ai/dsh-headless`、`@deepseek-ai/dsh-sdk-app`、`@deepseek-ai/dsh-sdk-minimal`、`@deepseek-ai/dsh-acp-app`)始终来自当前运行的 `dsh` 所属的安装;树外组合包来自 profile 中由 pnpm 管理的 `node_modules`。挂载配置行前,launcher 会按此顺序遍历安装与所选 bundle,并物化计算出的 fallback 链接。内部 runtime 与 dual 模式会在测试中消费同一份不可变 generation,但不改变 CLI 的 link 模式行为。所有模式都保留 profile 已安装包的原生优先级。 +组合包名称先从 dsh 安装目录解析,再从 profile 目录解析。因此,内置组合包(`@deepseek-ai/dsh-base`、`@deepseek-ai/dsh-web-app`、`@deepseek-ai/dsh-headless`、`@deepseek-ai/dsh-sdk-app`、`@deepseek-ai/dsh-sdk-minimal`、`@deepseek-ai/dsh-acp-app`)始终来自当前运行的 `dsh` 所属的安装;树外组合包来自 profile 中由 pnpm 管理的 `node_modules`。挂载配置行前,launcher 会按此顺序遍历安装与所选 bundle,并将生成的不可变 generation 安装到 Node 的运行时解析器中。启动不会创建共享或 profile 自有的 fallback 链接。profile 已安装包保留原生优先级;profile 初始化和包管理器写入与运行时解析相互独立。 `web`、`headless`、`sdk`、`sdk-minimal` 和 `acp` profile 首次使用时会从随附模板自动初始化(`web`:base + web-app,实时应用 patch;`headless`:base + headless,只在启动时应用 patch;`sdk`:base + sdk-app,只在启动时应用 patch;`sdk-minimal`:独立组合包,只在启动时应用 patch;`acp`:base + acp-app,只在启动时应用 patch)。其他缺失的 profile 会显式报错,并提示运行 `dsh plugin --profile add `。 diff --git a/apps/cli/src/profile-boot.ts b/apps/cli/src/profile-boot.ts index 9b6d0618ea..ededd48a87 100644 --- a/apps/cli/src/profile-boot.ts +++ b/apps/cli/src/profile-boot.ts @@ -191,6 +191,7 @@ interface ComposedProfile { * then the telemetry switch. * @param name - the profile name. * @param patchFiles - `--patch` overlay paths, in argv order. + * @param resolutionMode - runtime lookup, disk links, or dual verification of both. * @param fromDefaultProfile - shipped template for a missing named profile. * @param resolvedProfile - application-owned profile and installation. * @returns the profile and its patch layers. @@ -237,7 +238,7 @@ export interface RunProfileOptions { args: readonly string[] /** Application-owned package runtime, scoped to plugin package operations. */ packageManager?: ProfileContext['packageManager'] - /** Module fallback backend; pkg executables always use runtime resolution. */ + /** Module fallback backend; defaults to runtime. Plain Node callers may override it; pkg executables always use runtime. */ resolutionMode?: ProfileResolutionMode } @@ -259,7 +260,7 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con ) const packaged = (process as NodeJS.Process & { pkg?: unknown }).pkg !== undefined - const resolutionMode = packaged ? 'runtime' : options.resolutionMode ?? 'link' + const resolutionMode = packaged ? 'runtime' : options.resolutionMode ?? 'runtime' const app: { current?: Context } = {} let disposal: Promise | undefined const dispose = (): Promise => disposal ??= (async () => { diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index 781396262a..dbaf904569 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -984,7 +984,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', try { await waitForFile(fixture.ready) expect(readFileSync(fixture.echo, 'utf8')).toBe('bundle-default') - expect(existsSync(join(fixture.home, 'profiles', 'node_modules'))).toBe(true) + expect(existsSync(join(fixture.home, 'profiles', 'node_modules'))).toBe(false) requestProfileShutdown(child, fixture) expect((await child).exitCode).toBe(0) } finally { diff --git a/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts b/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts index 3601745871..7019b87f71 100644 --- a/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts +++ b/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts @@ -5,11 +5,15 @@ import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { promisify } from 'node:util' import { zstdDecompress } from 'node:zlib' +import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' import { execa } from 'execa' import { describe, expect, it } from 'vitest' -const binScript = fileURLToPath(new URL('../../../src/bin.ts', import.meta.url)) const repoRoot = fileURLToPath(new URL('../../../../../', import.meta.url)) +const launch = resolveExampleLaunch({ + srcBin: fileURLToPath(new URL('../../../src/bin.ts', import.meta.url)), + mode: 'lib', +}) const decompress = promisify(zstdDecompress) /** Frame one text or tool response from the local Messages endpoint. */ @@ -86,16 +90,15 @@ describe('Python SDK dsh profile keyless smoke', () => { if (address === null || typeof address === 'string') throw new Error('model server did not bind a TCP port') // The line-predicate protocol driving below is the genuinely custom part; // execa owns spawn, the deadline, and exit settlement around it. - const child = execa(process.execPath, [ - '--import', - 'tsx/esm', - binScript, + const child = execa(launch.command, [ + ...launch.args, '--profile', 'sdk', ...(editorEnabled ? ['--patch', editorPatch] : []), ], { cwd: repoRoot, env: { + ...launch.env, DSH_HOME: join(root, '.dsh'), DSH_PERMISSION_MODE: 'danger-full-access', DSH_TELEMETRY_DISABLED: '1', @@ -236,16 +239,15 @@ describe('Python SDK dsh profile keyless smoke', () => { await new Promise(resolve => modelServer.listen(0, '127.0.0.1', resolve)) const address = modelServer.address() if (address === null || typeof address === 'string') throw new Error('model server did not bind a TCP port') - const child = execa(process.execPath, [ - '--import', - 'tsx/esm', - binScript, + const child = execa(launch.command, [ + ...launch.args, '--profile', 'sdk-minimal', ...(editorEnabled ? ['--patch', editorPatch] : []), ], { cwd: repoRoot, env: { + ...launch.env, DSH_HOME: join(root, '.dsh'), DSH_SYSTEM_PROMPT: 'Minimal allowlist prompt.', DEEPSEEK_API_KEY: 'keyless-smoke-no-call', @@ -285,7 +287,7 @@ describe('Python SDK dsh profile keyless smoke', () => { const event = params?.event as Record | undefined return params?.sessionId === 'minimal' && event?.type === 'turn/end' }, () => stderr) - expect(turnEnd).toMatchObject({ + expect(turnEnd, `${JSON.stringify(turnEnd)}\n${stderr}`).toMatchObject({ params: { event: { data: { reason: { kind: 'completed' } } } }, }) @@ -341,11 +343,11 @@ describe('Python SDK dsh profile keyless smoke', () => { await mkdir(home) if (blocked) await writeFile(join(home, 'logs'), 'blocked') await writeFile(patch, '- id: agent-loop\n config:\n maxParallelToolCalls: 0\n') - const child = execa(process.execPath, [ - '--import', 'tsx/esm', binScript, '--profile', 'sdk', '--patch', patch, + const child = execa(launch.command, [ + ...launch.args, '--profile', 'sdk', '--patch', patch, ], { cwd: repoRoot, - env: { DSH_HOME: home, DSH_TELEMETRY_DISABLED: '1', DEEPSEEK_API_KEY: 'keyless-no-call' }, + env: { ...launch.env, DSH_HOME: home, DSH_TELEMETRY_DISABLED: '1', DEEPSEEK_API_KEY: 'keyless-no-call' }, stdin: 'pipe', stripFinalNewline: false, timeout: 25_000, @@ -378,15 +380,14 @@ describe('Python SDK dsh profile keyless smoke', () => { it('rejects an invalid max-token success env value', async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-python-sdk-runtime-invalid-')) try { - const { exitCode, stdout, stderr } = await execa(process.execPath, [ - '--import', - 'tsx/esm', - binScript, + const { exitCode, stdout, stderr } = await execa(launch.command, [ + ...launch.args, '--profile', 'sdk', ], { cwd: repoRoot, env: { + ...launch.env, DSH_HOME: join(root, '.dsh'), DEEPSEEK_API_KEY: 'keyless-smoke-no-call', DSH_MAX_TOKENS_AS_SUCCESS: 'sometimes', diff --git a/apps/cli/tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts b/apps/cli/tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts index 0c5b4c26d4..b1f89c11d1 100644 --- a/apps/cli/tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts +++ b/apps/cli/tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts @@ -138,7 +138,7 @@ function start(f: ReturnType, extra: string[] = []) { expect(response.status).toBe(200) const html = await response.text() expect(html).toContain('__DSH_BOOT__') - const bundlePath = / + + LinkSVG + `)) + const parsed = new DOMParser().parseFromString(html, 'text/html') + expect(parsed.head.firstElementChild?.getAttribute('http-equiv')).toBe('Content-Security-Policy') + expect(parsed.querySelector('meta')?.getAttribute('content')).toContain("connect-src 'none'") + expect(parsed.querySelectorAll('script, iframe, object, embed, base, a[href], a[xlink\\:href]')).toHaveLength(0) + expect(parsed.querySelectorAll('meta[http-equiv]')).toHaveLength(1) + expect(parsed.querySelector('p')?.textContent).toBe('Static') + expect(parsed.querySelector('style')?.textContent).toContain('color: red') + expect(parsed.querySelector('img')?.getAttribute('src')).toBe('data:image/png;base64,AA==') +}) + +it('preserves document styling and removes navigation inside nested templates and SVG animation', () => { + const html = createBasicHtmlDocument(new TextEncoder().encode(` + +
+ + `)) + const parsed = new DOMParser().parseFromString(html, 'text/html') + expect(parsed.documentElement.lang).toBe('zh') + expect(parsed.documentElement.className).toBe('dark') + expect(parsed.body.style.margin).toBe('0px') + expect(parsed.body.dir).toBe('rtl') + expect(parsed.querySelectorAll('noscript, link, set, animate')).toHaveLength(0) + const nested = parsed.querySelector('template')!.content.querySelector('template')!.content + expect(nested.querySelector('a')!.hasAttribute('href')).toBe(false) + expect(nested.querySelector('iframe')).toBeNull() +}) diff --git a/packages/client/ui-sidebar-documentpreview/tests/code-registration.client.spec.tsx b/packages/client/ui-sidebar-documentpreview/tests/code-registration.client.spec.tsx index 7cddeef29d..17fe414745 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/code-registration.client.spec.tsx +++ b/packages/client/ui-sidebar-documentpreview/tests/code-registration.client.spec.tsx @@ -4,9 +4,9 @@ import { afterEach, describe, expect, it } from 'vitest' import { act } from '@testing-library/react' import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' +import { CODE_HIGHLIGHT_EXTENSIONS } from '@deepseek-ai/dsh-client-ui-primitives' import { apply } from '../src/client/code/index.ts' import { CodeBody } from '../src/client/code/CodeBody.tsx' -import { CODE_EXTENSIONS } from '../src/client/code/languages.ts' import { DocumentPreviewRegistry } from '../src/client/document/registry.ts' import { documentTabInfoFactory } from '../src/client/document/contract.ts' @@ -39,7 +39,7 @@ describe('code renderer registration', () => { const h = await boot() await h.rt.mount(plugin) expect(h.previews.getSnapshot()).toHaveLength(1) - expect(h.previews.getSnapshot()[0]).toMatchObject({ id: ID, extensions: CODE_EXTENSIONS, priority: 'builtin', loading: 'text-pages', wrap: true }) + expect(h.previews.getSnapshot()[0]).toMatchObject({ id: ID, extensions: CODE_HIGHLIGHT_EXTENSIONS, priority: 'builtin', loading: 'text-pages', wrap: true }) expect(h.rt.slots.entries(SLOT)).toEqual([]) await h.declare() const entries = h.rt.slots.entries(SLOT) diff --git a/packages/client/ui-sidebar-documentpreview/tests/document-seat.client.spec.tsx b/packages/client/ui-sidebar-documentpreview/tests/document-seat.client.spec.tsx index 5c8f1c258c..f938754af5 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/document-seat.client.spec.tsx +++ b/packages/client/ui-sidebar-documentpreview/tests/document-seat.client.spec.tsx @@ -1,5 +1,6 @@ // @vitest-environment jsdom /** Document extension registration and dispatch through the production Sidebar and Slot renderer. */ +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, fireEvent, screen, waitFor } from '@testing-library/react' import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' @@ -36,6 +37,7 @@ afterEach(async () => { async function boot() { const rt = await SlotTestRuntime.create() runtime = rt + rt.ctx.provide('settingsScope', { developerTools: { enabled: createSnapshotStore(true) } } as never) rt.ctx.provide('layout', { openRightbar: vi.fn(), closeRightbar: vi.fn() } as never) const locale = new LocaleRuntime(rt.ctx) rt.ctx.provide('locale', locale) @@ -100,7 +102,9 @@ describe('document extension seat', () => { const h = await boot() act(() => { h.rt.ctx.sidebarRight.openResource(sessionFileAddress('address-session', path)) }) await waitFor(() => { expect(h.view.container.querySelectorAll('[data-textpreview-line]')).toHaveLength(2) }) - expect(h.read).toHaveBeenCalledExactlyOnceWith('address-session', path, { offset: 1 }, expect.any(AbortSignal)) + expect(h.read.mock.calls).toEqual([ + ['address-session', path, { offset: 1 }, expect.any(AbortSignal)], + ]) expect(h.bytes).not.toHaveBeenCalled() }, ) @@ -147,7 +151,7 @@ describe('document extension seat', () => { }) const tab = h.view.container.querySelector('[data-renderer-tab]')?.getAttribute('data-renderer-tab') expect(h.read).not.toHaveBeenCalled() - expect(h.bytes).toHaveBeenCalledTimes(1) + expect(h.bytes).toHaveBeenCalledOnce() fireEvent.click(h.view.container.querySelector('[data-document-viewer-menu]')!) fireEvent.click(screen.getByRole('menuitem', { name: 'builtin-reader' })) await waitFor(() => { expect(h.view.container.querySelector('[data-renderer="builtin-reader"]')?.textContent).toBe('first\nsecond') }) @@ -169,6 +173,6 @@ describe('document extension seat', () => { }) await act(async () => { await remove!() }) await waitFor(() => { expect(h.view.container.querySelector('[data-document-markdown]')).not.toBeNull() }) - expect(h.read).toHaveBeenCalledTimes(1) + expect(h.read).toHaveBeenCalledOnce() }) }) diff --git a/packages/client/ui-sidebar-documentpreview/tests/document-toolbar.client.spec.tsx b/packages/client/ui-sidebar-documentpreview/tests/document-toolbar.client.spec.tsx index 09fdd1216e..0b4a14fb4c 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/document-toolbar.client.spec.tsx +++ b/packages/client/ui-sidebar-documentpreview/tests/document-toolbar.client.spec.tsx @@ -151,18 +151,19 @@ describe('document toolbar', () => { await pending.promise }) h.bytes.mockReturnValueOnce(pending.promise) - const renderSlot = vi.fn(() => null) + const renderSlot = vi.fn((_key: string) => null) + const documentRenders = (): number => renderSlot.mock.calls.filter(call => call[0] === 'sidebar.right.tab.document').length const props: TextPreviewProps = { ...h.props(), useDocumentPreviews: selector => selector([binary]), renderSlot } const view = render() expect(view.getByRole('status').hasAttribute('data-document-loading')).toBe(true) expect(view.getByRole('status').getAttribute('aria-label')).toBe('loading') expect(view.container.querySelector('[data-textpreview-body]')?.firstElementChild).toBe(view.getByRole('status')) - expect(renderSlot).not.toHaveBeenCalled() + expect(documentRenders()).toBe(0) await act(async () => { pending.resolve(result) await pending.promise }) - expect(renderSlot).toHaveBeenCalled() + expect(documentRenders()).toBeGreaterThan(0) expect(view.queryByRole('status')).toBeNull() }) @@ -184,7 +185,7 @@ describe('document toolbar', () => { const h = harness() const result = { ok: true as const, value: { absolutePath: ABSOLUTE_PATH, version: 'v1', offset: 0, data: new TextEncoder().encode('all'), bytes: 3, eof: true } } const read = h.bytes.mockResolvedValueOnce({ - ok: false, error: new RemoteError('workspace-file/not-found', 'Missing file', { path: ABSOLUTE_PATH }), + ok: false, error: new RemoteError('gateway/internal', 'socket closed', {}), }).mockResolvedValue(result) h.useResource.mockReturnValue({ status: 'failed', value: undefined, failure: new RemoteError('workspace-file/not-found', 'Missing file', { path: ABSOLUTE_PATH }) }) @@ -302,6 +303,11 @@ describe('document toolbar', () => { expect(view.container.querySelector('[data-textpreview-path]')).not.toBeNull() expect(view.container.querySelector('[data-document-viewer-menu]')).toBeNull() expect(view.container.querySelector('[data-textpreview-tool="reload"]')).toBeNull() + // Both handoff seats receive the file once its Host path is known. + const header = view.container.querySelector('[data-textpreview-path]')?.parentElement + expect(header?.querySelector('[data-slot="sidebar.right.tab.document.actions"]')?.getAttribute('data-slot-path')).toBe(ABSOLUTE_PATH) + const empty = view.container.querySelector('[data-textpreview-unsupported]') + expect(empty?.querySelector('[data-slot="sidebar.right.tab.document.unpreviewable"]')?.getAttribute('data-slot-path')).toBe(ABSOLUTE_PATH) expect(h.read).not.toHaveBeenCalled() expect(h.bytes).not.toHaveBeenCalled() h.controller.abort() diff --git a/packages/client/ui-sidebar-documentpreview/tests/face.client.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/face.client.spec.ts index 9a728c0844..2a8a85e183 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/face.client.spec.ts +++ b/packages/client/ui-sidebar-documentpreview/tests/face.client.spec.ts @@ -14,7 +14,7 @@ import { textFace } from '../src/client/face.ts' import type { DocumentFileBytes, ReadDocumentBytes, ReadWorkspaceFilePage } from '../src/client/rpc.ts' import { hostFileOf } from '../src/client/rpc.ts' import { createTextStore } from '../src/client/store.ts' -import { ABSOLUTE_PATH, FILE, PATH, SESSION, failure, page } from './fixtures.client.ts' +import { ABSOLUTE_PATH, FILE, PATH, SESSION, createResources, failure, page } from './fixtures.client.ts' import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit' const TAB_1 = 'tab-1' as TabId @@ -94,7 +94,8 @@ function bench(sessionId = 'other-session' as SessionId) { // The store's own `forget`, counted: the record's end must forget a tab exactly once. const forget = vi.fn(instance.actions.forget) // Injected for another session on purpose: the address's session must win. - const face = textFace(read, bytes)(sessionId, { ...instance.actions, forget }) + const resources = createResources() + const face = textFace(read, bytes, resources)(sessionId, { ...instance.actions, forget }) /** Settle the oldest outstanding read, or the oldest one for `offset`. */ const settle = async (result: RemoteResult, offset?: number): Promise => { const at = offset === undefined ? 0 : pending.findIndex(call => call.offset === offset) @@ -104,7 +105,7 @@ function bench(sessionId = 'other-session' as SessionId) { await call.promise } return { - instance, read, face, forget, settle, bytes, controller, + instance, read, face, forget, settle, bytes, controller, resources, settleAll: whole.settle, outstandingAll: whole.outstanding, outstanding: () => pending.map(call => call.offset), @@ -120,6 +121,19 @@ const settlements = [ ] as const describe('textFace', () => { + it('subscribes to a dependency once and stops accepting members after the tab ends', () => { + const { face, resources, controller, forget } = bench() + const source = vi.spyOn(resources, 'source') + const address = sessionFileAddress(SESSION, 'style.css') + face.addResource(TAB_1, address, controller.signal) + face.addResource(TAB_1, address, controller.signal) + expect(source).toHaveBeenCalledExactlyOnceWith(address) + controller.abort() + expect(forget).toHaveBeenCalledExactlyOnceWith(TAB_1) + face.addResource(TAB_1, sessionFileAddress(SESSION, 'other.css'), controller.signal) + expect(source).toHaveBeenCalledTimes(1) + }) + it('marks the read in flight, then keeps the page', async () => { const { read, face, settle, tab } = bench() const controller = new AbortController() @@ -403,7 +417,7 @@ it.each([new Error('invalid bytes'), 'invalid bytes'])('reports an active comple const controller = new AbortController() const read = vi.fn().mockRejectedValue(failure) try { - textFace(vi.fn(), read)(SESSION, instance.actions).loadAll(TAB_1, FILE, controller.signal) + textFace(vi.fn(), read, createResources())(SESSION, instance.actions).loadAll(TAB_1, FILE, controller.signal) await Promise.resolve() expect(instance.getSnapshot().byTab[TAB_1]?.failure).toMatchObject({ code: 'gateway/internal', message: 'invalid bytes' }) } finally { controller.abort() } @@ -413,7 +427,7 @@ it('ignores a complete-read rejection after renderer-owned loading takes over', const instance = createTextStore().create() const controller = new AbortController() const pending = Promise.withResolvers>>() - const face = textFace(vi.fn(), () => pending.promise)(SESSION, instance.actions) + const face = textFace(vi.fn(), () => pending.promise, createResources())(SESSION, instance.actions) try { face.loadAll(TAB_1, FILE, controller.signal) face.prepareRenderer(TAB_1, controller.signal, 'office') diff --git a/packages/client/ui-sidebar-documentpreview/tests/failure-line.client.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/failure-line.client.spec.ts index c43804d8ef..134cf3e822 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/failure-line.client.spec.ts +++ b/packages/client/ui-sidebar-documentpreview/tests/failure-line.client.spec.ts @@ -6,7 +6,7 @@ import type { RemoteFailure } from '@deepseek-ai/dsh-api-remotes/client' import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' // The namespace declaration `TranslateNS<'sidebarDocumentPreview'>` resolves against. import type {} from '../src/client/index.ts' -import { failureLine } from '../src/client/failure-line.ts' +import { emptyFailureRecourse, failureLine } from '../src/client/failure-line.ts' /** Key-echoing translate that also shows its parameters, so a formatted value is visible. */ const t: TranslateNS<'sidebarDocumentPreview'> = (key, params) => @@ -33,3 +33,20 @@ describe('failureLine', () => { expect(failureLine(t, failure('gateway/internal', {}, 'socket closed'))).toBe('error.unavailable(message=socket closed)') }) }) + +describe('emptyFailureRecourse', () => { + it('hands a readable file the preview cannot render to the Host', () => { + expect(emptyFailureRecourse(failure('workspace-file/not-text'))).toBe('open') + expect(emptyFailureRecourse(failure('workspace-file/too-large', { limit: 512 }))).toBe('open') + }) + + it('offers nothing for a path with nothing to show or open', () => { + expect(emptyFailureRecourse(failure('workspace-file/not-found'))).toBe('none') + expect(emptyFailureRecourse(failure('workspace-file/not-regular-file'))).toBe('none') + }) + + it('retries carrier and unclassified failures', () => { + expect(emptyFailureRecourse(failure('gateway/internal'))).toBe('retry') + expect(emptyFailureRecourse(failure('someday/a-new-code'))).toBe('retry') + }) +}) diff --git a/packages/client/ui-sidebar-documentpreview/tests/fixtures.client.ts b/packages/client/ui-sidebar-documentpreview/tests/fixtures.client.ts index d0115bdf38..d34bd4bc5a 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/fixtures.client.ts +++ b/packages/client/ui-sidebar-documentpreview/tests/fixtures.client.ts @@ -8,6 +8,8 @@ * not the slot runtime. */ import { onTestFinished, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import { ResourceRegistry } from '../../resources/src/client/resources.ts' import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' import type { Mock } from 'vitest' import { act } from '@testing-library/react' @@ -29,8 +31,18 @@ import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit' type BodySlot = PropsRenderSlots<'sidebar.right.tab.document'>['renderSlot'] +export function createResources(): ResourceRegistry { + const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) + return new ResourceRegistry(ctx) +} + /** Preserve the body-slot callback used by component fixtures. */ -export function documentSlots(body: BodySlot): TextPreviewProps['renderSlot'] { return body } +export function documentSlots(body: BodySlot): TextPreviewProps['renderSlot'] { + return (name: string, owner: unknown, options?: unknown) => name === 'sidebar.right.tab.document' + ? body(name, owner as Parameters[1], options as Parameters[2]) + : null +} export const TAB_ID = 'tab-1' as TabId export const SESSION = 's-1' as SessionId @@ -120,7 +132,7 @@ export function harness(script: Record> const read = vi.fn((_session, _path, offset) => Promise.resolve(pages[offset] ?? failure('workspace-file/not-found', { path: PATH }))) const bytes = vi.fn() - const face = textFace(read, bytes)(SESSION, instance.actions) + const face = textFace(read, bytes, createResources())(SESSION, instance.actions) const current = { version: 'v1' as string | undefined, failure: undefined as RemoteFailure | undefined, snapshot: meta('v1', undefined) } const refresh = (): void => { current.snapshot = meta(current.version, current.failure) } const useResource = vi.fn<() => ResourceSnapshot>(() => current.snapshot) @@ -128,9 +140,17 @@ export function harness(script: Record> onTestFinished(() => { controller.abort() }) const tabActions = { openResource: vi.fn(), openTab: vi.fn(), close: vi.fn(), replace: vi.fn() } const definitions = [textBodyDefinition(() => t('viewer.text'))] - const renderSlot = documentSlots((_key, owner, opts) => createElement(TextBody, { - ...owner, useTabInfo: opts.hookContext, sessionId: SESSION, useResource, - } as unknown as DocumentPreviewProps)) + // The document seat renders the plain body; the file-handoff seats render a + // marker carrying what the owner handed them. + const renderSlot: TextPreviewProps['renderSlot'] = (key: string, owner: unknown, opts?: { hookContext?: unknown }) => + key === 'sidebar.right.tab.document' + ? createElement(TextBody, { + ...owner as object, useTabInfo: opts?.hookContext, sessionId: SESSION, useResource, + } as unknown as DocumentPreviewProps) + : key === 'sidebar.right.tab.document.action' ? null : createElement('div', { + 'data-slot': key, + 'data-slot-path': (owner as { absolutePath: string }).absolutePath, + }) const props = (navigation: { params?: unknown; revision: number } = { revision: 1 }) => ({ useTabInfo: () => ({ sidebar: { expanded: true, fullscreen: false }, @@ -150,6 +170,7 @@ export function harness(script: Record> reloadPages: face.reloadPages, prepareRenderer: face.prepareRenderer, loadAll: face.loadAll, reloadAll: face.reloadAll, + addResource: face.addResource, setResources: face.setResources, useDocumentPreviews: () => definitions, renderSlot, t, diff --git a/packages/client/ui-sidebar-documentpreview/tests/html-apply.client.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/html-apply.client.spec.ts index ccb8ec82c8..bcf14b5c6a 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/html-apply.client.spec.ts +++ b/packages/client/ui-sidebar-documentpreview/tests/html-apply.client.spec.ts @@ -1,4 +1,5 @@ /** HTML metadata and keyed slot contributions share one identity and unwind with their fiber. */ +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { sessionFileAddress } from '@deepseek-ai/dsh-util-workspace-path' @@ -30,6 +31,7 @@ describe('HTML registration', () => { it('registers its dictionary and matching keyed body, and removes all contributions on disposal', async () => { const ctx = new Context() + ctx.provide('settingsScope', { developerTools: { enabled: createSnapshotStore(true) } } as never) // No Session or Tab services are mounted; the global callback must use its file address. const registry = new DocumentPreviewRegistry() const dictionaries = new Map() @@ -46,7 +48,7 @@ describe('HTML registration', () => { bind: () => (key: keyof typeof en) => en[key], register: (name: string, value: unknown) => { dictionaries.set(name, value); return () => { dictionaries.delete(name) } }, } as never) - const fiber = ctx.plugin({ apply }) + const fiber = ctx.plugin({ inject: ['settingsScope'], apply }) dispose = async () => { await fiber.dispose() } await fiber.await() expect(registry.candidates('INDEX.HTM').map(entry => entry.id)).toEqual([HTML_BODY_ID]) 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 78eca6a5f2..be3a1a7869 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 @@ -1,11 +1,19 @@ // @vitest-environment jsdom /** HTML iframe ownership follows file identity and bytes, not locale or wrapping changes. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { act, cleanup, render, screen } from '@testing-library/react' +import { act, cleanup, render, screen, waitFor } from '@testing-library/react' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +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' +import { TextPreview } from '../src/client/TextPreview.tsx' +import type { DocumentBodyOwner } from '../src/client/document/contract.ts' +import { textFace } from '../src/client/face.ts' import { HtmlBody } from '../src/client/html/HtmlBody.tsx' import type { HtmlBodyProps } from '../src/client/html/HtmlBody.tsx' +import { htmlBodyDefinition } from '../src/client/html/index.ts' import { en } from '../src/client/html/locales.ts' +import { ADDRESS, ABSOLUTE_PATH, SESSION, TAB_ID, documentSlots, harness } from './fixtures.client.ts' const translations: ReadonlyMap = new Map(Object.entries(en)) let createDescriptor: PropertyDescriptor | undefined @@ -34,12 +42,15 @@ afterEach(() => { function props(text = '

hello

'): HtmlBodyProps { const signal = new AbortController().signal return { + useInteractivePreview: select => select(true), resourceAddress: 'dsh-resource://file/session/html/index.html', content: { kind: 'bytes', data: utf8(text) }, wrap: false, sessionId: 'html' as SessionId, useTabInfo: () => ({ tab: { signal } }), readRelated: vi.fn(), + addResource: vi.fn(), + setResources: vi.fn(), useResource: () => ({ value: undefined }), t: key => translations.get(key) ?? key, } as HtmlBodyProps @@ -48,6 +59,100 @@ function props(text = '

hello

'): HtmlBodyProps { const utf8 = (text: string): Uint8Array => new TextEncoder().encode(text) describe('HtmlBody', () => { + it.each(['css', 'js'] as const)('reloads the HTML when only its %s resource changes', async (extension) => { + const h = harness() + const previewProps = h.props() + const htmlProps = props() + const metadata = (version: string): ResourceSnapshot => ({ + status: 'live', value: { absolutePath: ABSOLUTE_PATH, version }, failure: undefined, + }) + const root = createSnapshotStore(metadata('root-v1')) + const dependency = createSnapshotStore(metadata('asset-v1')) + const release = vi.fn() + const dependencySource = { + getSnapshot: () => dependency.getSnapshot(), + subscribe: (listener: () => void) => { + const off = dependency.subscribe(listener) + return () => { off(); release() } + }, + } + const resources: Resources = { + source: address => address === ADDRESS ? root : dependencySource, + register: () => () => {}, pin: () => {}, + } + const face = textFace(h.read, h.bytes, resources)(SESSION, h.instance.actions) + const data = utf8(extension === 'css' + ? '

HTML content

' + : '

HTML content

') + h.bytes.mockResolvedValue({ ok: true, value: { absolutePath: ABSOLUTE_PATH, version: 'root-v1', data, offset: 0, eof: true } }) + const readRelated = vi.fn().mockResolvedValue({ ok: true, value: { + absolutePath: `/workspace/asset.${extension}`, version: 'asset-v1', offset: 0, eof: true, + data: btoa(extension === 'css' ? 'body { color: red }' : 'window.loaded = true'), + } }) + const definition = { ...htmlBodyDefinition(() => 'HTML'), extensions: ['md'] } + let interactive = true + const preview = () => select([definition])} + renderSlot={documentSlots((_key, owner) => select(interactive)} readRelated={readRelated} />)} /> + const view = render(preview()) + await waitFor(() => { + expect(screen.getByTitle(en.frame)).toBeDefined() + expect(h.instance.getSnapshot().byTab[TAB_ID]).toMatchObject({ loading: false, resourcesDirty: false }) + }) + const previous = screen.getByTitle(en.frame) + const reads = h.bytes.mock.calls.length + const relatedReads = readRelated.mock.calls.length + expect(reads).toBe(1) + expect(relatedReads).toBe(1) + expect(create).toHaveBeenCalledOnce() + readRelated.mockResolvedValueOnce({ ok: true, value: { + absolutePath: `/workspace/asset.${extension}`, version: 'asset-v2', offset: 0, eof: true, + data: btoa(extension === 'css' ? 'body { color: blue }' : 'window.loaded = false'), + } }) + act(() => { dependency.set(metadata('asset-v2')) }) + await waitFor(() => { expect(screen.getByTitle(en.frame)).not.toBe(previous) }) + expect(h.bytes).toHaveBeenCalledTimes(reads + 1) + expect(readRelated).toHaveBeenCalledTimes(relatedReads + 1) + expect(h.instance.getSnapshot().byTab[TAB_ID]?.version).toBe('root-v1') + expect(h.read).not.toHaveBeenCalled() + interactive = false + view.rerender(preview()) + const staticFrame = screen.getByTitle(en.frame) + expect(staticFrame.getAttribute('sandbox')).toBe('') + expect(release).toHaveBeenCalledOnce() + act(() => { dependency.set(metadata('asset-v3')) }) + expect(screen.getByTitle(en.frame)).toBe(staticFrame) + expect(h.bytes).toHaveBeenCalledTimes(reads + 1) + expect(readRelated).toHaveBeenCalledTimes(relatedReads + 1) + act(() => { root.set(metadata('root-v2')) }) + await waitFor(() => { expect(h.bytes).toHaveBeenCalledTimes(reads + 2) }) + expect(readRelated).toHaveBeenCalledTimes(relatedReads + 1) + view.unmount() + }) + + it('renders static HTML without reading related files, running scripts or retaining an advanced frame', async () => { + const initial = props('

Preview

Static content

') + const basic = { ...initial, useInteractivePreview: ((select: (enabled: boolean) => unknown) => select(false)) as HtmlBodyProps['useInteractivePreview'] } + const view = render() + const frame = screen.getByTitle(en.frame) + expect(frame.getAttribute('sandbox')).toBe('') + expect(frame.getAttribute('srcdoc')).toContain("default-src 'none'") + expect(frame.getAttribute('srcdoc')).not.toContain('Advanced

') + view.rerender() + const advanced = await screen.findByTitle(en.frame) + expect(advanced).not.toBe(frame) + expect(advanced.getAttribute('sandbox')).toBe('allow-scripts') + view.rerender() + expect(advanced.isConnected).toBe(false) + expect(revoke).toHaveBeenCalledOnce() + view.rerender() + expect(screen.getByRole('alert').textContent).toBe(en.failed) + }) + 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-documentpreview/tests/html-read-relative.client.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/html-read-relative.client.spec.ts index a7bd5a70f5..d043a1a579 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/html-read-relative.client.spec.ts +++ b/packages/client/ui-sidebar-documentpreview/tests/html-read-relative.client.spec.ts @@ -1,6 +1,7 @@ /** HTML URL decoding stays local; ordinary Remote reads leave path resolution and authorization to the Host. */ import { describe, expect, it, vi } from 'vitest' import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' +import { sessionFileAddress } from '@deepseek-ai/dsh-util-workspace-path' import { createReadHtmlRelative } from '../src/client/html/read-relative.ts' import type { ReadHtmlRelated } from '../src/client/html/read-relative.ts' @@ -12,21 +13,54 @@ describe('HTML relative file reader', () => { const readRelated = vi.fn().mockResolvedValue({ ok: true, value }) const tab = new AbortController() const loading = new AbortController() - const read = createReadHtmlRelative(readRelated, ADDRESS, tab.signal) + const addResource = vi.fn() + const read = createReadHtmlRelative(readRelated, ADDRESS, tab.signal, addResource) await expect(read('../a%20b.js?v=1#fragment', loading.signal)).resolves.toEqual({ ...value, data: new Uint8Array([120]) }) const signal = readRelated.mock.calls[0]?.[2] expect(readRelated).toHaveBeenCalledExactlyOnceWith(ADDRESS, '../a b.js', signal) + expect(addResource).toHaveBeenCalledExactlyOnceWith(sessionFileAddress('html', value.absolutePath)) expect(signal?.aborted).toBe(false) tab.abort() expect(signal?.aborted).toBe(true) }) + it('observes the Host-resolved dependency instead of a symlink and parent-segment spelling', async () => { + const value = { absolutePath: '/workspace/style.css', data: btoa('x'), version: 'v1', offset: 0, eof: true } + const readRelated = vi.fn().mockResolvedValue({ ok: true, value }) + const addResource = vi.fn() + const signal = new AbortController().signal + await createReadHtmlRelative(readRelated, ADDRESS, signal, addResource)('linked/../style.css', signal) + expect(addResource).toHaveBeenCalledExactlyOnceWith(sessionFileAddress('html', value.absolutePath)) + }) + + it('observes the Host-reported missing path so creation can invalidate the preview', async () => { + const path = '/workspace/missing.css' + const readRelated = vi.fn().mockResolvedValue({ + ok: false, error: new RemoteError('workspace-file/not-found', 'Missing dependency', { path }), + }) + const addResource = vi.fn() + const signal = new AbortController().signal + await expect(createReadHtmlRelative(readRelated, ADDRESS, signal, addResource)('linked/../missing.css', signal)) + .rejects.toThrow('Missing dependency') + expect(addResource).toHaveBeenCalledExactlyOnceWith(sessionFileAddress('html', path)) + }) + + it('does not guess a dependency path when the Host failure contains none', async () => { + const readRelated = vi.fn().mockResolvedValue({ + ok: false, error: new RemoteError('gateway/internal', 'Read failed', {}), + }) + const addResource = vi.fn() + const signal = new AbortController().signal + await expect(createReadHtmlRelative(readRelated, ADDRESS, signal, addResource)('asset.css', signal)).rejects.toThrow('Read failed') + expect(addResource).not.toHaveBeenCalled() + }) + it('refuses non-relative references and preserves Host permission failures', async () => { const readRelated = vi.fn().mockResolvedValue({ ok: false, error: new RemoteError('workspace-file/outside-workspace', 'outside workspace', { path: '../x.js' }), }) const signal = new AbortController().signal - const read = createReadHtmlRelative(readRelated, ADDRESS, signal) + const read = createReadHtmlRelative(readRelated, ADDRESS, signal, vi.fn()) for (const path of ['', '/x.js', 'file:///x.js', '%2Fx.js', 'C:/x.js', '..\\x.js', '%00.js', '%ZZ.js']) { await expect(read(path, signal)).rejects.toThrow() } @@ -38,7 +72,7 @@ describe('HTML relative file reader', () => { const controller = new AbortController() controller.abort() const readRelated = vi.fn() - const read = createReadHtmlRelative(readRelated, ADDRESS, controller.signal) + const read = createReadHtmlRelative(readRelated, ADDRESS, controller.signal, vi.fn()) await expect(read('x.js', new AbortController().signal)).rejects.toMatchObject({ name: 'AbortError' }) expect(readRelated).not.toHaveBeenCalled() }) @@ -47,7 +81,7 @@ describe('HTML relative file reader', () => { const pending = Promise.withResolvers>>() const readRelated = vi.fn().mockReturnValue(pending.promise) const loading = new AbortController() - const read = createReadHtmlRelative(readRelated, ADDRESS, new AbortController().signal) + const read = createReadHtmlRelative(readRelated, ADDRESS, new AbortController().signal, vi.fn()) const result = read('./late.js', loading.signal) const rejected = expect(result).rejects.toMatchObject({ name: 'AbortError' }) loading.abort() diff --git a/packages/client/ui-sidebar-documentpreview/tests/loading-indicator.client.spec.tsx b/packages/client/ui-sidebar-documentpreview/tests/loading-indicator.client.spec.tsx index 7ffc7aa826..aaf45a8a59 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/loading-indicator.client.spec.tsx +++ b/packages/client/ui-sidebar-documentpreview/tests/loading-indicator.client.spec.tsx @@ -12,5 +12,6 @@ describe('LoadingIndicator', () => { const status = getByRole('status') expect(status.textContent).toBe('') expect(status.getAttribute('aria-label')).toBe('Reading…') + expect(status.querySelector('[data-state="ongoing"]')).not.toBeNull() }) }) diff --git a/packages/client/ui-sidebar-documentpreview/tests/markdown-registration.client.spec.tsx b/packages/client/ui-sidebar-documentpreview/tests/markdown-registration.client.spec.tsx index 4e328a1977..1441718b91 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/markdown-registration.client.spec.tsx +++ b/packages/client/ui-sidebar-documentpreview/tests/markdown-registration.client.spec.tsx @@ -61,6 +61,7 @@ describe('Markdown implementation registration', () => { content: { kind: 'text', text: '# Notes\n\n```ts\nconst value = 1\n```', pages: [], eof: true }, wrap: false, scrollportRef: vi.fn(), + addResource: vi.fn(), setResources: vi.fn(), }, { entryKey: MARKDOWN_BODY_ID, hookContext: useTabInfo, diff --git a/packages/client/ui-sidebar-documentpreview/tests/office-font-notice.client.spec.tsx b/packages/client/ui-sidebar-documentpreview/tests/office-font-notice.client.spec.tsx index a66c536bec..169aaf0aa6 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/office-font-notice.client.spec.tsx +++ b/packages/client/ui-sidebar-documentpreview/tests/office-font-notice.client.spec.tsx @@ -15,7 +15,6 @@ afterEach(() => { cleanup(); vi.unstubAllGlobals() }) function props(overrides: Partial = {}): FontNoticeProps { return { - resourceAddress: 'dsh-resource://file/session/s-1/report.docx', sourceVersion: 'v1', fonts: ['Consolas', 'Missing Serif'], t: makeTranslate(en), ...overrides, } @@ -23,7 +22,7 @@ function props(overrides: Partial = {}): FontNoticeProps { it('opens details, restores focus on Escape or close, and dismisses outside without stealing focus', () => { render() - const more = screen.getByRole('button', { name: en.showMore }) + const more = screen.getByRole('button', { name: makeTranslate(en)('viewMissingFonts', { count: 2 }) }) fireEvent.click(more) const dialog = screen.getByRole('dialog', { name: en.missingFontsTitle }) expect(dialog.textContent).toContain('Consolas') @@ -36,7 +35,8 @@ it('opens details, restores focus on Escape or close, and dismisses outside with expect(document.activeElement).toBe(more) fireEvent.click(more) fireEvent.click(screen.getByRole('button', { name: en.closeDetails })) - expect(screen.getByRole('status').textContent).toContain('Consolas') + expect(screen.getByRole('button', { name: makeTranslate(en)('viewMissingFonts', { count: 2 }) })).toBe(more) + expect(screen.queryByRole('status')).toBeNull() expect(document.activeElement).toBe(more) fireEvent.click(more) fireEvent.pointerDown(document.body) @@ -48,7 +48,7 @@ it('opens details, restores focus on Escape or close, and dismisses outside with it('keeps details open while focus moves inside them and closes when focus leaves the notice', () => { render(<>) - const more = screen.getByRole('button', { name: en.showMore }) + const more = screen.getByRole('button', { name: makeTranslate(en)('viewMissingFonts', { count: 2 }) }) fireEvent.click(more) const dialog = screen.getByRole('dialog') fireEvent.blur(dialog, { relatedTarget: screen.getByRole('button', { name: en.closeDetails }) }) @@ -59,24 +59,23 @@ it('keeps details open while focus moves inside them and closes when focus leave expect(screen.queryByRole('dialog')).toBeNull() }) -it('hides the entire notice until another source version is loaded', () => { - const input = props() - const view = render() - fireEvent.click(screen.getByRole('button', { name: en.showMore })) - fireEvent.click(screen.getByRole('button', { name: en.dismissNotice })) - expect(screen.queryByRole('status')).toBeNull() +it('shows a warning without automatically opening details or remembering that they were viewed', () => { + const view = render() + const warning = screen.getByRole('button') + expect(warning.getAttribute('aria-expanded')).toBe('false') expect(screen.queryByRole('dialog')).toBeNull() - expect(screen.queryByRole('button', { name: en.showMore })).toBeNull() - view.rerender() - expect(screen.queryByRole('status')).toBeNull() - view.rerender() - expect(screen.getByRole('status')).toBeDefined() + fireEvent.click(warning) + fireEvent.click(screen.getByRole('button', { name: en.closeDetails })) + expect(screen.getByRole('button', { name: makeTranslate(en)('viewMissingFonts', { count: 2 }) })).toBe(warning) + view.unmount() + render() + expect(screen.getByRole('button').getAttribute('aria-expanded')).toBe('false') expect(screen.queryByRole('dialog')).toBeNull() }) it('has localized copy and does not reserve a notice for fonts that are available', () => { const view = render() - fireEvent.click(screen.getByRole('button', { name: zh.showMore })) + fireEvent.click(screen.getByRole('button', { name: makeTranslate(zh)('viewMissingFonts', { count: 2 }) })) expect(screen.getByRole('dialog', { name: zh.missingFontsTitle })).toBeDefined() view.rerender() expect(view.container.childElementCount).toBe(0) diff --git a/packages/client/ui-sidebar-documentpreview/tests/office-registration.client.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/office-registration.client.spec.ts index f572cf219b..22bd4da75b 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/office-registration.client.spec.ts +++ b/packages/client/ui-sidebar-documentpreview/tests/office-registration.client.spec.ts @@ -99,7 +99,7 @@ it.each(['remote', 'render', 'files'] as const)('keeps Office registration and g expect(h.register).toHaveBeenCalledWith(expect.objectContaining({ name: 'sidebar.right.tab.document', key: '@deepseek-ai/dsh-client-ui-sidebar-documentpreview/office', locale: 'sidebarOffice', }), OfficeBody) - expect(h.removeNotice).toHaveBeenCalledTimes(2) + expect(h.removeNotice).toHaveBeenCalledTimes(3) }) it('requests a Host PDF with source identity and borrows the same binary cache result', async () => { diff --git a/packages/client/ui-sidebar-documentpreview/tests/pdf-body.client.spec.tsx b/packages/client/ui-sidebar-documentpreview/tests/pdf-body.client.spec.tsx index ee2e2a5c0d..a39f99a865 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/pdf-body.client.spec.tsx +++ b/packages/client/ui-sidebar-documentpreview/tests/pdf-body.client.spec.tsx @@ -107,6 +107,7 @@ describe('PDF body', () => { const view = render() expect(screen.getByRole('status').getAttribute('aria-label')).toBe('Reading…') expect(screen.getByRole('status').hasAttribute('data-document-loading')).toBe(true) + expect(screen.getByRole('status').querySelector('[data-state="ongoing"]')).not.toBeNull() await act(async () => { loads[0]!.deferred.resolve(documentOf()) }) await act(async () => {}) expect(view.container.querySelector('[role="toolbar"]')).toBeNull() diff --git a/packages/client/ui-sidebar-documentpreview/tests/renderer-loading.client.spec.tsx b/packages/client/ui-sidebar-documentpreview/tests/renderer-loading.client.spec.tsx index 1a8836358e..472a8192f2 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/renderer-loading.client.spec.tsx +++ b/packages/client/ui-sidebar-documentpreview/tests/renderer-loading.client.spec.tsx @@ -9,6 +9,7 @@ import type { OfficeToPdfGeneration } from '@deepseek-ai/dsh-office-to-pdf/types import type { DocumentPreviewDefinition } from '../src/client/document/registry.ts' import type { DocumentContent } from '../src/client/document/contract.ts' import { TextPreview, type TextPreviewProps } from '../src/client/TextPreview.tsx' +import { OfficeFontAction, type OfficeFontActionProps } from '../src/client/office/OfficeFontAction.tsx' import { OfficeBody, type OfficeBodyProps } from '../src/client/office/OfficeBody.tsx' import { createOfficeStore, type OfficeState } from '../src/client/office/store.ts' import type { ReadOfficeDocument } from '../src/client/office/cache.ts' @@ -45,8 +46,9 @@ it('lets a non-Office renderer load content, report its version, and reload thro useEffect(() => { if (displayed !== undefined) request?.loaded(displayed.version) }, [displayed, request?.loaded]) return

{displayed?.text ?? 'Loading custom content'}

} - const renderSlot: TextPreviewProps['renderSlot'] = (_name, input) => { - const owner = input as unknown as OwnerOf<'sidebar.right.tab.document'> + const renderSlot: TextPreviewProps['renderSlot'] = (name: string, input: unknown) => { + if (name !== 'sidebar.right.tab.document') return null + const owner = input as OwnerOf<'sidebar.right.tab.document'> return } const useDocumentPreviews: TextPreviewProps['useDocumentPreviews'] = selector => selector([custom]) @@ -54,6 +56,7 @@ it('lets a non-Office renderer load content, report its version, and reload thro expect(read).toHaveBeenCalledTimes(1) expect(await screen.findByText('Custom content v1')).toBeTruthy() expect(h.instance.getSnapshot().byTab[TAB_ID]?.version).toBe('v1') + act(() => { h.instance.actions.toggledAutoRefresh(TAB_ID) }) h.setVersion('v2') view.rerender() expect(screen.getByText('changed')).toBeTruthy() @@ -72,9 +75,9 @@ const definition: DocumentPreviewDefinition = { id: 'office', extensions: ['md'], binaryExtensions: ['md'], title: () => 'Office', loading: 'renderer', } type Result = Awaited> -const result = (version = 'v1'): Result => ({ ok: true, value: { +const result = (version = 'v1', missingFonts: readonly string[] = []): Result => ({ ok: true, value: { absolutePath: ABSOLUTE_PATH, version, data: new TextEncoder().encode(`PDF ${version}`), - offset: 0, eof: true, missingFonts: [], generation: 'engine' as OfficeToPdfGeneration, + offset: 0, eof: true, missingFonts, generation: 'engine' as OfficeToPdfGeneration, } }) function setup() { @@ -99,12 +102,17 @@ function setup() { } const describeFailure: OfficeBodyProps['describeFailure'] = error => error.message let request: Extract | undefined - const slots: TextPreviewProps['renderSlot'] = (_key, input, options) => { - const owner = input as unknown as OwnerOf<'sidebar.right.tab.document'> + const slots: TextPreviewProps['renderSlot'] = (key: string, input: unknown, options?: { hookContext?: unknown }) => { + if (key === 'sidebar.right.tab.document.action') { + return , useTabInfo: options?.hookContext, useStore: useOffice, + actions: office.actions, t: makeTranslate(en) } as unknown as OfficeFontActionProps} /> + } + if (key !== 'sidebar.right.tab.document') return null + const owner = input as OwnerOf<'sidebar.right.tab.document'> if (owner.content.kind !== 'renderer') return

Raw bytes

request = owner.content // The component fixture supplies the standard seats used by Office; the real slot binding is exercised by the browser scenario. - const props = { ...h.props(), ...owner, useTabInfo: options.hookContext, useStore: useOffice, + const props = { ...h.props(), ...owner, useTabInfo: options?.hookContext, useStore: useOffice, actions: office.actions, read, retainTab, describeFailure, t: makeTranslate(en), renderSlot: (_name: string, child: { content: DocumentContent }) => (

{child.content.kind === 'bytes' ? new TextDecoder().decode(child.content.data) : ''}

@@ -124,7 +132,7 @@ function setup() { return { h, office, pending, read, View, request: () => request! } } -it('loads without reading raw bytes, retains content across remounts, and reloads only after a source-change action', async () => { +it('retains renderer content across remounts and waits for reload when automatic refresh is paused', async () => { const h = setup() let mounted = render() expect(screen.getByRole('status').getAttribute('aria-label')).toBe(en.loading) @@ -137,6 +145,7 @@ it('loads without reading raw bytes, retains content across remounts, and reload mounted = render() expect(screen.getByText('PDF v1')).toBeTruthy() expect(h.read).toHaveBeenCalledTimes(1) + act(() => { h.h.instance.actions.toggledAutoRefresh(TAB_ID) }) h.h.setVersion('v2') mounted.rerender() expect(screen.getByText('changed')).toBeTruthy() @@ -154,19 +163,45 @@ it('aborts a superseded load and rejects its late bytes and version report', asy const previous = h.request() fireEvent.click(screen.getByRole('button', { name: 'reload' })) expect(h.pending[0]!.signal.aborted).toBe(true) + act(() => { previous.failed() }) + expect(h.h.instance.getSnapshot().byTab[TAB_ID]?.loading).toBe(true) await act(async () => { h.pending[1]!.deferred.resolve(result('v2')) }) await act(async () => { h.pending[0]!.deferred.resolve(result('v1')); previous.loaded('v1') }) expect(screen.getByText('PDF v2')).toBeTruthy() expect(h.h.instance.getSnapshot().byTab[TAB_ID]?.version).toBe('v2') }) +it.each(['declared', 'exception'] as const)('automatically retries a %s conversion failure only after another file change', async (kind) => { + const h = setup() + render() + await act(async () => { + if (kind === 'declared') h.pending[0]!.deferred.resolve({ + ok: false, error: new RemoteError('document-render/failed', 'Conversion failed', { reason: 'failed' }), + }) + else h.pending[0]!.deferred.reject(new Error('Conversion failed')) + }) + expect(screen.getByText('Conversion failed')).toBeTruthy() + expect(h.h.instance.getSnapshot().byTab[TAB_ID]?.loading).toBe(false) + expect(h.read).toHaveBeenCalledTimes(1) + act(() => { + h.h.setVersion('v2') + h.h.instance.actions.resourceChanged(TAB_ID) + }) + expect(h.read).toHaveBeenCalledTimes(2) + await act(async () => { h.pending[1]!.deferred.resolve(result('v2')) }) + expect(screen.getByText('PDF v2')).toBeTruthy() + expect(h.h.instance.getSnapshot().byTab[TAB_ID]?.loading).toBe(false) +}) + it.each(['replace', 'close', 'hide'] as const)('retires pending conversion on %s and ignores a late rejection', async (transition) => { const h = setup() h.h.bytes.mockResolvedValue({ ok: true, value: { absolutePath: ABSOLUTE_PATH, version: 'v1', offset: 0, eof: true, data: new Uint8Array() } }) const mounted = render() + const previous = h.request() if (transition === 'replace') mounted.rerender() else if (transition === 'close') act(() => { h.h.controller.abort() }) else mounted.unmount() + if (transition !== 'hide') act(() => { previous.failed() }) expect(h.pending[0]!.signal.aborted).toBe(true) await act(async () => { h.pending[0]!.deferred.reject(new Error('late error')) }) if (transition === 'close') { @@ -211,3 +246,26 @@ it('starts no conversion when a body receives ordinary shared content', () => { expect(view.container.childElementCount).toBe(0) expect(h.read).not.toHaveBeenCalled() }) + +it('shows the current revision’s missing fonts in the toolbar and clears stale details during reload', async () => { + const h = setup() + const view = render() + expect(screen.queryByRole('button', { name: /Missing fonts:/ })).toBeNull() + await act(async () => { h.pending[0]!.deferred.resolve(result('v1', ['Missing Serif'])) }) + const warning = screen.getByRole('button', { name: 'Missing fonts: 1. Click to view.' }) + fireEvent.click(warning) + expect(screen.getByRole('dialog').textContent).toContain('Missing Serif') + fireEvent.click(screen.getByRole('button', { name: 'reload' })) + expect(screen.queryByRole('button', { name: /Missing fonts:/ })).toBeNull() + expect(screen.queryByRole('dialog')).toBeNull() + await act(async () => { h.pending[1]!.deferred.resolve(result('v2', ['Missing Sans'])) }) + expect(screen.queryByRole('dialog')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Missing fonts: 1. Click to view.' })) + expect(screen.getByRole('dialog').textContent).toContain('Missing Sans') + expect(screen.getByRole('dialog').textContent).not.toContain('Missing Serif') + h.h.bytes.mockResolvedValue({ ok: true, value: { absolutePath: ABSOLUTE_PATH, version: 'v1', offset: 0, eof: true, data: new Uint8Array() } }) + view.rerender() + await settle() + expect(screen.queryByRole('button', { name: /Missing fonts:/ })).toBeNull() + expect(screen.queryByRole('dialog')).toBeNull() +}) diff --git a/packages/client/ui-sidebar-documentpreview/tests/resource-group.client.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/resource-group.client.spec.ts new file mode 100644 index 0000000000..3254e0d3f2 --- /dev/null +++ b/packages/client/ui-sidebar-documentpreview/tests/resource-group.client.spec.ts @@ -0,0 +1,90 @@ +import { describe, expect, it, onTestFinished, vi } from 'vitest' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' +import type { Resources, ResourceSnapshot } from '@deepseek-ai/dsh-client-resources/client' +import type { WorkspaceFileStat } from '@deepseek-ai/dsh-api-workspace-files/types' +import { ResourceGroup } from '../src/client/document/resource-group.ts' + +function harness() { + const state = createSnapshotStore>({ status: 'loading', value: undefined, failure: undefined }) + const release = vi.fn() + const source = { + getSnapshot: () => state.getSnapshot(), + subscribe: (listener: () => void) => { + const off = state.subscribe(listener) + return () => { off(); release() } + }, + } + const resources: Resources = { source: () => source, register: () => () => {}, pin: () => {} } + const changed = vi.fn() + const group = new ResourceGroup(resources, changed) + onTestFinished(() => { group.close() }) + const version = (version: string): void => { + state.set({ status: 'live', value: { absolutePath: '/style.css', version }, failure: undefined }) + } + return { group, changed, version, release, state } +} + +describe('ResourceGroup', () => { + it('uses initial metadata as a baseline and invalidates only on later changes', () => { + const h = harness() + h.group.add('style.css') + h.version('v2') + expect(h.changed).not.toHaveBeenCalled() + h.version('v3') + expect(h.changed).toHaveBeenCalledOnce() + h.group.close() + }) + + it('subscribes to an existing member only once', () => { + const h = harness() + h.group.add('style.css') + h.version('v2') + h.group.add('style.css') + expect(h.changed).not.toHaveBeenCalled() + h.version('v3') + expect(h.changed).toHaveBeenCalledOnce() + h.group.close() + }) + + it('ignores repeated metadata and stops observing after close', () => { + const h = harness() + h.version('v1') + h.group.add('style.css') + h.version('v1') + expect(h.changed).not.toHaveBeenCalled() + h.version('v2') + expect(h.changed).toHaveBeenCalledOnce() + h.group.close() + h.version('v3') + expect(h.changed).toHaveBeenCalledOnce() + }) + + it('shares membership and releases members absent from the next document', () => { + const h = harness() + h.group.add('style.css') + h.group.add('style.css') + h.group.set(['style.css', 'script.js']) + h.group.set(['script.js']) + expect(h.release).toHaveBeenCalledOnce() + h.group.close() + h.group.close() + expect(h.release).toHaveBeenCalledTimes(2) + }) + + it('invalidates on failure and recovery without repeating an unchanged failure', () => { + const h = harness() + h.group.add('style.css') + h.version('v1') + const failed = { + ...h.state.getSnapshot(), status: 'failed' as const, + failure: new RemoteError('workspace-file/not-found', 'File missing', { path: 'style.css' }), + } + h.state.set(failed) + expect(h.changed).toHaveBeenCalledOnce() + h.state.set({ ...failed }) + expect(h.changed).toHaveBeenCalledOnce() + h.version('v1') + expect(h.changed).toHaveBeenCalledTimes(2) + }) +}) diff --git a/packages/client/ui-sidebar-documentpreview/tests/text-preview.client.spec.tsx b/packages/client/ui-sidebar-documentpreview/tests/text-preview.client.spec.tsx index 5352141578..335b276f90 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/text-preview.client.spec.tsx +++ b/packages/client/ui-sidebar-documentpreview/tests/text-preview.client.spec.tsx @@ -253,20 +253,59 @@ describe('TextPreview — pages', () => { }) it('says why a page failed and retries the same page', async () => { + const h = harness({ 1: failure('gateway/internal', { path: PATH }) }) + const view = render() + await settle() + const failed = view.container.querySelector('[data-textpreview-failed]') + expect(failed?.getAttribute('data-textpreview-failed')).toBe('gateway/internal') + expect(view.container.textContent).toContain('error.unavailable(message=boom)') + // Nothing read yet: the failure stands as the body, under the file's type sheet. + expect(failed?.querySelector('svg')).not.toBeNull() + expect(view.container.querySelector('[data-textpreview-more]')).toBeNull() + expect(view.container.querySelector('[data-slot="sidebar.right.tab.document.unpreviewable"]')).toBeNull() + h.script(1, page(1, ['one'], true)) + click(view.container, '[data-textpreview-retry]') + await settle() + expect(h.read).toHaveBeenLastCalledWith(SESSION, PATH, 1, h.controller.signal) + expect(lines(view.container)).toEqual(['one\n']) + expect(view.container.querySelector('[data-textpreview-failed]')).toBeNull() + }) + + it('hands a readable file it cannot render to the unpreviewable seat instead of Retry', async () => { const h = harness({ 1: failure('workspace-file/not-text', { path: PATH }) }) const view = render() await settle() const failed = view.container.querySelector('[data-textpreview-failed]') expect(failed?.getAttribute('data-textpreview-failed')).toBe('workspace-file/not-text') expect(view.container.textContent).toContain('error.notText') - // Nothing read yet: the failure stands as the body, under the file's type sheet. - expect(failed?.querySelector('svg')).not.toBeNull() - expect(view.container.querySelector('[data-textpreview-more]')).toBeNull() - h.script(1, page(1, ['one'], true)) - click(view.container, '[data-textpreview-retry]') + expect(view.container.querySelector('[data-textpreview-retry]')).toBeNull() + const seat = failed?.querySelector('[data-slot="sidebar.right.tab.document.unpreviewable"]') + expect(seat?.getAttribute('data-slot-path')).toBe(ABSOLUTE_PATH) + // The header's own handoff seat receives the same file. + const actions = view.container.querySelector('[data-slot="sidebar.right.tab.document.actions"]') + expect(actions?.getAttribute('data-slot-path')).toBe(ABSOLUTE_PATH) + }) + + it('offers neither Retry nor a handoff for a path with nothing to show', async () => { + const h = harness({ 1: failure('workspace-file/not-found', { path: PATH }) }) + const view = render() await settle() - expect(h.read).toHaveBeenLastCalledWith(SESSION, PATH, 1, h.controller.signal) - expect(lines(view.container)).toEqual(['one\n']) + expect(view.container.querySelector('[data-textpreview-failed]')?.getAttribute('data-textpreview-failed')).toBe('workspace-file/not-found') + expect(view.container.querySelector('[data-textpreview-retry]')).toBeNull() + expect(view.container.querySelector('[data-slot="sidebar.right.tab.document.unpreviewable"]')).toBeNull() + }) + + it('withholds the handoff seats until the file reports a Host path', async () => { + const h = harness({ 1: failure('workspace-file/not-text', { path: PATH }) }) + h.setVersion(undefined) + const view = render() + await settle() + expect(view.container.querySelector('[data-textpreview-failed]')).not.toBeNull() + expect(view.container.querySelector('[data-slot]')).toBeNull() + h.script(1, page(1, ['reloaded'], true)) + click(view.container, '[data-textpreview-tool="reload"]') + await settle() + expect(h.read).toHaveBeenCalledTimes(2) expect(view.container.querySelector('[data-textpreview-failed]')).toBeNull() }) @@ -287,11 +326,12 @@ describe('TextPreview — pages', () => { expect(view.container.querySelector('[data-textpreview-failed]')).toBeNull() }) - it('announces a change and, on request, re-reads the pages keeping the reader\'s place', async () => { + it('announces a change while automatic refresh is paused and reloads on request, keeping the reader\'s place', async () => { const h = harness({ 1: page(1, ['a', 'b'], true) }) const view = render() await settle() fireEvent.scroll(body(view.container), { target: { scrollTop: 50 } }) + click(view.container, '[data-textpreview-tool="auto-refresh"]') h.setVersion('v2') view.rerender() expect(view.container.querySelector('[data-textpreview-changed]')?.textContent).toContain('changed') @@ -307,10 +347,40 @@ describe('TextPreview — pages', () => { }) describe('TextPreview — the file\'s metadata', () => { + it('refreshes by default with the toggle hidden and catches up when automatic refresh resumes', async () => { + const h = harness({ 1: page(1, ['old'], true) }) + const view = render() + await settle() + const toggle = view.container.querySelector('[data-textpreview-tool="auto-refresh"]')! + expect(toggle.closest('[hidden]')).not.toBeNull() + expect(toggle.getAttribute('aria-pressed')).toBe('true') + expect(view.queryByRole('button', { name: 'autoRefresh' })).toBeNull() + expect(view.getByRole('button', { name: 'reload' })).toBeTruthy() + fireEvent.scroll(body(view.container), { target: { scrollTop: 40 } }) + h.script(1, page(1, ['updated'], true, 'v2')) + h.setVersion('v2') + view.rerender() + await settle() + expect(lines(view.container)).toEqual(['updated\n']) + expect(body(view.container).scrollTop).toBe(40) + expect(h.read).toHaveBeenCalledTimes(2) + fireEvent.click(toggle) + h.script(1, page(1, ['latest'], true, 'v3')) + h.setVersion('v3') + view.rerender() + expect(lines(view.container)).toEqual(['updated\n']) + expect(h.read).toHaveBeenCalledTimes(2) + fireEvent.click(toggle) + await settle() + expect(lines(view.container)).toEqual(['latest\n']) + expect(h.read).toHaveBeenCalledTimes(3) + }) + it('does not treat the observation present at read start as a later file change', async () => { const h = harness({ 1: page(1, ['newer read'], true, 'v2') }) const view = render() await settle() + click(view.container, '[data-textpreview-tool="auto-refresh"]') expect(h.instance.getSnapshot().byTab[TAB_ID]).toMatchObject({ version: 'v2', observedVersion: 'v1' }) expect(view.container.querySelector('[data-textpreview-changed]')).toBeNull() h.setVersion('v3') @@ -326,11 +396,12 @@ describe('TextPreview — the file\'s metadata', () => { expect(view.container.querySelector('[data-textpreview-changed]')).not.toBeNull() }) - it('announces metadata that changes while the first content read is still pending', async () => { + it('announces metadata that changes during the first read while automatic refresh is paused', async () => { const h = harness() const pending = Promise.withResolvers>() h.read.mockReturnValueOnce(pending.promise) const view = render() + click(view.container, '[data-textpreview-tool="auto-refresh"]') h.setVersion('v2') view.rerender() await act(async () => { pending.resolve(page(1, ['read v1'], true)); await pending.promise }) @@ -348,6 +419,8 @@ describe('TextPreview — the file\'s metadata', () => { const first = render() const second = render() await settle() + click(first.container, '[data-textpreview-tool="auto-refresh"]') + click(second.container, '[data-textpreview-tool="auto-refresh"]') h.setVersion('v2') first.rerender() second.rerender() diff --git a/packages/client/ui-sidebar-documentpreview/tsconfig.client.json b/packages/client/ui-sidebar-documentpreview/tsconfig.client.json index f1a8702766..c02235dfc2 100644 --- a/packages/client/ui-sidebar-documentpreview/tsconfig.client.json +++ b/packages/client/ui-sidebar-documentpreview/tsconfig.client.json @@ -64,6 +64,9 @@ }, { "path": "../../document/office-to-pdf" + }, + { + "path": "../ui-settings" } ] } diff --git a/packages/client/ui-sidebar-files/README.i18n.yaml b/packages/client/ui-sidebar-files/README.i18n.yaml index fefd4561f0..9edfb4b6cc 100644 --- a/packages/client/ui-sidebar-files/README.i18n.yaml +++ b/packages/client/ui-sidebar-files/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-files/README.md -README.md: efaf0a584e0e8617d0cb97442f9ebb5fa1fe7b59 -README.zh.md: a0d0abffcd6e9f4e979d9a6548537745d0a74ba6 +README.md: 491072c1daa95dc33cb268e7c825a95d738fae7c +README.zh.md: eceffb1c3ac0f7ba0437b23ee9e1ed6f905f5369 diff --git a/packages/client/ui-sidebar-files/README.md b/packages/client/ui-sidebar-files/README.md index efaf0a584e..491072c1da 100644 --- a/packages/client/ui-sidebar-files/README.md +++ b/packages/client/ui-sidebar-files/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -The right Sidebar's navigator tab type: the session's workspace root as a tree, listed one level at a time over the wire, opening files into the Sidebar. It is a page type reached from the guide and claims no address; it opens files by address for the `dsh-resource://file` viewers to claim — nothing in `ui-sidebar-right` knows this package. +Browse a Session's workspace tree and open files in Sidebar previews. The root and expanded directories refresh automatically from direct-entry watches; manual reload remains available. The tab is reached from the guide and claims no resource address. ## Table of Contents @@ -25,25 +25,27 @@ The right Sidebar's navigator tab type: the session's workspace root as a tree, ## What it registers - **The type** — `ctx.sidebarRightTabs.register(...)` with kind `files`, id `@deepseek-ai/dsh-client-ui-sidebar-files`, band `builtin`, no patterns, and one guide entry (order 10, its title and description from the `sidebarFiles` namespace, its glyph the shared folder icon) that opens the type. -- **The body** — the keyed `sidebar.right.pane.tab` seat under that id: a header row under the strip, then the tree. The header row is the document preview's (`ui-sidebar-documentpreview`): the root path, its directories greyed and its last segment in full ink, never ellipsized (a path wider than the row keeps its end and fades its start), with the one control, reload, at its right. The row is copied rather than shared because a plugin bundle shares runtime code only through the platform modules; once the artifact and slot surfaces settle, one copy in `ui-primitives` could serve every pane header. +- **The body** — the keyed `sidebar.right.pane.tab` seat under that id: a header row under the strip, then the tree. The header row is the document preview's (`ui-sidebar-documentpreview`): the root path, its directories greyed and its last segment in full ink, never ellipsized (a path wider than the row keeps its end and fades its start), with the visible reload control at its right. The row is copied rather than shared because a plugin bundle shares runtime code only through the platform modules; once the artifact and slot surfaces settle, one copy in `ui-primitives` could serve every pane header. - **The chip title** — the keyed `sidebar.right.pane.tab.title` seat under that id: a shared `FileTypeIcon` folder glyph at 16px before the type's label. The tree's own rows never draw this sheet. -Seven source files under `src/client/`: `definition.tsx` (the type), `store.ts` (what it keeps), `face.ts` (how it lists, Remote binding included), `FilesBody.tsx` (what it draws, with its ordering and failure-line helpers), `FilesTitle.tsx` (the chip title), `locales.ts` (what it says), and `index.ts` (the wiring). +Source files under `src/client/`: `definition.tsx` (the type), `store.ts` (what it keeps), `face.ts` (Remote reads and watches), `directory-node.ts` (open directories and their lifetimes), `FilesBody.tsx` (what it draws, with its ordering and failure-line helpers), `FilesTitle.tsx` (the chip title), `locales.ts` (what it says), and `index.ts` (the wiring). ## The tree -The root is the session's working directory, read from `useSessions().byId[sessionId].cwd`, and split for the header row by `pathPartsOf` from `@deepseek-ai/dsh-util-workspace-path`. Every level is keyed by absolute path; a child's path is its parent's joined with the entry name by `/`. A level is listed when it is first expanded, through `remote.workspaceFiles.list(sessionId, absolutePath)` on the `@deepseek-ai/dsh-api-workspace-files` namespace; the adapter keeps the listing's entries and truncation flag and drops its workspace-relative path. Rows are ordered directories first, then by natural, case-insensitive name; dotfiles are shown like any other entry. +The root is the session's working directory, read from `useSessions().byId[sessionId].cwd`, and split for the header row by `pathPartsOf` from `@deepseek-ai/dsh-util-workspace-path`. Filesystem roots such as `/` and Windows drive roots are valid tree roots. Every level is keyed by absolute path; a child's path is its parent's joined with the entry name by `/`. A level is listed when it is first expanded, through `remote.workspaceFiles.list(sessionId, absolutePath)` on the `@deepseek-ai/dsh-api-workspace-files` namespace; the adapter keeps the listing's entries and truncation flag and drops its workspace-relative path. Rows are ordered directories first, then by natural, case-insensitive name; dotfiles are shown like any other entry. | Entry type | Row | |---|---| -| `directory` | Toggles; the level is fetched the first time it opens and kept while collapsed. | +| `directory` | Toggles; reopening lists again and restores still-present expanded descendants. Displayed entries remain cached while collapsed. | | `file` | Opens `dsh-resource://file/session//`, built by `fileAddressFor` from `@deepseek-ai/dsh-util-workspace-path` from the entry's absolute path and the tree's root, through `useTabInfo().tab.actions.openResource`, landing in the tab's own pane. | | `other` | Shown greyed and not clickable, so the directory is reported whole. | -A level cut by the endpoint's entry cap ends with a marker; an empty level says so; a level that failed shows one line per code — `workspace-file/not-found`, `outside-workspace`, `not-directory` — and the transport's own message otherwise. Reload drops every listed level and asks again for the expanded ones; collapsed levels are fetched again when they next open. A session without a working directory shows a single line instead of a tree. +A level cut by the endpoint's entry cap ends with a marker; an empty level says so; a level that failed shows one line per code — `workspace-file/not-found`, `outside-workspace`, `not-directory` — and the transport's own message otherwise. Reload refreshes the root and expanded levels in place, retaining displayed entries during reads instead of resetting the whole tree; collapsed levels are fetched again when they next open. A session without a working directory shows a single line instead of a tree. -State lives in the type's own store, bucketed by tab id: `root`, `levels` (loading / ready / failed per absolute path), `expanded`, and `scrollTop`, which the body tracks locally while scrolling and commits once when it unmounts. Because the store outlives the body, switching to another sidebar tab and back remounts the tree with its levels intact and its scroll offset restored. The owner's `signal` ends a bucket: on abort the tab is forgotten, and neither a listing that settles afterwards nor the unmount's offset commit writes anything. +State lives in the type's own store, bucketed by tab id: `root`, `levels` (loading / ready / failed per absolute path), `expanded`, `autoRefresh`, and `scrollTop`, which the body tracks locally while scrolling and commits once when it unmounts. Because the store outlives the body, switching to another sidebar tab and back remounts the tree with its levels intact and its scroll offset restored. The owner's `signal` ends a bucket: on abort the tab is forgotten, and neither a listing that settles afterwards nor the unmount's offset commit writes anything. + +Each open `DirectoryNode` owns its target watch for the Tab lifetime; collapse closes that node and its hidden descendants. Expansion changes during ancestor restoration update the store and pending nodes, so restored descendants follow the latest expansion preferences. Automatic refresh defaults to enabled; its separate toggle is hidden while state, labels, styles, and toggle logic remain. Changes received during a directory read or its completion remain pending for another refresh. ## Model Experience @@ -57,7 +59,7 @@ None; directory listings travel over the Remote and assemble no model request. ## Known Limitations and Deferred Work -- **Listing only.** No search, artifact filter, drag-and-drop, rename, context menu, current-file highlight, or filesystem watching; a level changes only through reload. +- **Listing only.** No search, artifact filter, drag-and-drop, rename, context menu, or current-file highlight. - **One root.** The tree is rooted at the session's working directory; there is no way to browse above it, and the Host refuses paths outside the workspace root anyway. @@ -70,4 +72,4 @@ None.
-**Runtime invariant:** No companion is published. The tree's only runtime state is one Slot store per tab, written by the body that owns it and forgotten on the tab's abort signal; there is no second observation of it to compare against. +**Runtime invariant:** No companion is published. The tab-owned face keeps directory reads and watches private, and writes displayed state through its Slot store; tab cancellation releases both. The package exposes no independent observation for a runtime comparison. diff --git a/packages/client/ui-sidebar-files/README.zh.md b/packages/client/ui-sidebar-files/README.zh.md index a0d0abffcd..eceffb1c3a 100644 --- a/packages/client/ui-sidebar-files/README.zh.md +++ b/packages/client/ui-sidebar-files/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -右侧 Sidebar 的导航器 tab 类型:把会话的工作区根目录画成一棵树,逐层经线上列出,并把文件打开到 Sidebar 里。它是从引导页进入的页类型,不认领任何地址;它按地址打开文件,交给 `dsh-resource://file` 的查看器认领:`ui-sidebar-right` 里没有任何东西认识本包。 +浏览 Session 的工作区目录树,并在 Sidebar 预览中打开文件。根目录与已展开目录通过直接子项监听自动刷新,手动重新读取仍可用。该 tab 从引导页进入,不认领资源地址。 ## 目录 @@ -25,25 +25,27 @@ kind: "package-reference" ## 注册了什么 - **类型**:`ctx.sidebarRightTabs.register(...)`,kind 为 `files`,id 为 `@deepseek-ai/dsh-client-ui-sidebar-files`,档位 `builtin`,没有 patterns,另有一个打开该类型的引导页入口(order 10,标题与描述取自 `sidebarFiles` 命名空间,图标是共享的文件夹图标)。 -- **正文**:以该 id 为键的 `sidebar.right.pane.tab` slot:strip 下的一行标题行,然后是树。标题行与文档预览(`ui-sidebar-documentpreview`)的相同:根路径,目录部分灰色、最后一段正色,从不省略号截断(比行宽的路径保留末尾、淡出开头),右端是它唯一的控件、重新读取。这一行是复制而非共享,因为插件 bundle 只经平台模块共享运行时代码;待 artifact 与各 slot 的形态定下来后,可以在 `ui-primitives` 放一份供每个 pane 标题行使用。 +- **正文**:以该 id 为键的 `sidebar.right.pane.tab` slot:strip 下的一行标题行,然后是树。标题行与文档预览(`ui-sidebar-documentpreview`)的相同:根路径,目录部分灰色、最后一段正色,从不省略号截断(比行宽的路径保留末尾、淡出开头),右端显示重新读取控件。这一行是复制而非共享,因为插件 bundle 只经平台模块共享运行时代码;待 artifact 与各 slot 的形态定下来后,可以在 `ui-primitives` 放一份供每个 pane 标题行使用。 - **标签页标题**:以该 id 为键的 `sidebar.right.pane.tab.title` slot:类型标签前的一枚 16px 共享 `FileTypeIcon` 文件夹图标。树本身的行不画这枚图标。 -`src/client/` 下七个源文件:`definition.tsx`(类型是什么)、`store.ts`(它保存什么)、`face.ts`(它如何列目录,含 Remote 绑定)、`FilesBody.tsx`(它画什么,含排序与失败行两个辅助函数)、`FilesTitle.tsx`(标签页标题)、`locales.ts`(它说什么)、`index.ts`(接线)。 +`src/client/` 下的源文件:`definition.tsx`(类型是什么)、`store.ts`(它保存什么)、`face.ts`(Remote 读取与监听)、`directory-node.ts`(打开的目录及其生命周期)、`FilesBody.tsx`(它画什么,含排序与失败行两个辅助函数)、`FilesTitle.tsx`(标签页标题)、`locales.ts`(它说什么)、`index.ts`(接线)。 ## 树 -根是会话的工作目录,读自 `useSessions().byId[sessionId].cwd`,标题行里的拆分由 `@deepseek-ai/dsh-util-workspace-path` 的 `pathPartsOf` 给出。每一层以绝对路径为键;子路径是父路径以 `/` 拼上条目名。一层在首次展开时经 `@deepseek-ai/dsh-api-workspace-files` 命名空间的 `remote.workspaceFiles.list(sessionId, absolutePath)` 列出;适配器保留列表的条目与截断标志,丢弃其工作区相对路径。行序为目录优先,其后按自然序、不分大小写的名称排列;dotfiles 与其他条目一样显示。 +根是会话的工作目录,读自 `useSessions().byId[sessionId].cwd`,标题行里的拆分由 `@deepseek-ai/dsh-util-workspace-path` 的 `pathPartsOf` 给出。`/` 和 Windows 盘根等文件系统根路径均可作为树的根。每一层以绝对路径为键;子路径是父路径以 `/` 拼上条目名。一层在首次展开时经 `@deepseek-ai/dsh-api-workspace-files` 命名空间的 `remote.workspaceFiles.list(sessionId, absolutePath)` 列出;适配器保留列表的条目与截断标志,丢弃其工作区相对路径。行序为目录优先,其后按自然序、不分大小写的名称排列;dotfiles 与其他条目一样显示。 | 条目类型 | 行 | |---|---| -| `directory` | 切换展开与折叠;该层在首次打开时拉取,折叠期间保留。 | +| `directory` | 切换展开与折叠;再次打开时重新列举,并恢复仍存在的已展开后代。折叠期间保留已显示条目的缓存。 | | `file` | 经 `useTabInfo().tab.actions.openResource` 打开 `dsh-resource://file/session//`,地址由 `@deepseek-ai/dsh-util-workspace-path` 的 `fileAddressFor` 从条目的绝对路径与树的根生成,落在该 tab 自己的 pane 里。 | | `other` | 灰显且不可点击,从而完整呈现目录内容。 | -被端点条目上限截断的层以一条标记收尾;空层如实说明;失败的层按错误码各显示一行(`workspace-file/not-found`、`outside-workspace`、`not-directory`),其他情况显示传输层自己的消息。重新读取丢弃所有已列出的层并只对展开中的层重新请求;折叠的层在下次打开时重新拉取。没有工作目录的会话只显示一行说明,而不是树。 +被端点条目上限截断的层以一条标记收尾;空层如实说明;失败的层按错误码各显示一行(`workspace-file/not-found`、`outside-workspace`、`not-directory`),其他情况显示传输层自己的消息。重新读取就地刷新根与展开中的层,读取期间保留显示条目,不重置整棵树;折叠的层在下次打开时重新拉取。没有工作目录的会话只显示一行说明,而不是树。 -状态保存在类型自己的存储里,按 tab id 分桶:`root`、`levels`(每个绝对路径的 loading / ready / failed)、`expanded` 与 `scrollTop`——滚动期间偏移由正文自己记录,卸载时一次性写入。存储比 body 活得久,切到其他侧栏 tab 再切回来时树带着已加载的层重新挂载,滚动位置也随之恢复。owner 的 `signal` 终结一个桶:中止时忘掉该 tab,其后才结算的列表与卸载时的偏移提交都什么也不写。 +状态保存在类型自己的存储里,按 tab id 分桶:`root`、`levels`(每个绝对路径的 loading / ready / failed)、`expanded`、`autoRefresh` 与 `scrollTop`——滚动期间偏移由正文自己记录,卸载时一次性写入。存储比 body 活得久,切到其他侧栏 tab 再切回来时树带着已加载的层重新挂载,滚动位置也随之恢复。owner 的 `signal` 终结一个桶:中止时忘掉该 tab,其后才结算的列表与卸载时的偏移提交都什么也不写。 + +每个打开的 `DirectoryNode` 在 Tab 生命周期内持有自己的目标监听;折叠关闭该节点及其不可见后代。祖先恢复期间的展开状态变化会更新存储和待恢复节点,后代恢复时遵循最新展开偏好。自动刷新默认开启;独立开关暂时隐藏,状态、文案、样式与切换逻辑保留。目录读取期间或收尾时收到的变化仍会留待下一次刷新。 ## 模型体验 @@ -57,7 +59,7 @@ kind: "package-reference" ## 已知限制与暂缓事项 -- **只有列目录。**没有搜索、产物过滤、拖拽、重命名、右键菜单、当前文件高亮或文件系统监听;一层只会因重新读取而变化。 +- **只有列目录。**没有搜索、产物过滤、拖拽、重命名、右键菜单或当前文件高亮。 - **只有一个根。**树以会话工作目录为根;没有办法浏览到它之上,而 Host 本来也拒绝工作区根之外的路径。 @@ -70,4 +72,4 @@ kind: "package-reference"
-**运行时不变量:** 不发布 companion。树唯一的运行时状态是每 tab 一份的 Slot store,由持有它的正文写入、随 tab 的中止信号忘掉;没有第二个观测源可与之比对。 +**运行时不变量:** 不发布 companion。tab 持有的 face 内部管理目录读取与监听,并通过 Slot store 写入显示状态;tab 取消时释放二者。本包未暴露可用于运行时比对的独立观测源。 diff --git a/packages/client/ui-sidebar-files/package.json b/packages/client/ui-sidebar-files/package.json index 3c0c62be27..20d74ac18c 100644 --- a/packages/client/ui-sidebar-files/package.json +++ b/packages/client/ui-sidebar-files/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-sidebar-files", "description": "Workspace file tree tab type for the right Sidebar: lazy directory listing over the workspaceFiles Remote namespace, opening files into the Sidebar", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, @@ -58,6 +58,7 @@ "@deepseek-ai/dsh-client-ui-sidebar-right": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/dsh-util-workspace-path": "workspace:^", "@testing-library/react": "^16.1.0", "@types/react": "~18.3.1", diff --git a/packages/client/ui-sidebar-files/src/client/FilesBody.module.css b/packages/client/ui-sidebar-files/src/client/FilesBody.module.css index a49f6688a3..a069eec6a8 100644 --- a/packages/client/ui-sidebar-files/src/client/FilesBody.module.css +++ b/packages/client/ui-sidebar-files/src/client/FilesBody.module.css @@ -201,6 +201,11 @@ background: var(--dsw-alias-interactive-bg-hover); } +.tool[aria-pressed='true'] { + color: var(--dsw-alias-label-primary); + background: var(--dsw-alias-interactive-bg-hover); +} + /* The folder sheet before the chip's text; the chip's title row centres it on the text's line and spaces it. */ .titleIcon { diff --git a/packages/client/ui-sidebar-files/src/client/FilesBody.tsx b/packages/client/ui-sidebar-files/src/client/FilesBody.tsx index 7a8c70f501..3b707f94a4 100644 --- a/packages/client/ui-sidebar-files/src/client/FilesBody.tsx +++ b/packages/client/ui-sidebar-files/src/client/FilesBody.tsx @@ -7,8 +7,7 @@ * file opens through the owner's `tabActions` for a `file:` viewer to claim, and * anything else is shown but refuses to open. The header row is the text * preview's: the root's path, directories greyed and the last segment in full - * ink, then the one control at its end, reload, which drops every listed level - * and asks again for the expanded ones. + * ink, then reload, which refreshes the expanded directories in place. */ import { useEffect, useLayoutEffect, useRef } from 'react' import type { ReactNode, RefObject } from 'react' @@ -16,7 +15,8 @@ import clsx from 'clsx' import type { RemoteFailure } from '@deepseek-ai/dsh-api-remotes/client' import type { PropsLocale, PropsRuntime, PropsStore, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' import { - FileTypeIcon, IconFolderClose16, IconFolderOpen16, IconRefreshOutline16, classifyFileType, + FileTypeIcon, IconFolderCloseRegular, IconFolderOpenRegular, IconRefreshOutlineRegular, classifyFileType, + IconPauseOutlineRegular, IconPlayOutlineRegular, } from '@deepseek-ai/dsh-client-ui-primitives' import { fileAddressFor, pathPartsOf } from '@deepseek-ai/dsh-util-workspace-path' import type { WorkspaceDirectoryEntry } from '@deepseek-ai/dsh-api-workspace-files/types' @@ -103,7 +103,7 @@ function usePathClipped( /** What every level shares: the tab's tree and the two gestures. */ interface TreeContext { readonly state: FilesTabState - readonly onToggle: (path: string) => void + readonly onToggle: (parent: string, path: string) => void readonly onOpen: (path: string) => void readonly t: TranslateNS<'sidebarFiles'> } @@ -115,8 +115,8 @@ function Entry({ parent, entry, tree }: { parent: string; entry: WorkspaceDirect const expanded = tree.state.expanded.includes(path) return (
  • - {expanded &&
    } @@ -159,6 +159,7 @@ function Level({ path, tree }: { path: string; tree: TreeContext }): ReactNode { const entries = orderEntries(level.level.entries) return ( <> + {level.failure !== undefined &&
  • {failureLine(t, level.failure)}
  • } {entries.length === 0 &&
  • {t('empty')}
  • } {entries.map(entry => )} {level.level.truncated &&
  • {t('truncated')}
  • } @@ -168,7 +169,7 @@ function Level({ path, tree }: { path: string; tree: TreeContext }): ReactNode { /** The file tree's body: the workspace root and whatever the reader has opened under it. */ export function FilesBody({ - useTabInfo, sessionId, useSessions, useStore, actions, start, load, toggle, t, + useTabInfo, sessionId, useSessions, useStore, actions, start, refresh, setAutoRefresh, toggle, t, }: FilesBodyProps): ReactNode { const { tab } = useTabInfo() const { signal, actions: tabActions } = tab @@ -213,16 +214,13 @@ export function FilesBody({ if (state === undefined) return null const tree: TreeContext = { state, - onToggle: (path) => { toggle(tab.id, path, state.levels[path] !== undefined, signal) }, + onToggle: (parent, path) => { toggle(tab.id, parent, path, state.expanded, signal) }, // Every row is under the tree's root, so its address is session-relative. onOpen: (path) => { tabActions.openResource(fileAddressFor(sessionId, state.root, path)) }, t, } - // Reload drops every level and asks again for the expanded ones; a collapsed - // level is fetched again the next time it opens. const reload = (): void => { - actions.reset(tab.id) - for (const path of state.expanded) load(tab.id, path, signal) + refresh(tab.id) } const { directory, name } = pathPartsOf(state.root) return ( @@ -235,6 +233,14 @@ export function FilesBody({ {name} + {/* jscpd:ignore-end */} diff --git a/packages/client/ui-sidebar-files/src/client/directory-node.ts b/packages/client/ui-sidebar-files/src/client/directory-node.ts new file mode 100644 index 0000000000..fd5ebd7410 --- /dev/null +++ b/packages/client/ui-sidebar-files/src/client/directory-node.ts @@ -0,0 +1,156 @@ +/** Watch and read ownership for the Files panel's expanded directory tree. */ +import type { DirLevel } from './store.ts' + +/** One open directory and its active child nodes; cached view preferences live in the store. */ +export class DirectoryNode { + /** Open direct-child directories, keyed by absolute path. */ + readonly children = new Map() + private readonly controller = new AbortController() + private readonly signal: AbortSignal + private task: Promise | undefined + private reading: Promise | undefined + private dirty = false + private initialized = false + private automatic = true + + constructor( + readonly path: string, + private readonly load: (path: string, signal: AbortSignal) => Promise, + private readonly watch: (path: string, signal: AbortSignal) => AsyncIterable<'ready' | 'change'>, + private readonly failed: (path: string, error: unknown) => void, + lifetime: AbortSignal, + private restore: readonly string[] = [], + ) { + this.signal = AbortSignal.any([lifetime, this.controller.signal]) + } + + /** + * Start observation once; readiness triggers the initial listing. + * @returns this node. + */ + open(): this { + this.task ??= this.follow() + return this + } + + /** + * Find an active node in this subtree. + * @param path - absolute directory path. + * @returns the matching node, or undefined when that directory is closed. + */ + find(path: string): DirectoryNode | undefined { + if (path === this.path) return this + for (const child of this.children.values()) { + const found = child.find(path) + if (found !== undefined) return found + } + return undefined + } + + /** + * Update the expansion preferences used by pending directory listings. + * @param expanded - latest expansion preferences from the store. + */ + setExpanded(expanded: readonly string[]): void { + this.restore = expanded + for (const child of this.children.values()) child.setExpanded(expanded) + } + + /** + * Open a direct child using this node's lifetime and automatic-refresh setting. + * @param path - absolute direct-child directory path. + * @param restore - descendant expansion preferences to restore after listing. + * @returns the active child, or undefined after cancellation. + */ + expand(path: string, restore: readonly string[] = []): DirectoryNode | undefined { + if (this.signal.aborted) return undefined + let child = this.children.get(path) + if (child === undefined) { + child = new DirectoryNode(path, this.load, this.watch, this.failed, this.signal, restore) + child.automatic = this.automatic + this.children.set(path, child) + } + return child.open() + } + + /** + * Remove a child and its pending restoration preferences. + * @param path - absolute direct-child directory path. + * @returns once the child subtree's reads and watches have ended. + */ + async collapse(path: string): Promise { + this.restore = this.restore.filter(value => value !== path && !value.startsWith(`${path}/`)) + const child = this.children.get(path) + this.children.delete(path) + await child?.close() + } + + /** + * Control subtree rereads without ending subscriptions. + * @param enabled - refresh dirty nodes automatically. + */ + setAutomatic(enabled: boolean): void { + this.automatic = enabled + if (enabled && this.dirty) void this.refresh() + for (const child of this.children.values()) child.setAutomatic(enabled) + } + + /** Queue a reread, coalescing with active work. @returns completion of the active read and its coalesced rereads. */ + refresh(): Promise { + this.dirty = true + this.reading ??= this.read() + return this.reading + } + + /** Refresh this node and its open descendants. @returns once their listings settle. */ + async refreshTree(): Promise { + await this.refresh() + await Promise.all([...this.children.values()].map(child => child.refreshTree())) + } + + /** Cancel the active subtree. @returns once all owned reads and watches have ended. */ + async close(): Promise { + this.controller.abort() + await Promise.all([this.task, this.reading, ...[...this.children.values()].map(child => child.close())]) + this.children.clear() + } + + private async follow(): Promise { + try { + for await (const _event of this.watch(this.path, this.signal)) { + this.dirty = true + if (!this.initialized || this.automatic) void this.refresh() + } + } catch (error) { + if (!this.signal.aborted) { + if (!this.initialized) await this.refresh() + if (!(typeof error === 'object' && error !== null && 'code' in error && error.code === 'workspace-file/watch-unsupported')) { + this.failed(this.path, error) + } + } + } + } + + private async read(): Promise { + const readAgain = (): boolean => this.dirty && this.automatic && !this.signal.aborted + try { + do { + this.dirty = false + const level = await this.load(this.path, this.signal) + if (this.signal.aborted || level === undefined) return + this.initialized = true + const directories = new Set(level.entries.filter(entry => entry.type === 'directory') + .map(entry => `${this.path.replace(/[/\\]+$/, '')}/${entry.name}`)) + for (const path of this.children.keys()) { + if (!directories.has(path)) await this.collapse(path) + } + for (const path of directories) { + if (this.restore.includes(path)) this.expand(path, this.restore) + } + this.restore = [] + } while (readAgain()) + } finally { + this.reading = undefined + } + } +} diff --git a/packages/client/ui-sidebar-files/src/client/face.ts b/packages/client/ui-sidebar-files/src/client/face.ts index a170e897ce..f1a5c13d96 100644 --- a/packages/client/ui-sidebar-files/src/client/face.ts +++ b/packages/client/ui-sidebar-files/src/client/face.ts @@ -1,7 +1,7 @@ /** * The tree's asynchronous half: listing directories into the store. * - * The component never awaits anything. It calls `start` / `load` / `toggle`, and + * The component never awaits anything. It calls `start` / `refresh` / `toggle`, and * this face performs the listing and writes the outcome through the store's own * actions — the Slot-standard `inject` shape, so the session id is resolved by * the framework and the write set stays the store's. @@ -23,6 +23,47 @@ import type { BoundActions } from '@deepseek-ai/dsh-client-store' import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { DirLevel, createFilesStore } from './store.ts' +import type { WorkspaceFileWatchFrame } from '@deepseek-ai/dsh-api-workspace-files/types' +import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' +import { DirectoryNode } from './directory-node.ts' + +/** + * Observe one directory without recursively watching its descendants. + * @param sessionId - Session owning the directory tree. + * @param path - absolute directory path. + * @param signal - node lifetime. + * @returns readiness and invalidation notifications. + */ +export type WatchWorkspaceDirectory = (sessionId: SessionId, path: string, signal: AbortSignal) => AsyncIterable<'ready' | 'change'> + +/** + * Bind directory observation to the Remote stream supervisor. + * @param remote - Client Remote with workspace file streams. + * @returns a watcher that awaits stream disposal when its node ends. + */ +export function createWatch(remote: ClientRemote): WatchWorkspaceDirectory { + return async function* (sessionId, path, signal) { + const aborted = (): boolean => signal.aborted + if (aborted()) return + const stream = remote.$stream({ + name: `directory ${path}`, + open: lifetime => remote.workspaceFiles.changes(sessionId, path, lifetime), + ended: () => new Error(`Directory watch ended: ${path}`), + }) + const abort = (): void => { void stream.dispose() } + signal.addEventListener('abort', abort, { once: true }) + try { + for await (const item of stream) { + if (aborted()) return + if (item.value.kind === 'ready') item.accept() + yield item.value.kind + } + } finally { + signal.removeEventListener('abort', abort) + await stream.dispose() + } + } +} /** * One directory listing, bound to a Remote face. @@ -73,6 +114,10 @@ export function childPath(parent: string, name: string): string { /** The tree's injected business face, as the body receives it. */ export interface FilesInjected { + /** Refresh the open directory tree. @param tabId - owning tab. */ + readonly refresh: (tabId: TabId) => void + /** Control automatic rereads without closing watches. @param tabId - owning tab. @param enabled - automatic-refresh setting. */ + readonly setAutoRefresh: (tabId: TabId, enabled: boolean) => void /** * Seed this tab's tree and list its root. * @param tabId - the tab being drawn. @@ -88,22 +133,25 @@ export interface FilesInjected { */ readonly load: (tabId: TabId, path: string, signal: AbortSignal) => void /** - * Open or collapse one directory, listing it the first time it opens. + * Open or collapse one directory, retaining intent during ancestor restoration. * @param tabId - the tab being drawn. + * @param parentPath - the listed parent directory's exact tree key. * @param path - absolute directory path. - * @param loaded - whether this level already has state. + * @param expanded - current expansion preferences, including descendants to restore. * @param signal - the tab record's lifetime. */ - readonly toggle: (tabId: TabId, path: string, loaded: boolean, signal: AbortSignal) => void + readonly toggle: (tabId: TabId, parentPath: string, path: string, expanded: readonly string[], signal: AbortSignal) => void } /** * Bind the tree's face to one directory listing. * @param list - the bound `workspaceFiles.list` call. + * @param watch - target-scoped directory observation. * @returns the Slot `inject` factory: session and bound actions in, face out. */ export function filesFace( list: ListWorkspaceDirectory, + watch: WatchWorkspaceDirectory, ): (sessionId: SessionId, actions: BoundActions>) => FilesInjected { return ( sessionId: SessionId, @@ -111,6 +159,7 @@ export function filesFace( ): FilesInjected => { /** Per tab, per absolute path: the listing generation a settlement must match; the latest request wins. */ const generations = new Map>() + const roots = new Map() const nextGeneration = (tabId: TabId, path: string): number => { const byPath = generations.get(tabId) ?? new Map() generations.set(tabId, byPath) @@ -118,31 +167,54 @@ export function filesFace( byPath.set(path, generation) return generation } - const load = (tabId: TabId, path: string, signal: AbortSignal): void => { + const load = async (tabId: TabId, path: string, signal: AbortSignal): Promise => { if (signal.aborted) return const generation = nextGeneration(tabId, path) actions.loading(tabId, path) - void list(sessionId, path, signal).then((result) => { + return list(sessionId, path, signal).then((result) => { // A newer listing of this level was asked for since, or the record is // gone and its bookkeeping with it: nothing left for this one to write. - if (generations.get(tabId)?.get(path) !== generation) return + if (signal.aborted || generations.get(tabId)?.get(path) !== generation) return if (result.ok) actions.loaded(tabId, path, result.value) else actions.failed(tabId, path, result.error) + return result.ok ? result.value : undefined }) } return { + refresh: (tabId) => { void roots.get(tabId)?.refreshTree() }, + setAutoRefresh: (tabId, enabled) => { + actions.autoRefresh(tabId, enabled) + roots.get(tabId)?.setAutomatic(enabled) + }, start(tabId, root, signal) { actions.start(tabId, root) signal.addEventListener('abort', () => { + void roots.get(tabId)?.close() + roots.delete(tabId) generations.delete(tabId) actions.forget(tabId) }, { once: true }) - load(tabId, root, signal) + roots.set(tabId, new DirectoryNode(root, + (path, lifetime) => load(tabId, path, lifetime), + (path, lifetime) => watch(sessionId, path, lifetime), + (path, error) => { + if (!signal.aborted) actions.failed(tabId, path, new RemoteError('gateway/internal', error instanceof Error ? error.message : String(error), {})) + }, signal, + ).open()) }, - load, - toggle(tabId, path, loaded, signal) { + load: (tabId, path, signal) => { void load(tabId, path, signal) }, + toggle(tabId, parentPath, path, expanded, signal) { + if (signal.aborted) return + const root = roots.get(tabId) + if (root === undefined) return + const parent = root.find(parentPath) + if (parent === undefined && !expanded.includes(parentPath)) return + const collapsing = expanded.includes(path) + const next = collapsing ? expanded.filter(value => value !== path) : [...expanded, path] + root.setExpanded(next) + if (collapsing) void parent?.collapse(path) + else parent?.expand(path, next) actions.toggled(tabId, path) - if (!loaded) load(tabId, path, signal) }, } } diff --git a/packages/client/ui-sidebar-files/src/client/index.ts b/packages/client/ui-sidebar-files/src/client/index.ts index 572f5583b5..5a9dd0cee4 100644 --- a/packages/client/ui-sidebar-files/src/client/index.ts +++ b/packages/client/ui-sidebar-files/src/client/index.ts @@ -16,7 +16,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import type {} from '@deepseek-ai/dsh-client-ui-session/client' import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client' import { FILES_ID, filesDefinition } from './definition.tsx' -import { createList, filesFace } from './face.ts' +import { createList, createWatch, filesFace } from './face.ts' import { FilesBody } from './FilesBody.tsx' import { FilesTitle } from './FilesTitle.tsx' import { en, zh } from './locales.ts' @@ -46,7 +46,7 @@ export function apply(ctx: ClientContext): void { ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-sidebar-files: dictionaries') const store = createFilesStore() - const inject = filesFace(createList(ctx.remote)) + const inject = filesFace(createList(ctx.remote), createWatch(ctx.remote)) ctx.effect(() => ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register( { name: 'sidebar.right.pane.tab', key: FILES_ID, locale: NS, store, inject }, FilesBody, diff --git a/packages/client/ui-sidebar-files/src/client/locales.ts b/packages/client/ui-sidebar-files/src/client/locales.ts index b88e6bab40..a0af20c3c0 100644 --- a/packages/client/ui-sidebar-files/src/client/locales.ts +++ b/packages/client/ui-sidebar-files/src/client/locales.ts @@ -28,6 +28,9 @@ export const zh = { truncated: '条目太多,只显示了一部分。', noWorkspace: '这个会话没有工作区目录。', reload: '重新读取', + autoRefresh: '自动刷新', + 'autoRefresh.enable': '开启自动刷新', + 'autoRefresh.disable': '关闭自动刷新', 'entry.other': '这不是文件或目录,没法打开。', 'error.notFound': '这个目录不在了。可能已被移动或删除。', 'error.outsideWorkspace': '这个目录在工作区之外,侧栏不会读取它。', @@ -48,6 +51,9 @@ export const en = { truncated: 'Too many entries, showing only some of them.', noWorkspace: 'This session has no workspace directory.', reload: 'Reload', + autoRefresh: 'Auto refresh', + 'autoRefresh.enable': 'Enable auto refresh', + 'autoRefresh.disable': 'Disable auto refresh', 'entry.other': 'Not a file or a directory, so it cannot be opened.', 'error.notFound': 'That directory is gone. It may have been moved or deleted.', 'error.outsideWorkspace': 'That directory is outside the workspace, so the sidebar will not read it.', diff --git a/packages/client/ui-sidebar-files/src/client/store.ts b/packages/client/ui-sidebar-files/src/client/store.ts index 468b8b17de..897a04a46f 100644 --- a/packages/client/ui-sidebar-files/src/client/store.ts +++ b/packages/client/ui-sidebar-files/src/client/store.ts @@ -31,7 +31,7 @@ export interface DirLevel { /** What one directory level is doing right now. */ export type LevelState = | { readonly kind: 'loading' } - | { readonly kind: 'ready'; readonly level: DirLevel } + | { readonly kind: 'ready'; readonly level: DirLevel; readonly failure?: RemoteFailure } | { readonly kind: 'failed'; readonly failure: RemoteFailure } /** @@ -41,6 +41,7 @@ export type LevelState = * the Host reports it, and a child is the parent joined with the entry name. */ export interface FilesTabState { + autoRefresh: boolean /** Absolute path of the workspace root this tree is rooted at. */ root: string /** Level state by absolute directory path; a path absent here was never asked for. */ @@ -71,6 +72,7 @@ function bucket(state: FilesState, tabId: TabId): FilesTabState { /** The tree store's write set; every action names the tab it writes. */ type FilesActions = { + autoRefresh: (draft: FilesState, tabId: TabId, enabled: boolean) => void start: (draft: FilesState, tabId: TabId, root: string) => void loading: (draft: FilesState, tabId: TabId, path: string) => void loaded: (draft: FilesState, tabId: TabId, path: string, level: DirLevel) => void @@ -92,6 +94,7 @@ export function createFilesStore(): EngineStoreHandle return defineStore({ init: (): FilesState => ({ byTab: {} }), actions: { + autoRefresh: (d, tabId: TabId, enabled: boolean) => { bucket(d, tabId).autoRefresh = enabled }, /** * Seed one tab's tree at its workspace root, with the root expanded. * @param d - draft state. @@ -99,7 +102,7 @@ export function createFilesStore(): EngineStoreHandle * @param root - absolute path of the workspace root. */ start: (d, tabId: TabId, root: string) => { - d.byTab[tabId] = { root, levels: {}, expanded: [root], scrollTop: 0 } + d.byTab[tabId] = { root, levels: {}, expanded: [root], scrollTop: 0, autoRefresh: true } }, /** * Mark one directory as being listed. @@ -108,7 +111,8 @@ export function createFilesStore(): EngineStoreHandle * @param path - absolute directory path. */ loading: (d, tabId: TabId, path: string) => { - bucket(d, tabId).levels[path] = { kind: 'loading' } + const state = bucket(d, tabId) + if (state.levels[path]?.kind !== 'ready') state.levels[path] = { kind: 'loading' } }, /** * Record one directory's contents. @@ -118,7 +122,19 @@ export function createFilesStore(): EngineStoreHandle * @param level - the listing to show under it. */ loaded: (d, tabId: TabId, path: string, level: DirLevel) => { - bucket(d, tabId).levels[path] = { kind: 'ready', level } + const state = bucket(d, tabId) + const previous = state.levels[path] + if (previous?.kind === 'ready') { + const directories = new Set(level.entries.filter(entry => entry.type === 'directory').map(entry => entry.name)) + for (const entry of previous.level.entries) { + if (entry.type !== 'directory' || directories.has(entry.name)) continue + const removed = `${path.replace(/[/\\]+$/, '')}/${entry.name}` + state.expanded = state.expanded.filter(value => value !== removed && !value.startsWith(`${removed}/`)) + state.levels = Object.fromEntries(Object.entries(state.levels) + .filter(([key]) => key !== removed && !key.startsWith(`${removed}/`))) + } + } + state.levels[path] = { kind: 'ready', level } }, /** * Record why one directory could not be listed. @@ -128,7 +144,10 @@ export function createFilesStore(): EngineStoreHandle * @param failure - the settled Remote failure. */ failed: (d, tabId: TabId, path: string, failure: RemoteFailure) => { - bucket(d, tabId).levels[path] = { kind: 'failed', failure } + const level = bucket(d, tabId).levels[path] + bucket(d, tabId).levels[path] = level?.kind === 'ready' + ? { ...level, failure } + : { kind: 'failed', failure } }, /** * Open a collapsed directory, or collapse an open one. diff --git a/packages/client/ui-sidebar-files/tests/directory-node.client.spec.ts b/packages/client/ui-sidebar-files/tests/directory-node.client.spec.ts new file mode 100644 index 0000000000..98026478c3 --- /dev/null +++ b/packages/client/ui-sidebar-files/tests/directory-node.client.spec.ts @@ -0,0 +1,304 @@ +/** Directory-node lifecycle tests with controlled stream delivery and deferred reads. */ +import { describe, expect, it, onTestFinished, vi } from 'vitest' +import type { MockInstance } from 'vitest' +import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { DirectoryNode } from '../src/client/directory-node.ts' +import type { DirLevel } from '../src/client/store.ts' +import { scriptedList } from './scripted-list.client.ts' +import type { ScriptedList } from './scripted-list.client.ts' +import type { DirectoryWatch } from './scripted-watch.client.ts' + +const SESSION = 'directory-node-session' as SessionId +const ROOT = '/work/app' +const SRC = `${ROOT}/src` +const COMPONENTS = `${SRC}/components` +const EMPTY: DirLevel = { entries: [], truncated: false } +const ROOT_LEVEL: DirLevel = { entries: [{ name: 'src', type: 'directory' }], truncated: false } +const SRC_LEVEL: DirLevel = { entries: [{ name: 'components', type: 'directory' }], truncated: false } + +function mount(path = ROOT, restore: readonly string[] = []) { + const script = scriptedList() + const lifetime = new AbortController() + const reported = Promise.withResolvers() + const failed = vi.fn((path: string, error: unknown) => { reported.resolve([path, error]) }) + const node = new DirectoryNode(path, async (path, signal) => { + const result = await script.list(SESSION, path, signal) + return result.ok ? result.value : undefined + }, (path, signal) => script.watch(SESSION, path, signal), failed, lifetime.signal, restore) + onTestFinished(async () => { + const closing = node.close() + lifetime.abort() + await script.dispose() + await closing + }) + node.open() + return { node, script, failed, reported, lifetime } +} + +function readCompletion(refresh: MockInstance, index = refresh.mock.results.length - 1): Promise { + const result = refresh.mock.results[index] + if (result?.type !== 'return') throw new Error('expected the event to start a directory refresh') + return result.value +} + +/** The refresh promise includes child reconciliation, which follows the deferred list response. */ +async function watchedRead(node: DirectoryNode, script: ScriptedList, event: 'ready' | 'change' = 'ready', occurrence = 0) { + const refresh = vi.spyOn(node, 'refresh') + const index = refresh.mock.calls.length + const stream = await script.watches.forPath(node.path, occurrence) + await stream.deliver(event) + return { stream, finished: readCompletion(refresh, index) } +} + +async function listReady(node: DirectoryNode, script: ScriptedList, level: DirLevel): Promise { + const read = await watchedRead(node, script) + await script.settle({ ok: true, value: level }) + await read.finished + return read.stream +} + +describe('DirectoryNode', () => { + it('rereads an invalidation arriving between read completion and refresh cleanup', async () => { + const pending = Promise.withResolvers() + const load = vi.fn().mockReturnValueOnce(pending.promise).mockResolvedValue(EMPTY) + const node = new DirectoryNode(ROOT, load, async function* () {}, vi.fn(), new AbortController().signal) + onTestFinished(() => node.close()) + const reading = node.refresh() + pending.resolve(EMPTY) + queueMicrotask(() => { void node.refresh() }) + await reading + expect(load).toHaveBeenCalledTimes(2) + }) + + it('opens one watch per expanded directory and waits for each ready before its first list', async () => { + const { node, script } = mount() + expect(node.open()).toBe(node) + expect(script.watches.opened.map(stream => stream.path)).toEqual([ROOT]) + expect(script.list).not.toHaveBeenCalled() + await listReady(node, script, ROOT_LEVEL) + expect(node.children.size).toBe(0) + + const child = node.expand(SRC)! + expect(node.expand(SRC)).toBe(child) + expect(node.find(SRC)).toBe(child) + expect(node.find(COMPONENTS)).toBeUndefined() + expect(script.list).toHaveBeenCalledTimes(1) + const stream = await listReady(child, script, SRC_LEVEL) + expect(script.list).toHaveBeenLastCalledWith(SESSION, SRC, stream.signal) + expect(script.watches.opened.map(stream => stream.path)).toEqual([ROOT, SRC]) + expect(child.children.size).toBe(0) + }) + + it('collapsing a directory aborts its subtree and awaits every watch release', async () => { + const { node, script } = mount() + const rootStream = await listReady(node, script, ROOT_LEVEL) + const child = node.expand(SRC)! + const childStream = await listReady(child, script, SRC_LEVEL) + const grandchild = child.expand(COMPONENTS)! + const grandchildStream = await listReady(grandchild, script, EMPTY) + const childRelease = childStream.holdRelease() + const grandchildRelease = grandchildStream.holdRelease() + let collapsed = false + const closing = node.collapse(SRC).then(() => { collapsed = true }) + expect(node.find(SRC)).toBeUndefined() + expect(childStream.signal.aborted).toBe(true) + expect(grandchildStream.signal.aborted).toBe(true) + await Promise.all([childStream.releasing.promise, grandchildStream.releasing.promise]) + expect(collapsed).toBe(false) + childRelease.resolve(undefined) + await childStream.released.promise + expect(collapsed).toBe(false) + grandchildRelease.resolve(undefined) + await closing + expect(rootStream.signal.aborted).toBe(false) + expect(child.children.size).toBe(0) + expect(script.list).toHaveBeenCalledTimes(3) + }) + + it('does not restore a child collapsed while the reopened parent is still listing', async () => { + const sibling = `${SRC}/components-other` + const descendant = `${COMPONENTS}/nested` + const { node, script } = mount(SRC, [COMPONENTS, descendant, sibling]) + const read = await watchedRead(node, script) + expect(script.outstanding()).toEqual([SRC]) + expect(node.children.size).toBe(0) + await node.collapse(COMPONENTS) + await script.settle({ ok: true, value: { + entries: [{ name: 'components', type: 'directory' }, { name: 'components-other', type: 'directory' }], + truncated: false, + } }) + await read.finished + expect([...node.children.keys()]).toEqual([sibling]) + expect(script.watches.opened.map(stream => stream.path)).toEqual([SRC, sibling]) + + const reopened = node.expand(COMPONENTS)! + await listReady(reopened, script, { entries: [{ name: 'nested', type: 'directory' }], truncated: false }) + expect(reopened.children.size).toBe(0) + expect(script.watches.opened.some(stream => stream.path === descendant)).toBe(false) + }) + + it.each(['removed', 'file'] as const)('closes a child subtree when its parent lists it as %s', async (replacement) => { + const { node, script } = mount() + const rootStream = await listReady(node, script, ROOT_LEVEL) + const child = node.expand(SRC)! + const childStream = await listReady(child, script, SRC_LEVEL) + const grandchildStream = await listReady(child.expand(COMPONENTS)!, script, EMPTY) + const read = await watchedRead(node, script, 'change') + await script.settle({ ok: true, value: { + entries: replacement === 'file' ? [{ name: 'src', type: 'file' }] : [], + truncated: false, + } }) + await read.finished + expect(node.children.size).toBe(0) + expect(childStream.signal.aborted).toBe(true) + expect(grandchildStream.signal.aborted).toBe(true) + await Promise.all([childStream.released.promise, grandchildStream.released.promise]) + expect(rootStream.signal.aborted).toBe(false) + }) + + it('coalesces notifications during a list into one subsequent read', async () => { + const { node, script } = mount() + const read = await watchedRead(node, script) + await Promise.all([read.stream.deliver('change'), read.stream.deliver('change')]) + expect(script.list).toHaveBeenCalledTimes(1) + expect(script.outstanding()).toEqual([ROOT]) + await script.settle({ ok: true, value: ROOT_LEVEL }) + expect((await script.waitForList(1)).path).toBe(ROOT) + expect(script.outstanding()).toEqual([ROOT]) + await script.settle({ ok: true, value: EMPTY }) + await read.finished + expect(script.list).toHaveBeenCalledTimes(2) + expect(script.watches.opened).toHaveLength(1) + }) + + it('still initializes newly expanded nodes while automatic refresh is off and refreshes dirty nodes when reenabled', async () => { + const { node, script } = mount() + const rootStream = await listReady(node, script, ROOT_LEVEL) + node.setAutomatic(false) + const child = node.expand(SRC)! + const childStream = await listReady(child, script, SRC_LEVEL) + await rootStream.deliver('change') + await childStream.deliver('change') + await childStream.deliver('change') + expect(script.list).toHaveBeenCalledTimes(2) + expect(script.outstanding()).toEqual([]) + + const rootRefresh = vi.spyOn(node, 'refresh') + const childRefresh = vi.spyOn(child, 'refresh') + node.setAutomatic(true) + await script.waitForList(3) + const finished = Promise.all([readCompletion(rootRefresh), readCompletion(childRefresh)]) + expect(script.outstanding()).toEqual(expect.arrayContaining([ROOT, SRC])) + expect(script.outstanding()).toHaveLength(2) + await script.settle({ ok: true, value: ROOT_LEVEL }) + await script.settle({ ok: true, value: EMPTY }) + await finished + node.setAutomatic(true) + expect(script.list).toHaveBeenCalledTimes(4) + expect(script.watches.opened.map(stream => stream.path)).toEqual([ROOT, SRC]) + }) + + it('manual refresh consumes pending changes without enabling automatic refresh', async () => { + const { node, script } = mount() + const stream = await listReady(node, script, ROOT_LEVEL) + node.setAutomatic(false) + await stream.deliver('change') + const manual = node.refresh() + expect((await script.waitForList(1)).path).toBe(ROOT) + await script.settle({ ok: true, value: EMPTY }) + await manual + await stream.deliver('change') + expect(script.list).toHaveBeenCalledTimes(2) + const refresh = vi.spyOn(node, 'refresh') + node.setAutomatic(true) + const finished = readCompletion(refresh) + await script.settle({ ok: true, value: EMPTY }) + await finished + expect(script.list).toHaveBeenCalledTimes(3) + }) + + it('close awaits an in-flight read as well as watch release and ignores late restoration data', async () => { + const { node, script } = mount(ROOT, [SRC]) + const read = await watchedRead(node, script) + const release = read.stream.holdRelease() + let closed = false + const closing = node.close().then(() => { closed = true }) + expect(read.stream.signal.aborted).toBe(true) + expect((await script.waitForList(0)).signal.aborted).toBe(true) + await read.stream.releasing.promise + expect(closed).toBe(false) + release.resolve(undefined) + await read.stream.released.promise + expect(closed).toBe(false) + await script.settle({ ok: true, value: ROOT_LEVEL }) + await closing + expect(node.children.size).toBe(0) + expect(node.expand(SRC)).toBeUndefined() + expect(script.watches.opened.map(stream => stream.path)).toEqual([ROOT]) + }) + + it('closes a subscription cancelled before ready without listing', async () => { + const { node, script } = mount() + const stream = await script.watches.forPath(ROOT) + await node.close() + expect(stream.signal.aborted).toBe(true) + expect(script.list).not.toHaveBeenCalled() + await stream.released.promise + }) + + it('does not report a stream error whose release completes after node cancellation', async () => { + const { node, script, failed } = mount() + const stream = await script.watches.forPath(ROOT) + const release = stream.holdRelease() + stream.fail(new Error('watch stopped during cancellation')) + await stream.releasing.promise + const closing = node.close() + expect(stream.signal.aborted).toBe(true) + release.resolve(undefined) + await closing + expect(failed).not.toHaveBeenCalled() + expect(script.list).not.toHaveBeenCalled() + }) + + it('lists after watch-unsupported and keeps manual refresh available without reporting a failed directory', async () => { + const { node, script, failed } = mount() + const refresh = vi.spyOn(node, 'refresh') + const stream = await script.watches.forPath(ROOT) + stream.fail(new RemoteError('workspace-file/watch-unsupported', 'watching is unavailable', { path: ROOT })) + expect((await script.waitForList(0)).path).toBe(ROOT) + const initial = readCompletion(refresh) + await script.settle({ ok: true, value: ROOT_LEVEL }) + await initial + const manual = node.refresh() + expect((await script.waitForList(1)).path).toBe(ROOT) + await script.settle({ ok: true, value: EMPTY }) + await manual + await node.close() + expect(failed).not.toHaveBeenCalled() + expect(script.list).toHaveBeenCalledTimes(2) + expect(script.watches.opened).toHaveLength(1) + }) + + it('falls back to an initial list and reports a stream failure before ready', async () => { + const { script, failed, reported } = mount() + const stream = await script.watches.forPath(ROOT) + const error = new Error('directory stream unavailable') + stream.fail(error) + expect((await script.waitForList(0)).path).toBe(ROOT) + expect(failed).not.toHaveBeenCalled() + await script.settle({ ok: true, value: ROOT_LEVEL }) + expect(await reported.promise).toEqual([ROOT, error]) + await stream.released.promise + }) + + it('reports a later stream failure without replacing an already loaded directory', async () => { + const { node, script, reported } = mount() + const stream = await listReady(node, script, ROOT_LEVEL) + const error = new Error('directory stream disconnected') + stream.fail(error) + expect(await reported.promise).toEqual([ROOT, error]) + expect(script.list).toHaveBeenCalledTimes(1) + await stream.released.promise + }) +}) diff --git a/packages/client/ui-sidebar-files/tests/face.client.spec.ts b/packages/client/ui-sidebar-files/tests/face.client.spec.ts index dfa1710b23..29de3825cc 100644 --- a/packages/client/ui-sidebar-files/tests/face.client.spec.ts +++ b/packages/client/ui-sidebar-files/tests/face.client.spec.ts @@ -1,16 +1,5 @@ -/** - * The tree's asynchronous half against a scripted listing, and the Remote - * adapter under it. - * - * The face's contract is what reaches the store and when: a level is `loading` - * before the listing settles, `ready` or `failed` after, never written once the - * owner's signal aborted or a newer listing of the level was asked for, and a - * tab whose record is gone leaves no bucket behind. The adapter's is what it - * keeps and what it drops: entries and the - * truncation flag reach the store, the endpoint's workspace-relative path does - * not, and a failure passes through untouched. - */ -import { describe, expect, it, vi } from 'vitest' +/** Directory subscriptions and read settlements through the face's real store actions. */ +import { describe, expect, it, onTestFinished, vi } from 'vitest' import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { WorkspaceDirectoryListing } from '@deepseek-ai/dsh-api-workspace-files/types' @@ -18,6 +7,7 @@ import { childPath, createList, filesFace } from '../src/client/face.ts' import type { WorkspaceFilesListRemote } from '../src/client/face.ts' import { createFilesStore } from '../src/client/store.ts' import type { DirLevel } from '../src/client/store.ts' +import { DirectoryNode } from '../src/client/directory-node.ts' import { scriptedList } from './scripted-list.client.ts' import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit' @@ -30,83 +20,265 @@ const LEVEL: DirLevel = { entries: [{ name: 'src', type: 'directory' }], truncat function mount() { const instance = createFilesStore().create() const script = scriptedList() - const face = filesFace(script.list)(SESSION, instance.actions) - return { ...script, face, snapshot: () => instance.getSnapshot().byTab[TAB] } + const face = filesFace(script.list, script.watch)(SESSION, instance.actions) + const controller = new AbortController() + onTestFinished(async () => { + controller.abort() + await script.dispose() + }) + return { ...script, face, controller, instance, actions: instance.actions, snapshot: () => instance.getSnapshot().byTab[TAB] } } describe('filesFace', () => { - it('start seeds the tab and lists the root with the session and the absolute root path', async () => { - const { face, list, settle, snapshot } = mount() - const controller = new AbortController() + it.each(['/', 'C:/', 'C:\\', '/work/app/'])('expands a child of the exact root key %s', async (root) => { + const h = mount() + const child = childPath(root, 'src') + h.face.start(TAB, root, h.controller.signal) + await h.watches.ready(root) + await h.settle({ ok: true, value: LEVEL }) + h.face.toggle(TAB, root, child, [root], h.controller.signal) + const stream = await h.watches.ready(child) + expect(h.snapshot()?.expanded).toEqual([root, child]) + expect(h.list).toHaveBeenLastCalledWith(SESSION, child, stream.signal) + await h.settle({ ok: true, value: { entries: [], truncated: false } }) + }) + + it('subscribes to the root before listing it and waits for ready', async () => { + const { face, list, watches, settle, snapshot, controller } = mount() face.start(TAB, ROOT, controller.signal) - expect(list).toHaveBeenCalledWith(SESSION, ROOT, controller.signal) + const stream = await watches.forPath(ROOT) + expect(stream.sessionId).toBe(SESSION) + expect(stream.signal.aborted).toBe(false) + expect(list).not.toHaveBeenCalled() + expect(snapshot()).toEqual({ root: ROOT, expanded: [ROOT], levels: {}, scrollTop: 0, autoRefresh: true }) + await stream.deliver('ready') + expect(list).toHaveBeenCalledWith(SESSION, ROOT, stream.signal) expect(snapshot()!.levels[ROOT]).toEqual({ kind: 'loading' }) await settle({ ok: true, value: LEVEL }) expect(snapshot()!.levels[ROOT]).toEqual({ kind: 'ready', level: LEVEL }) }) it('records a failed listing under its level', async () => { - const { face, settle, snapshot } = mount() - face.start(TAB, ROOT, new AbortController().signal) + const { face, watches, settle, snapshot, controller } = mount() + face.start(TAB, ROOT, controller.signal) + await watches.ready(ROOT) const error = new RemoteError('workspace-file/not-directory', 'not a directory', { path: ROOT, kind: 'file' }) await settle({ ok: false, error }) expect(snapshot()!.levels[ROOT]).toEqual({ kind: 'failed', failure: error }) }) - it('toggle expands and lists a directory the first time, and only toggles afterwards', async () => { - const { face, list, settle, snapshot } = mount() - const signal = new AbortController().signal + it('subscribes on expansion, releases on collapse, and rereads a reopened directory after ready', async () => { + const { face, list, watches, settle, snapshot, controller } = mount() + const { signal } = controller const child = `${ROOT}/src` face.start(TAB, ROOT, signal) + const rootStream = await watches.ready(ROOT) await settle({ ok: true, value: LEVEL }) - face.toggle(TAB, child, false, signal) - expect(list).toHaveBeenLastCalledWith(SESSION, child, signal) + face.toggle(TAB, ROOT, child, [ROOT], signal) expect(snapshot()!.expanded).toEqual([ROOT, child]) + expect(list).toHaveBeenCalledTimes(1) + const childStream = await watches.ready(child) + expect(list).toHaveBeenLastCalledWith(SESSION, child, childStream.signal) await settle({ ok: true, value: LEVEL }) - face.toggle(TAB, child, true, signal) + face.toggle(TAB, ROOT, child, [ROOT, child], signal) + expect(childStream.signal.aborted).toBe(true) + await childStream.released.promise + expect(rootStream.signal.aborted).toBe(false) expect(snapshot()!.expanded).toEqual([ROOT]) expect(list).toHaveBeenCalledTimes(2) + face.toggle(TAB, ROOT, child, [ROOT], signal) + expect(snapshot()!.levels[child]).toEqual({ kind: 'ready', level: LEVEL }) + expect(list).toHaveBeenCalledTimes(2) + const reopened = await watches.ready(child, 1) + expect(reopened.signal).not.toBe(childStream.signal) + expect(reopened.signal.aborted).toBe(false) + expect(list).toHaveBeenLastCalledWith(SESSION, child, reopened.signal) + const current: DirLevel = { entries: [{ name: 'new.ts', type: 'file' }], truncated: false } + await settle({ ok: true, value: current }) + expect(snapshot()!.levels[child]).toEqual({ kind: 'ready', level: current }) + }) + + it.each([false, true])('keeps deep expansion intent while restoring an ancestor (reopen: %s)', async (reopen) => { + const h = mount() + const refresh = vi.spyOn(DirectoryNode.prototype, 'refresh') + onTestFinished(() => { refresh.mockRestore() }) + const src = `${ROOT}/src` + const components = `${src}/components` + const nested = `${components}/nested` + const srcLevel: DirLevel = { entries: [{ name: 'components', type: 'directory' }], truncated: false } + const componentsLevel: DirLevel = { entries: [{ name: 'nested', type: 'directory' }], truncated: false } + const empty: DirLevel = { entries: [], truncated: false } + const toggle = (parent: string, path: string): void => { + h.face.toggle(TAB, parent, path, h.snapshot()!.expanded, h.controller.signal) + } + h.face.start(TAB, ROOT, h.controller.signal) + await h.watches.ready(ROOT) + await h.settle({ ok: true, value: LEVEL }) + for (const [parent, path, level] of [[ROOT, src, srcLevel], [src, components, componentsLevel], [components, nested, empty]] as const) { + toggle(parent, path) + await h.watches.ready(path) + await h.settle({ ok: true, value: level }) + } + toggle(ROOT, src) + await Promise.all(h.watches.opened.slice(1).map(stream => stream.released.promise)) + toggle(ROOT, src) + await h.watches.ready(src, 1) + expect(h.outstanding()).toEqual([src]) + expect(h.watches.opened.filter(stream => !stream.signal.aborted).map(stream => stream.path)).toEqual([ROOT, src]) + + toggle(components, nested) + expect(h.snapshot()!.expanded).not.toContain(nested) + if (reopen) { + toggle(components, nested) + expect(h.snapshot()!.expanded).toContain(nested) + } + await h.settle({ ok: true, value: srcLevel }) + await h.watches.ready(components, 1) + const reading = refresh.mock.results.at(-1) + if (reading?.type !== 'return') throw new Error('Expected the restored directory to start listing') + await h.settle({ ok: true, value: componentsLevel }) + await reading.value + expect(h.watches.opened.filter(stream => stream.path === nested)).toHaveLength(reopen ? 2 : 1) + if (reopen) { + await h.watches.ready(nested, 1) + await h.settle({ ok: true, value: empty }) + } }) it('abort forgets the bucket and a late settlement writes nothing', async () => { - const { face, settle, snapshot } = mount() - const controller = new AbortController() + const { face, watches, settle, snapshot, controller } = mount() face.start(TAB, ROOT, controller.signal) + const stream = await watches.ready(ROOT) controller.abort() + expect(stream.signal.aborted).toBe(true) expect(snapshot()).toBeUndefined() await settle({ ok: true, value: LEVEL }) + await stream.released.promise + expect(snapshot()).toBeUndefined() + }) + + it('abort before ready closes the subscription without starting a listing', async () => { + const { face, list, watches, snapshot, controller } = mount() + face.start(TAB, ROOT, controller.signal) + const stream = await watches.forPath(ROOT) + controller.abort() + await stream.released.promise + expect(stream.signal.aborted).toBe(true) + expect(list).not.toHaveBeenCalled() expect(snapshot()).toBeUndefined() }) it('makes no request for a record that already ended', () => { - const { face, list } = mount() - const controller = new AbortController() + const { face, list, controller } = mount() controller.abort() face.load(TAB, ROOT, controller.signal) expect(list).not.toHaveBeenCalled() }) + it('ignores expansion after cancellation or when its parent is not active', async () => { + const h = mount() + h.face.toggle(TAB, ROOT, `${ROOT}/src`, [ROOT], h.controller.signal) + expect(h.snapshot()).toBeUndefined() + h.face.start(TAB, ROOT, h.controller.signal) + const stream = await h.watches.ready(ROOT) + await h.settle({ ok: true, value: LEVEL }) + const before = h.snapshot() + h.face.toggle(TAB, `${ROOT}/src`, `${ROOT}/src/nested`, [ROOT], h.controller.signal) + expect(h.snapshot()).toBe(before) + expect(h.watches.opened.map(watch => watch.path)).toEqual([ROOT]) + h.controller.abort() + await stream.released.promise + h.face.toggle(TAB, ROOT, `${ROOT}/src`, [ROOT], h.controller.signal) + expect(h.snapshot()).toBeUndefined() + expect(h.list).toHaveBeenCalledTimes(1) + expect(h.watches.opened).toHaveLength(1) + }) + + it.each([new Error('watch disconnected'), 'watch disconnected'])('records a watch failure without an Error prefix: %s', async (failure) => { + const h = mount() + const reported = Promise.withResolvers() + const unsubscribe = h.instance.subscribe(() => { + const level = h.snapshot()?.levels[ROOT] + if (level?.kind === 'ready' && level.failure !== undefined) reported.resolve(undefined) + }) + onTestFinished(unsubscribe) + h.face.start(TAB, ROOT, h.controller.signal) + const stream = await h.watches.forPath(ROOT) + stream.fail(failure) + expect((await h.waitForList(0)).path).toBe(ROOT) + await h.settle({ ok: true, value: LEVEL }) + await reported.promise + expect(h.snapshot()!.levels[ROOT]).toMatchObject({ + kind: 'ready', level: LEVEL, + failure: { code: 'gateway/internal', message: 'watch disconnected' }, + }) + await stream.released.promise + }) + + it('does not publish a watch failure when the tab closes during its fallback list', async () => { + const close = vi.spyOn(DirectoryNode.prototype, 'close') + onTestFinished(() => { close.mockRestore() }) + const h = mount() + h.face.start(TAB, ROOT, h.controller.signal) + const stream = await h.watches.forPath(ROOT) + stream.fail(new Error('watch disconnected')) + await h.waitForList(0) + h.controller.abort() + const closing = close.mock.results[0] + if (closing?.type !== 'return') throw new Error('expected the tab to close its directory tree') + await h.settle({ ok: true, value: LEVEL }) + await expect(closing.value).resolves.toBeUndefined() + expect(h.snapshot()).toBeUndefined() + expect(h.watches.opened).toHaveLength(1) + }) + it('lets the latest listing of a level win, whichever settles first', async () => { - const { face, list, settle, settleLatest, snapshot, outstanding } = mount() - const signal = new AbortController().signal + const { face, list, watches, settle, settleLatest, snapshot, outstanding, controller } = mount() + const { signal } = controller const older: DirLevel = { entries: [{ name: 'old.txt', type: 'file' }], truncated: false } face.start(TAB, ROOT, signal) - // The reload gesture asks for the root again while the first listing is still out. + await watches.ready(ROOT) face.load(TAB, ROOT, signal) expect(list).toHaveBeenCalledTimes(2) expect(outstanding()).toEqual([ROOT, ROOT]) await settleLatest({ ok: true, value: LEVEL }) expect(snapshot()!.levels[ROOT]).toEqual({ kind: 'ready', level: LEVEL }) - // The retired listing lands afterwards and changes nothing. await settle({ ok: true, value: older }) expect(snapshot()!.levels[ROOT]).toEqual({ kind: 'ready', level: LEVEL }) - // A retired failure is dropped the same way. face.load(TAB, ROOT, signal) face.load(TAB, ROOT, signal) await settleLatest({ ok: true, value: LEVEL }) await settle({ ok: false, error: new RemoteError('workspace-file/not-found', 'gone', { path: ROOT }) }) expect(snapshot()!.levels[ROOT]).toEqual({ kind: 'ready', level: LEVEL }) }) + + it('manually refreshes expanded nodes while preserving cached levels, scroll, and the automatic setting', async () => { + const { face, watches, settle, waitForList, snapshot, controller, actions } = mount() + const child = `${ROOT}/src` + const collapsed = `${ROOT}/docs` + face.start(TAB, ROOT, controller.signal) + await watches.ready(ROOT) + await settle({ ok: true, value: LEVEL }) + face.toggle(TAB, ROOT, child, snapshot()!.expanded, controller.signal) + await watches.ready(child) + await settle({ ok: true, value: LEVEL }) + actions.loaded(TAB, collapsed, LEVEL) + actions.scrolled(TAB, 120) + face.setAutoRefresh(TAB, false) + const cached = snapshot()! + + face.refresh(TAB) + expect((await waitForList(2)).path).toBe(ROOT) + expect(snapshot()!.levels).toEqual(cached.levels) + await settle({ ok: true, value: LEVEL }) + expect((await waitForList(3)).path).toBe(child) + expect(snapshot()!.levels[child]).toEqual(cached.levels[child]) + await settle({ ok: true, value: { entries: [], truncated: false } }) + expect(snapshot()!.levels[collapsed]).toEqual(cached.levels[collapsed]) + expect(snapshot()).toMatchObject({ expanded: [ROOT, child], scrollTop: 120, autoRefresh: false }) + expect(watches.opened.map(stream => stream.path)).toEqual([ROOT, child]) + expect(watches.opened.every(stream => !stream.signal.aborted)).toBe(true) + }) }) describe('createList', () => { diff --git a/packages/client/ui-sidebar-files/tests/files-body.client.spec.tsx b/packages/client/ui-sidebar-files/tests/files-body.client.spec.tsx index e8dd9cecd5..f7d531da8f 100644 --- a/packages/client/ui-sidebar-files/tests/files-body.client.spec.tsx +++ b/packages/client/ui-sidebar-files/tests/files-body.client.spec.tsx @@ -1,14 +1,5 @@ // @vitest-environment jsdom -/** - * The body against a scripted listing. - * - * What is asserted is the reader's contract: the root lists itself on mount, - * rows come out directories-first, a directory click asks for exactly that - * level, a file click opens exactly that session-scoped `file:` address through - * the owner, an `other` entry is shown but not clickable, the tree says when it - * was cut or could not be read, and reload asks again for the expanded levels - * only. The two pure helpers the rows are built from are checked on their own. - */ +/** File-tree presentation over controlled directory watches and deferred listings. */ import { afterEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent } from '@testing-library/react' import { makeTranslate, RemoteError } from '@deepseek-ai/dsh-client-test-runtime' @@ -45,6 +36,8 @@ describe('FilesBody', () => { it('lists the root on mount, heads it with its path split at the last segment, and draws directories first with dotfiles kept', async () => { const { view, script } = mountBody() + expect(script.list).not.toHaveBeenCalled() + await act(() => script.watches.ready(ROOT)) expect(script.list).toHaveBeenCalledWith(SESSION, ROOT, expect.any(AbortSignal)) expect(view.container.querySelector('[data-files-row="loading"]')).not.toBeNull() await act(() => script.settle({ ok: true, value: ROOT_LEVEL })) @@ -60,6 +53,7 @@ describe('FilesBody', () => { it('heads a separator-only root by the root itself, since it has no final segment', async () => { const { view, script } = mountBody('/') + await act(() => script.watches.ready('/')) await act(() => script.settle({ ok: true, value: ROOT_LEVEL })) const path = view.container.querySelector('[data-files-path]') expect([...path?.querySelectorAll('span > span') ?? []].map(span => span.textContent)).toEqual(['/']) @@ -88,6 +82,7 @@ describe('FilesBody', () => { Object.defineProperty(HTMLElement.prototype, 'clientWidth', { configurable: true, get: () => boxWidth }) try { const { view, script } = mountBody() + await act(() => script.watches.ready(ROOT)) await act(() => script.settle({ ok: true, value: ROOT_LEVEL })) const path = view.container.querySelector('[data-files-path]') const text = path?.firstElementChild @@ -115,25 +110,37 @@ describe('FilesBody', () => { } }) - it('a directory click lists that level once and marks it expanded; a second click collapses without asking again', async () => { + it('reopening a directory shows its cache and replaces it after the new subscription is ready', async () => { const { view, script } = mountBody() + await act(() => script.watches.ready(ROOT)) await act(() => script.settle({ ok: true, value: ROOT_LEVEL })) const dir = view.container.querySelector(`[data-files-path="${ROOT}/src"] > button`)! act(() => { fireEvent.click(dir) }) + expect(script.list).toHaveBeenCalledTimes(1) + const childStream = await act(() => script.watches.ready(`${ROOT}/src`)) expect(script.list).toHaveBeenLastCalledWith(SESSION, `${ROOT}/src`, expect.any(AbortSignal)) expect(dir.getAttribute('aria-expanded')).toBe('true') await act(() => script.settle({ ok: true, value: { entries: [{ name: 'a.ts', type: 'file' }], truncated: false } })) expect(names(view.container)).toContain(`${ROOT}/src/a.ts`) act(() => { fireEvent.click(dir) }) + expect(childStream.signal.aborted).toBe(true) + await childStream.released.promise expect(dir.getAttribute('aria-expanded')).toBe('false') expect(names(view.container)).not.toContain(`${ROOT}/src/a.ts`) act(() => { fireEvent.click(dir) }) expect(names(view.container)).toContain(`${ROOT}/src/a.ts`) expect(script.list).toHaveBeenCalledTimes(2) + await act(() => script.watches.ready(`${ROOT}/src`, 1)) + expect(script.list).toHaveBeenCalledTimes(3) + expect(names(view.container)).toContain(`${ROOT}/src/a.ts`) + await act(() => script.settle({ ok: true, value: { entries: [{ name: 'b.ts', type: 'file' }], truncated: false } })) + expect(names(view.container)).toContain(`${ROOT}/src/b.ts`) + expect(names(view.container)).not.toContain(`${ROOT}/src/a.ts`) }) it('a file click opens its session-scoped file: address through the owner; an other entry offers no button', async () => { const { view, script, tabActions } = mountBody() + await act(() => script.watches.ready(ROOT)) await act(() => script.settle({ ok: true, value: ROOT_LEVEL })) fireEvent.click(view.container.querySelector(`[data-files-path="${ROOT}/README.md"] > button`)!) // Every row sits under the tree's root, so the address is the path relative to it. @@ -146,15 +153,18 @@ describe('FilesBody', () => { it('marks a cut listing and an empty one', async () => { const { view, script } = mountBody() + await act(() => script.watches.ready(ROOT)) await act(() => script.settle({ ok: true, value: { entries: [{ name: 'd', type: 'directory' }], truncated: true } })) expect(view.container.querySelector('[data-files-row="truncated"]')?.textContent).toBe(zh.truncated) act(() => { fireEvent.click(view.container.querySelector(`[data-files-path="${ROOT}/d"] > button`)!) }) + await act(() => script.watches.ready(`${ROOT}/d`)) await act(() => script.settle({ ok: true, value: { entries: [], truncated: false } })) expect(view.container.querySelector('[data-files-row="empty"]')?.textContent).toBe(zh.empty) }) it('shows a failed level under its directory with the failure code', async () => { const { view, script } = mountBody() + await act(() => script.watches.ready(ROOT)) await act(() => script.settle({ ok: false, error: new RemoteError('workspace-file/not-found', 'gone', { path: ROOT }), @@ -164,28 +174,81 @@ describe('FilesBody', () => { expect(failed?.textContent).toBe(zh['error.notFound']) }) - it('reload resets every level and lists the expanded ones again', async () => { - const { view, script, controller, instance } = mountBody() + it('shows a refresh failure beside the existing directory rows and clears it on retry', async () => { + const { view, script } = mountBody() + await act(() => script.watches.ready(ROOT)) + await act(() => script.settle({ ok: true, value: ROOT_LEVEL })) + const rows = names(view.container) + const reload = view.getByRole('button', { name: zh.reload }) + act(() => { fireEvent.click(reload) }) + await act(() => script.settle({ + ok: false, error: new RemoteError('workspace-file/not-found', 'gone', { path: ROOT }), + })) + expect(view.container.querySelector('[data-files-row="failed"]')?.textContent).toBe(zh['error.notFound']) + expect(names(view.container)).toEqual(rows) + act(() => { fireEvent.click(reload) }) + await act(() => script.settle({ ok: true, value: ROOT_LEVEL })) + expect(view.container.querySelector('[data-files-row="failed"]')).toBeNull() + expect(names(view.container)).toEqual(rows) + }) + + it('reload refreshes expanded nodes without clearing cached rows or the scroll position', async () => { + const { view, script, instance } = mountBody() const child = `${ROOT}/src` const collapsed = `${ROOT}/docs` + const rootStream = await act(() => script.watches.ready(ROOT)) await act(() => script.settle({ ok: true, value: ROOT_LEVEL })) act(() => { fireEvent.click(view.container.querySelector(`[data-files-path="${child}"] > button`)!) }) + await act(() => script.watches.ready(child)) await act(() => script.settle({ ok: true, value: ROOT_LEVEL })) - // A level listed earlier and since collapsed is dropped, not re-fetched. act(() => { instance.actions.loaded(TAB, collapsed, ROOT_LEVEL) }) - script.list.mockClear() + const cached = instance.getSnapshot().byTab[TAB]! + const childRow = view.container.querySelector(`[data-files-path="${child}"]`) + const body = view.container.querySelector('[data-files-body]')! + fireEvent.scroll(body, { target: { scrollTop: 120 } }) act(() => { fireEvent.click(view.container.querySelector('[data-files-reload]')!) }) - expect(script.list.mock.calls.map(call => call[1])).toEqual([ROOT, child]) - expect(script.list).toHaveBeenCalledWith(SESSION, ROOT, controller.signal) - const state = instance.getSnapshot().byTab[TAB]! - expect(state.expanded).toEqual([ROOT, child]) - expect(state.levels).toEqual({ [ROOT]: { kind: 'loading' }, [child]: { kind: 'loading' } }) + expect(script.list.mock.calls.slice(2).map(call => call[1])).toEqual([ROOT]) + expect(script.list).toHaveBeenLastCalledWith(SESSION, ROOT, rootStream.signal) + expect(instance.getSnapshot().byTab[TAB]!.levels).toEqual(cached.levels) + expect(view.container.querySelector('[data-files-row="loading"]')).toBeNull() + await act(async () => { + await script.settle({ ok: true, value: ROOT_LEVEL }) + await script.waitForList(3) + }) + expect(script.list.mock.calls.slice(2).map(call => call[1])).toEqual([ROOT, child]) + expect(instance.getSnapshot().byTab[TAB]!.levels).toEqual(cached.levels) + await act(() => script.settle({ ok: true, value: ROOT_LEVEL })) + expect(view.container.querySelector(`[data-files-path="${child}"]`)).toBe(childRow) + expect(body.scrollTop).toBe(120) + expect(instance.getSnapshot().byTab[TAB]!.expanded).toEqual([ROOT, child]) + expect(script.watches.opened.map(stream => stream.path)).toEqual([ROOT, child]) expect(view.container.querySelector('[data-files-reload]')?.getAttribute('aria-label')).toBe(zh.reload) }) + it('keeps the automatic control hidden and enabled by default, with manual reload independent of its setting', async () => { + const { view, script, instance } = mountBody() + const stream = await act(() => script.watches.ready(ROOT)) + await act(() => script.settle({ ok: true, value: ROOT_LEVEL })) + const automatic = view.container.querySelector('[data-files-auto-refresh]')! + expect(automatic.closest('span[hidden]')).not.toBeNull() + expect(automatic.getAttribute('aria-pressed')).toBe('true') + expect(view.queryByRole('button', { name: zh.autoRefresh })).toBeNull() + expect(view.getByRole('button', { name: zh.reload })).toBeDefined() + act(() => { fireEvent.click(automatic) }) + expect(automatic.getAttribute('aria-pressed')).toBe('false') + await act(() => stream.deliver('change')) + expect(script.list).toHaveBeenCalledTimes(1) + act(() => { fireEvent.click(view.getByRole('button', { name: zh.reload })) }) + await act(() => script.settle({ ok: true, value: ROOT_LEVEL })) + expect(script.list).toHaveBeenCalledTimes(2) + expect(instance.getSnapshot().byTab[TAB]!.autoRefresh).toBe(false) + expect(automatic.getAttribute('aria-pressed')).toBe('false') + }) + it('captures the body scroll offset on unmount and restores it when a tab switch remounts the tree', async () => { const { view, script, instance, remount } = mountBody() + await act(() => script.watches.ready(ROOT)) await act(() => script.settle({ ok: true, value: ROOT_LEVEL })) const body = view.container.querySelector('[data-files-body]')! fireEvent.scroll(body, { target: { scrollTop: 120 } }) @@ -199,6 +262,7 @@ describe('FilesBody', () => { it('a scroll before the owner aborts is not written to a forgotten bucket', async () => { const { view, script, controller, instance } = mountBody() + await act(() => script.watches.ready(ROOT)) await act(() => script.settle({ ok: true, value: ROOT_LEVEL })) fireEvent.scroll(view.container.querySelector('[data-files-body]')!, { target: { scrollTop: 80 } }) act(() => { controller.abort() }) @@ -208,6 +272,7 @@ describe('FilesBody', () => { it('an aborted record is forgotten and not seeded again while the body is still mounted', async () => { const { view, script, controller, instance } = mountBody() + await act(() => script.watches.ready(ROOT)) await act(() => script.settle({ ok: true, value: ROOT_LEVEL })) act(() => { controller.abort() }) expect(instance.getSnapshot().byTab[TAB]).toBeUndefined() diff --git a/packages/client/ui-sidebar-files/tests/mount.client.tsx b/packages/client/ui-sidebar-files/tests/mount.client.tsx index 54131b5e31..3373d912ff 100644 --- a/packages/client/ui-sidebar-files/tests/mount.client.tsx +++ b/packages/client/ui-sidebar-files/tests/mount.client.tsx @@ -8,7 +8,7 @@ import { useSyncExternalStore } from 'react' import { render } from '@testing-library/react' import type { RenderResult } from '@testing-library/react' -import { vi } from 'vitest' +import { onTestFinished, vi } from 'vitest' import type { Mock } from 'vitest' import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' @@ -61,8 +61,12 @@ export interface Mounted { function harness(cwd: string | null) { const instance = createFilesStore().create() const script = scriptedList() - const face = filesFace(script.list)(SESSION, instance.actions) + const face = filesFace(script.list, script.watch)(SESSION, instance.actions) const controller = new AbortController() + onTestFinished(async () => { + controller.abort() + await script.dispose() + }) const tabActions: MockedTabActions = { openResource: vi.fn(), openTab: vi.fn(), diff --git a/packages/client/ui-sidebar-files/tests/scripted-list.client.ts b/packages/client/ui-sidebar-files/tests/scripted-list.client.ts index 7825afc719..ea235c60d2 100644 --- a/packages/client/ui-sidebar-files/tests/scripted-list.client.ts +++ b/packages/client/ui-sidebar-files/tests/scripted-list.client.ts @@ -1,12 +1,17 @@ -/** A directory listing the spec settles by hand, one deferred result per call. */ +/** Deferred directory reads paired with independently controlled watch streams. */ import { vi } from 'vitest' import type { Mock } from 'vitest' import type { RemoteResult } from '@deepseek-ai/dsh-api-remotes/client' -import type { ListWorkspaceDirectory } from '../src/client/face.ts' +import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { ListWorkspaceDirectory, WatchWorkspaceDirectory } from '../src/client/face.ts' import type { DirLevel } from '../src/client/store.ts' +import { DirectoryWatches } from './scripted-watch.client.ts' /** The scripted listing: the mock the face receives, and the hand that settles it. */ export interface ScriptedList { + readonly watch: WatchWorkspaceDirectory + readonly watches: DirectoryWatches readonly list: Mock /** * Settle the oldest outstanding call and let its store write land. @@ -20,32 +25,62 @@ export interface ScriptedList { readonly settleLatest: (result: RemoteResult) => Promise /** Paths of calls not yet settled, oldest first. */ readonly outstanding: () => readonly string[] + /** Wait for a specific invocation, independently of its response. */ + readonly waitForList: (index: number) => Promise + /** Settle outstanding reads and await every watch release after the owner aborts. */ + readonly dispose: () => Promise +} + +/** One listing awaiting the spec's answer. */ +export interface PendingList { + readonly sessionId: SessionId + readonly path: string + readonly signal: AbortSignal + readonly result: Promise> + resolve(result: RemoteResult): void } /** * Build a listing whose every call stays pending until the spec settles it. - * @returns the scripted listing. + * @returns the scripted listing and watch controls. */ -/** One listing awaiting the spec's answer. */ -interface PendingList { - readonly path: string - resolve(result: RemoteResult): void -} - export function scriptedList(): ScriptedList { const pending: PendingList[] = [] - const list = vi.fn((_sessionId, path) => - new Promise((resolve) => { pending.push({ path, resolve }) })) + const calls: PendingList[] = [] + const waiters = new Map void>() + const watches = new DirectoryWatches() + const list = vi.fn((sessionId, path, signal) => { + const result = Promise.withResolvers>() + const call = { sessionId, path, signal, result: result.promise, resolve: result.resolve } + pending.push(call) + const index = calls.push(call) - 1 + waiters.get(index)?.(call) + waiters.delete(index) + return result.promise + }) const land = async (call: PendingList | undefined, result: RemoteResult): Promise => { if (call === undefined) throw new Error('no outstanding listing to settle') call.resolve(result) - await Promise.resolve() - await Promise.resolve() + await call.result } return { + watch: watches.watch, + watches, list, settle: result => land(pending.shift(), result), settleLatest: result => land(pending.pop(), result), outstanding: () => pending.map(call => call.path), + waitForList: (index) => { + const call = calls[index] + if (call !== undefined) return Promise.resolve(call) + return new Promise((resolve) => { waiters.set(index, resolve) }) + }, + dispose: async () => { + for (const call of pending.splice(0)) { + call.resolve({ ok: false, error: new RemoteError('gateway/internal', 'fixture closed', {}) }) + } + await Promise.all([watches.dispose(), ...calls.map(call => call.result)]) + waiters.clear() + }, } } diff --git a/packages/client/ui-sidebar-files/tests/scripted-watch.client.ts b/packages/client/ui-sidebar-files/tests/scripted-watch.client.ts new file mode 100644 index 0000000000..136d70c06c --- /dev/null +++ b/packages/client/ui-sidebar-files/tests/scripted-watch.client.ts @@ -0,0 +1,109 @@ +/** Target-scoped watch streams with explicit delivery and asynchronous release barriers. */ +import { vi } from 'vitest' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { WatchWorkspaceDirectory } from '../src/client/face.ts' + +type Event = 'ready' | 'change' +type Frame = { kind: 'event'; value: Event; delivered: () => void } | { kind: 'error'; error: unknown } + +/** One directory subscription; delivering an event waits for the consumer's next pull. */ +export class DirectoryWatch implements AsyncIterable { + readonly releasing = Promise.withResolvers() + readonly released = Promise.withResolvers() + private readonly controller = new AbortController() + private readonly lifetime: AbortSignal + private readonly queue: Frame[] = [] + private wake: (() => void) | undefined + private releaseGate: PromiseWithResolvers | undefined + + constructor(readonly sessionId: SessionId, readonly path: string, readonly signal: AbortSignal) { + this.lifetime = AbortSignal.any([signal, this.controller.signal]) + } + + deliver(value: Event): Promise { + return new Promise((resolve) => { + this.queue.push({ kind: 'event', value, delivered: resolve }) + this.wake?.() + }) + } + + fail(error: unknown): void { + this.queue.push({ kind: 'error', error }) + this.wake?.() + } + + holdRelease(): PromiseWithResolvers { + return this.releaseGate ??= Promise.withResolvers() + } + + stop(): void { + this.controller.abort() + this.releaseGate?.resolve(undefined) + } + + async *[Symbol.asyncIterator](): AsyncIterator { + const abort = (): void => { this.wake?.() } + this.lifetime.addEventListener('abort', abort, { once: true }) + try { + while (!this.lifetime.aborted) { + const next = this.queue.shift() + if (next === undefined) { + await new Promise((resolve) => { this.wake = resolve }) + this.wake = undefined + continue + } + if (next.kind === 'error') throw next.error + try { + yield next.value + } finally { + next.delivered() + } + } + } finally { + this.lifetime.removeEventListener('abort', abort) + this.releasing.resolve(undefined) + await this.releaseGate?.promise + this.released.resolve(undefined) + } + } +} + +/** Records independent streams, including new subscriptions to a reopened directory. */ +export class DirectoryWatches { + readonly opened: DirectoryWatch[] = [] + private readonly waiters = new Set<() => void>() + + readonly watch = vi.fn((sessionId, path, signal) => { + const stream = new DirectoryWatch(sessionId, path, signal) + this.opened.push(stream) + for (const waiter of this.waiters) waiter() + return stream + }) + + forPath(path: string, occurrence = 0): Promise { + const current = (): DirectoryWatch | undefined => this.opened.filter(stream => stream.path === path)[occurrence] + const stream = current() + if (stream !== undefined) return Promise.resolve(stream) + return new Promise((resolve) => { + const wait = (): void => { + const stream = current() + if (stream === undefined) return + this.waiters.delete(wait) + resolve(stream) + } + this.waiters.add(wait) + }) + } + + async ready(path: string, occurrence = 0): Promise { + const stream = await this.forPath(path, occurrence) + await stream.deliver('ready') + return stream + } + + async dispose(): Promise { + for (const stream of this.opened) stream.stop() + await Promise.all(this.opened.map(stream => stream.released.promise)) + this.waiters.clear() + } +} diff --git a/packages/client/ui-sidebar-files/tests/store.client.spec.ts b/packages/client/ui-sidebar-files/tests/store.client.spec.ts index c85ec67a9a..660e3bc3dd 100644 --- a/packages/client/ui-sidebar-files/tests/store.client.spec.ts +++ b/packages/client/ui-sidebar-files/tests/store.client.spec.ts @@ -1,11 +1,4 @@ -/** - * The tree's write set, one tab at a time. - * - * Two facts here are load-bearing for the body: a collapsed level keeps what it - * loaded (reopening draws at once), and `reset` clears levels while keeping the - * expanded set, which is what lets the reload gesture know which levels to ask - * for again. - */ +/** Tab-local directory caches, expansion preferences, and refresh settings. */ import { describe, expect, it } from 'vitest' import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' import { createFilesStore } from '../src/client/store.ts' @@ -33,21 +26,38 @@ describe('createFilesStore', () => { const { actions } = store const getSnapshot = (): ReturnType => store.getSnapshot() actions.start(TAB, ROOT) - expect(getSnapshot().byTab[TAB]).toEqual({ root: ROOT, levels: {}, expanded: [ROOT], scrollTop: 0 }) + expect(getSnapshot().byTab[TAB]).toEqual({ root: ROOT, levels: {}, expanded: [ROOT], scrollTop: 0, autoRefresh: true }) }) - it('walks one level through loading, ready, and failed', () => { + it('shows a failed initial listing and replaces it with a successful retry', () => { const store = createFilesStore().create() const { actions } = store const getSnapshot = (): ReturnType => store.getSnapshot() actions.start(TAB, ROOT) actions.loading(TAB, ROOT) expect(getSnapshot().byTab[TAB]!.levels[ROOT]).toEqual({ kind: 'loading' }) - actions.loaded(TAB, ROOT, LEVEL) - expect(getSnapshot().byTab[TAB]!.levels[ROOT]).toEqual({ kind: 'ready', level: LEVEL }) const failure = new RemoteError('workspace-file/not-found', 'gone', { path: ROOT }) actions.failed(TAB, ROOT, failure) expect(getSnapshot().byTab[TAB]!.levels[ROOT]).toEqual({ kind: 'failed', failure }) + actions.loading(TAB, ROOT) + expect(getSnapshot().byTab[TAB]!.levels[ROOT]).toEqual({ kind: 'loading' }) + actions.loaded(TAB, ROOT, LEVEL) + expect(getSnapshot().byTab[TAB]!.levels[ROOT]).toEqual({ kind: 'ready', level: LEVEL }) + }) + + it('retains a loaded level during refresh and beside a refresh failure', () => { + const store = createFilesStore().create() + const { actions } = store + actions.start(TAB, ROOT) + actions.loaded(TAB, ROOT, LEVEL) + const cached = store.getSnapshot().byTab[TAB]!.levels[ROOT] + actions.loading(TAB, ROOT) + expect(store.getSnapshot().byTab[TAB]!.levels[ROOT]).toBe(cached) + const failure = new RemoteError('workspace-file/not-found', 'gone', { path: ROOT }) + actions.failed(TAB, ROOT, failure) + expect(store.getSnapshot().byTab[TAB]!.levels[ROOT]).toEqual({ kind: 'ready', level: LEVEL, failure }) + actions.loaded(TAB, ROOT, LEVEL) + expect(store.getSnapshot().byTab[TAB]!.levels[ROOT]).toEqual({ kind: 'ready', level: LEVEL }) }) it('toggles a directory in and out of the expanded set without touching its level', () => { @@ -61,11 +71,10 @@ describe('createFilesStore', () => { expect(getSnapshot().byTab[TAB]!.expanded).toEqual([ROOT, child]) actions.toggled(TAB, child) expect(getSnapshot().byTab[TAB]!.expanded).toEqual([ROOT]) - // Collapsing keeps the listing, so reopening draws without another fetch. expect(getSnapshot().byTab[TAB]!.levels[child]).toEqual({ kind: 'ready', level: LEVEL }) }) - it('reset drops every level and keeps the expanded set', () => { + it('reset drops levels while keeping expansion, scroll, and the automatic setting', () => { const store = createFilesStore().create() const { actions } = store const getSnapshot = (): ReturnType => store.getSnapshot() @@ -74,8 +83,45 @@ describe('createFilesStore', () => { actions.loaded(TAB, ROOT, LEVEL) actions.toggled(TAB, child) actions.loaded(TAB, child, LEVEL) + actions.scrolled(TAB, 120) + actions.autoRefresh(TAB, false) actions.reset(TAB) - expect(getSnapshot().byTab[TAB]).toEqual({ root: ROOT, levels: {}, expanded: [ROOT, child], scrollTop: 0 }) + expect(getSnapshot().byTab[TAB]).toEqual({ root: ROOT, levels: {}, expanded: [ROOT, child], scrollTop: 120, autoRefresh: false }) + }) + + it('keeps the automatic setting independent for each tab', () => { + const store = createFilesStore().create() + const other = 'tab-2' as TabId + store.actions.start(TAB, ROOT) + store.actions.start(other, ROOT) + store.actions.autoRefresh(TAB, false) + expect(store.getSnapshot().byTab[TAB]!.autoRefresh).toBe(false) + expect(store.getSnapshot().byTab[other]!.autoRefresh).toBe(true) + store.actions.autoRefresh(TAB, true) + expect(store.getSnapshot().byTab[TAB]!.autoRefresh).toBe(true) + }) + + it.each(['removed', 'file'] as const)('drops cached descendants and expansion when a directory is %s', (replacement) => { + const store = createFilesStore().create() + const { actions } = store + const child = `${ROOT}/src` + const grandchild = `${child}/nested` + const sibling = `${ROOT}/src-other` + actions.start(TAB, ROOT) + actions.loaded(TAB, ROOT, { entries: [ + { name: 'src', type: 'directory' }, { name: 'src-other', type: 'directory' }, + ], truncated: false }) + for (const path of [child, grandchild, sibling]) { + actions.toggled(TAB, path) + actions.loaded(TAB, path, LEVEL) + } + actions.loaded(TAB, ROOT, { entries: replacement === 'file' + ? [{ name: 'src', type: 'file' }, { name: 'src-other', type: 'directory' }] + : [{ name: 'src-other', type: 'directory' }], truncated: false }) + const state = store.getSnapshot().byTab[TAB]! + expect(state.expanded).toEqual([ROOT, sibling]) + expect(Object.keys(state.levels)).toEqual([ROOT, sibling]) + expect(state.levels[sibling]).toEqual({ kind: 'ready', level: LEVEL }) }) it('remembers where the body is scrolled to', () => { diff --git a/packages/client/ui-sidebar-files/tests/watch.client.spec.ts b/packages/client/ui-sidebar-files/tests/watch.client.spec.ts new file mode 100644 index 0000000000..00218e65de --- /dev/null +++ b/packages/client/ui-sidebar-files/tests/watch.client.spec.ts @@ -0,0 +1,134 @@ +/** Directory notifications and disposal through the shared scripted Remote transport. */ +import { describe, expect, it, onTestFinished, vi } from 'vitest' +import type { ClientRemote } from '@deepseek-ai/dsh-api-remotes/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { SupervisedStreamOptions } from '@deepseek-ai/dsh-api-workspace-files/src/client/remote.ts' +import { FakeRemote } from '../../../api/workspace-files/tests/fake-remote.client.ts' +import { createWatch } from '../src/client/face.ts' + +const SESSION = 'directory-watch-session' as SessionId +const ROOT = '/work/app' + +function mount() { + const remote = new FakeRemote() + remote.autoReady = false + const controller = new AbortController() + const supervise = vi.spyOn(remote, '$stream') + // createWatch consumes only the stream supervisor and workspaceFiles.changes. + const iterator = createWatch(remote as unknown as ClientRemote)(SESSION, ROOT, controller.signal)[Symbol.asyncIterator]() + onTestFinished(async () => { + controller.abort() + const returned = iterator.return?.() + await remote.dispose() + await returned + }) + return { remote, controller, iterator, supervise } +} + +describe('createWatch', () => { + it('opens the requested directory, accepts ready, and forwards changes without accepting them', async () => { + const h = mount() + const first = h.iterator.next() + const watch = await h.remote.waitForChanges(0) + expect(watch).toMatchObject({ sessionId: SESSION, path: ROOT }) + expect(h.supervise.mock.calls[0]?.[0].name).toBe(`directory ${ROOT}`) + watch.source.push({ kind: 'ready' }) + await expect(first).resolves.toEqual({ done: false, value: 'ready' }) + expect(h.remote.calls).toEqual(['changes', 'accept']) + const next = h.iterator.next() + watch.source.push({ kind: 'change', change: { absolutePath: ROOT, version: 'changed' } }) + await expect(next).resolves.toEqual({ done: false, value: 'change' }) + expect(h.remote.calls).toEqual(['changes', 'accept']) + await h.iterator.return?.() + expect(watch.source.aborted).toBe(true) + }) + + it('does not create a stream for an already-cancelled node', async () => { + const h = mount() + h.controller.abort() + await expect(h.iterator.next()).resolves.toEqual({ done: true, value: undefined }) + expect(h.supervise).not.toHaveBeenCalled() + expect(h.remote.opened).toEqual([]) + }) + + it.each([false, true])('classifies a normal stream end after ready=%s and disposes it', async (ready) => { + const h = mount() + const first = h.iterator.next() + const watch = await h.remote.waitForChanges(0) + const options = h.supervise.mock.calls[0]![0] + const ended = vi.spyOn(options, 'ended') + let pending = first + if (ready) { + watch.source.push({ kind: 'ready' }) + await expect(first).resolves.toEqual({ done: false, value: 'ready' }) + pending = h.iterator.next() + } + const rejected = expect(pending).rejects.toThrow(`Directory watch ended: ${ROOT}`) + watch.source.end() + await rejected + expect(ended).toHaveBeenCalledExactlyOnceWith(ready) + expect(h.remote.disposed).toEqual([`directory ${ROOT}`]) + expect(watch.source.aborted).toBe(true) + }) + + it('awaits disposal on consumer return and removes the node abort listener', async () => { + const h = mount() + const gate = h.remote.holdDisposal() + const first = h.iterator.next() + const watch = await h.remote.waitForChanges(0) + watch.source.push({ kind: 'ready' }) + await first + const result = h.supervise.mock.results[0] + if (result?.type !== 'return') throw new Error('expected a supervised stream') + const dispose = vi.spyOn(result.value, 'dispose') + let closed = false + const closing = h.iterator.return!().then((value) => { closed = true; return value }) + try { + await h.remote.waitForDispose(0) + expect(closed).toBe(false) + expect(watch.source.aborted).toBe(true) + gate.resolve(undefined) + await expect(closing).resolves.toEqual({ done: true, value: undefined }) + expect(dispose).toHaveBeenCalledTimes(1) + h.controller.abort() + expect(dispose).toHaveBeenCalledTimes(1) + } finally { + gate.resolve(undefined) + await closing + } + }) + + it('does not yield a frame delivered after the node cancels', async () => { + const h = mount() + const supervise = FakeRemote.prototype.$stream.bind(h.remote) + const pulled = Promise.withResolvers() + const deliver = Promise.withResolvers() + h.supervise.mockImplementation(function(options: SupervisedStreamOptions) { + const stream = supervise(options) + return { + dispose: () => stream.dispose(), + async *[Symbol.asyncIterator]() { + for await (const item of stream) { + pulled.resolve(undefined) + await deliver.promise + yield item + } + }, + } + }) + const pending = h.iterator.next() + try { + const watch = await h.remote.waitForChanges(0) + watch.source.push({ kind: 'ready' }) + await pulled.promise + h.controller.abort() + await h.remote.waitForDispose(0) + deliver.resolve(undefined) + await expect(pending).resolves.toEqual({ done: true, value: undefined }) + expect(h.remote.calls).toEqual(['changes']) + expect(watch.source.aborted).toBe(true) + } finally { + deliver.resolve(undefined) + } + }) +}) diff --git a/packages/client/ui-sidebar-right/README.i18n.yaml b/packages/client/ui-sidebar-right/README.i18n.yaml index 7127827b55..4de8cd03f3 100644 --- a/packages/client/ui-sidebar-right/README.i18n.yaml +++ b/packages/client/ui-sidebar-right/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-right/README.md -README.md: e838f952530d71510c14cbb948d64c8fac319787 -README.zh.md: a529c31c8abb4c4927683e128cc0938a155e51c0 +README.md: dbe09becc8f5f322720c21f89e5042a1f423e6f0 +README.zh.md: 357f9366298dcc4313068d78f73105691d82c129 diff --git a/packages/client/ui-sidebar-right/README.md b/packages/client/ui-sidebar-right/README.md index e838f95253..dbe09becc8 100644 --- a/packages/client/ui-sidebar-right/README.md +++ b/packages/client/ui-sidebar-right/README.md @@ -90,6 +90,8 @@ Two more seats extend what is already there: `sidebar.right.tab.guide` (chain) r `openResource(address, options?)` and `openTab(kind, options?)` are the navigation controller, and every way into the column calls one of them: the conversation's file links and a tool row's line reference (`openResource(fileAddress, { params: { line } })`), the strip's add control and a guide entry box (`openTab`), a file tree's rows (`tab.actions.openResource`). A resource address is a `dsh-resource:///…` URI; without `options.kind` the registry claims it (globs and `canOpen`, best band wins), with it that kind's type in force opens it. A page is named by kind; the tab is recorded under an address this package composes and nobody else spells (`contract/seed.ts`). Both run the same steps as one history entry: a resource tab already showing the same (kind, contentId) is focused wherever it sits unless `revealIfOpened: false`; ordinary page tabs always deduplicate within the target pane, regardless of that option; otherwise a new tab lands in `options.replaceTab`'s pane and slot (closing that tab), else `options.paneId`, else the active docked pane; the panel expands, because content the user cannot see is not opened. Then the Tab domain records the navigation — `params` reach the body as `navigation.params`, with `revision` stepped — outside the layout history. `params` is typed by what is opened: a viewer for a resource type merges its entry into `SidebarRightResourceParamsMap` (the text preview declares `{ line?: number }`); a page type that takes parameters merges into `SidebarRightTabParamsMap` under its kind; values are JSON-shaped by convention, unchecked at run time. An address outside `dsh-resource://`, one no type claims, or a kind nothing registered throws: that is a wiring mistake, not a user error. +`preferNewPane: true` first asks the target docked pane to split under the normal two-pane and room rules, then falls back to that pane when no split is available. `replaceTab` takes precedence and disables this preference. + `close(tabId)` closes a tab; `active()` reads the active tab. `isExpanded()` and `toggleExpanded()` read and drive the column's expansion; the presentation switch is the panel's own control and not part of this face. Layout operations, for callers that arrange the column programmatically, each recorded like the gesture it stands in for: `focus(tabId)` focuses a tab and its pane; `split(paneId?)` splits a docked pane (the active one by default) under the same pane budget and room rule as the strip's control and returns the new pane's id, or `undefined` — recording nothing — when it cannot; `float(tabId, rect?)` takes a docked tab out into a panel; `dock(paneId)` returns a floating panel to the active docked pane. A tab or pane that does not exist, or already is where the call would put it, is left alone. The face exposes operations only: no layout snapshot, no operation log, no lookup by address. `_undo()` / `_redo()` step the mounted surface's history; they are `@internal` — the sequence has no user-facing control, and these exist for tests. Commands need a mounted session surface; with none, they throw rather than write into a surface nobody draws. diff --git a/packages/client/ui-sidebar-right/README.zh.md b/packages/client/ui-sidebar-right/README.zh.md index a529c31c8a..357f936629 100644 --- a/packages/client/ui-sidebar-right/README.zh.md +++ b/packages/client/ui-sidebar-right/README.zh.md @@ -90,6 +90,8 @@ tab 类型分两阶段注册,随包发布的引导类型走的正是别的包 `openResource(address, options?)` 与 `openTab(kind, options?)` 是导航控制器,进入该列的每条路都调用其中之一:会话区的文件链接与工具行的行号引用(`openResource(fileAddress, { params: { line } })`),tab 条的添加控件与引导入口框(`openTab`),文件树的行(`tab.actions.openResource`)。资源地址是 `dsh-resource:///…` URI;不带 `options.kind` 时由注册表认领(glob 与 `canOpen`,最高档胜出),带它时由该 kind 生效的类型打开。页按 kind 命名;tab 记录在本包拼出、别处无人书写的地址下(`contract/seed.ts`)。两者以同一组步骤作为一条历史运行:已展示同一 (kind, contentId) 的资源 tab 被聚焦,不限所在分栏,除非 `revealIfOpened: false`;页 tab 始终只在目标分栏内去重,不受该选项影响;否则新 tab 落到 `options.replaceTab` 所在的格与位置(并关掉那个 tab),再退而落到 `options.paneId`,再退而落到活跃停靠格;面板展开,因为用户看不到的内容不算打开。随后 Tab 域记录这次导航——`params` 以 `navigation.params` 抵达正文,`revision` 递增——不进布局历史。`params` 按所开之物定型:某资源类型的查看器把自己那项并入 `SidebarRightResourceParamsMap`(文本预览声明 `{ line?: number }`);接受参数的页类型按其 kind 并入 `SidebarRightTabParamsMap`;值约定为 JSON 形状,运行时不校验。`dsh-resource://` 之外的地址、无人认领的地址、或未注册的 kind 都会 throw:那是接线错误,不是用户错误。 +`preferNewPane: true` 会先按普通的两格上限与空间规则尝试分割目标停靠格,无法分栏时回退到该格。`replaceTab` 优先并会禁用这项偏好。 + `close(tabId)` 关闭一个 tab;`active()` 读取活动 tab。`isExpanded()` 与 `toggleExpanded()` 读取并驱动该列的展开;形态切换是面板自己的控件,不属于这个接口。布局操作供以编程方式安排该列的调用方使用,每个都像它替代的手势一样被记录:`focus(tabId)` 聚焦一个 tab 及其格;`split(paneId?)` 在与 tab 条控件相同的格预算与空间规则下分栏一个停靠格(默认活跃格),返回新格的 id,做不到时返回 `undefined`——且不记录任何东西;`float(tabId, rect?)` 把停靠 tab 浮出为浮窗;`dock(paneId)` 把浮窗放回活跃停靠格。不存在的 tab 或格、或已处于调用目标状态的,都原样不动。该接口只暴露操作:没有布局快照、没有操作日志、没有按地址查找。`_undo()` / `_redo()` 步进已挂载停靠面的历史;它们是 `@internal`——序列没有面向用户的控件,这两个只为测试存在。命令需要一个已挂载的会话停靠面;没有时它们 throw,而不是写进一个没人绘制的面里。 diff --git a/packages/client/ui-sidebar-right/package.json b/packages/client/ui-sidebar-right/package.json index 2057a9dcae..a62d0a9aaf 100644 --- a/packages/client/ui-sidebar-right/package.json +++ b/packages/client/ui-sidebar-right/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-sidebar-right", "description": "Right Sidebar: the docking surface's session-bound state, its panel and header expand control, and the navigation service over it", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-sidebar-right/src/client/contract/slots.ts b/packages/client/ui-sidebar-right/src/client/contract/slots.ts index da421aa39f..6095752b27 100644 --- a/packages/client/ui-sidebar-right/src/client/contract/slots.ts +++ b/packages/client/ui-sidebar-right/src/client/contract/slots.ts @@ -113,6 +113,8 @@ export interface SidebarRightTabNavigation { export interface SidebarRightTabPlacement { /** Land a new tab in this pane instead. */ readonly paneId?: PaneId + /** Prefer a new pane for new content; use the target pane when splitting is unavailable. */ + readonly preferNewPane?: boolean /** Resource tabs reveal existing content by default; `false` permits duplicates. Pages always deduplicate within the target pane. */ readonly revealIfOpened?: boolean /** `true` opens in this tab's place — its pane and strip slot — and closes this tab in the same step. */ diff --git a/packages/client/ui-sidebar-right/src/client/service.ts b/packages/client/ui-sidebar-right/src/client/service.ts index 682b81c69b..9fb5aaf413 100644 --- a/packages/client/ui-sidebar-right/src/client/service.ts +++ b/packages/client/ui-sidebar-right/src/client/service.ts @@ -115,6 +115,8 @@ export interface SidebarRightBinding { export interface SidebarRightPlacement { /** Land a new tab in this pane instead of the active docked one. */ readonly paneId?: PaneId + /** Prefer a new pane for new content; use the target pane when splitting is unavailable. */ + readonly preferNewPane?: boolean /** Take this tab's place — its pane and its strip slot — and close it in the same step. */ readonly replaceTab?: TabId /** @@ -378,15 +380,30 @@ export class SidebarRightController implements ISidebarRight { placement: SidebarRightPlacement, params: SidebarRightNavigationParams, ): void { + const surface = this.adopted.get(sessionId)?.store.getSnapshot().bySession[sessionId] + ?? (this.binding?.sessionId === sessionId ? this.binding.surfaces[sessionId] : undefined) + const targetPane = surface === undefined ? undefined : placement.paneId ?? activeDockPaneId(surface.layout) + const target = targetPane === undefined ? undefined : surface?.layout.nodes[targetPane] + const preferNewPane = placement.preferNewPane === true + && placement.replaceTab === undefined + && surface !== undefined + && target?.kind === 'pane' + && target.host === 'dock' + && target.tabs.length > 0 + && canSplit(surface.layout) + && dockPaneIds(surface.layout).length < 2 + && this.binding?.sessionId === sessionId + && this.binding.canSplitPane(target.id) const commit = (): void => { actions.openContent(sessionId, { kind: claim.kind, contentId: claim.contentId, title: claim.title, ...placement.paneId === undefined ? {} : { paneId: placement.paneId }, + ...preferNewPane ? { preferNewPane: true } : {}, ...placement.replaceTab === undefined ? {} : { replaceTab: placement.replaceTab }, ...placement.revealIfOpened === undefined ? {} : { revealIfOpened: placement.revealIfOpened }, }, (tabId) => { this.tabDomain.navigate(sessionId, tabId, { address, params }) }) } - const layout = this.adopted.get(sessionId)?.store.getSnapshot().bySession[sessionId]?.layout + const layout = surface?.layout const replaced = placement.replaceTab === undefined ? undefined : layout?.tabs[placement.replaceTab] const revealed = layout === undefined || placement.revealIfOpened === false ? undefined : findContentTab(layout, claim.contentId, claim.kind) diff --git a/packages/client/ui-sidebar-right/src/client/shell/ExpandButton.tsx b/packages/client/ui-sidebar-right/src/client/shell/ExpandButton.tsx index 7d9b2d276e..a89486d27c 100644 --- a/packages/client/ui-sidebar-right/src/client/shell/ExpandButton.tsx +++ b/packages/client/ui-sidebar-right/src/client/shell/ExpandButton.tsx @@ -14,7 +14,7 @@ * on the other edge. */ import type { ReactNode } from 'react' -import { IconPanelLeftOutline16, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconPanelLeftOutlineRegular, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import type { createSidebarRightStore } from '../stores.ts' @@ -41,7 +41,7 @@ export function ExpandButton({ sessionId, useStore, actions, t }: ExpandButtonPr data-sidebar-right-expand onClick={() => { actions.setExpanded(sessionId, true) }} > - + ) diff --git a/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx b/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx index 476134c47d..1a517f4684 100644 --- a/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx +++ b/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx @@ -30,7 +30,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react' import type { ReactNode, RefObject } from 'react' import { createPortal } from 'react-dom' -import { IconPanelLeftOutline16, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconPanelLeftOutlineRegular, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' import type { HostObservable, InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, } from '@deepseek-ai/dsh-client-ui-slots' @@ -225,28 +225,21 @@ function titlesFor(panel: PanelProps): TabRenderer { return tab => } -/** Expand-to-viewport glyph: four frame corners (figma extract). */ +/** Expand-to-viewport glyph from the shared product artwork. */ function FullscreenGlyph(): ReactNode { return ( ) } -/** Restore-from-fullscreen glyph: two corners drawn inward (figma extract). */ +/** Restore-from-fullscreen glyph from the shared product artwork. */ function ExitFullscreenGlyph(): ReactNode { return ( ) } @@ -279,7 +272,7 @@ function PanelChrome({ sessionId, fullscreen, autoFullscreen, actions, t }: Pick data-sidebar-right-toggle onClick={() => { actions.toggleExpanded(sessionId) }} > - + diff --git a/packages/client/ui-sidebar-right/src/client/stores.ts b/packages/client/ui-sidebar-right/src/client/stores.ts index 6778152500..9478980de6 100644 --- a/packages/client/ui-sidebar-right/src/client/stores.ts +++ b/packages/client/ui-sidebar-right/src/client/stores.ts @@ -32,7 +32,7 @@ import type { DockMode, DockZone, FloatRect, History, LayoutOp, LayoutState, Mint, PaneId, SplitId, TabId, TabRecord, } from '@deepseek-ai/dsh-client-ui-dockkit' import { - activeDockPaneId, createInitialState, dockPaneIds, EMPTY_HISTORY, findPaneContentTab, findTabPane, getPane, + activeDockPaneId, createInitialState, dockPaneIds, EMPTY_HISTORY, findContentTab, findPaneContentTab, findTabPane, getPane, planDropTab, planDuplicateTab, planFloatTab, planOpenContent, planPlaceTab, planResizeSplit, planSetExpanded, planSetMode, planSettle, planSplitPane, planUnfloatPane, record, replay, stepBack, stepForward, } from '@deepseek-ai/dsh-client-ui-dockkit' @@ -87,6 +87,8 @@ export interface OpenContentIntent { readonly title: string /** Land a new tab in this pane. */ readonly paneId?: PaneId + /** Split the target pane and put new content alone in the new pane. */ + readonly preferNewPane?: boolean /** Take this tab's pane and slot, and close it in the same entry. */ readonly replaceTab?: TabId /** Resource tabs reveal an existing identity by default; `false` permits duplicates. Pages always deduplicate within the target pane. */ @@ -319,18 +321,30 @@ export function createSidebarRightStore( // cross-pane reveal is for resources only. const page = contentId === pageAddress(kind) const held = page ? panePage(state, paneId ?? activeDockPaneId(state), kind) : undefined - const planned = held !== undefined - ? { ops: [{ type: 'focusTab' as const, tabId: held }], tabId: held } - : planOpenContent(state, mint, { - kind, - contentId, - title, - ...paneId === undefined ? {} : { paneId }, - ...index === undefined ? {} : { index }, - ...page - ? { revealIfOpened: false } - : intent.revealIfOpened === undefined ? {} : { revealIfOpened: intent.revealIfOpened }, + const revealed = page + ? held + : intent.revealIfOpened === false ? undefined : findContentTab(state, contentId, kind) + let openedInNewPane: TabId | undefined + const split = intent.preferNewPane === true && replace === undefined && revealed === undefined + ? planSplitPane(state, mint, paneId, (id) => { + openedInNewPane = id + return { id, kind, contentId, title } }) + : [] + const planned = revealed !== undefined + ? { ops: [{ type: 'focusTab' as const, tabId: revealed }], tabId: revealed } + : openedInNewPane !== undefined + ? { ops: split, tabId: openedInNewPane } + : planOpenContent(state, mint, { + kind, + contentId, + title, + ...paneId === undefined ? {} : { paneId }, + ...index === undefined ? {} : { index }, + ...page + ? { revealIfOpened: false } + : intent.revealIfOpened === undefined ? {} : { revealIfOpened: intent.revealIfOpened }, + }) ops.push(...planned.ops) if (replace !== undefined && replace !== planned.tabId) ops.push({ type: 'closeTab', tabId: replace }) settled(planned.tabId) diff --git a/packages/client/ui-sidebar-right/src/client/tab-domain.ts b/packages/client/ui-sidebar-right/src/client/tab-domain.ts index 53b259584e..8dfe2b3ba0 100644 --- a/packages/client/ui-sidebar-right/src/client/tab-domain.ts +++ b/packages/client/ui-sidebar-right/src/client/tab-domain.ts @@ -158,6 +158,7 @@ export class TabDomain { ? { replaceTab: tabId } : held.paneId === undefined ? {} : { paneId: held.paneId }, ...placement.paneId === undefined ? {} : { paneId: placement.paneId }, + ...placement.preferNewPane === undefined ? {} : { preferNewPane: placement.preferNewPane }, ...placement.revealIfOpened === undefined ? {} : { revealIfOpened: placement.revealIfOpened }, }) const held: Held = { diff --git a/packages/client/ui-sidebar-right/src/client/tabs/guide/GuideBody.module.css b/packages/client/ui-sidebar-right/src/client/tabs/guide/GuideBody.module.css index eb4676a3e2..07f68cee8f 100644 --- a/packages/client/ui-sidebar-right/src/client/tabs/guide/GuideBody.module.css +++ b/packages/client/ui-sidebar-right/src/client/tabs/guide/GuideBody.module.css @@ -56,7 +56,7 @@ font: inherit; text-align: left; background: var(--dsw-alias-bg-layer-1); - border: 0.5px solid var(--dsw-alias-border-l4); + border: 0.5px solid var(--dsw-alias-border-l3); border-radius: 24px; cursor: pointer; } @@ -95,7 +95,7 @@ .entryTitle { overflow: hidden; - font-size: 15px; + font-size: 14px; line-height: 1.4; white-space: nowrap; text-overflow: ellipsis; diff --git a/packages/client/ui-sidebar-right/src/client/tabs/guide/GuideTitle.tsx b/packages/client/ui-sidebar-right/src/client/tabs/guide/GuideTitle.tsx index 3c763ce95a..573c476f2d 100644 --- a/packages/client/ui-sidebar-right/src/client/tabs/guide/GuideTitle.tsx +++ b/packages/client/ui-sidebar-right/src/client/tabs/guide/GuideTitle.tsx @@ -18,8 +18,8 @@ import css from './GuideBody.module.css' export function CompassGlyph({ size = 16, className }: IconProps): ReactNode { return ( ) } @@ -35,19 +35,8 @@ export function CompassGlyph({ size = 16, className }: IconProps): ReactNode { export function CubeGlyph({ size = 16, className }: IconProps): ReactNode { return ( ) } diff --git a/packages/client/ui-sidebar-right/tests/service.client.spec.ts b/packages/client/ui-sidebar-right/tests/service.client.spec.ts index 35ffc47444..889497fd2a 100644 --- a/packages/client/ui-sidebar-right/tests/service.client.spec.ts +++ b/packages/client/ui-sidebar-right/tests/service.client.spec.ts @@ -169,6 +169,39 @@ describe('SidebarRightController — opening', () => { expect(findTabPane(layout(), tabOf('a.txt')).id).toBe(left) }) + it('opens new content alone in a preferred pane and falls back to the current pane at the limit', () => { + const { controller, publish, layout, tabOf, entries, expand } = harness() + expand() + publish() + const before = entries() + controller.openResource('dsh-resource://file/session/s-test/a.txt', { preferNewPane: true }) + const a = tabOf('a.txt') + const pane = findTabPane(layout(), a) + expect(dockPaneIds(layout())).toHaveLength(2) + expect(pane.tabs).toEqual([a]) + expect(entries()).toBe(before + 1) + + publish() + controller.openResource('dsh-resource://file/session/s-test/b.txt', { preferNewPane: true }) + expect(dockPaneIds(layout())).toHaveLength(2) + expect(findTabPane(layout(), tabOf('b.txt')).id).toBe(pane.id) + }) + + it('falls back when the pane is too narrow and reveals existing content without splitting', () => { + const { controller, publish, layout, tabOf, room, expand } = harness() + expand() + publish() + room.allowed = false + controller.openResource('dsh-resource://file/session/s-test/a.txt', { preferNewPane: true }) + expect(dockPaneIds(layout())).toHaveLength(1) + publish() + + room.allowed = true + controller.openResource('dsh-resource://file/session/s-test/a.txt', { preferNewPane: true }) + expect(dockPaneIds(layout())).toHaveLength(1) + expect(getPane(layout(), layout().activePaneId).activeTabId).toBe(tabOf('a.txt')) + }) + it('takes the replaced tab\'s pane and slot, closes it, and records one entry', () => { const { controller, publish, layout, tabOf, entries } = harness() publish() @@ -462,6 +495,22 @@ describe('SidebarRightController — a tab\'s own actions', () => { const A_TXT = 'dsh-resource://file/session/s-test/a.txt' const B_TXT = 'dsh-resource://file/session/s-test/b.txt' + it('opens into an adopted store before that store has created its session surface', () => { + const { controller, adopt, instance, titles } = harness() + const releaseOwn = adopt(SESSION, instance) + const other = createSidebarRightStore(() => ({ kind: 'guide', title: 'seed' })).create(OTHER) + const releaseBinding = controller.bind({ + sessionId: OTHER, + actions: other.actions, + surfaces: other.getSnapshot().bySession, + canSplitPane: () => true, + }) + controller.openResourceIn(SESSION, A_TXT) + expect(titles()).toContain('a.txt') + releaseBinding() + releaseOwn() + }) + it('land in the session the tab is in through its own adopted store, after another session\'s seat took over', () => { const { controller, adopt, instance, publish, layout, expand } = harness() const releaseOwn = adopt(SESSION, instance) diff --git a/packages/client/ui-sidebar-right/tests/tab-domain.client.spec.ts b/packages/client/ui-sidebar-right/tests/tab-domain.client.spec.ts index 793bda8083..7c9a10c46e 100644 --- a/packages/client/ui-sidebar-right/tests/tab-domain.client.spec.ts +++ b/packages/client/ui-sidebar-right/tests/tab-domain.client.spec.ts @@ -195,16 +195,19 @@ describe('TabDomain — a tab\'s own actions', () => { expect(navigator.closeIn).toHaveBeenCalledWith(SESSION, tabId) }) - it('passes revealIfOpened through, so a tab may open a second copy beside itself', () => { + it('passes placement options through, so a tab may prefer a new pane and open a second copy', () => { const { domain, navigator, controller, current } = harness() const tabId = controller.openContent({ kind: 'text', contentId: 'dsh-resource://file/session/s-one/a.txt', title: 'a' }) domain.sync(SESSION, current()) const { tabActions } = domain.occurrence(SESSION, recordOf(current(), tabId)) - tabActions.openResource('dsh-resource://file/session/s-one/a.txt', { revealIfOpened: false }) + tabActions.openResource('dsh-resource://file/session/s-one/a.txt', { + preferNewPane: true, + revealIfOpened: false, + }) expect(navigator.openResourceIn).toHaveBeenLastCalledWith( SESSION, 'dsh-resource://file/session/s-one/a.txt', - { paneId: current().rootId, revealIfOpened: false }, + { paneId: current().rootId, preferNewPane: true, revealIfOpened: false }, ) }) }) diff --git a/packages/client/ui-sidebar-terminal/package.json b/packages/client/ui-sidebar-terminal/package.json index 1d80ce2d79..d9a9fa48ac 100644 --- a/packages/client/ui-sidebar-terminal/package.json +++ b/packages/client/ui-sidebar-terminal/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-sidebar-terminal", "description": "Interactive shell tabs for the right Sidebar", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-sidebar-terminal/src/client/TerminalGuide.module.css b/packages/client/ui-sidebar-terminal/src/client/TerminalGuide.module.css index 3380810d55..1926b98b1c 100644 --- a/packages/client/ui-sidebar-terminal/src/client/TerminalGuide.module.css +++ b/packages/client/ui-sidebar-terminal/src/client/TerminalGuide.module.css @@ -1,8 +1,9 @@ -.entry { box-sizing: border-box; display: flex; align-items: stretch; width: 100%; overflow: hidden; border: 0.5px solid var(--dsw-alias-border-l4); border-radius: 24px; background: var(--dsw-alias-bg-layer-1); } +.entry { box-sizing: border-box; display: flex; align-items: stretch; width: 100%; overflow: hidden; border: 0.5px solid var(--dsw-alias-border-l3); border-radius: 24px; background: var(--dsw-alias-bg-layer-1); } .main { flex: 1; justify-content: flex-start; gap: 14px; min-width: 0; height: auto; min-height: 56px; padding: 14px 20px; border-radius: 24px 0 0 24px; text-align: left; } .icon { flex: none; } .text { display: flex; flex-direction: column; gap: 3px; min-width: 0; } -.title { overflow: hidden; color: var(--dsw-alias-label-primary); font-size: 15px; line-height: 1.4; white-space: nowrap; text-overflow: ellipsis; } +.title { overflow: hidden; color: var(--dsw-alias-label-primary); font-size: 14px; line-height: 1.4; white-space: nowrap; text-overflow: ellipsis; } .description { overflow: hidden; color: var(--dsw-alias-label-caption); font-size: 13px; line-height: 1.4; white-space: nowrap; text-overflow: ellipsis; } .trigger { align-self: stretch; flex: none; height: auto; width: 44px; padding: 0; border-radius: 0 24px 24px 0; } +.trigger svg { width: 18px; height: 18px; color: var(--dsw-alias-label-tertiary); } .menu { display: flex; align-self: stretch; flex: none; } diff --git a/packages/client/ui-sidebar-terminal/src/client/TerminalGuide.tsx b/packages/client/ui-sidebar-terminal/src/client/TerminalGuide.tsx index 68fb351686..4011f17de7 100644 --- a/packages/client/ui-sidebar-terminal/src/client/TerminalGuide.tsx +++ b/packages/client/ui-sidebar-terminal/src/client/TerminalGuide.tsx @@ -1,6 +1,6 @@ /** Shell launch menu owned by the terminal provider's guide entry. */ import { useEffect, useState, type ReactNode } from 'react' -import { Button, IconChevronDownOutline14, Menu } from '@deepseek-ai/dsh-client-ui-primitives' +import { Button, IconChevronDownOutlineRegular, Menu } from '@deepseek-ai/dsh-client-ui-primitives' import type { TerminalLaunchShells } from '@deepseek-ai/dsh-api-terminal-controller/client' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client' @@ -67,7 +67,7 @@ export function TerminalGuide({ title, description, kind, useTabInfo, loadShells }} anchor={} /> diff --git a/packages/client/ui-sidebar-terminal/src/client/TerminalIcon.tsx b/packages/client/ui-sidebar-terminal/src/client/TerminalIcon.tsx index df5525a08d..4c0fa3d11c 100644 --- a/packages/client/ui-sidebar-terminal/src/client/TerminalIcon.tsx +++ b/packages/client/ui-sidebar-terminal/src/client/TerminalIcon.tsx @@ -8,7 +8,8 @@ import type { IconProps } from '@deepseek-ai/dsh-client-ui-primitives' */ export function TerminalIcon(): ReactNode { return } @@ -19,7 +20,7 @@ export function TerminalIcon(): ReactNode { */ export function TerminalGuideIcon({ size = 26, className }: IconProps): ReactNode { return } diff --git a/packages/client/ui-sidebar-terminal/src/client/terminal.tsx b/packages/client/ui-sidebar-terminal/src/client/terminal.tsx index 9a64861bb3..1e3cd760d3 100644 --- a/packages/client/ui-sidebar-terminal/src/client/terminal.tsx +++ b/packages/client/ui-sidebar-terminal/src/client/terminal.tsx @@ -2,7 +2,7 @@ import { useEffect, useLayoutEffect, useRef, type ReactNode } from 'react' import { Terminal } from '@xterm/xterm' import { FitAddon } from '@xterm/addon-fit' -import { Button, IconPlusOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +import { Button, IconPlusOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' import type { TerminalViewState, TerminalView } from '@deepseek-ai/dsh-api-terminal-controller/client' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type { ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client' @@ -29,7 +29,7 @@ export function TerminalBody({ useTabInfo, useTerminal, useTheme, view, t }: Ter useEffect(() => model.mount(), [model]) if (state === undefined) return null const newTerminal = diff --git a/packages/client/ui-sidebar/README.i18n.yaml b/packages/client/ui-sidebar/README.i18n.yaml index b9e04d95cb..74a544bbbe 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: a0b9ffe8ca601fade50ff86fc1921d6b41b84748 -README.zh.md: 44b08c8ef76ec266c813c8199db8291f47db3d53 +README.md: 513e9d3151aba64b640475c3cd8c2f993df077db +README.zh.md: 69050dc8c530bf4c794d42113f2679fe41891e5f diff --git a/packages/client/ui-sidebar/README.md b/packages/client/ui-sidebar/README.md index a0b9ffe8ca..513e9d3151 100644 --- a/packages/client/ui-sidebar/README.md +++ b/packages/client/ui-sidebar/README.md @@ -45,7 +45,7 @@ On Windows Electron, `html[data-windows-titlebar]` fixes the sidebar toggle in t ### macOS desktop -Under `html[data-platform='darwin']` (set only by the desktop preload) the expanded column opens with a 52px top strip that clears the hiddenInset traffic lights, carries the collapse toggle, and acts as the window drag region; collapsing hides the column entirely instead of leaving the rail. The package registers `HeaderLeadingControls` into the conversation header's `conversation.session.header.leading` seat — the open-sidebar and New Session controls shown, purely via CSS against the AppFrame-published `data-sidebar-collapsed` attribute, only while the column is hidden. Rationale and the window-integration contract: the [macOS hidden-titlebar Agent Note](../../../.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.md). +Under `html[data-platform='darwin']` (set only by the desktop preload) the expanded column opens with a 52px top strip that clears the hiddenInset traffic lights, carries the collapse toggle, and acts as the window drag region; collapsing hides the column entirely instead of leaving the rail. The resident header remains mounted without a selected Session. The package registers `HeaderLeadingControls` into the conversation header's root-scoped `conversation.header.leading` seat — the open-sidebar and New Session controls shown, purely via CSS against the AppFrame-published `data-sidebar-collapsed` attribute, only while the column is hidden. Rationale and the window-integration contract: the [macOS hidden-titlebar Agent Note](../../../.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.md). ### Scrollbars diff --git a/packages/client/ui-sidebar/README.zh.md b/packages/client/ui-sidebar/README.zh.md index 44b08c8ef7..69050dc8c5 100644 --- a/packages/client/ui-sidebar/README.zh.md +++ b/packages/client/ui-sidebar/README.zh.md @@ -45,7 +45,7 @@ dsh Web 客户端的侧边栏让用户识别当前构建、启动新会话、将 ### macOS 桌面 -在 `html[data-platform='darwin']`(仅由桌面 preload 设置)下,展开的侧边栏列顶部有一条 52px 的顶部条:避开 hiddenInset 红绿灯、承载收起按钮,并作为窗口拖拽区;收起时整列隐藏而非保留轨道。本包向会话头部的 `conversation.session.header.leading` 座注册 `HeaderLeadingControls`——打开侧边栏与 New Session 两个控件,纯由 CSS 依据 AppFrame 发布的 `data-sidebar-collapsed` 属性仅在列隐藏时显示。设计依据与窗口集成约定见 [macOS 隐藏标题栏 Agent Note](../../../.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.zh.md)。 +在 `html[data-platform='darwin']`(仅由桌面 preload 设置)下,展开的侧边栏列顶部有一条 52px 的顶部条:避开 hiddenInset 红绿灯、承载收起按钮,并作为窗口拖拽区;收起时整列隐藏而非保留轨道。未选中会话时,常驻头部仍保持挂载。本包向会话头部的根作用域 `conversation.header.leading` 座注册 `HeaderLeadingControls`——打开侧边栏与 New Session 两个控件,纯由 CSS 依据 AppFrame 发布的 `data-sidebar-collapsed` 属性仅在列隐藏时显示。设计依据与窗口集成约定见 [macOS 隐藏标题栏 Agent Note](../../../.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.zh.md)。 ### 滚动条 diff --git a/packages/client/ui-sidebar/package.json b/packages/client/ui-sidebar/package.json index b62b246018..cb417bfdee 100644 --- a/packages/client/ui-sidebar/package.json +++ b/packages/client/ui-sidebar/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-sidebar", "description": "Sidebar plugin: session multi-level tree, search, grouping, state dots", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-sidebar/src/client/HeaderLeadingControls.tsx b/packages/client/ui-sidebar/src/client/HeaderLeadingControls.tsx index 77a98caa63..e4af1e420d 100644 --- a/packages/client/ui-sidebar/src/client/HeaderLeadingControls.tsx +++ b/packages/client/ui-sidebar/src/client/HeaderLeadingControls.tsx @@ -1,6 +1,6 @@ -/** macOS-desktop conversation-header controls for the fully hidden sidebar. */ +/** macOS-desktop resident-header controls for the fully hidden sidebar. */ import { - IconNewChatOutline16, IconPanelLeftOutline16, isDarwinDesktop, Tooltip, + IconNewChatOutlineMedium, IconPanelLeftOutlineRegular, isDarwinDesktop, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' // Type-only: pulls the conversation header slot declarations. @@ -8,14 +8,14 @@ import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import type { SidebarRootInjected } from './contract/slots.ts' import css from './HeaderLeadingControls.module.css' -/** Full props of the conversation-header leading occupant. */ +/** Full props of the resident-header leading occupant. */ export type HeaderLeadingControlsProps = - PropsRuntime<'conversation.session.header.leading'> + PropsRuntime<'conversation.header.leading'> & InjectFace & PropsLocale<'sidebar'> /** - * Sidebar-open and New Session controls in the conversation header's leading + * Sidebar-open and New Session controls in the resident header's leading * seat. On macOS desktop a collapsed sidebar hides entirely (no rail), taking * both controls off screen; this occupant puts them back beside the traffic * lights. Mounted whenever the platform matches; visibility rides the @@ -35,7 +35,7 @@ export function HeaderLeadingControls({ toggleSidebar, startSession, t }: Header aria-label={t('toggle.open')} onClick={() => { toggleSidebar() }} > - + @@ -45,7 +45,7 @@ export function HeaderLeadingControls({ toggleSidebar, startSession, t }: Header aria-label={t('session.new.label')} onClick={() => { startSession() }} > - + diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx index 6ed94f3b0d..ea362a51f2 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx @@ -19,7 +19,7 @@ import { useEffect, useRef, useState } from 'react' import clsx from 'clsx' import { - FishLogo, IconNewChatOutline16, IconPanelLeftOutline16, isDarwinDesktop, Tooltip, + FishLogo, IconNewChatOutlineMedium, IconPanelLeftOutlineRegular, isDarwinDesktop, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' import type { InjectFace, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type { @@ -182,7 +182,7 @@ export function SidebarRoot({ )} {/* Rail icons render at 18 (figma rail spec); expanded keeps the glyph-native sizes. */} - + {!wide && renderSlot('sidebar.toggle.badge', {})} @@ -245,7 +245,7 @@ export function SidebarRoot({ aria-label={t('session.new.label')} onClick={() => { startSession() }} > - + {wide && {t('session.new')}} diff --git a/packages/client/ui-sidebar/src/client/index.ts b/packages/client/ui-sidebar/src/client/index.ts index 573ed2cb61..f1e3a38ce1 100644 --- a/packages/client/ui-sidebar/src/client/index.ts +++ b/packages/client/ui-sidebar/src/client/index.ts @@ -89,8 +89,8 @@ export function apply(ctx: ClientContext): void { // Session controls move into the conversation header's leading seat; the // occupant reuses the shell's injected actions and shows itself purely // through CSS against the AppFrame's data-sidebar-collapsed attribute. - ctx.slots.inject('conversation.session.header.leading', () => ctx.slots.register({ - name: 'conversation.session.header.leading', + ctx.slots.inject('conversation.header.leading', () => ctx.slots.register({ + name: 'conversation.header.leading', locale: NS, inject: injectProps, }, HeaderLeadingControls)) diff --git a/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap b/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap index d0833b1a6b..ce6cdec05b 100644 --- a/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap +++ b/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap @@ -18,10 +18,12 @@ exports[`sidebar shell snapshots > renders Windows caption controls in expanded type="button" > - + ) } diff --git a/packages/client/ui-skill/README.i18n.yaml b/packages/client/ui-skill/README.i18n.yaml index b5491aded0..83f8c75b1c 100644 --- a/packages/client/ui-skill/README.i18n.yaml +++ b/packages/client/ui-skill/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-skill/README.md -README.md: a1e9e7047f102f2610160905727450833b540082 -README.zh.md: d7669b11fc7249c857e20f3c52f93b8bfd214281 +README.md: b2687d59fc6566d9ea8a0c0d4d5b16eea38282b5 +README.zh.md: 7e6c8a7ecee2fe10d5826b91c108733bb4d3746f diff --git a/packages/client/ui-skill/README.md b/packages/client/ui-skill/README.md index a1e9e7047f..b2687d59fc 100644 --- a/packages/client/ui-skill/README.md +++ b/packages/client/ui-skill/README.md @@ -33,7 +33,7 @@ Ordinary-session candidates come from the `skills/list` Remote; the host serves ### The skill tool row -A collapsed row renders the skill glyph, `Skill` title, and requested skill name; running calls carry the transcript shimmer, failures replace the name with the first error line, and interrupted calls use the warning state. A settled row expands into a bounded `Instructions` card containing the exact durable tool output, with the standard trajectory `Inspect` affordance when available. The row derives its name, lifecycle, and body only from the frozen call/result slice supplied by ui-tool, never from the current catalog, so replay stays stable when installed skills or their descriptions change. +A collapsed row renders the skill glyph, `Skill` title, and requested skill name on the same secondary font-size axis, line height, row height, and glyph scaling as ordinary Tool rows. Every lifecycle state retains the ordinary skill glyph; running calls carry the transcript shimmer, while failures replace the name with the first error line and interruptions retain their explicit status text. A settled row expands into a bounded `Instructions` card containing the exact durable tool output, with the standard trajectory `Inspect` affordance when available; this expanded card keeps its own typography. The row derives its name, lifecycle, and body only from the frozen call/result slice supplied by ui-tool, never from the current catalog, so replay stays stable when installed skills or their descriptions change. Hovering over `/name` highlights the entire reference. Clicking a known skill opens its provider-supplied `SKILL.md` path in the right Sidebar while keeping the token editable. An uncached click shares the per-Session catalog fetch and opens when it completes, retaining the clicked Session address. Preset changes, connection resets, and plugin disposal cancel pending previews; a later click fetches the current catalog again. Skills without a file path remain invocable but have no file preview. diff --git a/packages/client/ui-skill/README.zh.md b/packages/client/ui-skill/README.zh.md index d7669b11fc..7e6c8a7ece 100644 --- a/packages/client/ui-skill/README.zh.md +++ b/packages/client/ui-skill/README.zh.md @@ -33,7 +33,7 @@ kind: "package-reference" ### skill 工具行 -收起的行显示 skill 图标、`Skill` 标题与请求加载的 skill 名称;运行中的调用带有 transcript(文本记录)的扫光效果,失败时用错误首行替换名称,中断的调用使用警告状态。已结算的行展开为一个尺寸受限的 `Instructions` 卡片,其中原样呈现持久化的工具输出;可用时还会提供标准轨迹的 `Inspect` 入口。该行的名称、生命周期与正文只派生自 ui-tool 提供的冻结调用/结果切片,绝不读取当前目录,因此即使已安装的 skill 或其描述发生变化,回放仍保持稳定。 +收起的行显示 skill 图标、`Skill` 标题与请求加载的 skill 名称,并与普通工具行使用相同的 secondary 字号轴、行高、行高度和图标缩放。所有生命周期状态都保留普通 skill 图标;运行中的调用带有 transcript(文本记录)的扫光效果,失败时以错误首行替换名称,中断则保留明确状态文本。已结算的行展开为一个尺寸受限的 `Instructions` 卡片,其中原样呈现持久化的工具输出;可用时还会提供标准轨迹的 `Inspect` 入口,展开卡片保留自身排版。该行的名称、生命周期与正文只派生自 ui-tool 提供的冻结调用/结果切片,绝不读取当前目录,因此即使已安装的 skill 或其描述发生变化,回放仍保持稳定。 鼠标移入 `/name` 时,背景覆盖整个引用。点击已知 skill 会在右侧栏打开提供方给出的 `SKILL.md` 路径,同时保留文本的可编辑性。缓存未就绪时,点击复用该 Session 的目录请求,在完成后按点击时的 Session 地址打开预览。切换预设、重置连接和插件释放会取消待处理的预览;后续点击重新获取当前目录。没有文件路径的 skill 仍可调用,但没有文件预览。 diff --git a/packages/client/ui-skill/package.json b/packages/client/ui-skill/package.json index b028fd198b..c2e6f16ce5 100644 --- a/packages/client/ui-skill/package.json +++ b/packages/client/ui-skill/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-skill", "description": "Web skill references and the dedicated skill tool row", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-skill/src/client/SkillRow.module.css b/packages/client/ui-skill/src/client/SkillRow.module.css index 4dd75f8498..34f255483d 100644 --- a/packages/client/ui-skill/src/client/SkillRow.module.css +++ b/packages/client/ui-skill/src/client/SkillRow.module.css @@ -1,4 +1,4 @@ -/* Skill toolview: Bash-matched summary row plus a bounded instructions disclosure. */ +/* Skill toolview: ToolRow-matched summary line plus a bounded instructions disclosure. */ .card { display: flex; @@ -10,7 +10,7 @@ overflow: hidden; display: flex; align-items: center; - height: 24px; + height: calc(24px + var(--dsh-content-font-delta, 0px)); min-width: 0; } @@ -41,8 +41,8 @@ .leading { position: relative; flex: none; - width: 16px; - height: 16px; + width: calc(16px + var(--dsh-content-font-delta, 0px)); + height: calc(16px + var(--dsh-content-font-delta, 0px)); display: inline-flex; align-items: center; justify-content: center; @@ -50,6 +50,11 @@ color: var(--dsw-alias-label-tertiary); } +.leading svg { + width: calc(14px + var(--dsh-content-font-delta, 0px)); + height: calc(14px + var(--dsh-content-font-delta, 0px)); +} + .chevron { color: var(--dsw-alias-label-secondary); } @@ -78,8 +83,8 @@ .title { flex: none; - font-size: 14px; - line-height: 24px; + font-size: var(--dsh-content-font-size-secondary, 13px); + line-height: calc(24px + var(--dsh-content-font-delta, 0px)); color: var(--dsw-alias-label-secondary); } @@ -98,8 +103,8 @@ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - font-size: 14px; - line-height: 24px; + font-size: var(--dsh-content-font-size-secondary, 13px); + line-height: calc(24px + var(--dsh-content-font-delta, 0px)); color: var(--dsw-alias-label-tertiary); } @@ -107,6 +112,10 @@ color: var(--dsw-alias-state-error-primary); } +.stoppedSummary { + color: var(--dsw-alias-state-warn-label); +} + .bodyWrap { display: flex; flex-direction: column; diff --git a/packages/client/ui-skill/src/client/SkillRow.tsx b/packages/client/ui-skill/src/client/SkillRow.tsx index bee776d93c..6f0d3b5c54 100644 --- a/packages/client/ui-skill/src/client/SkillRow.tsx +++ b/packages/client/ui-skill/src/client/SkillRow.tsx @@ -1,6 +1,6 @@ import { useState, type KeyboardEvent, type ReactNode } from 'react' import { - IconChevronDownOutline14, IconInspectOutline12, IconSkillOutline16, StateDot, + IconChevronDownOutlineRegular, IconInspectOutlineRegular, IconSkillOutlineRegular, } from '@deepseek-ai/dsh-client-ui-primitives' import type { ToolCallViewProps } from '@deepseek-ai/dsh-client-ui-tool/client' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' @@ -72,34 +72,24 @@ function skillRowModel(block: ToolCallViewProps['block']): SkillRowModel { } } -/** State substitution for the collapsed leading slot. */ -function leadingFor(state: SkillRowState): ReactNode { - switch (state) { - case 'error': return - case 'stopped': return - default: return - } -} - /** Leading disclosure slot: state icon at rest, chevron on hover or while open. */ -function disclosureLeading(state: SkillRowState, open: boolean, expandable: boolean): ReactNode { - if (open) return - const icon = leadingFor(state) +function disclosureLeading(open: boolean, expandable: boolean): ReactNode { + if (open) return + const icon = if (!expandable) return icon return ( <> {icon} - + ) } -/** Visually hidden state copy for the colour-only lifecycle cues. */ +/** Visually hidden state copy for the color-only running sweep and error tone. */ function stateStatus(state: SkillRowState, t: SkillRowProps['t']): string | null { switch (state) { case 'running': return t('row.running') case 'error': return t('row.failed') - case 'stopped': return t('row.stopped') default: return null } } @@ -115,7 +105,7 @@ export function SkillRow({ block, inspect, t }: SkillRowProps) { const expandable = model.output !== null const open = expanded && expandable const status = stateStatus(model.state, t) - const summary = model.errorSummary ?? model.name + const summary = model.state === 'stopped' ? t('row.stopped') : model.errorSummary ?? model.name const toggleExpand = (): void => { setExpanded(value => !value) } @@ -131,7 +121,7 @@ export function SkillRow({ block, inspect, t }: SkillRowProps) { onClick: toggleExpand, onKeyDown: toggleFromKeyboard, } : {} - const leading = disclosureLeading(model.state, open, expandable) + const leading = disclosureLeading(open, expandable) return (
    {status} : null} {t('row.title')} - + {summary}
    @@ -155,7 +148,7 @@ export function SkillRow({ block, inspect, t }: SkillRowProps) { {inspect !== undefined ? ( ) : null} diff --git a/packages/client/ui-skill/tests/skill-row-styles.client.spec.ts b/packages/client/ui-skill/tests/skill-row-styles.client.spec.ts new file mode 100644 index 0000000000..f2bb2b4d3d --- /dev/null +++ b/packages/client/ui-skill/tests/skill-row-styles.client.spec.ts @@ -0,0 +1,38 @@ +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const css = readFileSync(fileURLToPath(new URL('../src/client/SkillRow.module.css', import.meta.url)), 'utf8') +const declarationText = css.replace(/\/\*[\s\S]*?\*\//g, ' ') + +function declarations(selector: string): string[] { + const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const rule = new RegExp(`(?:^|\\})\\s*${escaped}\\s*\\{([^{}]*)\\}`).exec(declarationText) + if (rule === null) throw new Error(`SkillRow.module.css has no \`${selector}\` rule`) + return (rule[1] ?? '').split(';').map(part => part.trim()).filter(Boolean) +} + +describe('SkillRow.module.css summary line', () => { + it('uses the shared ToolCall secondary font-size axis', () => { + for (const selector of ['.title', '.summary']) { + expect(declarations(selector)).toEqual(expect.arrayContaining([ + 'font-size: var(--dsh-content-font-size-secondary, 13px)', + 'line-height: calc(24px + var(--dsh-content-font-delta, 0px))', + ])) + } + }) + + it('scales the collapsed row and business glyph with the global font delta', () => { + expect(declarations('.row')).toEqual(expect.arrayContaining([ + 'height: calc(24px + var(--dsh-content-font-delta, 0px))', + ])) + expect(declarations('.leading')).toEqual(expect.arrayContaining([ + 'width: calc(16px + var(--dsh-content-font-delta, 0px))', + 'height: calc(16px + var(--dsh-content-font-delta, 0px))', + ])) + expect(declarations('.leading svg')).toEqual(expect.arrayContaining([ + 'width: calc(14px + var(--dsh-content-font-delta, 0px))', + 'height: calc(14px + var(--dsh-content-font-delta, 0px))', + ])) + }) +}) diff --git a/packages/client/ui-skill/tests/skill-row.client.spec.tsx b/packages/client/ui-skill/tests/skill-row.client.spec.tsx index daac0ed0de..da55917f84 100644 --- a/packages/client/ui-skill/tests/skill-row.client.spec.tsx +++ b/packages/client/ui-skill/tests/skill-row.client.spec.tsx @@ -97,6 +97,8 @@ describe('SkillRow', () => { }))} />) const row = screen.getByRole('button', { name: 'skill 加载失败SkillSkillError: missing resource' }) expect(view.container.querySelector('[data-tool="skill"]')?.getAttribute('data-state')).toBe('error') + expect(view.container.querySelector('[data-tool="skill"] > div > span:first-child svg')).not.toBeNull() + expect(view.container.querySelector('[data-tool="skill"] [data-state]')).toBeNull() expect(row.textContent).not.toContain('Check SKILL.md.') fireEvent.click(row) const output = view.container.querySelector('pre')! @@ -108,8 +110,10 @@ describe('SkillRow', () => { const stoppedView = render() - expect(stoppedView.container.textContent).toContain('skill 加载已中止') - expect(stoppedView.container.querySelector('[data-state="warning"]')).not.toBeNull() + const stoppedSummary = stoppedView.getByText('skill 加载已中止') + expect(stoppedSummary.className).toContain('stoppedSummary') + expect(stoppedView.container.querySelector('[data-tool="skill"] > div > span:first-child svg')).not.toBeNull() + expect(stoppedView.container.querySelector('[data-tool="skill"] [data-state]')).toBeNull() cleanup() const structuredView = render( ## Use this package -The session header keeps the current session title as the lineage breadcrumb; when the session has subagent descendants, the descendant-count trigger renders in the header actions band after the task list, with no breadcrumb separator. The trigger opens the descendant catalog, counts the complete subagent-only lineage, stops at ordinary forks, and shows ongoing activity when any counted descendant is running. Select any depth to open that child's conversation with its exact `{parentSessionId, childSessionId, mode}` address. +The session header keeps the current session title as the lineage breadcrumb; when the session has subagent descendants, the descendant-count trigger renders in the header actions band after the task list, with no breadcrumb separator. The trigger opens the descendant catalog, counts the complete subagent-only lineage, stops at ordinary forks, and shows ongoing activity when any counted descendant is running. Select any depth to open that child's conversation with its exact `{parentSessionId, childSessionId, mode}` address, or use the row's trailing arrow to open the same address in the right Sidebar, preferring a separate pane when room permits. + +This package registers the `dsh-resource://subagentchat/session/?parent=&mode=` resource and builtin Sidebar tab type. The resource refreshes the direct-parent catalog before it retains the child `SessionReference`, and releases the reference when the tab record closes. The tab renders the shared `conversation.content` Factory through `sidebar.chat.conversation`, fixes the local View to Chat, and omits the main Conversation header and width controls. ### Browsing the tree -Rows display mode plus `running`/`inactive` activity and an optional log-backed title; the trailing column stacks total durable provider usage above active-turn duration. Keyboard navigation works with ArrowRight/ArrowLeft to expand and collapse branches and ArrowUp/ArrowDown, Home, End, and Escape to navigate or close the tree. An unlabeled one-shot row falls back to its session id; corrupt, unsupported, or unavailable rows remain readable but disabled. +Rows display mode plus activity and an optional log-backed title; running uses the shared ongoing loader, an inactive child whose latest closed turn completed normally uses the shared success dot, other inactive children use the shared idle dot, and diagnostic rows use the shared error dot. Every row reserves the same 14px status column, centering smaller dots so titles align with the loader state. The compact header trigger vertically centers its activity glyph and count with a 4px gap. The trailing column stacks total durable provider usage above active-turn duration. Keyboard navigation works with ArrowRight/ArrowLeft to expand and collapse branches and ArrowUp/ArrowDown, Home, End, and Escape to navigate or close the tree. An unlabeled one-shot row falls back to its session id; corrupt, unsupported, or unavailable rows remain readable but disabled. ### Continuing a conversation @@ -53,9 +55,9 @@ The catalog and composer behavior are specified by the [Web subagent conversatio The header lineage renderer reads `subagentsByParent` and session summaries through the standard `useSessions` hook. The compact tree remains direct-catalog authoritative: each healthy row's `hasChildren` hint determines disclosure before interaction, a catalog level reserves the disclosure column only when at least one healthy row is a branch, and expanding a branch immediately reserves one disabled loading row per known direct descendant before lazily replacing them with that child's authoritative catalog. Every visible branch is reported to the runtime so membership frames cause a debounced refresh only where the tree is being consumed. -### Duration and tokens +### Duration, completion, and tokens -Token totals sum the four disjoint `tokenUsage` buckets. Duration sums completed `subagentTiming` turns, advances once per second only for an open turn on a running child, and freezes after the child becomes inactive; an interrupted open turn is bounded by its same-cut `active.through`, never by newer session metadata. +Token totals sum the four disjoint `tokenUsage` buckets. The `subagentTiming` projection sums completed-turn duration and records whether the latest closed turn ended with `completed`; opening another turn clears that completion until its own `turn/end`. Duration advances once per second only for an open turn on a running child and freezes after the child becomes inactive; an interrupted open turn is bounded by its same-cut `active.through`, never by newer session metadata. ### Composer election @@ -102,7 +104,7 @@ Append-only. This package never edits earlier request tokens. These limits define what the catalog can show and what `@` references mean; they are current package constraints. -- **The catalog has no durable outcome** — activity and timing do not distinguish completion, failure, or cancellation, and the UI exposes no Activation identity; stopping is limited to the composer's current-turn Stop for a running continuable child. +- **Non-completed inactive outcomes remain grouped** — the catalog distinguishes a latest normal completion from other inactive states, but does not distinguish failure, cancellation, refusal, token exhaustion, or a child with no closed turn; the UI exposes no Activation identity, and stopping is limited to the composer's current-turn Stop for a running continuable child. - **`@` references remain display-title text** — duplicate or renamed labels are ambiguous, so they intentionally do not acquire continuation semantics. diff --git a/packages/client/ui-subagent/README.zh.md b/packages/client/ui-subagent/README.zh.md index 1b667ca48f..992ece1c75 100644 --- a/packages/client/ui-subagent/README.zh.md +++ b/packages/client/ui-subagent/README.zh.md @@ -25,11 +25,13 @@ kind: "package-reference" ## 使用本包 -会话页头保留当前会话 title 作为谱系面包屑;会话存在 subagent 后代时,后代数量触发器渲染在页头操作区、任务列表之后,不带任何面包屑分隔符。触发器打开后代目录,统计仅含 subagent 的完整谱系、在普通 fork 处停止,并在任一计入统计的后代处于 `running` 时显示活动仍在进行。选择任意深度,即可用该子会话的确切 `{parentSessionId, childSessionId, mode}` 地址打开其对话。 +会话页头保留当前会话 title 作为谱系面包屑;会话存在 subagent 后代时,后代数量触发器渲染在页头操作区、任务列表之后,不带任何面包屑分隔符。触发器打开后代目录,统计仅含 subagent 的完整谱系、在普通 fork 处停止,并在任一计入统计的后代处于 `running` 时显示活动仍在进行。选择任意深度,即可用该子会话的确切 `{parentSessionId, childSessionId, mode}` 地址打开其对话。也可以使用行尾箭头在右侧 Sidebar 打开同一地址,并在空间允许时优先使用独立分栏。 + +本包注册 `dsh-resource://subagentchat/session/?parent=&mode=` 资源与 builtin Sidebar tab 类型。资源先刷新直接 parent 目录,再保留 child 的 `SessionReference`,并在 tab 记录关闭时释放 reference。tab 通过 `sidebar.chat.conversation` 渲染共享 `conversation.content` Factory,把局部 View 固定为 Chat,并省略主 Conversation 的 Header 与宽度控制。 ### 浏览目录 -行显示 mode、`running`/`inactive` 活动状态与由日志支撑的可选 title;尾随列在上行显示提供方的持久化 token 用量总计,在下行显示活跃轮次耗时。键盘导航:ArrowRight/ArrowLeft 展开和折叠分支;ArrowUp/ArrowDown、Home、End 与 Escape 用于导航或关闭树。没有 label 的 one-shot 行回退到其会话 id;损坏、不受支持或不可用的行仍保持可读但禁用。 +行显示 mode、活动状态与由日志支撑的可选 title;running 使用共享 ongoing loading,最近一个已结束轮次正常完成的 inactive child 使用共享 success 绿点,其他 inactive child 使用共享 idle 灰点,诊断行使用共享 error 红点。每行都为状态图标预留相同的 14px 列宽,并将较小的圆点居中,使 title 与 loading 状态对齐。紧凑的页头触发器会垂直居中活动图标与数量,并保留 4px 水平间距。尾随列在上行显示提供方的持久化 token 用量总计,在下行显示活跃轮次耗时。键盘导航:ArrowRight/ArrowLeft 展开和折叠分支;ArrowUp/ArrowDown、Home、End 与 Escape 用于导航或关闭树。没有 label 的 one-shot 行回退到其会话 id;损坏、不受支持或不可用的行仍保持可读但禁用。 ### 续接对话 @@ -53,9 +55,9 @@ kind: "package-reference" 页头谱系 renderer 通过标准 `useSessions` 钩子读取 `subagentsByParent` 与会话摘要。紧凑树仍以直接目录为权威依据:每个健康行的 `hasChildren` 提示在交互前决定是否显示展开控件;每层目录仅在其中至少一个健康行是分支时才预留展开列;展开分支时会立即为每个已知直接后代预留一行禁用的加载行,随后再用该 child 的权威目录懒加载结果替换。每个可见分支都会上报给运行时,使成员帧只在树正被消费的位置触发去抖动刷新。 -### 耗时与 token +### 耗时、完成状态与 token -token 用量总计为四个互不重叠的 `tokenUsage` 桶之和。耗时会累加已完成的 `subagentTiming` 轮次,仅在运行中 child 存在未结束轮次时每秒递增一次,并在 child 变为 inactive 后冻结;被中断的未结束轮次以其同一切面的 `active.through` 为上界,绝不使用更新的会话元数据。 +token 用量总计为四个互不重叠的 `tokenUsage` 桶之和。`subagentTiming` 投影会累加已结束轮次的耗时,并记录最近一个已结束轮次是否以 `completed` 结束;新轮次开始时会清除该完成状态,直到自身的 `turn/end` 到达。耗时仅在运行中 child 存在未结束轮次时每秒递增一次,并在 child 变为 inactive 后冻结;被中断的未结束轮次以其同一切面的 `active.through` 为上界,绝不使用更新的会话元数据。 ### 编辑器选举 @@ -102,7 +104,7 @@ one-shot child 始终选用只读编辑器。可继续 child 仅在其确切 par 这些限制定义目录能显示什么、`@` 引用意味着什么;它们是当前包约束。 -- **目录没有持久化结果**:活动状态与计时无法区分完成、失败或取消,且 UI 不公开 Activation 身份;停止能力仅限编辑器上针对运行中可继续 child 的当前轮次 Stop。 +- **非完成的 inactive 结果仍合并显示**:目录能区分最近一次正常完成与其他 inactive 状态,但不区分失败、取消、拒绝、token 耗尽或尚无已结束轮次的 child;UI 不公开 Activation 身份,停止能力仅限编辑器上针对运行中可继续 child 的当前轮次 Stop。 - **`@` 引用仍是显示标题文本**:重复或改名后的 label 会有歧义,因此它们刻意不获得继续执行语义。 diff --git a/packages/client/ui-subagent/package.json b/packages/client/ui-subagent/package.json index 038b909503..b67de14a56 100644 --- a/packages/client/ui-subagent/package.json +++ b/packages/client/ui-subagent/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-subagent", "description": "Subagent conversation catalog, continuation routing UI, and '@' reference source", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, @@ -30,8 +30,11 @@ "inject": [ "@deepseek-ai/dsh-api-session-controller", "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-resources", + "@deepseek-ai/dsh-client-ui-chat", "@deepseek-ai/dsh-client-ui-conversation", "@deepseek-ai/dsh-client-ui-primitives", + "@deepseek-ai/dsh-client-ui-sidebar-right", "@deepseek-ai/dsh-client-ui-input-trigger", "@deepseek-ai/dsh-client-ui-workspace" ], @@ -48,7 +51,9 @@ }, "devDependencies": { "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-resources": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-chat": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", @@ -64,6 +69,7 @@ "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-sidebar-right": "workspace:^", "@deepseek-ai/dsh-client-ui-session": "workspace:^", "@deepseek-ai/dsh-client-ui-workspace": "workspace:^" }, diff --git a/packages/client/ui-subagent/src/client/SubagentHeaderLineage.module.css b/packages/client/ui-subagent/src/client/SubagentHeaderLineage.module.css index 0aca182171..b62bf37d26 100644 --- a/packages/client/ui-subagent/src/client/SubagentHeaderLineage.module.css +++ b/packages/client/ui-subagent/src/client/SubagentHeaderLineage.module.css @@ -58,8 +58,10 @@ .activitySlot { display: inline-flex; flex: none; - width: 10px; - height: 10px; + align-items: center; + justify-content: center; + width: 14px; + height: 14px; } .trigger:hover, @@ -95,10 +97,11 @@ width: 336px; max-width: min(400px, calc(100vw - 32px)); max-height: min(560px, calc(100vh - 140px)); - padding: 4px; + padding: 3px; overflow: auto; - border-radius: 20px; + border-radius: 16px; background: var(--dsw-specific-menu); + backdrop-filter: var(--dsw-menu-backdrop-filter); --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); --dsw-elevation-stroke-color: var(--dsw-alias-border-l1); @@ -110,24 +113,24 @@ min-width: 0; } .menu > .node { - margin-left: -3px; + margin-left: -2px; } .row { position: relative; display: flex; align-items: flex-start; - gap: 8px; + gap: 6px; box-sizing: border-box; width: 100%; - min-height: 50px; - padding: 7px 8px 7px 11px; + min-height: 44px; + padding: 6px 7px 6px 9px; border: 0; - border-radius: 8px; + border-radius: 7px; background: transparent; color: var(--dsw-alias-label-primary); - font-size: 13px; - line-height: 18px; + font-size: 12px; + line-height: 17px; text-align: left; cursor: pointer; outline: none; @@ -144,16 +147,20 @@ flex: 1; align-self: stretch; align-items: flex-start; - gap: 8px; + gap: 6px; min-width: 0; - margin: -7px -8px -7px; - padding: 7px 8px; - border-radius: 8px; + margin: -6px -7px -6px; + padding: 6px 7px; + border-radius: 7px; } -.row > :global([data-state]), -.clickarea > :global([data-state]) { - margin-top: 4px; +.rowActivitySlot { + display: inline-flex; + flex: none; + align-items: center; + justify-content: center; + width: 14px; + height: 17px; } .disabled { @@ -173,7 +180,7 @@ .disclosureSpace { flex: none; width: 14px; - height: 18px; + height: 17px; } .disclosure { @@ -188,6 +195,11 @@ transition: transform 120ms ease; } +.disclosure svg { + width: 12px; + height: 12px; +} + .disclosure:hover { color: var(--dsw-alias-label-primary); } @@ -222,13 +234,13 @@ .summary, .metrics { color: var(--dsw-alias-label-tertiary); - font-size: 11px; - line-height: 16px; + font-size: 10px; + line-height: 15px; } .metrics { display: grid; - grid-template-rows: 18px 16px; + grid-template-rows: 17px 15px; flex: none; font-variant-numeric: tabular-nums; text-align: right; @@ -237,17 +249,40 @@ .metricToken { grid-row: 1; - line-height: 18px; + line-height: 17px; } .metricDuration { grid-row: 2; } +.sidebarButton { + display: inline-flex; + flex: none; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + margin: 4px 0; + padding: 6px; + border: 0; + border-radius: 50%; + corner-shape: round; + background: transparent; + color: var(--dsw-alias-label-tertiary); + cursor: pointer; +} + +.sidebarButton:hover, +.sidebarButton:focus-visible { + background: var(--dsw-alias-interactive-bg-hover); + color: var(--dsw-alias-label-primary); +} + .children { position: relative; - margin-left: 18px; - padding-left: 4px; + margin-left: 16px; + padding-left: 3px; } .children::before, @@ -259,8 +294,8 @@ } .children::before { - top: -26px; - height: 26px; + top: -23px; + height: 23px; } .children[aria-busy='true']::before { @@ -270,36 +305,36 @@ .children > .node::before { top: 0; bottom: 0; - left: -4px; + left: -3px; } .children > .node:last-child::before { bottom: auto; - height: 17px; + height: 15px; } .children > .node > .row::before { content: ''; position: absolute; - top: 16px; - left: -4px; - width: 14px; + top: 14px; + left: -3px; + width: 12px; border-top: 0.5px solid var(--dsw-alias-border-l2); } .notice, .error { - padding: 10px 12px; + padding: 8px 10px; color: var(--dsw-alias-label-tertiary); - font-size: 12px; - line-height: 18px; + font-size: 11px; + line-height: 16px; } .error { display: flex; align-items: center; justify-content: space-between; - gap: 12px; + gap: 10px; color: var(--dsw-alias-state-error-primary); } @@ -307,15 +342,20 @@ display: inline-flex; flex: none; align-items: center; - gap: 4px; - padding: 4px 6px; + gap: 3px; + padding: 3px 5px; border: 0; - border-radius: 6px; + border-radius: 5px; background: transparent; color: inherit; cursor: pointer; } +.refresh svg { + width: 12px; + height: 12px; +} + .refresh:hover { background: var(--dsw-alias-interactive-bg-hover); } diff --git a/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx b/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx index f42abac8e2..a2b12c8c00 100644 --- a/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx +++ b/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx @@ -9,7 +9,7 @@ import { import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { - IconChevronDownOutline14, IconChevronRightOutline14, IconRefreshOutline14, StateDot, + IconChevronDownOutlineRegular, IconChevronRightOutlineRegular, IconRefreshOutlineRegular, StateDot, } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale, PropsRuntime, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' import { NS } from './locales.ts' @@ -24,6 +24,7 @@ type Catalogs = SessionListState['subagentsByParent'] /** Business actions supplied by the slot registration. */ export interface SubagentCatalogInjected { openChild: (address: SubagentAddress) => void + openChildAside: (address: SubagentAddress) => void refresh: (parentSessionId: SessionId) => void setCatalogOpen: (parentSessionId: SessionId, open: boolean) => void } @@ -42,6 +43,7 @@ interface CatalogRowsProps { level: number now: number openChild: (address: SubagentAddress) => void + openChildAside: (address: SubagentAddress) => void refresh: (parentSessionId: SessionId) => void toggleBranch: (childSessionId: SessionId) => void closeCatalog: () => void @@ -226,7 +228,9 @@ function CatalogLoadingRows({ className={`${css.row} ${css.disabled} ${css.loadingRow}`} > - + + + {t('loading.label')} @@ -238,7 +242,7 @@ function CatalogLoadingRows({ /** Render one catalog level and recurse only through explicitly expanded rows. */ function CatalogRows({ parentSessionId, currentSessionId, catalog, catalogs, summaries, expanded, level, now, - openChild, refresh, toggleBranch, closeCatalog, t, + openChild, openChildAside, refresh, toggleBranch, closeCatalog, t, }: CatalogRowsProps & { t: TranslateNS }) { const emptyLoading = catalog.state === 'loading' && catalog.entries.length === 0 const reserveDisclosure = catalog.entries.some( @@ -262,7 +266,7 @@ function CatalogRows({ className={css.refresh} onClick={() => { refresh(parentSessionId) }} > - + {t('retry')}
    @@ -281,7 +285,9 @@ function CatalogRows({ title={reason} > {reserveDisclosure && } - + + + {entry.id} {reason} @@ -300,7 +306,13 @@ function CatalogRows({ const summary = summaries[entry.id] const label = entry.label ?? entry.id const mode = entry.mode === 'one-shot' ? t('mode.oneShot') : t('mode.continuable') - const activity = entry.activity === 'running' ? t('activity.running') : t('activity.inactive') + const completed = entry.activity === 'inactive' + && summary?.projectionValues?.subagentTiming?.lastTurnCompleted === true + const activity = entry.activity === 'running' + ? t('activity.running') + : completed + ? t('activity.completed') + : t('activity.inactive') const secondary = [summary?.title, mode, activity] .filter(value => value !== undefined) .join(' · ') @@ -327,6 +339,12 @@ function CatalogRows({ openChild({ parentSessionId, childSessionId: entry.id, mode: entry.mode }) closeCatalog() } + const openAside = (event: MouseEvent): void => { + event.preventDefault() + event.stopPropagation() + openChildAside({ parentSessionId, childSessionId: entry.id, mode: entry.mode }) + closeCatalog() + } const handleKey = (event: KeyboardEvent): void => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault() @@ -370,11 +388,13 @@ function CatalogRows({ aria-label={t(isExpanded ? 'branch.collapse' : 'branch.expand', { label })} onClick={toggle} > - + )}
    - + + + {label} {secondary} @@ -392,6 +412,18 @@ function CatalogRows({ )} )} + {!isCurrent && ( + + )}
    {isExpanded && !knownLeaf && ( @@ -420,6 +452,7 @@ function CatalogRows({ level={level + 1} now={now} openChild={openChild} + openChildAside={openChildAside} refresh={refresh} toggleBranch={toggleBranch} closeCatalog={closeCatalog} @@ -480,7 +513,7 @@ function catalogMenuPosition(trigger: HTMLButtonElement): CSSProperties { /** One trigger-plus-tree dropdown over the catalog rooted at `rootSessionId`. */ function CatalogDropdown({ rootSessionId, currentSessionId, displayTitle, openTitle, variant, - useSessions, openChild, refresh, setCatalogOpen, t, + useSessions, openChild, openChildAside, refresh, setCatalogOpen, t, }: CatalogDropdownProps) { const ancestorSwitcher = variant === 'switcher' && openTitle !== undefined const catalogs = useSessions(state => state.subagentsByParent) @@ -759,7 +792,7 @@ function CatalogDropdown({ )} {variant === 'switcher' ? - : } + : } {open && createPortal((
    { changeOpen(false) }} @@ -805,7 +839,7 @@ export type SubagentCatalogActionProps = * @returns The count dropdown, or null on a child session. */ export function SubagentCatalogAction({ - sessionId, useSessions, openChild, refresh, setCatalogOpen, t, + sessionId, useSessions, openChild, openChildAside, refresh, setCatalogOpen, t, }: SubagentCatalogActionProps) { const isChild = useSessions(state => state.byId[sessionId]?.origin === 'subagent') if (isChild) return null @@ -816,6 +850,7 @@ export function SubagentCatalogAction({ variant="count" useSessions={useSessions} openChild={openChild} + openChildAside={openChildAside} refresh={refresh} setCatalogOpen={setCatalogOpen} t={t} @@ -830,13 +865,13 @@ export function SubagentCatalogAction({ */ export function SubagentHeaderLineage({ lineageSessionId, displayTitle, openTitle, - useSessions, openChild, refresh, setCatalogOpen, t, + useSessions, openChild, openChildAside, refresh, setCatalogOpen, t, }: SubagentHeaderLineageProps) { const parentId = useSessions((state) => { const summary = state.byId[lineageSessionId] return summary?.origin === 'subagent' ? summary.parentId : undefined }) - const shared = { useSessions, openChild, refresh, setCatalogOpen, t } + const shared = { useSessions, openChild, openChildAside, refresh, setCatalogOpen, t } // Root sessions carry no breadcrumb; their descendant count lives in the // header actions band (SubagentCatalogAction), after the task list. if (parentId === undefined) return null diff --git a/packages/client/ui-subagent/src/client/index.ts b/packages/client/ui-subagent/src/client/index.ts index f424279118..869dc6e30c 100644 --- a/packages/client/ui-subagent/src/client/index.ts +++ b/packages/client/ui-subagent/src/client/index.ts @@ -7,9 +7,12 @@ import { SubagentCatalogAction, SubagentHeaderLineage, type SubagentCatalogInjec import { SubagentReadOnlyComposer, type SubagentReadOnlyMatch, } from './SubagentReadOnlyComposer.tsx' +import { registerSidebarChat, subagentChatAddress } from './sidebar-chat/index.tsx' import type {} from '@deepseek-ai/dsh-client-locale/client' +import type {} from '@deepseek-ai/dsh-client-ui-chat/client' import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import type {} from '@deepseek-ai/dsh-client-ui-session/client' +import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client' import type {} from '@deepseek-ai/dsh-client-ui-workspace/client' import { en, NS, zh, type SubagentKey } from './locales.ts' @@ -27,8 +30,8 @@ export type { SubagentReadOnlyComposerProps, SubagentReadOnlyMatch, } from './SubagentReadOnlyComposer.tsx' -/** Required services for conversation slots and session navigation. */ -export const inject = ['sessions', 'uiWorkspace', 'slots', 'locale'] +/** Required services for subagent presentation and navigation. */ +export const inject = ['sessions', 'uiWorkspace', 'slots', 'locale', 'sidebarRight'] /** Claim the composer for one-shot history or an unavailable continuation owner. */ function selectReadOnlySubagent(owner: ComposerChainProps): SubagentReadOnlyMatch | null { @@ -51,11 +54,20 @@ function selectReadOnlySubagent(owner: ComposerChainProps): SubagentReadOnlyMatc */ export function apply(ctx: ClientContext): void { ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-subagent: dictionaries') + ctx.inject(['resources', 'sidebarRightTabs'], (scope) => { + registerSidebarChat(scope, ctx.locale.bind(NS)) + }) const sessions = ctx.sessions const catalogActions = (_parentSessionId: SessionId): SubagentCatalogInjected => ({ openChild(address: SubagentAddress) { ctx.uiWorkspace.openSession(address) }, + openChildAside(address: SubagentAddress) { + ctx.sidebarRight.openResource(subagentChatAddress(address), { + kind: 'subagentchat', + preferNewPane: true, + }) + }, refresh(parentSessionId: SessionId) { void sessions.refreshSubagents(parentSessionId) }, diff --git a/packages/client/ui-subagent/src/client/locales.ts b/packages/client/ui-subagent/src/client/locales.ts index 312bad3af4..5dee6d264e 100644 --- a/packages/client/ui-subagent/src/client/locales.ts +++ b/packages/client/ui-subagent/src/client/locales.ts @@ -29,6 +29,7 @@ export const zh = { 'mode.oneShot': '一次性', 'mode.continuable': '可继续', 'activity.running': '正在运行', + 'activity.completed': '已完成', 'activity.inactive': '当前未运行', 'branch.collapse': '收起 {label} 的下级子代理', 'branch.expand': '展开 {label} 的下级子代理', @@ -38,6 +39,8 @@ export const zh = { 'count.running.other': '{count} 个子代理,正在运行', 'switcher.aria': '切换子代理:{title}', 'tree.aria': '子代理会话', + 'open.sidebar': '在侧边栏打开 {label}', + 'sidebar.chat': '聊天', 'readonly.oneShot.title': '一次性子代理记录', 'readonly.title': '此子代理暂时只读', 'readonly.oneShot.body': '一次性任务不支持后续消息,可在这里查看完整执行记录。', @@ -70,6 +73,7 @@ export const en: Record = { 'mode.oneShot': 'one-shot', 'mode.continuable': 'continuable', 'activity.running': 'running', + 'activity.completed': 'completed', 'activity.inactive': 'not running', 'branch.collapse': 'Collapse {label} descendants', 'branch.expand': 'Expand {label} descendants', @@ -79,6 +83,8 @@ export const en: Record = { 'count.running.other': '{count} subagents running', 'switcher.aria': 'Switch subagent: {title}', 'tree.aria': 'Subagent sessions', + 'open.sidebar': 'Open {label} in sidebar', + 'sidebar.chat': 'Chat', 'readonly.oneShot.title': 'One-shot subagent record', 'readonly.title': 'This subagent is read-only for now', 'readonly.oneShot.body': 'One-shot tasks do not accept follow-ups; review the full execution record here.', diff --git a/packages/client/ui-subagent/src/client/sidebar-chat/SidebarChat.module.css b/packages/client/ui-subagent/src/client/sidebar-chat/SidebarChat.module.css new file mode 100644 index 0000000000..267787e4e5 --- /dev/null +++ b/packages/client/ui-subagent/src/client/sidebar-chat/SidebarChat.module.css @@ -0,0 +1,7 @@ +.root { + display: flex; + width: 100%; + height: 100%; + min-width: 0; + min-height: 0; +} diff --git a/packages/client/ui-subagent/src/client/sidebar-chat/index.tsx b/packages/client/ui-subagent/src/client/sidebar-chat/index.tsx new file mode 100644 index 0000000000..375dcadfd3 --- /dev/null +++ b/packages/client/ui-subagent/src/client/sidebar-chat/index.tsx @@ -0,0 +1,204 @@ +/** Right-Sidebar presentation of an existing subagent Conversation. */ +import type { Context } from '@deepseek-ai/cordis' +import type { + ISessions, SessionReference, +} from '@deepseek-ai/dsh-api-session-controller/client' +import type { ResourceProvider } from '@deepseek-ai/dsh-client-resources/client' +import type { ConversationViewsProps } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { SidebarRightTabDefinition } from '@deepseek-ai/dsh-client-ui-sidebar-right/client' +import type { + PropsRenderFactories, PropsRenderSlots, PropsRuntime, TranslateNS, +} from '@deepseek-ai/dsh-client-ui-slots' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client' +import type { NS } from '../locales.ts' +import css from './SidebarChat.module.css' + +/** Stable implementation identity for the Sidebar tab body. */ +export const SUBAGENT_CHAT_ID = '@deepseek-ai/dsh-client-ui-subagent' + +/** Resource-address prefix for an embedded Session chat. */ +export const SUBAGENT_CHAT_ADDRESS = 'dsh-resource://subagentchat/session/' + +/** Value retained by one live chat resource occurrence. */ +export interface SubagentChatResource { + readonly address: SubagentAddress + readonly reference: SessionReference +} + +declare module '@deepseek-ai/dsh-api-session-controller/client' { + interface SessionReferenceSourceMap { + sidebarChat: unknown + } +} + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface ResourceProtocolMap { + subagentchat: SubagentChatResource + } + + interface SlotMap { + /** Session-scoped Conversation occurrence hosted by one Sidebar chat tab. */ + 'sidebar.chat.conversation': { kind: 'single'; scope: 'session' } + } +} + +/** + * Address one subagent Session together with the routing facts needed to restore it. + * @param address - durable direct-parent subagent address. + * @returns canonical Sidebar resource address. + */ +export function subagentChatAddress(address: SubagentAddress): string { + const query = new URLSearchParams({ + parent: address.parentSessionId, + mode: address.mode, + }) + return `${SUBAGENT_CHAT_ADDRESS}${encodeURIComponent(address.childSessionId)}?${query}` +} + +/** + * Parse one canonical Sidebar chat resource address. + * @param value - possible chat resource address. + * @returns the encoded direct-parent address, or undefined for another or malformed resource. + */ +export function parseSubagentChatAddress(value: string): SubagentAddress | undefined { + let url: URL + try { + url = new URL(value) + } catch (_invalidUrl) { + return undefined + } + if (url.protocol !== 'dsh-resource:' || url.hostname.toLowerCase() !== 'subagentchat') return undefined + const parts = url.pathname.split('/').filter(Boolean) + if (parts.length !== 2 || parts[0] !== 'session') return undefined + const parentSessionId = url.searchParams.get('parent') + const mode = url.searchParams.get('mode') + if (parentSessionId === null || parentSessionId === '' || (mode !== 'one-shot' && mode !== 'continuable')) { + return undefined + } + try { + const childSessionId = decodeURIComponent(parts[1] as string) + return { + parentSessionId: parentSessionId as SessionId, + childSessionId: childSessionId as SessionId, + mode, + } + } catch (_invalidEncoding) { + return undefined + } +} + +function waitForAbort(signal: AbortSignal): Promise { + if (signal.aborted) return Promise.resolve() + return new Promise((resolve) => { + signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) +} + +function isAbortRequested(signal: AbortSignal): boolean { + return signal.aborted +} + +function subagentChatResourceProvider(sessions: ISessions): ResourceProvider<'subagentchat'> { + return { + protocol: 'subagentchat', + async *open(resourceAddress, { signal }) { + const address = parseSubagentChatAddress(resourceAddress) + if (address === undefined) throw new Error(`ui-subagent: invalid chat resource address "${resourceAddress}"`) + if (isAbortRequested(signal)) return + await sessions.refreshSubagents(address.parentSessionId) + if (isAbortRequested(signal)) return + const reference = sessions.retain(address, { source: 'sidebarChat', signal }) + try { + yield { ok: true, value: { address, reference } } + await waitForAbort(signal) + } finally { + reference.release() + } + }, + } +} + +/** Fixed Chat selection used by an embedded Conversation occurrence. */ +export function FixedChatConversationView(props: ConversationViewsProps) { + return <>{props.renderSlot('conversation.session', { view: 'chat' })} +} + +/** Props supplied to the child-Session Conversation host. */ +export type ConversationSlotPanelProps = PropsRuntime<'sidebar.chat.conversation'> & PropsRenderFactories + +/** Render the shared Conversation content for one explicitly provided child Session. */ +export function ConversationSlotPanel({ + sessionId, useSession, useConversation, useSessions, renderFactorySlot, +}: ConversationSlotPanelProps) { + const session = useSession(value => value) + const conversation = useConversation(value => value) + const active = conversation.activeTargets.size > 0 + || (!session.blank && !session.awaitingFirstTurn) + || session.running + const shellPhase = active ? 'active' : session.promptAttempted ? 'engaging' : 'blank' + const summaryBlank = useSessions(state => state.byId[sessionId]?.blank) + const parentAvailabilityPending = session.subagent?.address.mode === 'continuable' + && session.subagent.parentAvailable === undefined + const settling = (shellPhase === 'blank' && session.openState === 'loading' && summaryBlank !== true) + || parentAvailabilityPending + const hero = shellPhase === 'blank' && (session.openState === 'open' || summaryBlank === true) + const phase = settling ? 'settling' : hero ? 'hero' : 'active' + return renderFactorySlot('conversation.content', { variant: 'embedded', phase, hero }, { + slots: { views: FixedChatConversationView }, + }) +} + +/** Props supplied to the parent-Session Sidebar tab body. */ +export type SidebarChatTabProps = + PropsRuntime<'sidebar.right.pane.tab'> & PropsRenderSlots<'sidebar.chat.conversation'> + +/** Bind a chat resource's child reference around its Conversation slot. */ +export function SidebarChatTab({ useResource, useTabInfo, SessionProvider, renderSlot }: SidebarChatTabProps) { + const { tab } = useTabInfo() + const resource = useResource<'subagentchat'>(tab.contentId) + return ( +
    + {resource.value === undefined + ? null + : ( + + {renderSlot('sidebar.chat.conversation', {})} + + )} +
    + ) +} + +/** + * Register the chat resource owner and its right-Sidebar presentation. + * @param ctx - Client root carrying Sessions, resources, Slots, and Sidebar registries. + * @param t - Chat namespace translator used for fallback tab titles. + */ +export function registerSidebarChat(ctx: Context, t: TranslateNS): void { + ctx.effect( + () => ctx.resources.register(subagentChatResourceProvider(ctx.sessions)), + 'ui-subagent: Sidebar chat resources', + ) + ctx.effect(() => ctx.sidebarRightTabs.register({ + id: SUBAGENT_CHAT_ID, + kind: 'subagentchat', + patterns: [`${SUBAGENT_CHAT_ADDRESS}**`], + priority: 'builtin', + canOpen: address => parseSubagentChatAddress(address) !== undefined, + title: (address) => { + const child = parseSubagentChatAddress(address)?.childSessionId + return child === undefined + ? t('sidebar.chat') + : ctx.sessions.list.getSnapshot().byId[child]?.projectionValues?.subagent?.label ?? child + }, + } satisfies SidebarRightTabDefinition), 'ui-subagent: Sidebar chat type') + ctx.effect(() => ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register({ + name: 'sidebar.right.pane.tab', + key: SUBAGENT_CHAT_ID, + children: { 'sidebar.chat.conversation': { kind: 'single', scope: 'session' } }, + }, SidebarChatTab)), 'ui-subagent: Sidebar chat body') + ctx.effect(() => ctx.slots.inject('sidebar.chat.conversation', () => ctx.slots.register({ + name: 'sidebar.chat.conversation', + }, ConversationSlotPanel)), 'ui-subagent: Sidebar Conversation') +} diff --git a/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts b/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts index 40c4109d0e..135a690be9 100644 --- a/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts @@ -1,4 +1,5 @@ /** ui-subagent browser half: catalog actions and read-only composer routing. */ +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { Context } from '@deepseek-ai/cordis' import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' import { describe, expect, it } from 'vitest' @@ -73,8 +74,13 @@ async function fullBench(sessions: SessionSummary[]) { face.actionCalls.push({ method: 'openSession', args: [address] }) }, } as never) + ctx.provide('sidebarRight', { + openResource: (address: string, options: unknown) => { + face.actionCalls.push({ method: 'openResource', args: [address, options] }) + }, + } as never) ctx.provide('remote', { $on: () => () => {} } as never) - ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) + ctx.provide('settingsScope', { developerTools: { enabled: createSnapshotStore(true) }, bind: () => stubSettingsScope().scope } as never) await provideSlotFaces(ctx) await ctx.plugin({ inject: localeInject, apply: applyLocale }).await() await ctx.plugin({ inject: [...inject], apply }).await() @@ -93,7 +99,7 @@ const FAMILY: SessionSummary[] = [ describe('apply', () => { it('declares the services it binds', () => { - expect(inject).toEqual(['sessions', 'uiWorkspace', 'slots', 'locale']) + expect(inject).toEqual(['sessions', 'uiWorkspace', 'slots', 'locale', 'sidebarRight']) }) it('registers catalog actions and selects read-only subagent composers from session facts', async () => { @@ -107,10 +113,18 @@ describe('apply', () => { mode: 'continuable', } actions.openChild(address) + actions.openChildAside(address) actions.refresh(sid('parent')) actions.setCatalogOpen(sid('parent'), true) expect(face.actionCalls).toEqual([ { method: 'openSession', args: [address] }, + { + method: 'openResource', + args: [ + 'dsh-resource://subagentchat/session/c1?parent=parent&mode=continuable', + { kind: 'subagentchat', preferNewPane: true }, + ], + }, { method: 'refreshSubagents', args: [sid('parent')] }, { method: 'setSubagentCatalogOpen', args: [sid('parent'), true] }, ]) diff --git a/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx b/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx index 15a6d33e88..8b2142b62b 100644 --- a/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx +++ b/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx @@ -72,6 +72,7 @@ function props( sessionId: PARENT, useSessions, openChild: vi.fn(), + openChildAside: vi.fn(), refresh: vi.fn(), setCatalogOpen: vi.fn(), lineageSessionId: PARENT, @@ -182,6 +183,14 @@ describe('SubagentHeaderLineage', () => { expect(screen.queryByRole('button', { name: '展开 reviewer 的下级子代理' })).toBeNull() expect(screen.getByRole('treeitem', { name: /reviewer/ }).children).toHaveLength(2) + const sidebarButton = screen.getByRole('button', { name: '在侧边栏打开 worker' }) + fireEvent.keyDown(sidebarButton, { key: 'Enter' }) + fireEvent.click(sidebarButton) + expect(input.openChildAside).toHaveBeenCalledWith({ + parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', + }) + + hoverCatalog(trigger) fireEvent.click(screen.getByRole('treeitem', { name: /worker/ })) expect(input.openChild).toHaveBeenCalledWith({ parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', @@ -361,7 +370,7 @@ describe('SubagentHeaderLineage', () => { }) }) - it('shows durable token totals, ticks active duration by seconds, and freezes inactive rows', async () => { + it('shows durable completion and token totals, ticks active duration, and freezes inactive rows', async () => { const now = 2_000_000_000_000 const minute = 60_000 const hour = 60 * minute @@ -419,6 +428,11 @@ describe('SubagentHeaderLineage', () => { projectionValues: { subagentTiming: { settledMs, + ...(id === 'finished' + ? { lastTurnCompleted: true } + : id === 'interrupted' + ? { lastTurnCompleted: false } + : {}), ...(activeSince === undefined || activeThrough === undefined ? {} : { active: { since: activeSince, through: activeThrough } }), @@ -441,8 +455,13 @@ describe('SubagentHeaderLineage', () => { const durationMetric = runningMetrics.getByText('1分10秒') expect(tokenMetric.parentElement).toBe(durationMetric.parentElement) expect(tokenMetric.nextElementSibling).toBe(durationMetric) - expect(screen.getByRole('treeitem', { name: /finished.*123 tok · 1小时02分03秒/ })).toBeTruthy() - expect(screen.getByRole('treeitem', { name: /interrupted.*123M tok · 6秒/ })).toBeTruthy() + const finishedRow = screen.getByRole('treeitem', { name: /finished.*已完成.*123 tok · 1小时02分03秒/ }) + expect(finishedRow.querySelector('[data-state="done"]')).not.toBeNull() + const interruptedRow = screen.getByRole('treeitem', { name: /interrupted.*当前未运行.*123M tok · 6秒/ }) + expect(interruptedRow.querySelector('[data-state="idle"]')).not.toBeNull() + for (const row of [runningRow, finishedRow, interruptedRow]) { + expect(row.querySelector('[data-state]')?.parentElement?.className).toContain('rowActivitySlot') + } expect(screen.getByRole('treeitem', { name: /days.*12天05小时06分07秒/ })).toBeTruthy() expect(screen.getByText('12天5小时').getAttribute('title')) .toBe('总活跃耗时:12天05小时06分07秒') @@ -454,8 +473,8 @@ describe('SubagentHeaderLineage', () => { await vi.advanceTimersByTimeAsync(1_000) expect(screen.getByRole('treeitem', { name: /running.*4\.6K tok · 1分11秒/ })).toBeTruthy() - expect(screen.getByRole('treeitem', { name: /finished.*123 tok · 1小时02分03秒/ })).toBeTruthy() - expect(screen.getByRole('treeitem', { name: /interrupted.*123M tok · 6秒/ })).toBeTruthy() + expect(screen.getByRole('treeitem', { name: /finished.*已完成.*123 tok · 1小时02分03秒/ })).toBeTruthy() + expect(screen.getByRole('treeitem', { name: /interrupted.*当前未运行.*123M tok · 6秒/ })).toBeTruthy() }) it('lazily expands and collapses descendant catalogs with direct-parent navigation', () => { @@ -533,8 +552,8 @@ describe('SubagentHeaderLineage', () => { }, summaries) view.rerender() expect(screen.getByRole('group').getAttribute('aria-busy')).toBeNull() - expect(screen.getByRole('treeitem', { name: /indexer/ })).toBeTruthy() - expect(screen.getByRole('treeitem', { name: /critic/ })).toBeTruthy() + expect(screen.getByRole('treeitem', { name: /indexer/ }).querySelector('[data-state="idle"]')).not.toBeNull() + expect(screen.getByRole('treeitem', { name: /critic/ }).querySelector('[data-state="ongoing"]')).not.toBeNull() expect(screen.queryByRole('treeitem', { name: '正在加载子代理' })).toBeNull() }) diff --git a/packages/client/ui-subagent/tests/sidebar-chat.client.spec.tsx b/packages/client/ui-subagent/tests/sidebar-chat.client.spec.tsx new file mode 100644 index 0000000000..a75f111f5f --- /dev/null +++ b/packages/client/ui-subagent/tests/sidebar-chat.client.spec.tsx @@ -0,0 +1,237 @@ +// @vitest-environment jsdom +import type { ReactNode } from 'react' +import { cleanup, render } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { Context } from '@deepseek-ai/cordis' +import type { + ISessions, SessionListState, SessionReference, SessionSnapshot, +} from '@deepseek-ai/dsh-api-session-controller/client' +import type { ResourceProvider } from '@deepseek-ai/dsh-client-resources/client' +import { sessionSnapshot } from '@deepseek-ai/dsh-client-test-runtime' +import type { ConversationViewsProps } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { SidebarRightTabDefinition } from '@deepseek-ai/dsh-client-ui-sidebar-right/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client' +import { + ConversationSlotPanel, FixedChatConversationView, parseSubagentChatAddress, + registerSidebarChat, subagentChatAddress, SUBAGENT_CHAT_ID, type SidebarChatTabProps, +} from '../src/client/sidebar-chat/index.tsx' +import { SidebarChatTab } from '../src/client/sidebar-chat/index.tsx' + +const PARENT = 'parent/a' as SessionId +const CHILD = 'child#1' as SessionId +const ADDRESS: SubagentAddress = { + parentSessionId: PARENT, + childSessionId: CHILD, + mode: 'continuable', +} + +afterEach(() => { + cleanup() + vi.restoreAllMocks() +}) + +describe('Sidebar chat address', () => { + it('round-trips the child identity and direct-parent routing metadata', () => { + const resource = subagentChatAddress(ADDRESS) + expect(resource).toBe('dsh-resource://subagentchat/session/child%231?parent=parent%2Fa&mode=continuable') + expect(parseSubagentChatAddress(resource)).toEqual(ADDRESS) + expect(parseSubagentChatAddress(subagentChatAddress({ ...ADDRESS, mode: 'one-shot' }))) + .toEqual({ ...ADDRESS, mode: 'one-shot' }) + }) + + it.each([ + 'not an address', + 'https://chat/session/child?parent=parent&mode=continuable', + 'dsh-resource://other/session/child?parent=parent&mode=continuable', + 'dsh-resource://subagentchat/other/child?parent=parent&mode=continuable', + 'dsh-resource://subagentchat/session/child?mode=continuable', + 'dsh-resource://subagentchat/session/child?parent=&mode=continuable', + 'dsh-resource://subagentchat/session/child?parent=parent&mode=unknown', + 'dsh-resource://subagentchat/session/%?parent=parent&mode=continuable', + ])('rejects %s', (address) => { + expect(parseSubagentChatAddress(address)).toBeUndefined() + }) +}) + +describe('Sidebar chat registration', () => { + it('registers the resource, tab, and child slot and retains the child for the resource lifetime', async () => { + const release = vi.fn() + const reference = { + sessionId: CHILD, + ready: Promise.resolve(undefined), + release, + [Symbol.dispose]: release, + } as unknown as SessionReference + const retain = vi.fn(() => reference) + const refreshSubagents = vi.fn(() => Promise.resolve()) + const list = { + getSnapshot: () => ({ + ids: [], + byId: { + [CHILD]: { + displayTitle: 'Projected title', + projectionValues: { subagent: { mode: 'continuable', label: 'Worker', seq: 1 } }, + }, + }, + phase: 'ready', + subagentsByParent: {}, + jobsBySession: {}, + } as unknown as SessionListState), + subscribe: () => () => {}, + } + let provider: ResourceProvider<'subagentchat'> | undefined + let definition: SidebarRightTabDefinition | undefined + const registrations: { options: Record; component: unknown }[] = [] + const ctx = { + sessions: { retain, refreshSubagents, list } as unknown as ISessions, + resources: { register: (value: ResourceProvider<'subagentchat'>) => { provider = value; return () => {} } }, + sidebarRightTabs: { register: (value: SidebarRightTabDefinition) => { definition = value; return () => {} } }, + slots: { + inject: (_name: string, install: () => () => void) => install(), + register: (options: Record, component: unknown) => { + registrations.push({ options, component }) + return () => {} + }, + }, + effect: (install: () => unknown) => { install(); return () => {} }, + } as unknown as Context + + registerSidebarChat(ctx, (key: string) => key === 'sidebar.chat' ? 'Chat' : key) + + expect(definition?.id).toBe(SUBAGENT_CHAT_ID) + expect(definition?.kind).toBe('subagentchat') + expect(definition?.canOpen?.(subagentChatAddress(ADDRESS))).toBe(true) + expect(definition?.canOpen?.('dsh-resource://subagentchat/invalid')).toBe(false) + expect(definition?.title(subagentChatAddress(ADDRESS))).toBe('Worker') + expect(definition?.title(subagentChatAddress({ ...ADDRESS, childSessionId: 'unknown' as SessionId }))).toBe('unknown') + expect(definition?.title('invalid')).toBe('Chat') + expect(registrations.map(entry => entry.options.name)).toEqual([ + 'sidebar.right.pane.tab', + 'sidebar.chat.conversation', + ]) + + const controller = new AbortController() + const stream = provider!.open(subagentChatAddress(ADDRESS), { signal: controller.signal })[Symbol.asyncIterator]() + expect(await stream.next()).toEqual({ done: false, value: { ok: true, value: { address: ADDRESS, reference } } }) + expect(refreshSubagents).toHaveBeenCalledWith(PARENT) + expect(retain).toHaveBeenCalledWith(ADDRESS, { source: 'sidebarChat', signal: controller.signal }) + const completion = stream.next() + await Promise.resolve() + controller.abort() + expect(await completion).toEqual({ done: true, value: undefined }) + expect(release).toHaveBeenCalledOnce() + + const abortedAfterYield = new AbortController() + const yielded = provider!.open(subagentChatAddress(ADDRESS), { signal: abortedAfterYield.signal })[Symbol.asyncIterator]() + expect((await yielded.next()).done).toBe(false) + abortedAfterYield.abort() + expect(await yielded.next()).toEqual({ done: true, value: undefined }) + expect(release).toHaveBeenCalledTimes(2) + + let finishRefresh: (() => void) | undefined + refreshSubagents.mockImplementationOnce(() => new Promise((resolve) => { finishRefresh = resolve })) + const abortedDuringRefresh = new AbortController() + const pending = provider!.open( + subagentChatAddress(ADDRESS), { signal: abortedDuringRefresh.signal }, + )[Symbol.asyncIterator]().next() + await vi.waitFor(() => { expect(finishRefresh).toBeTypeOf('function') }) + abortedDuringRefresh.abort() + finishRefresh?.() + expect(await pending).toEqual({ done: true, value: undefined }) + + const alreadyAborted = new AbortController() + alreadyAborted.abort() + const stopped = provider!.open(subagentChatAddress(ADDRESS), { signal: alreadyAborted.signal })[Symbol.asyncIterator]() + expect(await stopped.next()).toEqual({ done: true, value: undefined }) + expect(retain).toHaveBeenCalledTimes(2) + expect(release).toHaveBeenCalledTimes(2) + + const invalid = provider!.open('invalid', { signal: new AbortController().signal })[Symbol.asyncIterator]() + await expect(invalid.next()).rejects.toThrow('invalid chat resource address') + }) +}) + +describe('Sidebar chat components', () => { + it('binds a live resource reference around the child Session slot', () => { + const reference = { sessionId: CHILD } as SessionReference + let snapshot = { status: 'loading', value: undefined, failure: undefined } as const + const SessionProvider = vi.fn(({ children }: { children: ReactNode }) => <>{children}) + const renderSlot = vi.fn(() => child conversation) + const props = { + useTabInfo: () => ({ tab: { contentId: subagentChatAddress(ADDRESS) } }), + useResource: () => snapshot, + SessionProvider, + renderSlot, + } as unknown as SidebarChatTabProps + const view = render() + expect(view.container.textContent).toBe('') + + snapshot = { status: 'live', value: { address: ADDRESS, reference }, failure: undefined } as never + view.rerender() + expect(view.getByText('child conversation')).toBeTruthy() + expect(SessionProvider).toHaveBeenCalledWith(expect.objectContaining({ session: reference }), {}) + expect(renderSlot).toHaveBeenCalledWith('sidebar.chat.conversation', {}) + }) + + it.each([ + { + name: 'active', + session: { blank: false, awaitingFirstTurn: false, running: false }, + summaryBlank: false, + expected: { phase: 'active', hero: false }, + }, + { + name: 'hero', + session: { blank: true, awaitingFirstTurn: true, running: false, openState: 'open' }, + summaryBlank: true, + expected: { phase: 'hero', hero: true }, + }, + { + name: 'prompt attempted', + session: { blank: true, awaitingFirstTurn: true, running: false, openState: 'open', promptAttempted: true }, + summaryBlank: false, + expected: { phase: 'active', hero: false }, + }, + { + name: 'loading', + session: { blank: true, awaitingFirstTurn: true, running: false, openState: 'loading' }, + summaryBlank: false, + expected: { phase: 'settling', hero: false }, + }, + { + name: 'continuable parent discovery', + session: { + blank: false, + awaitingFirstTurn: false, + running: false, + subagent: { address: ADDRESS, parentAvailable: undefined }, + }, + summaryBlank: false, + expected: { phase: 'settling', hero: false }, + }, + ])('derives the $name embedded phase and fixes the local view to Chat', ({ session: patch, summaryBlank, expected }) => { + const snapshot = { ...sessionSnapshot(CHILD), ...patch } as SessionSnapshot + const renderFactorySlot = vi.fn(() => null) + render( unknown) => select(snapshot), + useConversation: (select: (value: { activeTargets: ReadonlySet }) => unknown) => select({ activeTargets: new Set() }), + useSessions: (select: (value: SessionListState) => unknown) => select({ + ids: [], byId: { [CHILD]: { blank: summaryBlank } }, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, + } as unknown as SessionListState), + renderFactorySlot, + } as unknown as Parameters[0])} />) + expect(renderFactorySlot).toHaveBeenCalledWith( + 'conversation.content', + { variant: 'embedded', ...expected }, + { slots: { views: FixedChatConversationView } }, + ) + }) + + it('routes the fixed local view through the ordinary Conversation Session slot', () => { + const renderSlot = vi.fn(() => null) + render() + expect(renderSlot).toHaveBeenCalledWith('conversation.session', { view: 'chat' }) + }) +}) diff --git a/packages/client/ui-subagent/tsconfig.json b/packages/client/ui-subagent/tsconfig.json index c7652afc94..f5818c86fb 100644 --- a/packages/client/ui-subagent/tsconfig.json +++ b/packages/client/ui-subagent/tsconfig.json @@ -20,6 +20,12 @@ { "path": "../locale" }, + { + "path": "../resources" + }, + { + "path": "../ui-chat" + }, { "path": "../ui-conversation" }, @@ -32,6 +38,9 @@ { "path": "../ui-renderer" }, + { + "path": "../ui-sidebar-right" + }, { "path": "../ui-session" }, diff --git a/packages/client/ui-theme/README.i18n.yaml b/packages/client/ui-theme/README.i18n.yaml index bbb71eb326..dfcdd66ea3 100644 --- a/packages/client/ui-theme/README.i18n.yaml +++ b/packages/client/ui-theme/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-theme/README.md -README.md: a25f0202d278d52448d52a22f451612295325f87 -README.zh.md: 08a0c37dc3c80b4385173e6ca74bb9254b4870ea +README.md: bc8f0402eadbe30971db265f330ceb6af19095ce +README.zh.md: dc3a3672fee40903c9c6a4de4922db6988187de1 diff --git a/packages/client/ui-theme/README.md b/packages/client/ui-theme/README.md index a25f0202d2..bc8f0402ea 100644 --- a/packages/client/ui-theme/README.md +++ b/packages/client/ui-theme/README.md @@ -51,15 +51,15 @@ The service owns theme and font-size state and publishes snapshots. The ui-layou ### Stylesheets -`src/styles/` holds six sheets imported in order by ui-theme's dynamic client entry: `base.css`, `corner-shape.css`, `design-platform.css`, `scrollbar.css`, `gradient-shadow-text.css`, and `shiki.css`. The client bundle compiles and injects them as plugin-owned global styles, so unload and HMR remove them with ui-theme. `scrollbar.css` is the sole consumer of the `--dsw-alias-scrollbar-*` tokens and must follow `design-platform.css`, which declares them. +`src/styles/` holds six sheets imported in order by ui-theme's dynamic client entry: `base.css`, `corner-shape.css`, `design-platform.css`, `scrollbar.css`, `gradient-shadow-text.css`, and `shiki.css`. The client bundle compiles and injects them as plugin-owned global styles, so unload and HMR remove them with ui-theme. `scrollbar.css` consumes the `--dsw-alias-scrollbar-*` tokens and must follow `design-platform.css`, which declares them. Status marks use their own semantic state tokens. `corner-shape.css` smooths every rounded corner: inside `@supports (corner-shape: superellipse(1.5))` it defines `--dsw-corner-shape` and applies it to all elements and their `::before`/`::after` through the universal selector, so engines without `corner-shape` keep circular corners. Full-round shapes — `border-radius: 50%` circles and pill radii — pair `corner-shape: round` with their radius in the owning component sheet because a superellipse deforms them; the corner-shape stylesheet spec enforces that pairing across every package stylesheet. -`gradient-shadow-text.css` derives `--dsh-content-font-delta` from `--dsh-content-font-size` and shifts the Markdown heading and base-text ladder by that increment. It also derives the secondary tier `--dsh-content-font-size-secondary` (setting −1 at ≤14, setting −2 above; 13px at the default) with its own `--dsh-content-font-delta-secondary` for the table variants and the flow rows one step under the body. Dense small and code variants stay fixed. Outside the ladder, the user bubble and composer draft read the body pair directly, and flow-row titles and summaries read the secondary pair. The sheet also owns the shadow scale (`--dsw-shadow-lv*`) and the elevation tokens: `--dsw-elevation-stroke` draws a 0.5px hairline through the rebindable `--dsw-elevation-stroke-color`, and `--dsw-elevation-panel`/`--dsw-elevation-prominent`/`--dsw-elevation-soft` (the composer's larger-blur, lower-alpha tier) layer two faint soft shadows over that stroke, so elevated surfaces set `border: 0` and carry no layout-consuming outline; the derived tokens are re-declared per element so a surface's stroke-color rebind takes effect. +`gradient-shadow-text.css` derives `--dsh-content-font-delta` from `--dsh-content-font-size` and shifts the Markdown heading and base-text ladder by that increment. It also derives the secondary tier `--dsh-content-font-size-secondary` (setting −1 at ≤14, setting −2 above; 13px at the default) with its own `--dsh-content-font-delta-secondary` for the table variants and the flow rows one step under the body. Dense small and code variants stay fixed. Outside the ladder, the user bubble and composer draft read the body pair directly, and flow-row titles and summaries read the secondary pair. The sheet also owns the shadow scale (`--dsw-shadow-lv*`), the translucent-menu `--dsw-menu-backdrop-filter`, and the elevation tokens: `--dsw-elevation-stroke` draws a 0.5px hairline through the rebindable `--dsw-elevation-stroke-color`, and `--dsw-elevation-panel`/`--dsw-elevation-prominent`/`--dsw-elevation-soft` (the composer's larger-blur, lower-alpha tier) layer two faint soft shadows over that stroke, so elevated surfaces set `border: 0` and carry no layout-consuming outline; the derived tokens are re-declared per element so a surface's stroke-color rebind takes effect. An elevated surface that paints `--dsw-specific-menu` also applies `backdrop-filter: var(--dsw-menu-backdrop-filter)` ([decision](../../../.agents/notes/implemented/feature/2026-09-17-compact-translucent-menu-surfaces.md)). ### Scrollbar rebinding -`scrollbar.css` binds `--dsh-scrollbar-thumb` and `--dsh-scrollbar-thumb-hover` on `body` to the l1 base-surface tokens; an elevated surface (menu, popover, dialog) rebinds them to the l2 tokens on its own container, and the pair's other legal target is `transparent` (ui-sidebar rebinds its column that way while the pointer is elsewhere). WebKit-based browsers also read `--dsh-scrollbar-width`, `--dsh-scrollbar-thumb-border`, and `--dsh-scrollbar-track-margin`; a scroll surface may rebind them to keep a wide draggable rail around a narrower visible thumb or to inset the track from rounded ends. The two rendering paths are mutually exclusive by construction: Firefox takes the standard thin scrollbar inside `@supports not selector(::-webkit-scrollbar)`, and WebKit-based engines take the pseudo-elements, so geometry and hover customization apply only through the pseudo-element path. +`scrollbar.css` binds `--dsh-scrollbar-thumb` and `--dsh-scrollbar-thumb-hover` on `body` to the l1 base-surface tokens; an elevated surface (menu, popover, dialog) rebinds them to the l2 tokens on its own container, and the pair's other legal target is `transparent` (ui-sidebar rebinds its column that way while the pointer is elsewhere). WebKit-based browsers use a 5px default `--dsh-scrollbar-width` and also read `--dsh-scrollbar-thumb-border` and `--dsh-scrollbar-track-margin`; a scroll surface may rebind them to keep a wide draggable rail around a narrower visible thumb or to inset the track from rounded ends. The two rendering paths are mutually exclusive by construction: Firefox takes the standard thin scrollbar inside `@supports not selector(::-webkit-scrollbar)`, and WebKit-based engines take the pseudo-elements, so geometry and hover customization apply only through the pseudo-element path. ### Preference persistence diff --git a/packages/client/ui-theme/README.zh.md b/packages/client/ui-theme/README.zh.md index 08a0c37dc3..dc3a3672fe 100644 --- a/packages/client/ui-theme/README.zh.md +++ b/packages/client/ui-theme/README.zh.md @@ -51,15 +51,15 @@ kind: "package-reference" ### 样式表 -`src/styles/` 下有六张样式表,由 ui-theme 的动态客户端 entry 依次导入:`base.css`、`corner-shape.css`、`design-platform.css`、`scrollbar.css`、`gradient-shadow-text.css` 与 `shiki.css`。客户端 bundle 将其编译并注入为插件持有的全局样式,因此卸载与 HMR(热模块替换)会随 ui-theme 一同移除。`scrollbar.css` 是 `--dsw-alias-scrollbar-*` token 的唯一消费方,必须排在声明这些 token 的 `design-platform.css` 之后。 +`src/styles/` 下有六张样式表,由 ui-theme 的动态客户端 entry 依次导入:`base.css`、`corner-shape.css`、`design-platform.css`、`scrollbar.css`、`gradient-shadow-text.css` 与 `shiki.css`。客户端 bundle 将其编译并注入为插件持有的全局样式,因此卸载与 HMR(热模块替换)会随 ui-theme 一同移除。`scrollbar.css` 消费 `--dsw-alias-scrollbar-*` token,必须排在声明这些 token 的 `design-platform.css` 之后。状态标记使用各自的语义状态 token。 `corner-shape.css` 平滑所有圆角:在 `@supports (corner-shape: superellipse(1.5))` 内定义 `--dsw-corner-shape`,并通过通配选择器应用到所有元素及其 `::before`/`::after`,因此不支持 `corner-shape` 的引擎保持普通圆弧。正圆形状——`border-radius: 50%` 的圆与胶囊半径——因超级椭圆会使其变形,须在所属组件样式表中把 `corner-shape: round` 与半径声明配对;corner-shape 样式表 spec 跨全部包样式表强制这一配对。 -`gradient-shadow-text.css` 从 `--dsh-content-font-size` 派生 `--dsh-content-font-delta`,并以该增量移动 Markdown 标题与基础文本阶梯。它同时派生低一档变量 `--dsh-content-font-size-secondary`(设置 ≤14 时为设置值 −1,>14 时为设置值 −2;默认设置下为 13 px)及配套的 `--dsh-content-font-delta-secondary`,供表格变体与比正文低一档的流内行使用。紧凑的小号文本与代码变体保持固定字号。阶梯之外,用户气泡与 composer 草稿直接读取正文字号变量对,流内行的标题及摘要读取低一档变量对。该表还持有阴影阶(`--dsw-shadow-lv*`)与 elevation token:`--dsw-elevation-stroke` 经可重绑的 `--dsw-elevation-stroke-color` 画 0.5 px 发丝描边,`--dsw-elevation-panel`/`--dsw-elevation-prominent`/`--dsw-elevation-soft`(composer 专用的更大模糊、更低透明度档)在描边之上叠两层极淡柔光,因此高层级表面设 `border: 0`,不再有占布局的轮廓;派生 token 逐元素重声明,使表面对描边色的重绑真实生效。 +`gradient-shadow-text.css` 从 `--dsh-content-font-size` 派生 `--dsh-content-font-delta`,并以该增量移动 Markdown 标题与基础文本阶梯。它同时派生低一档变量 `--dsh-content-font-size-secondary`(设置 ≤14 时为设置值 −1,>14 时为设置值 −2;默认设置下为 13 px)及配套的 `--dsh-content-font-delta-secondary`,供表格变体与比正文低一档的流内行使用。紧凑的小号文本与代码变体保持固定字号。阶梯之外,用户气泡与 composer 草稿直接读取正文字号变量对,流内行的标题及摘要读取低一档变量对。该表还持有阴影阶(`--dsw-shadow-lv*`)、半透明菜单使用的 `--dsw-menu-backdrop-filter` 与 elevation token:`--dsw-elevation-stroke` 经可重绑的 `--dsw-elevation-stroke-color` 画 0.5 px 发丝描边,`--dsw-elevation-panel`/`--dsw-elevation-prominent`/`--dsw-elevation-soft`(composer 专用的更大模糊、更低透明度档)在描边之上叠两层极淡柔光,因此高层级表面设 `border: 0`,不会产生占布局的轮廓;派生 token 逐元素重声明,使表面对描边色的重绑真实生效。绘制 `--dsw-specific-menu` 的高层级表面还会应用 `backdrop-filter: var(--dsw-menu-backdrop-filter)`([决定](../../../.agents/notes/implemented/feature/2026-09-17-compact-translucent-menu-surfaces.zh.md))。 ### 滚动条重新绑定 -`scrollbar.css` 在 `body` 上把 `--dsh-scrollbar-thumb` 与 `--dsh-scrollbar-thumb-hover` 绑定到 l1 基础表面 token;高层级表面(菜单、浮层、对话框)在自己的容器上把它们重新绑定为 l2 token;这组变量的另一个合法目标是 `transparent`(ui-sidebar 在指针不在栏内时就这样重新绑定自己的列)。WebKit 系浏览器还会读取 `--dsh-scrollbar-width`、`--dsh-scrollbar-thumb-border` 与 `--dsh-scrollbar-track-margin`;滚动表面可重新绑定它们,在较窄的可见滑块外保留较宽的拖动区域,或让轨道避开圆角两端。两条渲染路径在构造上互斥:Firefox 走 `@supports not selector(::-webkit-scrollbar)` 内的标准细滚动条,WebKit 系引擎走伪元素,因此几何与 hover 定制只经由伪元素路径生效。 +`scrollbar.css` 在 `body` 上把 `--dsh-scrollbar-thumb` 与 `--dsh-scrollbar-thumb-hover` 绑定到 l1 基础表面 token;高层级表面(菜单、浮层、对话框)在自己的容器上把它们重新绑定为 l2 token;这组变量的另一个合法目标是 `transparent`(ui-sidebar 在指针不在栏内时就这样重新绑定自己的列)。WebKit 系浏览器默认使用 5px 的 `--dsh-scrollbar-width`,并读取 `--dsh-scrollbar-thumb-border` 与 `--dsh-scrollbar-track-margin`;滚动表面可重新绑定它们,在较窄的可见滑块外保留较宽的拖动区域,或让轨道避开圆角两端。两条渲染路径在构造上互斥:Firefox 走 `@supports not selector(::-webkit-scrollbar)` 内的标准细滚动条,WebKit 系引擎走伪元素,因此几何与 hover 定制只经由伪元素路径生效。 ### 偏好持久化 diff --git a/packages/client/ui-theme/package.json b/packages/client/ui-theme/package.json index 0acb236659..9d63fffd3d 100644 --- a/packages/client/ui-theme/package.json +++ b/packages/client/ui-theme/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-theme", "description": "Theme plugin: Host bootstrap for the pre-plugin palette; DOM-free ThemeRuntime for light/dark/system state; --dsw-* token styles and Appearance settings row", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-theme/src/client/AppearanceRow.tsx b/packages/client/ui-theme/src/client/AppearanceRow.tsx index b1db97d009..d234bd81a5 100644 --- a/packages/client/ui-theme/src/client/AppearanceRow.tsx +++ b/packages/client/ui-theme/src/client/AppearanceRow.tsx @@ -7,7 +7,7 @@ */ import clsx from 'clsx' import { - IconDarkOutline16, IconFollowsystemOutline16, IconLightOutline16, + IconDarkOutlineMedium, IconFollowsystemOutlineMedium, IconLightOutlineMedium, } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' import type { ThemePreference } from '../theme-settings.ts' @@ -28,10 +28,10 @@ export type AppearanceRowComponentProps = & PropsLocale<'settings.theme'> & AppearanceRowInjected /** Cube order and icons (figma 501:30015-30017: Light, Dark, System). */ -const CUBES: readonly { id: ThemePreference; labelKey: ThemeKey; Icon: typeof IconLightOutline16 }[] = [ - { id: 'light', labelKey: 'appearance.light', Icon: IconLightOutline16 }, - { id: 'dark', labelKey: 'appearance.dark', Icon: IconDarkOutline16 }, - { id: 'system', labelKey: 'appearance.system', Icon: IconFollowsystemOutline16 }, +const CUBES: readonly { id: ThemePreference; labelKey: ThemeKey; Icon: typeof IconLightOutlineMedium }[] = [ + { id: 'light', labelKey: 'appearance.light', Icon: IconLightOutlineMedium }, + { id: 'dark', labelKey: 'appearance.dark', Icon: IconDarkOutlineMedium }, + { id: 'system', labelKey: 'appearance.system', Icon: IconFollowsystemOutlineMedium }, ] /** diff --git a/packages/client/ui-theme/src/client/FontSizeRow.tsx b/packages/client/ui-theme/src/client/FontSizeRow.tsx index 2783f3a0fc..5754ae7ad9 100644 --- a/packages/client/ui-theme/src/client/FontSizeRow.tsx +++ b/packages/client/ui-theme/src/client/FontSizeRow.tsx @@ -8,7 +8,7 @@ * click echo. */ import { - IconChevronDownOutline14, IconChevronUpOutline14, + IconChevronDownOutlineRegular, IconChevronUpOutlineRegular, } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' import { FONT_SIZE_MAX, FONT_SIZE_MIN } from '../theme-settings.ts' @@ -51,7 +51,7 @@ export function FontSizeRow({ t, setFontSize, useStore }: FontSizeRowComponentPr disabled={fontSize >= FONT_SIZE_MAX} onClick={() => { setFontSize(fontSize + 1) }} > - +
    diff --git a/packages/client/ui-theme/src/client/index.ts b/packages/client/ui-theme/src/client/index.ts index 9997d3f291..83f1a22082 100644 --- a/packages/client/ui-theme/src/client/index.ts +++ b/packages/client/ui-theme/src/client/index.ts @@ -139,6 +139,7 @@ const BUILTIN_INSPECT_TOKENS: readonly ThemeTokenInspection[] = Object.freeze([ { name: '--dsw-alias-label-primary', description: 'Primary text color.', valueType: 'CSS color', requiresLightAndDark: true, cssVariable: '--dsw-alias-label-primary' }, { name: '--dsw-alias-label-secondary', description: 'Secondary text color.', valueType: 'CSS color', requiresLightAndDark: true, cssVariable: '--dsw-alias-label-secondary' }, { name: '--dsw-alias-state-error-primary', description: 'Primary error state color.', valueType: 'CSS color', requiresLightAndDark: true, cssVariable: '--dsw-alias-state-error-primary' }, + { name: '--dsw-alias-state-idle-primary', description: 'Primary inactive state color.', valueType: 'CSS color', requiresLightAndDark: true, cssVariable: '--dsw-alias-state-idle-primary' }, { name: '--dsw-alias-state-success-primary', description: 'Primary success state color.', valueType: 'CSS color', requiresLightAndDark: true, cssVariable: '--dsw-alias-state-success-primary' }, { name: '--dsw-alias-state-warn-primary', description: 'Primary warning state color.', valueType: 'CSS color', requiresLightAndDark: true, cssVariable: '--dsw-alias-state-warn-primary' }, { name: '--dsw-specific-sidebar-fill', description: 'Sidebar column and title-row background.', valueType: 'CSS color', requiresLightAndDark: true, cssVariable: '--dsw-specific-sidebar-fill' }, diff --git a/packages/client/ui-theme/src/styles/design-platform.css b/packages/client/ui-theme/src/styles/design-platform.css index 8b32be8feb..9ff8e82684 100644 --- a/packages/client/ui-theme/src/styles/design-platform.css +++ b/packages/client/ui-theme/src/styles/design-platform.css @@ -226,6 +226,7 @@ body { --dsw-alias-state-business-tertiary: var(--dsw-static-deepseek-100); --dsw-alias-state-error-primary: var(--dsw-static-red-600); --dsw-alias-state-error-secondary: var(--dsw-static-red-400); + --dsw-alias-state-idle-primary: var(--dsw-static-neutral-300); --dsw-alias-state-success-primary: var(--dsw-static-green-500); --dsw-alias-state-success-secondary: var(--dsw-static-green-400); --dsw-alias-state-success-tertiary: var(--dsw-static-green-100); @@ -239,7 +240,7 @@ body { --dsw-specific-bubble: var(--dsw-static-deepseek-50); --dsw-specific-input-major: var(--dsw-static-neutral-bluish-00); --dsw-specific-login-input: var(--dsw-static-neutral-bluish-50); - --dsw-specific-menu: var(--dsw-alias-bg-layer-3); + --dsw-specific-menu: rgba(248, 249, 250, 0.58); --dsw-specific-selector: var(--dsw-static-neutral-bluish-60); --dsw-specific-sidebar-fill: var(--dsw-static-neutral-bluish-50); --dsw-specific-sidebar-nav-item-active-accent: var(--dsw-static-deepseek-100); @@ -323,6 +324,7 @@ body[data-ds-dark-theme] { --dsw-alias-state-business-tertiary: var(--dsw-static-deepseek-800); --dsw-alias-state-error-primary: var(--dsw-static-red-400); --dsw-alias-state-error-secondary: var(--dsw-static-red-400); + --dsw-alias-state-idle-primary: var(--dsw-static-neutral-600); --dsw-alias-state-success-primary: var(--dsw-static-green-500); --dsw-alias-state-success-secondary: var(--dsw-static-green-400); --dsw-alias-state-success-tertiary: var(--dsw-static-green-900); @@ -336,7 +338,7 @@ body[data-ds-dark-theme] { --dsw-specific-bubble: var(--dsw-static-neutral-bluish-850); --dsw-specific-input-major: var(--dsw-static-neutral-bluish-850); --dsw-specific-login-input: var(--dsw-static-neutral-bluish-900); - --dsw-specific-menu: var(--dsw-alias-bg-layer-3); + --dsw-specific-menu: rgba(48, 49, 54, 0.5); --dsw-specific-selector: var(--dsw-static-neutral-bluish-800); --dsw-specific-sidebar-fill: var(--dsw-static-neutral-bluish-900); --dsw-specific-sidebar-nav-item-active-accent: var(--dsw-static-neutral-bluish-800); diff --git a/packages/client/ui-theme/src/styles/gradient-shadow-text.css b/packages/client/ui-theme/src/styles/gradient-shadow-text.css index 36fee49415..b6dc4db464 100644 --- a/packages/client/ui-theme/src/styles/gradient-shadow-text.css +++ b/packages/client/ui-theme/src/styles/gradient-shadow-text.css @@ -15,8 +15,9 @@ body { 模糊、更低透明度的柔光。默认色只声明在 body 上,让表面的重绑沿继承 传给真正消费投影的后代。 */ --dsw-elevation-stroke-color: var(--dsw-alias-border-l4); - /* blur filter */ + /* Shared blur treatments for blocking masks and translucent menus. */ --dsw-mask-blur: blur(2px); + --dsw-menu-backdrop-filter: blur(40px) saturate(150%); } /* 派生 elevation 值逐元素声明而非从 body 继承:继承传下来的是在 body 上就 diff --git a/packages/client/ui-theme/src/styles/scrollbar.css b/packages/client/ui-theme/src/styles/scrollbar.css index d61bcbcedb..035d315130 100644 --- a/packages/client/ui-theme/src/styles/scrollbar.css +++ b/packages/client/ui-theme/src/styles/scrollbar.css @@ -22,14 +22,14 @@ body { below. A surface that must align itself beside a space-consuming bar (ConversationRoot's overlay composer seat) reads this instead of hardcoding the number. */ - --dsh-scrollbar-width: 8px; + --dsh-scrollbar-width: 5px; } /* The two paths are mutually exclusive, and the gate is load-bearing rather than defensive. A non-`auto` `scrollbar-width` or `scrollbar-color` makes Chromium and Safari drop every `::-webkit-scrollbar*` rule for that element, including `::-webkit-scrollbar-thumb:hover` — measured in chromium - as an 8px `::-webkit-scrollbar` width taking effect on its own and being + as a `::-webkit-scrollbar` width taking effect on its own and being ignored as soon as `scrollbar-width: thin` is added. Declaring both unconditionally therefore leaves the hover tokens with no rendering at all, because the engines that implement the hover pseudo-element are exactly the diff --git a/packages/client/ui-theme/tests/elevation-styles.client.spec.ts b/packages/client/ui-theme/tests/elevation-styles.client.spec.ts index bf246f2d10..ade17f1f06 100644 --- a/packages/client/ui-theme/tests/elevation-styles.client.spec.ts +++ b/packages/client/ui-theme/tests/elevation-styles.client.spec.ts @@ -22,6 +22,8 @@ const NEUTRAL_BORDER = /--dsw-alias-border-/ const sheetCss = readFileSync( fileURLToPath(new URL('../src/styles/gradient-shadow-text.css', import.meta.url)), 'utf8') +const platformCss = readFileSync( + fileURLToPath(new URL('../src/styles/design-platform.css', import.meta.url)), 'utf8') describe('elevation tokens', () => { const rules = parseRules(sheetCss) @@ -53,6 +55,76 @@ describe('elevation tokens', () => { expect(bodyOnly.has(name), name).toBe(false) } }) + + it('defines the translucent menu material for both palettes', () => { + expect(bodyOnly.get('--dsw-menu-backdrop-filter')).toBe('blur(40px) saturate(150%)') + const platformRules = parseRules(platformCss) + const value = (selector: string): string | undefined => platformRules + .filter(rule => rule.selectors.includes(selector)) + .flatMap(rule => rule.declarations) + .findLast(([property]) => property === '--dsw-specific-menu')?.[1] + expect(value('body')).toBe('rgba(248, 249, 250, 0.58)') + expect(value('body[data-ds-dark-theme]')).toBe('rgba(48, 49, 54, 0.5)') + }) +}) + +/** Elevated or isolated-background menu-fill rules that omit the shared backdrop filter. */ +function translucentMenusWithoutBackdrop(css: string): string[] { + return parseRules(css) + .filter(rule => rule.declarations.some(([property, value]) => + (property === 'background' || property === 'background-color') + && value === 'var(--dsw-specific-menu)')) + .filter(rule => rule.declarations.some(([property, value]) => + property === 'box-shadow' && ELEVATED_SHADOW.test(value)) + || rule.selectors.some(selector => /::(?:before|after)$/.test(selector))) + .filter(rule => !rule.declarations.some(([property, value]) => + property === 'backdrop-filter' && value === 'var(--dsw-menu-backdrop-filter)')) + .map(rule => rule.selectors.join(', ')) +} + +describe('translucent menu surfaces pair fill and filter', () => { + it('rejects a menu fill without the shared backdrop filter', () => { + expect(translucentMenusWithoutBackdrop( + '.a { background: var(--dsw-specific-menu); box-shadow: var(--dsw-elevation-panel); }', + )).toEqual(['.a']) + expect(translucentMenusWithoutBackdrop( + '.a { background: var(--dsw-specific-menu); box-shadow: var(--dsw-elevation-panel); backdrop-filter: var(--dsw-menu-backdrop-filter); }', + )).toEqual([]) + expect(translucentMenusWithoutBackdrop( + '.a::before { background: var(--dsw-specific-menu); }', + )).toEqual(['.a::before']) + expect(translucentMenusWithoutBackdrop( + '.a::before { background: var(--dsw-specific-menu); backdrop-filter: var(--dsw-menu-backdrop-filter); }', + )).toEqual([]) + }) + + it('covers every package menu-fill consumer', () => { + const missing = packageStylesheets().flatMap(file => + translucentMenusWithoutBackdrop(readFileSync(file, 'utf8')) + .map(selectors => `${file} ${selectors}`)) + expect(missing).toEqual([]) + }) + + it('keeps backdrop filtering on background layers when descendants use fixed positioning', () => { + const surfaces = [ + ['packages/client/ui-goal/src/client/GoalBar.module.css', '.bar', '.bar::before'], + ['packages/client/ui-conversation/src/client/queue/QueueDock.module.css', '.panel', '.panel::before'], + ['packages/extensions/ui-cordis/src/client/CordisPanel.module.css', '.panel', '.panel::before'], + ] as const + const files = packageStylesheets() + for (const [suffix, container, background] of surfaces) { + const file = files.find(candidate => candidate.replaceAll('\\', '/').endsWith(suffix)) + expect(file, suffix).toBeDefined() + const rules = parseRules(readFileSync(file!, 'utf8')) + const declarations = (selector: string) => new Map(rules + .filter(rule => rule.selectors.length === 1 && rule.selectors[0] === selector) + .flatMap(rule => rule.declarations)) + expect(declarations(container).has('backdrop-filter'), container).toBe(false) + expect(declarations(background).get('background'), background).toBe('var(--dsw-specific-menu)') + expect(declarations(background).get('backdrop-filter'), background) + .toBe('var(--dsw-menu-backdrop-filter)') + } + }) }) /** @@ -144,7 +216,6 @@ describe('neutral solid borders are hairlines', () => { */ const RING_TRACKS = new Set([ 'boot-page.module.css .spinner', - 'TrajectoryTable.module.css .historyLoadingSpinner', ]) it('rejects a wide neutral border and a wide filled divider', () => { diff --git a/packages/client/ui-theme/tests/scrollbar-styles.client.spec.ts b/packages/client/ui-theme/tests/scrollbar-styles.client.spec.ts index 9682bdeb3c..48a49786cc 100644 --- a/packages/client/ui-theme/tests/scrollbar-styles.client.spec.ts +++ b/packages/client/ui-theme/tests/scrollbar-styles.client.spec.ts @@ -142,6 +142,8 @@ const SURFACE_PROPERTIES = ['background', 'background-color'] * carries a radius, a shadow, and a fixed size, so shape cannot separate them. */ const SURFACE_TOKEN_PATTERN = /^--dsw-(?:alias-bg-|specific-)/ +/** Translucent elevated surfaces whose colors do not resolve to a palette rung. */ +const TRANSLUCENT_ELEVATED_SURFACES = new Set(['--dsw-specific-menu']) /** * The palette's own dark elevation ladder, resolved from `design-platform.css`: @@ -178,7 +180,7 @@ function elevatedRungs(): Set { return tokens } -const elevatedSurfaces = elevatedRungs() +const elevatedSurfaces = new Set([...elevatedRungs(), ...TRANSLUCENT_ELEVATED_SURFACES]) for (const file of packageStylesheets()) { const rules = parseRules(readFileSync(file, 'utf8')) @@ -471,15 +473,16 @@ describe('elevated surface rebinds', () => { } }) - it('resolves the elevated surface set from the palette ladder', () => { + it('resolves opaque elevated surfaces from the palette ladder and includes translucent elevated surfaces', () => { // The set has to come from the palette, not from the sheets that happen to // rebind: derived from rebinds it can only confirm what someone already // remembered, and a surface nobody has rebound yet — the case the check // exists for — would define itself as unelevated. Anchoring it here means a // new palette token on an elevated rung is in scope the moment it is // defined. `--dsw-specific-tip` is the regression that proved the point: it - // resolves to the same dark rung as the menu surface, and the Todo panel - // scrolled on it unrebound while a rebind-derived set stayed green. + // resolves to the same dark rung as the input surface, and the Todo panel + // scrolled on it unrebound while a rebind-derived set stayed green. The + // translucent menu remains explicit because its alpha color has no rung. expect(elevatedSurfaces).toContain('--dsw-alias-bg-layer-2') expect(elevatedSurfaces).toContain('--dsw-alias-bg-layer-3') expect(elevatedSurfaces).toContain('--dsw-specific-menu') diff --git a/packages/client/ui-tool/README.i18n.yaml b/packages/client/ui-tool/README.i18n.yaml index 7a92445368..ce636be50c 100644 --- a/packages/client/ui-tool/README.i18n.yaml +++ b/packages/client/ui-tool/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-tool/README.md -README.md: 15dffa634660a5cf94081452481399821d09b25b -README.zh.md: 6f01658e5fea4fefca9eded114971d3e88a13148 +README.md: 6a63c14d163742b1818320925248405831bd87b6 +README.zh.md: e53a97404ae17f0e93242c11dcc0b1f066542c12 diff --git a/packages/client/ui-tool/README.md b/packages/client/ui-tool/README.md index 15dffa6346..6a63c14d16 100644 --- a/packages/client/ui-tool/README.md +++ b/packages/client/ui-tool/README.md @@ -25,7 +25,7 @@ English | [中文](README.zh.md) ## Use this package -Tool calls appear in the conversation as cards: a root call tree with its nested subcalls, each atomic call rendered by its owning view. Users see running, successful, failed, and interrupted states that come only from the frozen call/result slice, and can open files or inspect calls through the Host callbacks. +Tool calls appear in the conversation as cards: a root call tree with its nested subcalls, each atomic call rendered by its owning view. Every lifecycle state retains the tool's ordinary business glyph; failure and interruption remain explicit through the frozen call/result state, accessible status text, and failure summary. Users can open files or inspect calls through the Host callbacks. ### Registering a business tool view @@ -62,12 +62,16 @@ The package realizes one dispatch rule: atomic Tool views are keyed by wire Tool ### Cards -Every card is read in place in the call tree; there is no second, full-height presentation of a selected call. Row renderers share one pure card model for each terminal, read, diff, search, and web card, and the image card's gallery renders through the tool-owned `tool.call.images` slot. These models validate raw call arguments, result content, failure state, persisted metadata, PTC dispatch `parentCallId`, and Session path facts. Unsupported or malformed inputs use flattened Tool result text. A file-path summary opens the file through the owner's `openFile`, which the chat view routes to the right Sidebar's text preview; `inspect` opens the trajectory view. Card-specific limits and fallback rules for the terminal, diff, read, search, and web cards remain in [the ui-primitives README](../ui-primitives/README.md); the image card's model in this package carries its own fallback rules. +Every card is read in place in the call tree; there is no second, full-height presentation of a selected call. Row renderers share one pure card model for each terminal, read, diff, search, and web card, and the image card's gallery renders through the tool-owned `tool.call.images` slot. These models validate raw call arguments, result content, failure state, persisted metadata, PTC dispatch `parentCallId`, and Session path facts. Unsupported or malformed inputs use flattened Tool result text. A file-path summary opens the file through the owner's `openFile`, which the chat view routes to the right Sidebar's text preview; `inspect` opens the trajectory view and is absent when that View is unavailable; cards then omit Inspect. Card-specific limits and fallback rules for the terminal, diff, read, search, and web cards remain in [the ui-primitives README](../ui-primitives/README.md); the image card's model in this package carries its own fallback rules. Chat diff cards keep nine rows before folding, enough for a file header, one removed/added pair, and three context lines on either side. The collapsed row and expanded footer share the primitive's exact or coarse-replacement counts. An Auto denial takes precedence over keyed specialized views. Its generic row preserves the call identity, omits raw arguments, and normalizes the stored reason only for display: trim surrounding whitespace and collapse line separators to spaces, with localized fallback for an empty result. Session and SDK error details keep the original reason. +Recorded tool details cover goal and schedule tools, Cordis inspection, workflow and Ralph reports, Session event/search/trace queries, agent and teammate controls, background jobs, persistent terminals, and LSP navigation. These expanded bodies read successful logged results, preserve generic input/output for failures or unsupported data, and keep Inspect available. Dates include the viewer's time zone, and statuses reflect the call result rather than current session state. Session traces preserve descendant indentation. LSP results open filesystem paths through the Host callback and display other URIs as text. The browser adapter consumes recorded producer text and JSON; Host service objects and presenter callbacks do not cross into the Client. [Compact tool details](../../../.agents/notes/implemented/architecture/2026-09-10-compact-tool-details.md) records the presentation trade-offs. + +Expanded status dots and labels use static semantic colors. Receipt and job-output headers keep neutral text and omit the status while expanded. An interruption receipt confirms only that interruption was requested. + The terminal model uses `hasSpillNotice` from the browser-safe `@deepseek-ai/dsh-spill-policy/notice` entry, not an independent UI pattern. The [spill-policy README](../../spill/spill-policy/README.md#shared-notice-ownership) owns notice formatting and recognition. This check conservatively selects generic output; matching text cannot authenticate its source, and replay leaves recorded result bytes untouched. diff --git a/packages/client/ui-tool/README.zh.md b/packages/client/ui-tool/README.zh.md index 6f01658e5f..e53a97404a 100644 --- a/packages/client/ui-tool/README.zh.md +++ b/packages/client/ui-tool/README.zh.md @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -工具调用在对话中显示为卡片:一个根调用树带其嵌套子调用,每个原子调用由所属视图渲染。用户看到运行中、成功、失败与中断状态,这些状态只来自冻结的调用/结果切片,并可通过宿主回调打开文件或检查调用。 +工具调用在对话中显示为卡片:一个根调用树带其嵌套子调用,每个原子调用由所属视图渲染。所有生命周期状态都保留工具的普通业务图标;失败与中断仍通过冻结调用/结果状态、无障碍状态文本和失败摘要明确表达。用户可通过宿主回调打开文件或检查调用。 ### 注册业务工具视图 @@ -62,12 +62,16 @@ owner 载荷为 `ToolCallOwnerProps`:`callId`、`toolName`、冻结的 `block` ### 卡片 -每张卡片都直接在调用树中查看;选中调用后不会再显示第二个全高视图。行 renderer 为 terminal、read、diff、search 和 web 卡片各复用同一个纯 card model,image 卡片的图库经由工具自有 `tool.call.images` slot 渲染。这些 model 校验原始调用参数、结果内容、失败状态、持久 metadata、PTC dispatch 的 `parentCallId` 与会话路径信息。不受支持或格式错误的输入使用压平的工具结果文本。文件路径摘要经属主的 `openFile` 打开文件,chat 视图把它路由到右侧 Sidebar 的文本预览;`inspect` 打开轨迹视图。terminal、diff、read、search 与 web 卡片的上限与 fallback 规则仍由 [ui-primitives README](../ui-primitives/README.zh.md) 负责;image 卡片的 fallback 规则由本包内的 card model 自行承载。 +每张卡片都直接在调用树中查看;选中调用后不会再显示第二个全高视图。行 renderer 为 terminal、read、diff、search 和 web 卡片各复用同一个纯 card model,image 卡片的图库经由工具自有 `tool.call.images` slot 渲染。这些 model 校验原始调用参数、结果内容、失败状态、持久 metadata、PTC dispatch 的 `parentCallId` 与会话路径信息。不受支持或格式错误的输入使用压平的工具结果文本。文件路径摘要经属主的 `openFile` 打开文件,chat 视图把它路由到右侧 Sidebar 的文本预览;`inspect` 打开轨迹视图;该视图不可用时不提供此回调,卡片随之隐藏 Inspect。terminal、diff、read、search 与 web 卡片的上限与 fallback 规则仍由 [ui-primitives README](../ui-primitives/README.zh.md) 负责;image 卡片的 fallback 规则由本包内的 card model 自行承载。 Chat diff 卡片在折叠前保留九行,足以容纳文件标题、一对删除与新增行及两侧各三行上下文。折叠工具行与展开卡片底部采用原语一致的精确或粗粒度替换统计。 Auto 拒绝优先于按工具名选择的专门视图。其通用行保留调用身份、省略原始参数,并且只在显示时归一化存储的理由:去除首尾空白,把行分隔符折叠为空格,结果为空时使用本地化通用理由。Session 与 SDK 错误详情保留原始理由。 +记录结果的工具详情覆盖目标和定时任务工具、Cordis 检查、workflow 与 Ralph 报告、Session 事件/搜索/轨迹查询、Agent 与 teammate 控制、后台作业、持久终端以及 LSP 导航。展开内容读取成功的记录结果,为失败或不支持的数据保留通用输入/输出,并保留 Inspect。日期包含查看者的时区,状态反映调用结果而非当前会话状态。Session 轨迹保留后代的缩进。LSP 结果通过 Host 回调打开文件系统路径,其他 URI 则显示为文本。浏览器适配器消费已记录的 producer 文本与 JSON;Host service 对象和 presenter 回调不会进入 Client。[紧凑工具详情](../../../.agents/notes/implemented/architecture/2026-09-10-compact-tool-details.zh.md)记录了呈现取舍。 + +展开后的状态圆点和文字使用静态语义色。操作回执和任务输出的标题保持中性色,展开时省略标题中的状态。中断回执仅确认已发出中断请求。 + terminal model 使用浏览器安全入口 `@deepseek-ai/dsh-spill-policy/notice` 的 `hasSpillNotice`,而非独立的 UI 匹配规则。[spill-policy README](../../spill/spill-policy/README.zh.md#shared-notice-ownership) 负责提示文本的格式化与识别。该检查保守地选择通用输出;匹配的文本无法证明其来源,回放也不改变已记录的结果字节。 diff --git a/packages/client/ui-tool/package.json b/packages/client/ui-tool/package.json index b84ce6d5ed..a013535d31 100644 --- a/packages/client/ui-tool/package.json +++ b/packages/client/ui-tool/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-tool", "description": "Client Tool call-tree renderer and keyed per-tool presentation slot", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, @@ -51,6 +51,9 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/dsh-tool-jobs": "workspace:^", + "@deepseek-ai/dsh-tool-session-query": "workspace:^", + "@deepseek-ai/dsh-tool-terminal": "workspace:^", "@deepseek-ai/dsh-spill": "workspace:^", "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", diff --git a/packages/client/ui-tool/src/client/apply.ts b/packages/client/ui-tool/src/client/apply.ts index 28bc6e51fe..e6a866d1b2 100644 --- a/packages/client/ui-tool/src/client/apply.ts +++ b/packages/client/ui-tool/src/client/apply.ts @@ -14,6 +14,7 @@ import { fileMutationToolview } from './tool/toolviews/file-mutation-row.tsx' import { readToolview } from './tool/toolviews/read-row.tsx' import { readImageToolview } from './tool/toolviews/read-image-row.tsx' import { searchToolview } from './tool/toolviews/search-row.tsx' +import { detailsToolview } from './tool/toolviews/details-row.tsx' import { todoToolview } from './tool/toolviews/todo-row.tsx' import { webToolview } from './tool/toolviews/web-row.tsx' @@ -47,5 +48,6 @@ export function apply(ctx: ClientContext): void { ctx.plugin(searchToolview) ctx.plugin(webToolview) ctx.plugin(todoToolview) + ctx.plugin(detailsToolview) ctx.plugin(askQuestionToolview) } diff --git a/packages/client/ui-tool/src/client/contract/slots.ts b/packages/client/ui-tool/src/client/contract/slots.ts index 512c6880bb..adb590eb58 100644 --- a/packages/client/ui-tool/src/client/contract/slots.ts +++ b/packages/client/ui-tool/src/client/contract/slots.ts @@ -10,18 +10,13 @@ import type {} from '@deepseek-ai/dsh-client-locale/client' declare module '@deepseek-ai/dsh-client-ui-slots' { interface SlotMap { /** - * Keyed atomic Tool call view, dispatched by the wire Tool name. Register - * with `key: ''` to own how one tool's calls render inside a - * turn — the key domain is open (any wire tool name, including a tool your - * own package registered), so there is no compile-time key set to pick - * from and a typo simply never renders. + * Keyed Tool call view dispatched by wire Tool name. Any name is allowed, + * including tools registered by your package. Register with + * `key: ''`; a typo never renders. * - * A key the shipped composition already covers is replaced, not shared; - * an unclaimed key falls back to the generic tool row, so registering is - * additive for your own tool and a takeover for a shipped one. The owner - * passes the call's identity, its frozen running-or-settled node, and the - * expansion state (see ToolCallOwnerProps), so the view stays a pure - * function of what the turn already knows. + * Registering an occupied key replaces its view; unclaimed keys use the + * generic row. The owner supplies the call identity and frozen running + * or settled node through ToolCallOwnerProps. */ 'tool.call.toolview': { kind: 'keyed'; scope: 'session'; owner: ToolCallOwnerProps } /** diff --git a/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx b/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx index f4d62697f7..6338f89f1c 100644 --- a/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx +++ b/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx @@ -29,7 +29,7 @@ const ToolCall = memo(function ToolCall({ cwd, home, loadImage, - inspect: () => { inspectCall(callId) }, + inspect: inspectCall === undefined ? undefined : () => { inspectCall(callId) }, }), [callId, toolName, block, openFile, cwd, home, loadImage, inspectCall]) const autoReviewDenied = useMemo( () => toolRowModel(toolName, block).autoReviewDenial !== null, diff --git a/packages/client/ui-tool/src/client/tool/components/ToolDetails.module.css b/packages/client/ui-tool/src/client/tool/components/ToolDetails.module.css new file mode 100644 index 0000000000..824bd18fde --- /dev/null +++ b/packages/client/ui-tool/src/client/tool/components/ToolDetails.module.css @@ -0,0 +1,258 @@ +.root { + margin: 4px 0 4px 4px; + border: 0.5px solid var(--dsw-alias-border-l1); + border-radius: 12px; + background: var(--dsw-alias-markdown-code-block); + color: var(--dsw-alias-label-secondary); + font: var(--dsw-font-xs-13); + overflow: hidden; +} + +.list { + list-style: none; + margin: 0; + padding: 0; + max-height: 320px; + overflow-y: auto; +} + +.item, +.empty { + margin: 0; + padding: 10px 14px; +} + +.item + .item { + border-top: 0.5px solid var(--dsw-alias-border-l2); +} + +.root[data-inspect]:not([data-caption]) > .list > .item, +.root[data-inspect]:not([data-caption]) .empty, +.root[data-inspect] .caption { + padding-inline-end: 88px; +} + +.caption { + padding: 10px 14px 6px; + color: var(--dsw-alias-label-caption); + font-size: 12px; +} + +.heading { + display: flex; + align-items: baseline; + gap: 8px; + flex-wrap: wrap; +} + +.text { + flex: 1; + min-width: 0; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.status { + flex: none; + align-self: flex-start; + display: inline-flex; + align-items: center; + justify-content: center; + width: 14px; + height: 18px; + font-size: 16px; + line-height: 18px; +} + +.pending { + box-sizing: border-box; + width: 10px; + height: 10px; + border: 1px solid var(--dsw-alias-label-tertiary); + border-radius: 2px; +} + +.heading:has(.statusText) .text { + flex-basis: 12em; +} + +.statusText { + margin-inline-start: auto; + color: var(--dsw-alias-label-caption); + font-size: 12px; + white-space: nowrap; +} + +.previous, +.unchanged { + color: var(--dsw-alias-label-tertiary); +} + +.item[data-change='added'] .status { + color: var(--dsw-alias-state-success-primary); +} + +.item[data-change='removed'] .status { + color: var(--dsw-alias-state-error-primary); +} + +.item[data-change='removed'] .text { + color: var(--dsw-alias-label-tertiary); + text-decoration: line-through; +} + +.unchanged { + border-top: 0.5px solid var(--dsw-alias-border-l2); +} + +.unchanged summary { + display: flex; + align-items: center; + gap: 6px; + padding: 9px 14px; + list-style: none; + cursor: pointer; +} + +.unchanged summary::-webkit-details-marker { + display: none; +} + +.unchanged[open] summary svg { + transform: rotate(90deg); +} + +.fields { + margin: 0; +} + +.heading + .fields { + margin-top: 6px; +} + +.field { + display: grid; + grid-template-columns: 5.5em minmax(0, 1fr); + gap: 12px; +} + +.field + .field { + margin-top: 4px; +} + +.field dt { + color: var(--dsw-alias-label-caption); +} + +.field dd { + margin: 0; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.badge { + flex: none; + display: inline-flex; + align-items: center; + gap: 5px; + color: var(--dsw-alias-label-caption); + font-size: 12px; +} + +.badge::before { + content: ''; + width: 5px; + height: 5px; + border-radius: 50%; + corner-shape: round; + background: currentColor; +} + +.badge[data-tone='info'] { color: var(--dsw-alias-state-business-primary); } +.badge[data-tone='success'] { color: var(--dsw-alias-state-success-primary); } +.badge[data-tone='warning'] { color: var(--dsw-alias-state-warn-primary); } +.badge[data-tone='error'] { color: var(--dsw-alias-state-error-primary); } + +.subtitle { + margin-top: 3px; + color: var(--dsw-alias-label-tertiary); + font-size: 12px; + overflow-wrap: anywhere; +} + +.description { + margin: 6px 0; + color: var(--dsw-alias-label-caption); + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.lines { + margin: 6px 0 0; + padding-inline-start: 18px; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.lines li + li { margin-top: 4px; } + +.group { + margin-top: 8px; + border-top: 0.5px solid var(--dsw-alias-border-l2); +} + +.group > summary { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 0 0; + color: var(--dsw-alias-label-caption); + list-style: none; + cursor: pointer; + overflow-wrap: anywhere; +} + +.group > summary::-webkit-details-marker { display: none; } +.group[open] > summary svg { transform: rotate(90deg); } +.group > summary svg { flex: none; } +.group .list { max-height: none; overflow: visible; } +.group .item { padding: 9px 0 2px 20px; } + +.prose, +.code { + max-width: 100%; + font-size: 13px; +} + +.prose { margin-top: 8px; } + +.root .item > .code { + margin: 8px 0 0; + max-height: 240px; + overflow: auto; +} + +.root .group .item:has(> .code:only-child) { + padding: 4px 0 0 20px; +} + +.root .group .item > .code:only-child { margin: 0; } + +.root .code [data-code-block-banner] { padding: 4px 10px; } +.root .code pre { padding: 6px 10px 8px; } + +.path { + flex: 1; + min-width: 0; + padding: 0; + border: none; + background: none; + color: inherit; + font: inherit; + text-align: start; + overflow-wrap: anywhere; + cursor: pointer; +} + +.path:hover { text-decoration: underline; } +.path:focus-visible { outline: 2px solid var(--dsw-alias-state-business-primary); outline-offset: 3px; } diff --git a/packages/client/ui-tool/src/client/tool/components/ToolDetails.tsx b/packages/client/ui-tool/src/client/tool/components/ToolDetails.tsx new file mode 100644 index 0000000000..801be3c7a0 --- /dev/null +++ b/packages/client/ui-tool/src/client/tool/components/ToolDetails.tsx @@ -0,0 +1,138 @@ +/** Compact, read-only fields and lists for recorded Tool results. */ +import { + CodeBlock, MarkdownText, IconCheckOutlineRegular, IconChevronRightOutlineRegular, IconPlayOutlineRegular, +} from '@deepseek-ai/dsh-client-ui-primitives' +import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' +import type { OpenFileOptions } from '@deepseek-ai/dsh-client-ui-chat/client' +import { markdownLabels } from '../models/primitive-labels.ts' +import css from './ToolDetails.module.css' + +/** One recorded entity, receipt, or collapsible group of related values. */ +export interface ToolDetailItem { + title?: string + subtitle?: string + description?: string + badge?: { label: string; tone: 'neutral' | 'info' | 'success' | 'warning' | 'error' } + status?: { value: 'completed' | 'in_progress' | 'pending'; label: string } + previousStatus?: string + change?: { value: 'added' | 'removed' | 'updated'; label: string } + fields: readonly { label: string; value: string }[] + lines?: readonly string[] + markdown?: string + code?: { text: string; language?: string } + location?: { path: string; line?: number } + groups?: readonly { label: string; items: readonly ToolDetailItem[] }[] +} + +/** Localized display data shared by the built-in detail cards. */ +export interface ToolDetailsModel { + items: readonly ToolDetailItem[] + summary?: string + /** Header text while expanded, omitting a receipt status shown in the body. */ + expandedSummary?: string + empty?: string + caption?: string + unchanged?: { label: string; items: ToolDetailsModel['items'] } +} + +interface DetailContentProps { + t: TranslateNS<'conversation'> + onOpenFile?: ((path: string, options?: OpenFileOptions) => void) | undefined +} + +function DetailItem({ item, t, onOpenFile }: { item: ToolDetailItem } & DetailContentProps) { + const status = item.status + return ( +
  • + {item.title !== undefined && ( +
    + {status !== undefined && ( + + {item.change?.value === 'added' ? '+' + : item.change?.value === 'removed' ? '−' + : status.value === 'completed' ? + : status.value === 'in_progress' ? + : } + + )} + {item.location !== undefined && onOpenFile !== undefined ? ( + + ) : {item.title}} + {item.badge !== undefined && {item.badge.label}} + {status !== undefined && ( + + {item.previousStatus !== undefined && ( + <>{item.previousStatus} → + )} + {status.label} + {item.change?.value === 'updated' && item.previousStatus === undefined && · {item.change.label}} + + )} +
    + )} + {item.subtitle !== undefined &&
    {item.subtitle}
    } + {item.description !== undefined &&

    {item.description}

    } + {item.fields.length > 0 && ( +
    + {item.fields.map(field => ( +
    +
    {field.label}
    +
    {field.value}
    +
    + ))} +
    + )} + {item.lines !== undefined &&
      {item.lines.map((line, index) =>
    • {line}
    • )}
    } + {item.markdown !== undefined &&
    } + {item.code !== undefined && ( + + )} + {item.groups?.map((group, index) => ( +
    + {group.label} +
      + {group.items.map((child, childIndex) => )} +
    +
    + ))} +
  • + ) +} + +/** + * Render recorded values with local disclosures, copy controls, and file navigation. + * @param props.model - Localized fields or list items; an empty list uses its empty label. + * @param props.hasInspect - Reserve space for the row's upper-right Inspect button. + * @param props.t - Conversation dictionary for shared Markdown and code controls. + * @param props.onOpenFile - Open a recorded file location in the session workspace. + * @returns The expanded detail body. + */ +export function ToolDetails({ model, hasInspect = false, t, onOpenFile }: { + model: ToolDetailsModel + hasInspect?: boolean +} & DetailContentProps) { + return ( +
    + {model.caption !== undefined &&
    {model.caption}
    } + {model.items.length === 0 ?

    {model.empty}

    : ( +
      + {model.items.map((item, index) => )} +
    + )} + {model.unchanged !== undefined && ( +
    + {model.unchanged.label} +
      + {model.unchanged.items.map((item, index) => )} +
    +
    + )} +
    + ) +} diff --git a/packages/client/ui-tool/src/client/tool/components/ToolRow.module.css b/packages/client/ui-tool/src/client/tool/components/ToolRow.module.css index f1933dc068..520e800a3a 100644 --- a/packages/client/ui-tool/src/client/tool/components/ToolRow.module.css +++ b/packages/client/ui-tool/src/client/tool/components/ToolRow.module.css @@ -155,6 +155,11 @@ color: var(--dsw-alias-state-error-primary); } +/* Interrupted rows retain their business glyph and replace the summary with a visible warning. */ +.stoppedSummary { + color: var(--dsw-alias-state-warn-label); +} + /* Expanded body + Inspect pill wrapper (sibling of .row: clicks never toggle). */ .bodyWrap { display: flex; @@ -191,6 +196,17 @@ opacity: 1; } +.detailsBodyWrap { + position: relative; +} + +.detailsBodyWrap .inspectButton { + position: absolute; + top: 12px; + right: 12px; + margin: 0; +} + /* Solid hover fill (a translucent token would let content bleed through). */ .inspectButton:hover { background: var(--dsw-alias-interactive-bg-hover-solid); @@ -344,9 +360,8 @@ border: 0.5px solid var(--dsw-alias-border-l1); } -/* Visually hidden run-state label for assistive technology: the StateDot and - the running sweep are aria-hidden / colour-only, so the text carries the - running/failed/interrupted state to a screen reader. */ +/* Visually hidden run-state label for assistive technology: the running sweep + and settlement summary tones are color-only. */ .visuallyHidden { position: absolute; width: 1px; diff --git a/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx b/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx index 0472875209..3799370527 100644 --- a/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx +++ b/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx @@ -1,7 +1,8 @@ import { useMemo, useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react' import clsx from 'clsx' import { - CodeBlock, DiffBlock, DisclosureRow, IconInspectOutline12, ReadBlock, SearchBlock, StateDot, TerminalBlock, WebBlock, + CodeBlock, DiffBlock, DisclosureRow, IconInspectOutlineRegular, ReadBlock, SearchBlock, + TerminalBlock, WebBlock, diffTotals, } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsRenderSlots, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' @@ -23,6 +24,7 @@ import { } from '../models/tool-call-model.ts' import type { WebCardModelProps } from '../models/web-card-model.ts' import { AskQuestionCard } from './AskQuestionCard.tsx' +import { ToolDetails, type ToolDetailsModel } from './ToolDetails.tsx' import css from './ToolRow.module.css' export interface ToolRowProps { @@ -70,6 +72,8 @@ export interface ToolRowProps { loadImage?: MessageImageLoader | undefined search?: SearchCardModel | null | undefined web?: WebCardModelProps | null | undefined + /** Read-only fields/list card derived from a successful recorded result. */ + details?: ToolDetailsModel | null | undefined state: ToolRowState /** * Filesystem path from tool args; when set with onOpenFile, the summary @@ -87,18 +91,7 @@ export interface ToolRowProps { inspect?: (() => void) | undefined } -function leadingFor(state: ToolRowState, icon: ReactNode): ReactNode { - switch (state) { - case 'error': return - case 'stopped': return - default: return icon - } -} - -/** Visually hidden run-state label: the StateDot and the CSS sweep are both - * aria-hidden / colour-only, so assistive technology needs this text to know a - * row is running, failed, or interrupted. null in the ok state (the icon and - * summary already describe a settled row). */ +/** Visually hidden run-state label for color-only running and settlement cues. */ function stateStatus(state: ToolRowState, t: TranslateNS<'conversation'>): string | null { switch (state) { case 'running': return t('row.running') @@ -128,6 +121,7 @@ export function ToolRow({ loadImage, search, web, + details, state, filePath, filePathLine, @@ -151,9 +145,10 @@ export function ToolRow({ const searchBody = search ?? null const webBody = web ?? null const askQuestionBody = askQuestion ?? null + const detailsBody = details ?? null const inputRaw = bodyRaw ?? null const outputText = output ?? null - const card = askQuestionBody ?? terminalBody ?? diffBody ?? readBody ?? imageBody ?? searchBody ?? webBody + const card = askQuestionBody ?? terminalBody ?? diffBody ?? readBody ?? imageBody ?? searchBody ?? webBody ?? detailsBody const expandable = inputRaw !== null || outputText !== null || card !== null const open = expanded && expandable const bodyText = useMemo( @@ -161,9 +156,12 @@ export function ToolRow({ [card, inputRaw, open, variant], ) const status = stateStatus(state, t) - // A failure must replace, not supplement, the normal summary. - const failureLine = state === 'error' ? errorSummary ?? null : null - const summaryText = failureLine ?? terminalBody?.description ?? summary + const normalSummary = terminalBody?.description ?? (open ? detailsBody?.expandedSummary ?? summary : summary) + // A failure keeps its first result line when available and otherwise turns + // the ordinary summary red. An interruption turns the tool-owned summary + // amber while retaining the business icon and hidden state announcement. + const failureLine = state === 'error' ? errorSummary ?? normalSummary : null + const summaryText = failureLine ?? normalSummary // A diff row's collapsed line carries the card's +/- totals (the same // numbers the expanded footer prints) so the change size reads without // expanding; an explicit summarySuffix (none today on diff rows) wins. @@ -172,11 +170,12 @@ export function ToolRow({ const { added, removed } = diffTotals(diffBody.card.diffs) return `+${added} -${removed}` }, [diffBody]) - const suffix = failureLine === null ? summarySuffix ?? diffStat : null + const settledWithCue = state === 'error' || state === 'stopped' + const suffix = settledWithCue ? null : summarySuffix ?? diffStat const toggleExpand = () => { setExpanded(v => !v) } - const openFile = filePath !== undefined && onOpenFile !== undefined && failureLine === null + const openFile = filePath !== undefined && onOpenFile !== undefined && !settledWithCue ? (event: MouseEvent) => { event.stopPropagation() if (filePathLine === undefined) onOpenFile(filePath) @@ -201,7 +200,7 @@ export function ToolRow({ leadingClassName={css.leading} titleClassName={css.title} chevronClassName={css.chevron} - icon={leadingFor(state, icon)} + icon={icon} title={title} open={open} expandable={expandable} @@ -224,7 +223,11 @@ export function ToolRow({ ) : ( {summaryText} @@ -235,7 +238,7 @@ export function ToolRow({ )} > -
    +
    {askQuestionBody !== null ? : terminalBody !== null @@ -289,43 +292,45 @@ export function ToolRow({ ) : webBody !== null ? - : ( - <> - {variant === 'code' && bodyText !== null && ( -
    - -
    - )} - {(cardBody !== null || outputText !== null) && ( -
    - {cardBody !== null && ( -
    - {t('row.input')} - {cardBody} -
    - )} - {cardBody !== null && outputText !== null && ( - - )} - {outputText !== null && ( -
    - {t('row.output')} - - {outputText} - -
    - )} -
    - )} - - )} + : detailsBody !== null + ? + : ( + <> + {variant === 'code' && bodyText !== null && ( +
    + +
    + )} + {(cardBody !== null || outputText !== null) && ( +
    + {cardBody !== null && ( +
    + {t('row.input')} + {cardBody} +
    + )} + {cardBody !== null && outputText !== null && ( + + )} + {outputText !== null && ( +
    + {t('row.output')} + + {outputText} + +
    + )} +
    + )} + + )} {inspect !== undefined && ( )} diff --git a/packages/client/ui-tool/src/client/tool/models/control-details-model.ts b/packages/client/ui-tool/src/client/tool/models/control-details-model.ts new file mode 100644 index 0000000000..47d6edceb8 --- /dev/null +++ b/packages/client/ui-tool/src/client/tool/models/control-details-model.ts @@ -0,0 +1,199 @@ +/** Entity lists and receipts for agent, job, terminal, and language-server tools. */ +import type { ToolDetailsModel } from '../components/ToolDetails.tsx' +import { + detailBadge, detailList, detailRecord, inspectionItems, + type DetailItem, type DetailTranslate, +} from './detail-model-shared.ts' + +const OUTPUT_TRUNCATED = '\n[output truncated]' + +function arg(args: Record, key: string): string { + const value = args[key] + return typeof value === 'string' ? value : '' +} + +function receipt(title: string, badge: NonNullable, t: DetailTranslate, fields: DetailItem['fields'] = [], description?: string): ToolDetailsModel { + return { + ...detailList([{ title, badge, fields, ...(description === undefined ? {} : { description }) }], `${title} · ${badge.label}`, t), + expandedSummary: title, + } +} + +function agentList(text: string, json: unknown, t: DetailTranslate): ToolDetailsModel | null { + if (Array.isArray(json)) return detailList(inspectionItems(json, t), t('detail.agents.count', { count: json.length }), t) + if (text === '(no subagents)') return detailList([], t('detail.agents.count', { count: 0 }), t) + const items: DetailItem[] = [] + for (const line of text.split('\n')) { + const match = /^(\S+) \[([^\]]+)\](?: parent=(\S+) depth=(\d+))?(?: — (.*))?$/u.exec(line) + if (match === null) return null + const [, id, state, parent, depth, title] = match + if (id === undefined || state === undefined) return null + items.push({ + title: title ?? id, ...(title === undefined ? {} : { subtitle: id }), + badge: detailBadge(state, t), + fields: parent === undefined ? [] : [ + { label: t('detail.field.parent'), value: parent }, + { label: t('detail.field.depth'), value: depth ?? '' }, + ], + }) + } + return detailList(items, t('detail.agents.count', { count: items.length }), t) +} + +function jobList(text: string, t: DetailTranslate): ToolDetailsModel | null { + if (text === '(no background jobs)') return detailList([], t('detail.jobs.count', { count: 0 }), t) + const items: DetailItem[] = [] + for (const line of text.split('\n')) { + const match = /^(\S+) \[([^\]]+)\] (\S+) — (.*)$/u.exec(line) + if (match === null) return null + const [, id, kind, state, title] = match + if (id === undefined || kind === undefined || state === undefined || title === undefined) return null + items.push({ title, subtitle: id, badge: detailBadge(state, t), fields: [{ label: t('detail.field.type'), value: kind }] }) + } + return detailList(items, t('detail.jobs.count', { count: items.length }), t) +} + +function terminalList(text: string, t: DetailTranslate): ToolDetailsModel | null { + if (text === '(no terminal sessions)') return detailList([], t('detail.terminals.count', { count: 0 }), t) + const items: DetailItem[] = [] + for (const line of text.split('\n')) { + const match = /^(\S+)(?: \((.*?)\))? \[([^\]]+)\] (running|exited code=(\S+) signal=(\S+))(?: pid=(\d+))?$/u.exec(line) + if (match === null) return null + const [, id, name, type, state, exitCode, signal, pid] = match + if (id === undefined || type === undefined || state === undefined) return null + const fields = [{ label: t('detail.field.type'), value: type }] + if (pid !== undefined) fields.push({ label: t('detail.field.pid'), value: pid }) + if (exitCode !== undefined) fields.push({ label: t('detail.field.exitCode'), value: exitCode }) + if (signal !== undefined && signal !== 'null') fields.push({ label: t('detail.field.signal'), value: signal }) + items.push({ + title: name ?? id, ...(name === undefined ? {} : { subtitle: id }), + badge: state === 'running' ? detailBadge('running', t) : { label: t('detail.status.exited'), tone: exitCode === '0' ? 'success' : 'neutral' }, fields, + }) + } + return detailList(items, t('detail.terminals.count', { count: items.length }), t) +} + +function lspDetails(args: Record, text: string, t: DetailTranslate): ToolDetailsModel | null { + const file = arg(args, 'file_path') + const operation = arg(args, 'operation') + if (file === '' || typeof args.line !== 'number' || typeof args.character !== 'number') return null + const source = `${file}:${args.line}:${args.character}` + if (operation === 'hover') return detailList([{ title: source, location: { path: file, line: args.line }, markdown: text, fields: [] }], source, t) + if (text === 'No results.') return detailList([], t('detail.locations.count', { count: 0 }), t) + const items: DetailItem[] = [] + for (const line of text.split('\n')) { + if (line.startsWith('… ')) { items.push({ description: line, fields: [] }); continue } + const match = /^(.*):(\d+):(\d+)$/u.exec(line) + if (match === null) return null + const [, path, row, column] = match + if (path === undefined || row === undefined || column === undefined) return null + const isUri = /^[a-z][a-z\d+.-]*:/iu.test(path) && !/^[a-z]:[\\/]/iu.test(path) + items.push({ + title: path, subtitle: t('detail.location', { line: row, column }), fields: [], + ...isUri ? {} : { location: { path, line: Number(row) } }, + }) + } + const count = items.filter(item => item.title !== undefined).length + return detailList(items, `${file} · ${t('detail.locations.count', { count })}`, t) +} + +/** + * Derive entity lists and operation receipts from supported recorded output formats. + * @param name - Wire tool name, including Team-scoped aliases. + * @param args - Parsed recorded arguments. + * @param text - Successful recorded result text. + * @param json - Parsed whole-result JSON, or undefined for non-JSON text. + * @param t - Conversation translator. + * @returns Compact details, or null when the output format is not recognized. + */ +export function controlDetails( + name: string, args: Record, text: string, json: unknown, t: DetailTranslate, +): ToolDetailsModel | null { + const target = arg(args, 'target') || arg(args, 'agent_id') || arg(args, 'sessionId') || arg(args, 'job_id') + switch (name) { + case 'list_agents': return agentList(text, json, t) + case 'job_list': return jobList(text, t) + case 'terminal_list': return terminalList(text, t) + case 'lsp': return lspDetails(args, text, t) + case 'spawn_teammate': + if (!detailRecord(json) || !detailRecord(json.member)) return null + return detailList(inspectionItems(json.member, t), arg(args, 'name'), t) + case 'team_task_create': + case 'team_task_get': + case 'team_task_update': + if (!detailRecord(json) || typeof json.subject !== 'string') return null + return detailList(inspectionItems(json, t), json.subject, t) + case 'team_task_list': + if (!detailRecord(json) || !Array.isArray(json.tasks)) return null + if (json.nextCursor !== undefined && typeof json.nextCursor !== 'number') return null + return { + ...detailList(inspectionItems(json.tasks, t), t('detail.tasks.count', { count: json.tasks.length }), t), + ...(json.nextCursor === undefined ? {} : { caption: t('detail.tasks.nextPage', { cursor: String(json.nextCursor) }) }), + } + case 'send_message': { + const status = detailRecord(json) ? json.status : undefined + if (status === 'accepted' || status === 'queued') return receipt(target, { + label: t(status === 'queued' ? 'detail.status.queued' : 'detail.receipt.delivered'), tone: status === 'queued' ? 'warning' : 'success', + }, t, [], arg(args, 'message')) + return text === `message delivered to agent ${target}` ? receipt(target, { label: t('detail.receipt.delivered'), tone: 'success' }, t, [], arg(args, 'message')) : null + } + case 'interrupt_agent': + if (detailRecord(json) && typeof json.previousStatus === 'string') return receipt(target, { label: t('detail.receipt.interrupt'), tone: 'warning' }, t, [{ label: t('detail.field.previousStatus'), value: detailBadge(json.previousStatus, t).label }]) + return text === `interrupt requested for agent ${target}` ? receipt(target, { label: t('detail.receipt.interrupt'), tone: 'warning' }, t) : null + case 'wait_agent': + if (!detailRecord(json) || typeof json.timedOut !== 'boolean') return null + if (detailRecord(json.noProgress) && typeof json.noProgress.message === 'string') return detailList([{ title: t('detail.wait.noProgress'), description: json.noProgress.message, fields: [] }], t('detail.wait.noProgress'), t) + return receipt(t('detail.wait.title'), { label: t(json.timedOut ? 'detail.wait.timeout' : 'detail.wait.changed'), tone: 'neutral' }, t) + case 'subagent': { + const started = /^started (background subagent job|subagent) (\S+)$/u.exec(text) + if (started !== null) return receipt(arg(args, 'prompt'), { label: t('detail.receipt.started'), tone: 'info' }, t, [{ label: t(started[1] === 'subagent' ? 'detail.field.agent' : 'detail.field.job'), value: started[2] ?? '' }]) + return detailList([{ title: t('detail.agent.reply'), markdown: text, fields: [], groups: [{ label: t('detail.field.task'), items: [{ description: arg(args, 'prompt'), fields: [] }] }] }], t('detail.agent.reply'), t) + } + case 'list_subagent_models': { + const items = text.split('\n').map((line) => { + const split = line.indexOf(' — ') + return split < 0 + ? { description: line, fields: [] } + : { title: line.slice(0, split), description: line.slice(split + 3), fields: [] } + }) + return detailList(items, arg(args, 'model') || arg(args, 'provider') || t('detail.models.title'), t) + } + case 'job_output': { + const match = /\n\[status: ([^,\]\n]+)(?:, ([^\]\n]+))?\]$/u.exec(text) + if (match === null || match[1] === undefined) return null + const output = text.slice(0, match.index) + const truncated = output.endsWith(OUTPUT_TRUNCATED) + const code = truncated ? output.slice(0, -OUTPUT_TRUNCATED.length) : output + const description = [match[2], truncated ? t('detail.output.truncated') : undefined] + .filter((value): value is string => value !== undefined).join(' · ') + return { + ...detailList([{ title: target, badge: detailBadge(match[1], t), fields: [], ...(description === '' ? {} : { description }), code: { text: code } }], `${target} · ${detailBadge(match[1], t).label}`, t), + expandedSummary: target, + } + } + case 'job_kill': + if (text === `requested cancellation of job ${target}`) return receipt(target, { label: t('detail.receipt.cancel'), tone: 'warning' }, t, [], arg(args, 'reason')) + if (text.startsWith(`job ${target} had already finished `)) return receipt(target, { label: t('detail.receipt.alreadyFinished'), tone: 'neutral' }, t) + return null + case 'terminal_open': { + const match = /^started terminal session (\S+)(?: \((.*?)\))? \[type: ([^\]]+)\]\n([\s\S]*)$/u.exec(text) + if (match === null || match[1] === undefined || match[3] === undefined || match[4] === undefined) return null + return detailList([{ title: match[2] ?? match[1], ...(match[2] === undefined ? {} : { subtitle: match[1] }), badge: { label: t('detail.receipt.started'), tone: 'info' }, fields: [{ label: t('detail.field.type'), value: match[3] }], code: { text: match[4] } }], match[2] ?? match[1], t) + } + case 'terminal_read': { + const match = /\n\[lines: (\d+)-(\d+) of (\d+)\](\n\[output truncated\])?$/u.exec(text) + if (match === null) return null + return detailList([{ title: target, subtitle: t('detail.output.lines', { begin: match[1] ?? '', end: match[2] ?? '', total: match[3] ?? '' }), fields: [], code: { text: text.slice(0, match.index) }, ...(match[4] === undefined ? {} : { description: t('detail.output.truncated') }) }], target, t) + } + case 'terminal_signal': { + const match = /^delivered (\S+) to foreground process group (\d+)$/u.exec(text) + if (match === null || match[1] === undefined || match[2] === undefined) return null + return receipt(target, { label: t('detail.receipt.signal'), tone: 'success' }, t, [{ label: t('detail.field.signal'), value: match[1] }, { label: t('detail.field.processGroup'), value: match[2] }]) + } + case 'terminal_close': + if (text === `closed terminal session ${target}`) return receipt(target, { label: t('detail.receipt.closed'), tone: 'neutral' }, t) + if (text === `terminal session ${target} was already closing`) return receipt(target, { label: t('detail.receipt.closing'), tone: 'neutral' }, t) + return null + default: return null + } +} diff --git a/packages/client/ui-tool/src/client/tool/models/detail-model-shared.ts b/packages/client/ui-tool/src/client/tool/models/detail-model-shared.ts new file mode 100644 index 0000000000..46c6b6b886 --- /dev/null +++ b/packages/client/ui-tool/src/client/tool/models/detail-model-shared.ts @@ -0,0 +1,161 @@ +/** Recorded-value formatting shared by entity lists and inspection results. */ +import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' +import type { ToolDetailItem, ToolDetailsModel } from '../components/ToolDetails.tsx' + +/** Translator supplied by the conversation slot. */ +export type DetailTranslate = TranslateNS<'conversation'> +/** A compact detail row derived from durable tool output. */ +export type DetailItem = ToolDetailItem + +/** + * Narrow parsed JSON to an object record. + * @param value - Parsed result or argument value. + * @returns Whether named fields can be read. + */ +export function detailRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** + * Check a recorded string field that must contain visible text. + * @param value - Parsed field value. + * @returns Whether the field is a non-empty string after trimming. + */ +export function nonempty(value: unknown): value is string { + return typeof value === 'string' && value.trim() !== '' +} + +/** + * Decode an entire JSON result without accepting a partial prefix. + * @param text - Recorded text. + * @returns Parsed JSON, or undefined for non-JSON output. + */ +export function detailJson(text: string): unknown { + try { return JSON.parse(text) } + catch { + // Text receipts and reports are decoded by their tool-specific adapters. + return undefined + } +} + +const STATUS_KEYS = { + running: 'detail.status.running', idle: 'detail.status.idle', ready: 'detail.status.ready', + inactive: 'detail.status.inactive', provisioning: 'detail.status.provisioning', + failed: 'detail.status.failed', error: 'detail.status.failed', + completed: 'detail.status.completed', complete: 'detail.status.completed', done: 'detail.status.completed', + pending: 'detail.todo.pending', in_progress: 'detail.todo.in_progress', + deleted: 'detail.status.deleted', killed: 'detail.status.killed', + blocked: 'detail.goal.blocked', accepted: 'detail.status.accepted', queued: 'detail.status.queued', +} as const + +/** + * Give a recorded status its localized name and a static semantic color. + * @param status - Result status, retained verbatim when the vocabulary is unknown. + * @param t - Conversation translator. + * @returns A badge that does not imply a live subscription. + */ +export function detailBadge(status: string, t: DetailTranslate): NonNullable { + const key = Object.hasOwn(STATUS_KEYS, status) ? STATUS_KEYS[status as keyof typeof STATUS_KEYS] : undefined + const tone = ['completed', 'complete', 'done', 'accepted'].includes(status) ? 'success' + : ['failed', 'error'].includes(status) ? 'error' + : ['blocked', 'killed', 'pending', 'queued', 'inactive'].includes(status) ? 'warning' + : ['running', 'in_progress', 'provisioning'].includes(status) ? 'info' : 'neutral' + return { label: key === undefined ? status : t(key), tone } +} + +const FIELD_KEYS = { + id: 'detail.field.id', revision: 'detail.field.revision', platform: 'detail.field.platform', + provider: 'detail.field.provider', model: 'detail.field.model', role: 'detail.field.role', + context: 'detail.field.context', ownerName: 'detail.field.owner', ready: 'detail.field.ready', + blockedBy: 'detail.field.dependencies', writeScopes: 'detail.field.writeScopes', + writeScopeWarnings: 'detail.field.warnings', diagnostics: 'detail.field.diagnostics', + methods: 'detail.field.methods', inputSchema: 'detail.field.inputSchema', outputSchema: 'detail.field.outputSchema', + currentPackageId: 'detail.field.currentPackage', nextPackageId: 'detail.field.nextPackage', + latestRun: 'detail.field.latestRun', packages: 'detail.field.packages', registrations: 'detail.field.registrations', + props: 'detail.field.props', data: 'detail.field.data', source: 'detail.field.source', + arguments: 'row.input', content: 'detail.field.content', message: 'detail.field.message', + messageId: 'detail.field.messageId', status: 'detail.state', root: 'detail.field.root', + pid: 'detail.field.pid', type: 'detail.field.type', time: 'detail.field.time', + seq: 'detail.field.seq', turn: 'detail.field.turn', step: 'detail.field.step', callId: 'detail.field.callId', + agentsStarted: 'detail.field.agents', output: 'row.output', result: 'detail.field.result', +} as const + +/** + * Name a known tool field while preserving extension-owned field names. + * @param key - Recorded JSON property name. + * @param t - Conversation translator. + * @returns Localized known label or the original property name. + */ +export function detailLabel(key: string, t: DetailTranslate): string { + const label = Object.hasOwn(FIELD_KEYS, key) ? FIELD_KEYS[key as keyof typeof FIELD_KEYS] : undefined + return label === undefined ? key : t(label) +} + +function scalar(value: unknown, t: DetailTranslate): string { + if (value === null) return t('detail.none') + if (typeof value === 'boolean') return t(value ? 'detail.yes' : 'detail.no') + return typeof value === 'string' ? value : JSON.stringify(value) +} + +const INSPECTION_KEY_ORDER = ['subject', 'title', 'name', 'pluginId', 'packageId', 'id', 'summary'] as const +const INSPECTION_DETAIL_KEYS = ['description', 'purpose'] as const +const MAX_INSPECTION_ITEMS = 40 + +/** + * Project open inspection records into readable fields and named disclosures. + * @param value - Parsed JSON, including provider-owned extension fields. + * @param t - Conversation translator. + * @param depth - Current disclosure depth; deeper records remain available as code. + * @returns Entity rows preserving the order of visible values. + */ +export function inspectionItems(value: unknown, t: DetailTranslate, depth = 0): DetailItem[] { + if (depth > 4 && value !== null && typeof value === 'object') { + return [{ code: { text: JSON.stringify(value, null, 2), language: 'json' }, fields: [] }] + } + if (Array.isArray(value)) { + const items = value.slice(0, MAX_INSPECTION_ITEMS).flatMap(entry => inspectionItems(entry, t, depth + 1)) + if (value.length > MAX_INSPECTION_ITEMS) items.push({ description: t('detail.moreInInspect', { count: value.length - MAX_INSPECTION_ITEMS }), fields: [] }) + return items.length === 0 ? [{ description: t('detail.empty'), fields: [] }] : items + } + if (!detailRecord(value)) return [{ description: scalar(value, t), fields: [] }] + const titleKey = INSPECTION_KEY_ORDER.find(key => typeof value[key] === 'string' && value[key] !== '') + const descriptionKey = INSPECTION_DETAIL_KEYS.find(key => typeof value[key] === 'string' && value[key] !== '') + const title = titleKey === undefined ? undefined : String(value[titleKey]) + const fields: DetailItem['fields'][number][] = [] + const groups: NonNullable[number][] = [] + for (const [key, field] of Object.entries(value)) { + if (key === titleKey || key === descriptionKey || (key === 'status' && typeof field === 'string')) continue + if (key === 'inputSchema' || key === 'outputSchema') { + groups.push({ + label: detailLabel(key, t), + items: [{ fields: [], code: { text: JSON.stringify(field, null, 2), language: 'json' } }], + }) + continue + } + if (Array.isArray(field) && field.length === 0) continue + if (key === 'arguments' && typeof field === 'string') { + const args = detailJson(field) + if (detailRecord(args)) { groups.push({ label: detailLabel(key, t), items: inspectionItems(args, t, depth + 1) }); continue } + } + if (field !== null && typeof field === 'object') groups.push({ label: detailLabel(key, t), items: inspectionItems(field, t, depth + 1) }) + else fields.push({ label: detailLabel(key, t), value: scalar(field, t) }) + } + return [{ + ...(title === undefined && typeof value.status !== 'string' ? {} : { title: title ?? t('detail.field.result') }), + ...(descriptionKey === undefined ? {} : { description: String(value[descriptionKey]) }), + ...(typeof value.status === 'string' ? { badge: detailBadge(value.status, t) } : {}), + fields, + ...(groups.length === 0 ? {} : { groups }), + }] +} + +/** + * Give a result list consistent historical context and empty-state copy. + * @param items - Recorded result rows. + * @param summary - Collapsed-row summary. + * @param t - Conversation translator. + * @returns A complete compact detail model. + */ +export function detailList(items: readonly DetailItem[], summary: string, t: DetailTranslate): ToolDetailsModel { + return { items, summary, caption: t('detail.recordedResult'), empty: t('detail.empty') } +} diff --git a/packages/client/ui-tool/src/client/tool/models/details-card-model.ts b/packages/client/ui-tool/src/client/tool/models/details-card-model.ts new file mode 100644 index 0000000000..cb38149518 --- /dev/null +++ b/packages/client/ui-tool/src/client/tool/models/details-card-model.ts @@ -0,0 +1,146 @@ +/** Recorded todo, goal, and schedule values for the compact detail body. */ +import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' +import type { ToolCallBlock } from './tool-call-model.ts' +import type { ToolDetailsModel } from '../components/ToolDetails.tsx' +import { parsedToolCall, singleResultText } from './raw-tool-call.ts' +import { controlDetails } from './control-details-model.ts' +import { inspectionDetails } from './inspection-details-model.ts' +import { detailJson, detailRecord, nonempty } from './detail-model-shared.ts' + +type Translate = TranslateNS<'conversation'> +type DetailItem = ToolDetailsModel['items'][number] + +function count(value: unknown): value is number { + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 +} + +function formatDate(date: Date, locale: string, fallback: string): string { + try { + return new Intl.DateTimeFormat(locale, { + year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit', timeZoneName: 'short', + }).format(date) + } catch { + return fallback + } +} + +/** + * Derive the compact todo list from a todo_write call. + * @param args - Parsed todo_write arguments. + * @param t - Conversation dictionary translator. + * @returns Localized todo details, or null for unsupported input. + */ +export function todosDetail(args: Record, t: Translate): ToolDetailsModel | null { + if (!Array.isArray(args.todos)) return null + const items: DetailItem[] = [] + const seen = new Set() + for (const todo of args.todos) { + if (!detailRecord(todo) || !nonempty(todo.content)) return null + const status = todo.status + if (status !== 'completed' && status !== 'in_progress' && status !== 'pending') return null + const title = todo.content.trim() + if (seen.has(title)) return null + seen.add(title) + items.push({ title, status: { value: status, label: t(`detail.todo.${status}`) }, fields: [] }) + } + return { items, empty: t('detail.todo.empty') } +} + +function goalDetail(value: unknown, t: Translate): ToolDetailsModel | null { + if (!detailRecord(value)) return null + if (value.goal === null) return { items: [], empty: t('detail.goal.empty') } + const goal = value.goal + if (!detailRecord(goal) || !nonempty(goal.id) || !nonempty(goal.objective) + || !count(goal.revision) || !count(goal.roundsStarted) || !count(goal.maxGoalRounds)) return null + const phase = goal.phase + if (phase !== 'active' && phase !== 'paused' && phase !== 'blocked' && phase !== 'complete') return null + if (value.activation !== 'armed' && value.activation !== 'disarmed') return null + const fields = [ + { label: t('detail.state'), value: t(phase === 'active' && value.activation === 'disarmed' ? 'detail.goal.disarmed' : `detail.goal.${phase}`) }, + { label: t('detail.goal.rounds'), value: `${goal.roundsStarted} / ${goal.maxGoalRounds}` }, + ] + if (goal.blockedReason !== undefined) { + if (!detailRecord(goal.blockedReason) || !nonempty(goal.blockedReason.code) || !nonempty(goal.blockedReason.message)) return null + fields.push({ label: t('detail.goal.reason'), value: goal.blockedReason.message }) + } + return { items: [{ title: goal.objective, fields }] } +} + +function interval(seconds: number, t: Translate): string { + if (seconds % 86400 === 0) return t('detail.days', { count: seconds / 86400 }) + if (seconds % 3600 === 0) return t('detail.hours', { count: seconds / 3600 }) + if (seconds % 60 === 0) return t('detail.minutes', { count: seconds / 60 }) + return t('detail.seconds', { count: seconds }) +} + +function scheduleItem(value: unknown, t: Translate, locale: string): DetailItem | null { + if (!detailRecord(value) || !nonempty(value.id) || !nonempty(value.prompt) + || typeof value.scheduledAt !== 'string' || value.deliveryMode !== 'session-local' + || (value.state !== 'scheduled' && value.state !== 'overdue')) return null + const date = new Date(value.scheduledAt) + if (!Number.isFinite(date.getTime()) || date.toISOString() !== value.scheduledAt) return null + let frequency: string + switch (value.kind) { + case 'at': frequency = t('detail.schedule.once'); break + case 'after': + if (!count(value.afterSeconds) || value.afterSeconds === 0) return null + frequency = t('detail.schedule.once') + break + case 'every': + if (!count(value.everySeconds) || value.everySeconds === 0) return null + frequency = t('detail.schedule.every', { interval: interval(value.everySeconds, t) }) + break + default: return null + } + const dateText = formatDate(date, locale, value.scheduledAt) + return { + title: value.prompt, + fields: [ + { label: t('detail.schedule.when'), value: dateText }, + { label: t('detail.schedule.frequency'), value: frequency }, + { label: t('detail.state'), value: t(`detail.schedule.${value.state}`) }, + ], + } +} + +/** + * Derive a supported successful result, retaining generic output on unknown or malformed data. + * @param block - Logged root or nested Tool call and its optional result. + * @param t - Conversation dictionary translator. + * @param locale - Display locale for absolute dates in the viewer's time zone. + * @returns Localized detail data, or null for raw input/output. + */ +export function detailsCardModel(block: ToolCallBlock, t: Translate, locale: string): ToolDetailsModel | null { + if (!('kind' in block) || block.isError) return null + const call = parsedToolCall(block) + if (call === null) return null + const text = singleResultText(block) + if (text === undefined) return null + const value = detailJson(text) + const details = controlDetails(call.name, call.args, text, value, t) ?? inspectionDetails(call.name, call.args, text, value, t, locale) + if (details !== null) return details + if (value === undefined) return null + switch (call.name) { + case 'create_goal': + case 'get_goal': + case 'update_goal': return goalDetail(value, t) + case 'schedule_create': { + const item = scheduleItem(value, t, locale) + return item === null ? null : { items: [item] } + } + case 'schedule_list': { + if (!Array.isArray(value)) return null + const items: DetailItem[] = [] + for (const entry of value) { + const item = scheduleItem(entry, t, locale) + if (item === null) return null + items.push(item) + } + return { items, summary: t('detail.schedule.count', { count: items.length }), empty: t('detail.schedule.empty') } + } + case 'schedule_delete': + if (!detailRecord(value) || !nonempty(value.id) || value.deleted !== true) return null + return { items: [{ title: value.id, fields: [{ label: t('detail.state'), value: t('detail.schedule.deleted') }] }] } + default: return null + } +} diff --git a/packages/client/ui-tool/src/client/tool/models/inspection-details-model.ts b/packages/client/ui-tool/src/client/tool/models/inspection-details-model.ts new file mode 100644 index 0000000000..652ee09333 --- /dev/null +++ b/packages/client/ui-tool/src/client/tool/models/inspection-details-model.ts @@ -0,0 +1,179 @@ +/** Runtime inspection, session queries, and workflow reports from recorded text. */ +import type { ToolDetailsModel } from '../components/ToolDetails.tsx' +import { hasSpillNotice } from '@deepseek-ai/dsh-spill-policy/notice' +import { + detailJson, detailList, detailRecord, inspectionItems, + type DetailItem, type DetailTranslate, +} from './detail-model-shared.ts' + +function dateText(value: string | number, locale: string): string { + const date = new Date(value) + if (!Number.isFinite(date.getTime())) return String(value) + try { + return new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeStyle: 'short' }).format(date) + } catch { + return String(value) + } +} + +function cordisDetails(name: string, args: Record, value: unknown, t: DetailTranslate): ToolDetailsModel | null { + if (!detailRecord(value)) return null + if (name === 'cordis_inspect_list') { + if (!Array.isArray(value.providers)) return null + return detailList(inspectionItems(value.providers, t), t('detail.providers.count', { count: value.providers.length }), t) + } + if (name === 'cordis_inspect_query') { + if (!('data' in value) || typeof value.provider !== 'string' || typeof value.method !== 'string') return null + return detailList(inspectionItems(value.data, t), `${value.provider}.${value.method}`, t) + } + if (name === 'cordis_inspect_self') { + if (Array.isArray(value.plugins)) return detailList(inspectionItems(value.plugins, t), t('detail.plugins.count', { count: value.plugins.length }), t) + return detailList(inspectionItems(value, t), String(args.pluginId ?? args.packageId ?? value.mode), t) + } + return null +} + +function workflowDetails(name: string, args: Record, text: string, t: DetailTranslate): ToolDetailsModel | null { + if (name === 'workflow') { + const match = /^workflow "([\s\S]*?)" completed \((\d+) agents?\)\.\nReturn value:\n([\s\S]*)$/u.exec(text) + if (match === null || match[1] === undefined || match[2] === undefined || match[3] === undefined) return null + const value = detailJson(match[3]) + if (value === undefined) return null + const header: DetailItem = { + title: match[1], badge: { label: t('detail.status.completed'), tone: 'success' }, + fields: [{ label: t('detail.field.agents'), value: match[2] }], + ...(typeof args.code === 'string' ? { groups: [{ label: t('detail.workflow.script'), items: [{ fields: [], code: { text: args.code, language: 'javascript' } }] }] } : {}), + } + return detailList([header, ...inspectionItems(value, t)], match[1], t) + } + const separator = '\nFinal report:\n' + const split = text.indexOf(separator) + if (split < 0) return null + const header = text.slice(0, split) + const rounds = /\b(\d+) rounds?\b/u.exec(header)?.[1] + const report = detailJson(text.slice(split + separator.length)) + if (!detailRecord(report) || typeof report.summary !== 'string' || !Array.isArray(report.evidence) + || !report.evidence.every(value => typeof value === 'string') || !Array.isArray(report.nextSteps) + || !report.nextSteps.every(value => typeof value === 'string') || typeof report.blocker !== 'string') return null + const badge = header.startsWith('Ralph worker reported completion ') + ? { label: t('detail.ralph.reportedComplete'), tone: 'success' as const } + : header.startsWith('Ralph worker reported a blocker ') + ? { label: t('detail.ralph.reportedBlocker'), tone: 'warning' as const } + : header.startsWith('Ralph reached its ') + ? { label: t('detail.ralph.limit'), tone: 'warning' as const } : undefined + if (badge === undefined) return null + const groups: NonNullable[number][] = [] + if (report.nextSteps.length > 0) groups.push({ label: t('detail.report.nextSteps'), items: [{ fields: [], lines: report.nextSteps }] }) + if (typeof args.objective === 'string') groups.push({ label: t('detail.field.task'), items: [{ fields: [], description: args.objective }] }) + const fields = rounds === undefined ? [] : [{ label: t('detail.goal.rounds'), value: rounds }] + if (report.blocker !== '') fields.push({ label: t('detail.goal.reason'), value: report.blocker }) + const item = { title: report.summary, badge, fields, ...(report.evidence.length === 0 ? {} : { lines: report.evidence }), groups } + return detailList([item], report.summary, t) +} + +const TRACE_FIELDS = { + Created: 'detail.field.time', Availability: 'detail.field.availability', Parent: 'detail.field.parent', + 'Best match': 'detail.field.bestMatch', Target: 'detail.field.target', + 'Replaced by': 'detail.trace.replacedBy', 'Replacement chain': 'detail.trace.replacementChain', + 'Events replaced by target': 'detail.trace.replaces', 'Events cited directly as sources': 'detail.trace.sources', + 'Direct derived events': 'detail.trace.derived', +} as const + +function textFields(text: string, t: DetailTranslate, locale: string): DetailItem['fields'] { + return text.split('\n').flatMap((line) => { + const match = /^\s*([^:]+): (.+)$/u.exec(line) + if (match === null || match[1] === undefined || match[2] === undefined) return [] + const key = Object.hasOwn(TRACE_FIELDS, match[1]) ? TRACE_FIELDS[match[1] as keyof typeof TRACE_FIELDS] : undefined + if (key === undefined) return [] + return [{ label: t(key), value: match[1] === 'Created' ? dateText(match[2], locale) : match[2] === 'none' ? t('detail.none') : match[2] }] + }) +} + +function searchDetails(name: string, text: string, t: DetailTranslate, locale: string): ToolDetailsModel | null { + if (text === 'No prior session matches found.' || text.endsWith('\n\nNo prior event matches found.')) return detailList([], t('detail.matches.count', { count: 0 }), t) + const blocks = text.split(/\n(?=\d+\. )/u).filter(block => /^\d+\. /u.test(block)) + if (blocks.length === 0) return null + const items: DetailItem[] = [] + for (const block of blocks) { + const snippet = /\n\s*Snippet: ([\s\S]*?)(?:\n\nResult cap reached\.|$)/u.exec(block)?.[1] + if (name === 'session_search') { + const match = /^\d+\. Session (\S+) — (.*)/u.exec(block) + if (match === null || match[1] === undefined || match[2] === undefined) return null + items.push({ + title: match[2], subtitle: match[1], ...(snippet === undefined ? {} : { description: snippet.trimEnd() }), + fields: textFields(block, t, locale), + }) + } else { + const match = /^\d+\. seq (\d+) \| ([^|]+) \| ([^|]+) \| ([^\n]+)/u.exec(block) + if (match === null || match[1] === undefined || match[2] === undefined + || match[3] === undefined || match[4] === undefined) return null + items.push({ title: snippet?.trimEnd() ?? match[2], subtitle: `${match[2].trim()} · #${match[1]}`, fields: [{ label: t('detail.field.time'), value: dateText(match[4], locale) }, { label: t('detail.field.surface'), value: match[3].trim() }] }) + } + } + return { + ...detailList(items, t('detail.matches.count', { count: items.length }), t), + ...text.includes('Result cap reached.') ? { caption: t('detail.matches.capped') } : {}, + } +} + +function eventReadDetails(text: string, t: DetailTranslate, locale: string): ToolDetailsModel | null { + const match = /^Session (\S+) — ([^\n]*)\nTarget event seq (\d+):\n```json\n([\s\S]*?)\n```([\s\S]*)$/u.exec(text) + if (match === null || match[1] === undefined || match[2] === undefined || match[3] === undefined || match[4] === undefined) return null + const event = detailJson(match[4]) + if (!detailRecord(event) || typeof event.type !== 'string' || !detailRecord(event.data)) return null + const fields = [{ label: t('detail.field.seq'), value: match[3] }] + if (typeof event.time === 'number') fields.push({ label: t('detail.field.time'), value: dateText(event.time, locale) }) + const groups: NonNullable[number][] = [] + const adjacent = match[5]?.trim() + if (adjacent) groups.push({ label: t('detail.event.neighbors'), items: [{ fields: [], description: adjacent }] }) + return detailList([ + { title: event.type, subtitle: `${match[2]} · ${match[1]}`, fields }, + ...inspectionItems(event.data, t), + ...groups.length === 0 ? [] : [{ fields: [], groups }], + ], `${event.type} · #${match[3]}`, t) +} + +function traceDetails(name: string, text: string, t: DetailTranslate, locale: string): ToolDetailsModel | null { + const match = /^Session (\S+) — ([^\n]*)\n([\s\S]*)$/u.exec(text) + if (match === null || match[1] === undefined || match[2] === undefined || match[3] === undefined) return null + if (name === 'session_event_trace') return detailList([{ title: match[2], subtitle: match[1], fields: textFields(match[3], t, locale) }], match[2], t) + const sections = match[3].split('\n\n') + const items: DetailItem[] = [{ title: match[2], subtitle: match[1], fields: textFields(sections[0] ?? '', t, locale) }] + for (const section of sections.slice(1)) { + const firstBreak = section.indexOf('\n') + const label = section.startsWith('Ancestors (nearest first):') ? t('detail.trace.ancestors') : section.startsWith('Descendants:') ? t('detail.trace.descendants') : undefined + if (label === undefined || firstBreak < 0) return null + const body = section.slice(firstBreak + 1) + items.push({ title: label, fields: [], ...(body === '- none' || body === '- none (target is a root session)' ? { description: t('detail.none') } : { lines: body.split('\n').map(line => line.replace(/^(\s*)- /u, '$1')) }) }) + } + return detailList(items, match[2], t) +} + +/** + * Present successful inspection, query, and workflow text as named records. + * @param name - Wire tool name. + * @param args - Recorded argument object. + * @param text - Recorded result text. + * @param json - Parsed whole-result JSON, or undefined for non-JSON text. + * @param t - Conversation translator. + * @param locale - Date display locale. + * @returns Structured details, or null when an output format is unknown. + */ +export function inspectionDetails( + name: string, args: Record, text: string, json: unknown, t: DetailTranslate, locale: string, +): ToolDetailsModel | null { + if (hasSpillNotice(text)) return null + switch (name) { + case 'cordis_inspect_list': + case 'cordis_inspect_query': + case 'cordis_inspect_self': return cordisDetails(name, args, json, t) + case 'workflow': + case 'ralph': return workflowDetails(name, args, text, t) + case 'session_search': + case 'session_event_search': return searchDetails(name, text, t, locale) + case 'session_event_read': return eventReadDetails(text, t, locale) + case 'session_trace': + case 'session_event_trace': return traceDetails(name, text, t, locale) + default: return null + } +} diff --git a/packages/client/ui-tool/src/client/tool/models/todo-diff-model.ts b/packages/client/ui-tool/src/client/tool/models/todo-diff-model.ts new file mode 100644 index 0000000000..ac4f9213fb --- /dev/null +++ b/packages/client/ui-tool/src/client/tool/models/todo-diff-model.ts @@ -0,0 +1,79 @@ +/** Todo changes relative to the preceding recorded write. */ +import type { ToolCallBlock } from '@deepseek-ai/dsh-client-ui-chat/client' +import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' +import type { ToolDetailsModel } from '../components/ToolDetails.tsx' +import type { TodoBaseline } from './todo-history.ts' +import { todosDetail } from './details-card-model.ts' +import { parsedToolCall } from './raw-tool-call.ts' + +/** + * Compare this write with its predecessor in the loaded call history. + * @param block - The write being displayed. + * @param baseline - List recorded before this call, or undefined when its start is unavailable. + * @param hasMore - Whether older unloaded history may contain a preceding list. + * @param t - Conversation dictionary translator. + * @returns Details and a change summary, or null for generic Tool output. + */ +export function todoDiffModel( + block: ToolCallBlock, + baseline: TodoBaseline | undefined, + hasMore: boolean, + t: TranslateNS<'conversation'>, +): { details: ToolDetailsModel; summary: string | null } | null { + if (!('kind' in block) || block.isError) return null + const call = parsedToolCall(block) + const current = call?.name === 'todo_write' ? todosDetail(call.args, t) : null + if (current === null) return null + const previous: ToolDetailsModel | null = baseline?.todos === undefined ? null : { items: baseline.todos.map(todo => ({ + title: todo.content, status: { value: todo.status, label: t(`detail.todo.${todo.status}`) }, fields: [], + })) } + if (baseline === undefined || (previous === null && hasMore)) { + return { details: { ...current, caption: t('todo.diff.unavailable') }, summary: null } + } + const previousByTitle = new Map(previous?.items.map(item => [item.title, item])) + const currentTitles = new Set(current.items.map(item => item.title)) + const retainedPositions = new Map(previous?.items.filter(item => currentTitles.has(item.title)).map((item, index) => [item.title, index])) + let retainedIndex = 0 + const items: ToolDetailsModel['items'][number][] = [] + const unchanged: ToolDetailsModel['items'][number][] = [] + let added = 0 + let updated = 0 + for (const item of current.items) { + const before = previousByTitle.get(item.title) + previousByTitle.delete(item.title) + if (before === undefined) { + added++ + items.push({ ...item, change: { value: 'added', label: t('todo.diff.addedItem') } }) + } else { + const moved = retainedPositions.get(item.title) !== retainedIndex++ + const statusChanged = before.status?.value !== item.status?.value + if (statusChanged || moved) { + updated++ + items.push({ + ...item, + ...statusChanged && before.status !== undefined ? { previousStatus: before.status.label } : {}, + change: { value: 'updated', label: t(statusChanged ? 'todo.diff.updatedItem' : 'todo.diff.movedItem') }, + }) + } else { + unchanged.push(item) + } + } + } + for (const item of previousByTitle.values()) { + items.push({ ...item, change: { value: 'removed', label: t('todo.diff.removedItem') } }) + } + const summary = [ + added > 0 ? t('todo.diff.added', { count: added }) : null, + updated > 0 ? t('todo.diff.updated', { count: updated }) : null, + previousByTitle.size > 0 ? t('todo.diff.removed', { count: previousByTitle.size }) : null, + ].filter(part => part !== null).join(' · ') + return { + summary: summary || t('todo.diff.noChanges'), + details: { + items, + caption: t(previous === null ? 'todo.diff.initial' : 'todo.diff.compare'), + empty: current.items.length === 0 && previous === null ? t('detail.todo.empty') : t('todo.diff.noChanges'), + ...unchanged.length === 0 ? {} : { unchanged: { label: t('todo.diff.unchanged', { count: unchanged.length }), items: unchanged } }, + }, + } +} diff --git a/packages/client/ui-tool/src/client/tool/models/todo-history.ts b/packages/client/ui-tool/src/client/tool/models/todo-history.ts new file mode 100644 index 0000000000..591bc05ca5 --- /dev/null +++ b/packages/client/ui-tool/src/client/tool/models/todo-history.ts @@ -0,0 +1,89 @@ +/** Indexed recorded todo predecessors for root and nested Tool calls. */ +import type {} from '@deepseek-ai/dsh-tools/types' +import type { Context } from '@deepseek-ai/cordis' +import type { + ConversationNodeDefinition, ConversationViewDefinition, ConversationViewNode, TodoItem, +} from '@deepseek-ai/dsh-client-ui-conversation/client' + +/** The durable list preceding one Tool invocation; absent before the first loaded write. */ +export interface TodoBaseline { + readonly todos: readonly TodoItem[] | undefined +} + +/** Session-owned call lookup, read only through the target's observable snapshot. */ +export interface TodoHistory { + /** @param callId - Root or nested call identity. @returns its recorded predecessor, when the start is loaded. */ + get(callId: string): TodoBaseline | undefined +} + +declare module '@deepseek-ai/dsh-client-ui-conversation/client' { + interface ConversationViewSnapshotMap { + /** Recorded todo lists preceding each loaded todo call. */ + 'tool-todo-history': TodoHistory + } +} + +/** Durable writes are indexed independently of Tool success receipts. */ +export const todoWriteDefinition: ConversationNodeDefinition = { + kind: 'tool-todo-write', + match: event => event.type === 'todo/write' ? { id: String(event.seq), role: 'start' } : null, + start: (_context, match) => { + if (match.event.type !== 'todo/write') throw new Error('tool-todo-write requires todo/write') + return match.event.data.todos + }, + update: context => context.state, + publication: () => 'none', +} + +/** Invocation predecessors are repaired by the assembler when older history arrives. */ +export const todoCallDefinition: ConversationNodeDefinition = { + kind: 'tool-todo-call', + target: 'tool-todo-history', + match: (event) => { + if (event.type === 'tool/call' && event.data.name === 'todo_write') { + return { id: String(event.data.callId), role: 'start' } + } + if (event.type === 'tool/ptc-dispatch-start' && event.data.name === 'todo_write') { + return { id: String(event.data.subCallId), role: 'start' } + } + return null + }, + start: (_context, _match, reader) => ({ todos: reader.previous('tool-todo-write')?.state }), + update: context => context.state, + buildViewNode: context => context.state === undefined ? null : { + key: context.key, kind: context.kind, id: context.id, target: 'tool-todo-history', data: context.state, + }, +} + +interface TodoHistoryNode extends ConversationViewNode { + readonly data: TodoBaseline +} + +/** Incremental lookup snapshots preserve earlier call baselines across later writes. */ +export const todoHistoryView: ConversationViewDefinition = { + target: 'tool-todo-history', + create: () => { + let calls = new Map() + return { + empty: calls, + replace: ({ nodes }) => calls = new Map(nodes.map(node => [node.id, node.data])), + apply: ({ upserts }) => { + if (upserts.length > 0) { + calls = new Map(calls) + for (const node of upserts) calls.set(node.id, node.data) + } + return calls + }, + } + }, +} + +/** + * Install the recorded-write index and call predecessor target. + * @param ctx - Tool presentation plugin context. + */ +export function registerTodoHistory(ctx: Context): void { + ctx.uiConversation.events.register(todoWriteDefinition) + ctx.uiConversation.events.register(todoCallDefinition) + ctx.uiConversation.views.register(todoHistoryView) +} diff --git a/packages/client/ui-tool/src/client/tool/models/tool-call-model.ts b/packages/client/ui-tool/src/client/tool/models/tool-call-model.ts index 0b6f386623..79e211a028 100644 --- a/packages/client/ui-tool/src/client/tool/models/tool-call-model.ts +++ b/packages/client/ui-tool/src/client/tool/models/tool-call-model.ts @@ -17,7 +17,7 @@ export type { ToolCallBlock } from '@deepseek-ai/dsh-client-ui-chat/client' /** Tool-call row variants selected by the generic atomic renderer. */ export type ToolRowVariant = 'search' | 'read' | 'bash' | 'write' | 'edit' | 'code' | 'others' -/** Row state semantic; colors self-supplied via StateDot (design gives none). */ +/** Row lifecycle state used by summary styling and accessible status text. */ export type ToolRowState = 'running' | 'ok' | 'error' | 'stopped' /** Locale-neutral structured fact consumed only by the user-facing Tool row. */ diff --git a/packages/client/ui-tool/src/client/tool/toolviews/GenericToolCard.tsx b/packages/client/ui-tool/src/client/tool/toolviews/GenericToolCard.tsx index 4765faeb3d..e79e88b1df 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/GenericToolCard.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/GenericToolCard.tsx @@ -1,6 +1,7 @@ import type { ReactNode } from 'react' import { - IconApiOutline14, IconBrowseOutline16, IconCodeOutline16, IconEditOutline16, IconSearchOutline16, IconSparkle16, + IconApiOutlineRegular, IconBrowseOutlineRegular, IconCodeOutlineRegular, IconEditOutlineRegular, IconSearchOutlineRegular, + IconSparkleRegular, } from '@deepseek-ai/dsh-client-ui-primitives' import type { ToolCallOwnerProps, ToolTreeProps } from '../../contract/slots.ts' import { readCardModel } from '../models/read-card-model.ts' @@ -14,13 +15,13 @@ import { ToolRow } from '../components/ToolRow.tsx' /** Variant leading icons (figma table); all glyphs render at 14 inside the 16px leading box. */ const VARIANT_ICONS: Record = { - search: , - read: , - bash: , - write: , - edit: , - code: , - others: , + search: , + read: , + bash: , + write: , + edit: , + code: , + others: , } /** Card props: the owner payload plus the render site's locale seat (plain prop). */ @@ -39,7 +40,7 @@ export function GenericToolCard({ toolName, block, cwd, home, openFile, inspect, const search = searchCardModel(block) const web = webCardModel(block) // A failing exit status is the terminal card's own error signal (the call - // itself settles isError:false), surfaced as the row's red state dot. + // itself settles isError:false), surfaced through the row's error summary. const state = model.state === 'ok' && terminal !== null && terminalFailed(terminal) ? 'error' : model.state diff --git a/packages/client/ui-tool/src/client/tool/toolviews/ask-question-row.tsx b/packages/client/ui-tool/src/client/tool/toolviews/ask-question-row.tsx index 2a9178eb12..4f1ab77cdb 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/ask-question-row.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/ask-question-row.tsx @@ -1,4 +1,4 @@ -import { IconQuestionOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconQuestionOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' import type { Context } from '@deepseek-ai/cordis' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { ToolCallViewProps } from '../../contract/slots.ts' @@ -183,7 +183,7 @@ export function AskQuestionRow({ toolName, block, inspect, t }: AskQuestionRowPr t={t} variant={model.variant} toolName={toolName} - icon={} + icon={} title={t('ask.rowTitle')} summary={summary} bodyRaw={transcript === null ? model.bodyRaw : null} diff --git a/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.module.css b/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.module.css index ab93cbbaeb..ab8acd692c 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.module.css +++ b/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.module.css @@ -129,8 +129,7 @@ } /* Same glyph-scaling rule as DisclosureRow's leading: the CSS edge overrides - the svg attributes so the 14px glyph follows the font-size delta; StateDot - (data-state) keeps its fixed figma size. + the svg attributes so the 14px business glyph follows the font-size delta. TODO: this bash header duplicates the DisclosureRow structure (and this is the fourth copy of the glyph rule, with DisclosureRow, MessageItem .compactionLeading, and MessageIconActions); migrating the bash row onto @@ -200,6 +199,10 @@ color: var(--dsw-alias-state-error-primary); } +.stoppedSummary { + color: var(--dsw-alias-state-warn-label); +} + /* Hover-revealed Inspect pill under the expanded terminal's bottom-left — ToolRow's .bodyWrap/.inspectButton treatment, replicated per the registrant posture: real flow (it reserves its line), revealed by hovering anywhere on diff --git a/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.tsx b/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.tsx index e5380979b9..7b1f8653c5 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.tsx @@ -2,7 +2,7 @@ import { useMemo, useState, type KeyboardEvent } from 'react' import type { Context } from '@deepseek-ai/cordis' import clsx from 'clsx' import { - IconApiOutline14, IconChevronDownOutline14, IconInspectOutline12, StateDot, TerminalBlock, + IconApiOutlineRegular, IconChevronDownOutlineRegular, IconInspectOutlineRegular, TerminalBlock, } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { ToolCallViewProps } from '../../contract/slots.ts' @@ -20,21 +20,11 @@ import css from './bash-sample.module.css' type BashRowProps = ToolCallViewProps & PropsLocale<'conversation'> -function leadingFor(state: ToolRowState) { - switch (state) { - case 'error': return - case 'stopped': return - // Running keeps the icon — the row sweep carries the in-flight signal. - default: return - } -} - -/** Visually hidden status — StateDot is aria-hidden; AT needs a text label. */ +/** Visually hidden status for the color-only running sweep and error tone. */ function stateStatus(state: ToolRowState, t: BashRowProps['t']): string | null { switch (state) { case 'running': return t('bash.running') case 'error': return t('bash.failed') - case 'stopped': return t('bash.stopped') default: return null } } @@ -48,7 +38,7 @@ export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }: const terminalModel = terminalCardModel(block, cwd) const terminal = terminalModel === null ? null : localizeTerminalCardModel(terminalModel, t) // A failing exit status is the terminal card's own error signal (the call - // itself settles isError:false), surfaced as the row's red state dot. + // itself settles isError:false), surfaced through the row's error summary. const state = model.state === 'ok' && terminalModel !== null && terminalFailed(terminalModel) ? 'error' : model.state @@ -67,7 +57,10 @@ export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }: : null, [genericBody, model.bodyRaw, model.variant, open], ) - const failureLine = model.state === 'error' ? model.errorSummary : null + const normalSummary = terminal?.description ?? model.summary + const settlementLine = state === 'error' + ? model.errorSummary ?? normalSummary + : state === 'stopped' ? t('bash.stopped') : null const toggleExpand = () => { setExpanded(v => !v) } @@ -76,16 +69,17 @@ export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }: event.preventDefault() toggleExpand() } + const businessIcon = const leading = open - ? + ? : expandable ? ( <> - {leadingFor(state)} - + {businessIcon} + ) - : leadingFor(state) + : businessIcon return (
    {status}} {t(model.titleKey)} - - {failureLine ?? terminal?.description ?? model.summary} + + {settlementLine ?? normalSummary}
    {open && ( @@ -142,7 +140,7 @@ export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }: )} {inspect !== undefined && ( )} diff --git a/packages/client/ui-tool/src/client/tool/toolviews/details-row.tsx b/packages/client/ui-tool/src/client/tool/toolviews/details-row.tsx new file mode 100644 index 0000000000..2c097ab679 --- /dev/null +++ b/packages/client/ui-tool/src/client/tool/toolviews/details-row.tsx @@ -0,0 +1,149 @@ +/** Keyed recorded-result rows sharing the compact detail body. */ +import { useMemo } from 'react' +import type { Context } from '@deepseek-ai/cordis' +import { + IconAgentPresetOutlineRegular, IconBranchOutlineRegular, IconChecklistOutlineRegular, IconClockOutlineRegular, + IconCodeOutlineRegular, IconCordisPluginOutlineRegular, IconGoalOutlineRegular, IconSearchOutlineRegular, +} from '@deepseek-ai/dsh-client-ui-primitives' +import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' +import type { ToolCallViewProps } from '../../contract/slots.ts' +import { CONVERSATION_NS as NS } from '../../locale.ts' +import { ToolRow } from '../components/ToolRow.tsx' +import { detailsCardModel } from '../models/details-card-model.ts' +import { parsedToolCall } from '../models/raw-tool-call.ts' +import { toolRowModel } from '../models/tool-call-model.ts' + +const TITLE_KEYS = { + create_goal: 'tool.title.createGoal', + get_goal: 'tool.title.getGoal', + update_goal: 'tool.title.updateGoal', + schedule_create: 'tool.title.createSchedule', + schedule_list: 'tool.title.listSchedules', + schedule_delete: 'tool.title.deleteSchedule', + cordis_inspect_list: 'tool.title.inspectProviders', + cordis_inspect_query: 'tool.title.queryRuntime', + cordis_inspect_self: 'tool.title.inspectPlugins', + workflow: 'tool.title.workflow', + ralph: 'tool.title.ralph', + session_event_read: 'tool.title.readEvent', + session_event_search: 'tool.title.searchEvents', + session_event_trace: 'tool.title.traceEvent', + session_search: 'tool.title.searchSessions', + session_trace: 'tool.title.traceSession', + list_subagent_models: 'tool.title.listModels', + subagent: 'tool.title.subagent', + list_agents: 'tool.title.listAgents', + send_message: 'tool.title.sendMessage', + interrupt_agent: 'tool.title.interruptAgent', + job_list: 'tool.title.listJobs', + job_output: 'tool.title.readJob', + job_kill: 'tool.title.killJob', + terminal_open: 'tool.title.openTerminal', + terminal_read: 'tool.title.readTerminal', + terminal_list: 'tool.title.listTerminals', + terminal_signal: 'tool.title.signalTerminal', + terminal_close: 'tool.title.closeTerminal', + lsp: 'tool.title.lsp', + spawn_teammate: 'tool.title.spawnTeammate', + team_task_create: 'tool.title.createTeamTask', + team_task_get: 'tool.title.getTeamTask', + team_task_update: 'tool.title.updateTeamTask', + team_task_list: 'tool.title.listTeamTasks', + wait_agent: 'tool.title.waitAgent', +} as const + +const LSP_TITLE_KEYS = { + goToDefinition: 'tool.title.findDefinition', + findReferences: 'tool.title.findReferences', + goToImplementation: 'tool.title.findImplementation', + hover: 'tool.title.hoverSymbol', +} as const + +function detailIcon(toolName: string) { + if (toolName.startsWith('schedule_')) return + if (toolName.endsWith('_goal')) return + if (toolName.startsWith('cordis_')) return + if (toolName.startsWith('terminal_')) return + if (toolName.startsWith('session_') || toolName === 'lsp') return + if (toolName.startsWith('job_') || toolName.startsWith('team_task_')) return + if (toolName === 'workflow' || toolName === 'ralph') return + return +} + +/** + * Present recorded entities, receipts, and report fields in the existing expandable row. + * @param props - Tool call, row actions, and locale supplied by the keyed slot. + * @returns A Tool row with structured details or generic input/output. + */ +export function DetailsRow({ toolName, block, cwd, home, openFile, inspect, t }: ToolCallViewProps & PropsLocale<'conversation'>) { + const model = toolRowModel(toolName, block, cwd, home) + const locale = document.documentElement.lang + const details = useMemo(() => detailsCardModel(block, t, locale), [block, t, locale]) + const operation = toolName === 'lsp' ? parsedToolCall(block)?.args.operation : undefined + const titleKey = typeof operation === 'string' && Object.hasOwn(LSP_TITLE_KEYS, operation) + ? LSP_TITLE_KEYS[operation as keyof typeof LSP_TITLE_KEYS] + : Object.hasOwn(TITLE_KEYS, toolName) ? TITLE_KEYS[toolName as keyof typeof TITLE_KEYS] : model.titleKey + return ( + + ) +} + +/** Register recorded-result details through the standard atomic Tool slot. */ +export const detailsToolview = { + name: 'details-toolview', + inject: ['slots'], + apply(ctx: Context): void { + ctx.slots.inject('tool.call.toolview', function* () { + yield ctx.slots.register({ name: 'tool.call.toolview', key: 'create_goal', locale: NS }, DetailsRow) + yield ctx.slots.register({ name: 'tool.call.toolview', key: 'get_goal', locale: NS }, DetailsRow) + yield ctx.slots.register({ name: 'tool.call.toolview', key: 'update_goal', locale: NS }, DetailsRow) + yield ctx.slots.register({ name: 'tool.call.toolview', key: 'schedule_create', locale: NS }, DetailsRow) + yield ctx.slots.register({ name: 'tool.call.toolview', key: 'schedule_list', locale: NS }, DetailsRow) + yield ctx.slots.register({ name: 'tool.call.toolview', key: 'schedule_delete', locale: NS }, DetailsRow) + yield ctx.slots.register({ name: 'tool.call.toolview', key: 'cordis_inspect_list', locale: NS }, DetailsRow) + yield ctx.slots.register({ name: 'tool.call.toolview', key: 'cordis_inspect_query', locale: NS }, DetailsRow) + yield ctx.slots.register({ name: 'tool.call.toolview', key: 'cordis_inspect_self', locale: NS }, DetailsRow) + yield ctx.slots.register({ name: 'tool.call.toolview', key: 'workflow', locale: NS }, DetailsRow) + yield ctx.slots.register({ name: 'tool.call.toolview', key: 'ralph', locale: NS }, DetailsRow) + yield ctx.slots.register({ name: 'tool.call.toolview', key: 'session_event_read', locale: NS }, DetailsRow) + yield ctx.slots.register({ name: 'tool.call.toolview', key: 'session_event_search', locale: NS }, DetailsRow) + yield ctx.slots.register({ name: 'tool.call.toolview', key: 'session_event_trace', locale: NS }, DetailsRow) + yield ctx.slots.register({ name: 'tool.call.toolview', key: 'session_search', locale: NS }, DetailsRow) + yield ctx.slots.register({ name: 'tool.call.toolview', key: 'session_trace', locale: NS }, DetailsRow) + yield ctx.slots.register({ name: 'tool.call.toolview', key: 'list_subagent_models', locale: NS }, DetailsRow) + yield ctx.slots.register({ name: 'tool.call.toolview', key: 'subagent', locale: NS }, DetailsRow) + yield ctx.slots.register({ name: 'tool.call.toolview', key: 'list_agents', locale: NS }, DetailsRow) + yield ctx.slots.register({ name: 'tool.call.toolview', key: 'send_message', locale: NS }, DetailsRow) + yield ctx.slots.register({ name: 'tool.call.toolview', key: 'interrupt_agent', locale: NS }, DetailsRow) + yield ctx.slots.register({ name: 'tool.call.toolview', key: 'job_list', locale: NS }, DetailsRow) + yield ctx.slots.register({ name: 'tool.call.toolview', key: 'job_output', locale: NS }, DetailsRow) + yield ctx.slots.register({ name: 'tool.call.toolview', key: 'job_kill', locale: NS }, DetailsRow) + yield ctx.slots.register({ name: 'tool.call.toolview', key: 'terminal_open', locale: NS }, DetailsRow) + yield ctx.slots.register({ name: 'tool.call.toolview', key: 'terminal_read', locale: NS }, DetailsRow) + yield ctx.slots.register({ name: 'tool.call.toolview', key: 'terminal_list', locale: NS }, DetailsRow) + yield ctx.slots.register({ name: 'tool.call.toolview', key: 'terminal_signal', locale: NS }, DetailsRow) + yield ctx.slots.register({ name: 'tool.call.toolview', key: 'terminal_close', locale: NS }, DetailsRow) + yield ctx.slots.register({ name: 'tool.call.toolview', key: 'lsp', locale: NS }, DetailsRow) + yield ctx.slots.register({ name: 'tool.call.toolview', key: 'spawn_teammate', locale: NS }, DetailsRow) + yield ctx.slots.register({ name: 'tool.call.toolview', key: 'team_task_create', locale: NS }, DetailsRow) + yield ctx.slots.register({ name: 'tool.call.toolview', key: 'team_task_get', locale: NS }, DetailsRow) + yield ctx.slots.register({ name: 'tool.call.toolview', key: 'team_task_update', locale: NS }, DetailsRow) + yield ctx.slots.register({ name: 'tool.call.toolview', key: 'team_task_list', locale: NS }, DetailsRow) + yield ctx.slots.register({ name: 'tool.call.toolview', key: 'wait_agent', locale: NS }, DetailsRow) + }) + }, +} diff --git a/packages/client/ui-tool/src/client/tool/toolviews/file-mutation-row.tsx b/packages/client/ui-tool/src/client/tool/toolviews/file-mutation-row.tsx index dafc8c0697..480fdc5efe 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/file-mutation-row.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/file-mutation-row.tsx @@ -1,5 +1,5 @@ import type { Context } from '@deepseek-ai/cordis' -import { IconEditOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconEditOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { ToolCallViewProps } from '../../contract/slots.ts' import { diffCardModel } from '../models/diff-card-model.ts' @@ -20,7 +20,7 @@ export function FileMutationRow({ toolName, block, cwd, home, openFile, inspect, t={t} variant={model.variant} toolName={toolName} - icon={} + icon={} title={t(model.titleKey)} summary={model.summary} output={model.output} diff --git a/packages/client/ui-tool/src/client/tool/toolviews/read-family-row.tsx b/packages/client/ui-tool/src/client/tool/toolviews/read-family-row.tsx index 40553313d2..efb1847346 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/read-family-row.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/read-family-row.tsx @@ -7,7 +7,7 @@ // row assembly lives here once instead of being copied per tool. import type { ReactNode } from 'react' -import { IconBrowseOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconBrowseOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' import type { ToolCallViewProps } from '../../contract/slots.ts' import { toolRowModel } from '../models/tool-call-model.ts' @@ -44,7 +44,7 @@ export function readFamilyRow( t={t} variant={model.variant} toolName={toolName} - icon={} + icon={} title={t(model.titleKey)} summary={model.summary} bodyRaw={null} diff --git a/packages/client/ui-tool/src/client/tool/toolviews/search-row.tsx b/packages/client/ui-tool/src/client/tool/toolviews/search-row.tsx index d3ce18e976..ccb32c8ad4 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/search-row.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/search-row.tsx @@ -1,5 +1,5 @@ import type { Context } from '@deepseek-ai/cordis' -import { IconSearchOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconSearchOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { ToolCallViewProps } from '../../contract/slots.ts' import { searchCardModel } from '../models/search-card-model.ts' @@ -23,7 +23,7 @@ export function SearchRow({ toolName, block, inspect, t }: SearchRowProps) { t={t} variant={model.variant} toolName={toolName} - icon={} + icon={} title={t(toolName === 'grep' ? SEARCH_TITLE_KEYS.grep : toolName === 'glob' ? SEARCH_TITLE_KEYS.glob : model.titleKey)} diff --git a/packages/client/ui-tool/src/client/tool/toolviews/todo-row.tsx b/packages/client/ui-tool/src/client/tool/toolviews/todo-row.tsx index 7493066722..eb1d240411 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/todo-row.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/todo-row.tsx @@ -1,13 +1,17 @@ -import { IconChecklistOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' +import { useMemo } from 'react' +import { IconChecklistOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' import type { Context } from '@deepseek-ai/cordis' -import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' +import type { HostObservable, InjectFace, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { ToolCallViewProps } from '../../contract/slots.ts' +import { registerTodoHistory, type TodoHistory } from '../models/todo-history.ts' +import { todoDiffModel } from '../models/todo-diff-model.ts' import { toolRowModel } from '../models/tool-call-model.ts' import { ToolRow } from '../components/ToolRow.tsx' import { CONVERSATION_NS as NS } from '../../locale.ts' import { planSummary, type PlanItemLike } from './plan-summary.ts' -type TodoRowProps = ToolCallViewProps & PropsLocale<'conversation'> +type TodoHistoryInjected = { hooks: { todoHistory: HostObservable } } +type TodoRowProps = ToolCallViewProps & PropsLocale<'conversation'> & InjectFace function isItem(value: unknown): value is PlanItemLike { return typeof value === 'object' && value !== null @@ -45,7 +49,10 @@ function summarize(argsRaw: string, t: TodoRowProps['t']): RowSummary | null { } /** Summarizes a plan update without presenting a cancelled call as completed. */ -export function TodoRow({ toolName, block, inspect, t }: TodoRowProps) { +export function TodoRow({ toolName, block, inspect, useTodoHistory, useSession, t }: TodoRowProps) { + const baseline = useTodoHistory(snapshot => snapshot?.get(block.callId)) + const hasMore = useSession(snapshot => snapshot.hasMore) + const diff = useMemo(() => todoDiffModel(block, baseline, hasMore, t), [block, baseline, hasMore, t]) const model = toolRowModel(toolName, block) const argsRaw = ('kind' in block ? block.call?.argsRaw : block.argsRaw) ?? '' const summary = summarize(argsRaw, t) ?? { text: model.summary, extra: 0 } @@ -54,12 +61,14 @@ export function TodoRow({ toolName, block, inspect, t }: TodoRowProps) { t={t} variant={model.variant} toolName={toolName} - icon={} + icon={} title={t('todo.rowTitle')} summary={summary.text} - summarySuffix={summary.extra > 0 ? `+${summary.extra}` : null} + summarySuffix={[diff?.summary, summary.extra > 0 ? `+${summary.extra}` : null] + .filter((part): part is string => part !== null && part !== undefined).join(' · ') || null} bodyRaw={model.bodyRaw} output={model.output} + details={diff?.details} errorSummary={model.errorSummary} state={model.state} inspect={inspect} @@ -70,9 +79,15 @@ export function TodoRow({ toolName, block, inspect, t }: TodoRowProps) { /** Registers the todo conversation row. */ export const todoToolview = { name: 'todo-toolview', - inject: ['slots'], + inject: ['slots', 'uiConversation'], apply(ctx: Context): void { + registerTodoHistory(ctx) ctx.slots.inject('tool.call.toolview', () => - ctx.slots.register({ name: 'tool.call.toolview', key: 'todo_write', locale: NS }, TodoRow)) + ctx.slots.register({ + name: 'tool.call.toolview', key: 'todo_write', locale: NS, + inject: (sessionId): TodoHistoryInjected => ({ + hooks: { todoHistory: ctx.uiConversation.binding(sessionId).target('tool-todo-history') }, + }), + }, TodoRow)) }, } diff --git a/packages/client/ui-tool/src/client/tool/toolviews/web-row.tsx b/packages/client/ui-tool/src/client/tool/toolviews/web-row.tsx index 6a739b7540..e51ae05695 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/web-row.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/web-row.tsx @@ -1,5 +1,5 @@ import type { Context } from '@deepseek-ai/cordis' -import { IconBrowseOutline16, IconGlobeOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconBrowseOutlineRegular, IconGlobeOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { ToolCallViewProps } from '../../contract/slots.ts' import { webCardModel } from '../models/web-card-model.ts' @@ -18,7 +18,7 @@ const WEB_TITLE_KEYS = { export function WebRow({ toolName, block, inspect, t }: WebRowProps) { const model = toolRowModel(toolName, block) const web = webCardModel(block) - const icon = toolName === 'web_fetch' ? : + const icon = toolName === 'web_fetch' ? : return ( { const view = render() expect(view.container.querySelector('[data-state="stopped"]')).not.toBeNull() + expect(view.container.querySelector('[data-state="stopped"] svg')).not.toBeNull() expect(screen.queryByText('已取消')).toBeNull() expect(screen.getByText(`ask_user_question · ${ARGS}`)).toBeTruthy() }) @@ -218,6 +219,7 @@ describe('AskQuestionRow', () => { it('other tool errors keep the generic summary with the error state', () => { const view = render() expect(view.container.querySelector('[data-state="error"]')).not.toBeNull() + expect(view.container.querySelector('[data-state="error"] svg')).not.toBeNull() expect(screen.getByText(`ask_user_question · ${ARGS}`)).toBeTruthy() }) diff --git a/packages/client/ui-tool/tests/assembly-surfaces.client.spec.tsx b/packages/client/ui-tool/tests/assembly-surfaces.client.spec.tsx index 4b48f88c0b..8aa49569f3 100644 --- a/packages/client/ui-tool/tests/assembly-surfaces.client.spec.tsx +++ b/packages/client/ui-tool/tests/assembly-surfaces.client.spec.tsx @@ -1,3 +1,4 @@ +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { toolSessionEvents } from './tool-fixtures.client.ts' // @vitest-environment jsdom /** Tool assembly acceptance through the real ui-conversation host. */ @@ -76,7 +77,7 @@ async function bench(nodes: ToolResultNode[]) { openWorkspacePath: vi.fn(async () => ({ ok: true, value: { opened: true } })), }, }) - runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) + runtime.ctx.provide('settingsScope', { developerTools: { enabled: createSnapshotStore(true) }, bind: () => stubSettingsScope().scope } as never) runtime.ctx.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() }) runtime.ctx.provide('sidebarRight', { openResource: vi.fn() } as never) runtime.ctx.provide('uiWorkspace', { @@ -115,7 +116,7 @@ describe('todo_write assembly (product registrations, no outlet twins)', () => { // Keyed toolview registration took the row (summary derived from args). const row = view.container.querySelector('[data-tool="todo_write"]') expect(row).not.toBeNull() - expect(row!.textContent).toContain('1/3 已完成 · 实现 fixture 样本') + expect(row!.textContent).toContain('新增 3') // The plan strip sits in the input dock, fed by the projection // (default-collapsed: the header summary shows; rows appear on expand). diff --git a/packages/client/ui-tool/tests/chat-ptc-subcalls.client.spec.tsx b/packages/client/ui-tool/tests/chat-ptc-subcalls.client.spec.tsx index 8c888dd667..54212fa981 100644 --- a/packages/client/ui-tool/tests/chat-ptc-subcalls.client.spec.tsx +++ b/packages/client/ui-tool/tests/chat-ptc-subcalls.client.spec.tsx @@ -80,6 +80,7 @@ function snapshotWith( /** Test-owned AppFrame role: declares and renders the Chat view list. */ type AppRootProps = PropsRenderSlots<'conversation.view'> const VIEW_OWNER: ConvViewOwnerProps = { + inspectCall: undefined, viewRequest: null, openView: () => {}, completeViewRequest: () => {}, @@ -103,7 +104,7 @@ async function bench(snapshot: ChatSnapshot) { const chat = createSnapshotStore(snapshot) const events = new ConversationEventRegistry(ctx) const views = new ConversationViewRegistry(ctx) - ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) + ctx.provide('settingsScope', { developerTools: { enabled: createSnapshotStore(true) }, bind: () => stubSettingsScope().scope } as never) ctx.provide('uiConversation', { events, views, @@ -206,7 +207,7 @@ describe('run_code sub-calls through the real chat machinery', () => { expect(pre!.querySelectorAll('span[style]').length).toBeGreaterThan(3) }) - it('an isError sub-call renders the error state dot exactly like a failed native row', async () => { + it('an isError sub-call renders the same error row state as a failed native row', async () => { const parent = 'call-64' const subCalls = [ subCall(11, parent, 1, 'mystery', { n: 1 }, 'Error: boom', true), diff --git a/packages/client/ui-tool/tests/coverage-tails.client.spec.tsx b/packages/client/ui-tool/tests/coverage-tails.client.spec.tsx index d480175039..cdbee8937b 100644 --- a/packages/client/ui-tool/tests/coverage-tails.client.spec.tsx +++ b/packages/client/ui-tool/tests/coverage-tails.client.spec.tsx @@ -41,12 +41,14 @@ function bashProps(block: RunningToolCall | ToolResultNode): BashRowProps { } describe('Tool presentation tails', () => { - it('ToolRow stopped state renders the warning dot in the leading slot', () => { + it('ToolRow stopped state retains the business icon and shows a warning summary', () => { const view = render( - } title="Bash" summary="s" state="stopped" />, + } + title="Bash" summary="s" state="stopped" />, ) - expect(view.queryByTestId('icon')).toBeNull() - expect(view.container.querySelector('[data-state="stopped"]')).not.toBeNull() + expect(view.queryByTestId('icon')).not.toBeNull() + const summary = view.getByText('s') + expect(summary.className).toContain('stoppedSummary') }) it('a settled others-variant row renders the sparkle icon in the leading slot', () => { @@ -79,7 +81,7 @@ describe('Tool presentation tails', () => { expect(row.getAttribute('data-clickable')).toBeNull() }) - it('BashRow carries data-state for running and StateDots for error/stopped', () => { + it('BashRow retains its business icon for failed and stopped states', () => { const running: RunningToolCall = { callId: 'c1', name: 'bash', argsRaw: '{"command":"ls","description":"List"}', turn: 1, step: 1, time: 1_000, subCalls: [], @@ -104,11 +106,15 @@ describe('Tool presentation tails', () => { const errorView = render() expect(errorView.container.querySelector('[data-sample="bash"]')).not.toBeNull() expect(errorView.container.querySelector('[data-state="error"]')).not.toBeNull() - expect(errorView.getByText('失败')).toBeTruthy() + expect(errorView.container.querySelector('[data-state="error"] svg')).not.toBeNull() + expect(errorView.getByText('Bash')).toBeTruthy() + expect(errorView.container.querySelector('[class*="_errorSummary_"]')).not.toBeNull() errorView.unmount() const stoppedView = render() expect(stoppedView.container.querySelector('[data-state="stopped"]')).not.toBeNull() - expect(stoppedView.getByText('已停止')).toBeTruthy() + expect(stoppedView.container.querySelector('[data-state="stopped"] svg')).not.toBeNull() + const stoppedSummary = stoppedView.getByText('已停止') + expect(stoppedSummary.className).toContain('stoppedSummary') }) }) diff --git a/packages/client/ui-tool/tests/details-models.client.spec.ts b/packages/client/ui-tool/tests/details-models.client.spec.ts new file mode 100644 index 0000000000..a38c41ff34 --- /dev/null +++ b/packages/client/ui-tool/tests/details-models.client.spec.ts @@ -0,0 +1,206 @@ +// @vitest-environment jsdom +/** Detail adapters accept the exact recorded result text and retain safe fallback paths. */ +import { describe, expect, it } from 'vitest' +import type { ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { en } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts' +import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts' +import { statusLine } from '@deepseek-ai/dsh-tool-jobs/src/render.ts' +import { presentation } from '@deepseek-ai/dsh-tool-session-query/src/presentation.ts' +import { renderList, renderRead, renderSpawn } from '@deepseek-ai/dsh-tool-terminal/src/render.ts' +import { formatSpillNotice } from '@deepseek-ai/dsh-spill-policy/notice' +import { detailBadge, detailJson, detailList, detailRecord, inspectionItems, nonempty } from '../src/client/tool/models/detail-model-shared.ts' +import { detailsCardModel } from '../src/client/tool/models/details-card-model.ts' +import { SpillLocator } from '@deepseek-ai/dsh-spill' + +const t = makeTranslate(en, commonEn) + +function output(name: string, text: string, args: Record = {}): ToolResultNode { + return { + kind: 'tool-result', seq: 10, time: 2_000, callTime: 1_000, callId: `${name}-1`, + call: { name, argsRaw: JSON.stringify(args) }, content: [{ type: 'text', text }], isError: false, subCalls: [], + } +} + +function details(name: string, text: string, args: Record = {}) { + return detailsCardModel(output(name, text, args), t, 'en') +} + +describe('shared detail values', () => { + it('narrows JSON, localizes status, and renders scalar and nested records', () => { + expect(detailJson('{"ok":true}')).toEqual({ ok: true }) + expect(detailJson('{')).toBeUndefined() + expect(detailRecord({})).toBe(true) + expect(detailRecord([])).toBe(false) + expect(nonempty(' text ')).toBe(true) + expect(nonempty(' ')).toBe(false) + expect(detailBadge('completed', t)).toEqual({ label: 'Completed', tone: 'success' }) + expect(detailBadge('unknown', t)).toEqual({ label: 'unknown', tone: 'neutral' }) + expect(inspectionItems([null, true, 'x', []], t)).toHaveLength(4) + expect(inspectionItems({ title: 'Record', description: 'Description', status: 'running', inputSchema: { type: 'object' }, arguments: '{"x":1}', empty: [], count: 2 }, t)[0]).toMatchObject({ + title: 'Record', description: 'Description', badge: { label: 'Running' }, + }) + expect(detailList([], 'none', t)).toMatchObject({ summary: 'none', caption: 'Recorded result', empty: 'No results' }) + }) + + it('limits nested inspection records and preserves large values as JSON code', () => { + const values = Array.from({ length: 41 }, (_, index) => ({ id: String(index) })) + expect(inspectionItems(values, t)).toHaveLength(41) + expect(inspectionItems({ nested: { deeper: { value: { more: { still: { data: true } } } } } }, t)[0]?.groups).toBeTruthy() + }) +}) + +describe('control detail adapters', () => { + it('uses producer wording for agent, job, terminal, and LSP lists', () => { + const agents = details('list_agents', 'a1 [running] — Build\na2 [ready] parent=a1 depth=1 — Review', { scope: 'descendants' }) + expect(agents?.summary).toBe('2 agents') + expect(details('list_agents', '(no subagents)')?.empty).toBe('No results') + expect(details('job_list', 'j1 [bash] running — Build')?.items[0]?.fields).toContainEqual({ label: 'Type', value: 'bash' }) + expect(details('job_list', '(no background jobs)')?.empty).toBe('No results') + expect(details('terminal_list', renderList([], 1000))?.empty).toBe('No results') + const terminals = [ + { sessionId: 'pty-1' as never, type: 'shell', status: { kind: 'running' as const } }, + { sessionId: 'pty-2' as never, name: 'done', type: 'shell', pid: 4, status: { kind: 'exited' as const, exitCode: 2, signal: null } }, + ] + expect(details('terminal_list', renderList(terminals, 1000))?.items).toHaveLength(2) + const locations = details('lsp', 'src/a.ts:2:3\nhttps://example.test/a.ts:4:5\nuntitled:Untitled-1:6:7\nC:\\src\\a.ts:8:9\nD:/lib/a.ts:10:11', { file_path: 'src/main.ts', line: 2, character: 3, operation: 'findReferences' })?.items + expect(locations?.map(item => item.location)).toEqual([ + { path: 'src/a.ts', line: 2 }, undefined, undefined, { path: 'C:\\src\\a.ts', line: 8 }, { path: 'D:/lib/a.ts', line: 10 }, + ]) + expect(details('lsp', 'No results.', { file_path: 'src/main.ts', line: 2, character: 3, operation: 'findReferences' })?.empty).toBe('No results') + expect(details('lsp', 'hover text', { file_path: 'src/main.ts', line: 2, character: 3, operation: 'hover' })?.items[0]?.markdown).toBe('hover text') + }) + + it('renders receipts, reports malformed formats, and handles truncation', () => { + expect(details('spawn_teammate', '{"member":{"id":"a1","status":"running"}}', { name: 'A' })?.items[0]).toMatchObject({ title: 'a1', badge: { label: 'Running' } }) + expect(details('team_task_create', '{"id":"task-1","subject":"Review","status":"pending"}')?.summary).toBe('Review') + expect(details('team_task_list', '{"tasks":[{"id":"task-1","subject":"Review"}],"nextCursor":3}')?.caption).toBe('More tasks available; next cursor is 3') + expect(details('send_message', '{"status":"queued"}', { agent_id: 'a1', message: 'hello' })?.items[0]?.badge?.label).toBe('Queued') + expect(details('send_message', 'message delivered to agent a1', { agent_id: 'a1', message: 'hello' })?.summary).toBe('a1 · Message delivered') + expect(details('interrupt_agent', '{"previousStatus":"running"}', { agent_id: 'a1' })?.items[0]?.fields).toContainEqual({ label: 'Previous status', value: 'Running' }) + expect(details('wait_agent', '{"timedOut":true}')?.summary).toBe('Teammate activity · Wait timed out') + expect(details('wait_agent', '{"timedOut":false,"noProgress":{"message":"No peer"}}')?.items[0]?.description).toBe('No peer') + expect(details('subagent', 'started background subagent job job-1', { prompt: 'Do work' })?.items[0]?.fields).toContainEqual({ label: 'Job ID', value: 'job-1' }) + expect(details('subagent', 'A useful reply', { prompt: 'Do work' })?.items[0]?.markdown).toBe('A useful reply') + expect(details('list_subagent_models', 'deepseek-chat — Fast\nunknown', { model: 'deepseek-chat' })?.items).toHaveLength(2) + expect(details('job_kill', 'requested cancellation of job j1', { job_id: 'j1', reason: 'stop' })?.summary).toContain('Cancellation requested') + expect(details('job_kill', 'job j1 had already finished completed', { job_id: 'j1' })?.summary).toContain('Already finished') + const spawnText = renderSpawn({ sessionId: 'pty-1', type: 'shell', status: { kind: 'running' }, motd: 'ready' }, 1000) + expect(details('terminal_open', spawnText)?.items[0]?.code?.text).toBe('ready') + const readText = renderRead({ text: 'line', totalLines: 2, lineBegin: 0, lineEnd: 1, truncated: true }, 1000) + expect(details('terminal_read', readText, { sessionId: 'pty-1' })?.items[0]?.description).toBe('Output truncated') + expect(details('terminal_signal', 'delivered SIGINT to foreground process group 12', { sessionId: 'pty-1' })?.items[0]?.fields).toContainEqual({ label: 'Process group', value: '12' }) + expect(details('terminal_close', 'closed terminal session pty-1', { sessionId: 'pty-1' })?.summary).toContain('Closed') + expect(details('terminal_close', 'terminal session pty-1 was already closing', { sessionId: 'pty-1' })?.summary).toContain('Closing') + expect(details('job_output', `hello\n[output truncated]\n${statusLine({ status: 'completed' })}`, { job_id: 'j1' })?.items[0]).toMatchObject({ code: { text: 'hello' }, description: 'Output truncated' }) + expect(details('job_output', `hello\n${statusLine({ status: 'completed', detail: 'exit code: 0' })}`, { job_id: 'j1' })?.items[0]?.description).toBe('exit code: 0') + expect(details('unknown', 'x')).toBeNull() + expect(details('team_task_list', '{"tasks":[],"nextCursor":"bad"}')).toBeNull() + expect(details('terminal_read', 'bad', { sessionId: 'pty-1' })).toBeNull() + expect(details('interrupt_agent', 'interrupt requested for agent a1', { agent_id: 'a1' })?.summary).toContain('Interrupt') + expect(details('interrupt_agent', 'unexpected', { agent_id: 'a1' })).toBeNull() + expect(details('job_kill', 'unexpected', { job_id: 'j1' })).toBeNull() + expect(details('terminal_close', 'unexpected', { sessionId: 'pty-1' })).toBeNull() + }) +}) + +describe('inspection detail adapters', () => { + const sessionRecord = (id: string, live: boolean, persisted: boolean) => ({ + header: { id: id as never, createdAt: 1_000, parentSession: undefined }, live, persisted, + }) + + it('accepts Cordis, workflow, and Ralph producer results', () => { + expect(details('cordis_inspect_list', '{"providers":[{"name":"Service","status":"ready"}]}')?.summary).toBe('1 inspect providers') + expect(details('cordis_inspect_query', '{"provider":"Service","method":"get","data":{"value":1}}')?.summary).toBe('Service.get') + expect(details('cordis_inspect_self', '{"mode":"client","plugins":[]}')?.summary).toBe('0 dynamic plugins') + expect(details('cordis_inspect_self', '{"mode":"host","packageId":"pkg","value":1}')?.summary).toBe('host') + expect(details('cordis_inspect_self', '{"mode":"host","value":1}', { pluginId: 'plugin-1' })?.summary).toBe('plugin-1') + expect(details('workflow', 'workflow "flow" completed (1 agent).\nReturn value:\n{"ok":true}', { code: 'return true' })?.items[0]?.fields).toContainEqual({ label: 'Agents started', value: '1' }) + const report = JSON.stringify({ summary: 'Done', evidence: ['checked'], nextSteps: ['ship'], blocker: '' }, null, 2) + expect(details('ralph', `Ralph worker reported completion after 2 rounds.\nFinal report:\n${report}`, { objective: 'Ship' })?.items[0]).toMatchObject({ title: 'Done', lines: ['checked'] }) + expect(details('ralph', `Ralph worker reported a blocker after 1 round.\nFinal report:\n${JSON.stringify({ summary: 'Blocked', evidence: [], nextSteps: [], blocker: 'No key' })}`)?.items[0]?.badge?.label).toBe('Worker reported a blocker') + expect(details('ralph', `Ralph reached its 3 rounds limit; the worker reported work remaining.\nFinal report:\n${JSON.stringify({ summary: 'Limited', evidence: [], nextSteps: [], blocker: '' })}`)?.items[0]?.badge?.label).toBe('Round limit reached') + }) + + it('uses the session-query producer presentation for searches, reads, and traces', () => { + const session = sessionRecord('s1', true, true) + const hit = { ...session, bestMatch: { sessionId: 's1' as never, seq: 2, type: 'user/message', time: 2_000, surface: 'current', snippet: 'needle' } } + const title = { text: 'Session one' } + const titles = new Map([ + ['s1' as never, title], ['s2' as never, { text: 'Child' }], ['s3' as never, { text: 'Grandchild' }], + ]) as Parameters[1] + const sessionSearch = presentation.formatSessionSearch({ items: [hit as never], capped: true }, titles, new Set()) + expect(details('session_search', sessionSearch)?.items[0]).toMatchObject({ title: 'Session one', subtitle: 's1', description: 'needle' }) + const eventSearch = presentation.formatEventSearch( + 's1' as never, title, + { items: [{ sessionId: 's1' as never, seq: 2 as never, type: 'tool/call', time: 2_000, surface: 'current', snippet: 'needle' }], capped: false }, + ) + expect(details('session_event_search', eventSearch)?.items[0]?.title).toBe('needle') + const trace = presentation.formatSessionTrace( + { target: session as never, ancestors: [], descendants: [], complete: true, root: session as never } as never, + [], false, [{ record: sessionRecord('s2', true, true) as never, descendants: [{ record: sessionRecord('s3', true, true) as never, descendants: [] }] }], titles, + ) + const traceItems = details('session_trace', trace)?.items + expect(traceItems).toHaveLength(3) + expect(traceItems?.[0]).toMatchObject({ title: 'Session one', subtitle: 's1' }) + expect(traceItems?.[2]?.lines).toEqual([ + 's2 — Child | 1970-01-01T00:00:01.000Z | live, persisted', + ' s3 — Grandchild | 1970-01-01T00:00:01.000Z | live, persisted', + ]) + const eventTrace = presentation.formatEventTrace( + 's1' as never, title, + { + session: { id: 's1' as never, createdAt: 1_000 } as never, + target: { sessionId: 's1' as never, seq: 2 as never, type: 'tool/call', time: 2_000, surface: 'current' }, + replacementChain: [], replacedEventSeqs: [], sourceEventSeqs: [], derivedEventSeqs: [], + }, + ) + expect(details('session_event_trace', eventTrace)?.items[0]?.fields).toContainEqual({ label: 'Target event', value: 'seq 2 | tool/call | current | 1970-01-01T00:00:02.000Z' }) + const event = { seq: 2, time: 2_000, type: 'tool/call', data: { name: 'x' } } + const eventRead = presentation.formatEventRead( + 's1' as never, title, + { + session: {} as never, inheritedEventCount: 0 as never, target: event as never, + events: [event as never], startSeq: 2 as never, endSeq: 2 as never, + }, + ) + expect(details('session_event_read', eventRead)?.items[0]?.title).toBe('tool/call') + expect(details('session_search', 'No prior session matches found.')?.summary).toBe('0 matches') + expect(details('session_event_search', 'Session s1 — Session one\n\nNo prior event matches found.')?.summary).toBe('0 matches') + }) + + it('retains generic output for malformed or spilled inspection results', () => { + expect(details('workflow', 'bad')).toBeNull() + expect(details('session_trace', 'Session s1 — title\nCreated: invalid\nAvailability: unavailable\n\nAncestors (nearest first):\n- none (target is a root session)\n\nDescendants:\n- none')?.items[0]?.fields).toContainEqual({ label: 'Time', value: 'invalid' }) + const notice = formatSpillNotice({ kind: 'exact', count: 100 }, { locator: SpillLocator('/tmp/result.txt'), retrievalHint: 'Read it with read' }) + expect(details('session_event_trace', `Session s1 — title\nCreated: 1970-01-01T00:00:00.000Z\n\n${notice}`)).toBeNull() + expect(details('session_search', 'not a result')).toBeNull() + expect(details('session_event_read', 'not a result')).toBeNull() + expect(details('workflow', 'workflow "flow" completed (1 agent).\nReturn value:\nnot-json')).toBeNull() + expect(details('ralph', 'Ralph worker reported completion after 1 round.\nFinal report:\nnot-json')).toBeNull() + expect(details('ralph', 'Ralph worker reported completion after 1 round.\nFinal report:\n{"summary":"x","evidence":[1],"nextSteps":[],"blocker":""}')).toBeNull() + expect(details('ralph', 'Ralph worker reported completion after 1 round.\nFinal report:\n{"summary":"x","evidence":[],"nextSteps":[1],"blocker":""}')).toBeNull() + expect(details('ralph', 'Unknown result\nFinal report:\n{"summary":"x","evidence":[],"nextSteps":[],"blocker":""}')).toBeNull() + expect(details('session_search', '1. Session s1 — title\n Created: 2026-01-01T00:00:00.000Z')).not.toBeNull() + expect(details('session_search', '1. Session — title')).toBeNull() + expect(details('session_event_search', '1. seq bad | tool/call | current | 2026-01-01T00:00:00.000Z')).toBeNull() + }) +}) + +describe('detailsCardModel fallback contract', () => { + it('keeps malformed successful domain results generic', () => { + expect(detailsCardModel(output('create_goal', 'not-json'), t, 'en')).toBeNull() + expect(detailsCardModel(output('schedule_create', '{"id":"s","prompt":"x","kind":"at","scheduledAt":"invalid","state":"scheduled","deliveryMode":"session-local"}'), t, 'en')).toBeNull() + }) + + it('keeps recorded dates readable when the document language is invalid', () => { + const scheduledAt = '2026-09-10T09:00:00.000Z' + const model = detailsCardModel(output('schedule_create', JSON.stringify({ + id: 'schedule-1', prompt: 'Review the build', kind: 'at', scheduledAt, + state: 'scheduled', deliveryMode: 'session-local', + })), t, 'en_US') + expect(model?.items[0]?.fields).toContainEqual({ label: 'Scheduled for', value: scheduledAt }) + const trace = detailsCardModel(output('session_event_trace', 'Session s1 — title\nCreated: 1970-01-01T00:00:00.000Z'), t, 'en_US') + expect(trace?.items[0]?.fields).toContainEqual({ label: 'Time', value: '1970-01-01T00:00:00.000Z' }) + }) +}) diff --git a/packages/client/ui-tool/tests/details-row.client.spec.tsx b/packages/client/ui-tool/tests/details-row.client.spec.tsx new file mode 100644 index 0000000000..1d3ed9100b --- /dev/null +++ b/packages/client/ui-tool/tests/details-row.client.spec.tsx @@ -0,0 +1,101 @@ +// @vitest-environment jsdom +/** Recorded detail cards, conservative fallback, and standard row interactions. */ +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { en } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts' +import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts' +import { detailsCardModel, todosDetail } from '../src/client/tool/models/details-card-model.ts' +import { DetailsRow, detailsToolview } from '../src/client/tool/toolviews/details-row.tsx' + +const t = makeTranslate(en, commonEn) +const goal = { id: 'goal-1', revision: 2, objective: 'Ship compact cards', phase: 'active', roundsStarted: 2, maxGoalRounds: 8 } +const schedule = { id: 'schedule-1', prompt: 'Review the build', kind: 'every', everySeconds: 3600, scheduledAt: '2026-09-10T09:00:00.000Z', state: 'scheduled', deliveryMode: 'session-local' } + +function result(name: string, value: unknown, args = '{}'): ToolResultNode { + return { kind: 'tool-result', seq: 10, time: 2000, callTime: 1000, callId: 'c1', call: { name, argsRaw: args }, content: [{ type: 'text', text: JSON.stringify(value) }], isError: false, subCalls: [] } +} + +afterEach(cleanup) + +describe('detailsCardModel', () => { + it('reads the recorded goal phase and activation, including blockers and absence', () => { + const model = (value: unknown) => detailsCardModel(result('get_goal', value), t, 'en') + expect(model({ goal, activation: 'disarmed' })?.items[0]).toEqual({ title: 'Ship compact cards', fields: [{ label: 'Status', value: 'Awaiting continuation' }, { label: 'Rounds', value: '2 / 8' }] }) + expect(model({ goal: { ...goal, phase: 'blocked', blockedReason: { code: 'agent_blocked', message: 'Needs credentials' } }, activation: 'disarmed' })?.items[0]?.fields).toContainEqual({ label: 'Blocker', value: 'Needs credentials' }) + expect(model({ goal: null })).toEqual({ items: [], empty: 'No goal' }) + }) + + it('keeps all reminders and their recorded state, including one-shot and empty lists', () => { + const model = detailsCardModel(result('schedule_list', [schedule, { ...schedule, id: 'schedule-2', kind: 'at', state: 'overdue' }, { ...schedule, id: 'schedule-3', kind: 'after', afterSeconds: 30 }]), t, 'en') + expect(model?.items).toHaveLength(3) + expect(model?.summary).toBe('3 reminders') + expect(model?.items[0]?.fields).toContainEqual({ label: 'Repeat', value: 'Every 1 h' }) + expect(model?.items[1]?.fields).toContainEqual({ label: 'Status', value: 'Overdue, awaiting session resume' }) + expect(model?.items[2]?.fields).toContainEqual({ label: 'Repeat', value: 'Once' }) + expect(detailsCardModel(result('schedule_list', []), t, 'en')).toMatchObject({ summary: '0 reminders', empty: 'No reminders' }) + expect(detailsCardModel(result('schedule_delete', { id: 'schedule-1', deleted: true }), t, 'en')?.items[0]?.fields).toContainEqual({ label: 'Status', value: 'Deleted' }) + }) + + it.each([ + ['get_goal', { goal: { ...goal, phase: 'unknown' }, activation: 'armed' }], + ['get_goal', { goal, activation: 'unknown' }], + ['get_goal', { goal: { ...goal, blockedReason: {} }, activation: 'armed' }], + ['schedule_create', { ...schedule, scheduledAt: 'invalid' }], + ['schedule_create', { ...schedule, kind: 'unknown' }], + ['schedule_create', { ...schedule, everySeconds: -1 }], + ['schedule_list', [schedule, {}]], + ['schedule_list', { code: 'corrupt_schedule_log', message: 'The session schedule log is corrupt.' }], + ['schedule_delete', { id: 'schedule-1', deleted: false, code: 'schedule_not_found' }], + ['schedule_delete', { code: 'persistence_uncertain', operation: 'delete', id: 'schedule-1', message: 'Persistence is uncertain.' }], + ['unknown_tool', { goal, activation: 'armed' }], + ])('retains raw output for unsupported %s results (%j)', (name, value) => { + expect(detailsCardModel(result(name, value), t, 'en')).toBeNull() + }) + + it('does not present an unsuccessful, partial, mixed-content, or window-truncated result as applied', () => { + const block = result('create_goal', { goal, activation: 'armed' }) + expect(detailsCardModel({ ...block, isError: true }, t, 'en')).toBeNull() + expect(detailsCardModel({ ...block, call: null }, t, 'en')).toBeNull() + expect(detailsCardModel({ ...block, call: { name: 'create_goal', argsRaw: '{' } }, t, 'en')).toBeNull() + expect(detailsCardModel({ ...block, content: [{ type: 'text', text: 'partial {' }] }, t, 'en')).toBeNull() + expect(detailsCardModel({ ...block, content: [...block.content, { type: 'text', text: 'Extra result' }] }, t, 'en')).toBeNull() + expect(detailsCardModel({ callId: 'c1', name: 'create_goal', argsRaw: '{}', turn: 1, step: 1, time: 1000, subCalls: [] }, t, 'en')).toBeNull() + expect(detailsCardModel({ ...block, parentCallId: 'parent' }, t, 'en')?.items[0]?.title).toBe(goal.objective) + }) +}) + +describe('todosDetail', () => { + it('accepts valid to-do lists and uses the tool’s trimmed text', () => { + expect(todosDetail({ todos: [{ content: ' Build the page ', status: 'in_progress' }] }, t)?.items[0]).toEqual({ title: 'Build the page', status: { value: 'in_progress', label: 'In progress' }, fields: [] }) + for (const todos of [[{ content: 'x', status: 'unknown' }], [{ content: '', status: 'pending' }], [null], [{ content: 'x', status: 'pending' }, { content: ' x ', status: 'completed' }]]) { + expect(todosDetail({ todos }, t)).toBeNull() + } + expect(todosDetail({ todos: [] }, t)?.empty).toBe('The to-do list is empty') + }) +}) + +describe('DetailsRow', () => { + it('expands fields with the keyboard and keeps Inspect available', () => { + const inspect = vi.fn() + render([0]} />) + expect(screen.queryByText('Rounds')).toBeNull() + fireEvent.keyDown(screen.getByRole('button', { expanded: false }), { key: 'Enter' }) + expect(screen.getByText('Rounds')).toBeTruthy() + expect(screen.getByText('2 / 8')).toBeTruthy() + expect(screen.queryByText('Output')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Inspect' })) + expect(inspect).toHaveBeenCalledOnce() + }) + + it('registers the supported tool names through the scoped keyed slot', () => { + const register = vi.fn((_spec: unknown, _component: unknown) => () => undefined) + const inject = vi.fn((_name: string, callback: () => Iterable<() => void>) => { + for (const dispose of callback()) dispose() + }) + detailsToolview.apply({ slots: { inject, register } } as never) + expect(register.mock.calls).toHaveLength(36) + expect(register.mock.calls.map(([spec]) => spec)).toContainEqual({ name: 'tool.call.toolview', key: 'cordis_inspect_query', locale: 'conversation' }) + }) +}) diff --git a/packages/client/ui-tool/tests/diff-card.client.spec.tsx b/packages/client/ui-tool/tests/diff-card.client.spec.tsx index e02e7b6c69..0e8682d6cb 100644 --- a/packages/client/ui-tool/tests/diff-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/diff-card.client.spec.tsx @@ -256,6 +256,7 @@ describe('FileMutationRow diff card', () => { cleanup() const errorView = render() expect(errorView.container.querySelector('[data-state="error"]')).not.toBeNull() + expect(errorView.container.querySelector('[data-state="error"] svg')).not.toBeNull() }) it('a mutation result with no metadata renders the summary row alone', () => { @@ -299,9 +300,9 @@ describe('FileMutationRow diff card', () => { error: { name: 'ToolError', code: 'interrupted' }, }))} />) expect(view.container.querySelector('[data-state="stopped"]')).not.toBeNull() - // The amber StateDot is aria-hidden, so ToolRow carries the state to AT as - // visually-hidden text; without it a stopped row is a colour-only signal. - expect(view.getByText('已停止')).toBeTruthy() + expect(view.container.querySelector('[data-state="stopped"] svg')).not.toBeNull() + expect(view.getByText('已停止').className).toContain('visuallyHidden') + expect(view.container.querySelector('[class*="_stoppedSummary_"]')?.textContent).toBe('notes/demo.txt') }) it('renders a plain summary span when the call carries no file path', () => { diff --git a/packages/client/ui-tool/tests/read-card.client.spec.tsx b/packages/client/ui-tool/tests/read-card.client.spec.tsx index 86aa89a78e..6fd5bcbd18 100644 --- a/packages/client/ui-tool/tests/read-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/read-card.client.spec.tsx @@ -261,6 +261,7 @@ describe('ReadRow keyed toolview', () => { content: [{ type: 'text', text: 'ENOENT' }], }))} />) expect(view.container.querySelector('[data-variant="read"]')?.getAttribute('data-state')).toBe('error') + expect(view.container.querySelector('[data-state="error"] svg')).not.toBeNull() expect(view.container.querySelector('[data-read]')).toBeNull() }) @@ -269,6 +270,7 @@ describe('ReadRow keyed toolview', () => { isError: true, error: { name: 'ToolError', code: 'interrupted' }, }))} />) expect(view.container.querySelector('[data-variant="read"]')?.getAttribute('data-state')).toBe('stopped') + expect(view.container.querySelector('[data-state="stopped"] svg')).not.toBeNull() }) it('registers under the read key of the keyed toolview slot', () => { diff --git a/packages/client/ui-tool/tests/search-card.client.spec.tsx b/packages/client/ui-tool/tests/search-card.client.spec.tsx index b189b6f6a8..ca8e8bde20 100644 --- a/packages/client/ui-tool/tests/search-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/search-card.client.spec.tsx @@ -268,6 +268,7 @@ describe('SearchRow keyed card', () => { isError: true, }), 'grep')} />) expect(errorView.container.querySelector('[data-variant="search"]')?.getAttribute('data-state')).toBe('error') + expect(errorView.container.querySelector('[data-variant="search"] svg')).not.toBeNull() }) it('surfaces the result text through the Output section when an errored search has no card', () => { diff --git a/packages/client/ui-tool/tests/terminal-card.client.spec.tsx b/packages/client/ui-tool/tests/terminal-card.client.spec.tsx index 2c9a4081a4..21738f4f48 100644 --- a/packages/client/ui-tool/tests/terminal-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/terminal-card.client.spec.tsx @@ -429,9 +429,8 @@ describe('BashRow terminal card', () => { expect(view.getByText('List files')).toBeTruthy() }) - // The row's leading StateDot and the card's run-state dot describe the same - // command, so a running row whose card claimed 'done' would be a contradiction - // the reader sees on one line. + // The row's running state and the card's run-state dot describe the same + // command, so a running row whose card claimed 'done' would contradict itself. it('agrees with the summary row about the run state', () => { const runningView = render() expect(runningView.container.querySelector('[data-variant="bash"]')?.getAttribute('data-state')).toBe('running') @@ -449,6 +448,7 @@ describe('BashRow terminal card', () => { content: [{ type: 'text', text: 'boom\n[exit code: 2]' }], }))} />) expect(view.container.querySelector('[data-variant="bash"]')?.getAttribute('data-state')).toBe('error') + expect(view.container.querySelector('[class*="_errorSummary_"]')?.textContent).toBe('List files') }) it('shows the call description as the terminal summary', () => { diff --git a/packages/client/ui-tool/tests/todo-history.client.spec.ts b/packages/client/ui-tool/tests/todo-history.client.spec.ts new file mode 100644 index 0000000000..2875f3c3bf --- /dev/null +++ b/packages/client/ui-tool/tests/todo-history.client.spec.ts @@ -0,0 +1,85 @@ +/** Recorded todo predecessors follow incremental history repair and nested invocations. */ +import { describe, expect, it } from 'vitest' +import { ConversationNodeAssembler } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { SessionEvent } from '@deepseek-ai/dsh-session/types' +import type { SessionLiveEventEntry } from '@deepseek-ai/dsh-api-session-controller/client' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { en } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts' +import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts' +import { todoCallDefinition, todoHistoryView, todoWriteDefinition, type TodoHistory } from '../src/client/tool/models/todo-history.ts' +import { todoDiffModel } from '../src/client/tool/models/todo-diff-model.ts' +import type { ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client' + +const t = makeTranslate(en, commonEn) +const first = [{ content: 'Build', status: 'in_progress' }, { content: 'Review', status: 'pending' }, { content: 'Old task', status: 'pending' }] as const +const second = [{ content: 'Build', status: 'completed' }, { content: 'Review', status: 'pending' }, { content: 'Publish', status: 'pending' }] as const + +function entry(seq: number, type: string, data: unknown): SessionLiveEventEntry { + return { type: 'event', event: { seq, time: 1000 + seq, type, data } as SessionEvent } +} + +function call(todos: unknown): ToolResultNode { + return { kind: 'tool-result', seq: 20, time: 2000, callTime: 1000, callId: 'second', call: { name: 'todo_write', argsRaw: JSON.stringify({ todos }) }, content: [], isError: false, subCalls: [] } +} + +function runtime() { + const assembler = new ConversationNodeAssembler( + { entries: () => [todoWriteDefinition, todoCallDefinition], fallbackEntry: () => undefined }, + { entries: () => [todoHistoryView] }, + ) + assembler.activateTarget('tool-todo-history') + return assembler +} + +function history(assembler: ConversationNodeAssembler): TodoHistory { + assembler.flush() + return assembler.snapshot('tool-todo-history') as TodoHistory +} + +describe('recorded todo history', () => { + it('repairs a missing predecessor on prepend and keeps earlier cards fixed after a later write', () => { + const assembler = runtime() + assembler.replaceWindow([entry(10, 'tool/call', { callId: 'second', name: 'todo_write', arguments: JSON.stringify({ todos: second }), turn: 1, step: 1 })], true) + expect(todoDiffModel(call(second), history(assembler).get('second'), true, t)?.summary).toBeNull() + assembler.prepend([entry(5, 'todo/write', { todos: first })], false) + const baseline = history(assembler).get('second') + expect(todoDiffModel(call(second), baseline, false, t)?.summary).toBe('1 added · 1 updated · 1 removed') + assembler.append(entry(11, 'todo/write', { todos: second })) + expect(history(assembler).get('second')).toEqual(baseline) + assembler.append(entry(12, 'tool/ptc-dispatch-start', { parentCallId: 'root', rootCallId: 'root', subCallId: 'nested', name: 'todo_write', arguments: { todos: [] } })) + expect(history(assembler).get('nested')?.todos).toEqual(second) + assembler.replaceWindow([], false) + expect(history(assembler).get('second')).toBeUndefined() + }) + + it('shows first-write additions, status transitions, removals, and unchanged items separately', () => { + expect(todoDiffModel(call(first), { todos: undefined }, false, t)?.summary).toBe('3 added') + const diff = todoDiffModel(call(second), { todos: first }, false, t) + expect(diff?.details.items).toMatchObject([ + { title: 'Build', previousStatus: 'In progress', change: { value: 'updated' } }, + { title: 'Publish', change: { value: 'added' } }, + { title: 'Old task', change: { value: 'removed' } }, + ]) + expect(diff?.details.unchanged?.items.map(item => item.title)).toEqual(['Review']) + expect(todoDiffModel(call(second), { todos: second }, false, t)?.summary).toBe('No changes to the list') + expect(todoDiffModel(call([]), { todos: undefined }, false, t)?.details.empty).toBe('The to-do list is empty') + expect(todoDiffModel({ ...call(first), isError: true }, { todos: undefined }, false, t)).toBeNull() + expect(todoDiffModel(call(first), undefined, false, t)?.details.caption).toBe('Previous list unavailable') + }) + + it('publishes immutable lookup snapshots when the index changes', () => { + const view = todoHistoryView.create() + const firstSnapshot = view.replace({ nodes: [{ id: 'call-1', data: { todos: first } }] } as never) + const secondSnapshot = view.apply({ upserts: [{ id: 'call-1', data: { todos: second } }] } as never) + expect(firstSnapshot.get('call-1')?.todos).toEqual(first) + expect(secondSnapshot.get('call-1')?.todos).toEqual(second) + expect(view.empty.get('call-1')).toBeUndefined() + }) + + it('distinguishes relative reordering from positions shifted by an insertion', () => { + const moved = todoDiffModel(call([first[1], first[0], first[2]]), { todos: first }, false, t) + expect(moved?.details.items.map(item => item.change?.label)).toEqual(['Reordered', 'Reordered']) + const inserted = todoDiffModel(call([{ content: 'New', status: 'pending' }, ...first]), { todos: first }, false, t) + expect(inserted?.summary).toBe('1 added') + }) +}) diff --git a/packages/client/ui-tool/tests/todo-row.client.spec.tsx b/packages/client/ui-tool/tests/todo-row.client.spec.tsx index 0012e65263..d6dfe8ad80 100644 --- a/packages/client/ui-tool/tests/todo-row.client.spec.tsx +++ b/packages/client/ui-tool/tests/todo-row.client.spec.tsx @@ -71,6 +71,8 @@ function rowProps(block: unknown): TodoRowProps { openFile: vi.fn(), sessionId: 's1', useSessions: () => undefined, + useTodoHistory: () => undefined, + useSession: () => true, t, } as unknown as TodoRowProps } @@ -118,6 +120,7 @@ describe('TodoRow', () => { it('falls back to the generic summary on malformed args and marks the error state', () => { const view = render() expect(view.container.querySelector('[data-state="error"]')).not.toBeNull() + expect(view.container.querySelector('[data-state="error"] svg')).not.toBeNull() expect(screen.getByText('todo_write · not json')).toBeTruthy() }) @@ -126,11 +129,14 @@ describe('TodoRow', () => { expect(screen.getByText('todo_write · {"other":1}')).toBeTruthy() }) - it('leading toggle expands the raw args body', () => { + it('leading toggle expands a read-only checklist', () => { render() fireEvent.click(screen.getByRole('button', { expanded: false })) expect(screen.getByRole('button', { expanded: true })).toBeTruthy() - expect(screen.getByText(/搭骨架/)).toBeTruthy() + expect(screen.getByText('搭骨架')).toBeTruthy() + expect(screen.getAllByRole('listitem')).toHaveLength(3) + expect(screen.getByLabelText('进行中')).toBeTruthy() + expect(screen.queryByText('输入')).toBeNull() }) it.each([ @@ -149,11 +155,17 @@ describe('TodoRow', () => { it('injects the keyed toolview declaration directly', () => { expect(todoToolview.name).toBe('todo-toolview') - expect(todoToolview.inject).toEqual(['slots']) - const register = vi.fn(() => () => undefined) + expect(todoToolview.inject).toEqual(['slots', 'uiConversation']) + const register = vi.fn((_spec: unknown, _component: unknown) => () => undefined) const inject = vi.fn((_name: string, callback: () => () => void) => callback()) - todoToolview.apply({ slots: { inject, register } } as never) + todoToolview.apply({ + slots: { inject, register }, + uiConversation: { events: { register: vi.fn() }, views: { register: vi.fn() } }, + } as never) expect(inject).toHaveBeenCalledWith('tool.call.toolview', expect.any(Function)) - expect(register).toHaveBeenCalledWith({ name: 'tool.call.toolview', key: 'todo_write', locale: NS }, TodoRow) + const [spec, component] = register.mock.calls[0] ?? [] + expect(spec).toMatchObject({ name: 'tool.call.toolview', key: 'todo_write', locale: NS }) + expect((spec as { inject?: unknown } | undefined)?.inject).toEqual(expect.any(Function)) + expect(component).toBe(TodoRow) }) }) diff --git a/packages/client/ui-tool/tests/tool-call-tree.client.spec.tsx b/packages/client/ui-tool/tests/tool-call-tree.client.spec.tsx index 093295b407..f8215ed61c 100644 --- a/packages/client/ui-tool/tests/tool-call-tree.client.spec.tsx +++ b/packages/client/ui-tool/tests/tool-call-tree.client.spec.tsx @@ -93,6 +93,12 @@ describe('ToolCallTree', () => { ]) }) + it('omits Inspect from tool owners when the target view is unavailable', () => { + const owners: ToolCallOwnerProps[] = [] + render() + expect(owners[0]?.inspect).toBeUndefined() + }) + it('dispatches a running call by its wire name and forwards inspect', () => { const owners: ToolCallOwnerProps[] = [] const block: ToolCallBlock = { diff --git a/packages/client/ui-tool/tests/tool-row.client.spec.tsx b/packages/client/ui-tool/tests/tool-row.client.spec.tsx index 9a616aaecc..6ef6cd713a 100644 --- a/packages/client/ui-tool/tests/tool-row.client.spec.tsx +++ b/packages/client/ui-tool/tests/tool-row.client.spec.tsx @@ -340,14 +340,12 @@ describe('ToolRow', () => { expect(view.queryByText(/"a": 1/)).toBeNull() }) - it('running keeps the icon (row sweep carries the signal); error swaps in a StateDot', () => { + it('keeps the business icon across running and error states', () => { const runningView = render() expect(runningView.queryByTestId('tool-icon')).not.toBeNull() expect(runningView.container.querySelector('[data-state="running"]')).not.toBeNull() const errorView = render() - expect(errorView.container.querySelector('[data-testid="tool-icon"]')).toBeNull() - // The dot rides the idle slot, so an expandable error row keeps the - // icon→chevron hover preview instead of losing it with the icon. + expect(errorView.container.querySelector('[data-testid="tool-icon"]')).not.toBeNull() expect(errorView.container.querySelector('[class*="chevronHover"]')).not.toBeNull() }) @@ -421,7 +419,8 @@ describe('ToolRow', () => { it('an error row without an error summary keeps the args summary', () => { const view = render() - expect(view.getByText('List files')).toBeTruthy() + const summary = view.getByText('List files') + expect(summary.className).toContain('errorSummary') }) it('renders summarySuffix outside the ellipsized summary span, and drops it on a failure line', () => { @@ -505,6 +504,20 @@ describe('GenericToolCard', () => { expect(view.container.querySelector('[data-variant="bash"]')).not.toBeNull() }) + it.each([ + 'bash', 'read', 'grep', 'write', 'run_code', 'unknown_tool', + ] as const)('keeps the %s business family artwork on failure', (toolName) => { + const failed = result({ + call: { name: toolName, argsRaw: '{}' }, + content: [{ type: 'text', text: 'failed' }], + isError: true, + }) + const view = render() + const root = view.container.querySelector(`[data-tool="${toolName}"]`)! + expect(root.querySelector('[data-disclosure-row] > :first-child svg')).not.toBeNull() + expect(root.querySelector('[data-state]')).toBeNull() + }) + it('unknown tools land on the others variant titled Tool call', () => { const view = render( , diff --git a/packages/client/ui-tool/tests/toolview-slot.client.spec.tsx b/packages/client/ui-tool/tests/toolview-slot.client.spec.tsx index c13ac0965a..8a854cd8fe 100644 --- a/packages/client/ui-tool/tests/toolview-slot.client.spec.tsx +++ b/packages/client/ui-tool/tests/toolview-slot.client.spec.tsx @@ -1,5 +1,6 @@ // @vitest-environment jsdom +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent } from '@testing-library/react' import type { ISession } from '@deepseek-ai/dsh-api-session-controller/client' @@ -67,7 +68,7 @@ async function bench(nodes: ToolResultNode[]) { const runtime = await SlotTestRuntime.create() const openWorkspacePath = vi.fn(async () => ({ ok: true, value: { opened: true } })) runtime.remote.provideNamespaces({ session: { openWorkspacePath } }) - runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) + runtime.ctx.provide('settingsScope', { developerTools: { enabled: createSnapshotStore(true) }, bind: () => stubSettingsScope().scope } as never) const layout = { openDetails: vi.fn(), closeDetails: vi.fn() } runtime.ctx.provide('layout', layout) const sidebarRight = { openResource: vi.fn<(address: string) => void>() } @@ -288,7 +289,7 @@ describe('registrant declaration injection', () => { openWorkspacePath: vi.fn(async () => ({ ok: true, value: { opened: true } })), }, }) - runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) + runtime.ctx.provide('settingsScope', { developerTools: { enabled: createSnapshotStore(true) }, bind: () => stubSettingsScope().scope } as never) runtime.ctx.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() }) runtime.ctx.provide('sidebarRight', { openResource: vi.fn() } as never) runtime.ctx.provide('uiWorkspace', { diff --git a/packages/client/ui-tool/tests/web-card.client.spec.tsx b/packages/client/ui-tool/tests/web-card.client.spec.tsx index 28f5c8c2d3..96f66d8cbd 100644 --- a/packages/client/ui-tool/tests/web-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/web-card.client.spec.tsx @@ -4,7 +4,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render } from '@testing-library/react' import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client' import type { ToolCallOwnerProps } from '@deepseek-ai/dsh-client-ui-tool/client' -import { IconGlobeOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconGlobeOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' import { webCardModel } from '../src/client/tool/models/web-card-model.ts' import { GenericToolCard } from '../src/client/tool/toolviews/GenericToolCard.tsx' import { WebRow, webToolview } from '../src/client/tool/toolviews/web-row.tsx' @@ -138,7 +138,7 @@ describe('chat row web body', () => { } it('the WebRow collapses to the summary row, expanding to the full search card', () => { - const globe = render().container.querySelector('svg')!.outerHTML + const globe = render().container.querySelector('svg')!.outerHTML const view = render() // Collapsed: the summary row alone, no card in the DOM. expect(view.getByText('网页搜索')).toBeTruthy() @@ -181,6 +181,7 @@ describe('chat row web body', () => { expect(view.container.querySelector('[data-web]')).toBeNull() // The row reflects the error state so the summary line still reads as failed. expect(view.container.querySelector('[data-state="error"]')).not.toBeNull() + expect(view.container.querySelector('[data-state="error"] svg')).not.toBeNull() }) it('the GenericToolCard fallback does not promote an unknown tool from metadata alone', () => { diff --git a/packages/client/ui-trajectory/README.i18n.yaml b/packages/client/ui-trajectory/README.i18n.yaml index e930caa64a..e865300cf5 100644 --- a/packages/client/ui-trajectory/README.i18n.yaml +++ b/packages/client/ui-trajectory/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-trajectory/README.md -README.md: 02cbf402a702a4a51e9e3a122e3df13c4eb4e772 -README.zh.md: 00971c0710e1620b97fc51ae03486a9114629a49 +README.md: 35135c726d59669ee6368d33ba35882897df7b15 +README.zh.md: ddde852f56924599b46a7f813619dcfc7d099714 diff --git a/packages/client/ui-trajectory/README.md b/packages/client/ui-trajectory/README.md index 02cbf402a7..35135c726d 100644 --- a/packages/client/ui-trajectory/README.md +++ b/packages/client/ui-trajectory/README.md @@ -25,13 +25,15 @@ The Trajectory tab lets you inspect agent activity as a turn-aware ledger and in ## Use this package -Open the Trajectory tab in the conversation's view ring to inspect agent activity as an event ledger and timeline. The ledger covers records with an explicit loading row until the initial tail is positioned; while an older prefix remains unloaded, a first-row control loads one earlier page on click and shows a disabled loading status while that page is pending. +Open the Trajectory tab in the conversation's view ring to inspect agent activity as an event ledger and timeline. The ledger covers records with an explicit loading row until the initial tail is positioned; while an older prefix remains unloaded, a first-row control loads one earlier page on click and shows the shared ongoing loader while that page is pending. ### Inspecting records Calls are identified by their recorded tool name. For `run_code`, the row shows the program description and the inspector opens numbered, highlighted source. The Code tab provides wrapping, a `{}` toggle for the original JSON arguments, and exact source copying, including trailing newlines. Copying the original arguments retains their recorded JSON whitespace. Each newly opened code view takes the last wrapping choice; changing it leaves other open views as they are. Output preserves the recorded text, using a tree for complete JSON objects or arrays. Highlighting uses only an unambiguous TypeScript or Python hint in the recorded tool schema; missing or conflicting hints leave plain source. See the [PTC inspection decision](../../../.agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.md) for replay constraints. -Selection, timeline navigation, folding, and search cover the React-visible window. Request numbers and cumulative usage cover the complete resident snapshot. Selecting a record opens a local inspector for token usage, duration, Input, Output, Timing, and durable images. Image URLs use the Conversation-owned per-session cache, so Chat and Trajectory share one authorized read per attachment. A user record shows the generic-file count beside its text, while a record without text shows its image and file counts. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. +Selection, timeline navigation, folding, and search cover the React-visible window. Request numbers and cumulative usage cover the complete resident snapshot. Selecting a record opens a local inspector for token usage, duration, Input, Output, Timing, and durable images. Image URLs use the Conversation-owned per-session cache, so Chat and Trajectory share one authorized read per attachment. A user record shows both nonzero image and ordinary-file counts beside its text, including attachment-only records. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. + +Summary and Preview share one ordered attachment list after the message text, preserving repeated references. Each row shows a contained image thumbnail or file-type icon, the recorded filename (a localized numbered label for unnamed images), and recorded size, type, and image dimensions where available. Zero-byte files retain their size, and truncated filenames expose the full name in a tooltip. Images open the existing lightbox. Raw keeps content-block order and unrendered text, with images and files in initially collapsed disclosures containing their complete recorded fields. Thinking in the inspector uses compact Markdown at the inspector's fixed 13px size and 20px line height, independent of the content-size setting. Headings add bold weight without increasing size or line height. Assistant output keeps its regular Markdown typography. diff --git a/packages/client/ui-trajectory/README.zh.md b/packages/client/ui-trajectory/README.zh.md index 00971c0710..ddde852f56 100644 --- a/packages/client/ui-trajectory/README.zh.md +++ b/packages/client/ui-trajectory/README.zh.md @@ -25,13 +25,15 @@ Trajectory 标签页让你以按轮次组织的事件记录表和交互式时间 ## 使用本包 -在对话视图环中打开 Trajectory 标签页,把 agent 活动作为事件记录表与时间线查看。初始尾部完成定位前,记录表会用明确的加载行遮住真实记录;更早的前缀仍未加载时,首行控件会在点击时加载一页更早的历史,并在该页加载期间显示禁用的加载状态。 +在对话视图环中打开 Trajectory 标签页,把 agent 活动作为事件记录表与时间线查看。初始尾部完成定位前,记录表会用明确的加载行遮住真实记录;更早的前缀仍未加载时,首行控件会在点击时加载一页更早的历史,并在该页加载期间显示共享 ongoing loading。 ### 检查记录 调用按已记录的工具名识别。对于 `run_code`,记录行展示程序说明,检查器显示带行号和语法高亮的源码。“代码”页提供换行、查看原始 JSON 参数的 `{}` 切换按钮,以及保留末尾换行的源码原样复制。复制原始参数时保留记录中 JSON 的空白。每个新打开的代码视图采用上次选择的换行状态;切换时,其他已打开视图保持原状。输出保留记录中的文本,完整 JSON 对象或数组使用树形展示。高亮只采用已记录工具 Schema 中明确且无冲突的 TypeScript 或 Python 提示;提示缺失或冲突时显示纯文本源码。回放约束见 [PTC 检查决策](../../../.agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.zh.md)。 -选择、时间线导航、折叠与搜索只覆盖 React 可见窗口。请求编号与累计用量覆盖完整的驻留快照。选择记录会打开局部检查器,查看 token 用量、耗时、输入、输出、计时与持久保留的图片。图片 URL 使用 Conversation 拥有的逐会话缓存,因此 Chat 与 Trajectory 对每个附件共享一次已授权读取。用户记录会在文本旁显示通用文件数量;记录没有文本时,则显示图片与文件数量。独立运行的压缩请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。 +选择、时间线导航、折叠与搜索只覆盖 React 可见窗口。请求编号与累计用量覆盖完整的驻留快照。选择记录会打开局部检查器,查看 token 用量、耗时、输入、输出、计时与持久保留的图片。图片 URL 使用 Conversation 拥有的逐会话缓存,因此 Chat 与 Trajectory 对每个附件共享一次已授权读取。用户记录会在文本旁同时显示非零的图片与普通文件数量,仅含附件的记录也显示这些数量。独立运行的压缩请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。 + +概述与预览共用正文之后的有序附件列表,保留重复引用。每行展示完整缩放的图片缩略图或文件类型图标、已记录的文件名(未命名图片使用本地化的编号名称),以及可用的已记录大小、类型和图片尺寸。零字节文件保留大小显示,截断的文件名通过提示显示完整名称。图片打开现有灯箱。原始内容保留内容块顺序和未渲染文本,图片与文件使用默认折叠的展开项,包含完整的已记录字段。 检查器中的思考内容使用紧凑 Markdown,沿用检查器固定的 13px 字号和 20px 行高,不受内容字号设置影响。标题只加粗,不放大字号或行高。助手输出保留常规 Markdown 排版。 diff --git a/packages/client/ui-trajectory/package.json b/packages/client/ui-trajectory/package.json index 55d143f118..3c19291ffc 100644 --- a/packages/client/ui-trajectory/package.json +++ b/packages/client/ui-trajectory/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-trajectory", "description": "Trajectory event ledger with an interactive timing overview: pure-consumer plugin registering into the conversation ViewMap (no service)", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css index a679c82569..b07fbe48a2 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css @@ -45,17 +45,6 @@ font: var(--dsw-font-xxs-12); } -.historyLoadingSpinner { - width: 10px; - height: 10px; - box-sizing: border-box; - border: 1.5px solid var(--dsw-alias-border-l2); - border-top-color: var(--dsw-alias-state-business-primary); - border-radius: 50%; - corner-shape: round; - animation: history-loading-spin 700ms linear infinite; -} - .table tbody .historyLoadRow td { height: 30px; padding: 0; @@ -107,16 +96,6 @@ visibility: hidden; } -@keyframes history-loading-spin { - to { transform: rotate(360deg); } -} - -@media (prefers-reduced-motion: reduce) { - .historyLoadingSpinner { - animation: none; - } -} - .table { --trajectory-turn-accent: color-mix( in srgb, @@ -1710,11 +1689,88 @@ gap: 8px; } -.messageImagesPreview { - margin: 6px 14px 8px; +.attachments { + display: flex; + flex-direction: column; + margin: 8px 14px 14px; + padding: 0; + gap: 8px; + list-style: none; +} + +.attachmentsPreview { + margin-block: 6px 8px; gap: 6px; } +.attachmentRow { + display: flex; + align-items: center; + min-width: 0; + padding: 8px; + gap: 10px; + border: 0.5px solid var(--dsw-alias-border-l2-darkmode-thin); + border-radius: 12px; +} + +.attachmentIcon { + display: grid; + flex: 0 0 48px; + width: 48px; + height: 48px; + place-items: center; +} + +.attachmentInfo { + display: flex; + flex: 1; + flex-direction: column; + min-width: 0; + gap: 3px; +} + +.attachmentName { + overflow: hidden; + color: var(--dsw-alias-label-primary); + font-size: 12px; + line-height: 18px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.attachmentMetadata { + overflow: hidden; + color: var(--dsw-alias-label-tertiary); + font-size: 11px; + line-height: 16px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.attachmentDisclosure { + margin-block: 8px; + border: 0.5px solid var(--dsw-alias-border-l2-darkmode-thin); + border-radius: 8px; +} + +.attachmentDisclosure > summary { + padding: 8px; + cursor: pointer; +} + +.attachmentDisclosure > summary .attachmentName { + display: block; +} + +.attachmentDisclosure > summary:focus-visible { + outline: 2px solid var(--dsw-alias-state-business-primary); + outline-offset: 2px; +} + +.attachmentDisclosure > pre { + padding: 0 8px 8px; +} + .assistantToolCalls { box-sizing: border-box; max-width: 100%; diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx index c5ae3ad812..fb765dca97 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx @@ -5,22 +5,25 @@ import type { CSSProperties, ReactNode } from 'react' import { useVirtualizer } from '@tanstack/react-virtual' import { CodeBlock, - IconCheckOutline16, - IconChevronRightOutline14, - IconCodeOutline16, - IconWrapLinesOutline16, - IconCopyOutline16, - IconSettingsOutline16, - IconSparkle16, - IconUserOutline16, + FileTypeIcon, + fileExtension, + fileSizeText, + IconCheckOutlineRegular, + IconChevronRightOutlineRegular, + IconCodeOutlineRegular, + IconWrapLinesOutlineRegular, + IconCopyOutlineRegular, + IconSettingsOutlineRegular, + IconSparkleRegular, + IconUserOutlineRegular, JsonTree, MarkdownText, + StateDot, Tooltip, writeClipboard, } from '@deepseek-ai/dsh-client-ui-primitives' import type { JsonTreeLabels, JsonTreeProps, MarkdownLabels } from '@deepseek-ai/dsh-client-ui-primitives' import { structuredPatch } from 'diff' -import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { AssistantRequestConfig, ConversationPromptSnapshot, RenderMessageImages, } from '@deepseek-ai/dsh-client-ui-conversation/client' @@ -118,11 +121,11 @@ function CompactedIcon(): ReactNode { } const KIND_ICON: Record = { - system: , - user: , + system: , + user: , context: , compacted: , - message: , + message: , tool: , subtool: , } @@ -1114,7 +1117,7 @@ function RecordListText({ return ( <> - {toolCallText.program && } + {toolCallText.program && } {toolCallText.name || '—'} {toolCallText.args !== undefined && ( @@ -1157,75 +1160,120 @@ function MarkdownFragment({ function SourceBlocks({ blocks, onOpenCall, - renderImages, t, }: { blocks: readonly TrajectorySourceBlock[] onOpenCall: (callId: string) => void - renderImages: RenderMessageImages t: TrajectoryTranslate }) { + const attachments = new Map(recordAttachments(blocks, t).map(entry => [entry.index, entry])) return (
    - {blocks.map((block, index) => ( -
    - {block.callId !== undefined - ? ( - - ) - : ( -
    - - {t('block.label', { index: index + 1, type: block.type })} - -
    - )} - {/* The Raw view keeps model block order and granularity: one - gallery per image block, unlike the aggregated record gallery. */} - {block.attachment !== undefined - ? renderImages({ images: [{ attachment: block.attachment }], align: 'start' }) - :
    {block.content}
    } -
    - ))} + {blocks.map((block, index) => { + const attachment = attachments.get(index) + return attachment !== undefined ? ( +
    + + + {t('block.label', { index: index + 1, type: block.type })} + + + {attachment.name} + + +
    {block.content}
    +
    + ) + : ( +
    + {block.callId !== undefined + ? ( + + ) + : ( +
    + + {t('block.label', { index: index + 1, type: block.type })} + +
    + )} +
    {block.content}
    +
    + ) + })}
    ) } -function recordImages( +function recordAttachments( blocks: readonly TrajectorySourceBlock[] | undefined, -): { readonly attachment: ImageAttachmentRef }[] { - return (blocks ?? []).flatMap(block => - block.attachment !== undefined ? [{ attachment: block.attachment }] : []) + t: TrajectoryTranslate, +) { + let imageIndex = 0 + return (blocks ?? []).flatMap((block, index) => { + const ref = block.attachment ?? block.file + if (ref === undefined) return [] + if (block.attachment !== undefined) imageIndex += 1 + return [{ + block, + index, + name: ref.name ?? t('attachment.imageName', { index: imageIndex }), + metadata: [ + block.attachment?.mediaType ?? fileExtension(ref.name ?? '').toUpperCase(), + fileSizeText(ref.bytes), + ...(block.attachment === undefined ? [] : [`${block.attachment.width} × ${block.attachment.height}`]), + ].filter(Boolean).join(' · '), + }] + }) } -function MessageImages({ +function RecordAttachments({ blocks, preview, renderImages, + t, }: { blocks: readonly TrajectorySourceBlock[] | undefined preview: boolean renderImages: RenderMessageImages + t: TrajectoryTranslate }) { - const images = recordImages(blocks) - if (images.length === 0) return null + const attachments = recordAttachments(blocks, t) + if (attachments.length === 0) return null return ( -
    - {renderImages({ images, align: 'start' })} -
    +
      + {attachments.map(({ block, index, name, metadata }) => ( +
    • + {block.attachment !== undefined + ? renderImages({ + images: [{ attachment: block.attachment, label: name }], + align: 'start', + thumbnail: true, + }) + : } +
      + {name} + {metadata} +
      +
    • + ))} +
    ) } @@ -1324,7 +1372,7 @@ function ToolCatalog({ {tools.map((tool, index) => (
    - + {tool.name} {tool.description} @@ -1488,7 +1536,6 @@ function MarkdownRecordContent({ ) @@ -1516,7 +1563,7 @@ function MarkdownRecordContent({ onClick={() => { onThinkingExpandedChange(!thinkingExpanded) }} > {t('record.thinking')} - + {thinkingExpanded && ( -
    ) } const source = markdownSource(record) - const hasImages = record.cell.sourceBlocks?.some(block => block.attachment !== undefined) === true + const hasAttachments = record.cell.sourceBlocks?.some(block => block.attachment !== undefined || block.file !== undefined) === true const hasToolCalls = record.cell.kind === 'message' && record.cell.sourceBlocks?.some(block => block.type === 'tool-call') === true - if (!source && !hasImages && !hasToolCalls) { + if (!source && !hasAttachments && !hasToolCalls) { const emptyLabel = isToolCallOnly(record.cell, t) ? t('record.toolCallOnly') : record.cell.text || t('record.noContent') return

    {emptyLabel}

    } - if (!rendered || (!hasImages && !hasToolCalls)) { + if (!rendered || (!hasAttachments && !hasToolCalls)) { return } return ( @@ -1576,7 +1624,7 @@ function MarkdownRecordContent({ t={t} /> )} - +
    ) } @@ -1841,7 +1889,7 @@ function InspectorCopyButton({ text, label, t }: { title={title} onClick={() => { void writeClipboard(text).then((ok) => { setState(ok ? 'copied' : 'failed') }) }} > - {state === 'copied' ? : } + {state === 'copied' ? : } ) } @@ -1875,7 +1923,7 @@ function ProgramInput({ program, initialWrapped, stringWrapping, onOpen, t }: { stringWrapping?.setDefault(next) }} > - + )} {onOpen === undefined && ( @@ -1997,7 +2045,7 @@ function OverviewSection({ onClick={onOpen} > {label} - + {actions} @@ -2556,7 +2604,7 @@ export function TrajectoryTable({ {showInitialLoading && (
    -
    @@ -2591,7 +2639,7 @@ export function TrajectoryTable({ }} > {olderBusy && ( -
    - + { - // An empty text block yields an empty preview; treat it as absent so an - // image-bearing record still labels its row instead of rendering blank. const preview = previewContent(node.content) const previewMarkdown = preview === '' ? undefined : preview const images = imageBlockCount(node.content) const files = fileBlockCount(node.content) const attachmentSummary = [ - previewMarkdown === undefined && images > 0 - ? t('layout.imageOnly', { count: images }) + images > 0 + ? t('layout.imageCount', { count: images }) : undefined, files > 0 ? t('layout.fileAttachments', { count: files }) : undefined, ].filter((value): value is string => value !== undefined).join(' · ') @@ -808,7 +806,7 @@ function summarizeAssistantActivity( return t('layout.toolCallOnly') } const images = blocks.filter(block => block.kind === 'image').length - if (images > 0) return t('layout.imageOnly', { count: images }) + if (images > 0) return t('layout.imageCount', { count: images }) return '' } @@ -829,7 +827,7 @@ function assistantSourceBlock(block: AssistantBlock): TrajectorySourceBlock { callId: block.callId, toolName: block.name, } - case 'image': return { type: 'image', content: '', attachment: block.attachment } + case 'image': return sourceBlock({ type: 'image', attachment: block.attachment }) case 'other': return sourceBlock(block.block) } } @@ -844,14 +842,20 @@ function sourceBlock(value: unknown): TrajectorySourceBlock { return { type: type === 'reasoning' ? 'thinking' : type, content: block.text } } if ( - type === 'image' + (type === 'image' || type === 'file') && typeof block.attachment === 'object' && block.attachment !== null && typeof (block.attachment as Record).attachmentId === 'string' ) { // Session-log content is validated into core ContentBlocks by the // Conversation node assembly; the `attachmentId` guard only keeps // wire-shaped 'other' blocks with an unrelated `attachment` member out. - return { type, content: '', attachment: block.attachment as ImageAttachmentRef } + return { + type, + content: stringifySourceValue(value), + ...(type === 'image' + ? { attachment: block.attachment as ImageAttachmentRef } + : { file: block.attachment as FileAttachmentRef }), + } } return { type, content: stringifySourceValue(value) } } @@ -1083,7 +1087,7 @@ function summarizeResult( } } const images = imageBlockCount(node.content) - if (images > 0) return { result: t('layout.imageOnly', { count: images }) } + if (images > 0) return { result: t('layout.imageCount', { count: images }) } return { result: t('record.noOutput') } } @@ -1110,7 +1114,7 @@ function detailResult(node: ToolResultNode, t: TrajectoryTranslate): string { .join('\n') if (text !== '') return text const images = imageBlockCount(node.content) - if (images > 0) return t('layout.imageOnly', { count: images }) + if (images > 0) return t('layout.imageCount', { count: images }) if ( node.content.length === 0 || node.content.every(block => diff --git a/packages/client/ui-trajectory/src/client/locales.ts b/packages/client/ui-trajectory/src/client/locales.ts index 7e76444ca1..2e891ff981 100644 --- a/packages/client/ui-trajectory/src/client/locales.ts +++ b/packages/client/ui-trajectory/src/client/locales.ts @@ -180,7 +180,9 @@ export const zh = { 'layout.compactionFailed': '上下文压缩失败', 'layout.compacted': '上下文已压缩', 'layout.toolCallOnly': '仅工具调用', - 'layout.imageOnly': '图片 ×{count}', + 'attachment.list': '附件', + 'attachment.imageName': '图片 {index}', + 'layout.imageCount': '图片 ×{count}', 'layout.fileAttachments': '文件 ×{count}', 'layout.initialSystemPrompt': '初始系统提示词', 'layout.systemPromptUpdated': '系统提示词已更新', @@ -380,7 +382,9 @@ export const en: Record = { 'layout.compactionFailed': 'Compaction failed', 'layout.compacted': 'Context compacted', 'layout.toolCallOnly': 'Tool call only', - 'layout.imageOnly': 'Images ×{count}', + 'attachment.list': 'Attachments', + 'attachment.imageName': 'Image {index}', + 'layout.imageCount': 'Images ×{count}', 'layout.fileAttachments': 'Files ×{count}', 'layout.initialSystemPrompt': 'Initial System Prompt', 'layout.systemPromptUpdated': 'System Prompt Updated', diff --git a/packages/client/ui-trajectory/src/client/trajectory-record.ts b/packages/client/ui-trajectory/src/client/trajectory-record.ts index 017143c487..bd37189fa4 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-record.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-record.ts @@ -1,7 +1,7 @@ /** Shared trajectory record data and formatting contracts. */ import type { HTMLAttributes } from 'react' -import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' +import type { FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { ConversationPromptSnapshot } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { TrajectoryTranslate } from './locales.ts' @@ -30,6 +30,8 @@ export interface TrajectorySourceBlock { type: string content: string attachment?: ImageAttachmentRef + /** Ordinary file metadata; never passed to the image loader. */ + file?: FileAttachmentRef callId?: string toolName?: string } diff --git a/packages/client/ui-trajectory/src/client/trajectory-snapshot-builder.ts b/packages/client/ui-trajectory/src/client/trajectory-snapshot-builder.ts index 07f6e5d2f3..933cb31f9c 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-snapshot-builder.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-snapshot-builder.ts @@ -306,6 +306,7 @@ export const trajectoryViewDefinition: ConversationViewDefinition< TrajectorySnapshot > = { target: 'trajectory', + toolCallFocus: callId => callId, create: () => new TrajectorySnapshotBuilder(), } diff --git a/packages/client/ui-trajectory/tests/client-bundle.client.spec.ts b/packages/client/ui-trajectory/tests/client-bundle.client.spec.ts index e1ca41bfb2..5f2fbe8569 100644 --- a/packages/client/ui-trajectory/tests/client-bundle.client.spec.ts +++ b/packages/client/ui-trajectory/tests/client-bundle.client.spec.ts @@ -6,6 +6,7 @@ * registers the view tab into a real SlotRegistry ring. Skips when dist/ is * not built (`pnpm --filter @deepseek-ai/dsh-client-ui-trajectory bundle`). */ +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { readFileSync } from 'node:fs' import { resolve } from 'node:path' import { Context } from '@deepseek-ai/cordis' @@ -89,7 +90,7 @@ describe('tsdown client artifact', () => { const { events, views } = uiConversation ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never) ctx.provide('remote', { $on: () => () => {} } as never) - ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) + ctx.provide('settingsScope', { developerTools: { enabled: createSnapshotStore(true) }, bind: () => stubSettingsScope().scope } as never) const locale = await import('@deepseek-ai/dsh-client-locale/client') ctx.plugin({ inject: [...locale.inject], apply: locale.apply }) const fiber = ctx.plugin(exports as { apply: (ctx: Context) => void }) diff --git a/packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts b/packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts index 1f15cc202a..0a4db8aeb9 100644 --- a/packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts +++ b/packages/client/ui-trajectory/tests/conversation-definitions.client.spec.ts @@ -172,6 +172,10 @@ function systemMessage(text: string) { } describe('Trajectory conversation Definitions', () => { + it('addresses tool-call inspection with the persisted call identity', () => { + expect(trajectoryViewDefinition.toolCallFocus?.('call-1')).toBe('call-1') + }) + it('assembles streaming usage, preserves retry facts, and materializes interruption', () => { const value = assembler([ at(1, 'turn/start', { turn: 1 }), diff --git a/packages/client/ui-trajectory/tests/layout.client.spec.tsx b/packages/client/ui-trajectory/tests/layout.client.spec.tsx index f9130ab4f7..df27f38e65 100644 --- a/packages/client/ui-trajectory/tests/layout.client.spec.tsx +++ b/packages/client/ui-trajectory/tests/layout.client.spec.tsx @@ -592,8 +592,8 @@ describe('durable image attachments', () => { expect(user?.text).toBe('Images ×2') expect(user?.previewMarkdown).toBeUndefined() expect(user?.sourceBlocks).toEqual([ - { type: 'image', content: '', attachment }, - { type: 'image', content: '', attachment }, + { type: 'image', content: JSON.stringify({ type: 'image', attachment }, null, 2), attachment }, + { type: 'image', content: JSON.stringify({ type: 'image', attachment }, null, 2), attachment }, ]) }) @@ -619,9 +619,9 @@ describe('durable image attachments', () => { ] as unknown as LegacyConversationSlice['nodes'] const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] }) const user = turns[0]?.groups[0]?.cells[0] - expect(user?.text).toBe('') + expect(user?.text).toBe('Images ×1') expect(user?.previewMarkdown).toBe('look at this') - expect(user?.sourceBlocks?.[1]).toEqual({ type: 'image', content: '', attachment }) + expect(user?.sourceBlocks?.[1]).toEqual({ type: 'image', content: JSON.stringify({ type: 'image', attachment }, null, 2), attachment }) }) it('maps assistant image blocks to attachment source blocks and labels image-only output', () => { @@ -634,7 +634,7 @@ describe('durable image attachments', () => { const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] }) const message = turns[0]?.groups.flatMap(g => g.cells).find(c => c.kind === 'message') expect(message?.text).toBe('Images ×1') - expect(message?.sourceBlocks).toEqual([{ type: 'image', content: '', attachment }]) + expect(message?.sourceBlocks).toEqual([{ type: 'image', content: JSON.stringify({ type: 'image', attachment }, null, 2), attachment }]) }) it('carries tool-result image refs into outputBlocks and labels the result', () => { @@ -653,7 +653,7 @@ describe('durable image attachments', () => { const tool = turns[0]?.groups.flatMap(g => g.cells).find(c => c.kind === 'tool') expect(tool?.result).toBe('Images ×1') expect(tool?.outputDetail).toBe('Images ×1') - expect(tool?.outputBlocks).toEqual([{ type: 'image', content: '', attachment }]) + expect(tool?.outputBlocks).toEqual([{ type: 'image', content: JSON.stringify({ type: 'image', attachment }, null, 2), attachment }]) }) it('shows wire-shaped blocks without an attachment as JSON, not as an image', () => { @@ -677,7 +677,7 @@ describe('durable file attachments', () => { bytes: 2447 * 1024 * 1024, } - it('labels file-only and mixed-text user records without rendering file cards', () => { + it('labels file-only and mixed-text user records', () => { const nodes = [ { kind: 'user', seq: 1, time: 1_000, source: null, @@ -696,7 +696,7 @@ describe('durable file attachments', () => { expect(users[1]).toMatchObject({ text: 'Files ×1', previewMarkdown: 'review these' }) }) - it('keeps image and file counts in a file-only attachment summary', () => { + it.each([undefined, '', 'review these'])('keeps both counts and repeated attachments with text %j', (text) => { const image = { attachmentId: `sha256:${'e'.repeat(64)}`, mediaType: 'image/png', @@ -706,9 +706,22 @@ describe('durable file attachments', () => { } const nodes = [{ kind: 'user', seq: 1, time: 1_000, source: null, - content: [{ type: 'image', attachment: image }, { type: 'file', attachment }], + content: [ + ...(text === undefined ? [] : [{ type: 'text', text }]), + { type: 'image', attachment: image }, + { type: 'file', attachment }, + { type: 'image', attachment: image }, + ], }] as unknown as LegacyConversationSlice['nodes'] const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] }) - expect(turns[0]?.groups[0]?.cells[0]?.text).toBe('Images ×1 · Files ×1') + const cell = turns[0]?.groups[0]?.cells[0] + expect(cell?.text).toBe('Images ×2 · Files ×1') + const blocks = cell?.sourceBlocks?.filter(block => block.type !== 'text') + expect(blocks?.map(block => block.attachment ?? block.file)).toEqual([image, attachment, image]) + expect(blocks?.map((block): unknown => JSON.parse(block.content))).toEqual([ + { type: 'image', attachment: image }, + { type: 'file', attachment }, + { type: 'image', attachment: image }, + ]) }) }) diff --git a/packages/client/ui-trajectory/tests/table.client.spec.tsx b/packages/client/ui-trajectory/tests/table.client.spec.tsx index 503e19eab7..7d054adc9b 100644 --- a/packages/client/ui-trajectory/tests/table.client.spec.tsx +++ b/packages/client/ui-trajectory/tests/table.client.spec.tsx @@ -2,7 +2,8 @@ /** Trajectory ledger selection, details, status, and fold behavior. */ import { afterEach, describe, expect, it, vi } from 'vitest' -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { AttachmentId } from '@deepseek-ai/dsh-attachment' import type { ComponentProps } from 'react' import type { RenderMessageImages } from '@deepseek-ai/dsh-client-ui-conversation/client' import { TrajectoryTable as LocalizedTrajectoryTable } from '../src/client/TrajectoryTable.tsx' @@ -661,6 +662,7 @@ describe('TrajectoryTable', () => { expect(onLoadOlder).toHaveBeenCalledOnce() expect(loadButton.hasAttribute('disabled')).toBe(true) expect(screen.getByRole('status').textContent).toBe('Loading earlier history…') + expect(screen.getByRole('button', { name: 'Loading earlier history…' }).querySelector('[data-state="ongoing"]')).not.toBeNull() resolveOlder?.(false) await waitFor(() => { @@ -697,6 +699,7 @@ describe('TrajectoryTable', () => { ) expect(screen.getByRole('status').textContent).toContain('Loading trajectory…') + expect(screen.getByRole('status').querySelector('[data-state="ongoing"]')).not.toBeNull() expect(screen.getByRole('table').getAttribute('data-scroll-ready')).toBeNull() view.rerender() @@ -1038,47 +1041,60 @@ describe('TrajectoryTable', () => { expect(screen.getByText('value:')).toBeTruthy() }) - it('renders user image attachments through the shared gallery in the details panel', () => { - const attachment = { - attachmentId: `sha256:${'a'.repeat(64)}`, - mediaType: 'image/png', - bytes: 68, - width: 640, - height: 320, - name: 'screenshot.png', - } as unknown as NonNullable< - NonNullable[number]['attachment'] - > - const turns: readonly TrajectoryTurnModel[] = [{ - turn: 1, - groups: [{ - title: 'Message', - cells: [{ - index: 1, - kind: 'user', - text: 'Images ×2', - sourceBlocks: [ - { type: 'image', content: '', attachment }, - { type: 'image', content: '', attachment }, - ], - timeSeconds: 0, - }], - }], - }] - - render() - fireEvent.click(screen.getByRole('row', { name: /USER/ })) - - const preview = screen.getAllByTestId('record-images') - expect(preview.length).toBeGreaterThan(0) - expect(preview[0]?.getAttribute('data-count')).toBe('2') - - fireEvent.click(screen.getByRole('tab', { name: 'Raw' })) - const rawGalleries = screen.getAllByTestId('record-images') - expect(rawGalleries).toHaveLength(2) - expect(rawGalleries[0]?.querySelector('[data-attachment-id]')?.getAttribute('data-attachment-id')) - .toBe(String(attachment.attachmentId)) - }) + it.each([['English', t, 'Attachments', 'Image 1', 'Summary', 'Preview', 'Raw'], + ['Chinese', tZh, '附件', '图片 1', '概述', '预览', '原始内容']] as const)( + 'keeps mixed attachments ordered and raw fields complete in %s', + (_locale, translate, listLabel, imageLabel, summaryTab, previewTab, rawTab) => { + const attachment = { + attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), + mediaType: 'image/png' as const, + bytes: 68, + width: 40, + height: 800, + originalDimensions: { width: 400, height: 8000 }, + } + const file = { + attachmentId: AttachmentId(`sha256:${'b'.repeat(64)}`), + name: `${'long-filename-'.repeat(12)}.txt`, + bytes: 0, + } + const content = [ + { type: 'text' as const, text: '**before**' }, + { type: 'image' as const, attachment }, + { type: 'text' as const, text: 'after' }, + { type: 'file' as const, attachment: file }, + { type: 'image' as const, attachment }, + ] + const turns = deriveTrajectoryLayout({ + nodes: [{ kind: 'user', seq: 1, time: 1000, source: { kind: 'user' }, content }], + partial: null, + runningCalls: [], + }, translate) + const renderImages = vi.fn(renderImagesStub) + const view = render() + fireEvent.click(view.container.querySelector('[data-trajectory-row-key]')!) + for (const tab of [summaryTab, previewTab]) { + fireEvent.click(screen.getByRole('tab', { name: tab })) + const list = screen.getByRole('list', { name: listLabel }) + const names = [...list.querySelectorAll('[title]')].map(el => el.getAttribute('title')) + expect(names).toEqual([imageLabel, file.name, imageLabel.replace('1', '2')]) + expect(within(list).getByText('TXT · 0B')).toBeTruthy() + expect(within(list).getAllByText('image/png · 68B · 40 × 800')).toHaveLength(2) + expect(renderImages).toHaveBeenCalledWith({ images: [{ attachment, label: imageLabel }], align: 'start', thumbnail: true }) + } + fireEvent.click(screen.getByRole('tab', { name: rawTab })) + expect(screen.queryByTestId('record-images')).toBeNull() + const disclosures = [...view.container.querySelectorAll('details')] + expect(disclosures).toHaveLength(3) + expect(disclosures.every(el => !el.open)).toBe(true) + expect(disclosures.map((el): unknown => JSON.parse(el.querySelector('pre')!.textContent))) + .toEqual([content[1], content[3], content[4]]) + const blocks = [...disclosures[0]!.parentElement!.children] + expect(blocks.map(el => el.tagName)).toEqual(['SECTION', 'DETAILS', 'SECTION', 'DETAILS', 'DETAILS']) + expect(blocks[0]!.querySelector('pre')!.textContent).toBe('**before**') + expect(blocks[2]!.querySelector('pre')!.textContent).toBe('after') + }, + ) it('renders a tool-result image through the shared gallery in the Result tab', () => { const attachment = { diff --git a/packages/client/ui-trajectory/tests/views.client.spec.tsx b/packages/client/ui-trajectory/tests/views.client.spec.tsx index 8b2765d380..94a97e79b6 100644 --- a/packages/client/ui-trajectory/tests/views.client.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.client.spec.tsx @@ -234,6 +234,7 @@ function standaloneProps( useInput: bindSnapshotSelector(input), inputActions, useProjection, + inspectCall: undefined, viewRequest: null, openView: () => {}, completeViewRequest: () => {}, @@ -272,6 +273,7 @@ async function bench(snapshot = historySnapshot(NODES)) { const targetSources: ConversationTargetSources = { chat: createSnapshotStore(undefined), trajectory: trajectoryStore, + 'tool-todo-history': createSnapshotStore(undefined), } const binding: ConversationBinding = { snapshot: conversationStore, @@ -290,7 +292,7 @@ async function bench(snapshot = historySnapshot(NODES)) { // The locale plugin backs the locale-aware view tab label ('locale' in // inject); its settings scope needs a connection handle. ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never) - ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) + ctx.provide('settingsScope', { developerTools: { enabled: createSnapshotStore(true) }, bind: () => stubSettingsScope().scope } as never) await runtime.mount({ inject: [...localeInject], apply: localeApply }) const provide = vi.spyOn(ctx.uiSession, 'provide') const feature = await runtime.mount({ inject: [...inject], apply }) @@ -308,10 +310,10 @@ function tabsOf(slots: SlotRegistry): ViewTab[] { .map(e => ({ id: e.options.id!, label: resolveSlotLabel(e.options.label) ?? e.options.id! })) } -type ConvViewOwner = Pick +type ConvViewOwner = Pick function isConvViewOwner(owner: object): owner is ConvViewOwner { - return 'viewRequest' in owner + return 'inspectCall' in owner && 'viewRequest' in owner && 'openView' in owner && typeof owner.openView === 'function' && 'completeViewRequest' in owner && typeof owner.completeViewRequest === 'function' } @@ -394,6 +396,7 @@ function mount(fixture: Awaited>) { return render( <> children} useStore={bindSnapshotSelector(conversation)} @@ -411,6 +414,7 @@ function mount(fixture: Awaited>) { renderSlot={renderSlot} bindDraftMirror={() => () => {}} openView={conversation.actions.openView} + useInspectCall={selector => selector(undefined)} /> , ) diff --git a/packages/client/ui-user-questions/README.i18n.yaml b/packages/client/ui-user-questions/README.i18n.yaml index 0a5c7e8d82..626839020e 100644 --- a/packages/client/ui-user-questions/README.i18n.yaml +++ b/packages/client/ui-user-questions/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-user-questions/README.md -README.md: ec20bbc1c16944eb3ffec6798455a544861fec61 -README.zh.md: 9d8a2bf216be8e4d755a64e0b36fdcac8242c734 +README.md: 929494485b5ca1c0afb6fa3d24f59627136cc915 +README.zh.md: 0269998b744e020bd9d58b8f919626297cd94f7f diff --git a/packages/client/ui-user-questions/README.md b/packages/client/ui-user-questions/README.md index ec20bbc1c1..929494485b 100644 --- a/packages/client/ui-user-questions/README.md +++ b/packages/client/ui-user-questions/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -When an agent asks a question in the Web client, this package replaces the chat composer with an interactive question surface. Users can move through questions, choose one or multiple options, enter custom answers, skip items, and submit one structured answer batch. Single-choice selections advance immediately, while drafts survive Session navigation for the lifetime of the page. A single question with a supported presentation intent can use a dedicated surface, including the plan-review card with `Chat about it`, `Refuse`, and `Approve` actions. +When an agent asks a question in the Web client, this package replaces the chat composer with an interactive question surface. Users can move through questions, choose one or multiple options, enter custom answers, skip items, and submit one structured answer batch. Single-choice selections advance immediately, while drafts survive Session navigation for the lifetime of the page. A single question with a supported presentation intent can use a dedicated surface, including the plan-review card with `Request changes` and `Approve` actions. ## Table of Contents @@ -33,7 +33,7 @@ A multi-select draft keeps its selected labels while the user opens or edits the ### The plan-review card -A `plan-review` intent — set by `dsh-plan-mode` on the `exit_plan_mode` review — renders the waiting-approval card layout: a `Plan review` strip, the plan as the scrolling markdown body, and one decision row of `Chat about it` / `Refuse` / `Approve`. Approve and Refuse answer with the asker's own option labels; `Chat about it` rejects the wait as `ASK_CANCELLED`, returning the composer so the user can say what they want instead. +A `plan-review` intent — set by `dsh-plan-mode` on the `exit_plan_mode` review — renders a compact approval card: a `Plan review` strip with a `View full plan` link, the plan title and a two-line plain-text summary, and `Request changes` / `Approve` actions. The strip uses the shared warning dot and changes it to the ongoing loader while either action settles. The complete plan opens in the sidebar through the link or its permanent Chat card. Approve answers with the asker's approval label; `Request changes` rejects the wait as `ASK_CANCELLED`, returning the composer for the user's feedback without submitting an approval. The card has no separate refusal button. ### Failure and recovery @@ -51,12 +51,14 @@ The package is one ownership rule: rendering a question is a host UI capability, ### Intent surface election -The card claims a request only when it can send every answer that request allows: one question, the intent declared, the plan present as `detail`, the named approve label offered, and a binary single choice (at most one option besides approve, not multi-select). Anything else stays on the generic flow, which can express it. An intent changes the layout, never which answers are reachable. +The card accepts one question declaring the intent, carrying the plan as `detail`, and offering the named approve label, with at most one alternative and no multi-select. Its secondary action returns to the composer for change requests. Larger choices and multi-select questions remain in the generic flow. ### Copy and locale Composer chrome copy (pager, buttons, placeholders, validation feedback) is bilingual: the plugin registers zh/en dictionaries under the `question` namespace of `dsh-client-locale` and hands the entry its bound translator plus the locale snapshot source through the inject face, so a locale switch re-renders a mounted composer. Question and option text arrives from the model and renders verbatim; carrier failure messages also display untranslated. +A plan review exposes `conversation.plan-review.actions` with its request key, full text, and optional invocation identity. The plan plugin opens logged plans from history and unlogged reviews as temporary sidebar previews. Opening a document does not answer or dismiss the review. + ----- diff --git a/packages/client/ui-user-questions/README.zh.md b/packages/client/ui-user-questions/README.zh.md index 9d8a2bf216..0269998b74 100644 --- a/packages/client/ui-user-questions/README.zh.md +++ b/packages/client/ui-user-questions/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -当 agent(智能体)在 Web 客户端中提问时,本包会用交互式提问界面接管聊天编辑器。用户可以在问题之间导航、选择一个或多个选项、输入自定义答案、跳过问题,并提交一批结构化答案。选择单选项后会立即前进,而草稿会在当前页面的生命周期内跨会话导航保留。若唯一的问题声明了受支持的呈现意图,则可使用专用界面,包括带 `Chat about it`、`Refuse` 和 `Approve` 操作的 plan-review 卡片。 +当 agent(智能体)在 Web 客户端中提问时,本包会用交互式提问界面接管聊天编辑器。用户可以在问题之间导航、选择一个或多个选项、输入自定义答案、跳过问题,并提交一批结构化答案。选择单选项后会立即前进,而草稿会在当前页面的生命周期内跨会话导航保留。若唯一的问题声明了受支持的呈现意图,则可使用专用界面,包括带 `Request changes` 和 `Approve` 操作的 plan-review 卡片。 ## 目录 @@ -33,7 +33,7 @@ kind: "package-reference" ### plan-review 卡片 -`plan-review` 意图——由 `dsh-plan-mode` 在 `exit_plan_mode` 审阅上设置——渲染等待审批卡片的布局:一条 `Plan review` 条带、计划作为可滚动的 markdown 主体,以及一行 `Chat about it` / `Refuse` / `Approve` 的决定操作。Approve 与 Refuse 用提问方自己的选项标签回答;`Chat about it` 以 `ASK_CANCELLED` 拒绝该等待,让编辑器归位,用户可以直接说出他想说的话。 +`plan-review` 意图——由 `dsh-plan-mode` 在 `exit_plan_mode` 审阅上设置——渲染紧凑审批卡片:顶部为带“查看全文”链接的“计划待审”条带,正文显示计划标题和两行纯文本摘要,底部提供“要求修改”和“同意执行”。条带使用共享 warning 状态点,并在任一操作结算期间切换为 ongoing loading。通过链接或聊天历史中的常驻卡片可在侧边栏阅读完整计划。“同意执行”用提问方的批准标签回答;“要求修改”以 `ASK_CANCELLED` 拒绝等待,让编辑器归位供用户输入反馈,不会提交批准。卡片不单列拒绝按钮。 ### 失败与恢复 @@ -51,12 +51,14 @@ kind: "package-reference" ### 意图界面选择 -卡片只在能够发出该请求允许的每一个答案时才接管:只有一个问题、声明了意图、计划以 `detail` 存在、提供了被指名的批准标签,且是二元单选(除批准外最多一个选项,且非多选)。其他任何情形都留在能够表达它的通用流程上。意图改变的只是布局,从不改变可达的答案。 +卡片接管声明了意图、以 `detail` 携带计划、提供了被指名的批准标签的单个问题,要求除批准外最多一个选项,且非多选。次要操作返回编辑器供用户提出修改要求。更多选项或多选问题仍由通用流程处理。 ### 文案与 locale 编辑器外框文案(翻页器、按钮、占位符、校验提示)是双语的:插件在 `dsh-client-locale` 的 `question` 命名空间下注册 zh/en 词典,并通过 inject face 把绑定的翻译函数和 locale 快照源交给该条目,因此切换语言会重新渲染已挂载的编辑器。问题与选项文本来自模型并原样渲染;载体失败消息也不经翻译直接显示。 +计划审批通过 `conversation.plan-review.actions` 插槽提供请求键、完整正文和可选的调用标识。计划插件从历史中打开已记录计划,并把没有调用标识的审批作为临时侧边栏预览打开。打开文档不会回答或关闭审批。 + ----- diff --git a/packages/client/ui-user-questions/package.json b/packages/client/ui-user-questions/package.json index 6ff89373c3..1b6c469eb9 100644 --- a/packages/client/ui-user-questions/package.json +++ b/packages/client/ui-user-questions/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-user-questions", "description": "Web ask_user_question composer takeover and plan-review presentation UI", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, @@ -66,7 +66,8 @@ "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "clsx": "^2.0.0" + "clsx": "^2.0.0", + "@deepseek-ai/dsh-llm": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-user-questions/src/client/PlanReviewPanel.module.css b/packages/client/ui-user-questions/src/client/PlanReviewPanel.module.css index bbe543610c..ed9a8cae5f 100644 --- a/packages/client/ui-user-questions/src/client/PlanReviewPanel.module.css +++ b/packages/client/ui-user-questions/src/client/PlanReviewPanel.module.css @@ -12,20 +12,12 @@ flex-direction: column; width: 100%; max-width: var(--dsh-chat-content-width); - /* Composer seat sits in a fixed-height conversation column (overflow - hidden): cap the card against the viewport and scroll the plan, so the - strip and the decision row stay reachable on a long plan. */ - max-height: min(60vh, 520px); - border: 1px solid var(--dsw-alias-state-warn-secondary); + --dsw-elevation-stroke-color: var(--dsw-alias-border-l2); + border: 0; border-radius: 20px; background: var(--dsw-specific-input-major); - box-shadow: var(--dsw-shadow-lv2); + box-shadow: var(--dsw-elevation-panel); color: var(--dsw-alias-label-primary); - /* Elevated surface in dark: the plan body inside scrolls once the card hits - the cap above, so the thumb takes the l2 pair (see ui-theme - styles/scrollbar.css for the rebinding contract). */ - --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); - --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); } .card, @@ -39,30 +31,11 @@ align-items: center; flex-shrink: 0; gap: 8px; - padding: 10px 16px; + padding: 12px 16px; background: var(--dsw-alias-state-warn-tertiary); color: var(--dsw-alias-state-warn-primary); - font-size: 13px; - line-height: 18px; -} - -.dot { - width: 8px; - height: 8px; - border-radius: 50%; - corner-shape: round; - background: var(--dsw-alias-state-warn-primary); -} - -/* The plan is the panel's message: it takes the whole body and the scroll. */ -.body { - flex: 1 1 auto; - min-height: 0; - overflow-y: auto; - overscroll-behavior: contain; - padding: 12px 16px 4px; font-size: 14px; - line-height: 22px; + line-height: 20px; } .footer { @@ -88,16 +61,34 @@ gap: 8px; } -/* The discuss verb stays a quiet text button beside the two decision - capsules: 14px glyph against the 14px label with a slightly wider gap, so - the icon reads as a prefix rather than a peer-sized control. */ -.discuss { - gap: 6px; - color: var(--dsw-alias-label-secondary); +.summary { + min-width: 0; + padding: 14px 16px 12px; } -.discuss:hover:not(:disabled) { - color: var(--dsw-alias-label-primary); +.title { + margin: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 15px; + font-weight: 500; + line-height: 22px; +} + +.description { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + overflow: hidden; + margin: 8px 0 0; + color: var(--dsw-alias-label-secondary); + font-size: 14px; + line-height: 24px; +} + +.discuss { + gap: 6px; } @media (max-width: 720px) { @@ -105,12 +96,14 @@ border-radius: 16px; } - .body { - padding: 10px 12px 4px; - } - .footer { align-items: flex-end; padding: 8px 12px 10px; } } + +.previewActions { + margin-left: auto; + display: flex; + align-items: center; +} diff --git a/packages/client/ui-user-questions/src/client/PlanReviewPanel.tsx b/packages/client/ui-user-questions/src/client/PlanReviewPanel.tsx index 9e18e5aced..b0f6946f96 100644 --- a/packages/client/ui-user-questions/src/client/PlanReviewPanel.tsx +++ b/packages/client/ui-user-questions/src/client/PlanReviewPanel.tsx @@ -1,11 +1,13 @@ import { useMemo, useState } from 'react' -import { Button, IconEditOutline16, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives' +import { + Button, extractMarkdownPlainText, IconEditOutlineRegular, StateDot, +} from '@deepseek-ai/dsh-client-ui-primitives' import type { PendingQuestion, PlanReview, QuestionComposerProps } from './contract/slots.ts' import css from './PlanReviewPanel.module.css' /** The panel's own props: the question domain face, the narrowed review, and the locale seat. */ export type PlanReviewPanelProps = - { pending: PendingQuestion; review: PlanReview } & Pick + { pending: PendingQuestion; review: PlanReview } & Pick /** * Optional-prop spread for a decision button's tooltip: `title` is optional on @@ -19,16 +21,12 @@ function tooltip(description: string | undefined): { title?: string } { } /** - * Render a plan review as a decision card. + * Render plan review controls; the submitted document opens in the sidebar. * * @param props - the question domain face, the narrowed plan review, and `t`. * @returns The plan-review takeover for this request. */ -export function PlanReviewPanel({ pending, review, t }: PlanReviewPanelProps) { - const markdownLabels = useMemo(() => ({ - code: { copyLabel: t('copy'), copiedLabel: t('copied') }, - footnotes: t('markdown.footnotes'), - }), [t]) +export function PlanReviewPanel({ pending, review, t, renderSlot }: PlanReviewPanelProps) { // The panel waits for the host's resolved frame before leaving, so repeated // clicks must not resubmit. A failed send re-enables it and shows the error. const [busy, setBusy] = useState(false) @@ -44,35 +42,35 @@ export function PlanReviewPanel({ pending, review, t }: PlanReviewPanelProps) { const decide = (label: string): void => { settle(() => pending.answer({ answers: [{ id: review.id, selected: [label] }] })) } - const decline = review.decline + const summary = useMemo(() => { + const title = extractMarkdownPlainText(review.plan, { mode: 'first-line' }) + const description = extractMarkdownPlainText(review.plan, { mode: 'first-paragraph' }) + return { title, description: description === title ? '' : description } + }, [review.plan]) return (
    -
    +
    - + {t('plan.header')} +
    + {renderSlot('conversation.plan-review.actions', { review, requestKey: pending.key })} +
    -
    - +
    +

    {summary.title}

    + {summary.description !== '' &&

    {summary.description}

    }
    {error}
    - {decline !== undefined && ( - - )}
    @@ -337,7 +336,7 @@ function QuestionFlow({ pending, t, useStore, actions }: QuestionFlowProps) { {question.multiSelect === true ? ( ) : {optionIndex + 1}} @@ -365,12 +364,12 @@ function QuestionFlow({ pending, t, useStore, actions }: QuestionFlowProps) { className={clsx(css.checkbox, draft.custom !== '' && css.checkboxChecked)} aria-hidden="true" > - {draft.custom !== '' && } + {draft.custom !== '' && } ) : ( )} { replaceProgress(index - 1, drafts); setError(null) }} > - + {index + 1} / {questions.length}
    diff --git a/packages/client/ui-user-questions/src/client/contract/slots.ts b/packages/client/ui-user-questions/src/client/contract/slots.ts index 2d42379205..813ec4a0be 100644 --- a/packages/client/ui-user-questions/src/client/contract/slots.ts +++ b/packages/client/ui-user-questions/src/client/contract/slots.ts @@ -1,6 +1,7 @@ /** Question composer props and one pending Remote waterfall response. */ -import type { PropsLocale, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' +import type { PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' // The client module declares the conversation.composer SlotMap entry required by PropsRuntime. +import type { ToolCallId } from '@deepseek-ai/dsh-llm/brand' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { AskUserQuestionAnswer, AskUserQuestionItem, @@ -14,6 +15,13 @@ declare module '@deepseek-ai/dsh-client-ui-session/client' { } } +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface SlotMap { + /** Actions for the exact plan under review; approval remains with the question composer. */ + 'conversation.plan-review.actions': { kind: 'list'; scope: 'session'; owner: { review: PlanReview; requestKey: PendingQuestion['key'] } } + } +} + /** One structured answer batch covering every question of the request. */ export type QuestionAnswer = AskUserQuestionAnswer @@ -50,6 +58,8 @@ export interface PlanReview { question: string /** The plan markdown under review. */ plan: string + /** Logged tool invocation used to reopen this plan. */ + callId?: ToolCallId /** The option that approves the plan. */ approve: QuestionOption /** The option that declines it; absent when the asker offered no other option. */ @@ -60,16 +70,10 @@ export interface PlanReview { * Narrow a request to a renderable plan review, or return undefined to leave it * to the generic question flow. * - * The card is one decision over one plan, and it claims a request only when it - * can send every answer that request allows — an intent changes the layout, - * never which answers are reachable. So the batch must be a single question - * that declares the intent, carries the plan as its detail, offers the approve - * label the intent names, and is a binary single choice: at most one option - * besides approve, and not multi-select. A third option or a multi-select batch - * has answers two buttons cannot express, so the generic flow keeps it — as it - * keeps any request whose intent the asker's own service would have rejected, - * because the client sits downstream of a wire boundary and every request must - * stay answerable. + * The card offers approval and a return to the composer for change requests. + * It accepts one question carrying the plan as detail and the named approve + * option, with at most one alternative and no multi-select. Larger choices + * remain in the generic question flow. * * @param questions - the request's whole question batch. * @returns The narrowed review, or undefined when the generic flow owns it. @@ -90,6 +94,7 @@ export function planReviewOf(questions: readonly QuestionItem[]): PlanReview | u id: question.id, question: question.question, plan: question.detail, + ...(intent.callId === undefined ? {} : { callId: intent.callId }), approve, ...(decline === undefined ? {} : { decline }), } @@ -220,5 +225,6 @@ export type QuestionWait = PendingQuestion export type QuestionComposerProps = PropsRuntime<'conversation.composer'> & PropsStore> + & PropsRenderSlots<'conversation.plan-review.actions'> & { matched: QuestionWait } & PropsLocale<'question'> diff --git a/packages/client/ui-user-questions/src/client/index.ts b/packages/client/ui-user-questions/src/client/index.ts index 4596347211..a356324f0b 100644 --- a/packages/client/ui-user-questions/src/client/index.ts +++ b/packages/client/ui-user-questions/src/client/index.ts @@ -98,6 +98,7 @@ export function apply(ctx: ClientContext): void { pendingInteraction instanceof PendingQuestion ? pendingInteraction : null, locale: NS, store: questionDraftStore, + children: { 'conversation.plan-review.actions': { kind: 'list', scope: 'session' } }, }, QuestionComposer, )) diff --git a/packages/client/ui-user-questions/src/client/locales.ts b/packages/client/ui-user-questions/src/client/locales.ts index e54243883b..046ceb7c88 100644 --- a/packages/client/ui-user-questions/src/client/locales.ts +++ b/packages/client/ui-user-questions/src/client/locales.ts @@ -14,9 +14,9 @@ export const zh = { 'action.skip': '跳过', 'action.next': '下一题', 'plan.header': '计划待审', - 'plan.approve': '确认执行', + 'plan.approve': '同意执行', 'plan.decline': '拒绝', - 'plan.discuss': '去聊天里说', + 'plan.discuss': '要求修改', } satisfies Record /** The question namespace key union. */ @@ -38,5 +38,5 @@ export const en = { 'plan.header': 'Plan review', 'plan.approve': 'Approve', 'plan.decline': 'Refuse', - 'plan.discuss': 'Chat about it', + 'plan.discuss': 'Request changes', } satisfies Record diff --git a/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx b/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx index 12efae8d73..570042510e 100644 --- a/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx +++ b/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx @@ -3,6 +3,7 @@ import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render, screen } from '@testing-library/react' import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { ToolCallId } from '@deepseek-ai/dsh-llm/brand' import { PendingQuestion, planReviewOf, type QuestionComposerProps, type QuestionWait, } from '../src/client/contract/slots.ts' @@ -107,6 +108,8 @@ const questionDraftStore = createQuestionDraftStore().create(SID) /** Framework standard-kit stubs: the panel consumes only the locale seat. */ const kit: Omit = { + renderSlot: () => null, + SessionProvider: ({ children }) => children, sessionId: SID, session: undefined, pendingInteraction: undefined, @@ -170,6 +173,16 @@ describe('planReviewOf', () => { }) }) + it('retains the logged invocation so the review can reopen its exact plan', () => { + const question = questions()[0]! + const callId = 'plan-call' as ToolCallId + const review = planReviewOf([{ + ...question, + intent: { kind: 'plan-review', approve: 'Approve', callId }, + }]) + expect(review).toEqual({ ...planReviewOf([question]), callId }) + }) + it('leaves the decline absent when the asker offered approve alone', () => { const [question] = questions() const review = planReviewOf([{ ...question as object, options: [{ label: 'Approve' }] } as never]) @@ -202,15 +215,35 @@ describe('planReviewOf', () => { }) describe('PlanReviewPanel', () => { - it('renders the plan under a review strip, with none of the quiz affordances', () => { + it('passes distinct pending request identities to the preview action for unlogged reviews', () => { + const rendered = vi.fn<(key: string, owner: unknown) => void>() + const renderSlot: QuestionComposerProps['renderSlot'] = (key, owner) => { rendered(key, owner); return null } + const first = wait() + const second = wait() + const view = render() + expect(rendered).toHaveBeenLastCalledWith('conversation.plan-review.actions', { + review: planReviewOf(first.carrier.questions), requestKey: first.carrier.key, + }) + view.rerender() + expect(rendered).toHaveBeenLastCalledWith('conversation.plan-review.actions', { + review: planReviewOf(second.carrier.questions), requestKey: second.carrier.key, + }) + expect(first.carrier.key).not.toBe(second.carrier.key) + expect(first.answer).not.toHaveBeenCalled() + expect(second.answer).not.toHaveBeenCalled() + }) + it('shows the plan title and summary above two review actions', () => { const { carrier } = wait() render() expect(document.querySelector('[data-plan-review-key]')?.getAttribute('data-plan-review-key')).toBe(carrier.key) expect(screen.getByText(zh['plan.header'])).toBeTruthy() - // The plan renders as markdown, so its heading is a heading. + expect(document.querySelector('[data-plan-review-key] [data-state="warning"]')).not.toBeNull() expect(screen.getByRole('heading', { name: 'Ship the picker' })).toBeTruthy() - expect(screen.getByText('render the rows')).toBeTruthy() + expect(screen.getByText('read the store')).toBeTruthy() + expect(screen.getAllByRole('button')).toHaveLength(2) + expect(screen.queryByText('render the rows')).toBeNull() + expect(document.querySelector('[data-plan-review-scroll]')).toBeNull() // The question text stays as the card's accessible name rather than a title // that reads like a test item. expect(screen.getByLabelText('Approve this plan and leave plan mode?')).toBeTruthy() @@ -221,6 +254,23 @@ describe('PlanReviewPanel', () => { expect(screen.queryByRole('textbox')).toBeNull() }) + it('extracts a plain summary and keeps a heading-only plan compact', () => { + const question = questions()[0]! + const detail = '# **Release** plan\n\nReview the [changes](https://example.com) before **shipping**.\n\n## Steps\n- Build' + const { carrier } = wait([{ ...question, detail }]) + const view = render() + expect(screen.getByRole('heading', { name: 'Release plan' })).toBeTruthy() + expect(screen.getByText('Review the changes before shipping.')).toBeTruthy() + expect(screen.queryByRole('link')).toBeNull() + const onlyTitle = wait([{ ...question, detail: '# Plan without a summary' }]) + view.rerender() + expect(screen.getByRole('heading', { name: 'Plan without a summary' })).toBeTruthy() + expect(document.querySelector('[data-plan-review-key] p')).toBeNull() + const paragraph = wait([{ ...question, detail: 'A single paragraph plan.' }]) + view.rerender() + expect(screen.getAllByText('A single paragraph plan.')).toHaveLength(1) + }) + it('answers with the asker\'s approve label and keeps its description as the tooltip', () => { const { carrier, answer } = wait() render() @@ -229,27 +279,23 @@ describe('PlanReviewPanel', () => { expect(approve.getAttribute('title')).toBe('Leave plan mode; the plan is carried out from the next step.') fireEvent.click(approve) expect(answer).toHaveBeenCalledWith(decision('Approve')) + expect(document.querySelector('[data-plan-review-key] [data-state="ongoing"]')).not.toBeNull() + expect(document.querySelector('[data-plan-review-key] section')?.getAttribute('aria-busy')).toBe('true') // One-shot: every action locks until the host's resolved frame lands. expect(approve.hasAttribute('disabled')).toBe(true) - expect(screen.getByRole('button', { name: zh['plan.decline'] }).hasAttribute('disabled')).toBe(true) + expect(screen.getByRole('button', { name: zh['plan.discuss'] }).hasAttribute('disabled')).toBe(true) fireEvent.click(approve) expect(answer).toHaveBeenCalledTimes(1) }) - it('answers with the asker\'s decline label', () => { - const { carrier, answer } = wait() - render() - - fireEvent.click(screen.getByRole('button', { name: zh['plan.decline'] })) - expect(answer).toHaveBeenCalledWith(decision('Keep planning')) - }) it('dismisses the request so the composer returns for a plain message', () => { - const { carrier, cancel } = wait() + const { carrier, cancel, answer } = wait() render() fireEvent.click(screen.getByRole('button', { name: zh['plan.discuss'] })) expect(cancel).toHaveBeenCalledWith() + expect(answer).not.toHaveBeenCalled() }) it('omits the tooltip for an option carrying no description', () => { @@ -260,7 +306,6 @@ describe('PlanReviewPanel', () => { render() expect(screen.getByRole('button', { name: zh['plan.approve'] }).hasAttribute('title')).toBe(false) - expect(screen.getByRole('button', { name: zh['plan.decline'] }).hasAttribute('title')).toBe(false) }) it('hides the decline action when the asker offered approve alone', () => { @@ -304,7 +349,7 @@ describe('PlanReviewPanel', () => { expect(screen.getByText('Plan review')).toBeTruthy() expect(screen.getByRole('button', { name: 'Approve' })).toBeTruthy() - expect(screen.getByRole('button', { name: 'Refuse' })).toBeTruthy() - expect(screen.getByRole('button', { name: 'Chat about it' })).toBeTruthy() + expect(screen.queryByRole('button', { name: 'Refuse' })).toBeNull() + expect(screen.getByRole('button', { name: 'Request changes' })).toBeTruthy() }) }) diff --git a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx index d916dd9389..cc26717c13 100644 --- a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx +++ b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx @@ -106,6 +106,8 @@ const inputState: InputState = { * the composed props type mandates delivery of the rest (framework hooks are * plain stubs per the client testing discipline). */ const kitBase: Omit = { + renderSlot: () => null, + SessionProvider: ({ children }) => children, session: undefined, sessionId: SID, pendingInteraction: undefined, diff --git a/packages/client/ui-user-questions/tsconfig.json b/packages/client/ui-user-questions/tsconfig.json index e66c67d105..c07d2c5fcc 100644 --- a/packages/client/ui-user-questions/tsconfig.json +++ b/packages/client/ui-user-questions/tsconfig.json @@ -46,6 +46,9 @@ }, { "path": "../ui-session" + }, + { + "path": "../../llm/llm" } ] } diff --git a/packages/client/ui-workflow-run/package.json b/packages/client/ui-workflow-run/package.json index f0e34297a1..ca7939dd83 100644 --- a/packages/client/ui-workflow-run/package.json +++ b/packages/client/ui-workflow-run/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-workflow-run", "description": "Durable workflow-run Conversation Node and nested member disclosure for dsh web", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.tsx b/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.tsx index 19d62b6af5..8cd4d768e6 100644 --- a/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.tsx +++ b/packages/client/ui-workflow-run/src/client/WorkflowRunPanel.tsx @@ -3,7 +3,7 @@ import { type FocusEvent, type MouseEvent, type ReactNode, } from 'react' import { - DisclosureRow, IconChevronRightOutline14, StateDot, + DisclosureRow, IconChevronRightOutlineRegular, StateDot, type DisclosureRowProps, type StateDotState, } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' @@ -212,7 +212,7 @@ function RunHeader({ children, count, name, onToggle, open, status, t }: { }) { return ( } + icon={} title={t('run.title', { name })} open={open} onToggle={onToggle} @@ -305,7 +305,7 @@ function PhaseSection({ onMouseDownCapture={pendingCleanCollapse ? preventPendingHeaderFocus : undefined} > } + icon={} title={readablePhase(phase.phase, t)} open={open} onToggle={onToggle} diff --git a/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx b/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx index 9aace64a9d..700e27330b 100644 --- a/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx +++ b/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx @@ -1,4 +1,5 @@ // @vitest-environment jsdom +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import { Context } from '@deepseek-ai/cordis' import { cleanup, fireEvent, render, screen } from '@testing-library/react' @@ -883,7 +884,7 @@ describe('plugin lifecycle', () => { await ctx.plugin(SlotRegistry).await() ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never) ctx.provide('remote', { $on: () => () => {} } as never) - ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) + ctx.provide('settingsScope', { developerTools: { enabled: createSnapshotStore(true) }, bind: () => stubSettingsScope().scope } as never) const sessions = new TestSessions(async (action) => { await action() }, ctx) ctx.provide('sessions', sessions) const openSession = vi.fn(async () => {}) diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index 9431d47206..5843a2009f 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-workspace/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-workspace/README.md -README.md: d57b7af4767a5a0206aec4ee9d9ed4fbbe37a21c -README.zh.md: 465150610de2f4f6e89ae3f414b277a8d2f3b10b +README.md: eabd5dc9eb8cad987da5eb7b6a747c3650ce69a4 +README.zh.md: 4358a00617376259a85b0e995aff3f95b295a2ff diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index d57b7af476..eabd5dc9eb 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -45,13 +45,15 @@ Collapsed search is one header action beside the view and add actions: activatin The Session row's Rename action opens a dialog prefilled with the row's display title; confirming an unchanged title is deliberately allowed — it pins the current automatic title against regeneration. Rename uses a temporary `workspaceOperation` reference, while fork-title assignment uses a temporary `controllerOperation` reference inside Session Controller; both await the reference's initial history opening. Archive commits without a confirmation dialog and the row disappears from every grouping surface when the archive-set echo lands. Fork forks at the source's last completed turn, increments the inherited persisted title on the client, and then opens the child. Workspace Delete opens a confirmation that states the retention boundary; success removes the group while its Sessions remain under Ungrouped. +A Session title wider than its row is clipped with an ellipsis at rest. Hovering the row scrolls the title to its far edge — the incremented title of a fork, for example — and reveals it without the ellipsis; leaving the row returns the title to its start. + ### Pending interactions -Session rows render the runtime's live `pendingInteraction` classification: approvals report **Waiting for approval**, plan reviews report **Plan awaiting review**, and ordinary questions report **Waiting for answer**. Every pending interaction uses an amber warning dot that takes precedence over the running indicator. +Session rows render the runtime's live `pendingInteraction` classification: approvals report **Waiting for approval**, plan reviews report **Plan awaiting review**, and ordinary questions report **Waiting for answer**. While an interaction is pending, the row uses the shared warning dot and replaces its trailing update time with **Approval**, **Plan review**, or **Answer**; the full status and relative time remain in hover details. Pending interaction takes precedence over the shared ongoing loader; a finished-but-unviewed Session uses done, while idle remains dot-free in the row and uses the shared idle dot in its hover details. ### Active Schedule markers -Grouped and flat Session rows, plus search results, show an outline alarm when `SessionSummary.projectionValues.schedule` is a non-empty array. The marker sits after the title; an ordinary row keeps its update time after the marker, while a search result has no update time. It is not a button, has no independent pointer action or tab stop, and clicking its area still opens the row. The localized tooltip and matching screen-reader label say **Has active scheduled task**. +Grouped and flat Session rows, plus search results, show an outline alarm when `SessionSummary.projectionValues.schedule` is a non-empty array. The marker sits after the title; an ordinary row keeps its update time or compact pending-interaction label after the marker, while a search result has neither trailing value. It is not a button, has no independent pointer action or tab stop, and clicking its area still opens the row. The localized tooltip and matching screen-reader label say **Has active scheduled task**. The value is intentionally best effort for cold Sessions. An identity-matching usable projection-cache row can prewarm the alarm without opening the Session; a missing or stale cache may briefly omit or retain it. The marker means only that the current list value contains an undispatched or undeleted Schedule record. It does not report whether a Schedule runtime is live or able to wake the Session. @@ -59,6 +61,8 @@ The value is intentionally best effort for cold Sessions. An identity-matching u `ctx.uiWorkspace.openSession(target)` synchronously replaces the owned `mainView` reference and returns the main area to Conversation without waiting for `reference.ready`, so history loading renders inside the selected Session view. The target may be a known Session id or a durable direct-parent subagent address; an explicit address does not require a preloaded parent catalog. `openWorkspace(id, beforeOpen?)` and `forkSession(id)` open their result only if no later navigation has superseded the request; New Session uses `openWorkspace`. The optional synchronous preparation callback runs after the target is retained and only for a current Workspace request, so superseded requests do not move composer drafts. Navigation or owner disposal suppresses the late UI commit, not the underlying Session creation. Archiving the main Session releases its reference and clears the main selection. Selection failure leaves a global panel visible. Session rows read `usePanelInfo` to suppress their selected appearance while a global panel is active; search and directory-picker focus alone do not leave that panel. +New Session tries to acquire the first eligible blank in catalog order; startup restoration tries the saved blank. If that writer is held, navigation creates a new Session without trying other blanks. Other acquisition failures abort the request and are currently reported only to the console. Released blanks retain their slash-command state when reused. Later navigation cancels a pending startup selection. + ## Understand the implementation @@ -73,7 +77,7 @@ Each registration declares a **directory-flow child hole** (`single` kind: `conv ### View state -Once the Workspace list baseline is ready, browser-persisted expansion and manual Session-order records retain only current Workspace ids plus Ungrouped and the flat-list account. `WorkspaceView.sessionIds` supplies real-Workspace membership, not Session display order. View actions require the current account orders explicitly. Flat-list membership and ordering use Session ids; row rendering adds status indicators once. Entering Manual snapshots every active account from the current display; reconciliation retains saved members that still belong to the account, removes departed members, and appends newly known members by recency. A new membership entry without a Session summary is omitted until that summary arrives, while an already saved slot survives a temporarily missing summary. During Workspace reconnection, Manual records an observed blank Session at the front of its saved flat and known group orders without removing other saved members; full membership reconciliation waits for the Workspace baseline. This reconciliation remains mounted while the sidebar is a rail or search replaces its body. Last updated derives directly from each current list snapshot without reading or writing saved positions; equal timestamps use Session ids as a stable tie-break. The shared sidebar projection hides rows whose durable Session summary has `origin: 'subagent'`, and each visible ordinary row inherits the blue activity indicator while any descendant reached through uninterrupted subagent-origin lineage is running. The same pure derivation reads the Schedule key from list projection values for grouped, flat, and search nodes; the package uses only the type-only `@deepseek-ai/dsh-schedule/client` dependency and does not import the Schedule runtime or `ui-schedule`. +Once the Workspace list baseline is ready, browser-persisted expansion and manual Session-order records retain only current Workspace ids plus Ungrouped and the flat-list account. `WorkspaceView.sessionIds` supplies real-Workspace membership, not Session display order. View actions require the current account orders explicitly. Flat-list membership and ordering use Session ids; row rendering adds status indicators once. Entering Manual snapshots every active account from the current display; reconciliation retains saved members that still belong to the account, removes departed members, and appends newly known members by recency. A new membership entry without a Session summary is omitted until that summary arrives, while an already saved slot survives a temporarily missing summary. During Workspace reconnection, Manual records an observed blank Session at the front of its saved flat and known group orders without removing other saved members; full membership reconciliation waits for the Workspace baseline. This reconciliation remains mounted while the sidebar is a rail or search replaces its body. Last updated derives directly from each current list snapshot without reading or writing saved positions; equal timestamps use Session ids as a stable tie-break. The shared sidebar projection hides rows whose durable Session summary has `origin: 'subagent'`, and each visible ordinary row inherits the shared ongoing loader while any descendant reached through uninterrupted subagent-origin lineage is running. The same pure derivation reads the Schedule key from list projection values for grouped, flat, and search nodes; the package uses only the type-only `@deepseek-ai/dsh-schedule/client` dependency and does not import the Schedule runtime or `ui-schedule`. ### Hover cards diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index 465150610d..4358a00617 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -45,13 +45,15 @@ kind: "package-reference" Session 行内的 Rename 操作打开一个以该行显示标题预填的对话框;确认未修改的标题是有意允许的——这正是把当前自动标题钉住、不再被重新生成覆盖的手势。Rename 使用临时 `workspaceOperation` reference,fork 标题设置则由 Session Controller 使用临时 `controllerOperation` reference;两者都等待该 reference 的首次历史打开。Archive 不经确认对话框直接提交,归档集合回声落地后,该行从所有分组视图中消失。Fork 在源会话最后一个已完成轮次处 fork,在客户端递增继承的持久化标题后再打开子会话。Workspace 行内的 Delete 操作会打开确认框,说明保留边界;成功后该分组被移除,其 Session 则留在 Ungrouped 下。 +标题宽于所在行时,静止状态以省略号裁切。把指针停在行上,标题会滚动到远端——例如 fork 递增后的标题——并在揭示时不显示省略号;指针离开后标题回到开头。 + ### 待处理交互 -Session 行渲染运行时的实时 `pendingInteraction` 分类:审批显示**等待审批**,计划审阅显示**计划待审**,普通问题显示**等待回答**。每个待处理交互都使用一枚琥珀色警告点,优先级高于运行指示器。 +Session 行渲染运行时的实时 `pendingInteraction` 分类:审批显示**等待审批**,计划审阅显示**计划待审**,普通问题显示**等待回答**。交互待处理期间,该行使用共享 warning 橙点,并以**待批准**、**计划待审**或**待回答**替换尾部更新时间;悬停详情仍保留完整状态和相对时间。待处理交互的优先级高于共享 ongoing loading;已完成但未查看的 Session 使用 done,idle 在行内不显示点,在悬停详情中使用共享 idle 灰点。 ### 活动 Schedule 标识 -分组与平铺 Session 行以及搜索结果会在 `SessionSummary.projectionValues.schedule` 为非空数组时显示一枚轮廓闹钟。标识位于标题之后;普通行的更新时间仍位于标识之后,搜索结果则没有更新时间。它不是按钮,没有独立 pointer 行为或 Tab stop,点击所在区域仍会打开整行。本地化 tooltip 与文本相同的读屏标签均为**有活动定时任务**。 +分组与平铺 Session 行以及搜索结果会在 `SessionSummary.projectionValues.schedule` 为非空数组时显示一枚轮廓闹钟。标识位于标题之后;普通行的更新时间或紧凑待处理文案仍位于标识之后,搜索结果则不显示尾部信息。它不是按钮,没有独立 pointer 行为或 Tab stop,点击所在区域仍会打开整行。本地化 tooltip 与文本相同的读屏标签均为**有活动定时任务**。 对于 cold Session,该值有意采用尽力而为语义。身份匹配且可用的 projection-cache 行可以在不打开 Session 的情况下预热闹钟;cache 缺失或陈旧可能造成短暂漏显或残留。标识只表示当前列表值包含尚未 dispatch 或 delete 的 Schedule 记录,不表示 Schedule 运行时当前 live 或能够唤醒该 Session。 @@ -59,6 +61,8 @@ Session 行渲染运行时的实时 `pendingInteraction` 分类:审批显示** `ctx.uiWorkspace.openSession(target)` 会同步替换其拥有的 `mainView` reference,并让主区域返回 Conversation,而不等待 `reference.ready`,因此历史加载会显示在已经选中的 Session 视图内。目标可以是已知 Session id,也可以是持久的直接父子 subagent 地址;显式地址不要求预先加载 parent catalog。`openWorkspace(id, beforeOpen?)` 和 `forkSession(id)` 仅在请求未被后续导航替代时打开结果;新会话使用 `openWorkspace`。可选的同步准备回调在目标被 retain 后执行,并且仅对仍有效的 Workspace 请求执行,因此过期请求不会搬移 composer 草稿。后续导航或 owner 释放会阻止晚到的 UI 提交,但不取消底层 Session 创建。归档主 Session 会释放其 reference 并清除主选择。选择失败时保留当前全局面板。Session 行读取 `usePanelInfo`,在全局面板活跃时不显示 Session 选中样式;仅把焦点移到搜索框或目录选择器不会离开该面板。 +新建会话尝试获取列表中第一个符合条件的空白会话;启动恢复尝试已保存的空白会话。若该写锁被占用,导航直接新建 Session,不再尝试其他空白会话。其他获取错误会中止请求,目前只报告到控制台。已释放的空白会话被复用时保留 slash 命令状态。后续导航会取消尚未完成的启动选择。 + ## 理解实现 @@ -73,7 +77,7 @@ Session 行渲染运行时的实时 `pendingInteraction` 分类:审批显示** ### 视图状态 -Workspace 列表基线就绪后,浏览器持久化的展开状态和手动 Session 顺序记录只保留当前 Workspace id、Ungrouped 和单列表记账。`WorkspaceView.sessionIds` 提供真实 Workspace 的成员关系,而不提供 Session 显示顺序。视图操作要求显式传入当前各记账的顺序。单列表的成员筛选和排序使用 Session id,行渲染只计算一次状态指示。进入手动排序会从当前显示结果一次性记录每个有效记账;对账会保留仍属于该记账的已保存成员、移除已经离开的成员,并按最近更新时间追加新发现的成员。尚无 Session 摘要的新成员会等摘要到达后再加入,而已经保存的位置在摘要暂时缺失时仍会保留。Workspace 重连期间,手动排序会将已观察到的空白 Session 记录到已保存的单列表及已知分组顺序首位,不移除其他已保存成员;完整成员对账等待 Workspace 基线到齐。即使侧边栏收成窄栏或搜索替代列表主体,这项对账也保持挂载。最近更新直接从每份当前列表快照派生,不读取或写入已保存位置;时间相同时按 Session id 稳定排序。共享侧边栏投影会隐藏持久化 Session 摘要中带有 `origin: 'subagent'` 的行;每个可见普通行都会在经不间断的 subagent 谱系可达的任一后代运行时继承蓝色活动指示器。同一项纯派生逻辑还会为分组、平铺与搜索节点读取列表 projection value 中的 Schedule key;本包只使用纯类型依赖 `@deepseek-ai/dsh-schedule/client`,不会导入 Schedule 运行时或 `ui-schedule`。 +Workspace 列表基线就绪后,浏览器持久化的展开状态和手动 Session 顺序记录只保留当前 Workspace id、Ungrouped 和单列表记账。`WorkspaceView.sessionIds` 提供真实 Workspace 的成员关系,而不提供 Session 显示顺序。视图操作要求显式传入当前各记账的顺序。单列表的成员筛选和排序使用 Session id,行渲染只计算一次状态指示。进入手动排序会从当前显示结果一次性记录每个有效记账;对账会保留仍属于该记账的已保存成员、移除已经离开的成员,并按最近更新时间追加新发现的成员。尚无 Session 摘要的新成员会等摘要到达后再加入,而已经保存的位置在摘要暂时缺失时仍会保留。Workspace 重连期间,手动排序会将已观察到的空白 Session 记录到已保存的单列表及已知分组顺序首位,不移除其他已保存成员;完整成员对账等待 Workspace 基线到齐。即使侧边栏收成窄栏或搜索替代列表主体,这项对账也保持挂载。最近更新直接从每份当前列表快照派生,不读取或写入已保存位置;时间相同时按 Session id 稳定排序。共享侧边栏投影会隐藏持久化 Session 摘要中带有 `origin: 'subagent'` 的行;每个可见普通行都会在经不间断的 subagent 谱系可达的任一后代运行时继承共享 ongoing loading。同一项纯派生逻辑还会为分组、平铺与搜索节点读取列表 projection value 中的 Schedule key;本包只使用纯类型依赖 `@deepseek-ai/dsh-schedule/client`,不会导入 Schedule 运行时或 `ui-schedule`。 ### 悬浮卡片 diff --git a/packages/client/ui-workspace/package.json b/packages/client/ui-workspace/package.json index 28e99e693e..0686e58497 100644 --- a/packages/client/ui-workspace/package.json +++ b/packages/client/ui-workspace/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-workspace", "description": "Workspace picker plugin: one WorkspacePicker registered into the sidebar and empty-state workspace slots", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx index b1801cc3d9..b74e3689dc 100644 --- a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx +++ b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx @@ -11,7 +11,7 @@ import type { ReactNode, RefObject } from 'react' import { useCallback, useEffect, useState } from 'react' import { - Button, IconFolderClose16, IconPlusOutline16, Menu, Modal, type MenuEntry, + Button, IconFolderCloseRegular, IconPlusOutlineRegular, Menu, Modal, type MenuEntry, } from '@deepseek-ai/dsh-client-ui-primitives' import type { WorkspaceId, WorkspaceSnapshot, WorkspaceView, @@ -99,7 +99,7 @@ export function WorkspacePickFlow({ if (flowOpen && !flowAvailable) setFlowOpen(false) }, [flowOpen, flowAvailable]) const addEntries: MenuEntry[] = flowAvailable - ? [{ id: ADD_WORKSPACE, label: t('menu.addWorkspace'), icon: , disabled: flowBusy }] + ? [{ id: ADD_WORKSPACE, label: t('menu.addWorkspace'), icon: , disabled: flowBusy }] : [] // With workspaces listed, the add action pins below the scroll region // (divider + always visible); otherwise it IS the menu. @@ -108,7 +108,7 @@ export function WorkspacePickFlow({ ? workspaces.map(workspace => ({ id: workspace.workspaceId, label: workspace.title, - icon: , + icon: , disabled: flowBusy, })) : addEntries diff --git a/packages/client/ui-workspace/src/client/locales.ts b/packages/client/ui-workspace/src/client/locales.ts index 9160d9194a..95c4d184eb 100644 --- a/packages/client/ui-workspace/src/client/locales.ts +++ b/packages/client/ui-workspace/src/client/locales.ts @@ -58,6 +58,9 @@ export const zh = { 'status.waitingApproval': '等待审批', 'status.planReview': '计划待审', 'status.waitingAnswer': '等待回答', + 'status.compact.approval': '待审批', + 'status.compact.planReview': '计划待审', + 'status.compact.answer': '待回答', 'status.completed': '已完成', 'schedule.active': '有活动定时任务', 'hover.created': '创建于 {time}', @@ -129,6 +132,9 @@ export const en = { 'status.waitingApproval': 'Waiting for approval', 'status.planReview': 'Plan awaiting review', 'status.waitingAnswer': 'Waiting for answer', + 'status.compact.approval': 'Approval', + 'status.compact.planReview': 'Plan review', + 'status.compact.answer': 'Answer', 'status.completed': 'Completed', 'schedule.active': 'Has active scheduled task', 'hover.created': 'Created {time}', diff --git a/packages/client/ui-workspace/src/client/navigation.ts b/packages/client/ui-workspace/src/client/navigation.ts index 4173e1bdee..c0341186ad 100644 --- a/packages/client/ui-workspace/src/client/navigation.ts +++ b/packages/client/ui-workspace/src/client/navigation.ts @@ -4,6 +4,7 @@ import { Service, type Context } from '@deepseek-ai/cordis' import type { ClientRemote, DirectoryListing, RemoteFailure } from '@deepseek-ai/dsh-api-remotes/client' import type { ISessions, + SessionCreateError, SessionReference, SessionTarget, SessionListState, @@ -11,7 +12,7 @@ import type { import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client' import type { - IWorkspaces, WorkspaceId, WorkspaceView, + IWorkspaces, WorkspaceId, WorkspaceSnapshot, WorkspaceView, } from '@deepseek-ai/dsh-api-workspace-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type {} from '@deepseek-ai/dsh-client-ui-layout/client' @@ -143,19 +144,33 @@ class UiWorkspaceService extends Service implements UiWorkspace { const inflight = this.connecting.get(workspaceId) if (inflight !== undefined) return inflight + const attempt = this.reuseOrCreateBlank(workspace) + .finally(() => { this.connecting.delete(workspaceId) }) + this.connecting.set(workspaceId, attempt) + return attempt + } + + private reuseOrCreateBlank(workspace: WorkspaceView): Promise { const archived = this.workspaces.list.getSnapshot().archivedSessionIds const sessions = this.sessions.list.getSnapshot() for (const id of sessions.ids) { const summary = sessions.byId[id] - if (summary !== undefined && summary.blank && summary.cwd === workspace.path - && workspace.sessionIds.includes(summary.id) - && !archived.includes(summary.id)) return summary.id + if (summary === undefined || !summary.blank || summary.cwd !== workspace.path + || !workspace.sessionIds.includes(id) || archived.includes(id)) continue + return this.reuseBlank(workspace.workspaceId, id) } + return this.sessions.create({ workspaceId: workspace.workspaceId }) + } - const attempt = this.sessions.create({ workspaceId }) - .finally(() => { this.connecting.delete(workspaceId) }) - this.connecting.set(workspaceId, attempt) - return attempt + private async reuseBlank(workspaceId: WorkspaceId, sessionId: SessionId): Promise { + try { + return await this.sessions.create({ workspaceId, sessionId }) + } catch (error: unknown) { + // Client plugin bundles do not share error-class identity. + if (!(error instanceof Error) || error.name !== 'SessionCreateError' + || (error as SessionCreateError).rpcError.code !== 'session/writer-held') throw error + return this.sessions.create({ workspaceId }) + } } openSession(target: SessionTarget): void { @@ -235,44 +250,17 @@ class UiWorkspaceService extends Service implements UiWorkspace { initial = 'done' return } - const saved = this.selection.getSnapshot() - const savedTarget = saved.subagentAddress - ?? (saved.sessionId !== undefined && sessions.byId[saved.sessionId] !== undefined - ? saved.sessionId - : undefined) - if (savedTarget !== undefined) { - initial = 'connecting' - try { - if (saved.subagentAddress !== undefined) { - void this.sessions.refreshSubagents(saved.subagentAddress.parentSessionId) - } - this.openSession(savedTarget) - initial = 'done' - } catch (reason: unknown) { - initial = 'waiting' - console.warn('initial Session restoration failed:', reason) - } - return - } - const target = recentWorkspace(workspace.items, sessions.byId) - if (target === undefined) { - initial = 'done' - return - } initial = 'connecting' - void this.connectWorkspace(target).then( - (sessionId) => { - if (this.mainReference === undefined) this.openSession(sessionId) - }, - ).then( + void this.restoreSelection(workspace, sessions).then( () => { initial = 'done' }, (reason: unknown) => { if (this.lifetime.signal.aborted) return initial = 'waiting' - console.warn('initial workspace selection failed:', reason) + console.warn('initial Session restoration failed:', reason) }, ) } + const disposeWorkspaces = this.workspaces.list.subscribe(reconcile) const disposeSessions = this.sessions.list.subscribe(reconcile) reconcile() @@ -283,6 +271,33 @@ class UiWorkspaceService extends Service implements UiWorkspace { } } + private async restoreSelection(workspaces: WorkspaceSnapshot, sessions: SessionListState): Promise { + const saved = this.selection.getSnapshot() + if (saved.subagentAddress !== undefined) { + void this.sessions.refreshSubagents(saved.subagentAddress.parentSessionId) + this.openSession(saved.subagentAddress) + return + } + const summary = saved.sessionId === undefined ? undefined : sessions.byId[saved.sessionId] + const workspace = summary === undefined ? undefined + : workspaces.items.find(item => item.sessionIds.includes(summary.id)) + if (summary !== undefined && (!summary.blank || workspace === undefined)) { + this.openSession(summary.id) + return + } + const navigation = AbortSignal.any([this.ctx.layout.beginNavigation(), this.lifetime.signal]) + let sessionId: SessionId | undefined + if (summary !== undefined && workspace !== undefined && summary.cwd === workspace.path + && !workspaces.archivedSessionIds.includes(summary.id)) { + sessionId = await this.reuseBlank(workspace.workspaceId, summary.id) + } + const target = workspace?.workspaceId ?? recentWorkspace(workspaces.items, sessions.byId) + if (sessionId === undefined && target !== undefined) sessionId = await this.connectWorkspace(target) + if (sessionId !== undefined && !navigation.aborted) { + this.replaceMain(sessionId, navigation) + } + } + /** @returns true when an archived current selection was cleared. */ private clearArchivedCurrent(): boolean { const current = this.mainReference?.sessionId diff --git a/packages/client/ui-workspace/src/client/rows/Rows.module.css b/packages/client/ui-workspace/src/client/rows/Rows.module.css index 9925fd4ecc..0f5792b4c2 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.module.css +++ b/packages/client/ui-workspace/src/client/rows/Rows.module.css @@ -189,8 +189,23 @@ outline: none; } +/* Hovering a session row reveals a title wider than its cell: Rows.tsx scrolls + it to its end while the pointer rests on the row — smoothly, through the + scroll-behavior here — and returns it to the start in one step when the + pointer leaves. The unclipped state drops the ellipsis, which would otherwise + cover the characters the reveal reached. */ .sessionRow .title { flex: 1; + scroll-behavior: smooth; +} + +/* Guarded because a touch tap latches `:hover` on the row after the title has + already returned: an unguarded rule would strand that row without its + ellipsis. */ +@media (hover: hover) { + .sessionRow:hover .title { + text-overflow: clip; + } } .meta { @@ -204,9 +219,9 @@ .time { flex: none; - font-size: 12px; - line-height: 20px; - color: var(--dsw-alias-label-tertiary); + font-size: 10px; + line-height: 16px; + color: var(--dsw-alias-label-caption); } .scheduleIndicator { @@ -366,4 +381,9 @@ animation: none; transition: none; } + + /* The title reveal jumps to the far edge instead of gliding there. */ + .sessionRow .title { + scroll-behavior: auto; + } } diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx index f7e6dd01a5..20cc9a4852 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.tsx +++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx @@ -1,16 +1,17 @@ /** * Workspace browser tree row components (figma Cell set 14:3080): pure presentational — * all data and callbacks arrive via props. Hover swaps (folder->chevron, - * time->ellipsis, action buttons) are CSS-only. Row ... menus are visual-only - * except workspace Rename/Delete and session Rename/Fork/Archive; the session - * and workspace hover cards are suppressed while a menu is open. + * time->ellipsis, action buttons) are CSS-only, and a session row's clipped + * title is scrolled programmatically while the row is hovered. Row ... menus are + * visual-only except workspace Rename/Delete and session Rename/Fork/Archive; the + * session and workspace hover cards are suppressed while a menu is open. */ import { useEffect, useRef, useState } from 'react' import clsx from 'clsx' import { - HoverCard, IconAlarmClockOutline16, IconArchiveOutline20, IconBranchOutline16, - IconEditOutline16, IconEllipsisOutline16, IconFolderClose16, IconFolderOpen16, - IconPlusOutline16, IconTrashOutline16, IconTriangleRightFill14, Menu, relativeTime, + HoverCard, IconAlarmClockOutlineRegular, IconArchiveOutlineRegular, IconBranchOutlineRegular, + IconEditOutlineRegular, IconEllipsisOutlineRegular, IconFolderCloseRegular, IconFolderOpenRegular, + IconPlusOutlineMedium, IconTrashOutlineRegular, IconTriangleRightFillRegular, Menu, relativeTime, StateDot, } from '@deepseek-ai/dsh-client-ui-primitives' import type { StateDotState } from '@deepseek-ai/dsh-client-ui-primitives' @@ -27,6 +28,30 @@ function displayTitle(node: SessionNode, t: RowTranslate): string { return node.blank ? t('session.new') : node.title } +/** + * Reveal a title wider than its one-line cell while its row is hovered: the + * title clips its own text, so the far edge (a fork's incremented title, for + * example) is reachable by scrolling the element to its end. Leaving returns it + * to the start in one step, because the resting ellipsis and the narrowed cell + * would otherwise meet the text while it travelled back. A title that fits has + * no scroll range to move, and the stylesheet decides whether either move + * glides or jumps. + * @param title - the row's clipping title element. + * @param revealed - whether the pointer is on the row. + */ +function revealClippedTitle(title: HTMLSpanElement | null, revealed: boolean): void { + /* v8 ignore next -- defensive: the title span renders unconditionally. */ + if (title === null) return + if (revealed) { + title.scrollLeft = title.scrollWidth - title.clientWidth + return + } + // jsdom implements no scrollTo; the lane's direct assignment is instant there + // anyway, so both paths land on the same resting position. + if (typeof title.scrollTo === 'function') title.scrollTo({ left: 0, behavior: 'instant' }) + else title.scrollLeft = 0 +} + /** Localized compact relative time ("刚刚"/"5分钟" in zh, "now"/"5min" in en). */ function timeLabel(updatedAt: number, now: number, t: RowTranslate): string { const { unit, n } = relativeTime(updatedAt, now) @@ -129,8 +154,8 @@ export function ProjectRowItem({ group, containsCurrentDescendant = false, onTog const active = containsCurrentDescendant || (group.expanded && group.containsCurrent) const [menuOpen, setMenuOpen] = useState(false) const workspaceMenuItems = [ - { id: 'rename', label: t('rename'), icon: }, - { id: 'delete', label: t('delete.workspace'), icon: , danger: true }, + { id: 'rename', label: t('rename'), icon: }, + { id: 'delete', label: t('delete.workspace'), icon: , danger: true }, ] const ownRow = (
    - {row.expanded ? : } + {row.expanded ? : } - + {label} @@ -181,7 +206,7 @@ export function ProjectRowItem({ group, containsCurrentDescendant = false, onTog aria-label={t('actions.workspace.aria', { name: label })} onClick={(e) => { e.stopPropagation(); setMenuOpen(v => !v) }} > - + )} /> @@ -192,7 +217,7 @@ export function ProjectRowItem({ group, containsCurrentDescendant = false, onTog aria-label={t('actions.newSession.aria', { name: label })} onClick={(e) => { e.stopPropagation(); onCreate() }} > - +
    @@ -224,6 +249,8 @@ function assertNever(value: never): never { interface SessionStatus { state: StateDotState label: string + /** Compact text that replaces the Session row's update time. */ + trailingLabel?: string } /** @@ -248,13 +275,25 @@ function sessionStatuses( let pending: SessionStatus | undefined switch (node.pendingInteraction) { case 'approval': - pending = { state: 'warning', label: t('status.waitingApproval') } + pending = { + state: 'warning', + label: t('status.waitingApproval'), + trailingLabel: t('status.compact.approval'), + } break case 'plan-review': - pending = { state: 'warning', label: t('status.planReview') } + pending = { + state: 'warning', + label: t('status.planReview'), + trailingLabel: t('status.compact.planReview'), + } break case 'question': - pending = { state: 'warning', label: t('status.waitingAnswer') } + pending = { + state: 'warning', + label: t('status.waitingAnswer'), + trailingLabel: t('status.compact.answer'), + } break case undefined: break /* v8 ignore next -- closed PendingInteractionStatus union */ @@ -267,7 +306,7 @@ function sessionStatuses( } if (subagents !== undefined) return [subagents] if (node.completed) return [{ state: 'done', label: t('status.completed') }] - return [{ state: 'done', label: t('status.idle') }] + return [{ state: 'idle', label: t('status.idle') }] } /** Primary status dot plus every status's screen-reader label, shared by the search and session rows. */ @@ -292,7 +331,7 @@ function ActiveScheduleIndicator({ t, search = false }: { t: RowTranslate; searc aria-label={label} title={label} > - + ) } @@ -345,7 +384,7 @@ export function SearchResultItem({ result, currentId, onOpen, t }: { > - {(primaryStatus.state !== 'done' || result.completed) && ( + {primaryStatus.state !== 'idle' && ( )} @@ -364,7 +403,8 @@ export function SearchResultItem({ result, currentId, onOpen, t }: { /** * One top-level 34px session row: status dot (pending user interaction outranks - * own or descendant activity), title, relative time, and the row actions menu. + * own or descendant activity), title, relative time or compact pending label, + * and the row actions menu. * @param props.node - derived session node. * @param props.currentId - selected session id (row highlight). * @param props.now - epoch ms for relative-time formatting. @@ -404,10 +444,11 @@ export function SessionNodeItem({ const selected = node.id === currentId const statuses = sessionStatuses(node, t) const primaryStatus = statuses[0] - const showStatus = primaryStatus.state !== 'done' || row.completed + const showStatus = primaryStatus.state !== 'idle' const draggable = drag !== undefined && !row.blank const [menuOpen, setMenuOpen] = useState(false) const rowRef = useRef(null) + const titleRef = useRef(null) useEffect(() => { if (onReveal === undefined) return rowRef.current?.scrollIntoView({ block: 'nearest' }) @@ -417,10 +458,9 @@ export function SessionNodeItem({ // touches the session log, so it is not styled as destructive and needs no // confirmation dialog. const sessionMenuItems = [ - { id: 'rename', label: t('rename'), icon: }, - { id: 'fork', label: t('menu.fork'), icon: }, - // 20-native glyph in the menu's 16px icon slot (Menu.module.css .itemIcon). - { id: 'archive', label: t('menu.archiveSession'), icon: }, + { id: 'rename', label: t('rename'), icon: }, + { id: 'fork', label: t('menu.fork'), icon: }, + { id: 'archive', label: t('menu.archiveSession'), icon: }, ] // Figma session cell: pad 8, status slot 16, then a 4px title gap. const ownRow = ( @@ -434,6 +474,8 @@ export function SessionNodeItem({ role="treeitem" aria-selected={selected} onClick={() => { onOpen(node.id) }} + onPointerEnter={() => { revealClippedTitle(titleRef.current, true) }} + onPointerLeave={() => { revealClippedTitle(titleRef.current, false) }} draggable={draggable} onDragStart={drag === undefined || row.blank ? undefined @@ -467,13 +509,20 @@ export function SessionNodeItem({ {showStatus && } )} - {title} + {title} {row.hasActiveSchedule && } {/* A blank New Session row is a provisional placeholder: nothing has happened in it yet, so a "now" timestamp and the row verbs (rename/fork/archive) would all act on content that does not exist — both trailing cells stay off until the first prompt. */} - {!row.blank && {timeLabel(row.updatedAt, now, t)}} + {!row.blank && ( + + {primaryStatus.trailingLabel ?? timeLabel(row.updatedAt, now, t)} + + )} {!row.blank && ( { e.stopPropagation(); setMenuOpen(v => !v) }} > - + )} /> diff --git a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.module.css b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.module.css index 6616da56e8..91ad5eb779 100644 --- a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.module.css +++ b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.module.css @@ -1,6 +1,6 @@ .root { --dsh-session-list-edge-inset: var(--dsh-sidebar-inline-padding); - --dsh-session-list-scrollbar-width: 8px; + --dsh-session-list-scrollbar-width: 5px; --dsh-session-list-scrollbar-offset: 2px; flex: 1; min-height: 0; @@ -341,7 +341,7 @@ } /* List: the only scrolling region. Block children keep their design heights - under content overflow. The 2px edge offset, stable 8px themed scrollbar, + under content overflow. The 2px edge offset, stable 5px themed scrollbar, and remaining padding equal the shell's right inset, with or without overflow, so moving the bar does not move the rows. */ .list { diff --git a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx index e41a695b0d..784391e45d 100644 --- a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx @@ -12,8 +12,8 @@ import { type CSSProperties, type ReactNode, useEffect, useMemo, useRef, useState } from 'react' import clsx from 'clsx' import { - Button, IconCloseFill14, IconPersonalizationOutline16, - IconProjectAddOutline16, IconSearchOutline16, Menu, Modal, Tooltip, + Button, IconCloseFillRegular, IconPersonalizationOutlineRegular, + IconProjectAddOutlineRegular, IconSearchOutlineRegular, Menu, Modal, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' import type { SessionListState, SessionSearchResultItem, @@ -138,7 +138,7 @@ function ViewOptionsMenu({ groupBy, orderBy, onGroupPick, onOrderPick, t }: { aria-label={t('viewOptions.label')} onClick={() => { setOpen(v => !v) }} > - + )} @@ -1139,7 +1139,7 @@ export function WorkspaceBrowser({ setSearchExpanded(true) }} > - + - + )}
    @@ -1198,7 +1198,7 @@ export function WorkspaceBrowser({ setWsPickerOpen(v => !v) }} > - + )} @@ -1235,7 +1235,7 @@ export function WorkspaceBrowser({ expandSidebar() }} > - +
    } diff --git a/packages/client/ui-workspace/tests/browser-styles.client.spec.ts b/packages/client/ui-workspace/tests/browser-styles.client.spec.ts index 3d0f31b318..38d4ee36a3 100644 --- a/packages/client/ui-workspace/tests/browser-styles.client.spec.ts +++ b/packages/client/ui-workspace/tests/browser-styles.client.spec.ts @@ -45,7 +45,7 @@ describe('WorkspaceBrowser.module.css list', () => { it('counts the themed scrollbar inside the shell trailing inset', () => { expect(root?.get('--dsh-session-list-edge-inset')).toBe('var(--dsh-sidebar-inline-padding)') - expect(root?.get('--dsh-session-list-scrollbar-width')).toBe('8px') + expect(root?.get('--dsh-session-list-scrollbar-width')).toBe('5px') expect(root?.get('--dsh-session-list-scrollbar-offset')).toBe('2px') expect(root?.get('padding-right')).toBe('var(--dsh-session-list-edge-inset)') expect(listArea?.get('margin-left')).toBe('-4px') @@ -108,6 +108,15 @@ describe('WorkspaceBrowser.module.css list', () => { .toBe('var(--dsw-alias-interactive-bg-hover)') }) + it('reveals a clipped session title by scrolling it on row hover', () => { + // Smooth versus reduced motion is pinned as a computed style in + // apps/web/tests/sidebar-title-hover-scroll.e2e.ts: this helper merges + // same-selector rules across media queries, so the reduce override would + // mask the smooth declaration here. + expect(rowDeclarations('.sessionRow .title')?.get('flex')).toBe('1') + expect(rowDeclarations('.sessionRow:hover .title')?.get('text-overflow')).toBe('clip') + }) + it('pins both rail controls to the shared left anchor during the column slide', () => { expect(declarations('.rail .sectionHeader')?.get('justify-content')).toBe('flex-start') expect(declarations('.rail .iconButton')?.get('width')).toBe('36px') diff --git a/packages/client/ui-workspace/tests/rows.client.spec.tsx b/packages/client/ui-workspace/tests/rows.client.spec.tsx index 7865c31c22..57308820e2 100644 --- a/packages/client/ui-workspace/tests/rows.client.spec.tsx +++ b/packages/client/ui-workspace/tests/rows.client.spec.tsx @@ -4,15 +4,17 @@ import { act, cleanup, createEvent, fireEvent, render, screen } from '@testing-l import type { WorkspaceId } from '@deepseek-ai/dsh-api-workspace-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import type { RowDragProps } from '../src/client/rows/Rows.tsx' import { ProjectRowItem, SearchResultItem, SessionNodeItem } from '../src/client/rows/Rows.tsx' import type { GroupNode, SearchResultNode, SessionNode } from '../src/client/tree.ts' -import { zh } from '../src/client/locales.ts' +import { en, zh } from '../src/client/locales.ts' afterEach(cleanup) const t = makeTranslate(zh, commonZh) as never +const tEn = makeTranslate(en, commonEn) as never const sid = (id: string) => id as SessionId const wid = (id: string) => id as WorkspaceId @@ -169,6 +171,56 @@ describe('workspace browser rows', () => { expect(onOpen).toHaveBeenCalledWith(node.id) }) + it('reveals a clipped session title by scrolling it while the row is hovered', () => { + const node: SessionNode = { + id: sid('clipped'), title: 'A Session Title Long Enough To Be Clipped (1)', blank: false, + running: false, runningSubagentCount: 0, completed: false, hasActiveSchedule: false, updatedAt: 0, + } + render( + , + ) + const row = screen.getByRole('treeitem') + const title = screen.getByText(node.title) + // jsdom lays out nothing: the title's clipped geometry is stated outright. + const geometry = (scrollWidth: number, clientWidth: number): void => { + Object.defineProperty(title, 'scrollWidth', { value: scrollWidth, configurable: true }) + Object.defineProperty(title, 'clientWidth', { value: clientWidth, configurable: true }) + } + + geometry(320, 180) + fireEvent.pointerEnter(row) + expect(title.scrollLeft).toBe(140) + fireEvent.pointerLeave(row) + expect(title.scrollLeft).toBe(0) + + // A title that fits has no scroll range: hovering leaves it at its start. + geometry(180, 180) + fireEvent.pointerEnter(row) + expect(title.scrollLeft).toBe(0) + }) + + it('returns a revealed title to its start in one step', () => { + const node: SessionNode = { + id: sid('instant-return'), title: 'A Session Title Long Enough To Be Clipped (1)', blank: false, + running: false, runningSubagentCount: 0, completed: false, hasActiveSchedule: false, updatedAt: 0, + } + render( + , + ) + const row = screen.getByRole('treeitem') + const title = screen.getByText(node.title) + const scrollTo = vi.fn() + Object.defineProperty(title, 'scrollTo', { value: scrollTo, configurable: true }) + + fireEvent.pointerEnter(row) + fireEvent.pointerLeave(row) + // Browsers receive the explicit instant behavior that the stylesheet's + // smooth scroll-behavior would otherwise override. + expect(scrollTo).toHaveBeenCalledWith({ left: 0, behavior: 'instant' }) + }) + it('keeps the active-Schedule marker between the title and time in grouped and flat rows', () => { const onOpen = vi.fn() const node: SessionNode = { @@ -512,10 +564,10 @@ describe('workspace browser rows', () => { }) it.each([ - ['approval', '等待审批'], - ['plan-review', '计划待审'], - ['question', '等待回答'], - ] as const)('shows %s as warning ahead of the running state', (pendingInteraction, label) => { + ['approval', '等待审批', '待审批'], + ['plan-review', '计划待审', '计划待审'], + ['question', '等待回答', '待回答'], + ] as const)('shows %s as warning and replaces the row time', (pendingInteraction, label, compactLabel) => { vi.useFakeTimers() try { const node: SessionNode = { @@ -526,9 +578,13 @@ describe('workspace browser rows', () => { const view = render() const row = screen.getByRole('treeitem') + const title = screen.getByText('Needs input') expect(row.querySelector('[data-state="warning"]')).toBeTruthy() expect(row.querySelector('[data-state="ongoing"]')).toBeNull() - expect(screen.getByText(label)).toBeTruthy() + expect(row.textContent).toContain(label) + expect(title.nextElementSibling?.textContent).toBe(compactLabel) + expect(title.nextElementSibling?.getAttribute('aria-hidden')).toBe('true') + expect(row.textContent).not.toContain('刚刚') view.rerender() @@ -536,13 +592,32 @@ describe('workspace browser rows', () => { fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement) act(() => { vi.advanceTimersByTime(500) }) - expect(screen.getAllByText(label)).toHaveLength(2) + expect(screen.getAllByText(label).length).toBeGreaterThanOrEqual(2) + expect(screen.getByText('刚刚')).toBeTruthy() expect(document.querySelectorAll('[data-state="warning"]')).toHaveLength(2) } finally { vi.useRealTimers() } }) + it.each([ + ['approval', 'Approval'], + ['plan-review', 'Plan review'], + ['question', 'Answer'], + ] as const)('uses the compact English %s row label', (pendingInteraction, compactLabel) => { + const node: SessionNode = { + id: sid(pendingInteraction), title: 'Needs input', blank: false, + pendingInteraction, running: false, runningSubagentCount: 0, completed: false, + hasActiveSchedule: false, updatedAt: 0, + } + render() + const row = screen.getByRole('treeitem') + expect(screen.getByText('Needs input').nextElementSibling?.textContent).toBe(compactLabel) + expect(screen.getByText('Needs input').nextElementSibling?.getAttribute('aria-hidden')).toBe('true') + expect(row.textContent).not.toContain('now') + }) + it('idle hover card shows the Idle status line', () => { vi.useFakeTimers() try { @@ -554,7 +629,7 @@ describe('workspace browser rows', () => { onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />) fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement) act(() => { vi.advanceTimersByTime(500) }) - expect(screen.getByText('空闲')).toBeTruthy() + expect(screen.getByText('空闲').parentElement?.querySelector('[data-state="idle"]')).not.toBeNull() expect(screen.getAllByText('刚刚')).toHaveLength(2) } finally { vi.useRealTimers() diff --git a/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts b/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts index fd5c205f0a..9c35feeab7 100644 --- a/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts +++ b/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts @@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { ISessions, SessionListState, SessionReference, SessionSummary, } from '@deepseek-ai/dsh-api-session-controller/client' +import { SessionCreateError } from '@deepseek-ai/dsh-api-session-controller/client' import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client' import type { IWorkspaces, WorkspaceId, WorkspaceSnapshot, WorkspaceView, @@ -388,11 +389,12 @@ describe('UiWorkspaceService', () => { summary('stray', { blank: true, cwd: '/w/a' }), summary('blank', { blank: true, cwd: '/w/a' }), summary('archived', { blank: true, cwd: '/w/b' }), - ]), + ], 'pending'), workspaces: workspaceState([workspace('a', [sid('blank')]), workspace('b', [sid('archived')])], [sid('archived')]), }) await expect(b.uiWorkspace.connectWorkspace(wid('a'))).resolves.toBe(sid('blank')) - expect(b.sessions.create).not.toHaveBeenCalled() + expect(b.sessions.create).toHaveBeenCalledExactlyOnceWith({ workspaceId: wid('a'), sessionId: sid('blank') }) + b.sessions.create.mockClear() b.sessions.retain.mockClear() const created = Promise.withResolvers() b.sessions.create.mockReturnValue(created.promise) @@ -484,6 +486,174 @@ describe('UiWorkspaceService', () => { }) }) + it('uses catalog order for Workspace connects without reading the saved selection', async () => { + persistSelection({ sessionId: sid('saved') }) + const b = bench({ + sessions: sessionState([ + summary('first', { blank: true, cwd: '/w/a' }), + summary('saved', { blank: true, cwd: '/w/a' }), + ], 'pending'), + workspaces: workspaceState([workspace('a', [sid('first'), sid('saved')])]), + }) + await expect(b.uiWorkspace.connectWorkspace(wid('a'))).resolves.toBe(sid('first')) + }) + + it.each([ + { archived: true, cwd: '/w/a' }, + { archived: false, cwd: '/other' }, + ])('does not reclaim an ineligible saved blank: %j', async ({ archived, cwd }) => { + persistSelection({ sessionId: sid('saved') }) + const b = bench({ + sessions: sessionState([summary('saved', { blank: true, cwd })]), + workspaces: workspaceState([workspace('a', [sid('saved')])], archived ? [sid('saved')] : []), + }) + await vi.waitFor(() => { + expect(b.sessions.retain).toHaveBeenCalledExactlyOnceWith(sid('created-a'), { source: 'mainView' }) + }) + expect(b.sessions.create).toHaveBeenCalledExactlyOnceWith({ workspaceId: wid('a') }) + }) + + it('restores a saved ungrouped blank directly', () => { + persistSelection({ sessionId: sid('saved') }) + const b = bench({ + sessions: sessionState([summary('saved', { blank: true })]), + workspaces: workspaceState(), + }) + expect(b.sessions.retain).toHaveBeenCalledExactlyOnceWith(sid('saved'), { source: 'mainView' }) + expect(b.sessions.create).not.toHaveBeenCalled() + }) + + it('reclaims the saved blank before opening history, even when another blank is listed first', async () => { + persistSelection({ sessionId: sid('saved') }) + const acquired = Promise.withResolvers() + const b = bench({ + sessions: sessionState([ + summary('other', { blank: true, cwd: '/w/a' }), + summary('saved', { blank: true, cwd: '/w/a' }), + ]), + workspaces: workspaceState([workspace('a', [sid('other'), sid('saved')])]), + configureSessions: (sessions) => { sessions.create.mockReturnValue(acquired.promise) }, + }) + expect(b.sessions.create).toHaveBeenCalledExactlyOnceWith({ workspaceId: wid('a'), sessionId: sid('saved') }) + expect(b.sessions.retain).not.toHaveBeenCalled() + acquired.resolve(sid('saved')) + await vi.waitFor(() => { + expect(b.sessions.retain).toHaveBeenCalledExactlyOnceWith(sid('saved'), { source: 'mainView' }) + }) + }) + + it.each(['saved', 'workspace'])('creates after the selected %s blank is held without trying another blank', async (source) => { + if (source === 'saved') persistSelection({ sessionId: sid('held') }) + const b = bench({ + sessions: sessionState([ + summary('held', { blank: true, cwd: '/w/a' }), + summary('free', { blank: true, cwd: '/w/a' }), + ], source === 'saved' ? 'ready' : 'pending'), + workspaces: workspaceState([workspace('a', [sid('held'), sid('free')])]), + configureSessions: (sessions) => { + sessions.create.mockRejectedValueOnce(new SessionCreateError( + new RemoteError('session/writer-held', 'held', { sessionId: sid('held') }), sid('held'), + )) + }, + }) + if (source === 'saved') { + await vi.waitFor(() => { + expect(b.sessions.retain).toHaveBeenCalledExactlyOnceWith(sid('created-a'), { source: 'mainView' }) + }) + } else { + await expect(b.uiWorkspace.connectWorkspace(wid('a'))).resolves.toBe(sid('created-a')) + } + expect(b.sessions.create.mock.calls).toEqual([ + [{ workspaceId: wid('a'), sessionId: sid('held') }], + [{ workspaceId: wid('a') }], + ]) + }) + + it.each(['workspace', 'panel'])('does not let saved blank restoration replace a later %s navigation', async (target) => { + persistSelection({ sessionId: sid('saved') }) + const acquired = Promise.withResolvers() + const created = Promise.withResolvers() + const acquisitionDone = vi.fn() + const b = bench({ + sessions: sessionState([summary('saved', { blank: true, cwd: '/w/a' })]), + workspaces: workspaceState([workspace('a', [sid('saved')]), workspace('b')]), + configureSessions: (sessions) => { + sessions.create.mockImplementation(options => options?.sessionId === sid('saved') + ? acquired.promise.then((id) => { acquisitionDone(); return id }) : created.promise) + }, + }) + const opening = target === 'workspace' ? b.uiWorkspace.openWorkspace(wid('b')) : undefined + if (target === 'panel') b.layout.selectPanel('other-panel' as MainPanelId) + acquired.resolve(sid('saved')) + await vi.waitFor(() => { expect(acquisitionDone).toHaveBeenCalledOnce() }) + expect(b.sessions.retain).not.toHaveBeenCalled() + created.resolve(sid('created-b')) + await opening + expect(b.sessions.retain.mock.calls.map(([id]) => id)).toEqual(target === 'workspace' ? [sid('created-b')] : []) + }) + + it('coalesces overlapping blank acquisitions before starting history', async () => { + const acquired = Promise.withResolvers() + const b = bench({ + sessions: sessionState([summary('blank', { blank: true, cwd: '/w/a' })], 'pending'), + workspaces: workspaceState([workspace('a', [sid('blank')])]), + configureSessions: (sessions) => { sessions.create.mockReturnValue(acquired.promise) }, + }) + const first = b.uiWorkspace.connectWorkspace(wid('a')) + const second = b.uiWorkspace.connectWorkspace(wid('a')) + expect(b.sessions.create).toHaveBeenCalledOnce() + acquired.resolve(sid('blank')) + await expect(Promise.all([first, second])).resolves.toEqual([sid('blank'), sid('blank')]) + }) + + it.each([ + new Error('filesystem denied'), + 'failed transport', + new SessionCreateError(new RemoteError('gateway/internal', 'read failed', {}), sid('blank')), + ])('does not replace a blank after a non-contention failure: %s', async (error) => { + const b = bench({ + sessions: sessionState([summary('blank', { blank: true, cwd: '/w/a' })], 'pending'), + workspaces: workspaceState([workspace('a', [sid('blank')])]), + configureSessions: (sessions) => { sessions.create.mockRejectedValue(error) }, + }) + await expect(b.uiWorkspace.connectWorkspace(wid('a'))).rejects.toBe(error) + expect(b.sessions.create).toHaveBeenCalledOnce() + expect(b.sessions.retain).not.toHaveBeenCalled() + }) + + it('waits for a new catalog before retrying failed startup blank acquisition', async () => { + persistSelection({ sessionId: sid('blank') }) + const failure = new Error('read failed') + const warning = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const b = bench({ + sessions: sessionState([summary('blank', { blank: true, cwd: '/w/a' })]), + workspaces: workspaceState([workspace('a', [sid('blank')])]), + configureSessions: (sessions) => { sessions.create.mockRejectedValueOnce(failure) }, + }) + await vi.waitFor(() => { + expect(warning).toHaveBeenCalledWith('initial Session restoration failed:', failure) + }) + expect(b.sessions.retain).not.toHaveBeenCalled() + b.sessions.list.set(b.sessions.list.getSnapshot()) + await vi.waitFor(() => { + expect(b.sessions.retain).toHaveBeenCalledExactlyOnceWith(sid('blank'), { source: 'mainView' }) + }) + }) + + it('settles empty startup without creating a Session on later catalog updates', () => { + const b = bench({ sessions: sessionState(), workspaces: workspaceState() }) + b.workspaces.list.set(workspaceState([workspace('a')])) + expect(b.sessions.create).not.toHaveBeenCalled() + }) + + it('clears a selected blank archived by a catalog update without reconnecting', () => { + const b = bench() + b.uiWorkspace.openSession(sid('blank')) + b.workspaces.list.set(workspaceState([workspace('a', [sid('blank')])], [sid('blank')])) + expect(b.sessions.retained[0]!.release).toHaveBeenCalledOnce() + expect(b.sessions.create).not.toHaveBeenCalled() + }) + it('does not let a pending startup Workspace replace a manual Session selection', async () => { const created = Promise.withResolvers() const b = bench({ @@ -538,7 +708,7 @@ describe('UiWorkspaceService', () => { }) }) - it('reports and retries a failed persisted Session restoration', () => { + it('reports and retries a failed persisted Session restoration', async () => { const failure = new Error('restore failed') const warning = vi.spyOn(console, 'warn').mockImplementation(() => undefined) const sessions = sessionState([summary('saved')]) @@ -551,7 +721,9 @@ describe('UiWorkspaceService', () => { }, }) - expect(warning).toHaveBeenCalledWith('initial Session restoration failed:', failure) + await vi.waitFor(() => { + expect(warning).toHaveBeenCalledWith('initial Session restoration failed:', failure) + }) b.sessions.list.set(sessions) expect(b.sessions.retain).toHaveBeenCalledTimes(2) diff --git a/packages/client/web/package.json b/packages/client/web/package.json index 5587505b74..d908cb0837 100644 --- a/packages/client/web/package.json +++ b/packages/client/web/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-web", "description": "Web boot kernel: static module table, Cordis loader, framework-free boot page, and UI-renderer handoff", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/compaction/command-compact/package.json b/packages/compaction/command-compact/package.json index f68e082d3e..985ba09ae1 100644 --- a/packages/compaction/command-compact/package.json +++ b/packages/compaction/command-compact/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-command-compact", "description": "Human-facing slash command for explicit session compaction", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/compaction/compaction-basic/package.json b/packages/compaction/compaction-basic/package.json index 1c77752acb..76f5184d57 100644 --- a/packages/compaction/compaction-basic/package.json +++ b/packages/compaction/compaction-basic/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-compaction-basic", "description": "Token-meter-driven compaction policy and LLM summarization backend for the DeepSeek Harness", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/compaction/compaction-image-offload/package.json b/packages/compaction/compaction-image-offload/package.json index 638e2809a9..0baa3e2d2d 100644 --- a/packages/compaction/compaction-image-offload/package.json +++ b/packages/compaction/compaction-image-offload/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-compaction-image-offload", "description": "Durable image offload for image-capable routes: replace over-budget request images with placeholders and retry", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/compaction/compaction-tool-result-pruner/package.json b/packages/compaction/compaction-tool-result-pruner/package.json index 7f674838de..3b7cc8626f 100644 --- a/packages/compaction/compaction-tool-result-pruner/package.json +++ b/packages/compaction/compaction-tool-result-pruner/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-compaction-tool-result-pruner", "description": "Replay-safe model-free head/middle/tail pruning for tool-result surface nodes", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/compaction/compaction/package.json b/packages/compaction/compaction/package.json index fb871602e9..8be19988bc 100644 --- a/packages/compaction/compaction/package.json +++ b/packages/compaction/compaction/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-compaction", "description": "Abstract compaction service seam (ctx.compaction) for the DeepSeek Harness", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/computer-use/computer-use/package.json b/packages/computer-use/computer-use/package.json index c8a2299fc0..455f361ad4 100644 --- a/packages/computer-use/computer-use/package.json +++ b/packages/computer-use/computer-use/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-computer-use", "description": "Exclusive named computer-use provider registration", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/context/agent-instructions/package.json b/packages/context/agent-instructions/package.json index bb29627236..78b124ed79 100644 --- a/packages/context/agent-instructions/package.json +++ b/packages/context/agent-instructions/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-agent-instructions", "description": "Workspace context loader for AGENTS.md/CLAUDE.md instruction files", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/context/agent-instructions/tests/agent-instructions.spec.ts b/packages/context/agent-instructions/tests/agent-instructions.spec.ts index e40b6f317b..469132f21e 100644 --- a/packages/context/agent-instructions/tests/agent-instructions.spec.ts +++ b/packages/context/agent-instructions/tests/agent-instructions.spec.ts @@ -76,6 +76,7 @@ async function write(path: string, content: string): Promise { } class RecordingFileSystem extends FileSystem { + override watch(): never { throw new Error('Fixture does not support watching') } entries = new Map() missingOnResolve = new Set() throwOnStat = new Set() diff --git a/packages/context/file-reference-local/package.json b/packages/context/file-reference-local/package.json index 01a58ef28f..9bf0158ee4 100644 --- a/packages/context/file-reference-local/package.json +++ b/packages/context/file-reference-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-file-reference-local", "description": "Local-filesystem ctx.fileReferences provider with bounded fuzzy indexes", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/context/file-reference/package.json b/packages/context/file-reference/package.json index 8c703f47d4..f3c3a776ca 100644 --- a/packages/context/file-reference/package.json +++ b/packages/context/file-reference/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-file-reference", "description": "File-reference discovery contract and shared @file grammar", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/context/session-reference/README.i18n.yaml b/packages/context/session-reference/README.i18n.yaml index 7bd0b1d5f8..74c7f4efd6 100644 --- a/packages/context/session-reference/README.i18n.yaml +++ b/packages/context/session-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 packages/context/session-reference/README.md -README.md: 533a1282da920b6daa47ef5be98e49c691a8cb84 -README.zh.md: e7b43c9a56cd5b4062770144ccea0bf125153b40 +README.md: e1afeef9b0e78826705ad2b3e426373b2bf0833d +README.zh.md: e55dab4f6403cb6aee947f80873ffe99919acc5f diff --git a/packages/context/session-reference/README.md b/packages/context/session-reference/README.md index 533a1282da..e1afeef9b0 100644 --- a/packages/context/session-reference/README.md +++ b/packages/context/session-reference/README.md @@ -39,7 +39,7 @@ For a truncated reference, an optional spill backend saves the full captured tex ### Finding sessions to reference -`listCandidates(agent, query?, limit?)` lists sessions other than the agent's own, filters case-insensitively by id, working directory, or the projected title, and ranks same-directory sessions first. Each candidate carries its latest title as the mention label, falling back to the session id when the title is absent or unreadable, and reports whether its working directory is the requesting agent's so a host can surface a location only when it distinguishes the row. Browser consumers call the same discovery as `ctx.remote.sessionReferenceResolver.candidates`, which attaches each candidate's canonical mention. +`listCandidates(agent, query?, limit?)` lists sessions other than the agent's own, filters case-insensitively by id, working directory, projected title, or display title, and ranks same-directory sessions first. Each candidate carries its latest title as `label`, falling back to the session id when the title is absent or unreadable. Its display title prefers a subagent's durable creation label over that title. The candidate also reports whether its working directory is the requesting agent's so a host can surface a location only when it distinguishes the row. Browser consumers call the same discovery as `ctx.remote.sessionReferenceResolver.candidates`, which labels the canonical mention with `displayTitle` when present. ### Configuration diff --git a/packages/context/session-reference/README.zh.md b/packages/context/session-reference/README.zh.md index e7b43c9a56..e55dab4f64 100644 --- a/packages/context/session-reference/README.zh.md +++ b/packages/context/session-reference/README.zh.md @@ -39,7 +39,7 @@ kind: "package-reference" ### 查找可引用的会话 -`listCandidates(agent, query?, limit?)` 列出除 agent 自身外的会话,按 id、工作目录或投影标题做不区分大小写的过滤,并把同目录会话排在前面。每个候选以其最新标题作为 mention 标签;标题缺失或不可读时回退到会话 id,并报告其工作目录是否就是发起方 agent 的工作目录,宿主因此可以只在位置能区分该行时才显示它。浏览器消费方通过 `ctx.remote.sessionReferenceResolver.candidates` 调用同一发现能力,该方法会为每个候选附上规范 mention。 +`listCandidates(agent, query?, limit?)` 列出除 agent 自身外的会话,按 id、工作目录、投影标题或显示标题做不区分大小写的过滤,并把同目录会话排在前面。每个候选的 `label` 使用最新标题;标题缺失或不可读时回退到会话 id。显示标题优先使用 subagent 的持久创建 label,再回退到该标题。候选还会报告其工作目录是否就是发起方 agent 的工作目录,宿主因此可以只在位置能区分该行时才显示它。浏览器消费方通过 `ctx.remote.sessionReferenceResolver.candidates` 调用同一发现能力;该方法在 `displayTitle` 存在时用它标记规范 mention。 ### 配置 diff --git a/packages/context/session-reference/package.json b/packages/context/session-reference/package.json index 3b44e78d1f..221fdb8e6e 100644 --- a/packages/context/session-reference/package.json +++ b/packages/context/session-reference/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-reference", "description": "Cross-session snapshot references and durable untrusted model context (ctx.sessionReferenceResolver)", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, @@ -61,6 +61,7 @@ "@deepseek-ai/dsh-session-query": "workspace:^", "@deepseek-ai/dsh-session-title": "workspace:^", "@deepseek-ai/dsh-spill": "workspace:^", + "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^" }, @@ -88,6 +89,7 @@ "@deepseek-ai/dsh-session-query-sqlite": "workspace:^", "@deepseek-ai/dsh-session-title": "workspace:^", "@deepseek-ai/dsh-spill": "workspace:^", + "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-spill-local": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tool-fs": "workspace:^", diff --git a/packages/context/session-reference/src/index.ts b/packages/context/session-reference/src/index.ts index 2ac9980a90..848e4ddc15 100644 --- a/packages/context/session-reference/src/index.ts +++ b/packages/context/session-reference/src/index.ts @@ -18,6 +18,7 @@ import type { SessionId } from '@deepseek-ai/dsh-session' import type { ProjectionSnapshot } from '@deepseek-ai/dsh-session-projection' import type {} from '@deepseek-ai/dsh-session-projection-cache' import type {} from '@deepseek-ai/dsh-session-title' +import type {} from '@deepseek-ai/dsh-subagent' import type {} from '@deepseek-ai/dsh-system-prompt' import type { SessionRecord, SessionSurfaceSnapshot } from '@deepseek-ai/dsh-session-query' import { prepareReferenceOmission, REFERENCE_WARNING } from './spill.ts' @@ -179,14 +180,13 @@ export class SessionReferenceResolver extends TypertRemoteService { /** * List reference candidates, ranked by working-directory affinity. * - * Discovery runs at keystroke rate, so a title only ever comes from a - * projection read: see {@link SessionReferenceResolver.projectedTitle} for - * which sessions can answer one and which fall back to their id. + * Discovery runs at keystroke rate, so titles and subagent labels only ever + * come from projection reads; sessions without either fall back to their id. * @param agent - target agent; self is excluded and its cwd drives ranking. - * @param query - optional case-insensitive session-id/cwd/title substring. + * @param query - optional case-insensitive session-id/cwd/title/display-title substring. * @param limit - optional positive result cap. * @param signal - optional cancellation boundary for host autocomplete teardown. - * @returns candidates labeled by latest title or, when absent, session id. + * @returns candidates with canonical mention labels and presentation titles. */ async listCandidates( agent: Agent, @@ -203,22 +203,20 @@ export class SessionReferenceResolver extends TypertRemoteService { const records = (await settleWithCancellation(this.ctx.sessionQuery.listSessions(signal), signal)) .filter(record => record.header.id !== agent.id) .map((record, index) => ({ record, index })) - const labelled = records.map(({ record, index }) => ({ - record, - index, - label: this.projectedTitle(record) ?? record.header.id, - })) - return labelled.filter(({ record, label }) => { + const labelled = records.map(({ record, index }) => ({ record, index, ...this.projectedLabels(record) })) + return labelled.filter(({ record, label, displayTitle }) => { if (needle === '') return true return record.header.id.toLocaleLowerCase().includes(needle) || record.header.cwd?.toLocaleLowerCase().includes(needle) === true || label.toLocaleLowerCase().includes(needle) + || displayTitle.toLocaleLowerCase().includes(needle) }).sort((a, b) => candidateRank(a.record.header.cwd, targetCwd) - candidateRank(b.record.header.cwd, targetCwd) || a.index - b.index) .slice(0, limit) - .map(({ record, label }) => ({ + .map(({ record, label, displayTitle }) => ({ sessionId: record.header.id, label, + displayTitle, ...record.header.cwd === undefined ? {} : { cwd: record.header.cwd }, sameWorkspace: record.header.cwd !== undefined && record.header.cwd === targetCwd, createdAt: record.header.createdAt, @@ -226,7 +224,7 @@ export class SessionReferenceResolver extends TypertRemoteService { } /** - * The title a session's projections can answer without reading its log. + * The mention label and display title a Session's projections can answer without reading its log. * * Attachment is decided by the store at read time, not by the listing: * a session that attached in between would otherwise be answered from a @@ -245,20 +243,28 @@ export class SessionReferenceResolver extends TypertRemoteService { * and cannot be found by its title until it is opened once, which * checkpoints it. * @param record - the listed session, live or cold. - * @returns the projected title, or undefined when no projection holds one. + * @returns the title-backed mention label and the subagent-label-first display title. */ - private projectedTitle(record: SessionRecord): string | undefined { + private projectedLabels(record: SessionRecord): { label: string; displayTitle: string } { const attached = this.ctx.get('sessions')?.get(record.header.id) const projections = this.ctx.get('sessionProjections') - if (attached !== undefined && projections !== undefined) { - return titleOf(projections.snapshot(attached, ['title'])) + const snapshot = attached !== undefined && projections !== undefined + ? projections.snapshot(attached, ['title', 'subagent']) + : record.header.isSeeded + ? undefined + : this.ctx.get('sessionProjectionCache')?.cachedSnapshot( + record.header, + SessionLogOffset(0), + ['title', 'subagent'], + ) + const label = titleOf(snapshot) ?? record.header.id + const subagent = snapshot?.values.subagent + return { + label, + displayTitle: subagent === undefined || subagent === null + ? label + : subagent.label ?? label, } - if (record.header.isSeeded) return undefined - return titleOf(this.ctx.get('sessionProjectionCache')?.cachedSnapshot( - record.header, - SessionLogOffset(0), - ['title'], - )) } /** @@ -279,7 +285,10 @@ export class SessionReferenceResolver extends TypertRemoteService { const candidates = await this.listCandidates(agent, query, this.config.candidateLimit, signal) return candidates.map(candidate => ({ ...candidate, - mention: formatSessionReferenceMention({ sessionId: candidate.sessionId, label: candidate.label }), + mention: formatSessionReferenceMention({ + sessionId: candidate.sessionId, + label: candidate.displayTitle ?? candidate.label, + }), })) } diff --git a/packages/context/session-reference/src/types.ts b/packages/context/session-reference/src/types.ts index 944ed6898d..3fdfdf8de5 100644 --- a/packages/context/session-reference/src/types.ts +++ b/packages/context/session-reference/src/types.ts @@ -51,6 +51,8 @@ export interface SessionReferenceCandidate { sessionId: SessionId /** Latest log-backed title, falling back to the opaque session id. */ label: string + /** Display and canonical-mention text, preferring a subagent's durable creation label over {@link label}. */ + displayTitle?: string /** Source session working directory, when recorded. */ cwd?: string /** diff --git a/packages/context/session-reference/tests/session-reference.spec.ts b/packages/context/session-reference/tests/session-reference.spec.ts index dc2caa5067..98da2c861b 100644 --- a/packages/context/session-reference/tests/session-reference.spec.ts +++ b/packages/context/session-reference/tests/session-reference.spec.ts @@ -54,11 +54,26 @@ async function harness(config: Config = {}): Promise { * resolver reads `cachedSnapshot` alone, and the point under test is which * sessions still reach a log fold. */ -function withProjectionCache(ctx: Context, rows: Record): void { +function withProjectionCache( + ctx: Context, + rows: Record, +): void { ctx.provide('sessionProjectionCache', { - cachedSnapshot: (meta: { id: SessionId }) => ( - meta.id in rows ? { asOfSeq: SessionSeq(0), values: { title: rows[meta.id] } } : undefined - ), + cachedSnapshot: (meta: { id: SessionId }) => { + const row = rows[meta.id] + if (!(meta.id in rows)) return undefined + return { + asOfSeq: SessionSeq(0), + values: row !== null && typeof row === 'object' ? row : { title: row }, + } + }, }) } @@ -633,16 +648,16 @@ describe('session reference discovery and preparation', () => { }) await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target))).resolves.toEqual([ - { sessionId: SessionId('same-later'), label: 'Latest title', cwd: '/same', sameWorkspace: true, createdAt: 25 }, - { sessionId: SessionId('same'), label: 'same', cwd: '/same', sameWorkspace: true, createdAt: 20 }, - { sessionId: SessionId('none'), label: 'none', sameWorkspace: false, createdAt: 30 }, - { sessionId: SessionId('other'), label: 'other', cwd: '/else', sameWorkspace: false, createdAt: 40 }, + { sessionId: SessionId('same-later'), label: 'Latest title', displayTitle: 'Latest title', cwd: '/same', sameWorkspace: true, createdAt: 25 }, + { sessionId: SessionId('same'), label: 'same', displayTitle: 'same', cwd: '/same', sameWorkspace: true, createdAt: 20 }, + { sessionId: SessionId('none'), label: 'none', displayTitle: 'none', sameWorkspace: false, createdAt: 30 }, + { sessionId: SessionId('other'), label: 'other', displayTitle: 'other', cwd: '/else', sameWorkspace: false, createdAt: 40 }, ]) await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target), 'els', 1)).resolves.toEqual([ - { sessionId: SessionId('other'), label: 'other', cwd: '/else', sameWorkspace: false, createdAt: 40 }, + { sessionId: SessionId('other'), label: 'other', displayTitle: 'other', cwd: '/else', sameWorkspace: false, createdAt: 40 }, ]) await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target), 'LATEST', 1)).resolves.toEqual([ - { sessionId: SessionId('same-later'), label: 'Latest title', cwd: '/same', sameWorkspace: true, createdAt: 25 }, + { sessionId: SessionId('same-later'), label: 'Latest title', displayTitle: 'Latest title', cwd: '/same', sameWorkspace: true, createdAt: 25 }, ]) await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target), '', 0)) .rejects.toThrow(expectCode('SESSION_REFERENCE_INVALID_REFERENCE')) @@ -675,7 +690,7 @@ describe('session reference discovery and preparation', () => { await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target), 'renamed')) .resolves.toEqual([ - { sessionId: live.id, label: 'Renamed mid turn', cwd: '/same', sameWorkspace: true, createdAt: live.header.createdAt }, + { sessionId: live.id, label: 'Renamed mid turn', displayTitle: 'Renamed mid turn', cwd: '/same', sameWorkspace: true, createdAt: live.header.createdAt }, ]) await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target), 'old title')).resolves.toEqual([]) expect(readTitles).not.toHaveBeenCalled() @@ -694,12 +709,66 @@ describe('session reference discovery and preparation', () => { await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target), 'checkpoint')) .resolves.toEqual([ - { sessionId: cold.id, label: 'Cold checkpoint', cwd: '/same', sameWorkspace: true, createdAt: 10 }, + { sessionId: cold.id, label: 'Cold checkpoint', displayTitle: 'Cold checkpoint', cwd: '/same', sameWorkspace: true, createdAt: 10 }, ]) expect(readTitles).not.toHaveBeenCalled() vi.restoreAllMocks() }) + it('keeps the projected title as the mention label and prefers the subagent label for display', async () => { + const ctx = await harness() + const target = ctx.sessions.create(SessionId('target'), { meta: { cwd: '/same' } }) + const child = { id: SessionId('child'), createdAt: 10, cwd: '/same' } + withProjectionCache(ctx, { + child: { + title: 'Investigate startup', + subagent: { mode: 'continuable', label: 'researcher', seq: SessionSeq(2) }, + }, + }) + vi.spyOn(ctx.sessionQuery, 'listSessions').mockResolvedValue([ + { header: child, live: false, persisted: true }, + ] as never) + + await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target), 'researcher')) + .resolves.toEqual([{ + sessionId: child.id, + label: 'Investigate startup', + displayTitle: 'researcher', + cwd: '/same', + sameWorkspace: true, + createdAt: 10, + }]) + await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target), 'investigate')) + .resolves.toHaveLength(1) + await expect(ctx.sessionReferenceResolver.remoteExportCandidates( + fakeAgent(target), 'researcher', new AbortController().signal, + )).resolves.toEqual([expect.objectContaining({ + mention: formatSessionReferenceMention({ sessionId: child.id, label: 'researcher' }), + })]) + }) + + it('falls back to the projected title when a subagent projection has no label', async () => { + const ctx = await harness() + const target = ctx.sessions.create(SessionId('target'), { meta: { cwd: '/same' } }) + const child = { id: SessionId('child'), createdAt: 10, cwd: '/same' } + withProjectionCache(ctx, { + child: { + title: 'Projected title', + subagent: { mode: 'continuable', seq: SessionSeq(2) }, + }, + }) + vi.spyOn(ctx.sessionQuery, 'listSessions').mockResolvedValue([ + { header: child, live: false, persisted: true }, + ] as never) + + await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target), 'child')) + .resolves.toEqual([expect.objectContaining({ + sessionId: child.id, + label: 'Projected title', + displayTitle: 'Projected title', + })]) + }) + it('labels a session no projection answers for by its id, still without a log read', async () => { const ctx = await harness() const target = ctx.sessions.create(SessionId('target'), { meta: { cwd: '/same' } }) @@ -718,7 +787,7 @@ describe('session reference discovery and preparation', () => { const readTitles = vi.spyOn(ctx.sessionQuery, 'readTitleSnapshots') await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target))).resolves.toEqual([ - { sessionId: seeded.id, label: seeded.id, cwd: '/same', sameWorkspace: true, createdAt: 10 }, + { sessionId: seeded.id, label: seeded.id, displayTitle: seeded.id, cwd: '/same', sameWorkspace: true, createdAt: 10 }, ]) // Its own title cannot find it, and discovery still never opens the log. await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target), 'anything')).resolves.toEqual([]) @@ -736,7 +805,7 @@ describe('session reference discovery and preparation', () => { other.append('session/title', { title: 'Unreadable', messageSeqs: [], source: { kind: 'fallback' } }) await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target))).resolves.toEqual([ - { sessionId: other.id, label: other.id, cwd: '/same', sameWorkspace: true, createdAt: other.header.createdAt }, + { sessionId: other.id, label: other.id, displayTitle: other.id, cwd: '/same', sameWorkspace: true, createdAt: other.header.createdAt }, ]) }) @@ -752,11 +821,24 @@ describe('session reference discovery and preparation', () => { expect(candidates).toEqual([{ sessionId: SessionId('source]'), label: 'source]', + displayTitle: 'source]', cwd: '/same', sameWorkspace: true, createdAt: 20, mention: formatSessionReferenceMention({ sessionId: SessionId('source]'), label: 'source]' }), }]) + + vi.spyOn(ctx.sessionReferenceResolver, 'listCandidates').mockResolvedValueOnce([{ + sessionId: SessionId('fallback'), + label: 'Fallback title', + sameWorkspace: false, + createdAt: 30, + }]) + await expect(ctx.sessionReferenceResolver.remoteExportCandidates( + fakeAgent(target), '', new AbortController().signal, + )).resolves.toEqual([expect.objectContaining({ + mention: formatSessionReferenceMention({ sessionId: SessionId('fallback'), label: 'Fallback title' }), + })]) }) it('prepares direct mentions at pre-step and keeps ordinary and plugin messages unchanged', async () => { @@ -842,7 +924,7 @@ describe('session reference discovery and preparation', () => { const source = ctx.sessions.create(SessionId('source')) await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target), 'source')).resolves.toEqual([ - { sessionId: source.id, label: source.id, sameWorkspace: false, createdAt: source.header.createdAt }, + { sessionId: source.id, label: source.id, displayTitle: source.id, sameWorkspace: false, createdAt: source.header.createdAt }, ]) }) diff --git a/packages/context/session-reference/tsconfig.json b/packages/context/session-reference/tsconfig.json index 1f57f73147..590eb4122a 100644 --- a/packages/context/session-reference/tsconfig.json +++ b/packages/context/session-reference/tsconfig.json @@ -49,6 +49,9 @@ }, { "path": "../../session/session-title" + }, + { + "path": "../../subagent/subagent" } ] } diff --git a/packages/context/time-context/package.json b/packages/context/time-context/package.json index f97126fc36..59d2912e29 100644 --- a/packages/context/time-context/package.json +++ b/packages/context/time-context/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-time-context", "description": "Opt-in durable per-step context with the current time and elapsed time", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/context/tmux-context/package.json b/packages/context/tmux-context/package.json index 8a9d88b5c1..464ea636f7 100644 --- a/packages/context/tmux-context/package.json +++ b/packages/context/tmux-context/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tmux-context", "description": "Opt-in durable per-step context with this agent's tmux pane and window location", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/core/agent-default-model/package.json b/packages/core/agent-default-model/package.json index cf87be5d32..e3f2e28cff 100644 --- a/packages/core/agent-default-model/package.json +++ b/packages/core/agent-default-model/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-agent-default-model", "description": "Default model selection shared by Agent entry points", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/core/agent-loop/package.json b/packages/core/agent-loop/package.json index 1c23ad9719..e6748484f7 100644 --- a/packages/core/agent-loop/package.json +++ b/packages/core/agent-loop/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-agent-loop", "description": "The concrete agent loop plugin for the DeepSeek Harness", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/core/agent-tool-presentation/package.json b/packages/core/agent-tool-presentation/package.json index 1e0b0d6b31..8e29edb2ea 100644 --- a/packages/core/agent-tool-presentation/package.json +++ b/packages/core/agent-tool-presentation/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-agent-tool-presentation", "description": "Agent-plane presentation selector: composes one agent's tools as PTC mode, native, or both", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/core/agent/package.json b/packages/core/agent/package.json index f3f72b360e..4a0895d9d4 100644 --- a/packages/core/agent/package.json +++ b/packages/core/agent/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-agent", "description": "Agent interface, registry, initiator scope, and event vocabulary for the DeepSeek Harness", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/core/scope/package.json b/packages/core/scope/package.json index 7503f8c406..2f4656b3bf 100644 --- a/packages/core/scope/package.json +++ b/packages/core/scope/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-scope", "description": "Scoped-context registration primitive (scope tags, scope-filtered event dispatch) for the DeepSeek Harness", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/core/session/package.json b/packages/core/session/package.json index 0ed5dff091..f0e27221a1 100644 --- a/packages/core/session/package.json +++ b/packages/core/session/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session", "description": "Event-sourced session store for the DeepSeek Harness", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/core/system-prompt/package.json b/packages/core/system-prompt/package.json index c07bca72d7..4d430d9ea6 100644 --- a/packages/core/system-prompt/package.json +++ b/packages/core/system-prompt/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-system-prompt", "description": "System prompt assembly registry for the DeepSeek Harness", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/core/tools/package.json b/packages/core/tools/package.json index c54b96c786..041bf5570b 100644 --- a/packages/core/tools/package.json +++ b/packages/core/tools/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tools", "description": "Tool registry and execution pipeline for the DeepSeek Harness", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/credentials/authorization/package.json b/packages/credentials/authorization/package.json index 947f1cb2d1..d74bce9de0 100644 --- a/packages/credentials/authorization/package.json +++ b/packages/credentials/authorization/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-authorization", "description": "Authorization seam (ctx.authorization): plugin-owned flows that obtain a credential through a conversation with the human", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/credentials/credentials-local/package.json b/packages/credentials/credentials-local/package.json index 71c423ad56..13affc9457 100644 --- a/packages/credentials/credentials-local/package.json +++ b/packages/credentials/credentials-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-credentials-local", "description": "File-backed credentials provider ($DSH_HOME/.env under the live process environment) for the DeepSeek Harness", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/credentials/credentials/package.json b/packages/credentials/credentials/package.json index f64cf8c9d5..068fc157c7 100644 --- a/packages/credentials/credentials/package.json +++ b/packages/credentials/credentials/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-credentials", "description": "Abstract credential seam (ctx.credentials): settings carry references to secrets, providers own the values", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/deliverables/tool-present/README.i18n.yaml b/packages/deliverables/tool-present/README.i18n.yaml index a40c948a43..90a7c23e7a 100644 --- a/packages/deliverables/tool-present/README.i18n.yaml +++ b/packages/deliverables/tool-present/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/deliverables/tool-present/README.md -README.md: 93003711545f678336a02c9bf8589176f844af65 -README.zh.md: 161efcc3931fbf57175064ccf1179b195320d4b9 +README.md: a7eb9e7cf9060472258666de186a7ecefe7aa407 +README.zh.md: 5ba4ab5f100517708d80e4860d9e5eed0f43a801 diff --git a/packages/deliverables/tool-present/README.md b/packages/deliverables/tool-present/README.md index 9300371154..a7eb9e7cf9 100644 --- a/packages/deliverables/tool-present/README.md +++ b/packages/deliverables/tool-present/README.md @@ -73,7 +73,7 @@ The pure `./types` entry declares `PresentedFile` and the Session event without #### What the model sees -The [present schema](../../../docs/tool-catalog.md#present) asks for existing accessible files: “Declare existing files accessible through the Session filesystem as final deliverables. When a file you create or update is an output the user asked to receive, you must call present after writing it and before your final response, including files created through Bash or code execution. Mentioning its path in your reply does not replace this call. The files must already exist. The user opens the current source files; their contents are not copied or preserved.” Results report `Presented ` for each file; the program result and durable event contain paths and optional descriptions. +The [present schema](../../../docs/tool-catalog.md#present) asks for existing accessible files: “Declare selected existing files accessible through the Session filesystem as final deliverables. Use present when the user needs a separate file deliverable, especially Office documents, spreadsheets, and slide decks. Prefer showing results in your final response when that is sufficient; creating or editing a file does not by itself require present. Usually select the 1-2 most important deliverables; include more when needed, but at most 4 files in a single present call. The files must already exist. The user opens the current source files; their contents are not copied or preserved.” Results report `Presented ` for each file; the program result and durable event contain paths and optional descriptions. #### Token effect diff --git a/packages/deliverables/tool-present/README.zh.md b/packages/deliverables/tool-present/README.zh.md index 161efcc393..5ba4ab5f10 100644 --- a/packages/deliverables/tool-present/README.zh.md +++ b/packages/deliverables/tool-present/README.zh.md @@ -73,7 +73,7 @@ kind: "package-reference" #### 模型看到的内容 -[present schema](../../../docs/tool-catalog.zh.md#present)要求已有且可访问的文件:“Declare existing files accessible through the Session filesystem as final deliverables. When a file you create or update is an output the user asked to receive, you must call present after writing it and before your final response, including files created through Bash or code execution. Mentioning its path in your reply does not replace this call. The files must already exist. The user opens the current source files; their contents are not copied or preserved.” 每个文件的结果为 `Presented `;程序结果和持久事件包含路径及可选说明。 +[present schema](../../../docs/tool-catalog.zh.md#present)要求已有且可访问的文件:“Declare selected existing files accessible through the Session filesystem as final deliverables. Use present when the user needs a separate file deliverable, especially Office documents, spreadsheets, and slide decks. Prefer showing results in your final response when that is sufficient; creating or editing a file does not by itself require present. Usually select the 1-2 most important deliverables; include more when needed, but at most 4 files in a single present call. The files must already exist. The user opens the current source files; their contents are not copied or preserved.” 每个文件的结果为 `Presented `;程序结果和持久事件包含路径及可选说明。 #### Token 影响 diff --git a/packages/deliverables/tool-present/package.json b/packages/deliverables/tool-present/package.json index 3acdd62f61..f50b3e820f 100644 --- a/packages/deliverables/tool-present/package.json +++ b/packages/deliverables/tool-present/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-present", "description": "Explicit workspace file delivery declarations for the DeepSeek Harness", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/deliverables/tool-present/src/index.ts b/packages/deliverables/tool-present/src/index.ts index 626006e17b..d355fc1a6b 100644 --- a/packages/deliverables/tool-present/src/index.ts +++ b/packages/deliverables/tool-present/src/index.ts @@ -37,10 +37,11 @@ export function apply(ctx: Context, config: Config): void { const pending = new WeakMap() ctx.tools.register(defineTool({ name: 'present', - description: 'Declare existing files accessible through the Session filesystem as final deliverables. ' - + 'When a file you create or update is an output the user asked to receive, you must call present after writing it and before your final response, including files created through Bash or code execution. ' - + 'Mentioning its path in your reply does not replace this call. The files must already exist. ' - + 'The user opens the current source files; their contents are not copied or preserved.', + description: 'Declare selected existing files accessible through the Session filesystem as final deliverables. ' + + 'Use present when the user needs a separate file deliverable, especially Office documents, spreadsheets, and slide decks. ' + + 'Prefer showing results in your final response when that is sufficient; creating or editing a file does not by itself require present. ' + + 'Usually select the 1-2 most important deliverables; include more when needed, but at most 4 files in a single present call. ' + + 'The files must already exist. The user opens the current source files; their contents are not copied or preserved.', parameters: { files: { type: 'array', required: true, diff --git a/packages/deliverables/workspace-changes/README.i18n.yaml b/packages/deliverables/workspace-changes/README.i18n.yaml index aaf8176288..bb8403671e 100644 --- a/packages/deliverables/workspace-changes/README.i18n.yaml +++ b/packages/deliverables/workspace-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 packages/deliverables/workspace-changes/README.md -README.md: 06b40006750cb6fb8cad614af9c275f9b7243e02 -README.zh.md: 8c49cfebb8cf79117c55150d8cc4b0de8ba3ac3a +README.md: 168a30cd993c4b53a34785dd6f4e31bd0f007d8a +README.zh.md: 42a96ffdd72efc6fcfee11eb973fa687021d6a0b diff --git a/packages/deliverables/workspace-changes/README.md b/packages/deliverables/workspace-changes/README.md index 06b4000675..168a30cd99 100644 --- a/packages/deliverables/workspace-changes/README.md +++ b/packages/deliverables/workspace-changes/README.md @@ -57,7 +57,7 @@ Each file carries a durable `path` — relative to the working directory inside One `TurnRecorder` per Session serializes its git work. `turn/start` queues the baseline: `rev-parse` locates the repository once per Session and the Session's temporary object directory is created, then `add --all --ignore-errors` into a temporary index seeded from the repository's index and `write-tree` produce the tree id; an unreadable file is skipped and reported through git's exit code 1, which the snapshot accepts. Each turn holds its own state object, so a record still running for an interrupted turn keeps that turn's files when the next turn starts. Every command runs with `GIT_OBJECT_DIRECTORY` pointing at the temporary directory and `GIT_ALTERNATE_OBJECT_DIRECTORIES` at the repository's objects, so committed content is read from the repository and new objects never land there. Every `tools/pre-execute` waits for that queue before a tool runs, so no mutation can precede its baseline; the same step queues the whole-file capture of the path a `write`, `edit`, or mutating `str_replace_editor` call names, so the copy precedes the edit, and `tool/result` events only mark that the turn has results to record. `agent/turn-stopping` records inside the turn: a second snapshot, `diff-tree -r -M --numstat` between the two trees, `check-ignore` for captured paths inside the work tree, a second copy and line comparison of each uncovered path, the appended event, and the summary kept under the event's sequence beside each listed file's two content sources, a path in a snapshot tree or a copy. A comparison is computed when asked for: `ls-tree -l` locates and sizes a snapshot side and `cat-file blob` reads it under `maxFileBytes`, copies are read from disk, and both sides go through the same line comparison with its timeout. `turn/end` records again only when tool results settled after the last record attempt, which covers aborted, failed, and steered turns without repeating a failed attempt; an empty list after an earlier record supersedes it. The repository's index is only read. -Git runs through the `subprocess` capability with a scrubbed environment, `GIT_TERMINAL_PROMPT=0`, `GIT_OPTIONAL_LOCKS=0`, the configured timeout, and bounded output. A failing step abandons that turn's record with a warning; the next turn starts afresh. Session disposal and plugin disposal abort queued work, forget the summaries, and remove the temporary directory. +Git runs through the `subprocess` capability with a scrubbed environment, `GIT_CONFIG_COUNT=0` (ambient indexed configuration is excluded because the credential scrub removes its key entries), `GIT_TERMINAL_PROMPT=0`, `GIT_OPTIONAL_LOCKS=0`, the configured timeout, and bounded output. A failing step abandons that turn's record with a warning; the next turn starts afresh. Session disposal and plugin disposal abort queued work, forget the summaries, and remove the temporary directory. **Runtime invariant:** No companion is published. Event listeners are effect-owned and the recorder owns the summaries, the snapshot trees, and the captured copies for its Session's lifetime; no independent observation can diverge from them. diff --git a/packages/deliverables/workspace-changes/README.zh.md b/packages/deliverables/workspace-changes/README.zh.md index 8c49cfebb8..42a96ffdd7 100644 --- a/packages/deliverables/workspace-changes/README.zh.md +++ b/packages/deliverables/workspace-changes/README.zh.md @@ -57,7 +57,7 @@ kind: "package-reference" 每个 Session 一个 `TurnRecorder`,串行化其 git 工作。`turn/start` 排入基线:`rev-parse` 每个 Session 只定位一次仓库并创建 Session 的临时对象目录,然后以仓库 index 为种子在临时 index 上执行 `add --all --ignore-errors` 与 `write-tree` 得到 tree id;不可读的文件被跳过并以 git 的退出码 1 报告,快照接受这个退出码。每轮持有自己的状态对象,因此被中断的轮次仍在运行的记录会在下一轮开始后保留自己那一轮的文件。每条命令都带 `GIT_OBJECT_DIRECTORY` 指向临时目录、`GIT_ALTERNATE_OBJECT_DIRECTORIES` 指向仓库的 objects,因此已提交内容从仓库读取,新对象不会落进仓库。每次 `tools/pre-execute` 都等待该队列,因此没有修改能先于其基线发生;同一步骤还排入对 `write`、`edit` 或有修改作用的 `str_replace_editor` 调用所指路径的整文件捕获,因此副本先于编辑,`tool/result` 事件只标记本轮有结果需要记录。`agent/turn-stopping` 在轮内记录:第二次快照、两棵树之间的 `diff-tree -r -M --numstat`、对工作树内已捕获路径的 `check-ignore`、对每个未覆盖路径的第二次复制与逐行对比、追加事件,以及按事件序号保存摘要和每个所列文件两侧的内容来源,即快照树中的路径或一份副本。对比在被请求时计算:`ls-tree -l` 定位快照一侧并取其大小,`cat-file blob` 在 `maxFileBytes` 之内读出,副本从磁盘读取,两侧随后走同一个带超时的逐行对比。`turn/end` 仅在最后一次记录尝试之后仍有工具结果结束时再次记录,这覆盖了中止、失败和被转向的轮次,且不会重复一次失败的尝试;早先记录之后的空列表会取代它。仓库的 index 只读取。 -git 通过 `subprocess` 能力运行,使用净化后的环境、`GIT_TERMINAL_PROMPT=0`、`GIT_OPTIONAL_LOCKS=0`、配置的超时与有界输出。任何步骤失败都会放弃本轮记录并给出警告;下一轮重新开始。Session 释放与插件释放会中止排队的工作、忘记摘要并删除临时目录。 +git 通过 `subprocess` 能力运行,使用净化后的环境、`GIT_CONFIG_COUNT=0`(凭据清理会移除索引配置的键,因此不继承这些环境配置)、`GIT_TERMINAL_PROMPT=0`、`GIT_OPTIONAL_LOCKS=0`、配置的超时与有界输出。任何步骤失败都会放弃本轮记录并给出警告;下一轮重新开始。Session 释放与插件释放会中止排队的工作、忘记摘要并删除临时目录。 **运行时不变式:** 不发布伴生入口。事件监听归 effect 所有,记录器在其 Session 存活期间同时拥有摘要、快照树与捕获的副本;没有独立观察会与它们分歧。 diff --git a/packages/deliverables/workspace-changes/package.json b/packages/deliverables/workspace-changes/package.json index 1b2878e306..f1b7b87afc 100644 --- a/packages/deliverables/workspace-changes/package.json +++ b/packages/deliverables/workspace-changes/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-workspace-changes", "description": "Per-turn workspace file changes recorded from git working-tree snapshots and whole-file captures, with per-file comparisons, for the DeepSeek Harness", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/deliverables/workspace-changes/src/git.ts b/packages/deliverables/workspace-changes/src/git.ts index c49ad66897..3766a2f082 100644 --- a/packages/deliverables/workspace-changes/src/git.ts +++ b/packages/deliverables/workspace-changes/src/git.ts @@ -65,7 +65,8 @@ export class GitRunner { }, graceMs: TERMINATE_GRACE_MS, signal, - env: { GIT_TERMINAL_PROMPT: '0', GIT_OPTIONAL_LOCKS: '0', LC_ALL: 'C', ...options.env }, + // The subprocess credential scrub removes ambient GIT_CONFIG_KEY_n entries. + env: { GIT_CONFIG_COUNT: '0', GIT_TERMINAL_PROMPT: '0', GIT_OPTIONAL_LOCKS: '0', LC_ALL: 'C', ...options.env }, }) const outcome = await handle.done if (signal.aborted) { diff --git a/packages/deliverables/workspace-changes/tests/git.spec.ts b/packages/deliverables/workspace-changes/tests/git.spec.ts index 35982a681b..ea7948939f 100644 --- a/packages/deliverables/workspace-changes/tests/git.spec.ts +++ b/packages/deliverables/workspace-changes/tests/git.spec.ts @@ -1,7 +1,7 @@ /** Git command bounds, snapshot recovery, and diff failure reporting. */ import { chmod, mkdir, readdir, readFile, realpath, writeFile } from 'node:fs/promises' import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' import { GitRunner, blobText, diffTrees, ignoredPaths, locateGitWorkspace, snapshotTree, treeBlob } from '../src/git.ts' @@ -29,6 +29,22 @@ async function runner(limits = { timeoutMs: 30_000, outputMaxBytes: 1024 * 1024 const signal = new AbortController().signal describe('GitRunner', () => { + it('ignores ambient indexed Git configuration after the credential scrub', async () => { + const cwd = await scratchDir('dsh-git-env-', cleanups) + git(cwd, 'init', '-q', '-b', 'main') + const { git: command } = await runner() + vi.stubEnv('GIT_CONFIG_COUNT', '1') + vi.stubEnv('GIT_CONFIG_KEY_0', 'core.bare') + vi.stubEnv('GIT_CONFIG_VALUE_0', 'true') + try { + const result = await command.run(['rev-parse', '--is-bare-repository'], { cwd, signal }) + expect(result.exitCode, result.stderr).toBe(0) + expect(result.stdout.trim()).toBe('false') + } finally { + vi.unstubAllEnvs() + } + }) + it('reports timeouts and external aborts as failures', async () => { const cwd = await scratchDir('dsh-git-runner-', cleanups) const { git: slow } = await runner({ timeoutMs: 1, outputMaxBytes: 1024 }) diff --git a/packages/document/office-to-pdf/package.json b/packages/document/office-to-pdf/package.json index 2acc567ba9..f6eb50a443 100644 --- a/packages/document/office-to-pdf/package.json +++ b/packages/document/office-to-pdf/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-office-to-pdf", "description": "Shared Office-to-PDF conversion with bounded queues and caching", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/experimental/README.i18n.yaml b/packages/experimental/README.i18n.yaml index fba483caa6..af98f96967 100644 --- a/packages/experimental/README.i18n.yaml +++ b/packages/experimental/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/experimental/README.md -README.md: 6bb562cf21703fb153c04d9fc839610563bba6cf -README.zh.md: c586a3a89611bfc78587c2b9a48245d79e67515f +README.md: f9fa7be744ff84993373b0cb15451e1a5d5641a9 +README.zh.md: 58e7d1614f8ab200e0dad8ebb53122b371b5430d diff --git a/packages/experimental/README.md b/packages/experimental/README.md index 6bb562cf21..f9fa7be744 100644 --- a/packages/experimental/README.md +++ b/packages/experimental/README.md @@ -24,9 +24,8 @@ The experimental group contains prototype capabilities whose contracts can chang | Package | Role | ctx key | |---|---|---| -| [`agent-team-profile`](agent-team-profile/README.md) | Published opt-in profile layer for Agent Teams | — | +| [`agent-team-profile`](agent-team-profile/README.md) | Agent Teams collaboration, tools, and Web UI bundle | — | | [`agent-team`](agent-team/README.md) | Named teammates with durable messages and a shared task board | `ctx.agentTeams` | -| [`agent-team-web-profile`](agent-team-web-profile/README.md) | Published opt-in Web layer for Agent Teams | — | | [`client-ui-agent-team`](client-ui-agent-team/README.md) | Team roster, task board, and teammate navigation for Web | — | | [`auto-review`](auto-review/README.md) | Explicit Web layer for same-model review before each native or PTC inner tool call | — | | [`ptc-runtime-python`](ptc-runtime-python/README.md) | CPython subprocess backend for the PTC execution seam | `ctx.ptcRuntime` | diff --git a/packages/experimental/README.zh.md b/packages/experimental/README.zh.md index c586a3a896..58e7d1614f 100644 --- a/packages/experimental/README.zh.md +++ b/packages/experimental/README.zh.md @@ -24,9 +24,8 @@ kind: "package-group" | 包 | 职责 | ctx 键 | |---|---|---| -| [`agent-team-profile`](agent-team-profile/README.zh.md) | Agent Teams 的公开 opt-in profile 层 | — | +| [`agent-team-profile`](agent-team-profile/README.zh.md) | Agent Teams 协作、工具与 Web UI 组合包 | — | | [`agent-team`](agent-team/README.zh.md) | 具名 teammate,成员之间持久消息与共享任务板 | `ctx.agentTeams` | -| [`agent-team-web-profile`](agent-team-web-profile/README.zh.md) | Agent Teams 的公开 opt-in Web 层 | — | | [`client-ui-agent-team`](client-ui-agent-team/README.zh.md) | Web Team roster、任务板与 teammate 导航 | — | | [`auto-review`](auto-review/README.zh.md) | 显式 Web 层,在每个原生或 PTC inner 工具调用前使用同一模型审查 | — | | [`ptc-runtime-python`](ptc-runtime-python/README.zh.md) | PTC 执行 seam 的 CPython 子进程后端 | `ctx.ptcRuntime` | diff --git a/packages/experimental/agent-team-profile/README.i18n.yaml b/packages/experimental/agent-team-profile/README.i18n.yaml index 362d87d9f8..12faf98360 100644 --- a/packages/experimental/agent-team-profile/README.i18n.yaml +++ b/packages/experimental/agent-team-profile/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/experimental/agent-team-profile/README.md -README.md: 9aa294991d76d63a596c1d9afed2f9991f424f20 -README.zh.md: 98db275bbf583b02d1f370008345fa68882dbba1 +README.md: 2d94b4d1c0e7de9079ac28945f1e79594b17bb76 +README.zh.md: b1126a266c472d5548b0adbc4139d701ac5b4f6f diff --git a/packages/experimental/agent-team-profile/README.md b/packages/experimental/agent-team-profile/README.md index 9aa294991d..2d94b4d1c0 100644 --- a/packages/experimental/agent-team-profile/README.md +++ b/packages/experimental/agent-team-profile/README.md @@ -1,5 +1,5 @@ --- -description: "Published experimental Agent Teams profile layer over dsh-base with teammate delegation and fresh workflow children." +description: "Enable team collaboration, tools, and the Web roster and task board with one experimental bundle." kind: "package-bundle" --- @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`dsh-experimental-agent-team-profile` is a published experimental profile layer that enables [Agent Teams](../agent-team/README.md) over `@deepseek-ai/dsh-base`. Its patch inserts the Team domain and Team-scoped tools and disables ordinary subagent delegation and the overlapping global continuable-child controls. Workflow remains available with fresh children. The dsh installation ships it as an optional bundle that no shipped profile enables; switch it on from the Web sidebar's Plugins page, or add it explicitly to an initialized profile. +`dsh-experimental-agent-team-profile` enables [Agent Teams](../agent-team/README.md) collaboration, tools, and Web UI with one bundle. Delegate work to teammates and view members, shared tasks, and teammate sessions in Web. Ordinary subagent delegation and overlapping global child controls are disabled; Workflow can still create fresh children. The bundle ships with dsh switched off. Enable it from the Plugins page or add it to an initialized profile. ## Table of Contents @@ -36,10 +36,20 @@ dsh --profile headless "Use Agent Teams to split this task between two teammates The profile must already contain `@deepseek-ai/dsh-base`, whose Subagent services and provider rows this layer consumes. Removing the package with `dsh plugin --profile remove @deepseek-ai/dsh-experimental-agent-team-profile` removes the bundle from the profile's ordered layer list. +Enable Agent Teams on the Web or Desktop Plugins page to activate both tools and UI. For a CLI Web profile, use: + +```sh +dsh plugin --profile web add @deepseek-ai/dsh-experimental-agent-team-profile +``` + +In an existing profile’s `package.json`, keep `@deepseek-ai/dsh-experimental-agent-team-profile` in `dsh.profile.bundles` and remove the separate `@deepseek-ai/dsh-experimental-agent-team-web-profile` entry. User patches targeting `ui-agent-team` still apply. + ### What you get The layer adds the Agent Teams domain and its scoped creation, roster, messaging, interruption, waiting, and task-board tools. Direct delegation uses `spawn_teammate`, which supports fresh and fork context. The `subagent` and `subagent_fork` tools and overlapping global child controls are disabled. Workflow retains the base profile’s `spawn` provider, while the underlying Subagent services and both providers remain available to teammates and workflow. +In Web and Desktop conversations, the [Team UI](../client-ui-agent-team/README.md) displays the member roster and shared task board and opens teammate sessions. The same bundle switch controls the tools and browser UI. + ----- @@ -48,13 +58,13 @@ The layer adds the Agent Teams domain and its scoped creation, roster, messaging
    Implementation internals — click to expand -The package's runtime content is [`cordis.patch.yml`](cordis.patch.yml). Applied after `dsh-base`, the patch disables `tool-subagent-control`, `tool-subagent-list-agents`, `tool-subagent`, and `tool-subagent-fork`, and inserts the Team service and tool rows with explicit providers and limits. +The package's runtime content is [`cordis.patch.yml`](cordis.patch.yml). Applied after `dsh-base`, the patch disables `tool-subagent-control`, `tool-subagent-list-agents`, `tool-subagent`, and `tool-subagent-fork`, and inserts the Team service, tools, and UI with explicit providers and limits. The UI plugin has an inert Host entry; only the Web Client loader mounts its browser entry, so headless needs no Web server. | File | Role | |---|---| | [`cordis.patch.yml`](cordis.patch.yml) | Ordered patch over `dsh-base` | | [`src/index.ts`](src/index.ts) | Empty module entry; the patch is the runtime content | -| — | No runtime invariant companion is published; the package carries only a static profile patch. The Team domain and tool packages own the mutable relationships it activates. | +| — | No runtime invariant companion is published; the package carries only a static profile patch. The Team service and tools own their mutable relationships; the UI package owns its disposable slot registration. |
    @@ -66,6 +76,7 @@ The package's runtime content is [`cordis.patch.yml`](cordis.patch.yml). Applied - [Experimental packages](../README.md) — incubation status and publication policy. - [Agent Teams service](../agent-team/README.md) — durable roster, messaging, and task-board behavior. - [Agent Teams tools](../tool-agent-team/README.md) — the Team-scoped model tool surface. +- [Agent Teams browser UI](../client-ui-agent-team/README.md) — roster, task board, and teammate session navigation. - [Base bundle](../../bundle/base/README.md) — the profile layer this patch extends. ----- @@ -94,6 +105,7 @@ The bundle's composition is prefix-stable while its patch, Team identity, and co - **Opt-in only** — the package ships with the installation switched off; no shipped CLI, Web, SDK, ACP, or Python profile enables it. - **Workflow child tools** — the [Team tool visibility limitation](../tool-agent-team/README.md#known-limitations-and-deferred-work) also applies to workflow children. - **Shared checkout** — every teammate observes the same working directory; this bundle adds no worktree isolation or filesystem locking. +- **Preset-scoped child controls** — Web presets can still mount continuable Subagent controls in their own scope; this top-level bundle does not replace those registrations. - **Base profile required** — the patch depends on row ids and Subagent providers supplied by `dsh-base`; it is not a standalone profile. diff --git a/packages/experimental/agent-team-profile/README.zh.md b/packages/experimental/agent-team-profile/README.zh.md index 98db275bbf..b1126a266c 100644 --- a/packages/experimental/agent-team-profile/README.zh.md +++ b/packages/experimental/agent-team-profile/README.zh.md @@ -1,5 +1,5 @@ --- -description: "叠加在 dsh-base 上公开发布的实验性 Agent Teams profile 层,提供 teammate 委派与 fresh workflow 子代理。" +description: "在一个实验性组合包中启用团队协作、工具与 Web 成员和任务看板。" kind: "package-bundle" --- @@ -9,7 +9,7 @@ kind: "package-bundle" ## 概述 -`dsh-experimental-agent-team-profile` 是在 `@deepseek-ai/dsh-base` 之上启用 [Agent Teams](../agent-team/README.zh.md) 的公开实验性 profile 层。它的 patch 会插入 Team domain 与 Team-scoped 工具,并禁用普通 subagent 委派和名称重叠的全局 continuable-child control。Workflow 仍可创建 fresh 子代理。dsh 安装随附本包作为可选组合包,随附 profile 都不会启用它;可在 Web 侧栏的插件页开启,或显式添加到已初始化的 profile。 +`dsh-experimental-agent-team-profile` 让 [Agent Teams](../agent-team/README.zh.md) 的团队协作、工具和 Web 界面通过一个组合包启用。开启后可委派 teammate,并在 Web 中查看成员、任务看板和成员会话。普通 subagent 委派及名称重叠的全局 child control 会被禁用;Workflow 仍可创建 fresh 子代理。本包随 dsh 安装提供,默认关闭,可在插件页开启或添加到已初始化的 profile。 ## 目录 @@ -36,10 +36,20 @@ dsh --profile headless "Use Agent Teams to split this task between two teammates profile 必须已经包含 `@deepseek-ai/dsh-base`,本层会使用其中的 Subagent 服务与提供方配置行。执行 `dsh plugin --profile remove @deepseek-ai/dsh-experimental-agent-team-profile` 移除本包时,bundle 也会从 profile 的有序层列表中移除。 +在 Web 或 Desktop 的插件页开启「智能体团队」,即可同时启用工具与界面。CLI 的 Web profile 也可使用以下命令: + +```sh +dsh plugin --profile web add @deepseek-ai/dsh-experimental-agent-team-profile +``` + +已有 profile 的 `package.json` 中,`dsh.profile.bundles` 应只保留 `@deepseek-ai/dsh-experimental-agent-team-profile`,删除独立的 `@deepseek-ai/dsh-experimental-agent-team-web-profile` 条目。用户 patch 中的 `ui-agent-team` 配置仍然有效。 + ### 获得的功能 本层会添加 Agent Teams domain,以及 Team-scoped 创建、roster、消息、interrupt、等待与任务板工具。直接委派使用支持 fresh 和 fork 上下文的 `spawn_teammate`。`subagent`、`subagent_fork` 工具和名称重叠的全局 child control 均被禁用。Workflow 保留 base profile 的 `spawn` 提供方,底层 Subagent 服务和两个提供方仍供 teammate 与 workflow 使用。 +在 Web 和 Desktop 会话中,[Team UI](../client-ui-agent-team/README.zh.md) 显示成员列表与共享任务看板,并可打开成员会话。同一个组合包开关控制工具与浏览器 UI。 + ----- @@ -48,13 +58,13 @@ profile 必须已经包含 `@deepseek-ai/dsh-base`,本层会使用其中的 Su
    实现细节——点击展开 -本包的运行时内容是 [`cordis.patch.yml`](cordis.patch.yml)。在 `dsh-base` 之后应用时,patch 会禁用 `tool-subagent-control`、`tool-subagent-list-agents`、`tool-subagent` 和 `tool-subagent-fork`,并以显式 provider 和限制插入 Team 服务与工具行。 +本包的运行时内容是 [`cordis.patch.yml`](cordis.patch.yml)。在 `dsh-base` 之后应用时,patch 会禁用 `tool-subagent-control`、`tool-subagent-list-agents`、`tool-subagent` 和 `tool-subagent-fork`,并以显式 provider 和限制插入 Team 服务、工具和 UI 行。UI 插件的 Host 入口不执行逻辑;只有 Web 客户端加载器会挂载其浏览器入口,因此 headless 无需启动 Web 服务。 | 文件 | 职责 | |---|---| | [`cordis.patch.yml`](cordis.patch.yml) | 叠加在 `dsh-base` 之上的有序 patch | | [`src/index.ts`](src/index.ts) | 空模块入口;patch 是运行时内容 | -| — | 不发布运行时不变式伴生入口;本包是静态 bundle,不持有可独立观察的运行时关系。 | +| — | 不发布运行时不变式伴生入口;本包只携带静态 profile patch。Team 服务与工具各自持有其可变关系,UI 包持有其可释放的 slot 注册。 |
    @@ -66,6 +76,7 @@ profile 必须已经包含 `@deepseek-ai/dsh-base`,本层会使用其中的 Su - [实验性包](../README.zh.md)——孵化状态与发布规则。 - [Agent Teams service](../agent-team/README.zh.md)——持久 roster、消息与任务板行为。 - [Agent Teams 工具](../tool-agent-team/README.zh.md)——Team-scoped 模型工具表层。 +- [Agent Teams 浏览器 UI](../client-ui-agent-team/README.zh.md)——成员列表、任务看板与成员会话导航。 - [Base bundle](../../bundle/base/README.zh.md)——本 patch 扩展的 profile 层。 ----- @@ -94,6 +105,7 @@ Team 策略与 schema 由 [`@deepseek-ai/dsh-experimental-tool-agent-team`](../t - **仅显式启用**——本包随安装提供但默认关闭;随附 CLI、Web、SDK、ACP 与 Python profile 都不会启用它。 - **Workflow 子代理工具**——[Team 工具可见性限制](../tool-agent-team/README.zh.md#known-limitations-and-deferred-work)也适用于 workflow 子代理。 - **共享 checkout**——所有 teammate 都观察同一个工作目录;本 bundle 不提供 worktree 隔离或文件系统锁。 +- **预设内的子代理控件**——Web 预设仍可在预设作用域挂载 continuable Subagent 控件;顶层组合包不会替换这些注册。 - **需要 base profile**——本 patch 依赖 `dsh-base` 提供的配置行 id 与 Subagent 提供方;它不是独立 profile。 diff --git a/packages/experimental/agent-team-profile/cordis.patch.yml b/packages/experimental/agent-team-profile/cordis.patch.yml index f1eee0e9ba..5543297c91 100644 --- a/packages/experimental/agent-team-profile/cordis.patch.yml +++ b/packages/experimental/agent-team-profile/cordis.patch.yml @@ -28,3 +28,6 @@ config: freshProvider: spawn forkProvider: fork + + - id: ui-agent-team + name: '@deepseek-ai/dsh-experimental-client-ui-agent-team' diff --git a/packages/experimental/agent-team-profile/package.json b/packages/experimental/agent-team-profile/package.json index 400b82db62..a8b8967147 100644 --- a/packages/experimental/agent-team-profile/package.json +++ b/packages/experimental/agent-team-profile/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-agent-team-profile", - "description": "Experimental profile bundle enabling Agent Teams over dsh-base", - "version": "0.1.6-alpha.1", + "description": "Agent Teams collaboration, tools, and Web UI in one experimental bundle", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, @@ -35,6 +35,7 @@ }, "dependencies": { "@deepseek-ai/dsh-experimental-agent-team": "workspace:^", + "@deepseek-ai/dsh-experimental-client-ui-agent-team": "workspace:^", "@deepseek-ai/dsh-experimental-tool-agent-team": "workspace:^" }, "peerDependencies": { diff --git a/packages/experimental/agent-team-profile/tests/profile.spec.ts b/packages/experimental/agent-team-profile/tests/profile.spec.ts index fdebc49182..dd65a021ec 100644 --- a/packages/experimental/agent-team-profile/tests/profile.spec.ts +++ b/packages/experimental/agent-team-profile/tests/profile.spec.ts @@ -21,6 +21,7 @@ describe('Agent Teams profile bundle', () => { expect(manifest.dsh?.bundle?.patch).toBe('./cordis.patch.yml') expect(manifest.dependencies).toMatchObject({ '@deepseek-ai/dsh-experimental-agent-team': 'workspace:^', + '@deepseek-ai/dsh-experimental-client-ui-agent-team': 'workspace:^', '@deepseek-ai/dsh-experimental-tool-agent-team': 'workspace:^', }) @@ -48,5 +49,8 @@ describe('Agent Teams profile bundle', () => { name: '@deepseek-ai/dsh-experimental-tool-agent-team', config: { freshProvider: 'spawn', forkProvider: 'fork' }, }) + expect(inserted.find(entry => entry.id === 'ui-agent-team')).toMatchObject({ + name: '@deepseek-ai/dsh-experimental-client-ui-agent-team', + }) }) }) diff --git a/packages/experimental/agent-team-web-profile/README.i18n.yaml b/packages/experimental/agent-team-web-profile/README.i18n.yaml deleted file mode 100644 index 9febb49882..0000000000 --- a/packages/experimental/agent-team-web-profile/README.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# 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 packages/experimental/agent-team-web-profile/README.md -README.md: e21a7eeebc83a97d3e4630c95a9aeaa4213bc227 -README.zh.md: 9966697cb7225083a6fff515675b9dd9ecc51c82 diff --git a/packages/experimental/agent-team-web-profile/README.md b/packages/experimental/agent-team-web-profile/README.md deleted file mode 100644 index e21a7eeebc..0000000000 --- a/packages/experimental/agent-team-web-profile/README.md +++ /dev/null @@ -1,98 +0,0 @@ ---- -description: "Add the published experimental Agent Teams panel to a Web profile after the Host Team layer." -kind: "package-bundle" ---- - -# @deepseek-ai/dsh-experimental-agent-team-web-profile - -English | [中文](README.zh.md) - -## Summary - -`dsh-experimental-agent-team-web-profile` is the published experimental Web layer for [Agent Teams](../agent-team/README.md). Add it after `@deepseek-ai/dsh-web-app` and [`@deepseek-ai/dsh-experimental-agent-team-profile`](../agent-team-profile/README.md) to show the Team roster, task board, and teammate navigation in the browser. Removing either experimental layer leaves the stable base and Web composition unchanged. The dsh installation ships it as an optional bundle that no shipped Web profile enables; switch it on from the Web sidebar's Plugins page after the Host layer. - -## Table of Contents - -- [Use this package](#use-this-package) -- [Understand the implementation](#understand-the-implementation) -- [Further Exploration](#further-exploration) -- [Model Experience](#model-experience) -- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) -- [Dev Note](#dev-note) - ------ - - -## Use this package - -### Install into a profile - -Add the Host and Web Agent Teams layers to an initialized `web` profile in this order: - -```sh -dsh plugin --profile web add @deepseek-ai/dsh-experimental-agent-team-profile -dsh plugin --profile web add @deepseek-ai/dsh-experimental-agent-team-web-profile -``` - -The first command supplies the Team domain, generated Remote methods, and model tools. The second command activates this package's declared patch and its browser presentation. Removing the package with `dsh plugin --profile web remove @deepseek-ai/dsh-experimental-agent-team-web-profile` removes the Web layer from the profile's ordered bundle list. - -### What you get - -The conversation header gains the Team roster, shared task board, and teammate navigation. [`@deepseek-ai/dsh-experimental-client-ui-agent-team`](../client-ui-agent-team/README.md) owns those browser interactions and mounts the generated Client Remote namespace used to reach the Host Team service. - ------ - - -## Understand the implementation - -
    -Implementation internals — click to expand - -The package's runtime content is [`cordis.patch.yml`](cordis.patch.yml). Applied after `dsh-web-app` and the Host Agent Teams layer, its single `insert` entry adds the `ui-agent-team` row for `@deepseek-ai/dsh-experimental-client-ui-agent-team`. The inserted Client plugin owns the generated Remote assembly and Team UI; this static bundle holds no mutable state and installs no runtime invariant. - -| File | Role | -|---|---| -| [`cordis.patch.yml`](cordis.patch.yml) | Ordered Web patch containing the `ui-agent-team` row | -| [`src/index.ts`](src/index.ts) | Empty module entry; the patch is the runtime content | -| — | No runtime invariant companion is published; the package carries only a static profile patch. The Remote assembly and Team UI own their activation requirements. | - -
    - ------ - - -## Further Exploration - -- [Experimental packages](../README.md) — incubation status and publication policy. -- [Agent Teams Host profile](../agent-team-profile/README.md) — the required domain, Remote, and model-tool layer. -- [Agent Teams browser UI](../client-ui-agent-team/README.md) — roster, task-board, and teammate-navigation behavior. -- [Web bundle](../../bundle/web-app/README.md) — the stable browser layer this patch extends. - ------ - - -## Model Experience - -Indirectly, through the Host-side Agent Teams profile selected alongside this Web layer. - -#### KV Cache effect - -This Web bundle adds no model request content; the Host-side Team tools own prompt, schema, and cache effects. - -## Known Limitations and Deferred Work - - - -- **Ordered composition** — `dsh-base`, `dsh-web-app`, `dsh-experimental-agent-team-profile`, and this package must remain in that order. -- **Preset-scoped legacy controls** — stable Web presets still mount continuable Subagent controls inside the preset scope. Top-level Host profile overrides do not replace those scoped registrations, so the Team roster and legacy child controls can both appear until Web has a Team-aware preset. The [Web Agent Teams decision](../../../.agents/notes/archived/feature/2026-08-06-agent-teams-web.md) records this deferred composition work. -- **Opt-in only** — the package ships with the installation switched off; no shipped Web profile enables either Agent Teams layer. - - -### Dev Note - -
    -Working context for maintainers — click to expand - -None. - -
    diff --git a/packages/experimental/agent-team-web-profile/README.zh.md b/packages/experimental/agent-team-web-profile/README.zh.md deleted file mode 100644 index 9966697cb7..0000000000 --- a/packages/experimental/agent-team-web-profile/README.zh.md +++ /dev/null @@ -1,98 +0,0 @@ ---- -description: "在 Host Team 层之后,为 Web profile 添加公开发布的实验性 Agent Teams 面板。" -kind: "package-bundle" ---- - -# @deepseek-ai/dsh-experimental-agent-team-web-profile - -[English](README.md) | 中文 - -## 概述 - -`dsh-experimental-agent-team-web-profile` 是 [Agent Teams](../agent-team/README.zh.md) 公开发布的实验性 Web 层。把它放在 `@deepseek-ai/dsh-web-app` 与 [`@deepseek-ai/dsh-experimental-agent-team-profile`](../agent-team-profile/README.zh.md) 之后,即可在浏览器中显示 Team roster、任务板与 teammate 导航。移除任一实验层都会让稳定的 base 与 Web composition 保持不变。dsh 安装随附本包作为可选组合包,随附 Web profile 不会启用它;在开启 Host 层后可在 Web 侧栏的插件页开启。 - -## 目录 - -- [使用本包](#use-this-package) -- [理解实现](#understand-the-implementation) -- [进一步探索](#further-exploration) -- [模型体验](#model-experience) -- [已知限制与延期工作](#known-limitations-and-deferred-work) -- [开发备注](#dev-note) - ------ - - -## 使用本包 - -### 安装到 profile - -按以下顺序把 Host 与 Web Agent Teams 层添加到已初始化的 `web` profile: - -```sh -dsh plugin --profile web add @deepseek-ai/dsh-experimental-agent-team-profile -dsh plugin --profile web add @deepseek-ai/dsh-experimental-agent-team-web-profile -``` - -第一条命令提供 Team domain、生成的 Remote 方法与模型工具。第二条命令激活本包声明的 patch 及其浏览器 presentation。执行 `dsh plugin --profile web remove @deepseek-ai/dsh-experimental-agent-team-web-profile` 移除本包时,Web 层也会从 profile 的有序 bundle 列表中移除。 - -### 获得的功能 - -对话标题栏会获得 Team roster、共享任务板与 teammate 导航。[`@deepseek-ai/dsh-experimental-client-ui-agent-team`](../client-ui-agent-team/README.zh.md) 负责这些浏览器交互,并挂载用于访问 Host Team service 的生成 Client Remote namespace。 - ------ - - -## 理解实现 - -
    -实现细节——点击展开 - -本包的运行时内容是 [`cordis.patch.yml`](cordis.patch.yml)。在 `dsh-web-app` 与 Host Agent Teams 层之后应用时,它唯一的 `insert` 条目会为 `@deepseek-ai/dsh-experimental-client-ui-agent-team` 添加 `ui-agent-team` 行。插入的 Client 插件负责生成的 Remote assembly 与 Team UI;这个静态 bundle 不持有可变状态,也不安装运行时不变式。 - -| 文件 | 职责 | -|---|---| -| [`cordis.patch.yml`](cordis.patch.yml) | 包含 `ui-agent-team` 行的有序 Web patch | -| [`src/index.ts`](src/index.ts) | 空模块入口;patch 是运行时内容 | -| — | 不发布运行时不变式伴生入口;本包只携带静态 profile patch,Remote assembly 与 Team UI 负责各自的激活要求。 | - -
    - ------ - - -## 进一步探索 - -- [实验性包](../README.zh.md)——孵化状态与发布规则。 -- [Agent Teams Host profile](../agent-team-profile/README.zh.md)——所需的 domain、Remote 与模型工具层。 -- [Agent Teams 浏览器 UI](../client-ui-agent-team/README.zh.md)——roster、任务板与 teammate 导航行为。 -- [Web bundle](../../bundle/web-app/README.zh.md)——本 patch 扩展的稳定浏览器层。 - ------ - - -## 模型体验 - -通过与本 Web 层同时选择的 Host-side Agent Teams profile 间接产生影响。 - -#### KV Cache 影响 - -本 Web bundle 不添加任何模型请求内容;Host-side Team 工具负责提示词、schema 与缓存影响。 - -## 已知限制与延期工作 - - - -- **有序组合**——`dsh-base`、`dsh-web-app`、`dsh-experimental-agent-team-profile` 与本包必须保持这个顺序。 -- **Preset-scoped 旧控制项**——稳定 Web preset 仍会在 preset scope 内挂载 continuable Subagent 控制项。顶层 Host profile override 不会替换这些 scoped registration,因此在 Web 获得 Team-aware preset 前,Team roster 与旧 child 控制项可能同时出现。[Web Agent Teams 决策](../../../.agents/notes/archived/feature/2026-08-06-agent-teams-web.md)记录了这项暂缓的 composition 工作。 -- **仅显式启用**——本包随安装提供但默认关闭;随附 Web profile 不会启用任何 Agent Teams 层。 - - -### 开发备注 - -
    -维护者的工作上下文——点击展开 - -无。 - -
    diff --git a/packages/experimental/agent-team-web-profile/cordis.patch.yml b/packages/experimental/agent-team-web-profile/cordis.patch.yml deleted file mode 100644 index e9cffef438..0000000000 --- a/packages/experimental/agent-team-web-profile/cordis.patch.yml +++ /dev/null @@ -1,6 +0,0 @@ -# Experimental Agent Teams Web layer. Apply after dsh-web-app and the host-side -# dsh-agent-team-profile so the browser mounts only when the Team service is present. - -- insert: - - id: ui-agent-team - name: '@deepseek-ai/dsh-experimental-client-ui-agent-team' diff --git a/packages/experimental/agent-team-web-profile/package.json b/packages/experimental/agent-team-web-profile/package.json deleted file mode 100644 index ad91bced8d..0000000000 --- a/packages/experimental/agent-team-web-profile/package.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "name": "@deepseek-ai/dsh-experimental-agent-team-web-profile", - "description": "Experimental Web profile layer for Agent Teams Remote and UI plugins", - "version": "0.1.6-alpha.1", - "publishConfig": { - "access": "public" - }, - "repository": { - "type": "git", - "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", - "directory": "packages/experimental/agent-team-web-profile" - }, - "type": "module", - "main": "lib/index.js", - "types": "lib/types/index.d.ts", - "exports": { - ".": { - "types": "./lib/types/index.d.ts", - "default": "./lib/index.js" - }, - "./cordis.patch.yml": "./cordis.patch.yml", - "./src/*": "./src/*", - "./package.json": "./package.json" - }, - "files": [ - "lib/index.js", - "cordis.patch.yml", - "lib/types/**/*.d.ts" - ], - "license": "MIT", - "dsh": { - "bundle": { - "patch": "./cordis.patch.yml" - } - }, - "dependencies": { - "@deepseek-ai/dsh-experimental-client-ui-agent-team": "workspace:^" - }, - "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^" - }, - "devDependencies": { - "@deepseek-ai/cordis-plugin-include": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", - "js-yaml": "^4.2.0" - } -} diff --git a/packages/experimental/agent-team-web-profile/src/index.ts b/packages/experimental/agent-team-web-profile/src/index.ts deleted file mode 100644 index 726f066041..0000000000 --- a/packages/experimental/agent-team-web-profile/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -/** Experimental Web profile layer for the Agent Teams Client plugins. */ - -export {} diff --git a/packages/experimental/agent-team-web-profile/tests/profile.spec.ts b/packages/experimental/agent-team-web-profile/tests/profile.spec.ts deleted file mode 100644 index 21fde2cab5..0000000000 --- a/packages/experimental/agent-team-web-profile/tests/profile.spec.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** The experimental Web bundle must carry one parseable Team Client layer. */ - -import { readFileSync } from 'node:fs' -import { fileURLToPath } from 'node:url' -import { resolve } from 'node:path' -import { describe, expect, it } from 'vitest' -import * as yaml from 'js-yaml' -import { entryListSchema } from '@deepseek-ai/cordis-plugin-include' - -describe('Agent Teams Web profile bundle', () => { - it('declares a public parseable layer containing the Team UI', () => { - const root = fileURLToPath(new URL('..', import.meta.url)) - const manifest = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')) as { - private?: boolean - publishConfig?: { access?: string } - dependencies?: Record - dsh?: { bundle?: { patch?: string } } - } - expect(manifest.private).toBeUndefined() - expect(manifest.publishConfig?.access).toBe('public') - expect(manifest.dsh?.bundle?.patch).toBe('./cordis.patch.yml') - expect(manifest.dependencies).toEqual({ - '@deepseek-ai/dsh-experimental-client-ui-agent-team': 'workspace:^', - }) - - const parsed = yaml.load( - readFileSync(resolve(root, manifest.dsh!.bundle!.patch!), 'utf8'), - { schema: entryListSchema }, - ) as { insert?: { id?: string; name?: string }[] }[] - expect(parsed.flatMap(patch => patch.insert ?? [])).toEqual([ - { id: 'ui-agent-team', name: '@deepseek-ai/dsh-experimental-client-ui-agent-team' }, - ]) - }) -}) diff --git a/packages/experimental/agent-team-web-profile/tsconfig.json b/packages/experimental/agent-team-web-profile/tsconfig.json deleted file mode 100644 index d4186340bf..0000000000 --- a/packages/experimental/agent-team-web-profile/tsconfig.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "extends": "../../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib/types" - }, - "include": ["src"], - "references": [ - { "path": "../../../vendor/cordis" } - ] -} diff --git a/packages/experimental/agent-team/README.i18n.yaml b/packages/experimental/agent-team/README.i18n.yaml index 56fefe2904..47dbb4329a 100644 --- a/packages/experimental/agent-team/README.i18n.yaml +++ b/packages/experimental/agent-team/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/experimental/agent-team/README.md -README.md: 1dba55aaef5c2f061de85de3b38cf5c644fa90b7 -README.zh.md: 749f5b9d27ed863639a6dabad80254c9a19727dd +README.md: 6e2b55018971ec96802de0e5be042d49b45c674a +README.zh.md: b497d3790e43a9bc843e2a6b88c9755b698034b5 diff --git a/packages/experimental/agent-team/README.md b/packages/experimental/agent-team/README.md index 1dba55aaef..6e2b550189 100644 --- a/packages/experimental/agent-team/README.md +++ b/packages/experimental/agent-team/README.md @@ -60,7 +60,7 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a Ask the Lead to create a teammate: give it a unique lowercase name such as `reviewer` and describe its job. A teammate starts fresh with no memory of the Lead's conversation, or as a fork that inherits the Lead's completed turns; the creation request chooses which. Teammate names are permanent — even a teammate whose creation failed keeps its name, and no name is ever reused. -The roster shows every member with its role (`lead` or `teammate`) and current status: `running`, `idle`, `inactive` (a member that exists but is not loaded), `provisioning`, or `failed`. A member that is not loaded receives its messages when it wakes. +The roster shows every member with its role (`lead` or `teammate`) and current status: `running`, `inactive` (no turn is executing, whether loaded or stored), `provisioning`, or `failed`. A member that is not loaded receives its messages when it wakes. Only the Lead can create teammates or interrupt them. @@ -68,7 +68,7 @@ Only the Lead can create teammates or interrupt them. Any member can send a message to any other member or to the Lead. A live member receives it immediately; an offline member's messages queue and arrive when it resumes. Messages are never lost and never delivered twice. -Every message uses Steer: a running target receives it at the nearest step boundary, an idle target starts a turn, and an inactive teammate cold-resumes. The sender always sees the outcome — accepted by the target inbox, or retained as queued when delivery is temporarily unavailable. A queued message is already safely stored, so it must not be resent. +Every message uses Steer: a running target receives it at the nearest step boundary; an inactive target starts a turn if loaded or cold-resumes otherwise. The sender always sees the outcome — accepted by the target inbox, or retained as queued when delivery is temporarily unavailable. A queued message is already safely stored, so it must not be resent. ### Shared task board @@ -198,7 +198,7 @@ These limits describe what a team cannot do yet or what needs special operationa - **One process and one shared checkout** — members share cwd and observe edits immediately; this package provides no worktree, remote member, merge, or filesystem lock. - **Advisory write scopes** — Bash, formatters, code generators, and direct external writers can bypass filesystem version checks; Leads must coordinate ownership and review the final diff. - **Flat immutable roster** — only the Lead creates direct teammates; there is no nested Team, rename, deletion, or name reuse. -- **No automatic ownership release** — idle, interruption, process exit, and failed work do not release a task owner. +- **No automatic ownership release** — inactivity, interruption, process exit, and failed work do not release a task owner. - **Mailbox is not cross-process exactly-once** — concurrent harness processes over one Team are unsupported. diff --git a/packages/experimental/agent-team/README.zh.md b/packages/experimental/agent-team/README.zh.md index 749f5b9d27..b497d3790e 100644 --- a/packages/experimental/agent-team/README.zh.md +++ b/packages/experimental/agent-team/README.zh.md @@ -60,7 +60,7 @@ kind: "package-reference" 请 Lead 创建 teammate:给它一个唯一的小写名字(例如 `reviewer`)并描述其职责。teammate 可以 fresh 启动(不携带 Lead 对话的任何记忆),也可以作为 fork 启动(继承 Lead 已完成的轮次);创建请求决定用哪种。teammate 名字是永久的——即使创建失败的 teammate 也保留其名字,任何名字都不会被复用。 -roster 显示每个成员的职责(`lead` 或 `teammate`)与当前状态:`running`、`idle`、`inactive`(存在但未加载的成员)、`provisioning` 或 `failed`。未加载的成员会在唤醒后收到其消息。 +roster 显示每个成员的职责(`lead` 或 `teammate`)与当前状态:`running`、`inactive`(当前没有执行轮次,包括已加载和仅存储的成员)、`provisioning` 或 `failed`。未加载的成员会在唤醒后收到其消息。 只有 Lead 可以创建 teammate 或中断它们。 @@ -68,7 +68,7 @@ roster 显示每个成员的职责(`lead` 或 `teammate`)与当前状态:` 任何成员都可以向任何其他成员或 Lead 发送消息。live 成员会立即收到;离线成员的消息会排队,并在其恢复后到达。消息不会丢失,也不会重复投递。 -每条消息都使用 Steer:running target 在最近的步骤边界收到消息,idle target 启动一个轮次,inactive teammate 则冷恢复。发送方始终能看到结果——target inbox 已接受,或在投递暂时不可用时保留为 queued。排队的消息已经安全存储,因此绝不能重发。 +每条消息都使用 Steer:running target 在最近的步骤边界收到消息,inactive target 在已加载时启动一个轮次,否则冷恢复。发送方始终能看到结果——target inbox 已接受,或在投递暂时不可用时保留为 queued。排队的消息已经安全存储,因此绝不能重发。 ### 共享任务板 @@ -198,7 +198,7 @@ Peer 消息追加在 target 可复用历史前缀之后。冷恢复会先复用 - **单进程、共享 checkout**——成员共享 cwd,修改立即可见;本包不提供 worktree、远端成员、merge 或文件锁。 - **write scope 仅作提示**——Bash、formatter、代码生成器与直接外部写入可以绕过文件版本检查;Lead 必须协调 owner 并检查最终 diff。 - **扁平且不可变的 roster**——只有 Lead 可以创建直接 teammate;不支持嵌套 Team、重命名、删除或名字复用。 -- **不会自动释放 owner**——idle、interrupt、进程退出与工作失败都不会释放任务 owner。 +- **不会自动释放 owner**——成员不活动、interrupt、进程退出与工作失败都不会释放任务 owner。 - **mailbox 不保证跨进程 exactly-once**——不支持多个 harness 进程并发操作同一 Team。 diff --git a/packages/experimental/agent-team/package.json b/packages/experimental/agent-team/package.json index a74c72f939..8a1ebd621d 100644 --- a/packages/experimental/agent-team/package.json +++ b/packages/experimental/agent-team/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-agent-team", "description": "Implicit-root Agent Teams roster, durable peer mailbox, and shared task DAG", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/experimental/agent-team/src/index.ts b/packages/experimental/agent-team/src/index.ts index 9066e185ca..abdedc6d63 100644 --- a/packages/experimental/agent-team/src/index.ts +++ b/packages/experimental/agent-team/src/index.ts @@ -221,7 +221,7 @@ export class TeamService extends TypertRemoteService { * @param targetName - durable teammate name. * @returns the target status sampled before cancellation. */ - interrupt(caller: Agent, targetName: string): { previousStatus: 'running' | 'idle' | 'inactive' } { + interrupt(caller: Agent, targetName: string): { previousStatus: 'running' | 'inactive' } { return this.roster.interrupt(caller, targetName) } diff --git a/packages/experimental/agent-team/src/roster.ts b/packages/experimental/agent-team/src/roster.ts index ae52c741c2..d58357e03f 100644 --- a/packages/experimental/agent-team/src/roster.ts +++ b/packages/experimental/agent-team/src/roster.ts @@ -133,7 +133,7 @@ export class TeamRoster { id: root.id, name: 'lead', role: 'lead', - status: root.status, + status: availability(root), ...root.options.model === undefined ? {} : { model: root.options.model }, diagnostics: [], }] @@ -148,7 +148,7 @@ export class TeamRoster { ? 'failed' : member.phase === 'provisioning' ? 'provisioning' - : live?.status ?? 'inactive', + : availability(live), description: member.description, provider: member.provider, context: member.context, @@ -201,7 +201,7 @@ export class TeamRoster { * @param targetName - durable teammate name. * @returns the target status sampled before cancellation. */ - interrupt(caller: Agent, targetName: string): { previousStatus: 'running' | 'idle' | 'inactive' } { + interrupt(caller: Agent, targetName: string): { previousStatus: 'running' | 'inactive' } { const membership = this.membership(caller) if (membership.role !== 'lead') throw new TeamError('only the Team Lead can interrupt teammates', 'TEAM_LEAD_REQUIRED') const state = this.journal.state(membership.root) @@ -209,7 +209,7 @@ export class TeamRoster { if (target.id === membership.root.id) throw new TeamError('the Team Lead cannot interrupt itself', 'TEAM_INVALID_TARGET') const live = this.ctx.agents.get(target.id) if (live === undefined) return { previousStatus: 'inactive' } - const previousStatus = live.status + const previousStatus = availability(live) this.ctx.subagents.interrupt(target.id, { kind: 'ancestor', agent: caller }) return { previousStatus } } @@ -440,7 +440,7 @@ export class TeamRoster { id: member.id, name: member.name, role: 'teammate', - status: live?.status ?? 'inactive', + status: availability(live), description: member.description, provider: member.provider, context: member.context, @@ -487,3 +487,8 @@ export class TeamRoster { return foldSubagentDescriptor(agent.session.snapshotEvents(agent.session.inheritedEventCount)) !== undefined } } + +/** Turn availability is independent of whether the Agent is loaded. */ +function availability(agent: Agent | undefined): 'running' | 'inactive' { + return agent?.status === 'running' ? 'running' : 'inactive' +} diff --git a/packages/experimental/agent-team/src/types.ts b/packages/experimental/agent-team/src/types.ts index f7b8ca414f..cf659ce9d0 100644 --- a/packages/experimental/agent-team/src/types.ts +++ b/packages/experimental/agent-team/src/types.ts @@ -59,7 +59,7 @@ export interface TeamMemberView { readonly id: SessionId readonly name: string readonly role: 'lead' | 'teammate' - readonly status: 'running' | 'idle' | 'inactive' | 'provisioning' | 'failed' + readonly status: 'running' | 'inactive' | 'provisioning' | 'failed' readonly description?: string readonly provider?: string readonly context?: 'fresh' | 'fork' diff --git a/packages/experimental/agent-team/tests/team.spec.ts b/packages/experimental/agent-team/tests/team.spec.ts index 79354a6427..416df1fd8f 100644 --- a/packages/experimental/agent-team/tests/team.spec.ts +++ b/packages/experimental/agent-team/tests/team.spec.ts @@ -178,7 +178,7 @@ describe('Team identity and provisioning', () => { expect(service.listMembers(lead)).toEqual([expect.objectContaining({ name: 'lead', - status: 'idle', + status: 'inactive', diagnostics: [], })]) const provisioning = { @@ -220,7 +220,7 @@ describe('Team identity and provisioning', () => { expect((await ctx.sessionPersistence.stat(forked.member.id))?.header.isSeeded).toBe(true) expect((await ctx.sessionPersistence.stat(fresh.member.id))?.header.isSeeded).toBe(false) expect(ctx.agentTeams.listMembers(lead).map(row => [row.name, row.context, row.status])).toEqual([ - ['lead', undefined, 'idle'], + ['lead', undefined, 'inactive'], ['fork-worker', 'fork', 'inactive'], ['fresh-worker', 'fresh', 'inactive'], ]) @@ -878,7 +878,7 @@ describe('Team Remote API', () => { const { ctx, lead } = await setup([]) expect(ctx.agentTeams.typertRemote).toMatchObject({ serviceKey: 'agentTeams', namespace: 'agentTeams' }) expect(ctx.agentTeams.remoteView(lead)).toEqual({ - members: [expect.objectContaining({ name: 'lead', role: 'lead', status: 'idle' })], + members: [expect.objectContaining({ name: 'lead', role: 'lead', status: 'inactive' })], tasks: [], }) diff --git a/packages/experimental/auto-review/package.json b/packages/experimental/auto-review/package.json index 586639a1f2..853cc277c6 100644 --- a/packages/experimental/auto-review/package.json +++ b/packages/experimental/auto-review/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-auto-review", "description": "Per-tool LLM authorization review for the DeepSeek Harness Auto permission preset", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "repository": { "type": "git", "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", diff --git a/packages/experimental/browser-use-chrome-devtools-mcp/package.json b/packages/experimental/browser-use-chrome-devtools-mcp/package.json index 9ad760be38..014c008ad8 100644 --- a/packages/experimental/browser-use-chrome-devtools-mcp/package.json +++ b/packages/experimental/browser-use-chrome-devtools-mcp/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-browser-use-chrome-devtools-mcp", "description": "Experimental per-Session Chromium browser tools through chrome-devtools-mcp", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/experimental/browser-use-playwright-mcp/package.json b/packages/experimental/browser-use-playwright-mcp/package.json index e5dbfe057a..eaabc9607b 100644 --- a/packages/experimental/browser-use-playwright-mcp/package.json +++ b/packages/experimental/browser-use-playwright-mcp/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-browser-use-playwright-mcp", "description": "Experimental per-Session Chromium browser tools through @playwright/mcp", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/experimental/browser-use-runtime/package.json b/packages/experimental/browser-use-runtime/package.json index 11a52cc37a..962b53ad3a 100644 --- a/packages/experimental/browser-use-runtime/package.json +++ b/packages/experimental/browser-use-runtime/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-browser-use-runtime", "description": "Session-owned browser resource lifecycles and MCP integration for experimental providers", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/experimental/browser-use-stagehand-native/package.json b/packages/experimental/browser-use-stagehand-native/package.json index 89f52dbace..e88afeeabe 100644 --- a/packages/experimental/browser-use-stagehand-native/package.json +++ b/packages/experimental/browser-use-stagehand-native/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-browser-use-stagehand-native", "description": "Experimental Stagehand browser tools with separately configured native models", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/experimental/client-ui-agent-team/README.i18n.yaml b/packages/experimental/client-ui-agent-team/README.i18n.yaml index d4a0ae340d..196e093462 100644 --- a/packages/experimental/client-ui-agent-team/README.i18n.yaml +++ b/packages/experimental/client-ui-agent-team/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/experimental/client-ui-agent-team/README.md -README.md: ab40e2de09406991c55160005f2f16cf6931e195 -README.zh.md: 2913ff65930f260d53a0bee3b3ed6cb9634fcd5a +README.md: 34e89cfe92db9656cf2fc948e154627c0d5f9407 +README.zh.md: e01cbbb534282fb2c6731579fc753954bcde314b diff --git a/packages/experimental/client-ui-agent-team/README.md b/packages/experimental/client-ui-agent-team/README.md index ab40e2de09..34e89cfe92 100644 --- a/packages/experimental/client-ui-agent-team/README.md +++ b/packages/experimental/client-ui-agent-team/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -This package adds an Agent Teams action to the Web conversation header, where a user can inspect the current roster, manage the shared task board, and navigate into a teammate's conversation. It reads authoritative Team state through the generated `ctx.remote.agentTeams` contribution and keeps ordinary child-history navigation on the stable addressed-subagent path. Choose it through the published experimental Agent Teams Web profile. The browser projection does not extend the stable API Proxy, store Team state, or register model-facing input. +This package adds an Agent Teams action to the Web conversation header, where a user can inspect the current roster, manage the shared task board, and navigate into a teammate's conversation. It reads authoritative Team state through the generated `ctx.remote.agentTeams` contribution and keeps ordinary child-history navigation on the stable addressed-subagent path. Choose it through the published experimental Agent Teams bundle. The browser projection does not extend the stable API Proxy, store Team state, or register model-facing input. ## Table of Contents @@ -25,14 +25,16 @@ This package adds an Agent Teams action to the Web conversation header, where a ## Use this package -Install the package through [`@deepseek-ai/dsh-experimental-agent-team-web-profile`](../agent-team-web-profile/README.md) after the stable Web bundle and the Host-side Agent Teams profile. The Web Client loader mounts the `/client` export; the root Host export is inert, and the package has no user configuration fields. +Enable this package through [`@deepseek-ai/dsh-experimental-agent-team-profile`](../agent-team-profile/README.md), which supplies the Team service, tools, and Web UI together. The Web Client loader mounts the `/client` export; the root Host export is inert, and the package has no user configuration fields. ### Inspect and navigate the roster -Opening the panel calls `agentTeams/view`. Roster rows show durable names, runtime status, model, and diagnostics. Selecting a healthy teammate refreshes the existing direct-child catalog and opens the ordinary `{ parentSessionId, childSessionId, mode: 'continuable' }` address. History and later human prompts continue through the stable addressed-subagent conversation path; this package adds no Team-specific address field. +Opening the panel calls `agentTeams/view`. Roster rows show durable names, turn availability, model, and diagnostics. Provisioning and running members use the shared ongoing loader, inactive members use idle, and failed members use error. Selecting a healthy teammate refreshes the existing direct-child catalog and opens the ordinary `{ parentSessionId, childSessionId, mode: 'continuable' }` address. History and later human prompts continue through the stable addressed-subagent conversation path; this package adds no Team-specific address field. ### Manage the task board +Ready pending tasks use idle, blocked pending tasks use warning, in-progress tasks use ongoing, and completed tasks use done. + The task board shows task identity, owner, blockers, readiness, advisory write scopes, and overlap warnings. A user can create, edit, assign or unassign, complete, reopen, and delete tasks through `agentTeams/createTask` and `agentTeams/updateTask`. Every update sends the displayed revision, and create or update rejections remain explicit business results. ----- @@ -61,7 +63,7 @@ Starting a create or update invalidates older refreshes. Success reloads the com ## Further Exploration -- [Agent Teams Web profile](../agent-team-web-profile/README.md) — the published opt-in bundle that mounts this Client plugin. +- [Agent Teams bundle](../agent-team-profile/README.md) — the published opt-in bundle that mounts this Client plugin. - [Agent Teams service](../agent-team/README.md) — authoritative roster, task, and Remote behavior. - [Conversation UI](../../client/ui-conversation/README.md) — the stable header slot and addressed-subagent navigation surface. - [Experimental packages](../README.md) — incubation status and publication policy. diff --git a/packages/experimental/client-ui-agent-team/README.zh.md b/packages/experimental/client-ui-agent-team/README.zh.md index 2913ff6593..e01cbbb534 100644 --- a/packages/experimental/client-ui-agent-team/README.zh.md +++ b/packages/experimental/client-ui-agent-team/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -本包向 Web 会话页头添加 Agent Teams action,让用户检查当前 roster、管理共享任务板并导航到 teammate 会话。它通过生成的 `ctx.remote.agentTeams` contribution 读取权威 Team 状态,并让普通 child history 导航继续使用稳定的 addressed-subagent 路径。通过公开发布的实验性 Agent Teams Web profile 选择本包。这个浏览器 projection 不扩展稳定 API Proxy、不存储 Team 状态,也不注册面向模型的输入。 +本包向 Web 会话页头添加 Agent Teams action,让用户检查当前 roster、管理共享任务板并导航到 teammate 会话。它通过生成的 `ctx.remote.agentTeams` contribution 读取权威 Team 状态,并让普通 child history 导航继续使用稳定的 addressed-subagent 路径。通过公开发布的实验性 Agent Teams bundle 选择本包。这个浏览器 projection 不扩展稳定 API Proxy、不存储 Team 状态,也不注册面向模型的输入。 ## 目录 @@ -25,14 +25,16 @@ kind: "package-reference" ## 使用本包 -在稳定 Web bundle 与 Host-side Agent Teams profile 之后,通过 [`@deepseek-ai/dsh-experimental-agent-team-web-profile`](../agent-team-web-profile/README.zh.md) 安装本包。Web Client loader 挂载 `/client` export;root Host export 不执行行为,本包也没有用户配置字段。 +通过 [`@deepseek-ai/dsh-experimental-agent-team-profile`](../agent-team-profile/README.zh.md) 启用本包。这个组合包同时提供团队服务、工具与 Web 界面。Web Client loader 挂载 `/client` export;root Host export 不执行行为,本包也没有用户配置字段。 ### 检查并导航 roster -打开 panel 会调用 `agentTeams/view`。Roster row 展示持久 name、运行时 status、model 与 diagnostics。选择健康 teammate 时,系统刷新既有直接 child catalog,并打开普通的 `{ parentSessionId, childSessionId, mode: 'continuable' }` address。History 与后续人类提示词继续使用稳定 addressed-subagent 会话路径;本包不会添加 Team 专用 address 字段。 +打开 panel 会调用 `agentTeams/view`。Roster row 展示持久 name、轮次可用状态、model 与 diagnostics。provisioning 和 running 成员使用共享 ongoing loading,inactive 成员使用 idle 灰点,failed 成员使用 error 红点。选择健康 teammate 时,系统刷新既有直接 child catalog,并打开普通的 `{ parentSessionId, childSessionId, mode: 'continuable' }` address。History 与后续人类提示词继续使用稳定 addressed-subagent 会话路径;本包不会添加 Team 专用 address 字段。 ### 管理任务板 +可开始的 pending 任务使用 idle 灰点,被依赖阻塞的 pending 任务使用 warning 橙点,in-progress 任务使用 ongoing loading,completed 任务使用 done 绿点。 + 任务板展示 task identity、owner、blocker、readiness、提示性 write scope 与重叠 warning。用户可以通过 `agentTeams/createTask` 与 `agentTeams/updateTask` 创建、编辑、分配或取消分配、完成、重开和删除任务。每次 update 都发送当前显示的 revision,create 或 update rejection 都保留为显式 business result。 ----- @@ -61,7 +63,7 @@ Client export 挂载来自 [`@deepseek-ai/dsh-experimental-agent-team/remote`](. ## 进一步探索 -- [Agent Teams Web profile](../agent-team-web-profile/README.zh.md)——挂载本 Client plugin 的公开 opt-in bundle。 +- [Agent Teams bundle](../agent-team-profile/README.zh.md)——挂载本 Client plugin 的公开 opt-in bundle。 - [Agent Teams service](../agent-team/README.zh.md)——权威 roster、task 与 Remote 行为。 - [会话 UI](../../client/ui-conversation/README.zh.md)——稳定 header slot 与 addressed-subagent 导航表层。 - [实验性包](../README.zh.md)——孵化状态与发布规则。 diff --git a/packages/experimental/client-ui-agent-team/package.json b/packages/experimental/client-ui-agent-team/package.json index 5a607055c4..b344f4cfe0 100644 --- a/packages/experimental/client-ui-agent-team/package.json +++ b/packages/experimental/client-ui-agent-team/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-client-ui-agent-team", "description": "Web Agent Teams roster, task board, and teammate navigation", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/experimental/client-ui-agent-team/src/client/TeamAction.module.css b/packages/experimental/client-ui-agent-team/src/client/TeamAction.module.css index bd9f75be52..e8051dc387 100644 --- a/packages/experimental/client-ui-agent-team/src/client/TeamAction.module.css +++ b/packages/experimental/client-ui-agent-team/src/client/TeamAction.module.css @@ -55,6 +55,7 @@ border: 0; border-radius: 12px; background: var(--dsw-specific-menu); + backdrop-filter: var(--dsw-menu-backdrop-filter); --dsw-elevation-stroke-color: var(--dsw-alias-border-l1); box-shadow: var(--dsw-elevation-prominent); } @@ -157,7 +158,10 @@ background: var(--dsw-alias-bg-base); } -.taskTitle span { +.taskState { + display: inline-flex; + align-items: center; + gap: 6px; margin-left: auto; color: var(--dsw-alias-label-tertiary); font-size: 11px; @@ -235,6 +239,9 @@ .notice, .error { + display: flex; + align-items: center; + gap: 6px; padding: 9px; font-size: 12px; } diff --git a/packages/experimental/client-ui-agent-team/src/client/TeamAction.tsx b/packages/experimental/client-ui-agent-team/src/client/TeamAction.tsx index 93b6c6d858..f0a25a790e 100644 --- a/packages/experimental/client-ui-agent-team/src/client/TeamAction.tsx +++ b/packages/experimental/client-ui-agent-team/src/client/TeamAction.tsx @@ -10,8 +10,9 @@ import type { } from '@deepseek-ai/dsh-experimental-agent-team/client' import type { RemoteResult } from '@deepseek-ai/dsh-api-remotes/client' import { - IconCheckOutline14, IconCloseOutline16, IconEditOutline16, IconPlusOutline16, - IconRefreshOutline14, IconTrashOutline16, IconUserOutline16, StateDot, + IconCheckOutlineRegular, IconCloseOutlineRegular, IconEditOutlineRegular, IconPlusOutlineRegular, + IconRefreshOutlineRegular, IconTrashOutlineRegular, IconUserOutlineRegular, StateDot, + type StateDotState, } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' @@ -88,13 +89,31 @@ function statusKey(status: TeamTask['status']): TeamKey { function memberStatusKey(status: TeamRosterMember['status']): TeamKey { switch (status) { case 'running': return 'memberStatus.running' - case 'idle': return 'memberStatus.idle' case 'inactive': return 'memberStatus.inactive' case 'provisioning': return 'memberStatus.provisioning' case 'failed': return 'memberStatus.failed' } } +function memberDotState(status: TeamRosterMember['status']): StateDotState { + switch (status) { + case 'running': + case 'provisioning': return 'ongoing' + case 'inactive': return 'idle' + case 'failed': return 'error' + } +} + +function taskDotState(task: TeamTask): StateDotState { + switch (task.status) { + case 'pending': return task.ready ? 'idle' : 'warning' + case 'in_progress': return 'ongoing' + case 'completed': return 'done' + /* v8 ignore next -- Team views omit deleted task tombstones. */ + case 'deleted': return 'idle' + } +} + /** Render the live Team roster and compare-and-set task board. */ export function TeamAction({ sessionId, load, createTask, updateTask, openTeammate, t, @@ -255,7 +274,7 @@ export function TeamAction({ if (next) void refresh() }} > - + {t('trigger')} {teammates.length > 0 && {teammates.length}} @@ -264,15 +283,22 @@ export function TeamAction({
    {t('trigger')} + {loading && view !== null && ( + + )}
    - {error !== null &&
    {error}
    } - {loading && view === null &&
    {t('loading')}
    } + {error !== null && ( +
    {error}
    + )} + {loading && view === null && ( +
    {t('loading')}
    + )} {view !== null && ( <>
    @@ -289,7 +315,7 @@ export function TeamAction({ void openTeammate(sessionId, member).catch((reason: unknown) => { setError(String(reason)) }) }} > - + {member.name} {t(memberStatusKey(member.status))}{member.model === undefined ? '' : ` · ${t('model')}: ${member.model}`} @@ -303,7 +329,7 @@ export function TeamAction({

    {t('tasks')}

    {creating && ( @@ -334,7 +360,10 @@ export function TeamAction({
    {task.subject} - {t(statusKey(task.status))} + + + {t(statusKey(task.status))} +

    {task.description}

    @@ -365,14 +394,14 @@ export function TeamAction({ {task.status === 'in_progress' && ( + }}> {t('complete')} )} {task.status === 'completed' && ( + }}> {t('delete')}
    ))} diff --git a/packages/experimental/client-ui-agent-team/src/client/locales.ts b/packages/experimental/client-ui-agent-team/src/client/locales.ts index 692df60d64..930446a9d7 100644 --- a/packages/experimental/client-ui-agent-team/src/client/locales.ts +++ b/packages/experimental/client-ui-agent-team/src/client/locales.ts @@ -33,7 +33,6 @@ export const zh = { blocked: '被依赖阻塞', conflict: '任务状态已变化,已重新加载;请检查后重试。', 'memberStatus.running': '运行中', - 'memberStatus.idle': '空闲', 'memberStatus.inactive': '未运行', 'memberStatus.provisioning': '准备中', 'memberStatus.failed': '失败', @@ -75,7 +74,6 @@ export const en = { blocked: 'Blocked by dependencies', conflict: 'Task state changed and was reloaded. Review it before retrying.', 'memberStatus.running': 'Running', - 'memberStatus.idle': 'Idle', 'memberStatus.inactive': 'Inactive', 'memberStatus.provisioning': 'Provisioning', 'memberStatus.failed': 'Failed', diff --git a/packages/experimental/client-ui-agent-team/tests/browser-plugin.client.spec.ts b/packages/experimental/client-ui-agent-team/tests/browser-plugin.client.spec.ts index b1e473d518..360839e3dc 100644 --- a/packages/experimental/client-ui-agent-team/tests/browser-plugin.client.spec.ts +++ b/packages/experimental/client-ui-agent-team/tests/browser-plugin.client.spec.ts @@ -56,7 +56,7 @@ async function bench(options: { } const view = { members: [{ - id: SESSION, name: 'lead', role: 'lead' as const, status: 'idle' as const, diagnostics: [], + id: SESSION, name: 'lead', role: 'lead' as const, status: 'inactive' as const, diagnostics: [], }], tasks: [task], } ctx.provide('remote.agentTeams', { @@ -179,7 +179,7 @@ describe('ui-team browser plugin', () => { id: SESSION, name: 'lead', role: 'lead', - status: 'idle', + status: 'inactive', diagnostics: [], }) expect(b.navigation).toEqual([]) diff --git a/packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx b/packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx index 51d59f1732..52136ffc1e 100644 --- a/packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx +++ b/packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx @@ -33,7 +33,7 @@ const task: TeamTask = { } const view: TeamView = { members: [ - { id: SESSION, name: 'lead', role: 'lead', status: 'idle', model: 'model-a', diagnostics: [] }, + { id: SESSION, name: 'lead', role: 'lead', status: 'inactive', model: 'model-a', diagnostics: [] }, { id: 'worker-id' as SessionId, name: 'worker', @@ -95,7 +95,7 @@ describe('TeamAction', () => { const firstLoad = Promise.withResolvers<{ ok: true; value: TeamView }>() const nextView: TeamView = { ...view, - members: [{ id: nextSession, name: 'lead', role: 'lead', status: 'idle', diagnostics: [] }], + members: [{ id: nextSession, name: 'lead', role: 'lead', status: 'inactive', diagnostics: [] }], tasks: [{ ...task, id: 'task-next' as TeamTaskId, subject: 'Next session task' }], } const load = vi.fn((sessionId: SessionId) => sessionId === SESSION @@ -145,6 +145,7 @@ describe('TeamAction', () => { const refresh = screen.getByRole('button', { name: zh.refresh }) fireEvent.click(refresh) + expect(screen.getByRole('status', { name: zh.loading })).toBeTruthy() fireEvent.click(refresh) newer.resolve({ ok: true, value: newestView }) expect(await screen.findByText('Newest task')).toBeTruthy() @@ -500,8 +501,15 @@ describe('TeamAction', () => { expect(await screen.findByText('provider failed')).toBeTruthy() expect(screen.getByText(zh.ready)).toBeTruthy() expect(screen.getByText(zh.blocked)).toBeTruthy() - expect(screen.getByRole('button', { name: /failed-worker/u }).disabled).toBe(true) - expect(screen.getByRole('button', { name: /provisioning-worker/u }).disabled).toBe(true) + const failedMember = screen.getByRole('button', { name: /failed-worker/u }) + const provisioningMember = screen.getByRole('button', { name: /provisioning-worker/u }) + expect(failedMember.disabled).toBe(true) + expect(failedMember.querySelector('[data-state="error"]')).not.toBeNull() + expect(provisioningMember.disabled).toBe(true) + expect(provisioningMember.querySelector('[data-state="ongoing"]')).not.toBeNull() + const tasks = [...document.querySelectorAll('article')] + expect(tasks.map(card => card.querySelector('[data-state]')?.getAttribute('data-state'))) + .toEqual(['idle', 'warning', 'done']) fireEvent.click(screen.getByRole('button', { name: /^worker运行中/u })) expect(await screen.findByText('Error: navigation failed')).toBeTruthy() diff --git a/packages/experimental/computer-use-cua-driver-mcp/package.json b/packages/experimental/computer-use-cua-driver-mcp/package.json index cb3bbb5074..6dc75993e4 100644 --- a/packages/experimental/computer-use-cua-driver-mcp/package.json +++ b/packages/experimental/computer-use-cua-driver-mcp/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-computer-use-cua-driver-mcp", "description": "Experimental computer use through an installed Cua Driver MCP executable", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/experimental/computer-use-cua-driver-native/package.json b/packages/experimental/computer-use-cua-driver-native/package.json index dc84730248..933322cf08 100644 --- a/packages/experimental/computer-use-cua-driver-native/package.json +++ b/packages/experimental/computer-use-cua-driver-native/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-computer-use-cua-driver-native", "description": "Experimental computer-use provider embedding the Cua Driver native npm SDK", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/experimental/inspector/package.json b/packages/experimental/inspector/package.json index 11af6268c6..8dcdb4af44 100644 --- a/packages/experimental/inspector/package.json +++ b/packages/experimental/inspector/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-inspector", "description": "Experimental cross-realm CDP hub for Host debugging and Client Runtime inspection", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/experimental/inspector/src/client/cdp/sources.ts b/packages/experimental/inspector/src/client/cdp/sources.ts index 23d63909a9..5812464de9 100644 --- a/packages/experimental/inspector/src/client/cdp/sources.ts +++ b/packages/experimental/inspector/src/client/cdp/sources.ts @@ -181,7 +181,7 @@ export function discoverInspectorClientSourceCatalog(): ClientSourceCatalog | un return Reflect.get(value, 'id') === PACKAGE_ID }) as Record | undefined if (row === undefined || typeof row.url !== 'string' || typeof row.rev !== 'string') return undefined - const base = browserLocation() + const base = documentBase() if (base === undefined) return undefined const sourceUrl = new URL(row.url, base) const sourceMapUrl = new URL(sourceUrl.href) @@ -203,7 +203,16 @@ async function fetchText(url: string): Promise { return response.text() } -function browserLocation(): string | undefined { +/** + * Base every app-owned route reference resolves against: the document's + * `baseURI`, else the location URL, else undefined outside a browser. + */ +function documentBase(): string | undefined { + const document = Reflect.get(globalThis, 'document') as unknown + if (typeof document === 'object' && document !== null) { + const baseURI = Reflect.get(document, 'baseURI') as unknown + if (typeof baseURI === 'string' && baseURI !== '') return baseURI + } const location = Reflect.get(globalThis, 'location') as unknown if (typeof location !== 'object' || location === null) return undefined const href = Reflect.get(location, 'href') as unknown @@ -224,7 +233,7 @@ function renderError(error: unknown): string { function normalizedUrl(value: string): string { try { - const url = new URL(value, browserLocation()) + const url = new URL(value, documentBase()) url.hash = '' return url.href } catch { diff --git a/packages/experimental/inspector/tests/plugin.client.spec.ts b/packages/experimental/inspector/tests/plugin.client.spec.ts index 6f18841eae..f997229925 100644 --- a/packages/experimental/inspector/tests/plugin.client.spec.ts +++ b/packages/experimental/inspector/tests/plugin.client.spec.ts @@ -366,7 +366,7 @@ describe('experimental Inspector Client plugin', () => { rev: 'graph', entries: [{ id: '@deepseek-ai/dsh-experimental-inspector', - url: '/plugins/@deepseek-ai/dsh-experimental-inspector/client.js?rev=bundle-rev', + url: 'plugins/@deepseek-ai/dsh-experimental-inspector/client.js?rev=bundle-rev', rev: 'bundle-rev', }], }) diff --git a/packages/experimental/ptc-runtime-python/package.json b/packages/experimental/ptc-runtime-python/package.json index f70aea8334..48a103ef57 100644 --- a/packages/experimental/ptc-runtime-python/package.json +++ b/packages/experimental/ptc-runtime-python/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-ptc-runtime-python", "description": "CPython subprocess implementation of the DeepSeek Harness PTC execution seam", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "repository": { "type": "git", "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", diff --git a/packages/experimental/tool-agent-team/README.i18n.yaml b/packages/experimental/tool-agent-team/README.i18n.yaml index 3658a87d71..2b30aa79fc 100644 --- a/packages/experimental/tool-agent-team/README.i18n.yaml +++ b/packages/experimental/tool-agent-team/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/experimental/tool-agent-team/README.md -README.md: 225b9cb90173803ba7c777c5d643e04e47c6c602 -README.zh.md: 382c60ee0c92c26919d52ae24137913800fb5fa7 +README.md: bfa3fc7f466d4b7f405231de3de4cf648fc690de +README.zh.md: 990e786fd69877af17cef339a696bb5e93d5925c diff --git a/packages/experimental/tool-agent-team/README.md b/packages/experimental/tool-agent-team/README.md index 225b9cb901..bfa3fc7f46 100644 --- a/packages/experimental/tool-agent-team/README.md +++ b/packages/experimental/tool-agent-team/README.md @@ -57,11 +57,11 @@ Try it by asking the Lead model: "create a teammate named reviewer to check the The nine tools group into four capabilities: - **Create a teammate** — `spawn_teammate` takes a name, a description, and the initial task; only the Lead can call it. -- **Send messages** — `send_message` steers a running member at its nearest step boundary, starts an idle member, and cold-resumes an inactive teammate. -- **See and wait** — `list_agents` shows the roster with live status; `wait_agent` waits for the next team change; `interrupt_agent` stops a teammate's current turn (Lead only). +- **Send messages** — `send_message` steers a running member at its nearest step boundary, starts or resumes an inactive member. +- **See and wait** — `list_agents` returns each member’s `target` and availability; `wait_agent` waits for the next team change; `interrupt_agent` stops a teammate's current turn (Lead only). - **Manage the task board** — `team_task_create`, `team_task_list`, `team_task_get`, and `team_task_update` add, browse, read, and update shared tasks. -Any member can message any other member and use the task board; only the Lead creates and interrupts teammates. Task updates keep the domain's owner and revision checks, so an outdated edit is rejected instead of overwriting newer work. +Creation and listing results identify members by `target`, with no member Session ID. Use that value in message and interrupt calls or the task tools’ `owner` parameter; task `ownerName` uses the same value. `inactive` means no turn is executing, whether the member is loaded or must be resumed; it does not describe task completion or outcome. `provisioning` and `failed` describe member creation. Any member can message any other member and use the task board; only the Lead creates and interrupts teammates. Task updates keep the domain's owner and revision checks, so an outdated edit is rejected instead of overwriting newer work. ### What success and failure look like diff --git a/packages/experimental/tool-agent-team/README.zh.md b/packages/experimental/tool-agent-team/README.zh.md index 382c60ee0c..990e786fd6 100644 --- a/packages/experimental/tool-agent-team/README.zh.md +++ b/packages/experimental/tool-agent-team/README.zh.md @@ -57,11 +57,11 @@ kind: "package-reference" 九个工具分为四类能力: - **创建 teammate**——`spawn_teammate` 接收名字、描述与初始任务;只有 Lead 可以调用它。 -- **发送消息**——`send_message` 在最近的步骤边界对运行中的成员进行 steering(中途引导)、启动空闲成员,并冷恢复非活动 teammate。 -- **查看与等待**——`list_agents` 显示带实时状态的 roster;`wait_agent` 等待下一次团队变化;`interrupt_agent` 停止 teammate 的当前轮次(仅限 Lead)。 +- **发送消息**——`send_message` 在最近的步骤边界对运行中的成员进行 steering(中途引导)、启动或恢复非活动成员。 +- **查看与等待**——`list_agents` 返回各成员的 `target` 与可用状态;`wait_agent` 等待下一次团队变化;`interrupt_agent` 停止 teammate 的当前轮次(仅限 Lead)。 - **管理任务板**——`team_task_create`、`team_task_list`、`team_task_get` 与 `team_task_update` 添加、浏览、读取与更新共享任务。 -任何成员都可以给任何其他成员发消息并使用任务板;只有 Lead 可以创建与中断 teammate。任务更新保留领域的 owner 与 revision 校验,因此过期的编辑会被拒绝,而不是覆盖更新的成果。 +创建和列表结果使用 `target` 标识成员,不包含成员 Session ID。可将该值用于消息和中断调用,或任务工具的 `owner` 参数;任务的 `ownerName` 使用相同值。`inactive` 表示没有轮次在执行,包括已加载和需要恢复的成员;它不表示任务完成或结果。`provisioning` 与 `failed` 描述成员创建状态。任何成员都可以给任何其他成员发消息并使用任务板;只有 Lead 可以创建与中断 teammate。任务更新保留领域的 owner 与 revision 校验,因此过期的编辑会被拒绝,而不是覆盖更新的成果。 ### 成功与失败的表现 diff --git a/packages/experimental/tool-agent-team/package.json b/packages/experimental/tool-agent-team/package.json index 93bd42f7e6..f638578aac 100644 --- a/packages/experimental/tool-agent-team/package.json +++ b/packages/experimental/tool-agent-team/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-tool-agent-team", "description": "Scoped model-facing Agent Teams tools over ctx.agentTeams", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/experimental/tool-agent-team/src/index.ts b/packages/experimental/tool-agent-team/src/index.ts index 8b2c3ec4a7..3ab4b77235 100644 --- a/packages/experimental/tool-agent-team/src/index.ts +++ b/packages/experimental/tool-agent-team/src/index.ts @@ -34,13 +34,13 @@ The Team Lead and all teammates share the same working directory and filesystem. Prefer read/edit/write for file changes. If a file operation returns FS_STALE_VERSION, read the current file, rebase your intended change onto the new content, and retry. Bash, formatters, code generators, and scripts are not fully protected by the filesystem version guard; coordinate them explicitly and have the Lead review the final diff and run tests. -send_message steers a running target at its nearest step boundary, starts an idle target, and cold-resumes an inactive teammate. A delivered peer item starts with its stable message id and sender name. A successful send is already durable even when its result says queued; do not resend it. Shared-task workflow is list, get, claim with the current revision, perform the work, then complete. Task readiness never starts an owner. Before wait_agent, use list_agents and make sure another required member is running or provisioning; use send_message first when the required member is inactive. wait_agent observes only changes after that call starts, never wakes a member, and returns noProgress immediately when no other member can produce a change. Re-list after wakeup or timeout. The Lead must wait for required teammates before giving the final answer.` +Use the target returned by spawn_teammate or list_agents for send_message and interrupt_agent, or as owner when assigning or filtering shared tasks. send_message steers a running target at its nearest step boundary and starts or resumes an inactive target. inactive means no turn is executing; it does not describe task completion, success, failure, or waiting for other agents. provisioning means member creation is in progress; failed means member creation failed. A delivered peer item starts with its stable message id and sender name. A successful send is already durable even when its result says queued; do not resend it. Shared-task workflow is list, get, claim with the current revision, perform the work, then complete. Task readiness never starts an owner. Before wait_agent, use list_agents and make sure another required member is running or provisioning; use send_message first when the required member is inactive. wait_agent observes only changes after that call starts, never wakes a member, and returns noProgress immediately when no other member can produce a change. Re-list after wakeup or timeout. The Lead must wait for required teammates before giving the final answer.` const ACTIVE_WAIT_STATUSES: ReadonlySet = new Set(['running', 'provisioning']) const NO_ACTIVE_PEER_MESSAGE = 'No other Team member is running or provisioning. wait_agent cannot make progress or wake inactive teammates. Re-list with list_agents and team_task_list, then use send_message to wake each required inactive teammate before waiting again.' /** - * One roster row, matching `TeamMemberView`. The Lead pseudo-row omits the + * One model-facing roster row. The Lead pseudo-row omits the * teammate-only provisioning fields, so only identity, role, status, and * diagnostics are required. */ @@ -48,10 +48,9 @@ const MEMBER_VIEW_SCHEMA = { type: 'object', additionalProperties: false, properties: { - id: { type: 'string', required: true }, - name: { type: 'string', required: true }, + target: { type: 'string', required: true }, role: { type: 'string', required: true, enum: ['lead', 'teammate'] }, - status: { type: 'string', required: true, enum: ['running', 'idle', 'inactive', 'provisioning', 'failed'] }, + status: { type: 'string', required: true, enum: ['running', 'inactive', 'provisioning', 'failed'] }, description: { type: 'string' }, provider: { type: 'string' }, context: { type: 'string', enum: ['fresh', 'fork'] }, @@ -60,6 +59,12 @@ const MEMBER_VIEW_SCHEMA = { }, } as const +/** Expose the member name as its model-facing target. */ +function modelMember(member: TeamMemberView): InferValue { + const { id: _id, name, ...details } = member + return { target: name, ...details } +} + /** One shared task, matching the public `TeamTaskView`. */ const TASK_VIEW_SCHEMA = { type: 'object', @@ -118,7 +123,7 @@ const INTERRUPT_VALUE_SCHEMA = { type: 'object', additionalProperties: false, properties: { - previousStatus: { type: 'string', required: true, enum: ['running', 'idle', 'inactive'] }, + previousStatus: { type: 'string', required: true, enum: ['running', 'inactive'] }, }, } as const @@ -184,7 +189,7 @@ function install(agent: Agent, ctx: Context, config: Required): () => vo async execute(args, exec) { const agent = callingAgent(exec.agent, 'spawn_teammate') const context = args.context ?? 'fresh' - return await ctx.agentTeams.spawnTeammate(agent, { + const result = await ctx.agentTeams.spawnTeammate(agent, { name: args.name, description: args.description, prompt: [ @@ -195,14 +200,15 @@ function install(agent: Agent, ctx: Context, config: Required): () => vo provider: context === 'fork' ? config.forkProvider : config.freshProvider, signal: exec.signal, }) + return { member: modelMember(result.member) } }, }))) register(scoped.tools.register(defineTool({ name: 'send_message', - description: 'Send one durable message to another Team member. A running target receives it at the nearest step boundary; an idle target starts a turn; an inactive teammate cold-resumes.', + description: 'Send one durable message to another Team member. A running target receives it at the nearest step boundary; an inactive target starts or resumes a turn.', parameters: { - target: { type: 'string', required: true, description: 'Team member name, or lead.' }, + target: { type: 'string', required: true, description: 'Member target returned by spawn_teammate or list_agents, including lead.' }, message: { type: 'string', required: true, description: 'Self-contained message for the target.' }, }, output: jsonOutput(SEND_VALUE_SCHEMA), @@ -217,11 +223,11 @@ function install(agent: Agent, ctx: Context, config: Required): () => vo register(scoped.tools.register(defineTool({ name: 'list_agents', - description: 'List the Lead and every durable teammate with current runtime status.', + description: 'List the Lead and every durable teammate with an addressable target and current availability. inactive means no turn is executing, not a task result. provisioning and failed describe member creation.', parameters: {}, output: jsonOutput(MEMBER_LIST_VALUE_SCHEMA), - async execute(_args, exec) { - return Promise.resolve(ctx.agentTeams.listMembers(callingAgent(exec.agent, 'list_agents'))) + execute(_args, exec) { + return Promise.resolve(ctx.agentTeams.listMembers(callingAgent(exec.agent, 'list_agents')).map(modelMember)) }, }))) @@ -264,14 +270,11 @@ function install(agent: Agent, ctx: Context, config: Required): () => vo name: 'interrupt_agent', description: 'Interrupt one teammate\'s current turn while preserving its pending inbox. Team Lead only.', parameters: { - target: { type: 'string', required: true, description: 'Teammate name.' }, + target: { type: 'string', required: true, description: 'Teammate target returned by spawn_teammate or list_agents.' }, }, output: jsonOutput(INTERRUPT_VALUE_SCHEMA), - async execute(args, exec) { - return Promise.resolve(ctx.agentTeams.interrupt( - callingAgent(exec.agent, 'interrupt_agent'), - args.target, - )) + execute(args, exec) { + return Promise.resolve(ctx.agentTeams.interrupt(callingAgent(exec.agent, 'interrupt_agent'), args.target)) }, }))) @@ -308,7 +311,7 @@ function install(agent: Agent, ctx: Context, config: Required): () => vo enum: ['pending', 'in_progress', 'completed'], description: 'Optional exact status filter.', }, - owner: { type: 'string', description: 'Optional member-name filter; use unowned for tasks without an owner.' }, + owner: { type: 'string', description: 'Optional member target from spawn_teammate or list_agents, matching ownerName; use unowned for tasks without an owner.' }, ready: { type: 'boolean', description: 'Optional readiness filter.' }, cursor: { type: 'integer', description: 'Zero-based result offset. Defaults to 0.' }, limit: { type: 'integer', description: 'Number of rows, 1 through 100. Defaults to 50.' }, @@ -362,7 +365,7 @@ function install(agent: Agent, ctx: Context, config: Required): () => vo description: { type: 'string', description: 'Replacement details for edit.' }, blocked_by: { type: 'array', items: { type: 'string' }, description: 'Complete blocker list for set_dependencies.' }, write_scopes: { type: 'array', items: { type: 'string' }, description: 'Replacement advisory write scopes for edit.' }, - owner: { type: 'string', description: 'Member name for Lead-only reassign; omit to unassign.' }, + owner: { type: 'string', description: 'Member target from spawn_teammate or list_agents for Lead-only reassign; omit to unassign.' }, }, output: jsonOutput(TASK_VIEW_SCHEMA), async execute(args, exec) { diff --git a/packages/experimental/tool-agent-team/tests/tool-team.spec.ts b/packages/experimental/tool-agent-team/tests/tool-team.spec.ts index bd09be0221..09025f208a 100644 --- a/packages/experimental/tool-agent-team/tests/tool-team.spec.ts +++ b/packages/experimental/tool-agent-team/tests/tool-team.spec.ts @@ -97,16 +97,11 @@ function text(result: Awaited>): string { return result.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('') } -function spawnedChildId(result: Awaited>): SessionId { - const parsed: unknown = JSON.parse(text(result)) - if (typeof parsed !== 'object' || parsed === null || !('member' in parsed)) { - throw new Error('spawn_teammate result has no member') - } - const member = parsed.member - if (typeof member !== 'object' || member === null || !('id' in member) || typeof member.id !== 'string') { - throw new Error('spawn_teammate result has no member id') - } - return SessionId(member.id) +function spawnedChildId(ctx: Context, lead: Agent, result: Awaited>): SessionId { + const parsed = JSON.parse(text(result)) as { member: { target: string } } + const member = ctx.agentTeams.listMembers(lead).find(member => member.name === parsed.member.target) + if (member === undefined) throw new Error('spawn_teammate target has no roster member') + return member.id } async function assembly(ctx: Context, agent: Agent) { @@ -133,6 +128,81 @@ async function waitNoAgent(ctx: Context, id: SessionId): Promise { } describe('dsh-tool-team', () => { + it.each(['running', 'inactive', 'provisioning', 'failed'] as const)( + 'projects %s members consistently in creation, listing, and schemas', async (status) => { + const { ctx, lead } = await setup([]) + const member = { + id: SessionId('private-member-session'), name: 'reviewer', role: 'teammate' as const, + status, description: 'review changes', diagnostics: [], + } + vi.spyOn(ctx.agentTeams, 'spawnTeammate').mockResolvedValue({ member }) + vi.spyOn(ctx.agentTeams, 'listMembers').mockReturnValue([member]) + const expected = { + target: 'reviewer', role: 'teammate', status, + description: 'review changes', diagnostics: [], + } + const spawned = await execute(ctx, lead, 'spawn_teammate', { + name: 'reviewer', description: 'review changes', prompt: 'review', + }) + const listed = await execute(ctx, lead, 'list_agents', {}) + expect(spawned.isError).toBe(false) + expect(listed.isError).toBe(false) + expect(JSON.parse(text(spawned))).toEqual({ member: expected }) + expect(JSON.parse(text(listed))).toEqual([expected]) + const scope = scopeOf(lead.ctx) + const spawnSchema = ctx.tools.get('spawn_teammate', scope)?.output.schema.properties?.member + const listSchema = ctx.tools.get('list_agents', scope)?.output.schema.items + for (const schema of [spawnSchema, listSchema]) { + expect(schema?.properties).toHaveProperty('target') + expect(schema?.properties).not.toHaveProperty('id') + expect(schema?.properties).not.toHaveProperty('name') + expect(schema?.properties?.status?.enum).toEqual(['running', 'inactive', 'provisioning', 'failed']) + } + expect(ctx.agentTeams.listMembers(lead)).toEqual([member]) + }, + ) + + it.each(['running', 'inactive'] as const)('returns interrupted %s status', async (previousStatus) => { + const { ctx, lead } = await setup([]) + const interrupt = vi.spyOn(ctx.agentTeams, 'interrupt').mockReturnValue({ previousStatus }) + const result = await execute(ctx, lead, 'interrupt_agent', { target: 'reviewer' }) + expect(JSON.parse(text(result))).toEqual({ previousStatus }) + expect(interrupt).toHaveBeenCalledWith(lead, 'reviewer') + }) + + it('uses returned targets for messages, interruption, and task assignment', async () => { + const { ctx, lead } = await setup(['hang', 'hang']) + const spawned = await execute(ctx, lead, 'spawn_teammate', { + name: 'reviewer', description: 'review changes', prompt: 'wait for work', + }) + const { member } = JSON.parse(text(spawned)) as { member: { target: string } } + const childId = spawnedChildId(ctx, lead, spawned) + const child = await waitRunning(ctx, childId) + expect(member).not.toHaveProperty('id') + expect(member).not.toHaveProperty('name') + const listed = JSON.parse(text(await execute(ctx, lead, 'list_agents', {}))) as Array<{ target: string }> + expect(listed.map(row => row.target)).toEqual(['lead', member.target]) + const created = await execute(ctx, lead, 'team_task_create', { subject: 'review', description: 'review changes' }) + const task = JSON.parse(text(created)) as { id: string; revision: number } + const assigned = await execute(ctx, lead, 'team_task_update', { + task_id: task.id, expected_revision: task.revision, action: 'reassign', owner: member.target, + }) + expect(assigned.isError).toBe(false) + expect(JSON.parse(text(assigned))).toMatchObject({ ownerName: member.target }) + const tasks = await execute(ctx, lead, 'team_task_list', { owner: listed[1]!.target }) + expect(JSON.parse(text(tasks))).toMatchObject({ tasks: [{ id: task.id, ownerName: member.target }] }) + const sent = await execute(ctx, lead, 'send_message', { target: member.target, message: 'review the diff' }) + expect(sent.isError).toBe(false) + const interrupted = await execute(ctx, lead, 'interrupt_agent', { target: listed[1]!.target }) + expect(interrupted.isError).toBe(false) + await child.whenIdle() + expect(child.status).toBe('idle') + expect(ctx.agentTeams.interrupt(lead, member.target)).toEqual({ previousStatus: 'inactive' }) + const stored = await execute(ctx, lead, 'list_agents', {}) + expect(JSON.parse(text(stored))).toContainEqual(expect.objectContaining({ target: member.target, status: 'inactive' })) + expect(ctx.agentTeams.listMembers(lead)[1]).toMatchObject({ id: childId, name: member.target, status: 'inactive' }) + }) + it('installs the complete scoped schema and shared-checkout policy for roots and teammates', async () => { const { ctx, lead } = await setup(['hang']) const leadAssembly = await assembly(ctx, lead) @@ -153,7 +223,7 @@ describe('dsh-tool-team', () => { prompt: 'stay available', }) expect(spawned.isError, text(spawned)).toBe(false) - const childId = spawnedChildId(spawned) + const childId = spawnedChildId(ctx, lead, spawned) const child = await waitRunning(ctx, childId) const childAssembly = await assembly(ctx, child) expect(childAssembly.tools.map(schema => schema.name).filter(name => TOOL_NAMES.includes(name)).sort()) @@ -189,7 +259,7 @@ describe('dsh-tool-team', () => { name: 'reviewer', description: 'review', prompt: 'Review the work', context: mode, }) expect(spawned.isError, text(spawned)).toBe(false) - const childId = spawnedChildId(spawned) + const childId = spawnedChildId(ctx, lead, spawned) await waitNoAgent(ctx, childId) const childRequest = serializeRequest(adapter.requests[1]!) expect(childRequest.tools).toEqual(parentRequest.tools) @@ -245,7 +315,7 @@ describe('dsh-tool-team', () => { const spawned = await execute(ctx, lead, 'spawn_teammate', { name: 'reviewer', description: 'review', prompt: 'Review the work', }) - const teammateId = spawnedChildId(spawned) + const teammateId = spawnedChildId(ctx, lead, spawned) await waitNoAgent(ctx, teammateId) await using persisted = await ctx.sessionPersistence.open(teammateId, 'read') const { events: seed } = await persisted.read() @@ -298,7 +368,7 @@ describe('dsh-tool-team', () => { const spawned = await execute(ctx, lead, 'spawn_teammate', { name: 'reviewer', description: 'review', prompt: 'Review the work', }) - const childId = spawnedChildId(spawned) + const childId = spawnedChildId(ctx, lead, spawned) await waitNoAgent(ctx, childId) const reminder = '\nYou are teammate "reviewer".\n\n\n' const first = serializeRequest(adapter.requests[0]!).messages @@ -340,7 +410,7 @@ describe('dsh-tool-team', () => { name: 'reviewer', description: 'review', prompt: 'Review the work', }) expect(spawned.isError, text(spawned)).toBe(false) - await waitNoAgent(ctx, spawnedChildId(spawned)) + await waitNoAgent(ctx, spawnedChildId(ctx, lead, spawned)) expect(serializeRequest(adapter.requests[0]!).messages.at(-1)?.content) .toBe('\nYou are teammate "reviewer".\n\n\nReview the work') }) @@ -350,7 +420,7 @@ describe('dsh-tool-team', () => { const inactiveSpawn = await execute(inactiveSetup.ctx, inactiveSetup.lead, 'spawn_teammate', { name: 'inactive-worker', description: 'finish immediately', prompt: 'finish', }) - const inactiveId = spawnedChildId(inactiveSpawn) + const inactiveId = spawnedChildId(inactiveSetup.ctx, inactiveSetup.lead, inactiveSpawn) await waitNoAgent(inactiveSetup.ctx, inactiveId) const noProgress = await execute(inactiveSetup.ctx, inactiveSetup.lead, 'wait_agent', { timeout_ms: 3_600_000 }) expect(noProgress.isError).toBe(false) @@ -371,7 +441,7 @@ describe('dsh-tool-team', () => { const activeSpawn = await execute(activeSetup.ctx, activeSetup.lead, 'spawn_teammate', { name: 'active-worker', description: 'stay active', prompt: 'wait', }) - const activeId = spawnedChildId(activeSpawn) + const activeId = spawnedChildId(activeSetup.ctx, activeSetup.lead, activeSpawn) await waitRunning(activeSetup.ctx, activeId) const controller = new AbortController() const waiting = execute(activeSetup.ctx, activeSetup.lead, 'wait_agent', { timeout_ms: 10_000 }, controller.signal) @@ -389,13 +459,13 @@ describe('dsh-tool-team', () => { const spawned = await execute(ctx, lead, 'spawn_teammate', { name: 'json-worker', description: 'json worker', prompt: 'wait', context: 'fresh', }) - const childId = spawnedChildId(spawned) + const childId = spawnedChildId(ctx, lead, spawned) const child = await waitRunning(ctx, childId) const roster = await execute(ctx, child, 'list_agents', {}) expect(JSON.parse(text(roster))).toMatchObject([ - { name: 'lead', role: 'lead' }, - { name: 'json-worker', role: 'teammate' }, + { target: 'lead', role: 'lead' }, + { target: 'json-worker', role: 'teammate' }, ]) // Every Team result reaches the model as compact JSON: indentation would // spend tokens on every roster, task, and receipt without adding meaning. @@ -457,7 +527,7 @@ describe('dsh-tool-team', () => { const spawned = await execute(ctx, lead, 'spawn_teammate', { name: 'fork-worker', description: 'fork worker', prompt: 'stay active', context: 'fork', }) - const childId = spawnedChildId(spawned) + const childId = spawnedChildId(ctx, lead, spawned) await waitRunning(ctx, childId) const firstResult = await execute(ctx, lead, 'team_task_create', { @@ -532,7 +602,7 @@ describe('dsh-tool-team', () => { const spawned = await execute(ctx, lead, 'spawn_teammate', { name: 'hmr-worker', description: 'hmr worker', prompt: 'wait', }) - const childId = spawnedChildId(spawned) + const childId = spawnedChildId(ctx, lead, spawned) const child = await waitRunning(ctx, childId) await fiber.dispose() @@ -598,7 +668,7 @@ describe('dsh-tool-team', () => { const spawned = await execute(ctx, lead, 'spawn_teammate', { name: 'cold-worker', description: 'cold worker', prompt: 'finish once', }) - const childId = spawnedChildId(spawned) + const childId = spawnedChildId(ctx, lead, spawned) await vi.waitFor(() => { expect(ctx.agents.get(childId)).toBeUndefined() }, { timeout: 5_000 }) await ctx.agentTeams.sendMessage(lead, { @@ -640,7 +710,7 @@ describe('dsh-tool-team', () => { name: 'custom-provider', description: 'custom provider', prompt: 'go', }) expect(result.isError).toBe(false) - const childId = spawnedChildId(result) + const childId = spawnedChildId(ctx, lead, result) await vi.waitFor(() => { expect(ctx.agents.get(childId)).toBeUndefined() }, { timeout: 5_000 }) expect(ctx.agentTeams.listMembers(lead)[1]).toMatchObject({ provider: 'team-fresh' }) }) diff --git a/packages/experimental/webworker-packer/package.json b/packages/experimental/webworker-packer/package.json index e62ace335a..ae7d6748a0 100644 --- a/packages/experimental/webworker-packer/package.json +++ b/packages/experimental/webworker-packer/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-webworker-packer", "description": "Build-time packer for the browser runtime's base VFS image and ordered data-overlay archives", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/experimental/webworker-runtime/package.json b/packages/experimental/webworker-runtime/package.json index 5185a17489..c8fa0e5582 100644 --- a/packages/experimental/webworker-runtime/package.json +++ b/packages/experimental/webworker-runtime/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-webworker-runtime", "description": "Browser-only harness runtime: in-memory VFS, module transform and loader, postMessage tunnel, and the dedicated Web Worker assembly, with the Node-compatibility layer that lets the host tree run unchanged", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/experimental/webworker-runtime/src/client/client.ts b/packages/experimental/webworker-runtime/src/client/client.ts index 6afb527705..a569e48276 100644 --- a/packages/experimental/webworker-runtime/src/client/client.ts +++ b/packages/experimental/webworker-runtime/src/client/client.ts @@ -294,7 +294,8 @@ export class WorkerTunnel { * The image packs each bundle with a trailing `sourceURL` naming its image * path, so the blob shows under that name in the debugger instead of as an * anonymous blob entry. - * @param url - Graph combo URL (`/plugins/??/client.js&rev=...`). + * @param url - Graph combo reference (`plugins/??/client.js&rev=...`), which + * this tunnel resolves against the page origin it maps from. */ async loadBundle(url: string): Promise { const response = await this.fetch(url) diff --git a/packages/experimental/webworker-runtime/tests/client/apply-injections.spec.ts b/packages/experimental/webworker-runtime/tests/client/apply-injections.spec.ts index 2152d0eadd..1f5f09be12 100644 --- a/packages/experimental/webworker-runtime/tests/client/apply-injections.spec.ts +++ b/packages/experimental/webworker-runtime/tests/client/apply-injections.spec.ts @@ -9,8 +9,8 @@ afterEach(() => { it('ignores script preload hints and executes script sources through the worker loader', async () => { const loadScript = vi.fn(async () => {}) - const preload = '/plugins/??app-a/client.js,app-b/client.js&rev=app' - const bootstrap = '/plugins/??modules/client.js&rev=boot' + const preload = 'plugins/??app-a/client.js,app-b/client.js&rev=app' + const bootstrap = 'plugins/??modules/client.js&rev=boot' await applyIndexInjections([ { kind: 'script-preload', src: preload }, diff --git a/packages/experimental/webworker-runtime/tests/client/load-bundle.spec.ts b/packages/experimental/webworker-runtime/tests/client/load-bundle.spec.ts index 9c19fe4c15..62f0091041 100644 --- a/packages/experimental/webworker-runtime/tests/client/load-bundle.spec.ts +++ b/packages/experimental/webworker-runtime/tests/client/load-bundle.spec.ts @@ -30,6 +30,7 @@ afterEach(() => { }) it('loads a combo map through the tunnel and embeds it in the blob script', async () => { + document.head.innerHTML = '' const { worker, sent, deliver } = stubWorker() const tunnel = new WorkerTunnel(worker) const blobs: Blob[] = [] @@ -52,10 +53,11 @@ it('loads a combo map through the tunnel and embeds it in the blob script', asyn } }) - const scriptUrl = '/plugins/??a/client.js,b/client.js&rev=abc' - const mapUrl = '/plugins/??a/client.js.map,b/client.js.map&rev=abc' + // The worker's Host starts at '/', independently of the preview document directory. + const scriptUrl = 'plugins/??a/client.js,b/client.js&rev=abc' + const mapUrl = '??a/client.js.map,b/client.js.map&rev=abc' const loading = tunnel.loadBundle(scriptUrl) - expect(sent[0]?.url).toBe(`http://localhost:3000${scriptUrl}`) + expect(sent[0]?.url).toBe(`http://localhost:3000/${scriptUrl}`) deliver({ t: 'res', id: 1, @@ -64,7 +66,7 @@ it('loads a combo map through the tunnel and embeds it in the blob script', asyn body: new TextEncoder().encode(`factory();\n//# sourceMappingURL=${mapUrl}\n`).buffer, }) await vi.waitFor(() => { expect(sent).toHaveLength(2) }) - expect(sent[1]?.url).toBe(`http://localhost:3000${mapUrl}`) + expect(sent[1]?.url).toBe(`http://localhost:3000/plugins/${mapUrl}`) const map = '{"version":3,"sections":[]}' deliver({ t: 'res', diff --git a/packages/extensions/cordis-client-runner/package.json b/packages/extensions/cordis-client-runner/package.json index ba27b7bf20..f06794f87b 100644 --- a/packages/extensions/cordis-client-runner/package.json +++ b/packages/extensions/cordis-client-runner/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-cordis-client-runner", "description": "Browser half of dynamic dual-half plugin packages: event subscription, closure evaluation, guard facade, and loader entries", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index 55aa9fa686..f263f0ea5c 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -176,7 +176,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.assistant-actions\', () => ctx.slots.register(\n { name: \'conversation.chat.assistant-actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:219', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:227', }, { key: 'conversation.chat.commandview', @@ -224,7 +224,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.commandview\', () => ctx.slots.register(\n { name: \'conversation.chat.commandview\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:207', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:215', }, { key: 'conversation.chat.node', @@ -241,7 +241,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ }, ], ownerProps: [ - '/** Stable owner currency delivered to a keyed Chat renderer. */\nexport interface ChatNodeOwnerProps {\n cwd?: string | undefined\n /** Open the current source file of a skill referenced by a sent message. */\n openSkill: (name: string) => void\n openFile: (path: string, options?: OpenFileOptions) => void\n inspectCall: (callId: ToolCallId) => void\n forkAt: (seq: number) => void\n /**\n * Session-authorized image loader, down-threaded from the Chat view so a\n * chat-node renderer can render the attachment presentation slot directly\n * with only the durable references plus this loader, instead of receiving a\n * rendering closure.\n */\n loadImage: MessageImageLoader\n renderMessageImages: RenderMessageImages\n fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined\n /** Turn-process state when this Node belongs to a projected Turn. */\n turnProcess?: TurnProcessOwnerProps | undefined\n}', + '/** Stable owner currency delivered to a keyed Chat renderer. */\nexport interface ChatNodeOwnerProps {\n cwd?: string | undefined\n /** Open the current source file of a skill referenced by a sent message. */\n openSkill: (name: string) => void\n openFile: (path: string, options?: OpenFileOptions) => void\n inspectCall: ((callId: ToolCallId) => void) | undefined\n forkAt: (seq: number) => void\n /**\n * Session-authorized image loader, down-threaded from the Chat view so a\n * chat-node renderer can render the attachment presentation slot directly\n * with only the durable references plus this loader, instead of receiving a\n * rendering closure.\n */\n loadImage: MessageImageLoader\n renderMessageImages: RenderMessageImages\n fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined\n /** Turn-process state when this Node belongs to a projected Turn. */\n turnProcess?: TurnProcessOwnerProps | undefined\n}', ], ownerPropsReferences: [ 'MarkdownFileMentions', @@ -293,20 +293,32 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.node\', () => ctx.slots.register(\n { name: \'conversation.chat.node\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:188', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:196', }, { key: 'conversation.chat.turnTail', - kind: 'chain', + kind: 'list', scope: 'session', - summary: 'Selector-routed extension before a completed Turn\'s action row.', - doc: 'Selector-routed extension before a completed Turn\'s action row. The\ncomponent receives the Turn, closing sequence, and file opener. The first\nselector that accepts the owner renders; an all-declined chain is empty.', + summary: 'Ordered feature contributions before a completed Turn\'s action row.', + doc: 'Ordered feature contributions before a completed Turn\'s action row. Each\nentry receives the Turn, closing sequence, and file opener. A fresh `id`\nadds an entry; entries without content return null.', registerOptions: [ { - name: 'select', + name: 'id', requirement: 'required', - type: '(owner) => unknown | null', - doc: 'Pure routing selector. Entries are tried in ascending order; the first non-null result wins and arrives as the component\'s `matched` prop. All-null falls through to the owner\'s fallback.', + type: 'string', + doc: 'Your cell key. Use an id of your own: a fresh id is added beside the shipped entries, while reusing a shipped id puts you in THAT cell and replaces it. Owners that filter by id address you by it.', + }, + { + name: 'order', + requirement: 'optional', + type: 'number', + doc: 'Position among the entries, ascending (default 0).', + }, + { + name: 'label', + requirement: 'optional', + type: 'string | (() => string)', + doc: 'Display text where the owner projects one (nav rows, tabs). A thunk is re-read on every projection, so localized text follows the active locale without re-registering.', }, ], ownerProps: [ @@ -337,11 +349,12 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ slotInject: '', declaredBy: 'an entry in \'conversation.chat.node\' (client-ui-chat), so it exists while that entry is mounted', occupants: [ - 'client-ui-deliverables Deliverables', + 'client-ui-deliverables DeliverablesTail id \'@deepseek-ai/dsh-client-ui-deliverables\'', + 'client-ui-plan PlanCards', ], replaceRisk: 'none', - example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.turnTail\', () => ctx.slots.register(\n { name: \'conversation.chat.turnTail\', select: owner => null },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:213', + example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.turnTail\', () => ctx.slots.register(\n { name: \'conversation.chat.turnTail\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:221', }, { key: 'conversation.composer', @@ -393,7 +406,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.composer\', () => ctx.slots.register(\n { name: \'conversation.composer\', select: owner => null },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:170', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:183', }, { key: 'conversation.composer.bar', @@ -432,7 +445,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.composer.bar\', () => ctx.slots.register(\n { name: \'conversation.composer.bar\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:188', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:201', }, { key: 'conversation.composer.dock', @@ -488,7 +501,73 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.composer.dock\', () => ctx.slots.register(\n { name: \'conversation.composer.dock\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:182', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:195', + }, + { + key: 'conversation.header', + kind: 'single', + scope: 'session-maybe', + summary: 'Resident navigation container, including when no Session is selected.', + doc: 'Resident navigation container, including when no Session is selected.', + registerOptions: [], + ownerProps: [], + ownerPropsReferences: [], + standardProps: [ + 'useResource: UseResource', + 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', + 'useSessions: UseSessions', + 'useSessionStatus: UseSessionStatus', + 'useSessionRetainInfo: UseSessionRetainInfo', + 'useWorkspaces: SnapshotSelectorHook', + 'useConversation: MaybeSnapshotSelectorHook', + 'useInput: MaybeSnapshotSelectorHook', + 'inputActions: InputActions | undefined', + 'useSession: MaybeSnapshotSelectorHook', + 'sessionId: SessionId | undefined', + 'useProjection: UseProjection', + ], + keyDomain: '', + hookContext: '', + slotInject: '', + declaredBy: 'an entry in \'main.conversation\' (client-ui-conversation), so it exists while that entry is mounted', + occupants: [ + 'client-ui-conversation ConversationHeader', + ], + replaceRisk: 'shadows-shipped-ui', + example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.header\', () => ctx.slots.register(\n { name: \'conversation.header\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:135', + }, + { + key: 'conversation.header.leading', + kind: 'single', + scope: 'root', + summary: 'Global navigation before the Session title, available without a Session.', + doc: 'Global navigation before the Session title, available without a Session.', + registerOptions: [], + ownerProps: [ + '/** The leading seat exposes global navigation independently of a Session. */\nexport interface ConversationHeaderLeadingOwnerProps {\n /** Marker field: the occupant receives no owner-specific values. */\n children?: never\n}', + ], + ownerPropsReferences: [], + standardProps: [ + 'useResource: UseResource', + 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', + 'useSessions: UseSessions', + 'useSessionStatus: UseSessionStatus', + 'useSessionRetainInfo: UseSessionRetainInfo', + 'useWorkspaces: SnapshotSelectorHook', + ], + keyDomain: '', + hookContext: '', + slotInject: '', + declaredBy: 'an entry in \'conversation.header\' (client-ui-conversation), so it exists while that entry is mounted', + occupants: [ + 'client-ui-sidebar HeaderLeadingControls', + ], + replaceRisk: 'shadows-shipped-ui', + example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.header.leading\', () => ctx.slots.register(\n { name: \'conversation.header.leading\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:164', }, { key: 'conversation.hero.agentPreset', @@ -525,7 +604,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.agentPreset\', () => ctx.slots.register(\n { name: \'conversation.hero.agentPreset\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:176', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:189', }, { key: 'conversation.hero.brand.mark', @@ -554,7 +633,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.brand.mark\', () => ctx.slots.register(\n { name: \'conversation.hero.brand.mark\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:174', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:187', }, { key: 'conversation.hero.workspace', @@ -587,7 +666,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.workspace\', () => ctx.slots.register(\n { name: \'conversation.hero.workspace\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:172', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:185', }, { key: 'conversation.hero.workspace.directoryFlow', @@ -660,7 +739,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.attachments\', () => ctx.slots.register(\n { name: \'conversation.input.attachments\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:190', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:203', }, { key: 'conversation.input.dock', @@ -723,7 +802,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.dock\', () => ctx.slots.register(\n { name: \'conversation.input.dock\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:178', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:191', }, { key: 'conversation.input.left', @@ -777,7 +856,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.left\', () => ctx.slots.register(\n { name: \'conversation.input.left\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:184', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:197', }, { key: 'conversation.input.model', @@ -816,7 +895,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.model\', () => ctx.slots.register(\n { name: \'conversation.input.model\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:200', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:213', }, { key: 'conversation.input.overlay', @@ -874,7 +953,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.overlay\', () => ctx.slots.register(\n { name: \'conversation.input.overlay\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:180', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:193', }, { key: 'conversation.input.permission', @@ -913,7 +992,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.permission\', () => ctx.slots.register(\n { name: \'conversation.input.permission\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:198', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:211', }, { key: 'conversation.input.plan', @@ -952,7 +1031,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.plan\', () => ctx.slots.register(\n { name: \'conversation.input.plan\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:196', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:209', }, { key: 'conversation.input.right', @@ -1006,7 +1085,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.right\', () => ctx.slots.register(\n { name: \'conversation.input.right\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:186', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:199', }, { key: 'conversation.message.images', @@ -1016,7 +1095,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ doc: 'Renderer for one consecutive group of durable message images. The owner\nsupplies image references, an authorized loader, and alignment. A\nregistration replaces the shipped gallery; without one, images are omitted.', registerOptions: [], ownerProps: [ - '/** Message image group handed to the optional attachment presentation plugin. */\nexport interface MessageImagesOwnerProps {\n /** Durable references or submission-echo previews in source order. */\n images: readonly MessageImageSource[]\n /** Session-authorized image URL loader for the durable arm. */\n loadImage: MessageImageLoader\n /** Horizontal placement inside the owning record. */\n align: \'start\' | \'end\'\n /** Force every image into the compact message-attachment tile size. */\n compact?: boolean\n}', + '/** Message image group handed to the optional attachment presentation plugin. */\nexport interface MessageImagesOwnerProps {\n /** Durable references or submission-echo previews in source order. */\n images: readonly MessageImageSource[]\n /** Session-authorized image URL loader for the durable arm. */\n loadImage: MessageImageLoader\n /** Horizontal placement inside the owning record. */\n align: \'start\' | \'end\'\n /** Force every image into the compact message-attachment tile size. */\n compact?: boolean\n /** Fixed, uncropped thumbnail for an attachment list row. */\n thumbnail?: boolean\n}', ], ownerPropsReferences: [ 'Message', @@ -1049,7 +1128,65 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.message.images\', () => ctx.slots.register(\n { name: \'conversation.message.images\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:201', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:209', + }, + { + key: 'conversation.plan-review.actions', + kind: 'list', + scope: 'session', + summary: 'Actions for the exact plan under review; approval remains with the question composer.', + doc: 'Actions for the exact plan under review; approval remains with the question composer.', + registerOptions: [ + { + name: 'id', + requirement: 'required', + type: 'string', + doc: 'Your cell key. Use an id of your own: a fresh id is added beside the shipped entries, while reusing a shipped id puts you in THAT cell and replaces it. Owners that filter by id address you by it.', + }, + { + name: 'order', + requirement: 'optional', + type: 'number', + doc: 'Position among the entries, ascending (default 0).', + }, + { + name: 'label', + requirement: 'optional', + type: 'string | (() => string)', + doc: 'Display text where the owner projects one (nav rows, tabs). A thunk is re-read on every projection, so localized text follows the active locale without re-registering.', + }, + ], + ownerProps: [ + '/**\n * A request narrowed to the `plan-review` presentation intent: everything the\n * decision card renders and answers with, so the panel never re-reads the\n * request fields. `approve` and `decline` are the asker\'s own options — an\n * answer must carry one of those labels verbatim — and `plan` is the markdown\n * body under review.\n */\nexport interface PlanReview {\n /** The reviewed question\'s id, echoed in the answer. */\n id: string\n /** The question text, kept as the card\'s accessible name. */\n question: string\n /** The plan markdown under review. */\n plan: string\n /** Logged tool invocation used to reopen this plan. */\n callId?: ToolCallId\n /** The option that approves the plan. */\n approve: QuestionOption\n /** The option that declines it; absent when the asker offered no other option. */\n decline?: QuestionOption\n}', + ], + ownerPropsReferences: [], + standardProps: [ + 'useResource: UseResource', + 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', + 'useSessions: UseSessions', + 'useSessionStatus: UseSessionStatus', + 'useSessionRetainInfo: UseSessionRetainInfo', + 'useWorkspaces: SnapshotSelectorHook', + 'useChat: UseChat', + 'useConversation: UseConversation', + 'useInput: SnapshotSelectorHook', + 'inputActions: InputActions', + 'useSession: SessionSnapshotSelector', + 'sessionId: SessionId', + 'useProjection: UseProjection', + 'useTrajectory: UseTrajectory', + ], + keyDomain: '', + hookContext: '', + slotInject: '', + declaredBy: 'an entry in \'conversation.composer\' (client-ui-user-questions), so it exists while that entry is mounted', + occupants: [ + 'client-ui-plan PlanReviewOpen', + ], + replaceRisk: 'none', + example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.plan-review.actions\', () => ctx.slots.register(\n { name: \'conversation.plan-review.actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', + source: 'packages/client/ui-user-questions/src/client/contract/slots.ts:21', }, { key: 'conversation.session', @@ -1086,7 +1223,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session\', () => ctx.slots.register(\n { name: \'conversation.session\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:123', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:129', }, { key: 'conversation.session.header', @@ -1117,13 +1254,13 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ keyDomain: '', hookContext: '', slotInject: '', - declaredBy: 'an entry in \'main.conversation\' (client-ui-conversation), so it exists while that entry is mounted', + declaredBy: 'an entry in \'conversation.header\' (client-ui-conversation), so it exists while that entry is mounted', occupants: [ 'client-ui-conversation ConversationSessionHeader', ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header\', () => ctx.slots.register(\n { name: \'conversation.session.header\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:125', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:137', }, { key: 'conversation.session.header.actions', @@ -1186,7 +1323,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.actions\', () => ctx.slots.register(\n { name: \'conversation.session.header.actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:133', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:152', }, { key: 'conversation.session.header.corner', @@ -1225,46 +1362,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.corner\', () => ctx.slots.register(\n { name: \'conversation.session.header.corner\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:162', - }, - { - key: 'conversation.session.header.leading', - kind: 'single', - scope: 'session', - summary: 'Leading seat before the Session breadcrumbs, for window-chrome-adjacent controls (macOS desktop sidebar reopen and New Session while the sidebar is hidden).', - doc: 'Leading seat before the Session breadcrumbs, for window-chrome-adjacent\ncontrols (macOS desktop sidebar reopen and New Session while the sidebar\nis hidden). The seat is laid out only while its occupant renders\nsomething, and it stays mounted through the blank-session state so a\nhidden sidebar always keeps a reopen control on screen.', - registerOptions: [], - ownerProps: [ - '/** The leading seat\'s occupant derives its state from standard Session props. */\nexport interface ConversationHeaderLeadingOwnerProps {\n /** Marker field: the occupant receives no owner-specific values. */\n children?: never\n}', - ], - ownerPropsReferences: [], - standardProps: [ - 'useResource: UseResource', - 'useWorkspaces: SnapshotSelectorHook', - 'usePanelInfo: UsePanelInfo', - 'useSessions: UseSessions', - 'useSessionStatus: UseSessionStatus', - 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', - 'useChat: UseChat', - 'useConversation: UseConversation', - 'useInput: SnapshotSelectorHook', - 'inputActions: InputActions', - 'useSession: SessionSnapshotSelector', - 'sessionId: SessionId', - 'useProjection: UseProjection', - 'useTrajectory: UseTrajectory', - ], - keyDomain: '', - hookContext: '', - slotInject: '', - declaredBy: 'an entry in \'conversation.session.header\' (client-ui-conversation), so it exists while that entry is mounted', - occupants: [ - 'client-ui-sidebar HeaderLeadingControls', - ], - replaceRisk: 'shadows-shipped-ui', - example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.leading\', () => ctx.slots.register(\n { name: \'conversation.session.header.leading\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:151', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:175', }, { key: 'conversation.session.header.lineage', @@ -1305,7 +1403,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.lineage\', () => ctx.slots.register(\n { name: \'conversation.session.header.lineage\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:127', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:146', }, { key: 'conversation.session.header.utilities', @@ -1364,7 +1462,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.utilities\', () => ctx.slots.register(\n { name: \'conversation.session.header.utilities\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:139', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:158', }, { key: 'conversation.trajectory.images', @@ -1374,7 +1472,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ doc: 'Renderer for one group of durable record images in the Trajectory\nledger. The owner supplies image references, an authorized loader, and\nalignment. A registration replaces the shipped gallery; without one,\nimages are omitted.', registerOptions: [], ownerProps: [ - '/** Message image group handed to the optional attachment presentation plugin. */\nexport interface MessageImagesOwnerProps {\n /** Durable references or submission-echo previews in source order. */\n images: readonly MessageImageSource[]\n /** Session-authorized image URL loader for the durable arm. */\n loadImage: MessageImageLoader\n /** Horizontal placement inside the owning record. */\n align: \'start\' | \'end\'\n /** Force every image into the compact message-attachment tile size. */\n compact?: boolean\n}', + '/** Message image group handed to the optional attachment presentation plugin. */\nexport interface MessageImagesOwnerProps {\n /** Durable references or submission-echo previews in source order. */\n images: readonly MessageImageSource[]\n /** Session-authorized image URL loader for the durable arm. */\n loadImage: MessageImageLoader\n /** Horizontal placement inside the owning record. */\n align: \'start\' | \'end\'\n /** Force every image into the compact message-attachment tile size. */\n compact?: boolean\n /** Fixed, uncropped thumbnail for an attachment list row. */\n thumbnail?: boolean\n}', ], ownerPropsReferences: [ 'Message', @@ -1436,7 +1534,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ }, ], ownerProps: [ - '/** Conversation View entries obtain their data from registered standard hooks. */\nexport interface ConvViewOwnerProps {\n /** Focus request addressed to the selected View. */\n viewRequest: import(\'./views.ts\').ConversationViewRequest | null\n /** Select a View and address one opaque focus identity to it. */\n openView: (view: string, focus: string) => void\n /** Acknowledge the current one-shot focus request. */\n completeViewRequest: () => void\n}', + '/** Conversation View entries obtain their data from registered standard hooks. */\nexport interface ConvViewOwnerProps {\n /** Open a tool call\'s inspector when an inspection target is available. */\n inspectCall: ((callId: string) => void) | undefined\n /** Focus request addressed to the selected View. */\n viewRequest: import(\'./views.ts\').ConversationViewRequest | null\n /** Select a View and address one opaque focus identity to it. */\n openView: (view: string, focus: string) => void\n /** Acknowledge the current one-shot focus request. */\n completeViewRequest: () => void\n}', ], ownerPropsReferences: [ 'ConversationViewRequest', @@ -1468,7 +1566,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.view\', () => ctx.slots.register(\n { name: \'conversation.view\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:168', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:181', }, { key: 'main', @@ -1540,7 +1638,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'main.conversation\', () => ctx.slots.register(\n { name: \'main.conversation\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:121', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:127', }, { key: 'plugins.bundle.config', @@ -1894,9 +1992,11 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ declaredBy: 'an entry in \'settings.section\' (client-ui-settings-general), so it exists while that entry is mounted', occupants: [ 'client-locale LanguageRow id \'language\'', + 'client-ui-chat PerformanceUsageRow id \'performance-usage\'', 'client-ui-chat TranscriptViewRow id \'transcript-view\'', 'client-ui-conversation EnterBehaviorRow id \'composer-enter\'', 'client-ui-permission-presets PermissionRow id \'permission\'', + 'client-ui-settings-general DeveloperToolsRow id \'developer-tools\'', 'client-ui-theme AppearanceRow id \'appearance\'', 'client-ui-theme FontSizeRow id \'font-size\'', ], @@ -2348,6 +2448,43 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.brand.name\', () => ctx.slots.register(\n { name: \'sidebar.brand.name\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:29', }, + { + key: 'sidebar.chat.conversation', + kind: 'single', + scope: 'session', + summary: 'Session-scoped Conversation occurrence hosted by one Sidebar chat tab.', + doc: 'Session-scoped Conversation occurrence hosted by one Sidebar chat tab.', + registerOptions: [], + ownerProps: [], + ownerPropsReferences: [], + standardProps: [ + 'useResource: UseResource', + 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', + 'useSessions: UseSessions', + 'useSessionStatus: UseSessionStatus', + 'useSessionRetainInfo: UseSessionRetainInfo', + 'useWorkspaces: SnapshotSelectorHook', + 'useChat: UseChat', + 'useConversation: UseConversation', + 'useInput: SnapshotSelectorHook', + 'inputActions: InputActions', + 'useSession: SessionSnapshotSelector', + 'sessionId: SessionId', + 'useProjection: UseProjection', + 'useTrajectory: UseTrajectory', + ], + keyDomain: '', + hookContext: '', + slotInject: '', + declaredBy: 'an entry in \'sidebar.right.pane.tab\' (client-ui-subagent), so it exists while that entry is mounted', + occupants: [ + 'client-ui-subagent ConversationSlotPanel', + ], + replaceRisk: 'shadows-shipped-ui', + example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.chat.conversation\', () => ctx.slots.register(\n { name: \'sidebar.chat.conversation\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', + source: 'packages/client/ui-subagent/src/client/sidebar-chat/index.tsx:42', + }, { key: 'sidebar.footer.action', kind: 'list', @@ -2487,11 +2624,13 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ declaredBy: 'an entry in \'rightbar.session\' (client-ui-sidebar-right), so it exists while that entry is mounted', occupants: [ 'client-ui-deliverables ReviewTab', + 'client-ui-plan PlanPreview', 'client-ui-sidebar-browser BrowserBody', 'client-ui-sidebar-documentpreview TextPreview', 'client-ui-sidebar-files FilesBody', 'client-ui-sidebar-right GuideBody', 'client-ui-sidebar-terminal LazyTerminalBody', + 'client-ui-subagent SidebarChatTab', ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.right.pane.tab\', () => ctx.slots.register(\n { name: \'sidebar.right.pane.tab\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', @@ -2535,6 +2674,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ slotInject: 'SidebarRightTabInjected', declaredBy: 'an entry in \'rightbar.session\' (client-ui-sidebar-right), so it exists while that entry is mounted', occupants: [ + 'client-ui-plan PlanTitle', 'client-ui-sidebar-browser BrowserTitle', 'client-ui-sidebar-documentpreview TextTitle', 'client-ui-sidebar-files FilesTitle', @@ -2560,7 +2700,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ }, ], ownerProps: [ - '/** Content and viewing inputs shared by document bodies and nested PDF presentation. */\nexport interface DocumentBodyOwner {\n /** Original file address, also readable through the standard useResource hook. */\n readonly resourceAddress: string\n /** Ordinary file content or a renderer-owned loading request; text accumulates until eof. */\n readonly content: DocumentContent\n /** The document toolbar\'s current wrapping preference. */\n readonly wrap: boolean\n /** Report a renderer-owned scrollport; passing `null` restores the shared body as the owner. */\n readonly scrollportRef: RefCallback\n}', + '/** Content and viewing inputs shared by document bodies and nested PDF presentation. */\nexport interface DocumentBodyOwner {\n /** Observe a file read by this renderer. @param address - complete file resource address. */\n readonly addResource: (address: string) => void\n /** Replace this renderer\'s dependencies. @param addresses - complete file resource addresses. */\n readonly setResources: (addresses: readonly string[]) => void\n /** Original file address, also readable through the standard useResource hook. */\n readonly resourceAddress: string\n /** Ordinary file content or a renderer-owned loading request; text accumulates until eof. */\n readonly content: DocumentContent\n /** The document toolbar\'s current wrapping preference. */\n readonly wrap: boolean\n /** Report a renderer-owned scrollport; passing `null` restores the shared body as the owner. */\n readonly scrollportRef: RefCallback\n}', ], ownerPropsReferences: [ 'DocumentContent', @@ -2597,7 +2737,111 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.right.tab.document\', () => ctx.slots.register(\n { name: \'sidebar.right.tab.document\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts:45', + source: 'packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts:51', + }, + { + key: 'sidebar.right.tab.document.action', + kind: 'keyed', + scope: 'session', + summary: 'Renderer-specific controls before the document toolbar\'s reload button.', + doc: 'Renderer-specific controls before the document toolbar\'s reload button.', + registerOptions: [ + { + name: 'key', + requirement: 'required', + type: 'string', + doc: 'Your cell key: the entry renders where the owner dispatches this exact key. Registering an already-occupied key replaces that occupant.', + }, + ], + ownerProps: [ + '/**\n * Ordinary file contents, or a request for the selected renderer to load its content.\n * Byte arrays are transient UI input, never persisted layout or Session data.\n */\nexport type DocumentContent =\n | { readonly kind: \'text\'; readonly text: string; readonly pages: readonly DocumentTextPage[]; readonly eof: boolean }\n | { readonly kind: \'bytes\'; readonly data: Uint8Array }\n | {\n readonly kind: \'renderer\'\n /** Changes on reload or implementation replacement; retained contents belong to one revision. */\n readonly revision: number\n /** Report the displayed source version; stale revisions cannot update the owner. @param version - loaded source version. */\n readonly loaded: (version: string) => void\n /** End a failed load; a later file change can start another revision. */\n readonly failed: () => void\n /** Cancel the current load and start a new revision. */\n readonly reload: () => void\n }', + ], + ownerPropsReferences: [ + 'DocumentTextPage', + ], + standardProps: [ + 'useResource: UseResource', + 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', + 'useSessions: UseSessions', + 'useSessionStatus: UseSessionStatus', + 'useSessionRetainInfo: UseSessionRetainInfo', + 'useWorkspaces: SnapshotSelectorHook', + 'useChat: UseChat', + 'useConversation: UseConversation', + 'useInput: SnapshotSelectorHook', + 'inputActions: InputActions', + 'useSession: SessionSnapshotSelector', + 'sessionId: SessionId', + 'useProjection: UseProjection', + 'useTrajectory: UseTrajectory', + ], + keyDomain: 'open: any string the owner dispatches (no compile-time key set), none are taken yet', + hookContext: 'UseSidebarRightTabInfo', + slotInject: '{ hooks: { tabInfo: SlotHookFactory<\'sidebar.right.tab.document\', UseSidebarRightTabInfo> } }', + declaredBy: 'an entry in \'sidebar.right.pane.tab\' (client-ui-sidebar-documentpreview), so it exists while that entry is mounted', + occupants: [ + 'client-ui-sidebar-documentpreview OfficeFontAction', + ], + replaceRisk: 'none', + example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.right.tab.document.action\', () => ctx.slots.register(\n { name: \'sidebar.right.tab.document.action\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', + source: 'packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts:87', + }, + { + key: 'sidebar.right.tab.document.actions', + kind: 'list', + scope: 'session', + summary: 'Header toolbar contributions acting on the previewed file, rendered after the preview\'s own controls once the file\'s Host path is known.', + doc: 'Header toolbar contributions acting on the previewed file, rendered\nafter the preview\'s own controls once the file\'s Host path is known.', + registerOptions: [ + { + name: 'id', + requirement: 'required', + type: 'string', + doc: 'Your cell key. Use an id of your own: a fresh id is added beside the shipped entries, while reusing a shipped id puts you in THAT cell and replaces it. Owners that filter by id address you by it.', + }, + { + name: 'order', + requirement: 'optional', + type: 'number', + doc: 'Position among the entries, ascending (default 0).', + }, + { + name: 'label', + requirement: 'optional', + type: 'string | (() => string)', + doc: 'Display text where the owner projects one (nav rows, tabs). A thunk is re-read on every projection, so localized text follows the active locale without re-registering.', + }, + ], + ownerProps: [], + ownerPropsReferences: [], + standardProps: [ + 'useResource: UseResource', + 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', + 'useSessions: UseSessions', + 'useSessionStatus: UseSessionStatus', + 'useSessionRetainInfo: UseSessionRetainInfo', + 'useWorkspaces: SnapshotSelectorHook', + 'useChat: UseChat', + 'useConversation: UseConversation', + 'useInput: SnapshotSelectorHook', + 'inputActions: InputActions', + 'useSession: SessionSnapshotSelector', + 'sessionId: SessionId', + 'useProjection: UseProjection', + 'useTrajectory: UseTrajectory', + ], + keyDomain: '', + hookContext: '', + slotInject: '', + declaredBy: 'an entry in \'sidebar.right.pane.tab\' (client-ui-sidebar-documentpreview), so it exists while that entry is mounted', + occupants: [ + 'client-ui-open-in-app OpenPathAction id \'open-in-app\'', + ], + replaceRisk: 'none', + example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.right.tab.document.actions\', () => ctx.slots.register(\n { name: \'sidebar.right.tab.document.actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', + source: 'packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts:66', }, { key: 'sidebar.right.tab.document.office.pdf', @@ -2614,7 +2858,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ }, ], ownerProps: [ - '/** Content and viewing inputs shared by document bodies and nested PDF presentation. */\nexport interface DocumentBodyOwner {\n /** Original file address, also readable through the standard useResource hook. */\n readonly resourceAddress: string\n /** Ordinary file content or a renderer-owned loading request; text accumulates until eof. */\n readonly content: DocumentContent\n /** The document toolbar\'s current wrapping preference. */\n readonly wrap: boolean\n /** Report a renderer-owned scrollport; passing `null` restores the shared body as the owner. */\n readonly scrollportRef: RefCallback\n}', + '/** Content and viewing inputs shared by document bodies and nested PDF presentation. */\nexport interface DocumentBodyOwner {\n /** Observe a file read by this renderer. @param address - complete file resource address. */\n readonly addResource: (address: string) => void\n /** Replace this renderer\'s dependencies. @param addresses - complete file resource addresses. */\n readonly setResources: (addresses: readonly string[]) => void\n /** Original file address, also readable through the standard useResource hook. */\n readonly resourceAddress: string\n /** Ordinary file content or a renderer-owned loading request; text accumulates until eof. */\n readonly content: DocumentContent\n /** The document toolbar\'s current wrapping preference. */\n readonly wrap: boolean\n /** Report a renderer-owned scrollport; passing `null` restores the shared body as the owner. */\n readonly scrollportRef: RefCallback\n}', ], ownerPropsReferences: [ 'DocumentContent', @@ -2645,7 +2889,63 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.right.tab.document.office.pdf\', () => ctx.slots.register(\n { name: \'sidebar.right.tab.document.office.pdf\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-sidebar-documentpreview/src/client/office/OfficeBody.tsx:21', + source: 'packages/client/ui-sidebar-documentpreview/src/client/office/OfficeBody.tsx:20', + }, + { + key: 'sidebar.right.tab.document.unpreviewable', + kind: 'list', + scope: 'session', + summary: 'Empty-state contributions for a file this preview cannot render, offered where Retry would stand once the file\'s Host path is known.', + doc: 'Empty-state contributions for a file this preview cannot render,\noffered where Retry would stand once the file\'s Host path is known.', + registerOptions: [ + { + name: 'id', + requirement: 'required', + type: 'string', + doc: 'Your cell key. Use an id of your own: a fresh id is added beside the shipped entries, while reusing a shipped id puts you in THAT cell and replaces it. Owners that filter by id address you by it.', + }, + { + name: 'order', + requirement: 'optional', + type: 'number', + doc: 'Position among the entries, ascending (default 0).', + }, + { + name: 'label', + requirement: 'optional', + type: 'string | (() => string)', + doc: 'Display text where the owner projects one (nav rows, tabs). A thunk is re-read on every projection, so localized text follows the active locale without re-registering.', + }, + ], + ownerProps: [], + ownerPropsReferences: [], + standardProps: [ + 'useResource: UseResource', + 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', + 'useSessions: UseSessions', + 'useSessionStatus: UseSessionStatus', + 'useSessionRetainInfo: UseSessionRetainInfo', + 'useWorkspaces: SnapshotSelectorHook', + 'useChat: UseChat', + 'useConversation: UseConversation', + 'useInput: SnapshotSelectorHook', + 'inputActions: InputActions', + 'useSession: SessionSnapshotSelector', + 'sessionId: SessionId', + 'useProjection: UseProjection', + 'useTrajectory: UseTrajectory', + ], + keyDomain: '', + hookContext: '', + slotInject: '', + declaredBy: 'an entry in \'sidebar.right.pane.tab\' (client-ui-sidebar-documentpreview), so it exists while that entry is mounted', + occupants: [ + 'client-ui-open-in-app OpenPathEmptyAction id \'open-in-app\'', + ], + replaceRisk: 'none', + example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.right.tab.document.unpreviewable\', () => ctx.slots.register(\n { name: \'sidebar.right.tab.document.unpreviewable\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', + source: 'packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts:78', }, { key: 'sidebar.right.tab.guide', @@ -2956,14 +3256,14 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'tool.call.images\', () => ctx.slots.register(\n { name: \'tool.call.images\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-tool/src/client/contract/slots.ts:40', + source: 'packages/client/ui-tool/src/client/contract/slots.ts:35', }, { key: 'tool.call.toolview', kind: 'keyed', scope: 'session', - summary: 'Keyed atomic Tool call view, dispatched by the wire Tool name.', - doc: 'Keyed atomic Tool call view, dispatched by the wire Tool name. Register\nwith `key: \'\'` to own how one tool\'s calls render inside a\nturn — the key domain is open (any wire tool name, including a tool your\nown package registered), so there is no compile-time key set to pick\nfrom and a typo simply never renders.\n\nA key the shipped composition already covers is replaced, not shared;\nan unclaimed key falls back to the generic tool row, so registering is\nadditive for your own tool and a takeover for a shipped one. The owner\npasses the call\'s identity, its frozen running-or-settled node, and the\nexpansion state (see ToolCallOwnerProps), so the view stays a pure\nfunction of what the turn already knows.', + summary: 'Keyed Tool call view dispatched by wire Tool name.', + doc: 'Keyed Tool call view dispatched by wire Tool name. Any name is allowed,\nincluding tools registered by your package. Register with\n`key: \'\'`; a typo never renders.\n\nRegistering an occupied key replaces its view; unclaimed keys use the\ngeneric row. The owner supplies the call identity and frozen running\nor settled node through ToolCallOwnerProps.', registerOptions: [ { name: 'key', @@ -2997,7 +3297,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useProjection: UseProjection', 'useTrajectory: UseTrajectory', ], - keyDomain: 'open: any string the owner dispatches (no compile-time key set), already taken: ask_user_question, bash, cordis_define, cordis_run, cordis_stop, cordis_undefine, edit, glob, grep, present, read, read_image, skill, todo_write, web_fetch, web_search, write', + keyDomain: 'open: any string the owner dispatches (no compile-time key set), already taken: ask_user_question, bash, cordis_define, cordis_inspect_list, cordis_inspect_query, cordis_inspect_self, cordis_run, cordis_stop, cordis_undefine, create_goal, edit, get_goal, glob, grep, interrupt_agent, job_kill, job_list, job_output, list_agents, list_subagent_models, lsp, present, ralph, read, read_image, schedule_create, schedule_delete, schedule_list, send_message, session_event_read, session_event_search, session_event_trace, session_search, session_trace, skill, spawn_teammate, subagent, team_task_create, team_task_get, team_task_list, team_task_update, terminal_close, terminal_list, terminal_open, terminal_read, terminal_signal, todo_write, update_goal, wait_agent, web_fetch, web_search, workflow, write', hookContext: '', slotInject: '', declaredBy: 'an entry in \'conversation.chat.node\' (client-ui-tool), so it exists while that entry is mounted', @@ -3006,6 +3306,42 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'client-ui-skill SkillRow key \'skill\'', 'client-ui-tool AskQuestionRow key \'ask_user_question\'', 'client-ui-tool BashRow key \'bash\'', + 'client-ui-tool DetailsRow key \'create_goal\'', + 'client-ui-tool DetailsRow key \'get_goal\'', + 'client-ui-tool DetailsRow key \'update_goal\'', + 'client-ui-tool DetailsRow key \'schedule_create\'', + 'client-ui-tool DetailsRow key \'schedule_list\'', + 'client-ui-tool DetailsRow key \'schedule_delete\'', + 'client-ui-tool DetailsRow key \'cordis_inspect_list\'', + 'client-ui-tool DetailsRow key \'cordis_inspect_query\'', + 'client-ui-tool DetailsRow key \'cordis_inspect_self\'', + 'client-ui-tool DetailsRow key \'workflow\'', + 'client-ui-tool DetailsRow key \'ralph\'', + 'client-ui-tool DetailsRow key \'session_event_read\'', + 'client-ui-tool DetailsRow key \'session_event_search\'', + 'client-ui-tool DetailsRow key \'session_event_trace\'', + 'client-ui-tool DetailsRow key \'session_search\'', + 'client-ui-tool DetailsRow key \'session_trace\'', + 'client-ui-tool DetailsRow key \'list_subagent_models\'', + 'client-ui-tool DetailsRow key \'subagent\'', + 'client-ui-tool DetailsRow key \'list_agents\'', + 'client-ui-tool DetailsRow key \'send_message\'', + 'client-ui-tool DetailsRow key \'interrupt_agent\'', + 'client-ui-tool DetailsRow key \'job_list\'', + 'client-ui-tool DetailsRow key \'job_output\'', + 'client-ui-tool DetailsRow key \'job_kill\'', + 'client-ui-tool DetailsRow key \'terminal_open\'', + 'client-ui-tool DetailsRow key \'terminal_read\'', + 'client-ui-tool DetailsRow key \'terminal_list\'', + 'client-ui-tool DetailsRow key \'terminal_signal\'', + 'client-ui-tool DetailsRow key \'terminal_close\'', + 'client-ui-tool DetailsRow key \'lsp\'', + 'client-ui-tool DetailsRow key \'spawn_teammate\'', + 'client-ui-tool DetailsRow key \'team_task_create\'', + 'client-ui-tool DetailsRow key \'team_task_get\'', + 'client-ui-tool DetailsRow key \'team_task_update\'', + 'client-ui-tool DetailsRow key \'team_task_list\'', + 'client-ui-tool DetailsRow key \'wait_agent\'', 'client-ui-tool FileMutationRow key \'edit\'', 'client-ui-tool FileMutationRow key \'write\'', 'client-ui-tool ReadImageRow key \'read_image\'', @@ -3022,7 +3358,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'tool.call.toolview\', () => ctx.slots.register(\n { name: \'tool.call.toolview\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-tool/src/client/contract/slots.ts:26', + source: 'packages/client/ui-tool/src/client/contract/slots.ts:21', }, { key: 'tool.view.cordis', diff --git a/packages/extensions/cordis-host-runner/package.json b/packages/extensions/cordis-host-runner/package.json index 0f7be96672..116524ec7b 100644 --- a/packages/extensions/cordis-host-runner/package.json +++ b/packages/extensions/cordis-host-runner/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-cordis-host-runner", "description": "Dynamic package definition registry, host-half sandbox lifecycle, and invoke handler table for model-mounted dual-half packages", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/extensions/tool-cordis/package.json b/packages/extensions/tool-cordis/package.json index a9a8ddfeed..8e9d109d8b 100644 --- a/packages/extensions/tool-cordis/package.json +++ b/packages/extensions/tool-cordis/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-cordis", "description": "Read-only runtime API inspection for Harness plugin development", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 002567d03a..87f9f7445a 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -405,7 +405,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'one observed change or a timeout result.', }, { - signature: 'interrupt(caller: Agent, targetName: string): { previousStatus: \'running\' | \'idle\' | \'inactive\' }', + signature: 'interrupt(caller: Agent, targetName: string): { previousStatus: \'running\' | \'inactive\' }', description: 'Interrupt one live teammate turn without clearing its pending inbox.', parameters: [{ name: 'caller', description: 'exact live Lead Agent.' }, { name: 'targetName', description: 'durable teammate name.' }], returns: 'the target status sampled before cancellation.', @@ -769,8 +769,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ { signature: 'authenticatedUrl(baseUrl: string): string', description: 'Add the fresh process token to an ordinary Web application URL.', - parameters: [{ name: 'baseUrl', description: 'clean canonical browser origin.' }], - returns: 'root URL accepted by {@link authorizeIndex} for initial login.', + parameters: [{ name: 'baseUrl', description: 'clean application URL whose authority and mount are preserved.' }], + returns: 'tokenized URL for initial login; a mount proxy strips its prefix before {@link authorizeIndex}.', }, ], }, @@ -975,6 +975,13 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ summary: 'Abstract filesystem provider.', description: 'Abstract filesystem provider. Targets must preserve identity across aliases; reads expose regular UTF-8 text or typed errors, listings are stable and content-free, and mutations are atomic. Optional guards add stale protection without changing the unguarded provider contract.', methods: [ + { + signature: 'abstract watch(target: FsTarget, changed: (error?: Error) => void, signal: AbortSignal): Promise<() => Promise>', + description: 'Observe one file or a directory\'s direct entries in this provider\'s execution world.', + parameters: [{ name: 'target', description: 'resolved file or directory, including an absent path to observe for creation.' }, { name: 'changed', description: 'invalidation callback; errors can be reported during or after initialization.' }, { name: 'signal', description: 'cancels watcher initialization; the caller closes an initialized watcher.' }], + returns: 'a promise resolving once observation is active, with an asynchronous close function.', + throws: ['when the provider does not support watching or cannot initialize the watcher.'], + }, { signature: 'abstract resolve(path: string, opts?: { cwd?: string; signal?: AbortSignal }): Promise', description: 'Resolve a model/plugin-supplied path into a stable FsTarget. May perform I/O (a remote/sandboxed backend may need a round-trip to map a path to a stable identity), hence async even though the local backend only normalizes + realpaths.', @@ -1547,9 +1554,15 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'Package versions, one-liners, rows, activation selections, whether the installation offers the bundle, and removal availability.', }, { - signature: '@Remote async inspect(spec: string, signal?: AbortSignal): Promise', + signature: '@Remote async registries(): Promise', + description: 'Read the registries this manager asks: the configured first one, its fallbacks in order, and what pnpm\'s own configuration names.', + parameters: [], + returns: 'The registries in pnpm\'s comparison form; null is the one pnpm\'s own configuration names, `resolved` as pnpm reads it now.', + }, + { + signature: '@Remote async inspect(spec: string, options?: InspectOptions, signal?: AbortSignal): Promise', description: 'Read what a spec names before installing it.', - parameters: [{ name: 'spec', description: 'One package spec: a registry name, an absolute path, a git address, or a tarball.' }, { name: 'signal', description: 'Ends a registry lookup early.' }], + parameters: [{ name: 'spec', description: 'One package spec: a registry name, an absolute path, a git address, or a tarball.' }, { name: 'options', description: 'The registry asked first.' }, { name: 'signal', description: 'Ends a registry lookup early.' }], returns: 'The package the spec names, or why it is refused.', }, { @@ -1567,8 +1580,14 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ { signature: '@Remote installBundle(spec: string, options?: InstallBundleOptions): Promise', description: 'Install a package using the same pnpm implementation as dsh plugin. A run that fails, is cancelled, or adds a package without a bundle patch restores `package.json` and `pnpm-lock.yaml` as they were; downloaded files can stay.', - parameters: [{ name: 'spec', description: 'One package spec, including local paths relative to the invocation directory.' }, { name: 'options', description: 'Whether to activate the installed bundle (defaults to true), the request id a cancellation names, and the pending build scripts to allow for this profile before pnpm runs.' }], - returns: 'Package-manager diagnostics and observed activation outcome.', + parameters: [{ name: 'spec', description: 'One package spec, including local paths relative to the invocation directory.' }, { name: 'options', description: 'Whether to activate the installed bundle (defaults to true), the request id a cancellation names, the pending build scripts to allow for this profile before pnpm runs, and the registry asked first.' }], + returns: 'Package-manager diagnostics, the registries asked, and the observed activation outcome.', + }, + { + signature: '@Remote async waitForInstall(requestId: PluginInstallRequestId): Promise', + description: 'Recover the result of an active installation without cancelling it.', + parameters: [{ name: 'requestId', description: 'The id supplied when installation started.' }], + returns: 'The installation\'s outcome after it settles, or null if no active request has that id. Completed results are not retained; null establishes neither success nor cancellation.', }, { signature: '@Remote async cancelInstall(requestId: PluginInstallRequestId): Promise', @@ -1744,10 +1763,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: '@Remote(\'openWorkspacePath\') async openWorkspacePath( request: SessionOpenWorkspacePathRequest, signal: AbortSignal, ): Promise', - description: 'Open one path prepared by a Session-aware caller on the Host desktop.', + description: 'Verify one path through the composed filesystem and open it on the Host desktop.', parameters: [{ name: 'request', description: 'path after best-effort Session workspace resolution.' }, { name: 'signal', description: 'caller lifetime; abort terminates the native command.' }], returns: 'confirmation after the native opener accepts the path.', - throws: ['RemoteError when the request is invalid, cancelled, or the opener fails.'], + throws: ['RemoteError when the request is invalid, has no verified Host mapping, is cancelled, or the opener fails.'], }, { signature: '@Remote(\'rename\') rename(request: SessionRenameRequest): Promise', @@ -2089,9 +2108,9 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ methods: [ { signature: 'async listCandidates( agent: Agent, query: string = \'\', limit: number = this.config.candidateLimit, signal?: AbortSignal, ): Promise', - description: 'List reference candidates, ranked by working-directory affinity.\n\nDiscovery runs at keystroke rate, so a title only ever comes from a projection read: see SessionReferenceResolver.projectedTitle for which sessions can answer one and which fall back to their id.', - parameters: [{ name: 'agent', description: 'target agent; self is excluded and its cwd drives ranking.' }, { name: 'query', description: 'optional case-insensitive session-id/cwd/title substring.' }, { name: 'limit', description: 'optional positive result cap.' }, { name: 'signal', description: 'optional cancellation boundary for host autocomplete teardown.' }], - returns: 'candidates labeled by latest title or, when absent, session id.', + description: 'List reference candidates, ranked by working-directory affinity.\n\nDiscovery runs at keystroke rate, so titles and subagent labels only ever come from projection reads; sessions without either fall back to their id.', + parameters: [{ name: 'agent', description: 'target agent; self is excluded and its cwd drives ranking.' }, { name: 'query', description: 'optional case-insensitive session-id/cwd/title/display-title substring.' }, { name: 'limit', description: 'optional positive result cap.' }, { name: 'signal', description: 'optional cancellation boundary for host autocomplete teardown.' }], + returns: 'candidates with canonical mention labels and presentation titles.', }, { signature: '@Remote(\'candidates\') async remoteExportCandidates( agent: Agent, query: string, signal: AbortSignal, ): Promise', @@ -3336,10 +3355,11 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'the directory\'s children in the backend\'s stable name order, bounded by the entry cap.', }, { - signature: '@Remote({ mode: \'stream\' }) changes(workspaceFileScope: WorkspaceFileScope, signal: AbortSignal): AsyncIterable', - description: 'Stream every `fs/observed` observation of a file inside the Session\'s workspace. Only instrumented filesystem operations report here; the OS is not watched.', - parameters: [{ name: 'workspaceFileScope', description: 'header-derived workspace root for the Session identity on the wire.' }, { name: 'signal', description: 'generation cancellation.' }], - returns: '`ready` once the Host observation queue is active and the workspace root is resolved, then queued and live observations in emission order.', + signature: '@Remote({ mode: \'stream\' }) changes(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): AsyncIterable', + description: 'Watch one file or a directory\'s direct entries in the Session\'s filesystem. Files use the backend\'s read authority; directories remain workspace-scoped.', + parameters: [{ name: 'workspaceFileScope', description: 'header-derived workspace root for the Session identity on the wire.' }, { name: 'path', description: 'target path; the Host determines its type and confines directories to the workspace.' }, { name: 'signal', description: 'generation cancellation.' }], + returns: '`ready` once the target watch is active, then current metadata for queued and live invalidations.', + throws: ['RemoteError when watching is unavailable or a directory is outside the workspace.'], }, ], }, @@ -3775,8 +3795,8 @@ export const EVENT_API: readonly EventApiEntry[] = [ mode: 'emit', signature: '\'plugin-manager/install-state\'(progress: PluginInstallProgress): void', summary: 'An installation moved between its Host phases.', - description: 'An installation moved between its Host phases.', - parameters: [{ name: 'progress', description: 'the installation\'s request id and phase.' }], + description: 'An installation moved between its Host phases. `installing` is announced once per registry the installation asks, with the attempt\'s registry and position; `cancelling` and `applying` once.', + parameters: [{ name: 'progress', description: 'the installation\'s request id and phase, with the attempt while installing.' }], }, { name: 'session-telemetry/record', @@ -4116,7 +4136,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'AskUserQuestionIntent', - declaration: 'export type AskUserQuestionIntent = {\n kind: \'plan-review\';\n approve: string;\n};', + declaration: 'export type AskUserQuestionIntent = {\n kind: \'plan-review\';\n approve: string;\n callId?: ToolCallId;\n};', }, { name: 'AskUserQuestionItem', @@ -4264,7 +4284,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ChangeResult', - declaration: 'export interface ChangeResult {\n changed: boolean;\n application: \'applied\' | \'restart-required\' | \'overridden\' | \'failed\' | \'cancelled\';\n stage: \'install\' | \'enable\' | \'remove\';\n target: string;\n enabled?: boolean;\n error?: ManagementError;\n warnings?: string[];\n packageResult?: PackageResult;\n bundle?: string;\n pendingBuilds?: string[];\n approvedBuilds?: string[];\n}', + declaration: 'export interface ChangeResult {\n changed: boolean;\n application: \'applied\' | \'restart-required\' | \'overridden\' | \'failed\' | \'cancelled\';\n stage: \'install\' | \'enable\' | \'remove\';\n target: string;\n enabled?: boolean;\n error?: ManagementError;\n warnings?: string[];\n packageResult?: PackageResult;\n bundle?: string;\n pendingBuilds?: string[];\n approvedBuilds?: string[];\n registries?: Registry[];\n failedAt?: \'registry\' | \'spec-host\';\n}', }, { name: 'ClientArtifactBaseline', @@ -4866,6 +4886,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'IndexInjectionPlacement', declaration: 'export type IndexInjectionPlacement = \'head\' | \'body\';', }, + { + name: 'InspectOptions', + declaration: 'export interface InspectOptions {\n readonly registry?: Registry;\n}', + }, { name: 'InspectorId', declaration: 'export type InspectorId = Branded;', @@ -4884,7 +4908,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'InstallBundleOptions', - declaration: 'export interface InstallBundleOptions {\n enabled?: boolean;\n requestId?: PluginInstallRequestId;\n approvedBuilds?: string[];\n}', + declaration: 'export interface InstallBundleOptions {\n enabled?: boolean;\n requestId?: PluginInstallRequestId;\n approvedBuilds?: string[];\n registry?: Registry;\n}', }, { name: 'InstallSpecKind', @@ -5388,7 +5412,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'PluginInstallProgress', - declaration: 'export interface PluginInstallProgress {\n readonly requestId: PluginInstallRequestId;\n readonly phase: \'installing\' | \'cancelling\' | \'applying\';\n}', + declaration: 'export interface PluginInstallProgress {\n readonly requestId: PluginInstallRequestId;\n readonly phase: \'installing\' | \'cancelling\' | \'applying\';\n readonly attempt?: {\n readonly registry: Registry;\n readonly index: number;\n readonly total: number;\n };\n}', }, { name: 'PluginInstallRequestId', @@ -5398,9 +5422,13 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'PluginInventoryEntry', declaration: 'export interface PluginInventoryEntry {\n readonly entryId: PluginEntryId;\n readonly moduleName: string;\n readonly enabled: boolean;\n readonly fiberPhase: PluginFiberPhase;\n}', }, + { + name: 'PluginRegistries', + declaration: 'export interface PluginRegistries {\n readonly registry: Registry;\n readonly fallbackRegistries: readonly string[];\n readonly resolved: string | null;\n}', + }, { name: 'PluginSpecInspection', - declaration: 'export type PluginSpecInspection = {\n readonly status: \'accepted\';\n readonly kind: InstallSpecKind;\n readonly name?: string;\n readonly version?: string;\n readonly description?: string;\n readonly bundle: boolean | null;\n} | {\n readonly status: \'refused\';\n readonly problem: PluginInspectProblem;\n readonly reason: string;\n};', + declaration: 'export type PluginSpecInspection = {\n readonly status: \'accepted\';\n readonly kind: InstallSpecKind;\n readonly name?: string;\n readonly version?: string;\n readonly description?: string;\n readonly bundle: boolean | null;\n readonly registry: Registry;\n readonly host?: string;\n} | {\n readonly status: \'refused\';\n readonly problem: PluginInspectProblem;\n readonly reason: string;\n readonly registries?: Registry[];\n};', }, { name: 'PostToolDecision', @@ -5578,6 +5606,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'RedactedSecret', declaration: 'export interface RedactedSecret {\n path: string[];\n set: boolean;\n}', }, + { + name: 'Registry', + declaration: 'export type Registry = string | null;', + }, { name: 'Reload', declaration: 'export interface Reload {\n filename: string;\n runtime?: Plugin.Runtime | undefined;\n}', @@ -6072,7 +6104,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SessionReferenceCandidate', - declaration: 'export interface SessionReferenceCandidate {\n sessionId: SessionId;\n label: string;\n cwd?: string;\n sameWorkspace: boolean;\n createdAt: number;\n}', + declaration: 'export interface SessionReferenceCandidate {\n sessionId: SessionId;\n label: string;\n displayTitle?: string;\n cwd?: string;\n sameWorkspace: boolean;\n createdAt: number;\n}', }, { name: 'SessionReferenceInput', @@ -6636,7 +6668,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'TeamMemberView', - declaration: 'export interface TeamMemberView {\n readonly id: SessionId;\n readonly name: string;\n readonly role: \'lead\' | \'teammate\';\n readonly status: \'running\' | \'idle\' | \'inactive\' | \'provisioning\' | \'failed\';\n readonly description?: string;\n readonly provider?: string;\n readonly context?: \'fresh\' | \'fork\';\n readonly model?: string;\n readonly diagnostics: string[];\n}', + declaration: 'export interface TeamMemberView {\n readonly id: SessionId;\n readonly name: string;\n readonly role: \'lead\' | \'teammate\';\n readonly status: \'running\' | \'inactive\' | \'provisioning\' | \'failed\';\n readonly description?: string;\n readonly provider?: string;\n readonly context?: \'fresh\' | \'fork\';\n readonly model?: string;\n readonly diagnostics: string[];\n}', }, { name: 'TeamMessageId', diff --git a/packages/extensions/ui-cordis/README.i18n.yaml b/packages/extensions/ui-cordis/README.i18n.yaml index b28964efed..a164478ef6 100644 --- a/packages/extensions/ui-cordis/README.i18n.yaml +++ b/packages/extensions/ui-cordis/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/extensions/ui-cordis/README.md -README.md: 17fea955dae2ecc4b4549775d8f9176ee5eba1f5 -README.zh.md: 35726f2451a70586c0600789d1c27b15f2d779ea +README.md: 1f3677d48eda13cad18a24ed6021f30535db686a +README.zh.md: f3855ce6f75efc7510abdaff8d211cec167d5117 diff --git a/packages/extensions/ui-cordis/README.md b/packages/extensions/ui-cordis/README.md index 17fea955da..1f3677d48e 100644 --- a/packages/extensions/ui-cordis/README.md +++ b/packages/extensions/ui-cordis/README.md @@ -29,11 +29,11 @@ Compose this package with the Host and Client runners to render historical tool ### What the panel shows -A `sidebar.footer.action` seat shows a badge counting what runs plus what waits; opening it lists every definition with its run controls. The list is never filtered by session: the current session's rows group first, everyone else's stay listed below. Rows come from the host's current inventory and update whenever an announcement changes what exists. A pending run request whose definition the last read does not cover still gets a row, rendered from the request's own session, label, purpose, and identity. Each row shows two independent facts — what the host runs and what this page has loaded — so a reloaded page offers "load back into this page" before the global stop, while a host-only definition reads plainly running and offers the stop alone. The row also carries this page's last render failure inline, in the same place as a load failure: one is "it never loaded", the other "it loaded and then threw". +A `sidebar.footer.action` seat shows a badge counting what runs plus what waits; opening it lists every definition with its run controls. The list is never filtered by session: the current session's rows group first, everyone else's stay listed below. Rows come from the host's current inventory and update whenever an announcement changes what exists. A pending run request whose definition the last read does not cover still gets a row, rendered from the request's own session, label, purpose, and identity. Each row shows two independent facts — what the host runs and what this page has loaded — and maps them onto the shared status marker: idle uses idle, client loading and an action in flight use ongoing, a running definition uses done, approval waits use warning, and failures use error. A reloaded page offers "load back into this page" before the global stop, while a host-only definition reads plainly running and offers the stop alone. The row also carries this page's last render failure inline, in the same place as a load failure: one is "it never loaded", the other "it loaded and then threw". ### What the tool cards show -The `cordis_define` card is a record: the name and purpose the model wrote, the source it wrote, and whether the definition is running — no switch, no approval, and a pointer to the panel. The `cordis_run` card shows the mode, the plugin, package, and run ids, the outcome, and offers the package's own business view through the `tool.view.cordis` slot when the package registered one. `cordis_stop` and `cordis_undefine` render compact action rows. All cards render from the recorded call and result, so replay shows the same card. +The `cordis_define` card is a record: the name and purpose the model wrote, the source it wrote, and whether the definition is running — no switch, no approval, and a pointer to the panel. The `cordis_run` card shows the mode, the plugin, package, and run ids, the outcome, and offers the package's own business view through the `tool.view.cordis` slot when the package registered one. Define, run, stop, and undefine cards retain their ordinary business glyph when they fail or are interrupted, including a correlated activation failure after a successful run receipt. All cards render from the recorded call and result, so replay shows the same card. ### Boundaries to plan around diff --git a/packages/extensions/ui-cordis/README.zh.md b/packages/extensions/ui-cordis/README.zh.md index 35726f2451..f3855ce6f7 100644 --- a/packages/extensions/ui-cordis/README.zh.md +++ b/packages/extensions/ui-cordis/README.zh.md @@ -29,11 +29,11 @@ kind: "package-reference" ### 面板显示什么 -一个 `sidebar.footer.action` 席位显示角标,计数在跑数加待确认数;点开后列出每个定义及其运行控件。列表从不按会话过滤:当前会话的行置顶成组,其他会话的行仍在下方列出。行来自 host 的当前清单,并在公告改变「有哪些定义」时更新。上一次读取覆盖不到的待审批 run 请求仍然有行,直接用请求自带的会话、标签、用途与标识渲染。每一行显示两个独立事实——host 在跑什么与本页装载了什么——因此刷新后的页面会先给「装回本页」、再给全局 stop,而纯 host 定义的行如实读作运行中、只给 stop。该行还会把本页最后一次渲染失败就地显示,与装载失败共用同一个位置:一个是「它从来没装上」,另一个是「它装上了、然后抛了」。 +一个 `sidebar.footer.action` 席位显示角标,计数在跑数加待确认数;点开后列出每个定义及其运行控件。列表从不按会话过滤:当前会话的行置顶成组,其他会话的行仍在下方列出。行来自 host 的当前清单,并在公告改变「有哪些定义」时更新。上一次读取覆盖不到的待审批 run 请求仍然有行,直接用请求自带的会话、标签、用途与标识渲染。每一行显示两个独立事实——host 在跑什么与本页装载了什么——并把它们映射到共享状态标记:idle 使用 idle,客户端装载及正在执行的操作使用 ongoing,运行中的定义使用 done,等待批准使用 warning,失败使用 error。刷新后的页面会先给「装回本页」、再给全局 stop,而纯 host 定义的行如实读作运行中、只给 stop。该行还会把本页最后一次渲染失败就地显示,与装载失败共用同一个位置:一个是「它从来没装上」,另一个是「它装上了、然后抛了」。 ### 工具卡片显示什么 -`cordis_define` 卡片是一份记录:模型写下的 name 与 purpose、它写的源码,以及该定义是否在跑——没有开关、没有审批,只有一句指向面板的指引。`cordis_run` 卡片显示模式、插件标识、包标识与运行标识、结果,并在包注册了业务视图时经 `tool.view.cordis` slot 提供它。`cordis_stop` 与 `cordis_undefine` 渲染紧凑的动作行。所有卡片都渲染会话记录下的 call 与 result,因此回放显示同一张卡。 +`cordis_define` 卡片是一份记录:模型写下的 name 与 purpose、它写的源码,以及该定义是否在跑——没有开关、没有审批,只有一句指向面板的指引。`cordis_run` 卡片显示模式、插件标识、包标识与运行标识、结果,并在包注册了业务视图时经 `tool.view.cordis` slot 提供它。define、run、stop 与 undefine 卡片在失败或中断时继续保留普通业务图标,包括调用回执成功后发生的关联激活失败。所有卡片都渲染会话记录下的 call 与 result,因此回放显示同一张卡。 ### 需要规划的边界 diff --git a/packages/extensions/ui-cordis/package.json b/packages/extensions/ui-cordis/package.json index 79298aa355..1772cb911d 100644 --- a/packages/extensions/ui-cordis/package.json +++ b/packages/extensions/ui-cordis/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-cordis", "description": "Cordis dynamic-plugin definition card: the keyed cordis_define tool row with its run/stop switch", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/extensions/ui-cordis/src/client/CordisActionRow.tsx b/packages/extensions/ui-cordis/src/client/CordisActionRow.tsx index 2856dc90b3..61fe374573 100644 --- a/packages/extensions/ui-cordis/src/client/CordisActionRow.tsx +++ b/packages/extensions/ui-cordis/src/client/CordisActionRow.tsx @@ -1,7 +1,7 @@ /** Localized cards for `cordis_stop` and `cordis_undefine`. */ import { - IconInspectOutline12, IconStopFill16, IconTrashOutline16, StateDot, + IconInspectOutlineRegular, IconStopFillRegular, IconTrashOutlineRegular, } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { ToolCallViewProps } from '@deepseek-ai/dsh-client-ui-tool/client' @@ -21,18 +21,14 @@ export function CordisActionRow({ callId, toolName, block, inspect, t }: CordisA
    - {card.state === 'error' - ? - : card.state === 'stopped' - ? - : remove ? : } + {remove ? : } {t(remove ? 'row.removeTitle' : 'row.stopTitle')} {summary} {inspect !== undefined && ( )}
    diff --git a/packages/extensions/ui-cordis/src/client/CordisDefineRow.module.css b/packages/extensions/ui-cordis/src/client/CordisDefineRow.module.css index 7390f9a0de..c74fdc2654 100644 --- a/packages/extensions/ui-cordis/src/client/CordisDefineRow.module.css +++ b/packages/extensions/ui-cordis/src/client/CordisDefineRow.module.css @@ -1,8 +1,6 @@ /* Cordis definition card: DisclosureRow chrome (leading/chevron/title come from that primitive) plus this card's own trailing run switch and source body. - No running sweep: `cordis_define` only registers a definition in host memory, - so the call settles in the same turn it starts and the state dot carries the - whole signal. */ + No running sweep: `cordis_define` settles in the same turn it starts. */ .card { display: flex; diff --git a/packages/extensions/ui-cordis/src/client/CordisDefineRow.tsx b/packages/extensions/ui-cordis/src/client/CordisDefineRow.tsx index 0f2207c591..0c0806a1ef 100644 --- a/packages/extensions/ui-cordis/src/client/CordisDefineRow.tsx +++ b/packages/extensions/ui-cordis/src/client/CordisDefineRow.tsx @@ -1,8 +1,8 @@ /** Read-only `cordis_define` card with Host and Client source tabs. */ -import { useId, useState, type ReactNode } from 'react' +import { useId, useState } from 'react' import { - CodeBlock, DisclosureRow, IconCodeOutline16, IconInspectOutline12, StateDot, + CodeBlock, DisclosureRow, IconCodeOutlineRegular, IconInspectOutlineRegular, } from '@deepseek-ai/dsh-client-ui-primitives' import type { InjectFace, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { ToolCallViewProps } from '@deepseek-ai/dsh-client-ui-tool/client' @@ -34,14 +34,6 @@ function stateStatus(state: CordisToolState): CordisKey | null { } } -function leadingFor(state: CordisToolState): ReactNode { - switch (state) { - case 'error': return - case 'stopped': return - default: return - } -} - /** Render one immutable Package definition. */ export function CordisDefineRow({ callId, block, inspect, useInventory, useLoaded, t, @@ -88,7 +80,7 @@ export function CordisDefineRow({ rowClassName={css.row} titleClassName={css.title} chevronClassName={css.chevron} - icon={leadingFor(card.state)} + icon={} title={t('row.defineTitle')} open={open} expandable={expandable} @@ -160,7 +152,7 @@ export function CordisDefineRow({ {card.pluginId !== null &&
    {t('panel.hint')}
    } {inspect !== undefined && ( )} diff --git a/packages/extensions/ui-cordis/src/client/CordisPanel.module.css b/packages/extensions/ui-cordis/src/client/CordisPanel.module.css index 3b7764f5f1..8bedbf169e 100644 --- a/packages/extensions/ui-cordis/src/client/CordisPanel.module.css +++ b/packages/extensions/ui-cordis/src/client/CordisPanel.module.css @@ -83,6 +83,7 @@ left/bottom offsets are measured from the trigger before paint. */ .panel { position: fixed; + isolation: isolate; z-index: 30; display: flex; flex-direction: column; @@ -92,7 +93,6 @@ overflow: hidden; border: 0; border-radius: 12px; - background: var(--dsw-specific-menu); --dsw-elevation-stroke-color: var(--dsw-alias-border-l1); box-shadow: var(--dsw-elevation-prominent); /* Only `.body` scrolls; the header remains fixed above it. */ @@ -100,6 +100,17 @@ --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); } +.panel::before { + position: absolute; + z-index: -1; + inset: 0; + border-radius: inherit; + background: var(--dsw-specific-menu); + backdrop-filter: var(--dsw-menu-backdrop-filter); + content: ''; + pointer-events: none; +} + .header { flex: none; display: flex; @@ -199,36 +210,13 @@ flex: none; display: inline-flex; align-items: center; + gap: 4px; height: 20px; - padding: 0 6px; - border-radius: 10px; - background: var(--dsw-alias-button-ghost-active-fill); font-size: 11px; line-height: 20px; color: var(--dsw-alias-label-caption); } -.row[data-cordis-status='idle'] .rowStatus { - background: var(--dsw-alias-button-ghost-active-fill); - color: var(--dsw-alias-label-caption); -} - -.row[data-cordis-status='awaiting-approval'] .rowStatus, -.row[data-cordis-status='client-pending'] .rowStatus { - background: var(--dsw-alias-state-warn-tertiary); - color: var(--dsw-alias-state-warn-label); -} - -.row[data-cordis-status='failed'] .rowStatus { - background: var(--dsw-alias-interactive-bg-hover-danger); - color: var(--dsw-alias-state-error-primary); -} - -.row[data-cordis-status='running'] .rowStatus { - background: var(--dsw-alias-state-success-tertiary); - color: var(--dsw-alias-state-success-primary); -} - .rowDetail { display: flex; align-items: center; diff --git a/packages/extensions/ui-cordis/src/client/CordisPanel.tsx b/packages/extensions/ui-cordis/src/client/CordisPanel.tsx index 17ca34848e..739dfd16f7 100644 --- a/packages/extensions/ui-cordis/src/client/CordisPanel.tsx +++ b/packages/extensions/ui-cordis/src/client/CordisPanel.tsx @@ -3,8 +3,9 @@ import { useEffect, useLayoutEffect, useRef, useState } from 'react' import type { ButtonHTMLAttributes, ReactNode } from 'react' import { - IconCheckOutline16, IconCloseOutline16, IconCordisPluginOutline14, IconPlayOutline16, - IconStopFill16, IconTrashOutline16, Tooltip, useDismissOnOutsidePointer, + IconCheckOutlineRegular, IconCloseOutlineRegular, IconCordisPluginOutlineRegular, IconPlayOutlineRegular, + IconStopFillRegular, IconTrashOutlineRegular, StateDot, Tooltip, useDismissOnOutsidePointer, + type StateDotState, } from '@deepseek-ai/dsh-client-ui-primitives' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client' @@ -73,6 +74,17 @@ function visiblePanelStatus( return cordisVisibleStatus(listed, listed.activeRun.packageId, loaded) } +function panelDotState(status: PanelStatus, busy: boolean): StateDotState { + if (busy) return 'ongoing' + switch (status) { + case 'idle': return 'idle' + case 'client-pending': return 'ongoing' + case 'running': return 'done' + case 'awaiting-approval': return 'warning' + case 'failed': return 'error' + } +} + function blockingFirst(rows: readonly RowView[]): readonly RowView[] { return [ ...rows.filter(row => row.activity?.phase === 'awaiting-approval'), @@ -96,8 +108,8 @@ function RowAction({ label, children, ...props }: { function DoubleCheckIcon() { return ( - - + + ) } @@ -247,7 +259,10 @@ export function CordisPanel({
    {pluginId} {name} - {t(STATUS_LABELS[status])} + + + {t(STATUS_LABELS[status])} +
    {listed !== undefined && listed.packages.length > 1 && selectedPackageId !== undefined && (
    @@ -478,7 +493,7 @@ export function CordisPanel({ aria-expanded={open} onClick={() => { setOpen(value => !value) }} > - + {wide && ( <> {t('panel.trigger')} diff --git a/packages/extensions/ui-cordis/src/client/CordisRunRow.tsx b/packages/extensions/ui-cordis/src/client/CordisRunRow.tsx index 10e44ee689..44cfbccc53 100644 --- a/packages/extensions/ui-cordis/src/client/CordisRunRow.tsx +++ b/packages/extensions/ui-cordis/src/client/CordisRunRow.tsx @@ -2,7 +2,7 @@ import { useEffect } from 'react' import { - IconCodeOutline16, IconInspectOutline12, StateDot, + IconCodeOutlineRegular, IconInspectOutlineRegular, } from '@deepseek-ai/dsh-client-ui-primitives' import type { InjectFace, PropsLocale, PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' import type { ToolCallViewProps } from '@deepseek-ai/dsh-client-ui-tool/client' @@ -81,7 +81,6 @@ export function CordisRunRow({ const summary = card.errorSummary ?? (card.pluginId === null ? callId : `${card.pluginId}${card.packageId === null ? '' : ` · ${card.packageId}`}`) const showBusiness = reading === 'running' && key !== null - return (
    - - {card.state === 'error' - ? - : card.state === 'stopped' - ? - : } - + {t(card.mode === 'update' ? 'row.updateTitle' : 'row.runTitle')} {summary} {status} {inspect !== undefined && ( )}
    diff --git a/packages/extensions/ui-cordis/tests/status-icons.client.spec.tsx b/packages/extensions/ui-cordis/tests/status-icons.client.spec.tsx new file mode 100644 index 0000000000..3d5a8920f1 --- /dev/null +++ b/packages/extensions/ui-cordis/tests/status-icons.client.spec.tsx @@ -0,0 +1,126 @@ +// @vitest-environment jsdom + +import { cleanup, render } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client' +import { CordisActionRow } from '../src/client/CordisActionRow.tsx' +import { CordisDefineRow } from '../src/client/CordisDefineRow.tsx' +import { CordisRunRow } from '../src/client/CordisRunRow.tsx' +import { zh } from '../src/client/locales.ts' + +afterEach(cleanup) + +const t = ((key: keyof typeof zh) => zh[key]) as Parameters[0]['t'] + +function failed(name: string): ToolResultNode { + return { + kind: 'tool-result', seq: 2, time: 2, callId: `call-${name}`, + call: { name, argsRaw: '{}' }, callTime: 1, + content: [{ type: 'text', text: 'failed' }], isError: true, subCalls: [], + } +} + +const useValue = (value: T) => (selector: (snapshot: T) => Selected): Selected => selector(value) + +describe('Cordis tool failure icons', () => { + it('retains the code glyph for define and run failures', () => { + const common = { + openFile: vi.fn(), + inspect: undefined, + t, + useInventory: useValue({ rows: [], removed: new Set(), read: true }), + useLoaded: useValue([]), + } + const define = render([0]} />) + expect(define.container.querySelector('[data-tool="cordis_define"] [data-disclosure-row] > :first-child svg')).not.toBeNull() + expect(define.container.querySelector('[data-tool="cordis_define"] [data-state]')).toBeNull() + define.unmount() + + const run = render([0]} />) + expect(run.container.querySelector('[data-tool="cordis_run"] > div:first-child > span:first-child svg')).not.toBeNull() + expect(run.container.querySelector('[data-tool="cordis_run"] [data-state]')).toBeNull() + }) + + it('retains the code glyph for interrupted define and run calls', () => { + const interrupted = (name: string): ToolResultNode => ({ + ...failed(name), + error: { name: 'InterruptedError', code: 'interrupted' }, + }) + const common = { + openFile: vi.fn(), inspect: undefined, t, + useInventory: useValue({ rows: [], removed: new Set(), read: true }), + useLoaded: useValue([]), + } + const define = render([0]} />) + expect(define.container.querySelector('[data-tool="cordis_define"] [data-disclosure-row] > :first-child svg')).not.toBeNull() + expect(define.container.querySelector('[data-tool="cordis_define"] [data-state]')).toBeNull() + define.unmount() + + const run = render([0]} />) + expect(run.container.querySelector('[data-tool="cordis_run"] > div:first-child > span:first-child svg')).not.toBeNull() + expect(run.container.querySelector('[data-tool="cordis_run"] [data-state]')).toBeNull() + }) + + it('uses the dashed code glyph when a successful run receipt later reports activation failure', () => { + const pluginId = 'plugin-1' + const packageId = 'package-1' + const pluginRunId = 'run-1' + const block: ToolResultNode = { + kind: 'tool-result', seq: 3, time: 3, callId: 'call-cordis_run', callTime: 2, + call: { name: 'cordis_run', argsRaw: JSON.stringify({ pluginId, packageId, mode: 'run' }) }, + content: [], isError: false, subCalls: [], meta: { pluginId, packageId, pluginRunId }, + } + const view = render([0]} />) + expect(view.container.querySelector('[data-cordis-status="failed"]')).not.toBeNull() + expect(view.container.querySelector('[data-state="ok"] svg')).not.toBeNull() + }) + + it.each(['cordis_stop', 'cordis_undefine'] as const)('%s retains its action icon on failure', (toolName) => { + const view = render([0]} />) + expect(view.container.querySelector('[data-state="error"] svg')).not.toBeNull() + expect(view.container.querySelector('[data-state="error"] [data-state]')).toBeNull() + }) + + it.each(['cordis_stop', 'cordis_undefine'] as const)('%s retains its action icon when interrupted', (toolName) => { + const block: ToolResultNode = { + ...failed(toolName), + error: { name: 'InterruptedError', code: 'interrupted' }, + } + const view = render([0]} />) + expect(view.container.querySelector('[data-state="stopped"] svg')).not.toBeNull() + expect(view.container.querySelector('[data-state="stopped"] [data-state]')).toBeNull() + }) +}) diff --git a/packages/feedback/command-feedback/package.json b/packages/feedback/command-feedback/package.json index f65ffd4aa1..d37393c6ab 100644 --- a/packages/feedback/command-feedback/package.json +++ b/packages/feedback/command-feedback/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-command-feedback", "description": "Log-only session feedback: the record event, the sessionFeedback Host Remote, and the human-facing slash command", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/feedback/message-feedback/package.json b/packages/feedback/message-feedback/package.json index 219a250f6f..dde1cebae9 100644 --- a/packages/feedback/message-feedback/package.json +++ b/packages/feedback/message-feedback/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-message-feedback", "description": "Canonical Session-log ratings and notes for finalized assistant messages", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/fs/fs-local/README.i18n.yaml b/packages/fs/fs-local/README.i18n.yaml index 8317bf4ecd..1a4ca47419 100644 --- a/packages/fs/fs-local/README.i18n.yaml +++ b/packages/fs/fs-local/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/fs/fs-local/README.md -README.md: e781abd2032868a3768a41eb01ce5aac52f8c835 -README.zh.md: bf21f92a48c7c9d804908ee8bc25b2be8daa8967 +README.md: 9c005443eb064a03b3050c9ae82295c794315239 +README.zh.md: 3adc249afe68b4cd8b56c68101baafec47ffb981 diff --git a/packages/fs/fs-local/README.md b/packages/fs/fs-local/README.md index e781abd203..9c005443eb 100644 --- a/packages/fs/fs-local/README.md +++ b/packages/fs/fs-local/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Use `dsh-fs-local` to read, list, atomically write, and edit files on the host filesystem. Relative paths resolve from a configurable base directory, while absolute paths and parent traversal remain unrestricted. Paths and symlinks that reach the same file share one identity. Writes preserve file permissions, and optional version guards reject stale overwrites. Choose this package for direct host access; use `fs-sandbox` for confined mutations. +Use `dsh-fs-local` to read, list, watch, atomically write, and edit files on the host filesystem. Relative paths resolve from a configurable base directory, while absolute paths and parent traversal remain unrestricted. Paths and symlinks that reach the same file share one identity. Writes preserve file permissions, and optional version guards reject stale overwrites. Choose this package for direct host access; use `fs-sandbox` for confined mutations. ## Table of Contents @@ -52,7 +52,9 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a Read any regular UTF-8 text file whole or as a stream, read raw bytes up to a cap you choose or in a byte window, and list one directory level in stable name order. Create or replace a file atomically, and apply a literal text edit atomically; both mutations serialize per file, so concurrent writers never interleave. The version guard is optional: omit it for unconditional create-or-overwrite, or supply it to fail when the file changed since you last observed it. -Failures are typed `FsError`s with stable codes — `FS_NOT_FOUND`, `FS_NOT_TEXT` (binary content), `FS_STALE_VERSION` (changed since observation), `FS_EDIT_NOT_FOUND` or `FS_AMBIGUOUS_EDIT` (no unique literal match), and others — so callers branch on the code, never on message text. A missing target on an edit reports `FS_STALE_VERSION` whether or not the version guard is supplied. +Read, listing, and mutation failures are typed `FsError`s with stable codes — `FS_NOT_FOUND`, `FS_NOT_TEXT` (binary content), `FS_STALE_VERSION` (changed since observation), `FS_EDIT_NOT_FOUND` or `FS_AMBIGUOUS_EDIT` (no unique literal match), and others — so callers branch on the code, never on message text. A missing target on an edit reports `FS_STALE_VERSION` whether or not the version guard is supplied. + +Chokidar observes one file or a directory's direct entries through OS events, without polling or recursive watching. Files use a filtered parent-directory watch, so readiness also covers creation of an initially missing file. File watches cover in-place writes, atomic replacement, deletion, and same-path recreation while the parent directory remains. ----- @@ -90,7 +92,7 @@ Each edit probes, verifies the version guard before literal matching (so stale e ### Ownership and invariants -Raw I/O is Cordis-free and independently unit-tested in `src/fsio.ts`; `src/index.ts` stays thin wiring. `config.cwd` is a resolution default only — containment is the job of `fs-sandbox` or a `tools/execute` permission plugin. Cancellation is a best-effort `AbortSignal` checked before and after each asynchronous probe. +Raw I/O is Cordis-free and independently unit-tested in `src/fsio.ts`; `src/index.ts` stays thin wiring. `config.cwd` is a resolution default only — containment is the job of `fs-sandbox` or a `tools/execute` permission plugin. Cancellation is a best-effort `AbortSignal` checked before and after each asynchronous probe. For watches, the signal cancels initialization; after readiness, the caller must await the returned close function. @@ -127,6 +129,7 @@ No direct invalidation; the named consumer owns any request-prefix changes. These limits define when the local backend is a poor fit or needs special operational care. They are current package constraints, not a general filesystem comparison or a task backlog. - **`config.cwd` is not a sandbox** — it is a resolution default, not containment: absolute paths and `..` escape it. Enforce containment with a stricter `ctx.fs` backend or a permission plugin on the `tools/execute` waterfall. +- **Linux parent-directory recreation** — restoring observation after a parent directory is deleted and recreated is deferred; same-path file recreation while its parent remains is supported. - **Version tokens depend on filesystem metadata** — they combine device, inode, size, nanosecond mtime, and nanosecond ctime; a storage layer that cannot update any of those facts for a rewrite can still defeat the stale guard. - **`editText` holds the whole file (plus the edited copy) in memory** — streaming exists only on the read path. - **A sub-limit overwrite still buffers a contextual basis** — `writeText` may retain up to just below `config.diffBasisMaxBytes` of prior text in addition to the caller-owned replacement; the bound does not cap the returned `after` value or the whole-file presentation fallback. diff --git a/packages/fs/fs-local/README.zh.md b/packages/fs/fs-local/README.zh.md index bf21f92a48..3adc249afe 100644 --- a/packages/fs/fs-local/README.zh.md +++ b/packages/fs/fs-local/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -使用 `dsh-fs-local` 可在宿主文件系统上读取、列出、原子写入和编辑文件。相对路径从可配置的基准目录解析,而绝对路径和父目录遍历不受限制。到达同一文件的路径和符号链接共享一个身份。写入保留文件权限,可选版本防护会拒绝陈旧覆盖。直接访问宿主文件时选择本包;需要约束变更时使用 `fs-sandbox`。 +使用 `dsh-fs-local` 可在宿主文件系统上读取、列出、监听、原子写入和编辑文件。相对路径从可配置的基准目录解析,而绝对路径和父目录遍历不受限制。到达同一文件的路径和符号链接共享一个身份。写入保留文件权限,可选版本防护会拒绝陈旧覆盖。直接访问宿主文件时选择本包;需要约束变更时使用 `fs-sandbox`。 ## 目录 @@ -52,7 +52,9 @@ kind: "package-reference" 完整或流式读取任意普通 UTF-8 文本文件,按你选择的上限或按字节窗口读取原始字节,并按稳定名称顺序列出一层目录。原子地创建或替换文件,并原子地应用字面量文本编辑;两个变更操作都按文件串行化,并发写入方绝不会交错。版本防护是可选的:省略它即无条件创建或覆盖,提供它则在文件自上次观察以来发生变化时失败。 -失败是携带稳定错误码的类型化 `FsError`——`FS_NOT_FOUND`、`FS_NOT_TEXT`(二进制内容)、`FS_STALE_VERSION`(自观察以来已变化)、`FS_EDIT_NOT_FOUND` 或 `FS_AMBIGUOUS_EDIT`(无唯一字面量匹配)等——因此调用方依据错误码分支,绝不解析消息文本。编辑遇到缺失目标时,无论是否提供版本防护,都报告 `FS_STALE_VERSION`。 +读取、列出与变更操作的失败是携带稳定错误码的类型化 `FsError`——`FS_NOT_FOUND`、`FS_NOT_TEXT`(二进制内容)、`FS_STALE_VERSION`(自观察以来已变化)、`FS_EDIT_NOT_FOUND` 或 `FS_AMBIGUOUS_EDIT`(无唯一字面量匹配)等——因此调用方依据错误码分支,绝不解析消息文本。编辑遇到缺失目标时,无论是否提供版本防护,都报告 `FS_STALE_VERSION`。 + +Chokidar 通过 OS 事件观察单个文件或目录的直接子项,不使用轮询或递归监听。文件使用筛选到目标的父目录监听,因此就绪时也能接收起初缺失文件的创建事件。父目录保持存在时,文件监听覆盖原地写入、原子替换、删除和同路径重建。 ----- @@ -90,7 +92,7 @@ kind: "package-reference" ### 归属与不变式 -原始 I/O 不依赖 Cordis,在 `src/fsio.ts` 中独立单元测试;`src/index.ts` 保持为轻量接线。`config.cwd` 只是解析默认值——约束是 `fs-sandbox` 或 `tools/execute` 权限插件的工作。取消是尽力而为的 `AbortSignal`,在每次异步探测前后检查。 +原始 I/O 不依赖 Cordis,在 `src/fsio.ts` 中独立单元测试;`src/index.ts` 保持为轻量接线。`config.cwd` 只是解析默认值——约束是 `fs-sandbox` 或 `tools/execute` 权限插件的工作。取消是尽力而为的 `AbortSignal`,在每次异步探测前后检查。对于监听,signal 取消初始化;就绪后,调用方必须等待返回的关闭函数完成。 @@ -127,6 +129,7 @@ kind: "package-reference" 这些限制说明本地后端何时不合适,或何时需要特别的运维注意。它们是当前包约束,不是通用文件系统对比或任务积压。 - **`config.cwd` 不是沙箱**:它是解析默认值,而非约束;绝对路径和 `..` 可以逃逸。请使用更严格的 `ctx.fs` 后端或 `tools/execute` waterfall(瀑布式事件)上的权限插件实施约束。 +- **Linux 父目录重建**:父目录删除并重建后的监听恢复仍延期;父目录保持存在时,同路径文件重建仍受支持。 - **版本 token 依赖文件系统元数据**:它们组合设备、inode、大小、纳秒级 mtime 与纳秒级 ctime;如果存储层在重写时无法更新其中任何一项事实,仍可能绕过陈旧防护。 - **`editText` 会把整个文件及编辑后的副本保存在内存中**:只有读取路径支持流式处理。 - **低于上限的覆写仍会缓冲上下文基础**:`writeText` 除调用方持有的替换内容外,最多还会保留略低于 `config.diffBasisMaxBytes` 的旧文本;该上限不限制返回的 `after` 值,也不限制整文件展示回退。 diff --git a/packages/fs/fs-local/package.json b/packages/fs/fs-local/package.json index a7bb580235..c781f81f20 100644 --- a/packages/fs/fs-local/package.json +++ b/packages/fs/fs-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-fs-local", "description": "Local-filesystem implementation of the DeepSeek Harness filesystem seam (ctx.fs)", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, @@ -31,6 +31,7 @@ "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { + "chokidar": "^4.0.3", "koffi": "^3.1.0", "@deepseek-ai/schemastery": "workspace:^" }, diff --git a/packages/fs/fs-local/src/index.ts b/packages/fs/fs-local/src/index.ts index b755bbb0e5..17033e512b 100644 --- a/packages/fs/fs-local/src/index.ts +++ b/packages/fs/fs-local/src/index.ts @@ -6,7 +6,9 @@ import { Context } from '@deepseek-ai/cordis' import { constants as bufferConstants } from 'node:buffer' -import { isAbsolute, relative, resolve, sep } from 'node:path' +import { once } from 'node:events' +import { watch } from 'chokidar' +import { dirname, isAbsolute, relative, resolve, sep } from 'node:path' import { pathToFileURL } from 'node:url' import z from '@deepseek-ai/schemastery' import { FileSystem, FsError, FsVersion } from '@deepseek-ai/dsh-fs' @@ -64,6 +66,29 @@ const MAX_DIFF_BASIS_BYTES = Math.min( * containment with a stricter backend or a `tools/execute` permission plugin. */ export class LocalFileSystem extends FileSystem { + override async watch(target: FsTarget, changed: (error?: Error) => void, signal: AbortSignal): Promise<() => Promise> { + signal.throwIfAborted() + const path = resolve(this.processPath(target)) + const directory = (await this.stat(target, signal))?.type === 'directory' + signal.throwIfAborted() + const root = directory ? path : dirname(path) + const watcher = watch(root, { + ignoreInitial: true, depth: 0, + ignored: entry => !directory && resolve(entry) !== root && resolve(entry) !== path, + }) + watcher.on('all', (_event, entry) => { + if (directory || resolve(entry) === path) changed() + }) + watcher.on('error', (error) => { changed(error instanceof Error ? error : new Error(String(error))) }) + try { + await once(watcher, 'ready', { signal }) + return () => watcher.close() + } catch (error) { + await watcher.close() + throw error + } + } + static Config: z = z.object({ cwd: z.string().default(process.cwd()), diffBasisMaxBytes: z.number().default(DEFAULT_DIFF_BASIS_MAX_BYTES), diff --git a/packages/fs/fs-local/tests/watch.spec.ts b/packages/fs/fs-local/tests/watch.spec.ts new file mode 100644 index 0000000000..9aca3120eb --- /dev/null +++ b/packages/fs/fs-local/tests/watch.spec.ts @@ -0,0 +1,144 @@ +/** Watch initialization and asynchronous release at the Chokidar adapter. */ +import { Context } from '@deepseek-ai/cordis' +import { FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs' +import { dirname, join, resolve } from 'node:path' +import * as chokidar from 'chokidar' +import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest' +import { LocalFileSystem } from '../src/index.ts' + +vi.mock('chokidar', async (importOriginal) => { + const original = await importOriginal() + return { ...original, watch: vi.fn(original.watch) } +}) + +afterEach(() => { vi.restoreAllMocks() }) + +async function setup(kind: 'file' | 'directory' | 'missing' = 'file') { + const watcher = new chokidar.FSWatcher() + onTestFinished(() => watcher.close()) + const started = Promise.withResolvers() + const watch = vi.mocked(chokidar.watch).mockReset().mockImplementation(() => { + started.resolve(undefined) + return watcher + }) + const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) + await ctx.plugin(LocalFileSystem) + const path = resolve('/workspace/file.txt') + const target = { targetKey: FsTargetKey(path), displayPath: 'file.txt' } + const stat = vi.spyOn(ctx.fs, 'stat').mockResolvedValue(kind === 'missing' + ? undefined : { type: kind, version: FsVersion('v1'), size: 1 }) + const changed = vi.fn<(error?: Error) => void>() + const controller = new AbortController() + onTestFinished(() => { controller.abort() }) + return { watcher, watch, fs: ctx.fs, target, changed, controller, path, stat, started: started.promise } +} + +describe('local filesystem watch', () => { + it.each(['file', 'missing'] as const)('waits for the parent watch of a %s target and awaits closure', async (kind) => { + const h = await setup(kind) + const pending = h.fs.watch(h.target, h.changed, h.controller.signal) + await h.started + const ignored = h.watch.mock.calls[0]![1]!.ignored + if (typeof ignored !== 'function') throw new Error('Expected a target filter') + expect(h.watch).toHaveBeenCalledExactlyOnceWith(dirname(h.path), { ignoreInitial: true, depth: 0, ignored }) + expect(ignored(dirname(h.path))).toBe(false) + expect(ignored(h.path)).toBe(false) + expect(ignored(join(dirname(h.path), 'unrelated.txt'))).toBe(true) + let ready = false + void pending.then(() => { ready = true }) + await Promise.resolve(undefined) + expect(ready).toBe(false) + h.watcher.emit('ready') + const close = await pending + h.watcher.emit('all', 'addDir', dirname(h.path)) + h.watcher.emit('all', 'change', join(dirname(h.path), 'unrelated.txt')) + expect(h.changed).not.toHaveBeenCalled() + h.watcher.emit('all', 'change', h.path) + expect(h.changed).toHaveBeenCalledExactlyOnceWith() + const closed = Promise.withResolvers() + onTestFinished(() => { closed.resolve(undefined) }) + const nativeClose = h.watcher.close.bind(h.watcher) + vi.spyOn(h.watcher, 'close').mockImplementationOnce(async () => { await closed.promise; await nativeClose() }) + const closing = close() + try { + expect(h.watcher.closed).toBe(false) + } finally { + closed.resolve(undefined) + await closing + } + expect(h.watcher.closed).toBe(true) + }) + + it('watches a directory itself without filtering its direct entries', async () => { + const h = await setup('directory') + const pending = h.fs.watch(h.target, h.changed, h.controller.signal) + await h.started + expect(h.watch.mock.calls[0]![0]).toBe(h.path) + const ignored = h.watch.mock.calls[0]![1]!.ignored + if (typeof ignored !== 'function') throw new Error('Expected a target filter') + expect(ignored(join(h.path, 'child.txt'))).toBe(false) + h.watcher.emit('ready') + const close = await pending + h.watcher.emit('all', 'change', join(h.path, 'child.txt')) + expect(h.changed).toHaveBeenCalledExactlyOnceWith() + await close() + }) + + it('does not acquire a watcher when cancelled during metadata lookup', async () => { + const h = await setup() + const metadata = Promise.withResolvers() + h.stat.mockReturnValueOnce(metadata.promise) + const pending = h.fs.watch(h.target, h.changed, h.controller.signal) + const rejected = expect(pending).rejects.toThrow() + h.controller.abort() + metadata.resolve(undefined) + await rejected + expect(h.watch).not.toHaveBeenCalled() + }) + + it('rejects an already-cancelled initialization without acquiring a watcher', async () => { + const h = await setup() + h.controller.abort() + await expect(h.fs.watch(h.target, h.changed, h.controller.signal)).rejects.toThrow() + expect(h.watch).not.toHaveBeenCalled() + }) + + it('closes the acquired watcher when initialization is cancelled', async () => { + const h = await setup() + const pending = h.fs.watch(h.target, h.changed, h.controller.signal) + const rejected = expect(pending).rejects.toThrow() + await h.started + h.controller.abort() + await rejected + expect(h.watcher.closed).toBe(true) + expect(h.changed).not.toHaveBeenCalled() + }) + + it('reports initialization errors and closes before rejecting', async () => { + const h = await setup() + const error = new Error('watch failed') + const pending = h.fs.watch(h.target, h.changed, h.controller.signal) + const rejected = expect(pending).rejects.toBe(error) + await h.started + h.watcher.emit('error', error) + await rejected + expect(h.changed).toHaveBeenCalledExactlyOnceWith(error) + expect(h.watcher.closed).toBe(true) + }) + + it('normalizes watcher errors after initialization', async () => { + const h = await setup() + const pending = h.fs.watch(h.target, h.changed, h.controller.signal) + await h.started + h.watcher.emit('ready') + const close = await pending + const error = new Error('watch failed') + h.watcher.emit('error', error) + // EventEmitter callbacks can receive non-Error values from the external watcher. + h.watcher.emit('error', 'watch unavailable') + expect(h.changed.mock.calls).toEqual([[error], [new Error('watch unavailable')]]) + await close() + expect(h.watcher.closed).toBe(true) + }) +}) diff --git a/packages/fs/fs-observation-policy/package.json b/packages/fs/fs-observation-policy/package.json index f4a1123217..173783e733 100644 --- a/packages/fs/fs-observation-policy/package.json +++ b/packages/fs/fs-observation-policy/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-fs-observation-policy", "description": "File-context policy plugin for the DeepSeek Harness — observed-state, read-before-edit, and version-guarded write/edit added over the ctx.fs provider seam through the fs/* event gate (no service API)", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/fs/fs-sandbox/README.i18n.yaml b/packages/fs/fs-sandbox/README.i18n.yaml index 24bd8907dd..b23f4379e1 100644 --- a/packages/fs/fs-sandbox/README.i18n.yaml +++ b/packages/fs/fs-sandbox/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/fs/fs-sandbox/README.md -README.md: e126e71573f4ce1480543d0a94cfe8512f03472b -README.zh.md: b224296711ae7c43849fa2a426a2a6e79c99f65e +README.md: fde670f9a51fafb29b8d7057517ff26f551e9ff4 +README.zh.md: ff58cd0a4eb2094e138fa24da03a371ff2054c99 diff --git a/packages/fs/fs-sandbox/README.md b/packages/fs/fs-sandbox/README.md index e126e71573..fde670f9a5 100644 --- a/packages/fs/fs-sandbox/README.md +++ b/packages/fs/fs-sandbox/README.md @@ -47,7 +47,7 @@ The effective mode comes from the calling session's override or escalation grant ### Observable success and failures -Reads, listings, and metadata work exactly as with `fs-local`. A denied mutation returns an `FS_SANDBOX_DENIED` error carrying the effective mode; through the tools the model sees `[sandbox: file access denied under mode]` plus the one-approved-wider retry hint, identical to bash's denials. A session with an approved escalation may retry the same operation at a strictly wider mode for that one call. +Reads, listings, metadata, and read-only watches work exactly as with `fs-local`; the mutation fence does not restrict observation. A denied mutation returns an `FS_SANDBOX_DENIED` error carrying the effective mode; through the tools the model sees `[sandbox: file access denied under mode]` plus the one-approved-wider retry hint, identical to bash's denials. A session with an approved escalation may retry the same operation at a strictly wider mode for that one call. ----- diff --git a/packages/fs/fs-sandbox/README.zh.md b/packages/fs/fs-sandbox/README.zh.md index b224296711..ff58cd0a4e 100644 --- a/packages/fs/fs-sandbox/README.zh.md +++ b/packages/fs/fs-sandbox/README.zh.md @@ -47,7 +47,7 @@ kind: "package-reference" ### 可观察的成功与失败 -读取、列出与元数据操作与 `fs-local` 完全一致。被拒绝的变更返回携带有效模式的 `FS_SANDBOX_DENIED` 错误;经工具,模型会看到 `[sandbox: file access denied under mode]` 及唯一一次获批更宽权限的重试提示,与 bash 的拒绝完全相同。获得批准升级的会话可以在该次调用中以严格更宽的模式重试同一操作。 +读取、列出、元数据操作与只读监听均与 `fs-local` 完全一致;变更围栏不限制观察。被拒绝的变更返回携带有效模式的 `FS_SANDBOX_DENIED` 错误;经工具,模型会看到 `[sandbox: file access denied under mode]` 及唯一一次获批更宽权限的重试提示,与 bash 的拒绝完全相同。获得批准升级的会话可以在该次调用中以严格更宽的模式重试同一操作。 ----- diff --git a/packages/fs/fs-sandbox/package.json b/packages/fs/fs-sandbox/package.json index 8f8e4c891b..04ad3d27f4 100644 --- a/packages/fs/fs-sandbox/package.json +++ b/packages/fs/fs-sandbox/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-fs-sandbox", "description": "Sandbox-enforcing implementation of the DeepSeek Harness filesystem seam: fences write/edit by the per-call sandbox mode (read-only denies mutation, workspace-write contains it to the workspace + temp roots) while reads pass through", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/fs/fs/README.i18n.yaml b/packages/fs/fs/README.i18n.yaml index 8a8c0dbc63..f82aaf06fb 100644 --- a/packages/fs/fs/README.i18n.yaml +++ b/packages/fs/fs/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/fs/fs/README.md -README.md: e36e400d53cc581eb19733c9b3ccd890f34932a2 -README.zh.md: 3efc2106a9200ee84ab1a859a19fdb5b1631f056 +README.md: 549da2a42a70c0ac06bf50870076fceb400cbf78 +README.zh.md: c9b91913554c66f727289130d6d50c522ba60d91 diff --git a/packages/fs/fs/README.md b/packages/fs/fs/README.md index e36e400d53..549da2a42a 100644 --- a/packages/fs/fs/README.md +++ b/packages/fs/fs/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Use `dsh-fs` when an application needs consistent filesystem operations across host, confined, or remote execution environments. It lets consumers resolve stable file identities, map shared host files where supported, perform bounded text and byte reads, list directories, and apply atomic text writes and literal edits. Version guards are optional, so a backend works without policy enforcement; callers can supply a guard to reject a mutation after the file changes. Choose `fs-local` or `fs-sandbox` for host execution. Model-facing filesystem tools are provided separately by `dsh-tool-fs`. +Use `dsh-fs` when an application needs consistent filesystem operations across host, confined, or remote execution environments. It lets consumers resolve stable file identities, map shared host files where supported, perform bounded text and byte reads, list directories, watch targets, and apply atomic text writes and literal edits. Version guards are optional, so a backend works without policy enforcement; callers can supply a guard to reject a mutation after the file changes. Choose `fs-local` or `fs-sandbox` for host execution. Model-facing filesystem tools are provided separately by `dsh-tool-fs`. ## Table of Contents @@ -33,7 +33,9 @@ Pick [`fs-local`](../fs-local/README.md) for ordinary host files or [`fs-sandbox ### What the service lets you do -Through `ctx.fs` you can resolve any path to a stable target identity, read a whole text file or stream it in chunks, read raw bytes up to an explicit cap, list one directory level, atomically create or replace a file, and apply a literal text edit atomically. The version guard on both mutations is optional: omit it for unconditional create-or-overwrite, or supply it to fail when the file changed since you last observed it. Every operation returns data or a typed `FsError` carrying a stable code such as `FS_NOT_FOUND`, `FS_STALE_VERSION`, or `FS_AMBIGUOUS_EDIT`, so callers branch on the code, never on message text. +Through `ctx.fs` you can resolve any path to a stable target identity, read a whole text file or stream it in chunks, read raw bytes up to an explicit cap, list one directory level, atomically create or replace a file, and apply a literal text edit atomically. The version guard on both mutations is optional: omit it for unconditional create-or-overwrite, or supply it to fail when the file changed since you last observed it. Read, listing, and mutation failures are typed `FsError`s with stable codes such as `FS_NOT_FOUND`, `FS_STALE_VERSION`, or `FS_AMBIGUOUS_EDIT`, so callers branch on the code, never on message text. + +`watch(target, changed, signal)` reports invalidations for one file or a directory's direct entries. It resolves once observation is ready with an asynchronous close function that the caller must await. The signal cancels initialization; unsupported providers reject without polling. ----- @@ -49,7 +51,7 @@ This section explains the design decisions behind the contract and points at the The contract is built on one separation and three commitments: -- **Contract over mechanism.** The service names what a storage layer can do — resolve, stat, read, list, write, edit — and never how it stores bytes. Backends own target identity, execution-world coordinates, decoding, binary rejection, and atomicity. +- **Contract over mechanism.** The service names what a storage layer can do — resolve, stat, read, list, watch, write, edit — and never how it stores bytes. Backends own target identity, execution-world coordinates, decoding, binary rejection, and atomicity. - **Policy stays off the base class.** Observed-state, read-before-edit, and version-guarded mutations are a plugin's job (`dsh-fs-observation-policy`), added by supplying the optional guard — so a sandboxed or remote backend inherits no model-facing observation policy. - **`editText` stays on the seam.** Version check, literal match, and atomic rewrite share one critical section, so error attribution and one-wins/one-stale concurrency stay correct; a remote backend may implement it as a native compare-and-edit. - **Bounds live at this seam.** `readBytes` requires `maxBytes` and fails with `FS_TOO_LARGE` rather than truncating, so no backend ever buffers an unbounded file. `readByteRange` is bounded by its window instead: a backend transfers at most the requested `length` beyond the prefix it skips, so the caller's cap on `length` is the guard. @@ -72,7 +74,7 @@ The package declares three events so the emitter (`dsh-tool-fs`) and the policy ### Invariants - `targetKey` and `version` are branded opaque ids: consumers must not parse or interpret them; only `displayPath` is for model/UI output. -- Failures are typed `FsError`s with stable codes, never ad-hoc message strings. +- Read, listing, and mutation failures are typed `FsError`s with stable codes, never ad-hoc message strings; watch initialization may reject with an ordinary exception. - The seam arms no I/O deadline; cancellation is an optional per-primitive `AbortSignal`. @@ -110,7 +112,7 @@ No direct invalidation; the named consumer owns any request-prefix changes. These limits define when the contract is a poor fit or needs special operational care. They are current package constraints, not a general filesystem comparison or a task backlog. - **Text-only mutations by contract** — text reads and both mutations reject binary or non-UTF-8 content with `FS_NOT_TEXT`; `readBytes` and `readByteRange` are the raw-byte primitives, and binary-safe mutations remain deferred. -- **Thirteen primitives only** — no delete, rename, copy, or watch; `listDir` lists a single level, with recursion, globbing, pagination, and search out of scope ([directory-listing note](../../../.agents/notes/archived/architecture/2026-07-03-filesystem-directory-listing-seam.md)). +- **No delete, rename, or copy** — `listDir` lists a single level, with recursion, globbing, pagination, and search out of scope ([directory-listing note](../../../.agents/notes/archived/architecture/2026-07-03-filesystem-directory-listing-seam.md)). - **No I/O deadline** — the seam arms no timeout; cancellation is a best-effort optional `AbortSignal` per primitive ([fs family stance](../README.md)). - **Resolve-then-operate costs a remote backend two round-trips per tool call** — folding or caching resolution is left to such a backend. diff --git a/packages/fs/fs/README.zh.md b/packages/fs/fs/README.zh.md index 3efc2106a9..c9b9191355 100644 --- a/packages/fs/fs/README.zh.md +++ b/packages/fs/fs/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -应用需要在宿主、受限或远程执行环境中使用一致的文件系统操作时,选择 `dsh-fs`。消费方可以解析稳定的文件身份、在受支持时映射共享宿主文件、执行有界的文本与字节读取、列出目录,并原子地写入文本及执行字面量编辑。版本防护是可选的,因此后端无需策略强制也能工作;调用方可以提供防护,在文件变化后拒绝变更。在宿主执行时选择 `fs-local` 或 `fs-sandbox`。面向模型的文件系统工具由 `dsh-tool-fs` 单独提供。 +应用需要在宿主、受限或远程执行环境中使用一致的文件系统操作时,选择 `dsh-fs`。消费方可以解析稳定的文件身份、在受支持时映射共享宿主文件、执行有界的文本与字节读取、列出目录、监听目标,并原子地写入文本及执行字面量编辑。版本防护是可选的,因此后端无需策略强制也能工作;调用方可以提供防护,在文件变化后拒绝变更。在宿主执行时选择 `fs-local` 或 `fs-sandbox`。面向模型的文件系统工具由 `dsh-tool-fs` 单独提供。 ## 目录 @@ -33,7 +33,9 @@ kind: "package-reference" ### 服务能做什么 -通过 `ctx.fs`,你可以把任意路径解析为稳定的目标身份、完整读取或分片流式读取文本文件、按显式上限读取原始字节、列出一层目录、原子地创建或替换文件,并原子地应用字面量文本编辑。两个变更操作上的版本防护都是可选的:省略它即无条件创建或覆盖,提供它则在文件自上次观察以来发生变化时失败。每个操作要么返回数据,要么抛出携带稳定错误码(如 `FS_NOT_FOUND`、`FS_STALE_VERSION`、`FS_AMBIGUOUS_EDIT`)的类型化 `FsError`,调用方依据错误码分支,绝不解析消息文本。 +通过 `ctx.fs`,你可以把任意路径解析为稳定的目标身份、完整读取或分片流式读取文本文件、按显式上限读取原始字节、列出一层目录、原子地创建或替换文件,并原子地应用字面量文本编辑。两个变更操作上的版本防护都是可选的:省略它即无条件创建或覆盖,提供它则在文件自上次观察以来发生变化时失败。读取、列出与变更操作的失败使用携带稳定错误码(如 `FS_NOT_FOUND`、`FS_STALE_VERSION`、`FS_AMBIGUOUS_EDIT`)的类型化 `FsError`,调用方依据错误码分支,绝不解析消息文本。 + +`watch(target, changed, signal)` 报告单个文件或目录直接子项的失效通知。观察就绪后,它返回调用方必须等待完成的异步关闭函数。signal 取消初始化;不支持监听的提供方直接拒绝,不使用轮询。 ----- @@ -49,7 +51,7 @@ kind: "package-reference" 该约定建立在一个分离与三项承诺之上: -- **约定高于机制。** 服务只命名存储层能做什么——解析、stat、读取、列出、写入、编辑——绝不规定如何存储字节。后端拥有目标身份、执行世界坐标、解码、二进制拒绝与原子性。 +- **约定高于机制。** 服务只命名存储层能做什么——解析、stat、读取、列出、监听、写入、编辑——绝不规定如何存储字节。后端拥有目标身份、执行世界坐标、解码、二进制拒绝与原子性。 - **策略不放在基类上。** 已观察状态、编辑前读取与版本防护的变更是插件(`dsh-fs-observation-policy`)的职责,通过提供可选防护来添加——因此沙箱化或远程后端不会继承任何面向模型的观察策略。 - **`editText` 留在 seam 上。** 版本校验、字面量匹配与原子重写共享同一个临界区,错误归因与一方胜出/一方陈旧的并发语义因此保持正确;远程后端也可以将其实现为原生比较并编辑操作。 - **界限制在此 seam 上。** `readBytes` 要求 `maxBytes`,并以 `FS_TOO_LARGE` 失败而不是截断,因此任何后端都不会无界缓冲文件。`readByteRange` 则以窗口为界:后端最多传输所请求的 `length` 字节(外加为到达 `offset` 而跳过的前缀),因此由调用方对 `length` 的上限承担防护。 @@ -72,7 +74,7 @@ kind: "package-reference" ### 不变式 - `targetKey` 与 `version` 是带品牌的不透明 id:消费方不得解析或解释它们;只有 `displayPath` 用于模型/UI 输出。 -- 失败是携带稳定错误码的类型化 `FsError`,绝不是临时拼写的消息字符串。 +- 读取、列出与变更操作的失败是携带稳定错误码的类型化 `FsError`,绝不是临时拼写的消息字符串;监听初始化可以用普通异常拒绝。 - 该 seam 不设 I/O deadline;取消是每个原语上可选的 `AbortSignal`。 @@ -110,7 +112,7 @@ kind: "package-reference" 这些限制说明该约定何时不合适,或何时需要特别的运维注意。它们是当前包约束,不是通用文件系统对比或任务积压。 - **变更操作约定只支持文本**:文本读取和两个变更操作都以 `FS_NOT_TEXT` 拒绝二进制/非 UTF-8 内容;`readBytes` 与 `readByteRange` 是原始字节原语,二进制安全的变更操作仍延期。 -- **只有十三个原语**:没有删除、重命名、复制或监视;`listDir` 只列出一层,递归、glob、分页与搜索不在范围内(见[目录列出笔记](../../../.agents/notes/archived/architecture/2026-07-03-filesystem-directory-listing-seam.md))。 +- **没有删除、重命名或复制**:`listDir` 只列出一层,递归、glob、分页与搜索不在范围内(见[目录列出笔记](../../../.agents/notes/archived/architecture/2026-07-03-filesystem-directory-listing-seam.md))。 - **没有 I/O deadline**:该 seam 不启动超时;取消只是每个原语上尽力而为的可选 `AbortSignal`(见[fs 能力族立场](../README.zh.md))。 - **先解析后操作使远程后端每次工具调用需要两次往返**:折叠或缓存解析由这种后端自行决定。 diff --git a/packages/fs/fs/package.json b/packages/fs/fs/package.json index 8a6819b216..11337dc0e9 100644 --- a/packages/fs/fs/package.json +++ b/packages/fs/fs/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-fs", "description": "Abstract filesystem capability seam (ctx.fs) for the DeepSeek Harness — vocabulary types, the FileSystem service (text IO + optional version-guarded atomic mutations), and the fs/* policy event vocabulary", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/fs/fs/src/index.ts b/packages/fs/fs/src/index.ts index bfa05bf257..6f4ebe4e50 100644 --- a/packages/fs/fs/src/index.ts +++ b/packages/fs/fs/src/index.ts @@ -88,6 +88,16 @@ export abstract class FileSystem extends Service { super(ctx, 'fs') } + /** + * Observe one file or a directory's direct entries in this provider's execution world. + * @param target - resolved file or directory, including an absent path to observe for creation. + * @param changed - invalidation callback; errors can be reported during or after initialization. + * @param signal - cancels watcher initialization; the caller closes an initialized watcher. + * @returns a promise resolving once observation is active, with an asynchronous close function. + * @throws when the provider does not support watching or cannot initialize the watcher. + */ + abstract watch(target: FsTarget, changed: (error?: Error) => void, signal: AbortSignal): Promise<() => Promise> + /** * The sandbox mode this backend enforces on mutations BY DEFAULT, or * `undefined` when it does not confine at all — the capability fact the tool diff --git a/packages/fs/fs/tests/service.spec.ts b/packages/fs/fs/tests/service.spec.ts index f1b4800042..41e698a70e 100644 --- a/packages/fs/fs/tests/service.spec.ts +++ b/packages/fs/fs/tests/service.spec.ts @@ -21,6 +21,7 @@ import type { /** A minimal in-memory fake implementing the provider primitives. */ class FakeFileSystem extends FileSystem { + override watch(): never { throw new Error('Fixture does not support watching') } files = new Map() override async resolve(path: string): Promise { diff --git a/packages/fs/tool-fs-search/package.json b/packages/fs/tool-fs-search/package.json index e834eb4da8..b00cf3a2f8 100644 --- a/packages/fs/tool-fs-search/package.json +++ b/packages/fs/tool-fs-search/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-fs-search", "description": "Model-facing filesystem discovery tools (glob, grep) backed by the packaged ripgrep binary (@vscode/ripgrep)", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/fs/tool-fs/package.json b/packages/fs/tool-fs/package.json index 2ebc75972c..4f0e9a11a6 100644 --- a/packages/fs/tool-fs/package.json +++ b/packages/fs/tool-fs/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-fs", "description": "Model-facing filesystem tools (read, write, edit) over the DeepSeek Harness filesystem seam (ctx.fs)", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/fs/tool-fs/tests/tools.spec.ts b/packages/fs/tool-fs/tests/tools.spec.ts index f3f685f38e..28613a2920 100644 --- a/packages/fs/tool-fs/tests/tools.spec.ts +++ b/packages/fs/tool-fs/tests/tools.spec.ts @@ -41,6 +41,7 @@ const testToolSignal = new AbortController().signal /** An in-memory fake provider; a test can arm a rejection on any primitive. */ class FakeFs extends FileSystem { + override watch(): never { throw new Error('Fixture does not support watching') } files = new Map() rejectWith?: FsError writeIntents: (FsWriteIntent | undefined)[] = [] diff --git a/packages/fs/tool-str-replace-editor/package.json b/packages/fs/tool-str-replace-editor/package.json index bd0f284528..da5a7bc917 100644 --- a/packages/fs/tool-str-replace-editor/package.json +++ b/packages/fs/tool-str-replace-editor/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-str-replace-editor", "description": "Model-facing view, create, literal replace, and line insert tool over the Harness filesystem service", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/goal/command-goal/package.json b/packages/goal/command-goal/package.json index e4b1bb273d..a3edea6e33 100644 --- a/packages/goal/command-goal/package.json +++ b/packages/goal/command-goal/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-command-goal", "description": "Human-facing slash command for persisted same-session goals", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/goal/goal-round-driver/package.json b/packages/goal/goal-round-driver/package.json index d08c024a99..dbe8e6d44a 100644 --- a/packages/goal/goal-round-driver/package.json +++ b/packages/goal/goal-round-driver/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-goal-round-driver", "description": "Race-fenced same-session goal-round driver", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/goal/goal/package.json b/packages/goal/goal/package.json index 98f4821c04..b5f225b6ab 100644 --- a/packages/goal/goal/package.json +++ b/packages/goal/goal/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-goal", "description": "Event-sourced same-session goal state and lifecycle service for the DeepSeek Harness", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/goal/tool-goal/package.json b/packages/goal/tool-goal/package.json index 7bd609a601..5ca8ef1f1d 100644 --- a/packages/goal/tool-goal/package.json +++ b/packages/goal/tool-goal/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-goal", "description": "Model-facing same-session goal tools with execution-time authority checks", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/guard/repeat-tool-reminder/package.json b/packages/guard/repeat-tool-reminder/package.json index d3eff6baaa..9152cc7c5c 100644 --- a/packages/guard/repeat-tool-reminder/package.json +++ b/packages/guard/repeat-tool-reminder/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-repeat-tool-reminder", "description": "Repeat-tool-call guard plugin: advisory reminders when an agent loops on identical tool calls", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/guard/timeout-policy/package.json b/packages/guard/timeout-policy/package.json index cfd5908ac4..c4c80f8980 100644 --- a/packages/guard/timeout-policy/package.json +++ b/packages/guard/timeout-policy/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-call-timeout-policy", "description": "Tool-call timeout policy: a tools/execute wrapper that arms a per-tool deadline on exec.signal and returns TOOL_TIMEOUT when it wins", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/hooks/hook-protocol/package.json b/packages/hooks/hook-protocol/package.json index 48cb21aaf5..46bc9cc918 100644 --- a/packages/hooks/hook-protocol/package.json +++ b/packages/hooks/hook-protocol/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-hook-protocol", "description": "Shared Claude Code / Codex hook wire protocol: matcher engine, stdin/exit-code/stdout codec, multi-hook merge, and hook/* session events", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/hooks/hooks-claude-code/package.json b/packages/hooks/hooks-claude-code/package.json index 0a8339412f..27e70f15da 100644 --- a/packages/hooks/hooks-claude-code/package.json +++ b/packages/hooks/hooks-claude-code/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-hooks-claude-code", "description": "Bridge plugin: run a Claude Code hooks.json / settings hook config on the DeepSeek Harness interception seams", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/hooks/hooks-codex/package.json b/packages/hooks/hooks-codex/package.json index 4400f98352..9be8d766c0 100644 --- a/packages/hooks/hooks-codex/package.json +++ b/packages/hooks/hooks-codex/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-hooks-codex", "description": "Bridge plugin: run a Codex hooks.json hook config on the DeepSeek Harness interception seams", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/host/directory-picker-auto/package.json b/packages/host/directory-picker-auto/package.json index e58afc7e68..3f07279979 100644 --- a/packages/host/directory-picker-auto/package.json +++ b/packages/host/directory-picker-auto/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-host-directory-picker-auto", "description": "Adaptive chooser of the directory-picker seam: resolves the host situation at boot and mounts the native or browse backend for the DeepSeek Harness web GUI host", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/host/directory-picker-browse/package.json b/packages/host/directory-picker-browse/package.json index 241b7a610b..f8dc068d26 100644 --- a/packages/host/directory-picker-browse/package.json +++ b/packages/host/directory-picker-browse/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-host-directory-picker-browse", "description": "In-app browsing backend of the directory-picker seam (listing/creation primitives over the host filesystem)", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/host/directory-picker-native/package.json b/packages/host/directory-picker-native/package.json index 6f4f894168..51d5967648 100644 --- a/packages/host/directory-picker-native/package.json +++ b/packages/host/directory-picker-native/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-host-directory-picker-native", "description": "Native-OS-chooser backend of the directory-picker seam for the DeepSeek Harness web GUI host", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/host/directory-picker/package.json b/packages/host/directory-picker/package.json index 418fcf519b..d7f3c45eb0 100644 --- a/packages/host/directory-picker/package.json +++ b/packages/host/directory-picker/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-host-directory-picker", "description": "Abstract workspace-directory picking seam (ctx.directoryPicker) for the DeepSeek Harness web GUI host", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/host/frontend-static/README.i18n.yaml b/packages/host/frontend-static/README.i18n.yaml index eb2335a351..d9297be421 100644 --- a/packages/host/frontend-static/README.i18n.yaml +++ b/packages/host/frontend-static/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/host/frontend-static/README.md -README.md: e616c048119fc1ce75ad29b002c3298b84169af9 -README.zh.md: 6cf55bb6d82f665754767f0a8c5b9cc68d8236d6 +README.md: e9bf19b48acfbc8c1513b2b64226e17faf1dc2de +README.zh.md: 3021d6de08c6ab04732389423d0ae96846992dfe diff --git a/packages/host/frontend-static/README.md b/packages/host/frontend-static/README.md index e616c04811..e9bf19b48a 100644 --- a/packages/host/frontend-static/README.md +++ b/packages/host/frontend-static/README.md @@ -41,6 +41,8 @@ Compose this plugin in a browser-facing host that serves the built Web shell: it Requests are served from the dist root (the directory containing `distIndex`). The dist root and the configured index path render `index.html` with HTTP 200; any other existing file is served directly with its MIME type, and unknown extensions ship as `application/octet-stream`. A path that resolves outside the root is rejected with 403, so a crafted path cannot read files above the dist. An absent or non-file target inside the dist root — a missing file, a directory, or a missing configured index — returns an empty 404. Non-GET/HEAD requests without a matching named route are answered 405. Every successful index response is rendered through the webserver's `renderIndex`, so the boot manifest reaches the page on `/` and on the configured index path. +The served HTML carries one document base, ``, ahead of every injected resource row, so it freezes the entry directory the page was loaded from: the shell's own app-directory-relative references and the Host's plugin-resource rows both resolve under the mount that served the page. The same index therefore serves the origin root and whatever mount a prefix-stripping proxy owns; this plugin renders it only for the dist root and the configured index path. + Root and configured-index responses call `ctx.connection.authorizeIndex` before reading HTML. A valid process token receives a 303 redirect plus the persistent browser cookie; an existing valid cookie serves the index; every other index request receives the Connection-owned 401 response. Non-index files remain public static assets. Connection owns the token, cookie, expiry, and signing-record semantics. ### Observable failures @@ -57,7 +59,7 @@ Traversal returns 403 rather than an error page. An absent or non-file target in ### Design concept -The package is one function plugin around `serveStatic`: `apply` resolves the dist root from `distIndex`, builds a `renderIndex` closure that runs `ctx.webServer.renderIndex` over the raw `index.html`, and registers the fallback handler under an effect scope. The seat is single-owner by the webserver's contract — a second registration throws — and effect-scoped, so disposing the fiber releases the seat. +The package is one function plugin around `serveStatic`: `apply` resolves the dist root from `distIndex`, builds a `renderIndex` closure that runs `ctx.webServer.renderIndex` over the raw `index.html` and splices the document base in after the opening head tag (after the raw taps, so it precedes their markup too), and registers the fallback handler under an effect scope. The seat is single-owner by the webserver's contract — a second registration throws — and effect-scoped, so disposing the fiber releases the seat. ### The traversal fence diff --git a/packages/host/frontend-static/README.zh.md b/packages/host/frontend-static/README.zh.md index 6cf55bb6d8..3021d6de08 100644 --- a/packages/host/frontend-static/README.zh.md +++ b/packages/host/frontend-static/README.zh.md @@ -41,6 +41,8 @@ kind: "package-reference" 请求从 dist 根目录(包含 `distIndex` 的目录)提供。dist 根目录与配置的 index 路径以 HTTP 200 渲染 `index.html`;任何其他已有文件按自身 MIME 类型直接提供,未知扩展名按 `application/octet-stream` 提供。解析到根目录之外的路径以 403 拒绝,因此精心构造的路径无法读取 dist 之上的文件。dist 根目录内不存在或不是文件的目标——文件缺失、目录或配置的 index 缺失——返回空 404。没有匹配具名路由的非 GET/HEAD 请求返回 405。每个成功的 index 响应都经 webserver 的 `renderIndex` 渲染,因此启动 manifest(元数据清单)会通过 `/` 与配置的 index 路径送达页面。 +所服务的 HTML 携带唯一的文档 base ``,位于每一条注入资源行之前,因此它冻结页面加载时所处的入口目录:shell 自身的应用目录相对引用与宿主的插件资源行都在服务该页面的挂载下解析。同一份 index 因而既服务源站根目录,也服务剥离前缀的代理所拥有的任一挂载;本插件只为 dist 根目录与配置的 index 路径渲染它。 + 根路径与配置的 index 响应会在读取 HTML 前调用 `ctx.connection.authorizeIndex`。有效进程 token 会得到 303 重定向与持久浏览器 cookie;已有有效 cookie 时直接提供 index;其他 index 请求得到 Connection 所有的 401 响应。非 index 文件仍是公开静态资源。Token、cookie、过期时间与签名记录语义都归 Connection 所有。 ### 可观察的失败 @@ -57,7 +59,7 @@ kind: "package-reference" ### 设计理念 -本包是围绕 `serveStatic` 的一个函数插件:`apply` 从 `distIndex` 解析出 dist 根目录,构建一个对原始 `index.html` 运行 `ctx.webServer.renderIndex` 的 `renderIndex` 闭包,并在 effect 作用域下注册回退 handler。按 webserver 的约定,席位只有单一所有者——第二次注册会抛错——且受 effect 作用域约束,因此 dispose fiber 即释放席位。 +本包是围绕 `serveStatic` 的一个函数插件:`apply` 从 `distIndex` 解析出 dist 根目录,构建一个对原始 `index.html` 运行 `ctx.webServer.renderIndex` 并把文档 base 拼接在起始 head 标签之后的 `renderIndex` 闭包(在原始转换之后执行,因此也先于其标记),并在 effect 作用域下注册回退 handler。按 webserver 的约定,席位只有单一所有者——第二次注册会抛错——且受 effect 作用域约束,因此 dispose fiber 即释放席位。 ### 遍历栅栏 diff --git a/packages/host/frontend-static/package.json b/packages/host/frontend-static/package.json index fee79f6702..2f507c4e45 100644 --- a/packages/host/frontend-static/package.json +++ b/packages/host/frontend-static/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-host-frontend-static", "description": "SPA dist server for the Web shell: owns the webserver fallback seat, serving explicit index entries and static assets with traversal rejection and 404 misses", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/host/frontend-static/src/index.ts b/packages/host/frontend-static/src/index.ts index 3d218f31e6..e497efd228 100644 --- a/packages/host/frontend-static/src/index.ts +++ b/packages/host/frontend-static/src/index.ts @@ -113,13 +113,10 @@ export async function serveStatic( export function apply(ctx: Context, config: Config): void { const distIndex = config.distIndex const distRoot = dirname(distIndex) - // The dist is built with a relative base so the same files mount under any - // static directory; served pages also answer deep SPA-fallback paths, where - // relative asset URLs would resolve under the request directory, so the - // served form anchors them at the site root ahead of every URL-bearing tag. + // Insert after all index transforms so the base precedes every resource reference. const renderIndex = async (): Promise => { const body = ctx.webServer.renderIndex(await readFile(distIndex, 'utf8')) - return body.replace(/]*)?>/i, open => `${open}`) + return body.replace(/]*)?>/i, open => `${open}`) } ctx.effect(() => ctx.webServer.registerFallback(async (req, res) => { // Non-GET/HEAD without a matching named route is 405 (fallback-only diff --git a/packages/host/frontend-static/tests/frontend-static.spec.ts b/packages/host/frontend-static/tests/frontend-static.spec.ts index 2a38750c06..88008b7d65 100644 --- a/packages/host/frontend-static/tests/frontend-static.spec.ts +++ b/packages/host/frontend-static/tests/frontend-static.spec.ts @@ -105,7 +105,7 @@ describe('real Loader composition', () => { const launchUrl = loaded.connection.authenticatedUrl(`http://127.0.0.1:${String(port)}`) const exchange = await fetch(launchUrl, { redirect: 'manual' }) expect(exchange.status).toBe(303) - expect(exchange.headers.get('location')).toBe('/') + expect(exchange.headers.get('location')).toBe('./') const setCookie = exchange.headers.get('set-cookie') if (setCookie === null) throw new Error('authenticated frontend did not set a cookie') const cookie = setCookie.split(';', 1)[0]! @@ -141,13 +141,26 @@ describe('real Loader composition', () => { // Only the root and index path render index.html through registered taps. const untap = server.tapIndex(html => html.replace('', '')) + // A plugin row stands in for the Host's plugin-resource rows: the base the + // shell inserts must precede it, not merely exist. + const offRows = loaded.on('webserver/index-inject', (rows) => { + rows.push({ kind: 'script-preload', src: 'plugins/boot.js' }) + }) for (const path of ['/', '/index.html', '/?view=test']) { const got = await request(port, path, authenticated()) expect(got.status).toBe(200) expect(got.type).toBe('text/html; charset=utf-8') expect(got.body).toContain('__T__') expect(got.body).toContain('shell') + // The served document carries the entry-directory base exactly once, and + // it precedes every injected row and tap markup, so the shell's + // app-owned routes and the Host's resource rows resolve under one mount. + expect(got.body.match(/') + expect(base).toBeLessThan(got.body.indexOf('')) + expect(base).toBeLessThan(got.body.indexOf('')) } + offRows() expect(await request(port, '/', authenticated({ method: 'HEAD' }))).toEqual({ status: 200, type: 'text/html; charset=utf-8', diff --git a/packages/host/open-in-app/package.json b/packages/host/open-in-app/package.json index 81c74e6599..0a31a29205 100644 --- a/packages/host/open-in-app/package.json +++ b/packages/host/open-in-app/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-host-open-in-app", "description": "Host half of open-in-app: resolved application catalog, icons, and the launch endpoint as three webServer routes", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/host/open-in-app/src/index.ts b/packages/host/open-in-app/src/index.ts index 0c065e0111..8e8da91ec4 100644 --- a/packages/host/open-in-app/src/index.ts +++ b/packages/host/open-in-app/src/index.ts @@ -36,7 +36,7 @@ import { import { extractAppIcon, type OpenInAppIcon } from './icons.ts' import { internals } from './internals.ts' import { - OPEN_IN_APP_APPS_ROUTE, OPEN_IN_APP_ICON_PREFIX, OPEN_IN_APP_OPEN_ROUTE, + OPEN_IN_APP_APPS_PATH, OPEN_IN_APP_ICON_PREFIX_PATH, OPEN_IN_APP_OPEN_PATH, } from './shared.ts' export type * from './shared.ts' @@ -192,7 +192,7 @@ export function apply(ctx: Context, config: Config): void { ctx.effect(() => ctx.webServer.register({ kind: 'exact', - path: OPEN_IN_APP_APPS_ROUTE, + path: OPEN_IN_APP_APPS_PATH, handler: async (req, res) => { if (rejected(req, res)) return if (req.method !== 'GET') { @@ -201,11 +201,11 @@ export function apply(ctx: Context, config: Config): void { } sendJson(res, 200, { apps: [...(await availability()).keys()] }) }, - }), `open-in-app: GET ${OPEN_IN_APP_APPS_ROUTE}`) + }), `open-in-app: GET ${OPEN_IN_APP_APPS_PATH}`) ctx.effect(() => ctx.webServer.register({ kind: 'prefix', - path: OPEN_IN_APP_ICON_PREFIX, + path: OPEN_IN_APP_ICON_PREFIX_PATH, handler: async (req, res) => { if (rejected(req, res)) return if (req.method !== 'GET') { @@ -214,7 +214,7 @@ export function apply(ctx: Context, config: Config): void { } // Node always sets url on server requests; String keeps that fact local. const pathname = new URL(String(req.url), 'http://localhost').pathname - const id = pathname.slice(OPEN_IN_APP_ICON_PREFIX.length).replace(/^\//, '') + const id = pathname.slice(OPEN_IN_APP_ICON_PREFIX_PATH.length).replace(/^\//, '') const noIcon = (): void => { sendJson(res, 404, { code: 'not-found', message: `no icon for ${id}` }) } const app = OPEN_IN_APP_CATALOG.find(entry => entry.id === id) if (app === undefined) { @@ -236,11 +236,11 @@ export function apply(ctx: Context, config: Config): void { res.setHeader('cache-control', 'public, max-age=3600') res.end(icon.bytes) }, - }), `open-in-app: GET ${OPEN_IN_APP_ICON_PREFIX}/`) + }), `open-in-app: GET ${OPEN_IN_APP_ICON_PREFIX_PATH}/`) ctx.effect(() => ctx.webServer.register({ kind: 'exact', - path: OPEN_IN_APP_OPEN_ROUTE, + path: OPEN_IN_APP_OPEN_PATH, handler: async (req, res) => { if (rejected(req, res)) return if (req.method !== 'POST') { @@ -307,5 +307,5 @@ export function apply(ctx: Context, config: Config): void { sendJson(res, 502, { code: 'launch-failed', message: `failed to launch ${app.id}` }) } }, - }), `open-in-app: POST ${OPEN_IN_APP_OPEN_ROUTE}`) + }), `open-in-app: POST ${OPEN_IN_APP_OPEN_PATH}`) } diff --git a/packages/host/open-in-app/src/shared.ts b/packages/host/open-in-app/src/shared.ts index 8b9ff47c6d..9e703c1703 100644 --- a/packages/host/open-in-app/src/shared.ts +++ b/packages/host/open-in-app/src/shared.ts @@ -1,17 +1,29 @@ /** * Route paths and wire payloads shared verbatim by the host routes and the * browser package (`@deepseek-ai/dsh-client-ui-open-in-app`), published as - * the `./shared` subpath. Browser-safe: constants and types only. + * the `./shared` subpath. Browser-safe: constants and types only. Each route + * carries the absolute pathname the Host registers beside the + * document-relative form the browser addresses; see + * .agents/notes/implemented/architecture/2026-09-14-web-document-relative-app-routes.md. */ -/** GET route serving the probed application ids. */ -export const OPEN_IN_APP_APPS_ROUTE = '/open-in-app/apps' +/** GET route path serving the probed application ids. */ +export const OPEN_IN_APP_APPS_PATH = '/open-in-app/apps' -/** GET prefix serving one PNG bundle icon per application id. */ -export const OPEN_IN_APP_ICON_PREFIX = '/open-in-app/icon' +/** Browser-relative form of {@link OPEN_IN_APP_APPS_PATH}. */ +export const OPEN_IN_APP_APPS_ROUTE = OPEN_IN_APP_APPS_PATH.slice(1) -/** POST route launching one application on one workspace directory. */ -export const OPEN_IN_APP_OPEN_ROUTE = '/open-in-app/open' +/** GET prefix path serving one PNG bundle icon per application id. */ +export const OPEN_IN_APP_ICON_PREFIX_PATH = '/open-in-app/icon' + +/** Browser-relative form of {@link OPEN_IN_APP_ICON_PREFIX_PATH}. */ +export const OPEN_IN_APP_ICON_PREFIX_ROUTE = OPEN_IN_APP_ICON_PREFIX_PATH.slice(1) + +/** POST route path launching one application on one workspace directory. */ +export const OPEN_IN_APP_OPEN_PATH = '/open-in-app/open' + +/** Browser-relative form of {@link OPEN_IN_APP_OPEN_PATH}. */ +export const OPEN_IN_APP_OPEN_ROUTE = OPEN_IN_APP_OPEN_PATH.slice(1) /** Apps-route response: catalog ids probed as installed, in menu order. */ export interface OpenInAppAppsPayload { diff --git a/packages/host/plugin-inventory/package.json b/packages/host/plugin-inventory/package.json index 87867cb923..7d28dc02f6 100644 --- a/packages/host/plugin-inventory/package.json +++ b/packages/host/plugin-inventory/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-host-plugin-inventory", "description": "Read-only Remote projection of current Cordis Loader plugin state", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/host/webserver/package.json b/packages/host/webserver/package.json index ffd322bea2..16a3d87d84 100644 --- a/packages/host/webserver/package.json +++ b/packages/host/webserver/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-host-webserver", "description": "Web route-registration plugin: HTTP and upgrade routes, index transform taps, and static dist fallback; knows no harness concepts", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/host/webserver/src/injections.ts b/packages/host/webserver/src/injections.ts index 5b431918f5..79734fd34a 100644 --- a/packages/host/webserver/src/injections.ts +++ b/packages/host/webserver/src/injections.ts @@ -20,7 +20,7 @@ export type IndexInjection = /** * External classic script, executed in table order: a parser-blocking tag * when served, an awaited fetch-and-execute in the worker form (whose - * loader resolves worker-only URLs such as `/plugins/...`). + * loader resolves worker-only references such as `plugins/...`). */ | { kind: 'script-src'; placement: IndexInjectionPlacement; src: string } /** Advisory preload for an external classic script; static workers may ignore it. */ diff --git a/packages/identity/anonymous-user-id/package.json b/packages/identity/anonymous-user-id/package.json index 213b735fc9..282be9dd70 100644 --- a/packages/identity/anonymous-user-id/package.json +++ b/packages/identity/anonymous-user-id/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-anonymous-user-id", "description": "Shared anonymous user identity for DeepSeek Harness telemetry and feedback correlation", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/interaction/commands/package.json b/packages/interaction/commands/package.json index db49b1df19..8a41dc35bf 100644 --- a/packages/interaction/commands/package.json +++ b/packages/interaction/commands/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-commands", "description": "Plugin-owned human command registry for DeepSeek Harness UIs", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/interaction/permission-presets/package.json b/packages/interaction/permission-presets/package.json index 990a11ac78..2d1f9a9489 100644 --- a/packages/interaction/permission-presets/package.json +++ b/packages/interaction/permission-presets/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-permission-presets", "description": "User-facing permission presets (ctx.permissionPresets) for the DeepSeek Harness: one product-level Permissions select bundling the sandbox-mode and approval-policy knobs, written through to their own session events", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/interaction/tool-ask-user/package.json b/packages/interaction/tool-ask-user/package.json index 68ac229ab1..d4f94242c8 100644 --- a/packages/interaction/tool-ask-user/package.json +++ b/packages/interaction/tool-ask-user/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-ask-user", "description": "Model-facing ask_user_question tool over the ctx.userQuestions seam", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/interaction/user-approval/package.json b/packages/interaction/user-approval/package.json index e3f639233a..d0f4f32215 100644 --- a/packages/interaction/user-approval/package.json +++ b/packages/interaction/user-approval/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-user-approval", "description": "User-approval seam (ctx.approval) for the DeepSeek Harness: one-shot permission decisions dispatched to composed answerers over the approval/request waterfall, fail-closed by default", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/interaction/user-questions/README.i18n.yaml b/packages/interaction/user-questions/README.i18n.yaml index ae8e55b4e0..3e42cc7328 100644 --- a/packages/interaction/user-questions/README.i18n.yaml +++ b/packages/interaction/user-questions/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/interaction/user-questions/README.md -README.md: 5558c1483937982aad791f9c90e81ef9b02b9d83 -README.zh.md: 17cf9c973c82e4854c2807d85ff09cb9751bca14 +README.md: b7082f7b76ad974ecb75f48dc84f857784121fc9 +README.zh.md: 81f1f3e72b569dcaac96add3f6ff73cbe9038965 diff --git a/packages/interaction/user-questions/README.md b/packages/interaction/user-questions/README.md index 5558c14839..b7082f7b76 100644 --- a/packages/interaction/user-questions/README.md +++ b/packages/interaction/user-questions/README.md @@ -74,6 +74,8 @@ No direct invalidation; the named consumer owns any request-prefix changes. None. +The optional plan-review `callId` identifies the logged tool invocation for document navigation. It does not change the answer or its validation. + **Runtime invariant:** No companion is published. The answerer waterfall is resolved per request and returns directly to its caller; the seam publishes no independent request/answer audit stream. diff --git a/packages/interaction/user-questions/README.zh.md b/packages/interaction/user-questions/README.zh.md index 17cf9c973c..81f1f3e72b 100644 --- a/packages/interaction/user-questions/README.zh.md +++ b/packages/interaction/user-questions/README.zh.md @@ -74,6 +74,8 @@ kind: "package-reference" 无。 +计划审批中可选的 `callId` 标识已记录的工具调用,供文档导航使用,不改变回答及其校验。 + **运行时不变式:** 不发布伴生入口。answerer waterfall 按请求解析并把结果直接返回调用方;该 seam 不发布独立的请求/回答审计流。 diff --git a/packages/interaction/user-questions/package.json b/packages/interaction/user-questions/package.json index 4c89a6feab..2a2b782740 100644 --- a/packages/interaction/user-questions/package.json +++ b/packages/interaction/user-questions/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-user-questions", "description": "Abstract user-questions seam (ctx.userQuestions) for asking the human during agent runs", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/interaction/user-questions/src/types.ts b/packages/interaction/user-questions/src/types.ts index 1818a63d91..38e507ce29 100644 --- a/packages/interaction/user-questions/src/types.ts +++ b/packages/interaction/user-questions/src/types.ts @@ -2,6 +2,7 @@ import type { Scoped } from '@deepseek-ai/dsh-scope' import type { Agent } from '@deepseek-ai/dsh-agent/types' +import type { ToolCallId } from '@deepseek-ai/dsh-llm/brand' /** One selectable answer offered to the user. */ export interface AskUserQuestionOption { @@ -27,6 +28,8 @@ export type AskUserQuestionIntent = { * An `approve` naming no option of its own question is rejected at `ask()`. */ approve: string + /** Logged tool invocation whose arguments contain the reviewed plan. */ + callId?: ToolCallId } /** One question in a user-questions request. */ diff --git a/packages/jobs/jobs-local/package.json b/packages/jobs/jobs-local/package.json index 189daeb656..7251a74a48 100644 --- a/packages/jobs/jobs-local/package.json +++ b/packages/jobs/jobs-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-jobs-local", "description": "Process-local implementation of the DeepSeek Harness background job registry seam", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/jobs/jobs/package.json b/packages/jobs/jobs/package.json index f28980a70d..ea35be2cea 100644 --- a/packages/jobs/jobs/package.json +++ b/packages/jobs/jobs/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-jobs", "description": "Background job registry (ctx.jobs) for the DeepSeek Harness — shared ids, owner isolation, polling, cancellation, and completion listeners for long-running tool work", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/jobs/tool-jobs/package.json b/packages/jobs/tool-jobs/package.json index 83b9183c2c..95ad16b3d4 100644 --- a/packages/jobs/tool-jobs/package.json +++ b/packages/jobs/tool-jobs/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-jobs", "description": "Model-facing background job control tools (job_output, job_list, job_kill) over the ctx.jobs registry", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/jobs/tool-jobs/src/render.ts b/packages/jobs/tool-jobs/src/render.ts index a4da9f2b4f..b95eb4393b 100644 --- a/packages/jobs/tool-jobs/src/render.ts +++ b/packages/jobs/tool-jobs/src/render.ts @@ -6,7 +6,7 @@ * @module @deepseek-ai/dsh-tool-jobs/render */ -import type { JobChunk, JobView } from '@deepseek-ai/dsh-jobs' +import type { JobChunk, JobView } from '@deepseek-ai/dsh-jobs/view' /** Job state safe for model-authored programs; ownership and offsets are omitted. */ export interface PublicJobSnapshot { diff --git a/packages/llm/deepseek-llm-api-extensions/package.json b/packages/llm/deepseek-llm-api-extensions/package.json index 3034745232..fc4c9fa621 100644 --- a/packages/llm/deepseek-llm-api-extensions/package.json +++ b/packages/llm/deepseek-llm-api-extensions/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-deepseek-llm-api-extensions", "description": "Additive request-field registry for the official DeepSeek LLM API adapter", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index f3498bf0c3..cc43d12297 100644 --- a/packages/llm/llm-deepseek/README.i18n.yaml +++ b/packages/llm/llm-deepseek/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/llm/llm-deepseek/README.md -README.md: 18f1b3de48d554dbc2225cfcadf4900f8bb17b09 -README.zh.md: 38d288d233a84c3bbf8170d560ea42d372fab3fc +README.md: 33a76a316fd6f89e5d011ab3aa7a933331bd1e7b +README.zh.md: dbc7282140268985f215efcac0b4e602cd5b0e44 diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 18f1b3de48..33a76a316f 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -171,7 +171,7 @@ Read these pages when the package-level contract is not enough. They move from t #### What the model sees -The selected DeepSeek model receives the harness system prompt, message history, tool schemas, stop sequences, and call config (`maxTokens`, `reasoningEffort`, `temperature`) without adapter-authored prompt prose. Provider-specific request-extension fields remain outside model input. The vision model normally receives retained user and tool-result images as Files API references beside attachment handles and request-preview dimensions. It also receives a normalized-object path when the current execution filesystem maps the attachment provider's host object; the descriptor marks this copy read-only and warns that normalization may have resized or re-encoded the upload. A Files resolution failure sends all retained images as inline base64 instead, and an over-budget older image keeps the access resolved for that request in its placeholder. Reasoning content from a prior assistant turn is passed back verbatim, whether or not that turn called a tool. Messages sends `{}` for historical tool arguments that are malformed JSON or are not objects. Call ids, tool names, and results remain intact; the original arguments stay in the Session log. This silent fallback also applies after switching from Chat Completions. Newly generated Messages tool arguments still require valid JSON objects. +The selected DeepSeek model receives the harness system prompt, message history, tool schemas, stop sequences, and call config (`maxTokens`, `reasoningEffort`, `temperature`) without adapter-authored prompt prose. Provider-specific request-extension fields remain outside model input. The vision model normally receives retained user and tool-result images as Files API references beside attachment handles and request-preview dimensions. It also receives a normalized-object path when the current execution filesystem maps the attachment provider's host object; the descriptor marks this copy read-only and warns that normalization may have resized or re-encoded the upload. A Files resolution failure sends all retained images as inline base64 instead, and an over-budget older image keeps the access resolved for that request in its placeholder. Reasoning content from a prior assistant turn is passed back verbatim, whether or not that turn called a tool. Messages sends `{}` for historical tool arguments that are malformed JSON or are not objects. This argument fallback preserves call ids, tool names, and tool results; the original arguments stay in the Session log. This silent fallback also applies after switching from Chat Completions. Newly generated Messages tool arguments still require valid JSON objects. Messages omits `reasoning` and `tool-call` blocks inside user messages and tool results; omission of these two block types matches Chat Completions and the pi-ai adapter. This also permits replay of saved subagent notices containing assistant output; the original Session content remains intact. Empty user messages are skipped after conversion, while empty tool results retain their call ids and error flags. Other unsupported input blocks still fail with `UNSUPPORTED_CONTENT`. #### Token effect @@ -207,7 +207,8 @@ These limits define where the adapter stops and future work begins. They are cur - **A settings `models` list replaces the composition list wholesale** — settings-layer merging is per-field, and arrays are one field; per-entry catalog merging would need a keyed shape. - **`tool_choice` is not mapped** — not part of the core vocabulary (shared with the pi-ai twin). - **Requests use raw `fetch`, not `@cordisjs/plugin-http`** — no shared proxy or interception configuration. -- **Plugin-added content block types are skipped** — core text and supported image blocks are serialized, and empty tool output crosses the wire as the literal `(no output)`. +- **Chat Completions skips plugin-added content block types** — core text and supported image blocks are serialized, and empty tool output crosses the wire as the literal `(no output)`. +- **Messages in-history system updates require a retained user or tool-result turn** — if all user input after an update is omitted and the preceding wire turn is assistant, serialization fails with `UNSUPPORTED_CONTENT` before the next assistant or at the end of the request. Text or an empty tool result can retain that turn. Moving the update to an earlier turn is not supported; the [input-history decision](../../../.agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.md) records the ordering constraint. - **Images are input-only durable attachments** — direct external URLs and assistant image output are not supported; DeepSeek input normally uses the Files API and uses inline base64 only for per-request recovery. - The default catalog pre-registers `deepseek-flash` and its text/image and in-history capabilities without probing gateway availability. Requests can fail with `INVALID_REQUEST` until the gateway enables the id. With `DEEPSEEK_API_KEY` and a supporting gateway configured, `DEEPSEEK_FLASH_E2E=1` enables the Chat Completions check in [this package's e2e suite](tests/adapter.e2e.ts). - The [Messages system-update e2e checks](tests/messages/adapter.e2e.ts) require `DEEPSEEK_IN_HISTORY_MODEL` to name a supported model, such as `deepseek-flash`, and run with `high` effort. They skip when that variable is unset or empty; ordinary `off` text checks remain enabled with credentials. Known instruction-following instability with thinking disabled makes these system-update checks unsuitable for `off`. diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index 38d288d233..dbc7282140 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -171,7 +171,7 @@ Files 模式通过 `maxRequestFilesBytes` 与 `maxImagesPerRequest` 限制保留 #### 模型看到什么 -所选 DeepSeek 模型会收到 harness 系统提示词、消息历史、工具 schema、停止序列与调用配置(`maxTokens`、`reasoningEffort`、`temperature`),不包含适配器撰写的提示词散文。提供方专用请求扩展字段留在模型输入之外。视觉模型通常接收 Files API 引用形式的用户与工具结果图片,其旁带附件句柄和请求预览尺寸。当前执行文件系统可以映射附件提供方的宿主对象时,它还会收到规范化对象路径;描述符会把该副本标记为只读,并警告规范化可能缩放或重新编码上传内容。Files 解析失败时,全部保留图片改用内联 base64;超出预算的较旧图片则在占位文本中保留当前请求已解析的访问方式。此前 assistant 轮次的推理内容会原样传回,无论该轮次是否调用了工具。 对于非法 JSON 或非对象的历史工具参数,Messages 发送 `{}`。调用 ID、工具名和结果保持不变,原始参数仍保留在 Session 日志中。从 Chat Completions 切换后也适用此静默兜底。新生成的 Messages 工具参数仍须是有效 JSON 对象。 +所选 DeepSeek 模型会收到 harness 系统提示词、消息历史、工具 schema、停止序列与调用配置(`maxTokens`、`reasoningEffort`、`temperature`),不包含适配器撰写的提示词散文。提供方专用请求扩展字段留在模型输入之外。视觉模型通常接收 Files API 引用形式的用户与工具结果图片,其旁带附件句柄和请求预览尺寸。当前执行文件系统可以映射附件提供方的宿主对象时,它还会收到规范化对象路径;描述符会把该副本标记为只读,并警告规范化可能缩放或重新编码上传内容。Files 解析失败时,全部保留图片改用内联 base64;超出预算的较旧图片则在占位文本中保留当前请求已解析的访问方式。此前 assistant 轮次的推理内容会原样传回,无论该轮次是否调用了工具。 对于非法 JSON 或非对象的历史工具参数,Messages 发送 `{}`。此参数兜底保留调用 ID、工具名和工具结果;原始参数仍保留在 Session 日志中。从 Chat Completions 切换后也适用此静默兜底。新生成的 Messages 工具参数仍须是有效 JSON 对象。 Messages 会省略用户消息和工具结果中的 `reasoning` 与 `tool-call` 块;对这两类块的省略行为与 Chat Completions 和 pi-ai 适配器一致。这也允许回放包含助手输出的已保存子 Agent 通知;原始 Session 内容保持完整。转换后的空用户消息会被跳过,空工具结果则保留调用 id 和错误标记。其他不支持的输入块仍会以 `UNSUPPORTED_CONTENT` 失败。 #### Token 影响 @@ -207,7 +207,8 @@ loop 保留的响应块会追加到下一个请求,并保留其更早的可复 - **设置中的 `models` 列表会整体替换组合列表**——设置层按字段合并,数组只算一个字段;按条目合并目录需要带键的形状。 - **不映射 `tool_choice`**——不属于核心词汇(与 pi-ai 孪生共享)。 - **请求使用原始 `fetch`,而非 `@cordisjs/plugin-http`**——没有共享代理或拦截配置。 -- **跳过插件新增的内容块类型**——核心文本与受支持图片块会被序列化,空工具输出以字面量 `(no output)` 过线。 +- **Chat Completions 跳过插件新增的内容块类型**——核心文本与受支持图片块会被序列化,空工具输出以字面量 `(no output)` 过线。 +- **Messages 历史内 system 更新需要保留用户或工具结果轮次**——若更新后的全部用户输入都被省略,且前一个协议轮次是 assistant,序列化会在下一个 assistant 之前或请求结束处以 `UNSUPPORTED_CONTENT` 失败。文本或空工具结果可以保留该轮次。不支持将更新移到更早的轮次;[输入历史决策](../../../.agents/notes/implemented/bug-fix/2026-09-18-messages-input-history-compatibility.zh.md)记录了排序约束。 - **图片是仅用于输入的持久附件**——不支持直接外部 URL 与 assistant 图片输出;DeepSeek 输入通常使用 Files API,仅在单次请求恢复时使用内联 base64。 - 默认目录预注册 `deepseek-flash` 及其文本、图片和历史内更新能力,不探测网关可用性。网关开放该 ID 前,请求可能以 `INVALID_REQUEST` 失败。配置 `DEEPSEEK_API_KEY` 和支持该 ID 的网关后,设置 `DEEPSEEK_FLASH_E2E=1` 可启用[本包 e2e 测试文件](tests/adapter.e2e.ts)中的 Chat Completions 协议验证。 - [Messages system 更新 e2e](tests/messages/adapter.e2e.ts) 要求通过 `DEEPSEEK_IN_HISTORY_MODEL` 指定支持该能力的模型,例如 `deepseek-flash`,并使用 `high` 思考强度。该变量未设置或为空时跳过;普通 `off` 文本检查仍在有凭据时运行。关闭思考时已知的指令遵循不稳定,使这些 system 更新检查不适合使用 `off`。 diff --git a/packages/llm/llm-deepseek/package.json b/packages/llm/llm-deepseek/package.json index 3036ceb8fe..e3f69ca464 100644 --- a/packages/llm/llm-deepseek/package.json +++ b/packages/llm/llm-deepseek/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-llm-deepseek", "description": "DeepSeek adapter with Chat Completions and Messages protocols", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/llm/llm-deepseek/src/protocols/messages/serialize.ts b/packages/llm/llm-deepseek/src/protocols/messages/serialize.ts index a7cd5bcd89..41d3f3bb4a 100644 --- a/packages/llm/llm-deepseek/src/protocols/messages/serialize.ts +++ b/packages/llm/llm-deepseek/src/protocols/messages/serialize.ts @@ -39,6 +39,8 @@ function assistant(message: Message, model: string, onReplayDegrade?: (reason: s } /** Serialize one complete request using already prepared image bytes. + * User and tool-result content omits reasoning and tool-call blocks. + * Empty user messages are skipped; empty tool results retain their call ids. * @param options - provider-neutral request. * @param connection - validated defaults and thinking policy. * @param history - image-projected history with complete system snapshots; durable messages remain unchanged. @@ -58,6 +60,7 @@ export function serialize( const inHistory = model?.systemPromptUpdate === 'in-history' const input = (blocks: readonly ContentBlock[]): WireInput[] => blocks.flatMap((block): WireInput[] => { if (block.type === 'text') return block.text ? [{ type: 'text', text: block.text }] : [] + if (block.type === 'reasoning' || block.type === 'tool-call') return [] if (block.type !== 'image') return unsupported(`user/tool-result content ${block.type}`) const version = images.get(block.attachment.attachmentId) if (version === undefined) throw new LlmError('DeepSeek Messages request image is missing', 'INVALID_REQUEST') @@ -98,6 +101,7 @@ export function serialize( if (block.type !== 'tool-result') return input([block]) return [{ type: 'tool_result', tool_use_id: block.toolCallId, content: input(block.content), ...block.isError === undefined ? {} : { is_error: block.isError } }] }) + if (message.role === 'user' && content.length === 0) continue const previous = messages.at(-1) if (previous?.role === message.role) previous.content.push(...content) else messages.push({ role: message.role, content }) diff --git a/packages/llm/llm-deepseek/tests/messages/adapter.spec.ts b/packages/llm/llm-deepseek/tests/messages/adapter.spec.ts index 6af02de01b..d6752df872 100644 --- a/packages/llm/llm-deepseek/tests/messages/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/messages/adapter.spec.ts @@ -15,7 +15,7 @@ import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { AttachmentId } from '@deepseek-ai/dsh-attachment' import Loader from '@deepseek-ai/cordis-plugin-loader' import Include from '@deepseek-ai/cordis-plugin-include' -import LlmRuntime, { createAssistantMessage, createSystemMessage } from '@deepseek-ai/dsh-llm' +import LlmRuntime, { createAssistantMessage, createSystemMessage, createUserMessage, ToolCallId } from '@deepseek-ai/dsh-llm' import type { Message } from '@deepseek-ai/dsh-llm' import { credentialRef } from '@deepseek-ai/dsh-credentials' import LocalCredentials from '@deepseek-ai/dsh-credentials-local' @@ -53,6 +53,37 @@ async function send(agent: Agent, text: string) { } describe('direct Messages HTTP', () => { + it('continues through Messages with assistant blocks in saved user history', async () => { + const http = await endpoint() + const notice = createUserMessage({ source: { kind: 'plugin', plugin: 'saved-notice' }, content: [ + { type: 'text', text: 'Background subagent finished.' }, + { type: 'reasoning', text: 'child reasoning' }, + { type: 'tool-call', id: ToolCallId('child-call'), name: 'read', arguments: '{}' }, + { type: 'text', text: 'Its closing message: answer.' }, + ] }) + const empty = createUserMessage({ source: { kind: 'user' }, content: [{ type: 'reasoning', text: 'no closing text' }] }) + const history = [notice, empty] + const saved = JSON.stringify(history) + const llm = adapter({ baseURL: http.url }) + const first = await assemble(llm.stream(options({ messages: history }))) + const second = await assemble(llm.stream(options({ messages: [...history, first.message, user('continue')] }))) + + expect(first.assembler.finish.kind).toBe('stop') + expect(second.assembler.finish.kind).toBe('stop') + expect(http.requests).toHaveLength(2) + const wireNotice = { role: 'user', content: [ + { type: 'text', text: 'Background subagent finished.' }, + { type: 'text', text: 'Its closing message: answer.' }, + ] } + expect(http.requests[0]?.body.messages).toEqual([wireNotice]) + expect(http.requests[1]?.body.messages).toEqual([ + wireNotice, + { role: 'assistant', content: [{ type: 'text', text: 'Hello 世界' }] }, + { role: 'user', content: [{ type: 'text', text: 'continue' }] }, + ]) + expect(JSON.stringify(history)).toBe(saved) + }) + it('continues without a diagnostic callback when replay metadata is unusable', async () => { const http = await endpoint() const message = createAssistantMessage({ content: [{ type: 'text', text: 'Remember 731.' }], source: { diff --git a/packages/llm/llm-deepseek/tests/messages/fixtures/input-history.ts b/packages/llm/llm-deepseek/tests/messages/fixtures/input-history.ts new file mode 100644 index 0000000000..dbfbad61bf --- /dev/null +++ b/packages/llm/llm-deepseek/tests/messages/fixtures/input-history.ts @@ -0,0 +1,37 @@ +/** Replay persisted user input carrying assistant-only content through Messages. */ +import assert from 'node:assert/strict' +import type { Context } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/dsh-agent' +import { createUserMessage, ToolCallId } from '@deepseek-ai/dsh-llm' +import { resolveAdapterOptions } from '../../../src/config.ts' +import { serialize } from '../../../src/protocols/messages/serialize.ts' + +export const name = 'messages-input-history-snapshot' +export const inject = ['llm'] + +/** Record legacy-style notices and verify their provider representation. */ +export function apply(ctx: Context): void { + ctx.on('agent/pre-step', async (_input, next) => { + const decision = await next() + if (decision.kind !== 'enter') return decision + const source = { kind: 'plugin' as const, plugin: name } + return { ...decision, messages: [ + ...decision.messages, + createUserMessage({ source, content: [ + { type: 'text', text: 'Background subagent finished. Its closing message: Saved child answer.' }, + { type: 'reasoning', text: 'unforwarded-child-thought' }, + { type: 'tool-call', id: ToolCallId('unforwarded-child-call'), name: 'bash', arguments: '{}' }, + ] }), + createUserMessage({ source, content: [{ type: 'reasoning', text: 'unforwarded-empty-notice' }] }), + ] } + }) + const connection = resolveAdapterOptions({}) + ctx.on('llm/stream', (options, next) => { + const request = serialize(options, connection, options.messages, new Map(), () => undefined) + const payload = JSON.stringify(request.messages) + assert.ok(payload.includes('Saved child answer.')) + assert.ok(!payload.includes('unforwarded-')) + assert.ok(request.messages.every(message => message.content.length > 0)) + return next() + }) +} diff --git a/packages/llm/llm-deepseek/tests/messages/serialize.spec.ts b/packages/llm/llm-deepseek/tests/messages/serialize.spec.ts index 6e5d755202..eb1c6bccdf 100644 --- a/packages/llm/llm-deepseek/tests/messages/serialize.spec.ts +++ b/packages/llm/llm-deepseek/tests/messages/serialize.spec.ts @@ -65,6 +65,39 @@ describe('Messages request conversion', () => { expect(() => nativeBody([user(), assistant([call(), call('b')]), update, result()])).toThrow(/immediate results/) }) + it.each([[], [{ type: 'reasoning', text: 'child reasoning' }], [call('child-call')]] satisfies ContentBlock[][])( + 'rejects native system updates when their user input is omitted %#', (...content) => { + const empty = createMessage({ role: 'user', source: { kind: 'user' }, content }) + const history = [user(), assistant([{ type: 'text', text: 'answer' }]), createSystemMessage('updated', 'test'), empty] + const saved = JSON.stringify(history) + for (const messages of [history, [...history, assistant([{ type: 'text', text: 'next answer' }])]]) { + expect(() => nativeBody(messages)).toThrow(expect.objectContaining({ + code: 'UNSUPPORTED_CONTENT', + message: 'DeepSeek Messages cannot represent system update without a preceding user or tool-result turn', + })) + } + expect(JSON.stringify(history)).toBe(saved) + }, + ) + + it('keeps native system updates after retained text or tool results beside omitted user input', () => { + const reasoning: ContentBlock = { type: 'reasoning', text: 'child reasoning' } + const empty = createMessage({ role: 'user', source: { kind: 'user' }, content: [reasoning] }) + const update = createSystemMessage('updated', 'test') + for (const [previous, retained, expected] of [ + [assistant([{ type: 'text', text: 'answer' }]), user('continue'), [{ type: 'text', text: 'continue' }]], + [assistant([call()]), result('a', [reasoning]), [{ type: 'tool_result', tool_use_id: 'a', content: [], is_error: false }]], + ] as const) { + const history = [user(), previous, update, empty, retained, assistant([{ type: 'text', text: 'done' }])] + const saved = JSON.stringify(history) + const request = nativeBody(history) + expect(request.messages.map(message => message.role)).toEqual(['user', 'assistant', 'user', 'system', 'assistant']) + expect(request.messages[2]?.content).toEqual(expected) + expect(request.messages[3]?.content).toEqual([{ type: 'text', text: 'updated' }]) + expect(JSON.stringify(history)).toBe(saved) + } + }) + it('groups parallel results before ordinary text and keeps tool failure content', () => { const messages = [user(), assistant([call(), call('b')]), user('follow-up'), result(), createToolResultMessage({ callId: ToolCallId('b'), content: [{ type: 'text', text: 'permission denied' }], isError: true })] expect(body(messages).messages).toEqual([ @@ -86,6 +119,53 @@ describe('Messages request conversion', () => { expect(body([assistant([call()]), minimal]).messages[1]?.content[0]).toEqual({ type: 'tool_result', tool_use_id: 'a', content: [] }) }) + it('omits assistant-only blocks from user input while preserving text and durable content', () => { + const history = [createMessage({ role: 'user', source: { kind: 'user' }, content: [ + { type: 'text', text: 'Background subagent finished.\n' }, + { type: 'reasoning', text: 'child reasoning' }, + call('child-call'), + { type: 'text', text: ' child answer ' }, + ] })] + const saved = JSON.stringify(history) + + expect(body(history).messages).toEqual([{ role: 'user', content: [ + { type: 'text', text: 'Background subagent finished.\n' }, + { type: 'text', text: ' child answer ' }, + ] }]) + expect(JSON.stringify(history)).toBe(saved) + }) + + it('omits assistant-only blocks inside tool results while retaining calls, errors and empty results', () => { + const reasoning: ContentBlock = { type: 'reasoning', text: 'tool reasoning' } + const history = [assistant([reasoning, call(), call('b')]), + createToolResultMessage({ callId: ToolCallId('a'), content: [reasoning, call('nested-call'), { type: 'text', text: ' result\n' }], isError: true }), + result('b', [reasoning, call('another-nested-call')]), + ] + const saved = JSON.stringify(history) + + expect(body(history).messages).toEqual([ + { role: 'assistant', content: [ + { type: 'thinking', thinking: 'tool reasoning' }, + ...['a', 'b'].map(id => ({ type: 'tool_use', id, name: 'read', input: { path: 'a' } })), + ] }, + { role: 'user', content: [ + { type: 'tool_result', tool_use_id: 'a', content: [{ type: 'text', text: ' result\n' }], is_error: true }, + { type: 'tool_result', tool_use_id: 'b', content: [], is_error: false }, + ] }, + ]) + expect(JSON.stringify(history)).toBe(saved) + }) + + it.each([ + [], [{ type: 'reasoning', text: 'child reasoning' }], [call('child-call')], + ] satisfies ContentBlock[][])('omits empty user input after conversion %#', (...content) => { + const empty = createMessage({ role: 'user', source: { kind: 'user' }, content }) + expect(body([empty, user(), assistant([{ type: 'text', text: 'answer' }]), empty]).messages).toEqual([ + { role: 'user', content: [{ type: 'text', text: 'hello' }] }, + { role: 'assistant', content: [{ type: 'text', text: 'answer' }] }, + ]) + }) + it('collects leading system text and maps tools, stop sequences and explicit output cap', () => { const system = createMessage({ role: 'system', source: { kind: 'plugin', plugin: 'test' }, content: [{ type: 'text', text: 'instructions' }] }) expect(body([system, user()], { system: 'top', maxTokens: 123, stop: ['END'], tools: [{ name: 'read', description: 'Read a file', parameters: { type: 'object' } }] })).toMatchObject({ @@ -313,7 +393,8 @@ describe('Messages images', () => { await expect(prepareImages([assistant([image])], connection, model, attachments, access, signal)).rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' }) expect(() => body([result('a', [image])])).toThrow(/image/) expect(() => body([assistant([image])])).toThrow(/assistant/) - expect(() => body([result('a', [{ type: 'reasoning', text: 'bad' }])])).toThrow(/user/) + expect(() => body([result('a', [{ type: 'tool-result', toolCallId: ToolCallId('nested'), content: [] }])])) + .toThrow(/user\/tool-result content tool-result/) expect(() => serialize(options({ model }), connection, [result('a', [image])], new Map([[ref.attachmentId, version]]), access, undefined, new Map())) .toThrow(/request file id is missing/) }) diff --git a/packages/llm/llm-pi-ai/package.json b/packages/llm/llm-pi-ai/package.json index 458156d7a9..aded0159a4 100644 --- a/packages/llm/llm-pi-ai/package.json +++ b/packages/llm/llm-pi-ai/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-llm-pi-ai", "description": "pi-ai-backed DeepSeek adapter for the DeepSeek Harness LLM seam (design-verification twin of dsh-llm-deepseek)", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/llm/llm-retry/package.json b/packages/llm/llm-retry/package.json index e8757685fb..7000164d80 100644 --- a/packages/llm/llm-retry/package.json +++ b/packages/llm/llm-retry/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-llm-retry", "description": "Provider-routed LLM request retry policy for the DeepSeek Harness", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/llm/llm/package.json b/packages/llm/llm/package.json index 730227c937..7f88e79256 100644 --- a/packages/llm/llm/package.json +++ b/packages/llm/llm/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-llm", "description": "Provider-neutral LLM service interface for the DeepSeek Harness", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/llm/plugin-package-inventory-deepseek/package.json b/packages/llm/plugin-package-inventory-deepseek/package.json index 34d5c6cc3d..7799c7013d 100644 --- a/packages/llm/plugin-package-inventory-deepseek/package.json +++ b/packages/llm/plugin-package-inventory-deepseek/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-plugin-package-inventory-deepseek", "description": "Active Loader-backed plugin package inventory for official DeepSeek LLM API requests", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/llm/token-meter/package.json b/packages/llm/token-meter/package.json index ad50e9b574..24801893ee 100644 --- a/packages/llm/token-meter/package.json +++ b/packages/llm/token-meter/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-token-meter", "description": "Replay-aware token measurement service (ctx.tokenMeter) for the DeepSeek Harness", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/lsp/lsp-stdio/package.json b/packages/lsp/lsp-stdio/package.json index 2b25f3382e..4a79a07dbb 100644 --- a/packages/lsp/lsp-stdio/package.json +++ b/packages/lsp/lsp-stdio/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-lsp-stdio", "description": "Generic stdio language-server provider for the DeepSeek Harness LSP capability seam (ctx.lsp) — spawns configured servers, translates JSON-RPC, and serves transient-open goToDefinition/findReferences/goToImplementation/hover queries in the host filesystem namespace", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/lsp/lsp/package.json b/packages/lsp/lsp/package.json index 2456b6b95c..93ac2a883c 100644 --- a/packages/lsp/lsp/package.json +++ b/packages/lsp/lsp/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-lsp", "description": "Abstract LSP capability seam (ctx.lsp) for the DeepSeek Harness — language-server provider registry keyed by branded id and extension mapping, order-independent per-query selection, normalized definition/references/implementation/hover requests and results, and the LspError taxonomy", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/lsp/tool-lsp/package.json b/packages/lsp/tool-lsp/package.json index 65f60e8f3c..ea2a3049cf 100644 --- a/packages/lsp/tool-lsp/package.json +++ b/packages/lsp/tool-lsp/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-lsp", "description": "Model-facing lsp tool over the DeepSeek Harness LSP capability seam (ctx.lsp) — one read-only tool with goToDefinition/findReferences/goToImplementation/hover operations, one-based UTF-16 cursor coordinates, bounded location rendering, and hover normalization", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/mcp/mcp-client/package.json b/packages/mcp/mcp-client/package.json index 3f1a952ce9..4e4c4f7b57 100644 --- a/packages/mcp/mcp-client/package.json +++ b/packages/mcp/mcp-client/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-mcp-client", "description": "MCP client bridge: connects to MCP servers and registers their tools on ctx.tools", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/mcp/mcp-resources/package.json b/packages/mcp/mcp-resources/package.json index f567fc98be..032b5dc914 100644 --- a/packages/mcp/mcp-resources/package.json +++ b/packages/mcp/mcp-resources/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-mcp-resources", "description": "Scoped MCP resource discovery and reading through shared model tools", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/plan/plan-mode/README.i18n.yaml b/packages/plan/plan-mode/README.i18n.yaml index aa3473d5d1..87aee0ed5e 100644 --- a/packages/plan/plan-mode/README.i18n.yaml +++ b/packages/plan/plan-mode/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/plan/plan-mode/README.md -README.md: 693273ab4bdfddebd6145046e8354a5a211c8b8b -README.zh.md: 4782eed381ee3bdffab22ca162696f8711b33b47 +README.md: 642caf0c6db6470e93c7b0583d2bfe145ed99f29 +README.zh.md: 4165d5b925083d10c76312d00293fd0b0ac84ba0 diff --git a/packages/plan/plan-mode/README.md b/packages/plan/plan-mode/README.md index 693273ab4b..642caf0c6d 100644 --- a/packages/plan/plan-mode/README.md +++ b/packages/plan/plan-mode/README.md @@ -105,6 +105,8 @@ When `ctx.sessionProjections` is composed, the package registers the `plan` unit | [`src/client.ts`](src/client.ts) | Client-namespace re-export of the types outlet | | [`src/invariant.ts`](src/invariant.ts) | Invariant companion: validates the `plan/mode` payload shape | +The review intent carries the originating tool-call id, so the Web client can reopen the same submitted plan after the review closes. The complete Markdown remains in the existing native call or PTC dispatch log. + ----- diff --git a/packages/plan/plan-mode/README.zh.md b/packages/plan/plan-mode/README.zh.md index 4782eed381..4165d5b925 100644 --- a/packages/plan/plan-mode/README.zh.md +++ b/packages/plan/plan-mode/README.zh.md @@ -105,6 +105,8 @@ agent 完成计划后,会以 markdown 形式、从标题开头书写计划并 | [`src/client.ts`](src/client.ts) | types 出口的客户端命名空间再导出 | | [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件:校验 `plan/mode` 载荷结构 | +审批意图携带发起工具调用的标识,让 Web 客户端在审批关闭后重新打开同一份计划。完整 Markdown 保留在已有的原生调用或 PTC dispatch 日志中。 + ----- diff --git a/packages/plan/plan-mode/package.json b/packages/plan/plan-mode/package.json index bbe229bd52..48fb15031a 100644 --- a/packages/plan/plan-mode/package.json +++ b/packages/plan/plan-mode/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-plan-mode", "description": "Logged per-agent plan mode with deployment guidance, a direct slash command, and a user-reviewed exit", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/plan/plan-mode/src/index.ts b/packages/plan/plan-mode/src/index.ts index e5910064de..f2c53c27a4 100644 --- a/packages/plan/plan-mode/src/index.ts +++ b/packages/plan/plan-mode/src/index.ts @@ -312,7 +312,7 @@ export class PlanModeController extends Service { // Presentation only: a capable UI renders the plan as a review // decision instead of a generic question, and answers with one of // the labels above either way. - intent: { kind: 'plan-review', approve: APPROVE_LABEL }, + intent: { kind: 'plan-review', approve: APPROVE_LABEL, callId: exec.callId }, }], agent, signal: exec.signal, diff --git a/packages/plan/plan-mode/tests/plan-mode.spec.ts b/packages/plan/plan-mode/tests/plan-mode.spec.ts index 313a3c9192..bdabf94997 100644 --- a/packages/plan/plan-mode/tests/plan-mode.spec.ts +++ b/packages/plan/plan-mode/tests/plan-mode.spec.ts @@ -1074,7 +1074,7 @@ describe('exit_plan_mode', () => { const { ctx, agent, asked } = await setupWithReview({ selected: ['Approve'] }) await callExit(ctx, agent) const question = asked[0]?.questions[0] - expect(question?.intent).toEqual({ kind: 'plan-review', approve: 'Approve' }) + expect(question?.intent).toEqual({ kind: 'plan-review', approve: 'Approve', callId: `call-exit-${callCounter}` }) // The named label is one this same question offers, so a UI honouring the // intent answers a choice this tool accepts. expect(question?.options?.map(option => option.label)).toContain(question?.intent?.approve) diff --git a/packages/preset/agent-presets/package.json b/packages/preset/agent-presets/package.json index b6771f259b..345e1805f2 100644 --- a/packages/preset/agent-presets/package.json +++ b/packages/preset/agent-presets/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-agent-presets", "description": "Per-session agent composition from preset cordis.yml files for the DeepSeek Harness", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/preset/persona/package.json b/packages/preset/persona/package.json index 6b6fbfe358..e6f2d3feb4 100644 --- a/packages/preset/persona/package.json +++ b/packages/preset/persona/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-persona", "description": "Composition-authored deployment persona section for the DeepSeek Harness", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/ptc-runtime/ptc-runtime-node/package.json b/packages/ptc-runtime/ptc-runtime-node/package.json index 8fb0615ddb..c91f4afa2c 100644 --- a/packages/ptc-runtime/ptc-runtime-node/package.json +++ b/packages/ptc-runtime/ptc-runtime-node/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-ptc-runtime-node", "description": "Sandboxed Node process implementation of the DeepSeek Harness PTC execution capability", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/ptc-runtime/ptc-runtime/package.json b/packages/ptc-runtime/ptc-runtime/package.json index ad6bb0008e..183d61d88c 100644 --- a/packages/ptc-runtime/ptc-runtime/package.json +++ b/packages/ptc-runtime/ptc-runtime/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-ptc-runtime", "description": "Abstract PTC execution seam (ctx.ptcRuntime) for the DeepSeek Harness", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/runtime-diagnostics/invariants/package.json b/packages/runtime-diagnostics/invariants/package.json index 12d6030732..ba1af4b231 100644 --- a/packages/runtime-diagnostics/invariants/package.json +++ b/packages/runtime-diagnostics/invariants/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-invariants", "description": "Registry service for package-owned DeepSeek Harness runtime invariants", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/sandbox/sandbox-local/package.json b/packages/sandbox/sandbox-local/package.json index 0a5ca66418..863cb4185f 100644 --- a/packages/sandbox/sandbox-local/package.json +++ b/packages/sandbox/sandbox-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sandbox-local", "description": "Local process-sandbox backends for the DeepSeek Harness sandbox seam: bwrap, the npm-distributed landlock-run launcher, macOS Seatbelt, or the Windows ACL restricted-token runner — functionally probed, fail-closed", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/sandbox/sandbox-policy/package.json b/packages/sandbox/sandbox-policy/package.json index 20866a0190..5f02de815f 100644 --- a/packages/sandbox/sandbox-policy/package.json +++ b/packages/sandbox/sandbox-policy/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sandbox-policy", "description": "Per-call sandbox policy resolver and current model context: deployment fallbacks plus each session's mode and workspace root, shared by every enforcing capability family", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/sandbox/sandbox-windows-acl/package.json b/packages/sandbox/sandbox-windows-acl/package.json index 97b05ad68c..05fa2c64f0 100644 --- a/packages/sandbox/sandbox-windows-acl/package.json +++ b/packages/sandbox/sandbox-windows-acl/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sandbox-windows-acl", "description": "Windows ACL write-restriction sandbox backend (restricted-token spawn with capability-SID write allowlist) for the DeepSeek Harness sandbox seam", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/sandbox/sandbox/package.json b/packages/sandbox/sandbox/package.json index acad37c554..2894459359 100644 --- a/packages/sandbox/sandbox/package.json +++ b/packages/sandbox/sandbox/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sandbox", "description": "Abstract process-sandbox seam (ctx.sandbox) for the DeepSeek Harness: same-world confinement vocabulary and the SandboxProvider contract", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/schedule/schedule/package.json b/packages/schedule/schedule/package.json index d7edb02911..a9691390d8 100644 --- a/packages/schedule/schedule/package.json +++ b/packages/schedule/schedule/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-schedule", "description": "Agent-scoped durable after, at, and fixed-rate reminders over the session event log", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/sdk/client/package.json b/packages/sdk/client/package.json index 4648e9ad79..7601ebaf37 100644 --- a/packages/sdk/client/package.json +++ b/packages/sdk/client/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sdk-client", "description": "TypeScript client SDK for driving a DeepSeek Harness runtime subprocess over stdio JSON-RPC: the DeepSeekHarness high-level turns API and the lower-level HarnessClient", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/sdk/protocol/package.json b/packages/sdk/protocol/package.json index 8ddeb9d856..ea91db882a 100644 --- a/packages/sdk/protocol/package.json +++ b/packages/sdk/protocol/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sdk-protocol", "description": "Shared wire protocol for the DeepSeek Harness SDK runtime: the newline-delimited JSON-RPC stdio transport and the named request, result, and notification types spoken between the runtime server and SDK clients", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/sdk/server/package.json b/packages/sdk/server/package.json index 376fd47ad1..3670aea92a 100644 --- a/packages/sdk/server/package.json +++ b/packages/sdk/server/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sdk-jsonrpc-server", "description": "Stdio JSON-RPC server plugin for out-of-process DeepSeek Harness SDK clients", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/session-query/session-log-export/README.i18n.yaml b/packages/session-query/session-log-export/README.i18n.yaml index ae845c1725..a2c80bc2d6 100644 --- a/packages/session-query/session-log-export/README.i18n.yaml +++ b/packages/session-query/session-log-export/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/session-query/session-log-export/README.md -README.md: 8697b0c2c105c928f5325f953b7fb663ae68e18e -README.zh.md: fe60c6ab8cc8d2d5e7515416bb72624584c11b9d +README.md: 79b8d572a75f57aa13a0dfaa823a04ad1e818f87 +README.zh.md: d6229927130e7200cd21df0da64d0ce9ec80ab2f diff --git a/packages/session-query/session-log-export/README.md b/packages/session-query/session-log-export/README.md index 8697b0c2c1..79b8d572a7 100644 --- a/packages/session-query/session-log-export/README.md +++ b/packages/session-query/session-log-export/README.md @@ -27,6 +27,8 @@ English | [中文](README.zh.md) Use this package when the Web bundle should let users export a session log. It requires Connection, the command registry, Session query and persistence, and attachments. Mount the plugin, then choose `Download session log` from the Session Header's more-actions menu or type `/export`; the browser downloads `dsh-session-.zip`. +When `ui-message-feedback` is mounted, the same menu also offers `Feedback`, which opens its existing Session feedback dialog. Opening or dismissing that form does not export the Session or submit feedback. The feedback row follows the feedback plugin's availability; export remains available independently. + ### When to choose it Choose it for a Web deployment that needs user-facing session export with a visible download dialog. Avoid it when a programmatic or Host-side export is needed: this package produces a browser download, not a Host path write. The logs are serialized from persistence read handles, so any mounted backend is supported. @@ -50,7 +52,7 @@ The Web bundle mounts the package with Connection, `dsh-commands`, `dsh-client-u | Input | Result | |---|---| -| `/export` | Records a human-command lifecycle; the submitting browser downloads `GET /api/session.export?sessionId=&includeDescendants=true` | +| `/export` | Records a human-command lifecycle; the submitting browser downloads the document-relative `api/session.export?sessionId=&includeDescendants=true` (Host route `/api/session.export`) | | `/export ` | An error; browser downloads choose their destination through the browser's ordinary download behavior | ### What to expect @@ -77,7 +79,7 @@ The package has two halves. The Host half ([`src/index.ts`](src/index.ts)) regis ### Download flow -Both entry paths issue a `HEAD` preflight to `/api/session.export?...`, then hand the GET URL to the browser download manager without buffering the ZIP in JavaScript. One controller owns one in-flight download per session, collapses concurrent gestures into that operation, and cancels the preflight on plugin disposal. Modal state lives in a snapshot store keyed by session, so the button and the command share one dialog per session. +Both entry paths issue a `HEAD` preflight to the document-relative `api/session.export?...`, then hand the GET route to the browser download manager without buffering the ZIP in JavaScript. One controller owns one in-flight download per session, collapses concurrent gestures into that operation, and cancels the preflight on plugin disposal. Modal state lives in a snapshot store keyed by session, so the button and the command share one dialog per session. The Host route is a feature-owned exact Fetch contribution. Connection applies its Host/Origin and browser-session checks and bridges the streaming `Response`; this package owns query validation, live-session flushes, handle-based log reads and attachment reads, ZIP generation, and HTTP status semantics. diff --git a/packages/session-query/session-log-export/README.zh.md b/packages/session-query/session-log-export/README.zh.md index fe60c6ab8c..d622992713 100644 --- a/packages/session-query/session-log-export/README.zh.md +++ b/packages/session-query/session-log-export/README.zh.md @@ -27,6 +27,8 @@ kind: "package-reference" 当 Web bundle 需要让用户导出会话日志时使用本包。它需要 Connection、命令注册表、Session 查询与持久化以及附件服务。挂载插件,然后在 Session Header 的更多操作菜单中选择 `下载 Session 日志` 或输入 `/export`;浏览器会下载 `dsh-session-.zip`。 +挂载 `ui-message-feedback` 时,同一菜单还提供“反馈”,打开已有的 Session 反馈弹窗。打开或关闭该弹窗不会导出 Session 或提交反馈。反馈入口随反馈插件的可用状态显示;导出功能保持独立可用。 + ### 何时选择 为需要带可见下载弹窗的面向用户的会话导出的 Web 部署选择它。需要程序化或 Host 侧导出时避免使用:本包产生的是浏览器下载,而非 Host 路径写入。日志从持久化读句柄序列化而来,因此任何已挂载后端都受支持。 @@ -50,7 +52,7 @@ Web bundle 将本包与 Connection、`dsh-commands`、`dsh-client-ui-commands` | 输入 | 结果 | |---|---| -| `/export` | 记录用户命令的生命周期;提交命令的浏览器下载 `GET /api/session.export?sessionId=&includeDescendants=true` | +| `/export` | 记录用户命令的生命周期;提交命令的浏览器下载文档相对的 `api/session.export?sessionId=&includeDescendants=true`(Host 路由 `/api/session.export`) | | `/export ` | 错误;浏览器下载通过浏览器的普通下载行为选择目标位置 | ### 预期行为 @@ -77,7 +79,7 @@ Web bundle 将本包与 Connection、`dsh-commands`、`dsh-client-ui-commands` ### 下载流程 -两条入口都会先向 `/api/session.export?...` 发出 `HEAD` 预检请求,然后把 GET URL 交给浏览器下载管理器,JavaScript 不缓冲 ZIP。一个控制器按会话持有一项进行中的下载,把并发操作折叠进该任务,并在插件释放时取消预检。弹窗状态存放在按会话键控的快照存储中,因此按钮与命令按会话共享一个弹窗。 +两条入口都会先向文档相对的 `api/session.export?...` 发出 `HEAD` 预检请求,然后把 GET 路由交给浏览器下载管理器,JavaScript 不缓冲 ZIP。一个控制器按会话持有一项进行中的下载,把并发操作折叠进该任务,并在插件释放时取消预检。弹窗状态存放在按会话键控的快照存储中,因此按钮与命令按会话共享一个弹窗。 Host 路由是由该功能拥有的精确 Fetch 路由贡献。Connection 应用 Host/Origin 与浏览器会话检查并桥接流式 `Response`;本包拥有查询校验、活动会话 flush、基于句柄的日志读取与附件读取、ZIP 生成和 HTTP 状态语义。 diff --git a/packages/session-query/session-log-export/package.json b/packages/session-query/session-log-export/package.json index 75d743253d..7e3a26f866 100644 --- a/packages/session-query/session-log-export/package.json +++ b/packages/session-query/session-log-export/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-log-export", "description": "Web Session-log export command and shared download dialog", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, @@ -56,6 +56,7 @@ "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-client-ui-commands": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-message-feedback": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-session": "workspace:^", diff --git a/packages/session-query/session-log-export/src/client/HeaderAction.tsx b/packages/session-query/session-log-export/src/client/HeaderAction.tsx index cb20cb9f9d..9810fb86d3 100644 --- a/packages/session-query/session-log-export/src/client/HeaderAction.tsx +++ b/packages/session-query/session-log-export/src/client/HeaderAction.tsx @@ -1,16 +1,34 @@ import { useState } from 'react' import type { ReactNode } from 'react' -import { IconDownloadOutline16, IconEllipsisOutline16, Menu } from '@deepseek-ai/dsh-client-ui-primitives' +import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' +import type { InjectFace } from '@deepseek-ai/dsh-client-ui-slots' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { IconDownloadOutlineRegular, IconEllipsisOutlineRegular, IconPaperPlaneOutlineRegular, Menu } from '@deepseek-ai/dsh-client-ui-primitives' import { SessionLogDownloadDialog, type SessionLogDownloadDialogProps } from './Dialog.tsx' +import type { SessionLogDownloadDialogInjected } from './Dialog.tsx' import css from './HeaderAction.module.css' +/** Session download controls with observable feedback availability and a Session feedback action. */ +export interface SessionLogDownloadHeaderInjected extends SessionLogDownloadDialogInjected { + hooks: SessionLogDownloadDialogInjected['hooks'] & { feedbackAvailable: ObservableSnapshot } + /** + * Open the existing Session feedback draft without recording feedback; no-op after the feedback plugin unloads. + * @param sessionId - Session whose feedback form to open. + */ + openFeedback: (sessionId: SessionId) => void +} + +/** Session download props plus the optional feedback action. */ +export type SessionLogDownloadHeaderProps = SessionLogDownloadDialogProps & InjectFace + /** - * Render the Session Header more-actions icon button, its download menu, and the shared result dialog. + * Render the Session Header menu with download and optional feedback actions. * @param props - Session runtime, download controller, and localized copy. * @returns the persistent Header action and Session-scoped dialog. */ -export function SessionLogDownloadHeaderAction(props: SessionLogDownloadDialogProps): ReactNode { - const { sessionId, useSessionLogDownload, request, t } = props +export function SessionLogDownloadHeaderAction(props: SessionLogDownloadHeaderProps): ReactNode { + const { sessionId, useSessionLogDownload, useFeedbackAvailable, request, openFeedback, t } = props + const feedbackAvailable = useFeedbackAvailable(value => value) const entry = useSessionLogDownload(state => state.bySession[String(sessionId)]) const busy = entry?.status === 'downloading' const [open, setOpen] = useState(false) @@ -22,10 +40,14 @@ export function SessionLogDownloadHeaderAction(props: SessionLogDownloadDialogPr align="end" dense onClose={() => { setOpen(false) }} - items={[{ id: 'download', label: t('menu.download'), icon: , disabled: busy }]} - onSelect={() => { + items={[ + { id: 'download', label: t('menu.download'), icon: , disabled: busy }, + ...feedbackAvailable ? [{ id: 'feedback', label: t('menu.feedback'), icon: }] : [], + ]} + onSelect={(id) => { setOpen(false) - void request(sessionId) + if (id === 'feedback') openFeedback(sessionId) + else void request(sessionId) }} anchor={( )} /> diff --git a/packages/session-query/session-log-export/src/client/controller.ts b/packages/session-query/session-log-export/src/client/controller.ts index f7f01cceb9..071b99f55e 100644 --- a/packages/session-query/session-log-export/src/client/controller.ts +++ b/packages/session-query/session-log-export/src/client/controller.ts @@ -2,6 +2,7 @@ import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { SESSION_LOG_EXPORT_ROUTE } from '../routes.ts' /** Download phases presented by the shared modal. */ export type SessionLogDownloadStatus = 'downloading' | 'success' | 'error' @@ -18,6 +19,7 @@ export interface SessionLogDownloadState { bySession: Record } +/** HTTP carrier for the export route. */ type Fetch = (input: string | URL, init?: RequestInit) => Promise type Save = (url: string, filename: string) => void @@ -33,8 +35,9 @@ export function sessionLogZipFilename(sessionId: SessionId): string { } /** - * Hand a Host download URL to the browser download manager. - * @param url - same-origin Host download URL. + * Hand a Host download route to the browser download manager, which resolves it + * against the document's own base. + * @param url - document-relative Host download route. * @param filename - browser download filename. */ export function downloadUrl(url: string, filename: string): void { @@ -44,12 +47,6 @@ export function downloadUrl(url: string, filename: string): void { anchor.click() } -/** Resolve the browser's Host base with the connection carrier's null-origin fallback. */ -function hostBase(): string { - const origin = (globalThis as { location?: { origin?: string } }).location?.origin - return origin !== undefined && origin !== 'null' ? origin : 'http://dsh.internal' -} - function messageOf(error: unknown): string { return error instanceof Error ? error.message : String(error) } @@ -112,15 +109,14 @@ export class SessionLogDownloadController { private async run(sessionId: SessionId, signal: AbortSignal): Promise { this.publish(sessionId, { open: true, status: 'downloading', error: null }) try { - const url = new URL('/api/session.export', hostBase()) - url.searchParams.set('sessionId', sessionId) - url.searchParams.set('includeDescendants', 'true') - const response = await this.fetcher(url, { method: 'HEAD', signal }) + const query = new URLSearchParams({ sessionId, includeDescendants: 'true' }) + const route = `${SESSION_LOG_EXPORT_ROUTE}?${query.toString()}` + const response = await this.fetcher(route, { method: 'HEAD', signal }) if (!response.ok) { const detail = await response.text().catch(() => '') throw new Error(`Export failed: HTTP ${response.status}${detail === '' ? '' : ` ${detail}`}`) } - this.save(url.toString(), sessionLogZipFilename(sessionId)) + this.save(route, sessionLogZipFilename(sessionId)) const open = this.store.getSnapshot().bySession[String(sessionId)]?.open ?? true this.publish(sessionId, { open, status: 'success', error: null }) } catch (error: unknown) { diff --git a/packages/session-query/session-log-export/src/client/index.ts b/packages/session-query/session-log-export/src/client/index.ts index 97187311fa..bcba0dd581 100644 --- a/packages/session-query/session-log-export/src/client/index.ts +++ b/packages/session-query/session-log-export/src/client/index.ts @@ -7,9 +7,10 @@ import type {} from '@deepseek-ai/dsh-client-ui-commands/client' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import type {} from '@deepseek-ai/dsh-client-ui-session/client' +import type {} from '@deepseek-ai/dsh-client-ui-message-feedback/client' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { SessionLogDownloadController } from './controller.ts' -import type { SessionLogDownloadDialogInjected } from './Dialog.tsx' -import { SessionLogDownloadHeaderAction } from './HeaderAction.tsx' +import { SessionLogDownloadHeaderAction, type SessionLogDownloadHeaderInjected } from './HeaderAction.tsx' import { en, NS, zh, type SessionLogDownloadKey } from './locales.ts' declare module '@deepseek-ai/cordis' { @@ -37,6 +38,13 @@ export function apply(ctx: ClientContext): void { ctx.provide('sessionLogDownload', controller) ctx.effect(() => async () => { await controller.dispose() }, 'session-log-download: browser download lifecycle') ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'session-log-download: browser dictionaries') + const feedbackAvailable = createSnapshotStore(false) + ctx.inject(['feedbackUi'], (scope: ClientContext) => { + scope.effect(() => { + feedbackAvailable.set(true) + return () => { feedbackAvailable.set(false) } + }, 'session-log-download: feedback availability') + }) ctx.on('command/executed', (sessionId, commandName, result) => { if (commandName === 'export' && result.kind === 'success') void controller.download(sessionId) }) @@ -44,10 +52,12 @@ export function apply(ctx: ClientContext): void { name: 'conversation.session.header.utilities', id: 'session-log-download', locale: NS, - inject: (): SessionLogDownloadDialogInjected => ({ - hooks: { sessionLogDownload: controller.store }, + inject: (): SessionLogDownloadHeaderInjected => ({ + hooks: { sessionLogDownload: controller.store, feedbackAvailable }, request: (sessionId: SessionId) => controller.download(sessionId), dismiss: (sessionId: SessionId) => { controller.dismiss(sessionId) }, + // The feedback plugin can unload between the menu render and this click. + openFeedback: (sessionId: SessionId) => { ctx.get('feedbackUi')?.openSession(sessionId) }, }), }, SessionLogDownloadHeaderAction)) } diff --git a/packages/session-query/session-log-export/src/client/locales.ts b/packages/session-query/session-log-export/src/client/locales.ts index 54d68f2665..1fd687a379 100644 --- a/packages/session-query/session-log-export/src/client/locales.ts +++ b/packages/session-query/session-log-export/src/client/locales.ts @@ -5,6 +5,7 @@ export const NS = 'session-log-download' export const zh = { 'header.more': '更多操作', 'menu.download': '下载 Session 日志', + 'menu.feedback': '反馈', 'dialog.preparingTitle': '正在导出 Session', 'dialog.preparingDescription': '正在准备包含当前 Session、子 Session 和附件的 ZIP 文件。', 'dialog.successTitle': 'Session 导出已开始下载', @@ -18,6 +19,7 @@ export const zh = { export const en: Record = { 'header.more': 'More actions', 'menu.download': 'Download session log', + 'menu.feedback': 'Feedback', 'dialog.preparingTitle': 'Exporting Session', 'dialog.preparingDescription': 'Preparing a ZIP containing this Session, its sub-Sessions, and attachments.', 'dialog.successTitle': 'Session download started', diff --git a/packages/session-query/session-log-export/src/index.ts b/packages/session-query/session-log-export/src/index.ts index f907054d1a..98870d0618 100644 --- a/packages/session-query/session-log-export/src/index.ts +++ b/packages/session-query/session-log-export/src/index.ts @@ -17,6 +17,7 @@ import { type SessionLogCompressionLevel, type SessionLogExportReady, } from './archive.ts' +import { SESSION_LOG_EXPORT_PATH } from './routes.ts' export { DEFAULT_SESSION_LOG_COMPRESSION_LEVEL, @@ -39,8 +40,7 @@ export type { export const name = 'session-log-download' export const inject = ['commands', 'connection'] -/** Stable browser download path retained across the transport migration. */ -export const SESSION_LOG_EXPORT_PATH = '/api/session.export' +export { SESSION_LOG_EXPORT_PATH } from './routes.ts' /** Session-log archive policy. */ export interface Config { diff --git a/packages/session-query/session-log-export/src/routes.ts b/packages/session-query/session-log-export/src/routes.ts new file mode 100644 index 0000000000..e603351995 --- /dev/null +++ b/packages/session-query/session-log-export/src/routes.ts @@ -0,0 +1,11 @@ +/** + * The absolute pathname the Host registers the export under and the + * document-relative form the browser addresses; see + * .agents/notes/implemented/architecture/2026-09-14-web-document-relative-app-routes.md. + */ + +/** Absolute registration path for the ZIP download route. */ +export const SESSION_LOG_EXPORT_PATH = '/api/session.export' + +/** Browser-relative form of {@link SESSION_LOG_EXPORT_PATH}. */ +export const SESSION_LOG_EXPORT_ROUTE = SESSION_LOG_EXPORT_PATH.slice(1) diff --git a/packages/session-query/session-log-export/tests/client-apply.client.spec.tsx b/packages/session-query/session-log-export/tests/client-apply.client.spec.tsx index 1d669e298e..caaf8a4e95 100644 --- a/packages/session-query/session-log-export/tests/client-apply.client.spec.tsx +++ b/packages/session-query/session-log-export/tests/client-apply.client.spec.tsx @@ -6,6 +6,7 @@ import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import { SessionLogDownloadHeaderAction } from '../src/client/HeaderAction.tsx' import { apply, inject } from '../src/client/index.ts' +import type { SessionLogDownloadHeaderInjected } from '../src/client/HeaderAction.tsx' const SID = 'session-export-apply' as SessionId @@ -33,6 +34,26 @@ async function bench() { } describe('session-log-download browser plugin', () => { + it('follows feedback plugin availability and opens the selected Session form', async () => { + const b = await bench() + const entry = b.slots.entries('conversation.session.header.utilities')[0]! + const injected = (entry.inject as unknown as () => SessionLogDownloadHeaderInjected)() + expect(injected.hooks.feedbackAvailable.getSnapshot()).toBe(false) + injected.openFeedback(SID) + + const openSession = vi.fn() + const feedback = b.ctx.plugin((ctx) => { ctx.provide('feedbackUi', { openSession }) }) + await feedback.await() + await vi.waitFor(() => { expect(injected.hooks.feedbackAvailable.getSnapshot()).toBe(true) }) + injected.openFeedback(SID) + expect(openSession).toHaveBeenCalledWith(SID) + + await feedback.dispose() + await vi.waitFor(() => { expect(injected.hooks.feedbackAvailable.getSnapshot()).toBe(false) }) + expect(b.slots.entries('conversation.session.header.utilities')).toHaveLength(1) + await b.fiber.dispose() + }) + it('provides one controller and removes its Header contribution on disposal', async () => { vi.stubGlobal('fetch', vi.fn(async () => new Response('', { status: 500 }))) const b = await bench() diff --git a/packages/session-query/session-log-export/tests/controller.client.spec.ts b/packages/session-query/session-log-export/tests/controller.client.spec.ts index a5fc95852f..b12afab861 100644 --- a/packages/session-query/session-log-export/tests/controller.client.spec.ts +++ b/packages/session-query/session-log-export/tests/controller.client.spec.ts @@ -21,14 +21,12 @@ describe('SessionLogDownloadController', () => { await controller.download(SID) expect(fetcher).toHaveBeenCalledOnce() - const [url, init] = fetcher.mock.calls[0] as unknown as [URL, RequestInit] - expect(url.pathname).toBe('/api/session.export') - expect(url.searchParams.get('sessionId')).toBe(SID) - expect(url.searchParams.get('includeDescendants')).toBe('true') + const [route, init] = fetcher.mock.calls[0] as unknown as [string, RequestInit] + expect(route).toBe(`api/session.export?sessionId=${SID}&includeDescendants=true`) expect(init.method).toBe('HEAD') expect(init.signal).toBeInstanceOf(AbortSignal) expect(save).toHaveBeenCalledWith( - url.toString(), + route, 'dsh-session-session-export-controller.zip', ) expect(controller.store.getSnapshot().bySession[SID]).toEqual({ @@ -99,8 +97,7 @@ describe('SessionLogDownloadController', () => { await controller.dispose() }) - it('uses the null-origin fallback and default browser operations', async () => { - vi.stubGlobal('location', { origin: 'null' }) + it('requests the document-relative route through the default carrier', async () => { const fetcher = vi.fn(async (_input: string | URL, _init?: RequestInit) => new Response('zip')) vi.stubGlobal('fetch', fetcher) const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}) @@ -108,7 +105,7 @@ describe('SessionLogDownloadController', () => { await controller.download(SID) - expect((fetcher.mock.calls[0]?.[0] as URL).origin).toBe('http://dsh.internal') + expect(fetcher.mock.calls[0]?.[0]).toBe(`api/session.export?sessionId=${SID}&includeDescendants=true`) expect(fetcher.mock.calls[0]?.[1]).toMatchObject({ method: 'HEAD' }) expect(click).toHaveBeenCalledOnce() }) @@ -133,14 +130,14 @@ describe('SessionLogDownloadController', () => { }) describe('browser download helpers', () => { - it('sanitizes the archive filename and hands the URL to a download anchor', () => { + it('sanitizes the archive filename and hands the relative route to a download anchor', () => { const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}) expect(sessionLogZipFilename('a/b' as SessionId)).toBe('dsh-session-a_b.zip') - downloadUrl('http://host/api/session.export?sessionId=a', 'archive.zip') + downloadUrl('api/session.export?sessionId=a', 'archive.zip') expect(click).toHaveBeenCalledOnce() const anchor = click.mock.instances[0] as HTMLAnchorElement - expect(anchor.href).toBe('http://host/api/session.export?sessionId=a') + expect(anchor.getAttribute('href')).toBe('api/session.export?sessionId=a') expect(anchor.download).toBe('archive.zip') }) }) diff --git a/packages/session-query/session-log-export/tests/header-action.client.spec.tsx b/packages/session-query/session-log-export/tests/header-action.client.spec.tsx index aed71681c5..dc0da23e36 100644 --- a/packages/session-query/session-log-export/tests/header-action.client.spec.tsx +++ b/packages/session-query/session-log-export/tests/header-action.client.spec.tsx @@ -1,43 +1,82 @@ // @vitest-environment jsdom -import { cleanup, fireEvent, render, waitFor } from '@testing-library/react' +import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { useSyncExternalStore } from 'react' import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { createSnapshotStore, type ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import { SessionLogDownloadController } from '../src/client/controller.ts' import { SessionLogDownloadHeaderAction } from '../src/client/HeaderAction.tsx' -import type { SessionLogDownloadDialogProps } from '../src/client/Dialog.tsx' +import type { SessionLogDownloadHeaderProps } from '../src/client/HeaderAction.tsx' import { en } from '../src/client/locales.ts' const SID = 'session-export-header' as SessionId -function bindSessionExport(controller: SessionLogDownloadController) { - return function useSessionLogDownload(selector: (state: ReturnType) => T): T { +function bindSnapshot(store: ObservableSnapshot) { + return function useSnapshot(selector: (state: State) => T): T { return useSyncExternalStore( - listener => controller.store.subscribe(listener), - () => selector(controller.store.getSnapshot()), + listener => store.subscribe(listener), + () => selector(store.getSnapshot()), ) } } -function bench() { +function bench(feedbackAvailable = false) { const controller = new SessionLogDownloadController(async () => new Response('zip'), vi.fn()) const request = vi.fn((sessionId: SessionId) => controller.download(sessionId)) const dismiss = vi.fn((sessionId: SessionId) => { controller.dismiss(sessionId) }) - const useSessionLogDownload = bindSessionExport(controller) + const openFeedback = vi.fn() + const feedback = createSnapshotStore(feedbackAvailable) + const useSessionLogDownload = bindSnapshot(controller.store) const props = { sessionId: SID, useSessionLogDownload, + useFeedbackAvailable: bindSnapshot(feedback), + openFeedback, request, dismiss, t: (key: keyof typeof en): string => en[key], - } as unknown as SessionLogDownloadDialogProps + } as unknown as SessionLogDownloadHeaderProps const view = render() - return { controller, request, view } + return { controller, request, openFeedback, feedback, view } } afterEach(cleanup) describe('Session export Header action', () => { + it('opens Session feedback and closes the menu without starting a download', () => { + const b = bench(true) + fireEvent.click(b.view.getByRole('button', { name: 'More actions' })) + fireEvent.click(b.view.getByRole('menuitem', { name: 'Feedback' })) + expect(b.openFeedback).toHaveBeenCalledWith(SID) + expect(b.request).not.toHaveBeenCalled() + expect(b.view.queryByRole('menu')).toBeNull() + }) + + it('keeps export available without the feedback plugin', () => { + const b = bench() + fireEvent.click(b.view.getByRole('button', { name: 'More actions' })) + expect(b.view.queryByRole('menuitem', { name: 'Feedback' })).toBeNull() + expect(b.view.getByRole('menuitem', { name: 'Download session log' })).toBeTruthy() + }) + + it('updates the open menu when feedback becomes available, unloads, and reloads', () => { + const b = bench() + fireEvent.click(b.view.getByRole('button', { name: 'More actions' })) + expect(b.view.queryByRole('menuitem', { name: 'Feedback' })).toBeNull() + + act(() => { b.feedback.set(true) }) + expect(b.view.getByRole('menuitem', { name: 'Feedback' })).toBeTruthy() + act(() => { b.feedback.set(false) }) + expect(b.view.queryByRole('menuitem', { name: 'Feedback' })).toBeNull() + expect(b.view.getByRole('menuitem', { name: 'Download session log' })).toBeTruthy() + + act(() => { b.feedback.set(true) }) + fireEvent.click(b.view.getByRole('menuitem', { name: 'Feedback' })) + expect(b.openFeedback).toHaveBeenCalledWith(SID) + expect(b.request).not.toHaveBeenCalled() + expect(b.view.queryByRole('menu')).toBeNull() + }) + it('opens the more-actions menu and downloads through the shared controller', async () => { const b = bench() const button = b.view.getByRole('button', { name: 'More actions' }) @@ -61,18 +100,20 @@ describe('Session export Header action', () => { }) it('disables the download row while either entry path downloads this Session', async () => { - const b = bench() + const b = bench(true) let release!: (response: Response) => void const pending = new Promise((resolve) => { release = resolve }) const controller = new SessionLogDownloadController(() => pending, vi.fn()) - const useSessionLogDownload = bindSessionExport(controller) + const useSessionLogDownload = bindSnapshot(controller.store) b.view.rerender( controller.download(sessionId), dismiss: (sessionId: SessionId) => { controller.dismiss(sessionId) }, t: (key: keyof typeof en): string => en[key], - } as unknown as SessionLogDownloadDialogProps)} />) + } as unknown as SessionLogDownloadHeaderProps)} />) const download = controller.download(SID) const button = b.view.getByRole('button', { name: 'More actions' }) @@ -80,6 +121,7 @@ describe('Session export Header action', () => { fireEvent.click(button) const item = b.view.getByRole('menuitem', { name: 'Download session log' }) expect((item as HTMLButtonElement).disabled).toBe(true) + expect((b.view.getByRole('menuitem', { name: 'Feedback' }) as HTMLButtonElement).disabled).toBe(false) release(new Response('zip')) await download await waitFor(() => { expect(button.getAttribute('aria-busy')).toBe('false') }) diff --git a/packages/session-query/session-log-export/tsconfig.client.json b/packages/session-query/session-log-export/tsconfig.client.json index 6e3e746c4c..c8a2c65d03 100644 --- a/packages/session-query/session-log-export/tsconfig.client.json +++ b/packages/session-query/session-log-export/tsconfig.client.json @@ -11,6 +11,7 @@ "src/client/controller.ts", "src/client/index.ts", "src/client/locales.ts", + "src/routes.ts", "src/css-modules.d.ts" ], "references": [ @@ -19,6 +20,7 @@ { "path": "../../client/store" }, { "path": "../../client/ui-commands" }, { "path": "../../client/ui-conversation" }, + { "path": "../../client/ui-message-feedback" }, { "path": "../../client/ui-primitives" }, { "path": "../../client/ui-renderer" }, { "path": "../../client/ui-session" }, diff --git a/packages/session-query/session-log-export/tsconfig.host.json b/packages/session-query/session-log-export/tsconfig.host.json index 78ad234797..0832fbd1ac 100644 --- a/packages/session-query/session-log-export/tsconfig.host.json +++ b/packages/session-query/session-log-export/tsconfig.host.json @@ -7,7 +7,8 @@ }, "files": [ "src/archive.ts", - "src/index.ts" + "src/index.ts", + "src/routes.ts" ], "references": [ { "path": "../../../vendor/cordis" }, diff --git a/packages/session-query/session-query-sqlite/package.json b/packages/session-query/session-query-sqlite/package.json index 2f7ca61073..aec1841aed 100644 --- a/packages/session-query/session-query-sqlite/package.json +++ b/packages/session-query/session-query-sqlite/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-query-sqlite", "description": "Concrete ctx.sessionQuery backend with SQLite FTS5 search", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/session-query/session-query/package.json b/packages/session-query/session-query/package.json index bd0ffdc57c..a8e4b8e25b 100644 --- a/packages/session-query/session-query/package.json +++ b/packages/session-query/session-query/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-query", "description": "Combined session query service contract with concrete reads, traces, and filters", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/session-query/tool-session-query/package.json b/packages/session-query/tool-session-query/package.json index eaf82ff1dc..eb993e0c9f 100644 --- a/packages/session-query/tool-session-query/package.json +++ b/packages/session-query/tool-session-query/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-session-query", "description": "Workspace-authorized model-facing session history search, trace, and event read tools", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-checkpoint-policy/package.json b/packages/session/session-checkpoint-policy/package.json index 2b9b2e81e5..5b69e39bcf 100644 --- a/packages/session/session-checkpoint-policy/package.json +++ b/packages/session/session-checkpoint-policy/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-checkpoint-policy", "description": "Semantic session durability checkpoints before model requests and tool side effects", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-format-catalog/package.json b/packages/session/session-format-catalog/package.json index 05574cd6b5..67ad97eaa7 100644 --- a/packages/session/session-format-catalog/package.json +++ b/packages/session/session-format-catalog/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-format-catalog", "description": "Build-static first-party Session format codec and migration catalog", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-format-v0-to-v1/package.json b/packages/session/session-format-v0-to-v1/package.json index 9b4fb2debd..2b2fa9d263 100644 --- a/packages/session/session-format-v0-to-v1/package.json +++ b/packages/session/session-format-v0-to-v1/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-format-v0-to-v1", "description": "Frozen released-v0 Session codec and identity migration to v1", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-format-v1-to-v2/package.json b/packages/session/session-format-v1-to-v2/package.json index 1cb43e63f4..89c8b7b10f 100644 --- a/packages/session/session-format-v1-to-v2/package.json +++ b/packages/session/session-format-v1-to-v2/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-format-v1-to-v2", "description": "Frozen released-v1 Session codec and assistant-stream migration to v2", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-format-v2-to-v3/package.json b/packages/session/session-format-v2-to-v3/package.json index 030450b861..da088fc477 100644 --- a/packages/session/session-format-v2-to-v3/package.json +++ b/packages/session/session-format-v2-to-v3/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-format-v2-to-v3", "description": "Streaming system-prompt, canonical-envelope and PTC migration into V3", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-format/package.json b/packages/session/session-format/package.json index a5048bca75..c72c7385ce 100644 --- a/packages/session/session-format/package.json +++ b/packages/session/session-format/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-format", "description": "Streaming adjacent Session format migration machinery", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-log-deepseek/package.json b/packages/session/session-log-deepseek/package.json index 12e378a3f5..cc4739e290 100644 --- a/packages/session/session-log-deepseek/package.json +++ b/packages/session/session-log-deepseek/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-log-deepseek", "description": "Incremental lossless session-log request extension for the official DeepSeek LLM API", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-persistence-jsonl/package.json b/packages/session/session-persistence-jsonl/package.json index d5fad6d730..842ec06467 100644 --- a/packages/session/session-persistence-jsonl/package.json +++ b/packages/session/session-persistence-jsonl/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-persistence-jsonl", "description": "JSONL durable session persistence backend for the DeepSeek Harness", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-persistence/package.json b/packages/session/session-persistence/package.json index ceed4ed16d..e6e8d71eec 100644 --- a/packages/session/session-persistence/package.json +++ b/packages/session/session-persistence/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-persistence", "description": "Abstract durable session persistence seam (ctx.sessionPersistence) for the DeepSeek Harness", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-projection-cache/package.json b/packages/session/session-projection-cache/package.json index 3c68beafdb..020e55fc02 100644 --- a/packages/session/session-projection-cache/package.json +++ b/packages/session/session-projection-cache/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-projection-cache", "description": "Persisted projection cache (ctx.sessionProjectionCache): durable per-session checkpoint records on the session_projcache storage domain (per-record layout), throttled write-behind, and the cached listing read", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-projection/package.json b/packages/session/session-projection/package.json index f3640e115b..ba366a8a92 100644 --- a/packages/session/session-projection/package.json +++ b/packages/session/session-projection/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-projection", "description": "Session-projection seam: the merge-extensible projection type table, the provider contract, and the ctx.sessionProjections registry serving whole current values of log-derived per-session state", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-stats/package.json b/packages/session/session-stats/package.json index ca41dac0c4..0d63f3cb02 100644 --- a/packages/session/session-stats/package.json +++ b/packages/session/session-stats/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-stats", "description": "Whole-log conversation counts and wall times projection (sessionStats) for the DeepSeek Harness", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-telemetry-otel/package.json b/packages/session/session-telemetry-otel/package.json index 8b657ca991..9cef56bbcc 100644 --- a/packages/session/session-telemetry-otel/package.json +++ b/packages/session/session-telemetry-otel/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-telemetry-otel", "description": "OpenTelemetry backend for the DeepSeek Harness telemetry seam: hands captured session records to the OTel JS SDK's log pipeline", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-telemetry/package.json b/packages/session/session-telemetry/package.json index 8a1134f3cb..f7ae2b4687 100644 --- a/packages/session/session-telemetry/package.json +++ b/packages/session/session-telemetry/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-telemetry", "description": "SessionTelemetryBackend seam for the DeepSeek Harness: session-event capture, projection, redaction, and handoff to a reporting backend", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-title-all-prompts-llm/package.json b/packages/session/session-title-all-prompts-llm/package.json index 0778189f3a..707c4309eb 100644 --- a/packages/session/session-title-all-prompts-llm/package.json +++ b/packages/session/session-title-all-prompts-llm/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-title-all-prompts-llm", "description": "All-user-messages LLM provider plugin for DeepSeek Harness session titles", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-title-first-prompt-llm/package.json b/packages/session/session-title-first-prompt-llm/package.json index cd6cb70969..9df24b618f 100644 --- a/packages/session/session-title-first-prompt-llm/package.json +++ b/packages/session/session-title-first-prompt-llm/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-title-first-prompt-llm", "description": "First-message LLM provider plugin for DeepSeek Harness session titles", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-title-llm/package.json b/packages/session/session-title-llm/package.json index 97f7cfd36e..ecaeec25d9 100644 --- a/packages/session/session-title-llm/package.json +++ b/packages/session/session-title-llm/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-title-llm", "description": "Shared LLM generation policy for DeepSeek Harness session-title providers", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-title/package.json b/packages/session/session-title/package.json index d016b8669d..f0a5282c8c 100644 --- a/packages/session/session-title/package.json +++ b/packages/session/session-title/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-title", "description": "Log-backed session title service and provider registry for the DeepSeek Harness", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-turn-outline/package.json b/packages/session/session-turn-outline/package.json index 61daf88d9c..63e6b7732b 100644 --- a/packages/session/session-turn-outline/package.json +++ b/packages/session/session-turn-outline/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-turn-outline", "description": "Whole-log turn outline projection (turnOutline) for the DeepSeek Harness", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/settings/settings-file/package.json b/packages/settings/settings-file/package.json index 0979e6cb65..60b026c00d 100644 --- a/packages/settings/settings-file/package.json +++ b/packages/settings/settings-file/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-settings-file", "description": "File-backed settings provider (settings.yaml) for the DeepSeek Harness", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/settings/settings/package.json b/packages/settings/settings/package.json index 8939609546..30ad30c96b 100644 --- a/packages/settings/settings/package.json +++ b/packages/settings/settings/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-settings", "description": "Abstract user-settings seam (ctx.settings) for the DeepSeek Harness", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/shell/bash-local/package.json b/packages/shell/bash-local/package.json index 3702b11c7b..f94025292a 100644 --- a/packages/shell/bash-local/package.json +++ b/packages/shell/bash-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-bash-local", "description": "Local-subprocess implementation of the DeepSeek Harness bash executor seam", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/shell/bash-local/tests/executor.spec.ts b/packages/shell/bash-local/tests/executor.spec.ts index c381aa0a0a..1a9d99cfee 100644 --- a/packages/shell/bash-local/tests/executor.spec.ts +++ b/packages/shell/bash-local/tests/executor.spec.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, rmSync } from 'node:fs' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterAll, describe, expect, it, vi } from 'vitest' @@ -176,15 +176,25 @@ describe('LocalBashExecutor.run', () => { }) describe('LocalBashExecutor.start (background process handles)', () => { - it('start returns immediately with a running handle that settles as completed', async () => { + it('start returns a running handle before the child is allowed to finish', async (t) => { const { bash } = await setup() - const before = Date.now() - const proc = (await start(bash, bash.resolve({ command: 'sleep 0.2; echo done' }))) - expect(Date.now() - before).toBeLessThan(150) + const directory = mkdtempSync(join(spillDir, 'start-')) + const proc = await start(bash, bash.resolve({ + command: 'echo ready; while [ ! -f release ]; do sleep 0.02; done; echo done', + workdir: directory, + })) + t.onTestFinished(async () => { + proc.kill() + await proc.done + rmSync(directory, { recursive: true, force: true }) + }) + await readUntil(proc, 'ready\n') expect(proc.status).toBe('running') + writeFileSync(join(directory, 'release'), '') await proc.done expect(proc.status).toBe('completed') expect(proc.exitCode).toBe(0) + expect(proc.readOutput().delta).toBe('done\n') }) it('threads stdin and extra env into a background process', async () => { diff --git a/packages/shell/bash-sandbox/package.json b/packages/shell/bash-sandbox/package.json index d3a97c634f..cdc83d8d92 100644 --- a/packages/shell/bash-sandbox/package.json +++ b/packages/shell/bash-sandbox/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-bash-sandbox", "description": "Sandbox-consuming implementation of the DeepSeek Harness bash executor seam (confines every command via ctx.sandbox, reports denial/enforcement result facts)", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/shell/pwsh-local/package.json b/packages/shell/pwsh-local/package.json index 540a431264..e09bff4f6a 100644 --- a/packages/shell/pwsh-local/package.json +++ b/packages/shell/pwsh-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-pwsh-local", "description": "Local PowerShell implementation of the DeepSeek Harness bash executor seam", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/shell/pwsh-sandbox/package.json b/packages/shell/pwsh-sandbox/package.json index feaebd2942..7e15881771 100644 --- a/packages/shell/pwsh-sandbox/package.json +++ b/packages/shell/pwsh-sandbox/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-pwsh-sandbox", "description": "Sandbox-consuming implementation of the DeepSeek Harness PowerShell executor seam (confines every command via ctx.sandbox, reports denial/enforcement result facts)", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/shell/shell-env/package.json b/packages/shell/shell-env/package.json index e79645caaf..e9ebe245f4 100644 --- a/packages/shell/shell-env/package.json +++ b/packages/shell/shell-env/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-shell-env", "description": "Tool-independent managed DSH_* shell environment registry", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/shell/shell/package.json b/packages/shell/shell/package.json index 8ad644cf0d..4580b5a75e 100644 --- a/packages/shell/shell/package.json +++ b/packages/shell/shell/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-shell", "description": "Abstract bash executor seam (ctx.shell) for the DeepSeek Harness", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/shell/tool-bash-persistent/package.json b/packages/shell/tool-bash-persistent/package.json index d19cc2163a..7587e0be20 100644 --- a/packages/shell/tool-bash-persistent/package.json +++ b/packages/shell/tool-bash-persistent/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-bash-persistent", "description": "Model-facing owner-scoped persistent Bash tool backed by the Harness PTY service", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/shell/tool-bash/package.json b/packages/shell/tool-bash/package.json index 482cc30cca..311493595c 100644 --- a/packages/shell/tool-bash/package.json +++ b/packages/shell/tool-bash/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-bash", "description": "Model-facing bash tool with optional generic background-job and sandbox-escalation support", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/shell/tool-pwsh-persistent/package.json b/packages/shell/tool-pwsh-persistent/package.json index f38fb74ef2..2d14f4f879 100644 --- a/packages/shell/tool-pwsh-persistent/package.json +++ b/packages/shell/tool-pwsh-persistent/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-pwsh-persistent", "description": "Model-facing owner-scoped persistent PowerShell tool backed by the Harness PTY service", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/shell/tool-pwsh/package.json b/packages/shell/tool-pwsh/package.json index 256f81897d..5b1d2a8bc4 100644 --- a/packages/shell/tool-pwsh/package.json +++ b/packages/shell/tool-pwsh/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-pwsh", "description": "Model-facing pwsh tool over the bash executor seam", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/skill/skill-badge/package.json b/packages/skill/skill-badge/package.json index f731310364..9e4608803a 100644 --- a/packages/skill/skill-badge/package.json +++ b/packages/skill/skill-badge/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-skill-badge", "description": "Bundled dsh badge skill provider for DeepSeek Harness", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/skill/skill-filesystem/package.json b/packages/skill/skill-filesystem/package.json index 47da833ad8..3c5681f1eb 100644 --- a/packages/skill/skill-filesystem/package.json +++ b/packages/skill/skill-filesystem/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-skill-filesystem", "description": "Local filesystem skill provider for the DeepSeek Harness", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/skill/skill-filesystem/tests/skill-filesystem.spec.ts b/packages/skill/skill-filesystem/tests/skill-filesystem.spec.ts index 94ad76c761..145e6b8de8 100644 --- a/packages/skill/skill-filesystem/tests/skill-filesystem.spec.ts +++ b/packages/skill/skill-filesystem/tests/skill-filesystem.spec.ts @@ -31,6 +31,7 @@ async function writeFlatSkill(root: string, name: string, description: string, b } class TestFileSystem extends FileSystem { + override watch(): never { throw new Error('Fixture does not support watching') } listDirCalls = 0 failResolvePaths = new Set() failStatPaths = new Set() diff --git a/packages/skill/skill-office/package.json b/packages/skill/skill-office/package.json index ba80793802..d0bbe66c05 100644 --- a/packages/skill/skill-office/package.json +++ b/packages/skill/skill-office/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-skill-office", "description": "Bundled Word, PowerPoint, and Excel workflows and structural checks", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/skill/skill/package.json b/packages/skill/skill/package.json index 3fa675609e..f4777807e2 100644 --- a/packages/skill/skill/package.json +++ b/packages/skill/skill/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-skill", "description": "Agent skill provider registry for the DeepSeek Harness", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/skill/tool-skill/package.json b/packages/skill/tool-skill/package.json index 9549143903..83bcd81edb 100644 --- a/packages/skill/tool-skill/package.json +++ b/packages/skill/tool-skill/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-skill", "description": "Model-facing skill loading tool for the DeepSeek Harness", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/spill/spill-local/package.json b/packages/spill/spill-local/package.json index cb53d0deac..3578d9bc57 100644 --- a/packages/spill/spill-local/package.json +++ b/packages/spill/spill-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-spill-local", "description": "Local-filesystem implementation of the DeepSeek Harness spill storage seam (private session-scoped files)", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/spill/spill-policy/package.json b/packages/spill/spill-policy/package.json index fb50c07906..0f02366840 100644 --- a/packages/spill/spill-policy/package.json +++ b/packages/spill/spill-policy/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-spill-policy", "description": "Tool-result spill policy for the DeepSeek Harness — replaces oversized plain-text tool results with a retained preview plus a spill-file path (no service API)", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/spill/spill/package.json b/packages/spill/spill/package.json index 183cca1800..3e39db6dc6 100644 --- a/packages/spill/spill/package.json +++ b/packages/spill/spill/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-spill", "description": "Abstract spill storage seam (ctx.spillStore) for the DeepSeek Harness — save oversized tool text and return a retrieval locator", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/ssh/fs-ssh/README.i18n.yaml b/packages/ssh/fs-ssh/README.i18n.yaml index 08caee9ac6..9c53eeb146 100644 --- a/packages/ssh/fs-ssh/README.i18n.yaml +++ b/packages/ssh/fs-ssh/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/ssh/fs-ssh/README.md -README.md: 1f403796792611abc438f140356e442fbb357cde -README.zh.md: 7e77afe6c87ed7725dea87215c9a3647d650ae95 +README.md: a950726df972952a8430ef0d00ba455f2e0de11e +README.zh.md: fa69aaee52c705b094de1b0f4860109023c2069f diff --git a/packages/ssh/fs-ssh/README.md b/packages/ssh/fs-ssh/README.md index 1f40379679..a950726df9 100644 --- a/packages/ssh/fs-ssh/README.md +++ b/packages/ssh/fs-ssh/README.md @@ -67,6 +67,7 @@ This provider contributes no request-prefix content. Its consumers own model-vis - Whole-text reads and individual byte windows are limited to 8 MiB by the helper. Use text streaming or multiple byte windows for larger reads; other JSON transfers also obey the connection’s frame cap. +- Filesystem watching is unsupported: `watch()` throws without polling or opening a local watcher for a remote path. Ordinary reads and consumer-owned manual refresh remain available. - Remote file URLs are execution coordinates, not host filesystem handles or Web download links. diff --git a/packages/ssh/fs-ssh/README.zh.md b/packages/ssh/fs-ssh/README.zh.md index 7e77afe6c8..fa69aaee52 100644 --- a/packages/ssh/fs-ssh/README.zh.md +++ b/packages/ssh/fs-ssh/README.zh.md @@ -67,6 +67,7 @@ kind: "package-reference" - 辅助程序将整段文本读取及单次字节窗口限制为 8 MiB。更大的读取需使用文本流或多个字节窗口;其他 JSON 传输也受连接消息大小上限约束。 +- 不支持文件系统监听:`watch()` 直接抛错,不使用轮询,也不为远程路径建立本地监听。普通读取与消费方提供的手动刷新仍可用。 - 远端文件 URL 是执行坐标,不是主机文件系统句柄或 Web 下载链接。 diff --git a/packages/ssh/fs-ssh/package.json b/packages/ssh/fs-ssh/package.json index e652a7eefc..3db42bcaad 100644 --- a/packages/ssh/fs-ssh/package.json +++ b/packages/ssh/fs-ssh/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-fs-ssh", "description": "Filesystem provider over the shared POSIX SSH helper", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/ssh/fs-ssh/src/index.ts b/packages/ssh/fs-ssh/src/index.ts index 9a20414a95..1bbcfbf097 100644 --- a/packages/ssh/fs-ssh/src/index.ts +++ b/packages/ssh/fs-ssh/src/index.ts @@ -20,6 +20,8 @@ const errorCodes: Record = { export class SshFileSystem extends FileSystem { static inject = ['ssh', 'sandboxPolicy'] + override watch(): never { throw new Error('SSH filesystem watching is not supported') } + override get sandboxMode(): SandboxMode { return this.ctx.sandboxPolicy.defaultMode } override async resolve(path: string, opts?: { cwd?: string; signal?: AbortSignal }): Promise { diff --git a/packages/ssh/fs-ssh/tests/provider.spec.ts b/packages/ssh/fs-ssh/tests/provider.spec.ts index 110d483e49..d242d4184e 100644 --- a/packages/ssh/fs-ssh/tests/provider.spec.ts +++ b/packages/ssh/fs-ssh/tests/provider.spec.ts @@ -31,6 +31,13 @@ async function setup() { } describe('SSH filesystem provider', () => { + it('declares watching unsupported without sending a remote request', async () => { + const { fs, dispatch } = await setup() + expect(() => fs.watch(target, vi.fn(), new AbortController().signal)) + .toThrow('SSH filesystem watching is not supported') + expect(dispatch).not.toHaveBeenCalled() + }) + it.each([ ['literal%20name.ts', 'literal%2520name.ts'], ['back\\slash.ts', 'back%5Cslash.ts'], diff --git a/packages/ssh/sandbox-ssh/package.json b/packages/ssh/sandbox-ssh/package.json index d2633cf79e..0f6464597f 100644 --- a/packages/ssh/sandbox-ssh/package.json +++ b/packages/ssh/sandbox-ssh/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sandbox-ssh", "description": "Remote POSIX sandbox argv provider over the shared SSH helper", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/ssh/ssh/package.json b/packages/ssh/ssh/package.json index a4ab500bd6..6926a8a028 100644 --- a/packages/ssh/ssh/package.json +++ b/packages/ssh/ssh/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-ssh", "description": "Shared OpenSSH connection and versioned POSIX remote helper", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/ssh/subprocess-ssh/package.json b/packages/ssh/subprocess-ssh/package.json index 1132c63339..db2acd71cb 100644 --- a/packages/ssh/subprocess-ssh/package.json +++ b/packages/ssh/subprocess-ssh/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subprocess-ssh", "description": "Subprocess and terminal provider over the shared POSIX SSH helper", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/storage/storage-domain/package.json b/packages/storage/storage-domain/package.json index 1ddf8a14dc..2c411745f2 100644 --- a/packages/storage/storage-domain/package.json +++ b/packages/storage/storage-domain/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-storage-domain", "description": "Domain data form (ctx.storage.domain): schema-validated, event-emitting KV domains over storage backends for the DeepSeek Harness", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/storage/storage-json/package.json b/packages/storage/storage-json/package.json index 32f0c0c9c5..f4a60d6726 100644 --- a/packages/storage/storage-json/package.json +++ b/packages/storage/storage-json/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-storage-json", "description": "JSON file KV storage backend for the DeepSeek Harness storage hub", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/storage/storage-sqlite/package.json b/packages/storage/storage-sqlite/package.json index b554fbd8ca..3a6422c3dc 100644 --- a/packages/storage/storage-sqlite/package.json +++ b/packages/storage/storage-sqlite/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-storage-sqlite", "description": "SQLite storage backend (kv facet) for the DeepSeek Harness storage hub", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/storage/storage/package.json b/packages/storage/storage/package.json index 841da3e2f9..1088ace1d9 100644 --- a/packages/storage/storage/package.json +++ b/packages/storage/storage/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-storage", "description": "Storage hub (ctx.storage): named backend registry plus mounted data-form facilities for the DeepSeek Harness", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent-acp/package.json b/packages/subagent/subagent-acp/package.json index 6fa9ac8837..c478e0edf9 100644 --- a/packages/subagent/subagent-acp/package.json +++ b/packages/subagent/subagent-acp/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent-acp", "description": "Out-of-process ACP subagent backend: drives a child agent in a spawned subprocess over the Agent Client Protocol", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent-claude-code/package.json b/packages/subagent/subagent-claude-code/package.json index 7eaab9a4e1..319f432ec6 100644 --- a/packages/subagent/subagent-claude-code/package.json +++ b/packages/subagent/subagent-claude-code/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent-claude-code", "description": "One-shot Claude Code subagent provider over the official Agent SDK", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent-codex/package.json b/packages/subagent/subagent-codex/package.json index 952f7ad6fc..35c6f9a607 100644 --- a/packages/subagent/subagent-codex/package.json +++ b/packages/subagent/subagent-codex/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent-codex", "description": "One-shot Codex subagent provider over the official app-server protocol", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent-dsh-sdk/package.json b/packages/subagent/subagent-dsh-sdk/package.json index b78524ca54..8bd7216cbe 100644 --- a/packages/subagent/subagent-dsh-sdk/package.json +++ b/packages/subagent/subagent-dsh-sdk/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent-dsh-sdk", "description": "Out-of-process SDK subagent backend: drives a child DeepSeek Harness runtime subprocess over stdio JSON-RPC through the TypeScript SDK client", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent-fork-in-process/package.json b/packages/subagent/subagent-fork-in-process/package.json index 1d054f12a3..15f15b7623 100644 --- a/packages/subagent/subagent-fork-in-process/package.json +++ b/packages/subagent/subagent-fork-in-process/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent-fork-in-process", "description": "In-process fork subagent backend: runs a child agent seeded with a prefix of the parent's log", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent-in-process-driver/package.json b/packages/subagent/subagent-in-process-driver/package.json index 9c8aa2d912..a43abea024 100644 --- a/packages/subagent/subagent-in-process-driver/package.json +++ b/packages/subagent/subagent-in-process-driver/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent-in-process-driver", "description": "Shared in-process subagent run driver: drives a child agent on ctx.agents (used by the spawn and fork backends)", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent-spawn-in-process/package.json b/packages/subagent/subagent-spawn-in-process/package.json index 34cd0a3727..dbd9ef16ce 100644 --- a/packages/subagent/subagent-spawn-in-process/package.json +++ b/packages/subagent/subagent-spawn-in-process/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent-spawn-in-process", "description": "In-process spawn subagent backend: runs a fresh child agent on ctx.agents", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index 0c480c9342..8194c11b6b 100644 --- a/packages/subagent/subagent/README.i18n.yaml +++ b/packages/subagent/subagent/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/subagent/subagent/README.md -README.md: 4634f0a33f71709668732b7070a2bccded3db5c0 -README.zh.md: d3d8a597f23153b0c82e663d98142a9966f48c22 +README.md: d6fa74d2b31eafe867e0a3c22dbaae4cef008a6a +README.zh.md: 6159ae10c56f7c9bf0274279f092fbc552af009c diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index 4634f0a33f..d6fa74d2b3 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Use `dsh-subagent` to delegate work to named child agents, collect their results, and continue supported child conversations across turns. A composition can offer in-process, ACP, SDK, Codex, or Claude Code children side by side. Choose one-shot children for a single result or continuable children for later messages and interruption. You can also inspect available children, their mode, activity, and lineage without loading or resuming them. Enable at least one supported child backend and a delegation tool. +Use `dsh-subagent` to delegate work to named child agents, collect their results, and continue supported child conversations across turns. A composition can offer in-process, ACP, SDK, Codex, or Claude Code children side by side. Choose one-shot children for a single result or continuable children for later messages and interruption. You can also inspect available children, their mode, live activity, latest closed-turn completion, and lineage without loading or resuming them. Enable at least one supported child backend and a delegation tool. ## Table of Contents @@ -60,7 +60,7 @@ One-shot children run once and settle with a single result, plus an optional str ### Messaging, interrupting, and discovering -Every exact live Agent can use `sendMessage()` with a direct continuable child; a resident continuable child can also use it with its direct parent. A working target receives the Agent message through Steer at its nearest step; an idle target starts a turn, and only a direct child can be cold-resumed. The parent can also interrupt a running descendant or list its children at any time. A browser continuation prompt independently selects Queue or Steer and may carry image parts: the Host admits and persists each image batch through the attachment store before the child inbox accepts the message, and refuses delivery when the child's declared model does not accept image input. Discovery covers both shapes: the service lists direct children and the full descendant tree — mode, activity, and lineage — reading live session state and optional persistence, without loading any child. +Every exact live Agent can use `sendMessage()` with a direct continuable child; a resident continuable child can also use it with its direct parent. A working target receives the Agent message through Steer at its nearest step; an idle target starts a turn, and only a direct child can be cold-resumed. The parent can also interrupt a running descendant or list its children at any time. A browser continuation prompt independently selects Queue or Steer and may carry image parts: the Host admits and persists each image batch through the attachment store before the child inbox accepts the message, and refuses delivery when the child's declared model does not accept image input. Discovery covers both shapes: the service lists direct children and the full descendant tree — mode, live activity, latest closed-turn completion, and lineage — reading live session state and optional persistence, without loading any child. ### Failure and recovery @@ -108,7 +108,7 @@ A request is validated against the provider's advertised capabilities, a durable The manager reserves a child identity, resolves the durable descriptor, creates (or cold-resumes) the child Agent, installs it in an Activation, and submits the prompt. Model-authored messages cross one parent/child edge through fixed Steer scheduling; browser human prompts choose Queue or best-effort Steer through an internal adapter, while other host protocols may retain Queue for distinct turns. A Session queue command admits a live subagent-owned Agent only from its own continuable descriptor. Settlement waits for Agent activity to finish, an empty Inbox, and no owned children, then flushes final Session state with admission open. Under the child lock, the manager revalidates the wake generation, Session sequence, Inbox, and owned children; the synchronous task entry of `Agent.runMaintenance()` claims the idle phase and closes the private subagent Inbox in the same JavaScript turn before handle disposal. An absent direct-child Activation cold-resumes from the persisted session. When a resident Activation settles, the manager tells the child's direct parent in the parent's own turn stream. -Successful local child creation appends a `subagent/catalog` fact to the parent Session. One-shot creation records it after the provider returns; continuable creation records it after initial inbox admission and before returning the child id. Failure releases the child without publishing a compensating catalog event. A one-shot catalog append failure handles the run’s result rejection and preserves the catalog error; disposal failures are logged separately. The `subagentCatalog` projection excludes fork-inherited facts and exposes a direct-child list through `projections.values.subagentCatalog` in Session observations and client snapshots. Invalid own catalog payloads, including unsupported versions, reject projection restoration. Its immutable storage and checkpoint validation use [`dsh-chunked-list`](../../util/chunked-list/README.md). Its view preserves parent catalog event order in O(D) time for D facts. [The parent-catalog decision](../../../.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md) owns ordering, persistence costs, and alternatives. +Successful local child creation appends a `subagent/catalog` fact to the parent Session. One-shot creation records it after the provider returns; continuable creation records it after initial inbox admission and before returning the child id. Failure releases the child without publishing a compensating catalog event. A one-shot catalog append failure handles the run’s result rejection and preserves the catalog error; disposal failures are logged separately. The `subagentCatalog` projection excludes fork-inherited facts and exposes a direct-child list through `projections.values.subagentCatalog` in Session observations and client snapshots. Each child's `subagentTiming` projection accumulates post-descriptor duration and records whether its latest closed turn ended with `completed`, clearing that completion when another turn opens. Invalid own catalog payloads, including unsupported versions, reject projection restoration. Projection state-version changes refold cached rows from the durable log. The catalog view preserves parent event order in O(D) time for D facts, and its immutable storage and checkpoint validation use [`dsh-chunked-list`](../../util/chunked-list/README.md). [The parent-catalog decision](../../../.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md) owns ordering, persistence costs, and alternatives. ### Ownership and invariants @@ -132,6 +132,7 @@ Read these pages when the package-level contract is not enough. They move from t - [In-process spawn backend](../subagent-spawn-in-process/README.md) — the simplest provider to compose. - [Auto review](../../experimental/auto-review/README.md) — the current-session authorization mode inherited only by in-process DSH children. - [Out-of-process ACP backend](../subagent-acp/README.md) — children with their own runtime over the Agent Client Protocol. +- [DeepSeek input conversion](../../llm/llm-deepseek/README.md#model-experience) — provider replay rules for saved settlement notices. - [tool-subagent-control README](../tool-subagent-control/README.md) — the follow-up, interrupt, and listing surface. ----- @@ -186,7 +187,6 @@ These limits define when the seam is a poor fit or needs special operational car - **Wake gap during cancellation convergence** — a follow-up accepted after an interrupt signal but before the driver becomes idle stays queued until another waking send. - **Pending injected context retains an Activation** — settlement conservatively treats every Inbox occurrence as unfinished. Context parked after the Agent becomes idle keeps the child and its live ancestors resident until a waking delivery claims it, a queue mutation removes it, or manager teardown discards it. - **Process-local residency** — the Activation inbox and ownership graph do not coordinate two harness processes; concurrent access to one persistence store needs a durable mailbox and cross-process lease protocol. -- **Saved settlement notices are not rewritten** — a saved user-role notice containing reasoning still fails DeepSeek Messages serialization while it remains in the parent's request history. - **No replay of accepted-but-unlogged messages** — a crash can lose an accepted prompt that never reached the child's session log; the lost message is not replayed automatically. - **No durable parent mailbox** — child-to-parent messages require a resident continuable child and live direct parent, and provide acceptance identity rather than exactly-once delivery. - **Lifecycle events are observe-only** — a run-affecting `subagent/end` continuation or decision API waits for a concrete consumer. diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index d3d8a597f2..6159ae10c5 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -使用 `dsh-subagent` 把工作委派给具名子 agent、收集结果,并跨轮次继续受支持的子级对话。一个组合可以并排提供进程内、ACP(Agent Client Protocol)、SDK、Codex 或 Claude Code 子级。需要单个结果时选择一次性子级;需要后续消息与中断能力时选择可继续子级。你还可以检查可用子级及其模式、活动状态与谱系,而无需加载或恢复它们。启用时需要至少一个受支持的子级后端和一个委派工具。 +使用 `dsh-subagent` 把工作委派给具名子 agent、收集结果,并跨轮次继续受支持的子级对话。一个组合可以并排提供进程内、ACP(Agent Client Protocol)、SDK、Codex 或 Claude Code 子级。需要单个结果时选择一次性子级;需要后续消息与中断能力时选择可继续子级。你还可以检查可用子级及其模式、在线活动状态、最近已结束轮次的完成状态与谱系,而无需加载或恢复它们。启用时需要至少一个受支持的子级后端和一个委派工具。 ## 目录 @@ -60,7 +60,7 @@ kind: "package-reference" ### 消息、中断与发现 -每个确切在线 Agent 都可以对直接可继续 child 使用 `sendMessage()`;驻留的可继续 child 还可以对自己的直接 parent 使用它。正在工作的目标通过 Steer 在最近 step 接收 Agent 消息;空闲目标启动轮次,且只有直接 child 可以冷恢复。parent 也可以随时中断正在运行的后代或列举自己的子级。浏览器发出的继续执行提示词会独立选择 Queue 或 Steer,并且可以携带图片部分:Host 先通过附件存储完成整批图片的准入与持久化,子级 inbox 才接受这条消息;当子级声明的模型不接受图片输入时拒绝投递。发现覆盖两种形态:服务列举直接子级与完整后代树——模式、活动状态与谱系——直接读取在线会话状态与可选持久化,不加载任何子 agent。 +每个确切在线 Agent 都可以对直接可继续 child 使用 `sendMessage()`;驻留的可继续 child 还可以对自己的直接 parent 使用它。正在工作的目标通过 Steer 在最近 step 接收 Agent 消息;空闲目标启动轮次,且只有直接 child 可以冷恢复。parent 也可以随时中断正在运行的后代或列举自己的子级。浏览器发出的继续执行提示词会独立选择 Queue 或 Steer,并且可以携带图片部分:Host 先通过附件存储完成整批图片的准入与持久化,子级 inbox 才接受这条消息;当子级声明的模型不接受图片输入时拒绝投递。发现覆盖两种形态:服务列举直接子级与完整后代树——模式、在线活动状态、最近已结束轮次的完成状态与谱系——直接读取在线会话状态与可选持久化,不加载任何子 agent。 ### 失败与恢复 @@ -108,7 +108,7 @@ kind: "package-reference" 管理器预留 child 身份、解析持久化描述符、创建(或冷恢复)child、把它安装进 Activation 并提交提示词。模型编写的消息通过固定 Steer 调度跨一条 parent/child 边;浏览器人类 prompt 通过内部适配器选择 Queue 或 best-effort Steer,其他 host 协议仍可保留 Queue 以创建独立轮次。Session queue command 仅根据 child 自身的 continuable descriptor 准入在线 subagent-owned Agent。Settlement 会等待 Agent 活动结束、Inbox 为空且没有所拥有子级,再在准入开放时 flush 最终 Session 状态。管理器随后在 child lock 内重新验证 wake generation、Session 序号、Inbox 与所拥有子级;`Agent.runMaintenance()` 的同步 task 入口会占用 idle 阶段,并在同一个 JavaScript turn 内关闭私有 subagent Inbox,然后才释放句柄。直接 child 不存在 Activation 时会从持久化会话冷恢复。当驻留 Activation 结算时,管理器会在 parent 自身的轮次流中告知该 child 的直接 parent。 -本地子级创建成功时,父 Session 追加一条 `subagent/catalog` 事实。一次性创建在提供方返回后记录;可继续创建在初始 inbox 准入后、返回子级 id 前记录。失败会释放子级,不发布补偿性目录事件。一次性目录追加失败时会处理 run 的结果拒绝,并保留目录错误;资源释放失败会单独记录。`subagentCatalog` projection 排除 fork 继承的事实,通过 Session 观察和客户端快照中的 `projections.values.subagentCatalog` 暴露直接子级列表。无效的自身 catalog payload(包括不支持的版本)会使 projection 恢复失败。其不可变存储和检查点校验使用 [`dsh-chunked-list`](../../util/chunked-list/README.zh.md)。其视图对 D 条事实以 O(D) 时间保留父目录事件顺序。[父目录决策](../../../.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md) 说明排序、持久化成本和替代方案。 +本地子级创建成功时,父 Session 追加一条 `subagent/catalog` 事实。一次性创建在提供方返回后记录;可继续创建在初始 inbox 准入后、返回子级 id 前记录。失败会释放子级,不发布补偿性目录事件。一次性目录追加失败时会处理 run 的结果拒绝,并保留目录错误;资源释放失败会单独记录。`subagentCatalog` projection 排除 fork 继承的事实,通过 Session 观察和客户端快照中的 `projections.values.subagentCatalog` 暴露直接子级列表。每个 child 的 `subagentTiming` projection 会累加 descriptor 之后的耗时,并记录最近一个已结束轮次是否以 `completed` 结束;新轮次打开时会清除该完成状态。无效的自身 catalog payload(包括不支持的版本)会使 projection 恢复失败。projection 状态版本变更会从持久日志重新折叠缓存行。目录视图对 D 条事实以 O(D) 时间保留父目录事件顺序,其不可变存储和检查点校验使用 [`dsh-chunked-list`](../../util/chunked-list/README.zh.md)。[父目录决策](../../../.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md) 说明排序、持久化成本和替代方案。 ### 所有权与不变式 @@ -132,6 +132,7 @@ kind: "package-reference" - [进程内 spawn 后端](../subagent-spawn-in-process/README.zh.md)——最容易组合的提供方。 - [Auto review](../../experimental/auto-review/README.zh.md)——只有进程内 DSH 子级继承的当前会话授权模式。 - [进程外 ACP 后端](../subagent-acp/README.zh.md)——经 Agent Client Protocol 拥有自有运行时的子级。 +- [DeepSeek 输入转换](../../llm/llm-deepseek/README.zh.md#model-experience)——已保存结算通知的提供方回放规则。 - [tool-subagent-control README](../tool-subagent-control/README.zh.md)——后续消息、中断与列举面。 ----- @@ -186,7 +187,6 @@ You are a delegated subagent: your permission scope was fixed when you were star - **取消收敛期间存在唤醒缺口**——中断信号发出后、driver 进入 idle 前被接受的后续消息会保持排队,直到另一条唤醒发送到达。 - **待处理的注入上下文会保留 Activation**——settlement 会保守地把每个 Inbox occurrence 都视为未完成。Agent 进入 idle 后停放的上下文会让 child 及其在线祖先继续驻留,直到唤醒投递将其 claim、queue 变更将其移除,或 manager teardown 将其丢弃。 - **驻留仅限进程内**——Activation inbox 与所有权图不会在两个 harness 进程之间协调;对单个持久化存储的并发访问需要持久化邮箱与跨进程租约协议。 -- **已保存的结算通知不会被改写**——若用户角色的已保存通知含有推理块,只要它仍在父级请求历史中,DeepSeek Messages 序列化就会失败。 - **不回放已接受但未记录的消息**——崩溃可能丢失从未写入子会话日志、已被接受的提示词;丢失的消息不会自动回放。 - **没有持久化 parent mailbox**——child 到 parent 的消息要求驻留的可继续 child 与在线直接 parent,提供的是接受标识,不保证恰好一次投递。 - **生命周期事件只供观察**——影响运行的 `subagent/end` 延续或决策接口仍需等待具体消费方。 diff --git a/packages/subagent/subagent/package.json b/packages/subagent/subagent/package.json index 16394a723a..a435da1208 100644 --- a/packages/subagent/subagent/package.json +++ b/packages/subagent/subagent/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent", "description": "Abstract subagent seam (ctx.subagents): named-provider registry for delegating to child agents", - "version": "0.1.6-alpha.1", + "version": "0.1.6-alpha.2", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent/src/projection-types.ts b/packages/subagent/subagent/src/projection-types.ts index 931459df3d..c33a2df9fb 100644 --- a/packages/subagent/subagent/src/projection-types.ts +++ b/packages/subagent/subagent/src/projection-types.ts @@ -28,6 +28,11 @@ export interface SubagentTimingProjection { /** Latest event time folded into this projection cut. */ through: number } + /** + * Whether the latest closed turn after the child's own descriptor completed + * normally; absent while a turn is open or before one closes. + */ + lastTurnCompleted?: boolean } /** @@ -63,7 +68,7 @@ declare module '@deepseek-ai/dsh-session-projection/types' { interface SessionProjectionMap { /** Direct children in parent catalog event order, excluding fork-inherited facts. */ subagentCatalog: SubagentCatalogEntry[] - /** Active-turn duration for a descriptor-backed subagent session. */ + /** Active-turn duration and latest closed-turn completion for a descriptor-backed subagent session. */ subagentTiming: SubagentTimingProjection /** * Identity of a descriptor-backed subagent session. `null` ⟺ no valid diff --git a/packages/subagent/subagent/src/projection.ts b/packages/subagent/subagent/src/projection.ts index 6a7367d369..541721f2c1 100644 --- a/packages/subagent/subagent/src/projection.ts +++ b/packages/subagent/subagent/src/projection.ts @@ -23,6 +23,8 @@ export interface TimingState { pendingTurnStart?: number | undefined /** Whether the fold has crossed a descriptor in this logical log. */ descriptorSeen: boolean + /** Whether the latest closed post-descriptor turn completed normally; absent while a turn is open or before one closes. */ + lastTurnCompleted?: boolean | undefined } const activeIntervalSchema = z.object({ @@ -33,9 +35,11 @@ const activeIntervalSchema = z.object({ const projectionSchema: z.ZodType = z.object({ settledMs: z.number().int().nonnegative(), active: activeIntervalSchema.optional(), -}).strict().transform(({ settledMs, active }) => ({ + lastTurnCompleted: z.boolean().optional(), +}).strict().transform(({ settledMs, active, lastTurnCompleted }) => ({ settledMs, ...active === undefined ? {} : { active }, + ...lastTurnCompleted === undefined ? {} : { lastTurnCompleted }, })) const timingStateSchema: z.ZodType = z.object({ @@ -43,6 +47,7 @@ const timingStateSchema: z.ZodType = z.object({ active: activeIntervalSchema.optional(), pendingTurnStart: z.number().int().nonnegative().optional(), descriptorSeen: z.boolean(), + lastTurnCompleted: z.boolean().optional(), }).strict() declare module '@deepseek-ai/dsh-session-projection/types' { @@ -66,9 +71,10 @@ export const subagentTimingProjectionDefinition = { init: () => ({ descriptorSeen: false, settledMs: 0 }), apply: (state, event) => { if (event.type === 'turn/start') { + const { lastTurnCompleted: _closed, ...openState } = state return state.descriptorSeen - ? { ...state, active: { since: event.time, through: event.time } } - : { ...state, pendingTurnStart: event.time } + ? { ...openState, active: { since: event.time, through: event.time } } + : { ...openState, pendingTurnStart: event.time } } if (event.type === 'subagent/descriptor') { const activeSince = state.active?.since ?? state.pendingTurnStart @@ -91,6 +97,7 @@ export const subagentTimingProjectionDefinition = { return { ...rest, settledMs: state.settledMs + Math.max(0, event.time - active.since), + lastTurnCompleted: event.data.reason.kind === 'completed', } } if (state.active === undefined) return state @@ -101,9 +108,10 @@ export const subagentTimingProjectionDefinition = { view: state => ({ settledMs: state.settledMs, ...(state.active === undefined ? {} : { active: state.active }), + ...(state.lastTurnCompleted === undefined ? {} : { lastTurnCompleted: state.lastTurnCompleted }), }), }, - stateVersion: 2, + stateVersion: 3, } satisfies ProjectionDefinition<'subagentTiming', TimingState> interface IdentityState { diff --git a/packages/subagent/subagent/tests/timing-projection.spec.ts b/packages/subagent/subagent/tests/timing-projection.spec.ts index 688574b49c..397f590747 100644 --- a/packages/subagent/subagent/tests/timing-projection.spec.ts +++ b/packages/subagent/subagent/tests/timing-projection.spec.ts @@ -1,13 +1,23 @@ import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import SessionStore from '@deepseek-ai/dsh-session' -import type { SessionEvent } from '@deepseek-ai/dsh-session' +import type { SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SubagentRuntime from '../src/index.ts' import { subagentTimingProjectionDefinition, type TimingState } from '../src/projection.ts' -function event(type: SessionEvent['type'], seq: number, time: number): SessionEvent { - return { type, seq, time, data: {} } as SessionEvent +function event( + type: SessionEvent['type'], + seq: number, + time: number, + reason: TurnEndReason = { kind: 'completed' }, +): SessionEvent { + return { + type, + seq, + time, + data: type === 'turn/end' ? { turn: 1, reason } : {}, + } as SessionEvent } function fold(events: SessionEvent[]) { @@ -44,7 +54,7 @@ describe('subagent timing projection', () => { event('turn/end', 5, 4_100), event('turn/start', 6, 10_000), event('turn/end', 7, 12_000), - ])).toEqual({ settledMs: 5_100 }) + ])).toEqual({ settledMs: 5_100, lastTurnCompleted: true }) }) it('exposes an open turn start and never subtracts time for reversed boundaries', () => { @@ -57,6 +67,23 @@ describe('subagent timing projection', () => { ])).toEqual({ settledMs: 0, active: { since: 2_000, through: 2_500 } }) }) + it('publishes only the latest closed turn completion and clears it when another turn opens', () => { + expect(fold([ + event('turn/start', 0, 100), + event('subagent/descriptor', 1, 110), + event('turn/end', 2, 200), + event('turn/start', 3, 300), + event('turn/end', 4, 400, { kind: 'interrupted' }), + ])).toEqual({ settledMs: 200, lastTurnCompleted: false }) + + expect(fold([ + event('turn/start', 0, 100), + event('subagent/descriptor', 1, 110), + event('turn/end', 2, 200), + event('turn/start', 3, 300), + ])).toEqual({ settledMs: 100, active: { since: 300, through: 300 } }) + }) + it('ignores completed pre-descriptor turns and unrelated events', () => { const initial = subagentTimingProjectionDefinition.init() expect(subagentTimingProjectionDefinition.apply( diff --git a/packages/subagent/tool-subagent-control/README.i18n.yaml b/packages/subagent/tool-subagent-control/README.i18n.yaml index d4f18d593e..177ebd1d61 100644 --- a/packages/subagent/tool-subagent-control/README.i18n.yaml +++ b/packages/subagent/tool-subagent-control/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/subagent/tool-subagent-control/README.md -README.md: 99ca52dd4a7ea2629441c4d661632b8598c8171d -README.zh.md: 1ff49a3179dbe61db1a9353e2fab131082cb29ae +README.md: afc084c442a78b368e0698630828b0e0261da553 +README.zh.md: 044902d7056ee4ed899c1f860db14898023fd907 diff --git a/packages/subagent/tool-subagent-control/README.md b/packages/subagent/tool-subagent-control/README.md index 99ca52dd4a..afc084c442 100644 --- a/packages/subagent/tool-subagent-control/README.md +++ b/packages/subagent/tool-subagent-control/README.md @@ -46,7 +46,7 @@ This package takes no configuration: the root plugin provides `send_message` and ### send_message -Sends a message to an Agent named by `agent_id`: any exact live Agent may target its direct continuable child, while a resident continuable child may also target its direct parent. A working target receives the message at its nearest step boundary through Steer; an idle target starts a turn, and a cold direct child resumes through the continuation lifecycle. The call returns only acceptance (the accepted message's stable `messageId`), never a reply. A failure — an unsupported target, unavailable parent, unknown child, descriptor-less child that cannot be resumed, or rejected admission — states the message was not delivered. +Sends a message to an Agent named by `agent_id`: any exact live Agent may target its direct continuable child, while a resident continuable child may also target its direct parent. A working target receives the message at its nearest step boundary through Steer; an inactive target starts or resumes a turn through the continuation lifecycle. The call returns only acceptance (the accepted message's stable `messageId`), never a reply. A failure — an unsupported target, unavailable parent, unknown child, descriptor-less child that cannot be resumed, or rejected admission — states the message was not delivered. ### interrupt_agent @@ -54,7 +54,7 @@ Stops only the target's current turn: queued messages stay parked until a later ### list_agents -Lists the continuable children below the calling agent: `children` (default) shows direct children, `descendants` walks the whole tree in stable pre-order, annotating each entry with its durable direct-parent session id and depth. Status comes from the live Agent registry — `running`, `idle`, or `ready`. One-shot children are intentionally absent because they cannot accept `send_message`, and unreadable candidates appear as diagnostics. +Lists the continuable children below the calling agent: `children` (default) shows direct children, `descendants` walks the whole tree in stable pre-order, annotating each entry with its durable direct-parent session id and depth. Status comes from the live Agent registry — `running` or `inactive`. One-shot children are intentionally absent because they cannot accept `send_message`, and unreadable candidates appear as diagnostics. ----- @@ -150,7 +150,7 @@ Append-only; newly visible content follows the reusable request prefix and does #### What the model sees -One line per continuable child in stable catalog order: ` [] —