diff --git a/.agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.i18n.yaml index b1a81228cf..45e1c99967 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.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 -2026-07-24-single-harness-home-resolver.md: 10ed0e9f1fd6ac4630d92a66953fdf1d52b3b5f1 -2026-07-24-single-harness-home-resolver.zh.md: 1ce56281357595de134ddea285c8c2e0c1801ce9 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.md +2026-07-24-single-harness-home-resolver.md: 159ba88b7b4a8d50f1be2cbe5d9162a654014e16 +2026-07-24-single-harness-home-resolver.zh.md: 62046abca48a3c2b07fde4180031dc2186dc101f diff --git a/.agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.md b/.agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.md index 10ed0e9f1f..159ba88b7b 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.md +++ b/.agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.md @@ -22,7 +22,7 @@ One resolver owns the harness home, in `@deepseek-ai/dsh-paths`, single-root: explicit configured path > $DSH_HOME > ~/.dsh ``` -An empty or whitespace-only `$DSH_HOME` is treated as unset, matching the guard telemetry's old resolver carried: without it `resolve('')` would silently place the home at the current working directory. The harness keeps all user data under one root; there is no XDG config/data/cache split. `dshHomeDisplay()` names a resolved root symbolically for user-facing paths — `~/.dsh` for the default home, `$DSH_HOME` for any configured home — so the user-global `AGENTS.md` label never leaks an absolute machine path. It replaces workspace-context's bespoke default-vs-`$DSH_HOME` check. +An empty or whitespace-only `$DSH_HOME` is treated as unset, matching the guard telemetry's old resolver carried: without it `resolve('')` would silently place the home at the current working directory. The harness keeps all user data under one root; there is no XDG config/data/cache split. `dshHomePath(...segments)` joins deployment-owned children onto that root, and `dsh-app-boot` exposes it to Loader `!!js` config expressions before mounting entries, so shipped compositions derive `sessions` and `storages` without copying the resolver. `dshHomeDisplay()` names a resolved root symbolically for user-facing paths — `~/.dsh` for the default home, `$DSH_HOME` for any configured home — so the user-global `AGENTS.md` label never leaks an absolute machine path. It replaces workspace-context's bespoke default-vs-`$DSH_HOME` check. `@deepseek-ai/dsh-home` is deleted. Its three importers (`dsh-tool-bash`, `dsh-skill-local`, `dsh-agent-spine-demo`) now import `resolveDshHome` from `dsh-paths`. `dsh-telemetry`'s `globalConfigDir` delegates to `resolveDshHome`, dropping its second resolver, the `DSH_CONFIG_HOME` override, the XDG/`%APPDATA%` branches, and the `deepseek-harness` namespace; the anonymous id now lives directly under the harness home. diff --git a/.agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.zh.md b/.agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.zh.md index 1ce5628135..62046abca4 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-24-single-harness-home-resolver.zh.md @@ -22,7 +22,7 @@ Status: implemented explicit configured path > $DSH_HOME > ~/.dsh ``` -空或仅含空白的 `$DSH_HOME` 被当作未设置处理,这与 telemetry 旧解析器所带的保护一致:若无此保护,`resolve('')` 会悄悄把 home 落在当前工作目录。harness 把所有用户数据都放在同一个根目录下;不存在 XDG 的 config/data/cache 拆分。`dshHomeDisplay()` 为面向用户的路径以符号形式命名已解析的根目录——默认 home 显示为 `~/.dsh`,任何已配置的 home 显示为 `$DSH_HOME`——这样面向用户全局的 `AGENTS.md` 标签就绝不会泄露机器上的绝对路径。它取代了 workspace-context 中自定义的"默认值 vs `$DSH_HOME`"判断。 +空或仅含空白的 `$DSH_HOME` 被当作未设置处理,这与 telemetry 旧解析器所带的保护一致:若无此保护,`resolve('')` 会悄悄把 home 落在当前工作目录。harness 把所有用户数据都放在同一个根目录下;不存在 XDG 的 config/data/cache 拆分。`dshHomePath(...segments)` 将部署负责的子路径拼接到该根目录下,`dsh-app-boot` 在挂载条目前向 Loader `!!js` 配置表达式暴露它,因此出厂组合无需复制解析器即可派生 `sessions` 和 `storages`。`dshHomeDisplay()` 为面向用户的路径以符号形式命名已解析的根目录——默认 home 显示为 `~/.dsh`,任何已配置的 home 显示为 `$DSH_HOME`——这样面向用户全局的 `AGENTS.md` 标签就绝不会泄露机器上的绝对路径。它取代了 workspace-context 中自定义的"默认值 vs `$DSH_HOME`"判断。 `@deepseek-ai/dsh-home` 被删除。它的三个引用方(`dsh-tool-bash`、`dsh-skill-local`、`dsh-agent-spine-demo`)现在从 `dsh-paths` 导入 `resolveDshHome`。`dsh-telemetry` 的 `globalConfigDir` 转而委托给 `resolveDshHome`,去掉了它的第二个解析器、`DSH_CONFIG_HOME` 覆盖项、XDG/`%APPDATA%` 分支以及 `deepseek-harness` 命名空间;匿名 id 现在直接存放在 harness home 之下。 diff --git a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.i18n.yaml index bca3fb39ad..2efe235e28 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.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-30-client-locale-full-rollout.md -2026-07-30-client-locale-full-rollout.md: a357f20734d1aa8df60efbf28fd5b8a1a814d63e -2026-07-30-client-locale-full-rollout.zh.md: d22b743f0597405e7f42374ec2caed5523e85595 +2026-07-30-client-locale-full-rollout.md: 09baf5876029295f7a80b6a0fe6a6395d98f406c +2026-07-30-client-locale-full-rollout.zh.md: 806916aea15a21fd24fdfc4654976b3c4577a675 diff --git a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md index a357f20734..09baf58760 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md +++ b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md @@ -14,7 +14,7 @@ After the typed locale standard seat landed (`locale:` on register → framework **Component copy rides the standard `t` seat; deep children take `t` as a plain prop** typed `XxxProps['t']`. The dictionary canon is unchanged: `zh satisfies Record` is the key source and `en satisfies Record` locks bilingual balance. -**Zero-cordis atoms (ui-primitives) take copy as props**: `labels` on `TerminalBlock`/`JsonTree`, `copyLabel`/`copiedLabel` on `CodeBlock`, `codeLabels` on `MarkdownText`, `truncatedLabel` on `JsonBlock`, `label` on `ConnectionBanner`, `closeLabel` on `Modal` — defaults are the previous hardcoded strings, so a consumer passing nothing renders byte-identical output. Localized plugins pass dictionary-driven labels from their own `t` seat; call sites passing object props memoize them on the `t` identity (`MarkdownText` caches its component table on the `codeLabels` identity). +**Zero-cordis atoms (ui-primitives) take copy as props**: `copyLabel`/`copiedLabel` on `HoverCard`, `labels` on `TerminalBlock`/`JsonTree`, `copyLabel`/`copiedLabel` on `CodeBlock`, `codeLabels` on `MarkdownText`, `truncatedLabel` on `JsonBlock`, `label` on `ConnectionBanner`, `closeLabel` on `Modal` — defaults are the previous hardcoded strings, so a consumer passing nothing renders byte-identical output. Localized plugins pass dictionary-driven labels from their own `t` seat; call sites passing object props memoize them on the `t` identity (`MarkdownText` caches its component table on the `codeLabels` identity). **The non-translation boundary (deliberate decisions, not debt):** diff --git a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md index d22b743f05..806916aea1 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md @@ -14,7 +14,7 @@ typed locale 标准席位(`locale:` 注册声明 → 框架注入强类型 `t` **组件文案走标准 `t` 席位;深层子组件用 prop 下传**,类型写 `XxxProps['t']`。字典规范形态不变:`zh satisfies Record` 为 key 源、`en satisfies Record` 锁双语平衡。 -**zero-cordis 原子组件(ui-primitives)文案 props 化**:`TerminalBlock`/`JsonTree` 的 `labels`、`CodeBlock` 的 `copyLabel`/`copiedLabel`、`MarkdownText` 的 `codeLabels`、`JsonBlock` 的 `truncatedLabel`、`ConnectionBanner` 的 `label`、`Modal` 的 `closeLabel`——默认值即原硬编码字符串,不传 props 的消费者渲染逐字节不变。已本地化的插件从自己的 `t` 席位传字典驱动的 label;传对象 props 的调用点按 `t` 身份 memo(`MarkdownText` 的组件表按 `codeLabels` 身份缓存)。 +**zero-cordis 原子组件(ui-primitives)文案 props 化**:`HoverCard` 的 `copyLabel`/`copiedLabel`、`TerminalBlock`/`JsonTree` 的 `labels`、`CodeBlock` 的 `copyLabel`/`copiedLabel`、`MarkdownText` 的 `codeLabels`、`JsonBlock` 的 `truncatedLabel`、`ConnectionBanner` 的 `label`、`Modal` 的 `closeLabel`——默认值即原硬编码字符串,不传 props 的消费者渲染逐字节不变。已本地化的插件从自己的 `t` 席位传字典驱动的 label;传对象 props 的调用点按 `t` 身份 memo(`MarkdownText` 的组件表按 `codeLabels` 身份缓存)。 **不翻译边界(刻意决定,不是欠账):** diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.i18n.yaml new file mode 100644 index 0000000000..ed2f7646bc --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.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-07-30-hover-popup-pointer-grace.md +2026-07-30-hover-popup-pointer-grace.md: 3f60c98ec6453b633feebe408cbc0c0c49eedea1 +2026-07-30-hover-popup-pointer-grace.zh.md: db10e156103284383f911684b2c92977a0315275 diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.md b/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.md new file mode 100644 index 0000000000..3f60c98ec6 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.md @@ -0,0 +1,35 @@ +# Agent Note: Hover popup pointer grace + +Status: implemented + +English | [中文](2026-07-30-hover-popup-pointer-grace.zh.md) + +## Problem + +Both popups the workspace browser rows raise floated out of reach of the pointer. `HoverCard` closed on the first `pointerleave` from its anchor and rendered its card `pointer-events: none`, but the card sits 8px off the anchor's right edge, so every path to it crossed ground belonging to neither and killed the card before it arrived — the full workspace path and session title it exists to show could be read only in passing. The row action menus passed `closeOnPointerLeave`, whose handler sat on the portaled list: aiming back at the `...` trigger that opened the list closed it, and so did any overshoot past a list edge, with no window to come back. + +## Decision + +`usePointerGrace` ([packages/client/ui-primitives/src/pointer-grace.ts](../../../../packages/client/ui-primitives/src/pointer-grace.ts)) owns one cancelable delayed close, shared by both atoms, with `POINTER_GRACE_MS` at 200. Leaving arms the close; coming back cancels it. Transit through an anchor-to-popup gap is therefore survivable, while a pointer that has genuinely moved on still dismisses the popup. + +`HoverCard` arms the grace on leave instead of closing, and its card no longer sets `pointer-events: none`, so resting on the card holds it open. Re-entering while already open cancels the pending close without restarting the dwell, which keeps the card from blinking when the pointer crosses the gap. A press on the card starts a selection instead of dismissing it; only anchor-region presses and an owner flipping `disabled` dismiss immediately, ahead of the grace. + +`Menu` moves pointer-leave dismissal from the portaled list to the wrapper span. React's enter/leave traversal runs over the React tree, so the trigger and the portaled list are one region there: crossing the 4px gap between them, or aiming back at the trigger, no longer counts as leaving. Leaving is only armed while the list is open, and an owner-driven close (selection, Escape, outside click) disarms a pending grace close in an effect keyed on `open` alone — folding that into the outside-click effect would cancel the grace on every re-render, since owners pass a fresh `onClose` closure each time. + +## Alternatives considered + +**Close the popups only on outside click and Escape.** Rejected because both popups are hover-raised and unlabeled as dismissible; leaving them up after the pointer has moved to another row would strand a card over unrelated content. + +**Widen the anchor's hit area to abut the popup.** Rejected because the 8px and 4px offsets are the design's, and an invisible bridge element would have to track every reposition the fixed-positioned popups already do on scroll and resize. + +**Keep the hover card `pointer-events: none` and only add the grace.** Rejected because the pointer resting on the card would then hit whatever is behind it, so the grace would expire and close the card the user had just reached. + +**Give each atom its own timer.** Rejected because the two closes are the same behavior with the same tuning; a shared hook keeps them from drifting apart. + +## Consequences + +The hover card is now hit-testable and covers 244px of whatever it overlays while shown, which is the price of being reachable; it still lives only as long as the pointer is on the row or the card. Row menus survive the round trip between trigger and list, and a menu that closes for its own reason cannot be reopened into a stale pending close. Menus without `closeOnPointerLeave` are untouched — the wrapper handlers are only attached when it is set. + +## Testing + +`packages/client/ui-primitives/tests/hover-card.spec.tsx` and `tests/atoms.spec.tsx` pin the grace boundary, cancel-on-return, no-second-dwell, disarm-on-owner-close, and the no-arming-while-closed case. The reachability gestures themselves — hovering onto the card, and moving between an open list and its trigger — are pinned in the real browser by `apps/web/tests/workspace-management.e2e.ts`, since they depend on hit testing and layout that jsdom does not model. diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.zh.md b/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.zh.md new file mode 100644 index 0000000000..db10e15610 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-30-hover-popup-pointer-grace.zh.md @@ -0,0 +1,35 @@ +# Agent Note: 悬浮弹层的指针宽限期 + +Status: implemented + +[English](2026-07-30-hover-popup-pointer-grace.md) | 中文 + +## 问题 + +工作区浏览器行弹出的两种弹层都处于指针无法抵达的位置。`HoverCard` 在指针离开锚点的第一个 `pointerleave` 上就关闭,其卡片还设置了 `pointer-events: none`;但卡片位于锚点右边缘外 8px 处,因此通往卡片的每条路径都要穿过既不属于锚点也不属于卡片的区域,卡片在指针抵达之前就已被销毁——它本应展示的完整工作区路径和会话标题只能匆匆一瞥。行操作菜单传入了 `closeOnPointerLeave`,而其处理器挂在传送后的列表上:把指针移回打开该列表的 `...` 触发按钮会关闭列表,越过列表边缘的任何一次抖动同样如此,且没有任何折返窗口。 + +## 决策 + +`usePointerGrace`([packages/client/ui-primitives/src/pointer-grace.ts](../../../../packages/client/ui-primitives/src/pointer-grace.ts))持有唯一一个可取消的延迟关闭,由两个原子组件共享,`POINTER_GRACE_MS` 为 200。离开会启动关闭,折返则取消它。因此指针可以安全穿越锚点与弹层之间的间隙,而真正移开的指针仍会关闭弹层。 + +`HoverCard` 在离开时启动宽限期而不再立即关闭,其卡片也不再设置 `pointer-events: none`,因此指针停在卡片上即可让它保持打开。在已打开状态下重新进入只取消待执行的关闭,而不重启停留计时,从而避免指针穿越间隙时卡片闪烁。在卡片上按下指针用于开始文本选择,不会关闭卡片;只有锚点区域内的按下和所有者将 `disabled` 置真,才会抢在宽限期之前立即关闭卡片。 + +`Menu` 把指针离开关闭的处理从传送后的列表移到包裹 span 上。React 的 enter/leave 遍历基于 React 树进行,因此触发按钮与传送后的列表在这里属于同一区域:穿越两者之间 4px 的间隙、或把指针移回触发按钮,都不再算作离开。只有在列表打开时才会启动离开关闭;由所有者驱动的关闭(选择、Escape、外部点击)会在一个仅以 `open` 为依赖的 effect 中解除待执行的宽限关闭——若把它折叠进外部点击的 effect,则每次重新渲染都会取消宽限期,因为所有者每次都传入新的 `onClose` 闭包。 + +## 考虑过的替代方案 + +**仅通过外部点击和 Escape 关闭这两种弹层。** 之所以否决:两者都由悬停唤起,且没有可见的关闭标识;在指针已移到其他行之后仍让它们停留,会把卡片遗留在无关内容之上。 + +**扩大锚点的命中区域,使其与弹层相接。** 之所以否决:8px 与 4px 的偏移来自设计稿,而一个不可见的桥接元素还必须跟随这两个固定定位弹层已经在滚动和缩放时执行的每一次重新定位。 + +**保留悬浮卡片的 `pointer-events: none`,只加入宽限期。** 之所以否决:那样指针停在卡片上时命中的是卡片背后的元素,宽限期仍会到期,并关闭用户刚刚够到的卡片。 + +**让两个原子组件各自持有计时器。** 之所以否决:这两处关闭是同一种行为、同一套调参;共享 hook 可以防止它们各自漂移。 + +## 后果 + +悬浮卡片现在可被命中,显示期间会遮挡其覆盖区域的 244px——这是可抵达性的代价;它依然只在指针位于行或卡片上时存在。行菜单现在能承受触发按钮与列表之间的往返,而因自身原因关闭的菜单也不会被残留的待执行关闭重新关掉。未设置 `closeOnPointerLeave` 的菜单不受影响——只有设置该属性时才会挂上包裹层处理器。 + +## 测试 + +`packages/client/ui-primitives/tests/hover-card.spec.tsx` 与 `tests/atoms.spec.tsx` 固定验证宽限期边界、折返取消、不重启停留计时、所有者关闭时解除待执行关闭,以及列表关闭时不启动关闭。可抵达性手势本身——把指针移到卡片上,以及在打开的列表与其触发按钮之间移动——由 `apps/web/tests/workspace-management.e2e.ts` 在真实浏览器中固定验证,因为它们依赖 jsdom 无法建模的命中测试与布局。 diff --git a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.i18n.yaml b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.i18n.yaml index ebc2df795a..9259180188 100644 --- a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.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-25-session-list-browsing-and-manual-order.md -2026-07-25-session-list-browsing-and-manual-order.md: 831aa53e532a75392690c330837482bb0f9c32b1 -2026-07-25-session-list-browsing-and-manual-order.zh.md: 9ad074d59c13585aa4fca46ae4d40e2deb15cde6 +2026-07-25-session-list-browsing-and-manual-order.md: 5af9cf026c5a0e32837822b3d8cff827c052b07f +2026-07-25-session-list-browsing-and-manual-order.zh.md: 2cfa4efe2f0e53b419b264c9af0aff878d321cac diff --git a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.md b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.md index 831aa53e53..5af9cf026c 100644 --- a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.md +++ b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.md @@ -34,7 +34,7 @@ The UI is HTML5 drag on session rows inside a group (workspace grouping only, ou ### Shell/region split -ui-sidebar shrinks to the column-geometry shell: brand row, fold state machine, New Session, Settings, and one `sidebar.workspaces` hole; the shell↔region contract is two facts, `{ wide, expandSidebar }`. ui-workspace fully owns the browsing region (section header, search, grouped tree and flat list, every workspace dialog, drag) plus its groupBy store; the rail-state search/new-workspace icons belong to the region too and request shell expansion via `expandSidebar()`. The picker splits into the core `WorkspaceCreateFlow` (composed directly inside the region) and the thin `WorkspacePicker` wrapper (still filling ui-conversation's hero slot); the old `sidebar.workspace` picker slot and its declaration-aware deferral are deleted with it. +ui-sidebar shrinks to the column-geometry shell: brand row, fold state machine, New Session, Settings, and one `sidebar.workspaces` hole; the shell↔region contract is two facts, `{ wide, expandSidebar }`. ui-workspace fully owns the browsing region (section header, search, grouped tree and flat list, every workspace dialog, drag) plus its groupBy store; the rail-state search/add-workspace icons belong to the region too and request shell expansion via `expandSidebar()`. The picker splits into the core `WorkspacePickFlow` (composed directly inside the region; named `WorkspaceCreateFlow` until the [one-route Note](../simplification/2026-07-31-one-route-to-add-a-workspace.md)) and the thin `WorkspacePicker` wrapper (still filling ui-conversation's hero slot); the old `sidebar.workspace` picker slot and its declaration-aware deferral are deleted with it. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.zh.md b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.zh.md index 9ad074d59c..2cfa4efe2f 100644 --- a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.zh.md +++ b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.zh.md @@ -34,7 +34,7 @@ UI 为组内 session 行的 HTML5 拖拽(仅 workspace 分组、非搜索态;f ### 壳/区域切分 -ui-sidebar 缩为列几何壳:品牌行、折叠状态机、New Session、Settings,以及一个 `sidebar.workspaces` 洞;壳与区域的契约只有两个事实 `{ wide, expandSidebar }`。ui-workspace 全权拥有浏览区域(section header、搜索、分组树与平铺、全部 workspace 对话框、拖拽)及其 groupBy store;rail 态的搜索/新建图标也归区域,经 `expandSidebar()` 请求壳展开。picker 拆为核心件 `WorkspaceCreateFlow`(区域内直接组件组合)与薄包装 `WorkspacePicker`(继续填 ui-conversation 的 hero 坑);原 `sidebar.workspace` picker 坑与声明感知延迟注册随之删除。 +ui-sidebar 缩为列几何壳:品牌行、折叠状态机、New Session、Settings,以及一个 `sidebar.workspaces` 洞;壳与区域的契约只有两个事实 `{ wide, expandSidebar }`。ui-workspace 全权拥有浏览区域(section header、搜索、分组树与平铺、全部 workspace 对话框、拖拽)及其 groupBy store;rail 态的搜索/添加工作区图标也归区域,经 `expandSidebar()` 请求壳展开。picker 拆为核心件 `WorkspacePickFlow`(区域内直接组件组合;在[单一路径 Note](../simplification/2026-07-31-one-route-to-add-a-workspace.md)之前名为 `WorkspaceCreateFlow`)与薄包装 `WorkspacePicker`(继续填 ui-conversation 的 hero 坑);原 `sidebar.workspace` picker 坑与声明感知延迟注册随之删除。 ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.i18n.yaml b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.i18n.yaml index b8266cdd49..b876fa890c 100644 --- a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.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-25-workspace-ui-product-flow.md -2026-07-25-workspace-ui-product-flow.md: b8e1ec1efe19127cad8a12405dddeec38a4ff91e -2026-07-25-workspace-ui-product-flow.zh.md: b80b75a80671e9aa2ab59ff72c44c18a8ec5c16e +2026-07-25-workspace-ui-product-flow.md: 7e3338cfcb04c1588bbc97f6e8aa1cd2dac59141 +2026-07-25-workspace-ui-product-flow.zh.md: 8c092151f737b6bc10aa8424281eeda359f07c1d diff --git a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md index b8e1ec1efe..7e3338cfcb 100644 --- a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md +++ b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md @@ -50,9 +50,9 @@ On initial entry, the application waits until both the Workspace and Session bas When no Workspace exists, the page creates a frontend Workspace object named `workspace` and a frontend Session that targets it. Neither writes to the Host, and the composer always accepts input; the first send materializes the Workspace, attaches the Session, and sends the message in that order. -Top-level New Session, the plus button on a Workspace row, and the Workspace picker all invoke the same New Session action. An explicit Workspace id becomes the target directly; when none is specified, the action uses the most recent Workspace, or the Workspace Intent if no real Workspace exists. The Workspace picker's Use an existing folder and Create a new workspace actions immediately create a real Workspace when the user confirms, then retarget the frontend Session to it; an explicitly created empty Workspace remains even if the user sends no message. +Top-level New Session, the plus button on a Workspace row, and the Workspace picker all invoke the same New Session action. An explicit Workspace id becomes the target directly; when none is specified, the action uses the most recent Workspace, or the Workspace Intent if no real Workspace exists. The Workspace picker's one Add workspace action ([one-route Note](../simplification/2026-07-31-one-route-to-add-a-workspace.md); it was a pair of Use-an-existing-folder and create-by-name actions when this was decided) immediately creates a real Workspace when the user confirms a directory, then retargets the frontend Session to it; an explicitly created empty Workspace remains even if the user sends no message. -Create a new workspace temporarily uses the same input as both the directory name and display name. The UI prevents duplicate confirmation based on current Workspace titles, while the Host continues to reject same-name requests that bypass the UI or race concurrently. Moving Sessions across Workspaces, manual adoption from Ungrouped, and separate display-name and directory-name inputs remain outside this flow. +A new Workspace takes its display name from the directory it was created in, and the Host rejects a title already registered (the UI's own duplicate-name pre-check went with the create-by-name dialog). Moving Sessions across Workspaces, manual adoption from Ungrouped, and separate display-name and directory-name inputs remain outside this flow. ### First send and recovery diff --git a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.zh.md b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.zh.md index b80b75a806..8c092151f7 100644 --- a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.zh.md +++ b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.zh.md @@ -50,9 +50,9 @@ Session 自己持有首条输入并驱动一条内部流水线:必要时以预 完全没有 Workspace 时,页面创建默认名为 `workspace` 的前端 Workspace 对象和指向它的前端 Session。两者不写 Host,composer 始终可输入;首次发送才依次 materialize Workspace、attach Session、发送消息。 -顶部 New Session、Workspace 行内加号和 Workspace picker 最终都调用同一 New Session 动作:显式 Workspace id 直接成为目标,未指定时使用最近 Workspace,没有真实 Workspace 时使用 Workspace Intent。Workspace picker 的 Use an existing folder 与 Create a new workspace 会在用户确认时立即创建真实 Workspace,再把前端 Session 定位到该 Workspace;即使用户不发送消息,显式创建的空 Workspace 也保留。 +顶部 New Session、Workspace 行内加号和 Workspace picker 最终都调用同一 New Session 动作:显式 Workspace id 直接成为目标,未指定时使用最近 Workspace,没有真实 Workspace 时使用 Workspace Intent。Workspace picker 的单一 Add workspace 动作(见[单一路径 Note](../simplification/2026-07-31-one-route-to-add-a-workspace.md);本决策做出时是 Use an existing folder 与按名称创建两个动作)会在用户确认目录时立即创建真实 Workspace,再把前端 Session 定位到该 Workspace;即使用户不发送消息,显式创建的空 Workspace 也保留。 -Create a new workspace 暂时用同一个输入作为目录名和显示名。UI 根据当前 Workspace title 禁止重复确认,Host 继续拒绝绕过 UI 或并发产生的同名请求。跨 Workspace 移动 Session、从 Ungrouped 手动收编以及分别输入显示名和目录名仍不在此动线范围内。 +新建 Workspace 的显示名取自其所在目录,Host 拒绝已注册的同名 title(UI 侧的重名预检随按名称创建的对话框一并删除)。跨 Workspace 移动 Session、从 Ungrouped 手动收编以及分别输入显示名和目录名仍不在此动线范围内。 ### 首次发送与恢复 diff --git a/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.i18n.yaml index 47416d5a30..a2b2e940fd 100644 --- a/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.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-native-workspace-directory-picker.md -2026-07-27-native-workspace-directory-picker.md: 847b7a7c7844a47e534183008acfdc32e2e08125 -2026-07-27-native-workspace-directory-picker.zh.md: e484a2ba5860d43925aba82be31c803716a33bc5 +2026-07-27-native-workspace-directory-picker.md: 8dd7084eb8ffa417d9366cbc54bcbc59170c64fe +2026-07-27-native-workspace-directory-picker.zh.md: 9efc6571207b1f6a6996b9990c2a55afb70872bd diff --git a/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.md b/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.md index 847b7a7c78..8dd7084eb8 100644 --- a/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.md +++ b/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.md @@ -10,7 +10,7 @@ The desktop GUI asks users to type an absolute path when they add an existing wo ## Decision -Add a single-folder `host.pickDirectory` RPC and expose it through `WorkspacesService`. The workspace menu presents two flat actions: **Open local folder...** and **Create a new workspace**. Selecting a folder reuses the existing `workspace.create({ path })` flow, selects the returned workspace, and starts a blank session. +Add a single-folder `host.pickDirectory` RPC and expose it through `WorkspacesService`. The workspace menu presents the flat **Add workspace...** action (two actions when this was decided — **Open local folder...** beside a create-by-name entry the [one-route Note](../simplification/2026-07-31-one-route-to-add-a-workspace.md) later removed). Selecting a folder reuses the existing `workspace.create({ path })` flow, selects the returned workspace, and starts a blank session. The workspace manager must upsert the returned workspace before the selection callback runs. A newly adopted directory therefore renders its basename immediately. Reopening an already registered path preserves its existing workspace title. @@ -21,7 +21,7 @@ The workspace manager must upsert the returned workspace before the selection ca - A duplicate path selects the existing workspace. - A different path whose derived title conflicts with another workspace shows a focused error with **Choose again** and **Cancel** actions. - Other picker failures show a compact retryable error. -- The existing create-by-name flow remains unchanged. +- The create-by-name flow this decision left untouched is gone; picking a directory is now the whole of adding a workspace ([one-route Note](../simplification/2026-07-31-one-route-to-add-a-workspace.md)). ## Host boundary @@ -41,7 +41,7 @@ Platform adapters invoke native tools without a shell: ## Consequences -The current GUI opens one local folder through a native picker on macOS, Windows, and Linux. Cancelling changes no state, failures remain retryable, and duplicate paths are idempotent while title conflicts require an explicit new choice. The selected workspace and its displayed name refresh before a new blank session starts. Existing workspace creation by name remains available. +The current GUI opens one local folder through a native picker on macOS, Windows, and Linux. Cancelling changes no state, failures remain retryable, and duplicate paths are idempotent while title conflicts require an explicit new choice. The selected workspace and its displayed name refresh before a new blank session starts. This picker is now the only route to a workspace ([one-route Note](../simplification/2026-07-31-one-route-to-add-a-workspace.md)): the operator picks an existing directory, or creates one inside the chooser. The added host, runtime, component, and GUI tests cover the native boundary, request trust checks, cancellation and failure handling, existing-path reuse, title conflicts, and the immediate visible-name update. The privileged RPC remains specific to the local desktop carrier; a remote Web directory browser is outside this decision. diff --git a/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.zh.md b/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.zh.md index e484a2ba58..9efc657120 100644 --- a/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -新增一个用于选择单个文件夹的 `host.pickDirectory` RPC,并通过 `WorkspacesService` 暴露该 RPC。工作区菜单提供两个平铺操作:**打开本地文件夹…** 和 **创建新工作区**。选定文件夹后,系统复用现有的 `workspace.create({ path })` 流程,选中返回的工作区,并启动一个空白会话。 +新增一个用于选择单个文件夹的 `host.pickDirectory` RPC,并通过 `WorkspacesService` 暴露该 RPC。工作区菜单提供平铺操作 **添加工作区…**(本决策做出时是两个操作:**打开本地文件夹…** 与一个按名称创建的入口,后者已被[单一路径 Note](../simplification/2026-07-31-one-route-to-add-a-workspace.md)删除)。选定文件夹后,系统复用现有的 `workspace.create({ path })` 流程,选中返回的工作区,并启动一个空白会话。 工作区管理器必须在选择回调运行前插入或更新返回的工作区。因此,新纳入的目录会立即显示其 basename。再次打开已注册的路径时,则保留该工作区现有的标题。 @@ -21,7 +21,7 @@ Status: implemented - 路径重复时,选中现有工作区。 - 如果路径不同,但其派生标题与另一个工作区冲突,则显示明确指出该冲突的错误提示,其中包含 **重新选择** 和 **取消** 操作。 - 选择器的其他故障会显示简洁且可重试的错误提示。 -- 现有的按名称创建流程保持不变。 +- 本决策当时未触碰的按名称创建流程现已删除;选择目录就是添加工作区的全部(见[单一路径 Note](../simplification/2026-07-31-one-route-to-add-a-workspace.md))。 ## 宿主边界 @@ -41,7 +41,7 @@ Status: implemented ## 后果 -当前 GUI 可以在 macOS、Windows 和 Linux 上通过原生选择器打开一个本地文件夹。取消操作不会改变任何状态,故障仍可重试;重复路径的处理具有幂等性,标题冲突则要求用户明确重新选择。选中的工作区及其显示名称会在启动新的空白会话前完成刷新。现有的按名称创建工作区功能仍可使用。 +当前 GUI 可以在 macOS、Windows 和 Linux 上通过原生选择器打开一个本地文件夹。取消操作不会改变任何状态,故障仍可重试;重复路径的处理具有幂等性,标题冲突则要求用户明确重新选择。选中的工作区及其显示名称会在启动新的空白会话前完成刷新。该选择器现已是获得工作区的唯一路径(见[单一路径 Note](../simplification/2026-07-31-one-route-to-add-a-workspace.md)):操作者要么选一个已有目录,要么在选择器内新建一个。 新增的宿主、运行时、组件和 GUI 测试覆盖原生边界、请求信任校验、取消与故障处理、已有路径复用、标题冲突和可见名称即时更新。该特权 RPC 仍仅面向本地桌面载体;远程 Web 目录浏览器不属于本次决策范围。 diff --git a/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.i18n.yaml index 7123353c03..df8239ba4b 100644 --- a/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.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-28-cross-workspace-resume.md -2026-07-28-cross-workspace-resume.md: 1ef6195b6f269a6dc8cf869902b7772ad48f00c2 -2026-07-28-cross-workspace-resume.zh.md: d31dc5f0470e20f86062edb4ad75c37a6160fe23 +2026-07-28-cross-workspace-resume.md: e8a8636911015a5b300b45a30e268ee1be5d47d1 +2026-07-28-cross-workspace-resume.zh.md: 9e5e126f4b204927d7fb134a196dd0d0a98468d4 diff --git a/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.md b/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.md index 1ef6195b6f..e8a8636911 100644 --- a/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.md +++ b/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.md @@ -18,7 +18,7 @@ Finally, resume never changed directory. The host re-execs `dsh --resume=` t The shared CLI configuration supplies one session root under the Harness home, the picker gains a workspace scope, and the handoff carries the target directory. -**Storage.** The shared base owns the default in `apps/cli/config/base.cordis.yml`: its `session-persistence-jsonl` row resolves `sessions` beneath `DSH_HOME`, with the standard `~/.dsh` fallback when the environment has not been initialized. TUI, Web, and headless therefore consume one default without launcher patches or a boot slot. An overlay or personal patch that states an explicit root replaces that row's whole `config` and remains the deployment's authoritative choice. +**Storage.** The shared base owns the default in `apps/cli/config/base.cordis.yml`: its `session-persistence-jsonl` row calls the app-boot-provided `dshHomePath('sessions')`, which uses the canonical `DSH_HOME` resolver and its standard `~/.dsh` fallback. TUI, Web, and headless therefore consume one default without a session-specific launcher patch or slot. An overlay or personal patch that states an explicit root replaces that row's whole `config` and remains the deployment's authoritative choice. **Scope, not exclusion.** A workspace other than the current one is a display scope rather than a disabled reason. `showResume()` summarizes every record and the `ResumePicker` owns a `scope` of `'workspace' | 'all'`, defaulting to the current workspace so the common case is unchanged. Tab toggles; the scope line names the active scope and the count the other holds; each row in the all-workspaces scope reports its own workspace, and that label joins the searchable text only in the scope that shows it. A toggle clears the query and selection so the highlighted row always belongs to the visible list, and the per-row workspace line makes a row one terminal row taller in that scope, which the visible-count budget accounts for. diff --git a/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.zh.md b/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.zh.md index d31dc5f047..9e5e126f4b 100644 --- a/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.zh.md +++ b/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.zh.md @@ -18,7 +18,7 @@ Status: implemented 共享 CLI 配置提供 Harness home 下的同一个会话根目录,选择器获得 workspace 范围,交接过程携带目标目录。 -**存储。** 共享 base 在 `apps/cli/config/base.cordis.yml` 中拥有默认值:其 `session-persistence-jsonl` 配置项把 `sessions` 解析到 `DSH_HOME` 下;环境尚未初始化时则使用标准的 `~/.dsh` 回退值。因此 TUI、Web 与 headless 使用同一个默认值,无需启动器补丁或启动槽位。若 overlay 或个人 patch 显式声明根目录,它会整体替换该配置项的 `config`,并继续作为部署的权威选择。 +**存储。** 共享 base 在 `apps/cli/config/base.cordis.yml` 中拥有默认值:其 `session-persistence-jsonl` 配置项调用由 app-boot 提供的 `dshHomePath('sessions')`,该函数使用规范的 `DSH_HOME` 解析器及其标准的 `~/.dsh` 回退值。因此 TUI、Web 与 headless 使用同一个默认值,无需针对会话的启动器补丁或槽位。若 overlay 或个人 patch 显式声明根目录,它会整体替换该配置项的 `config`,并继续作为部署的权威选择。 **是范围,不是排除。** 当前 workspace 之外的 workspace 是一种展示范围,而不是禁用理由。`showResume()` 汇总每一条记录,`ResumePicker` 持有一个 `'workspace' | 'all'` 的 `scope`,默认为当前 workspace,因此常见场景毫无变化。Tab 切换范围;范围行会说明当前生效的范围,以及另一个范围下的数量;在全 workspace 范围中每一行都报告自己的 workspace,而该标签只在展示它的范围里才加入可搜索文本。切换范围会清空查询和选中项,使高亮行始终属于可见列表;而逐行的 workspace 行会让该范围下的每一行在终端里多占一行,可见条数预算已经把这一点计入。 diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml index a16f07a63c..5fc6eacf97 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.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-29-persistent-bash-str-replace-editor.md -2026-07-29-persistent-bash-str-replace-editor.md: a97af750bdd80ddf38dc2d126e70c245ed035f35 -2026-07-29-persistent-bash-str-replace-editor.zh.md: 0c2ab26693d90c91d5c41a128ebb77a3c6cc2e7f +2026-07-29-persistent-bash-str-replace-editor.md: 22851078c1cc8fa9d5716afa41c8a2e2b7e7725c +2026-07-29-persistent-bash-str-replace-editor.zh.md: cf4d18f26d11380a637d573e8ea98cb3ccfcdb59 diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md index a97af750bd..22851078c1 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md @@ -18,6 +18,8 @@ Some deployments need a one-call Bash schema whose shell state survives across m Both plugins are included in the Python runtime closure. The persistent Bash closure also includes the PTY service/local backend and the sandbox services required by that backend. Because `node-pty` executes a native `spawn-helper` on macOS, each packaged macOS runtime executable ships with a `-spawn-helper` sibling; Linux uses `forkpty` directly. A pinned `node-pty` patch checks `DSH_NODE_PTY_SPAWN_HELPER` first, so it remains a true override for a current external consumer that supplies a non-sibling helper. When the override is unset, the patch resolves the packaged executable sibling if present and otherwise preserves upstream lookup in ordinary Node runs. The macOS builders fail before publication when the helper is absent or not executable. +The shipped [`core-web.cordis.yml`](../../../../apps/cli/config/core-web.cordis.yml) overlay composes both plugins over the ordinary Web surface, disables its other model-facing consumers, and leaves the Web host, browser, Workspace, persistence, sandbox, and permission stack in place. The local PTY backend resolves the effective session sandbox mode when it creates the shell. While that owner has an open shell or a spawn in progress, a different permission mode is rejected before its session event commits; the editor continues through the Web filesystem sandbox. + ## Alternatives considered **One combined compatibility plugin.** Rejected because neither tool requires the other and the combined name would tie reusable capabilities to one benchmark. @@ -30,4 +32,4 @@ Both plugins are included in the Python runtime closure. The persistent Bash clo ## Consequences -Profiles can reproduce an external agent by configuring persona and descriptions while the underlying packages remain general. Persistent Bash requires an owning Agent and real PTY backend. Shell exit, timeout, or cancellation loses state. The editor delegates security and mutation policy to the mounted filesystem stack. Runtime-wheel consumers still need no Node installation; Linux wheels contain one executable, while macOS wheels also contain its private native helper. +Profiles can reproduce an external agent by configuring persona and descriptions while the underlying packages remain general. Persistent Bash requires an owning Agent and real PTY backend. Shell exit, timeout, or cancellation loses state. The editor delegates security and mutation policy to the mounted filesystem stack. The Core Web profile retains Web permissions but must close its persistent shell before changing modes. Runtime-wheel consumers still need no Node installation; Linux wheels contain one executable, while macOS wheels also contain its private native helper. diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md index 0c2ab26693..cf4d18f26d 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md @@ -18,6 +18,8 @@ 两个插件都进入 Python runtime 闭包。持久 Bash 的闭包还包含 PTY 服务/本地后端,以及该后端要求的沙箱服务。由于 `node-pty` 在 macOS 上会执行原生 `spawn-helper`,每个打包后的 macOS 运行时可执行文件都会携带一个 `-spawn-helper` 伴随文件;Linux 直接使用 `forkpty`。固定版本的 `node-pty` 补丁会先检查 `DSH_NODE_PTY_SPAWN_HELPER`,因此对当前提供非伴随 helper 的外部消费方而言,该变量仍是真正的覆盖项。未设置该覆盖时,补丁会在打包可执行文件的伴随文件存在时解析它,否则在普通 Node 运行中保留上游查找方式。若 helper 缺失或不可执行,macOS 构建器会在发布前失败。 +已交付的 [`core-web.cordis.yml`](../../../../apps/cli/config/core-web.cordis.yml) 覆盖层在常规 Web 界面之上组合这两个插件,禁用该界面的其他面向模型的消费方,并保留 Web 宿主、浏览器、Workspace、持久化、沙箱与权限栈。本地 PTY 后端会在创建 shell 时解析会话的有效沙箱模式。只要该所有者仍有打开的 shell 或仍在进行中的 spawn,另一种权限模式就会在对应的会话事件提交前遭到拒绝;编辑器则继续经由 Web 文件系统沙箱运行。 + ## 考虑过的替代方案 **单一组合兼容插件。** 被拒绝,因为两个工具互不依赖,组合命名还会把可复用能力绑定到某个基准。 @@ -30,4 +32,4 @@ ## 后果 -Profile 可以通过配置 persona 和描述复现外部 Agent,而底层包保持通用。持久 Bash 需要拥有它的 Agent 与真实 PTY 后端;shell 退出、超时或取消会丢失状态。编辑器把安全与变更策略委托给挂载的文件系统栈。运行时 wheel 包的消费方仍无需安装 Node;Linux wheel 包包含一个可执行文件,macOS wheel 包还包含其私有原生 helper。 +Profile 可以通过配置 persona 和描述复现外部 Agent,而底层包保持通用。持久 Bash 需要拥有它的 Agent 与真实 PTY 后端;shell 退出、超时或取消会丢失状态。编辑器把安全与变更策略委托给挂载的文件系统栈。Core Web profile 保留 Web 权限,但必须先关闭持久 shell 才能更改权限模式。运行时 wheel 包的消费方仍无需安装 Node;Linux wheel 包包含一个可执行文件,macOS wheel 包还包含其私有原生 helper。 diff --git a/.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.i18n.yaml new file mode 100644 index 0000000000..d4f7f72fe2 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.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-07-31-hover-card-click-copy.md +2026-07-31-hover-card-click-copy.md: c87734fe328fa2adb396d6685495faa82bc1fff2 +2026-07-31-hover-card-click-copy.zh.md: a57b5238b095de293605d4e309dcc2da3516e904 diff --git a/.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.md b/.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.md new file mode 100644 index 0000000000..c87734fe32 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.md @@ -0,0 +1,29 @@ +# Agent Note: Hover cards copy their primary value on activation + +Status: implemented + +English | [中文](2026-07-31-hover-card-click-copy.zh.md) + +## Problem + +Workspace and Session rows clip the two values their hover cards expose in full: the Workspace directory path and Session title. The [reachable card](../bug-fix/2026-07-30-hover-popup-pointer-grace.md) permits text selection, but selecting and copying a single known value is a needlessly precise gesture, and the card gives no confirmation that the clipboard accepted it. + +## Decision + +`HoverCard` accepts an optional `copyText` plus `copyLabel` and `copiedLabel`. With `copyText`, the whole card has button semantics for pointer and keyboard activation; its accessible name combines the localized action prefix with the exact value, it writes that value through the shared clipboard helper, and it replaces its content with the success label for up to one second only after the host accepts the write. The feedback retains the pre-copy card height and clears with the card. Without `copyText`, the atom retains its read/select-only behavior. + +The Workspace browser chooses the payload rather than making the primitive infer it from rendered text: a Workspace card passes the full directory path, and a non-blank Session card passes the full display title. A provisional blank New Session card remains read-only because its localized label is a placeholder, not session content. The browser's locale seat supplies `Copy`/`复制` and the success state `Copied`/`已复制`. + +Press and activation remain separate contracts. A pointer press inside the card keeps it mounted so text selection can begin; a completed non-collapsed selection intersecting the card suppresses pointer-click activation, while a plain click or button key activates copy. Anchor-region presses still dismiss immediately, and clipboard rejection leaves the original content visible without claiming success. + +## Alternatives considered + +**Copy the card's rendered `textContent`.** That would concatenate the primary value with creation time or running status, making the clipboard payload depend on presentation and localization. + +**Implement clipboard state in both Workspace card bodies.** The two consumers would duplicate host fallback, keyboard behavior, timer ownership, and success rendering even though the card owns the activation surface. + +**Change the common Chinese `copied` label from `复制成功` to `已复制`.** That would alter every existing copy control to satisfy one card interaction. The Workspace dictionary owns the card-specific wording instead. + +## Consequences + +Both non-placeholder hover-card variants gain the same click and keyboard affordance while retaining consumer-owned payload semantics and localized feedback. The generic atom adds one optional behavior path and a one-second timer; it clears copied state on close, ignores completion after close or unmount, and never reports a rejected write as success. Focused component coverage pins pointer selection precedence, activation, failure, feedback geometry and expiry, and cleanup, while the real-browser Workspace scenario verifies the English label, stable feedback height, and browser clipboard. diff --git a/.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.zh.md b/.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.zh.md new file mode 100644 index 0000000000..a57b5238b0 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.zh.md @@ -0,0 +1,29 @@ +# Agent Note(agent 决策记录):悬浮卡片激活时复制主要值 + +Status: implemented + +[English](2026-07-31-hover-card-click-copy.md) | 中文 + +## 问题 + +Workspace 与 Session 行会截断对应悬浮卡片完整展示的两个值:Workspace 目录路径和 Session 标题。这张[可抵达的卡片](../bug-fix/2026-07-30-hover-popup-pointer-grace.md)支持文本选择,但复制单个已知值仍需精确选择,操作没有必要地繁琐;卡片也不会确认剪贴板是否接受了写入。 + +## 决策 + +`HoverCard` 接收可选的 `copyText`,以及 `copyLabel` 和 `copiedLabel`。传入 `copyText` 后,整个卡片都会为指针与键盘激活提供按钮语义;其无障碍名称由本地化操作前缀和原值组成,卡片通过共享剪贴板辅助函数写入该值,并且只有宿主接受写入后,才会用成功标签替换内容最长一秒。反馈保持复制前的卡片高度,并会随卡片关闭一同清除。未传入 `copyText` 时,该原子组件维持只读且可选择文本的行为。 + +Workspace 浏览器选择复制载荷,不让基础组件从渲染文本中推断:Workspace 卡片传入完整目录路径,非空白 Session 卡片传入完整显示标题。临时的空白「新会话」卡片保持只读,因为其本地化标签是占位文案,并非会话内容。浏览器的 locale 席位提供 `Copy`/`复制`,成功状态则使用 `Copied`/`已复制`。 + +按下与激活仍是两份独立契约。卡片内发生指针按下时,卡片保持挂载,以便用户开始选择文本;文本选择完成后,若非折叠选区与卡片相交,就会阻止指针点击激活,而普通点击或按钮激活键会激活复制。锚点区域内发生指针按下时,卡片仍会立即消失;剪贴板拒绝写入时,卡片继续显示原内容,不会声称复制成功。 + +## 备选方案 + +**复制卡片渲染后的 `textContent`。** 这会把主要值与创建时间或运行状态拼接起来,使剪贴板载荷依赖表现形式和本地化结果。 + +**在两个 Workspace 卡片主体中分别实现剪贴板状态。** 两个消费方会重复实现宿主回退、键盘行为、计时器所有权和成功状态渲染,尽管激活表层由卡片持有。 + +**将通用中文 `copied` 标签从 `复制成功` 改为 `已复制`。** 这样会为了满足一种卡片交互而改变所有现有复制控件。卡片专用文案应由 Workspace 字典持有。 + +## 后果 + +两种非占位悬浮卡片都获得相同的点击与键盘操作能力,同时保留由消费方决定载荷的语义和本地化反馈。通用原子组件增加一条可选行为路径和一个一秒计时器;卡片关闭时会清除已复制状态,关闭或卸载后到达的完成结果会被忽略,写入被拒绝时绝不会报告成功。聚焦组件测试会固定指针选择文本的优先级、激活、失败、反馈尺寸与到期清除以及清理行为,真实浏览器中的 Workspace 场景则验证英文标签、反馈期间高度稳定和浏览器剪贴板。 diff --git a/.agents/notes/implemented/feature/2026-07-31-third-party-memory-mcp-examples.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-third-party-memory-mcp-examples.i18n.yaml new file mode 100644 index 0000000000..0eddfa1740 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-31-third-party-memory-mcp-examples.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-07-31-third-party-memory-mcp-examples.md +2026-07-31-third-party-memory-mcp-examples.md: e82d65a3a5a60cafcc47df5a6873cf5768cd8b8f +2026-07-31-third-party-memory-mcp-examples.zh.md: ee0e9f2e1378f9787e09e32cc05dd13a3648254c diff --git a/.agents/notes/implemented/feature/2026-07-31-third-party-memory-mcp-examples.md b/.agents/notes/implemented/feature/2026-07-31-third-party-memory-mcp-examples.md new file mode 100644 index 0000000000..e82d65a3a5 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-31-third-party-memory-mcp-examples.md @@ -0,0 +1,78 @@ +# Agent Note: Third-party memory MCP examples + +Status: implemented + +English | [中文](2026-07-31-third-party-memory-mcp-examples.zh.md) + +## Problem + +A direct vendor integration made one provider's API, configuration, health behavior, and tool semantics part of DSH. That was too much product surface for a capability already expressible through MCP, and it would require repeating the same adaptation for every memory system. Users instead need a small, inspectable way to opt into one external memory server while preserving the generic MCP boundary. + +The acceptance bar is stronger than "the socket connects": each reference must support a real DSH write in session A, recall from the provider in a fresh DSH session B, and use of the recalled value. At the same time, provider downloads, accounts, models, embeddings, storage initialization, and separate HTTP processes must remain upstream responsibilities. + +## Decision + +Ship three default-off Cordis overlay examples under `examples/mcp-memory`: Memorix, MCP Reference Memory, and Engram. Every file inserts exactly one `@deepseek-ai/dsh-mcp-client` row. None is referenced by the shipped composition, and the CLI declares the generic bridge only so an explicitly selected overlay can resolve it. + +These third-party configurations are provided as interoperability examples only. Their inclusion does not imply endorsement, recommendation, partnership, or ongoing support by DeepSeek. There is no memory preset registry, vendor-specific DSH plugin, universal memory service, installation UI, migration layer, health checker, or reconnect controller. Another memory MCP server uses the same documented stdio or Streamable HTTP row. + +## Responsibility boundary + +| Concern | DSH | Upstream provider or user | +|---|---|---| +| Parse selected overlay | Yes | Select one file | +| Start stdio command and stop it on plugin disposal | Yes | Install the pinned executable | +| Connect to Streamable HTTP and discover tools | Yes | Run and supervise the HTTP service | +| Register tools as `mcp____` | Yes | Define tool schemas and behavior | +| Account, auth, model, embedding, storage initialization | No | Yes | +| Vendor data migration, retry, crash recovery | No | Yes | + +The generic stdio transport scrubs ambient credential-shaped and `DSH_*` variables while inheriting other ambient variables. Baseline examples add only required overrides; optional provider secrets must be added to `config.env` or configured in the provider's own files. + +## Pins, storage, and identity + +| Provider | Tested contract | +|---|---| +| Memorix | npm `1.3.0`, tag commit `500792cad3144142293bfbb20acb4841c9f7fcfa` | +| MCP Reference Memory | npm `2026.7.4`, package commit `6dd0a683e198783e30feabf7abaf42f925bd18b1` | +| Engram | tag `v1.20.0`, commit `ba9e46ced152c37a7cb9e576153c41995873e2fc` | + +Storage remains provider-owned. Memorix uses `~/.memorix/data` and Engram uses `~/.engram` by default. The Reference Memory example sets a stable `$HOME/.dsh-mcp-reference-memory.jsonl` path instead of writing into the installed npm package directory. Each provider's own environment variable can override these locations before DSH starts. + +Project identity remains provider-owned: Memorix and Engram use the DSH working directory's Git project, with Engram optionally accepting `ENGRAM_PROJECT`. + +## Model guidance + +The examples do not patch `@deepseek-ai/dsh-system-prompt`: a config patch replaces a row's complete config and could erase an existing persona. The README instead offers one optional additive instruction: + +> When the user asks you to remember something, call a memory write tool. When historical information may be relevant, search memory and use relevant results. + +Provider tool descriptions remain authoritative. + +## Validation contract + +Remote CI never contacts third-party services or consumes secrets. The keyless suite parses all three overlay files, checks their generic bridge and secret boundary, replaces the upstream endpoint with the package-owned MCP fixture server, boots the real Cordis Loader, and proves tool discovery. + +Before merge, manual evidence for every pinned provider must separately show: + +1. DSH session A calls a write tool and receives success for a unique value. +2. Fresh DSH session B, under the same provider storage scope, calls search or recall and returns that value without session A's transcript. +3. Session B uses the recalled value in a subsequent answer. + +"Fresh session" means a new DSH session in the same Host. No Host restart is required. The generic MCP client discovers asynchronously and has no automatic reconnect after a child or HTTP transport closes; validation waits for tools before the first turn and uses HMR or a Host restart only after a crash. + +## Alternatives considered + +**One DSH plugin per provider.** Rejected because it repeats auth, configuration, lifecycle, and tool wrappers that MCP already standardizes and expands ownership for every added provider. + +**A memory-provider preset registry.** Rejected because a registry would make third-party versions and recommendations look like a supported DSH product surface. Copyable overlays keep ownership and drift visible. + +**Run `npx` or `go run` inside the MCP row.** Rejected after probes showed first-run npm downloads can exceed the MCP initialization timeout and an interrupted `npx` cache can become unusable. DSH starts a server process; it is not the provider package manager. Pinned installation commands are explicit prerequisites. + +**Inject the common instruction from the generic MCP client.** Rejected because the bridge serves non-memory MCP servers too, and generic prompt mutation would reintroduce provider semantics into shared runtime code. + +## Consequences + +Selecting a file gives the model the provider's complete discovered MCP tool surface, with schema/token cost determined by that provider. Removing `--config` removes the memory server. Users accept each upstream license, data policy, cloud cost, and operational model directly. + +The earlier vendor-specific change is superseded by this generic path. Future provider drift is handled by updating and revalidating a small example pin rather than adding runtime branches to DSH. diff --git a/.agents/notes/implemented/feature/2026-07-31-third-party-memory-mcp-examples.zh.md b/.agents/notes/implemented/feature/2026-07-31-third-party-memory-mcp-examples.zh.md new file mode 100644 index 0000000000..ee0e9f2e13 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-31-third-party-memory-mcp-examples.zh.md @@ -0,0 +1,78 @@ +# Agent Note: 第三方记忆 MCP 示例 + +Status: implemented + +[English](2026-07-31-third-party-memory-mcp-examples.md) | 中文 + +## 问题 + +直接集成某个提供方会使该提供方的 API、配置、健康状态行为和工具语义成为 DSH 的一部分。对于已经可以通过 MCP 表达的功能,这会让产品接口过于庞大,而且每接入一个记忆系统都需要重复同样的适配工作。用户需要的是一种精简、可检查的方式,在保留通用 MCP 边界的同时,选择启用一个外部记忆服务器。 + +验收标准不止于「套接字可以连接」:每份参考配置都必须支持 DSH 在会话 A 中实际写入,在新的 DSH 会话 B 中从提供方召回,并使用召回的值。与此同时,提供方下载、账户、模型、embedding、存储初始化和独立 HTTP 进程仍由上游负责。 + +## 决策 + +在 `examples/mcp-memory` 下交付三份默认关闭的 Cordis overlay 示例:Memorix、MCP Reference Memory 和 Engram。每个文件只插入一个 `@deepseek-ai/dsh-mcp-client` 配置项。交付组合不会引用这些文件;CLI(命令行界面)仅声明通用桥接器,使用户显式选择 overlay 时可以解析它。 + +这些第三方配置仅作为互操作参考;收录不代表 DeepSeek 的认可、推荐、合作关系或持续支持承诺。系统没有记忆预设注册表、提供方专属 DSH 插件、通用记忆服务、安装 UI、迁移层、健康检查器或重连控制器。其他记忆 MCP 服务器可以使用同一份文档中的 stdio 或 Streamable HTTP 配置项。 + +## 职责边界 + +| 事项 | DSH | 上游提供方或用户 | +|---|---|---| +| 解析选中的 overlay | 是 | 选择一个文件 | +| 启动 stdio 命令,并在插件 dispose(资源释放)时将其停止 | 是 | 安装固定版本的可执行文件 | +| 连接 Streamable HTTP 并发现工具 | 是 | 运行并监管 HTTP 服务 | +| 以 `mcp____` 注册工具 | 是 | 定义工具 schema 和行为 | +| 账户、认证、模型、embedding、存储初始化 | 否 | 是 | +| 提供方数据迁移、重试、崩溃恢复 | 否 | 是 | + +通用 stdio 传输会清除环境中名称类似凭据的变量和 `DSH_*` 变量,同时继承其他环境变量。基线示例仅添加必需的覆盖项;可选的提供方密钥必须添加到 `config.env`,或配置在提供方自己的文件中。 + +## 版本固定、存储与身份 + +| 提供方 | 已测试契约 | +|---|---| +| Memorix | npm `1.3.0`,tag commit `500792cad3144142293bfbb20acb4841c9f7fcfa` | +| MCP Reference Memory | npm `2026.7.4`,package commit `6dd0a683e198783e30feabf7abaf42f925bd18b1` | +| Engram | tag `v1.20.0`,commit `ba9e46ced152c37a7cb9e576153c41995873e2fc` | + +存储仍由提供方负责。Memorix 默认使用 `~/.memorix/data`,Engram 默认使用 `~/.engram`。Reference Memory 示例设置稳定的 `$HOME/.dsh-mcp-reference-memory.jsonl` 路径,而不是写入已安装的 npm 包(package)目录。每个提供方自己的环境变量都可以在 DSH 启动前覆盖这些位置。 + +项目身份仍由提供方负责:Memorix 和 Engram 使用 DSH 工作目录中的 Git 项目,其中 Engram 还可以选择接受 `ENGRAM_PROJECT`。 + +## 模型指导 + +示例不会修改 `@deepseek-ai/dsh-system-prompt`:配置 patch 会替换某个配置项的完整配置,可能抹除已有 persona。README 改为提供一条可选的附加指令: + +> 用户要求记住时调用写入工具;涉及历史信息时,主动检索并使用相关记忆。 + +提供方的工具描述仍然是权威定义。 + +## 验证契约 + +远程 CI 不会访问第三方服务或消耗密钥。无密钥套件解析全部三份 overlay 文件,检查其通用桥接器和密钥边界,将上游端点替换为包自带的 MCP fixture(测试前置数据)服务器,通过真实 Cordis Loader 启动,并验证工具发现。 + +合并前,每个固定版本的提供方都必须分别提供以下人工证据: + +1. DSH 会话 A 调用写入工具,为一个唯一值写入记忆,并收到成功结果。 +2. 新的 DSH 会话 B 在相同的提供方存储范围下调用搜索或召回,不借助会话 A 的 transcript(文本记录)便可返回该值。 +3. 会话 B 在后续回答中使用该召回值。 + +「新会话」是指同一个 Host 中新建的 DSH 会话,不需要重启 Host。通用 MCP 客户端以异步方式发现工具,子进程或 HTTP 传输关闭后不会自动重连;验证会在第一轮之前等待工具出现,并且只在崩溃后使用 HMR 或重启 Host。 + +## 考虑过的替代方案 + +**每个提供方使用一个 DSH 插件。** 不予采纳,因为这会重复 MCP 已经标准化的认证、配置、生命周期和工具包装层,并随着每增加一个提供方而扩大维护范围。 + +**记忆提供方预设注册表。** 不予采纳,因为注册表会让第三方版本和推荐看起来像受支持的 DSH 产品接口。可复制的 overlay 让所有权和版本偏移保持可见。 + +**在 MCP 配置项内运行 `npx` 或 `go run`。** 不予采纳,因为探测表明首次 npm 下载可能超过 MCP 初始化超时,而中断的 `npx` 缓存可能变得不可用。DSH 负责启动服务器进程,不是提供方的包管理器。固定版本的安装命令属于显式前置条件。 + +**由通用 MCP 客户端注入共用指令。** 不予采纳,因为该桥接器也服务于非记忆类 MCP 服务器,而且通用提示词变更会把提供方语义重新带入共享运行时代码。 + +## 后果 + +选择一个文件后,模型可以使用提供方发现到的完整 MCP 工具接口;工具 schema 和 token 成本由提供方决定。移除 `--config` 就会移除记忆服务器。用户直接接受各上游的许可证、数据政策、云服务费用和运维模式。 + +通用方案取代了早期针对特定提供方的改动。未来出现提供方版本偏移时,只需更新并重新验证一份小型示例的固定版本,不必向 DSH 添加运行时分支。 diff --git a/.agents/notes/implemented/feature/2026-07-31-web-cards-toolrow.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-web-cards-toolrow.i18n.yaml new file mode 100644 index 0000000000..483e4cc132 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-31-web-cards-toolrow.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-07-31-web-cards-toolrow.md +2026-07-31-web-cards-toolrow.md: caa18563a9e66f882873e8d7e84cc3ac20702033 +2026-07-31-web-cards-toolrow.zh.md: 4e9c429497e1265b4b39ed2479f382a2f36e7741 diff --git a/.agents/notes/implemented/feature/2026-07-31-web-cards-toolrow.md b/.agents/notes/implemented/feature/2026-07-31-web-cards-toolrow.md new file mode 100644 index 0000000000..caa18563a9 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-31-web-cards-toolrow.md @@ -0,0 +1,32 @@ +# Agent Note: Card tool rows collapse through one ToolRow + +Status: implemented + +English | [中文](2026-07-31-web-cards-toolrow.zh.md) + +## Problem + +The Web client grew five card render intents over successive PRs — terminal, diff, read, search, web — each landing as a keyed toolview registrant under `packages/client/ui-conversation/src/client/toolviews/`. They diverged in two ways the earlier PRs each acknowledged but deferred: + +- **Chrome duplication.** `read-row`, `search-row`, `web-row`, and `file-mutation-row` each hand-drew the summary row (leading state slot, visually-hidden status, title, separator dot, path-link/summary) as their own `
` with a private `.module.css`, instead of composing the shared `ToolRow`. `read-row` carried a `jscpd:ignore` marker naming the duplication and pointing at "a separate change tracked for all rows at once" — this change. +- **Resident vs. collapsed.** Those four rows kept their card (`ReadBlock`/`SearchBlock`/`WebBlock`/`DiffBlock`) resident below the summary — always expanded — while the terminal card (via `GenericToolCard`/`BashRow`) and every text row started collapsed behind ToolRow's whole-row expand. A conversation with several read/search/web/edit calls became a wall of always-open cards, defeating the summary-surface purpose of the message flow. + +## Decision + +`ToolRow` owns every card kind, and every keyed card row composes it. ToolRow already took `terminal` and `diff` card material; it now also takes `read`, `search`, and `web`, rendering whichever is present in its collapsed-by-default expanded body through the matching primitive (capped at the chat `CHAT_*` bounds). A call carries at most one card kind, so the props are mutually exclusive and the body picks the first present. + +The four keyed rows — `ReadRow`, `SearchRow`, `WebRow`, `FileMutationRow` — drop their hand-drawn chrome and private CSS and become thin `ToolRow` compositions, exactly like `AskQuestionRow`: derive the card model, pass it as the matching ToolRow prop, forward `filePath`/`onOpenFile` for the file tools and `output`/`errorSummary` for the cardless failure paths. Each row is now `ToolRowProps & PropsLocale<'conversation'>` and registers with `locale: NS`, because ToolRow needs the conversation `t` for its terminal/code body copy. `GenericToolCard` (the render-site fallback) does the same for read/search/web, so a card-declaring tool without its own keyed row collapses identically. + +The `DetailsPanel` Output section is unchanged: the panel is the single-call reading surface, so it renders each card resident at the primitive's full height, and a capped search keeps its recovery footer there. + +## Consequences + +- One expand interaction across all tool rows: collapsed one-line summary, whole row toggles the card. The card is not in the DOM until expanded (`DisclosureRow` renders `children` only when open), so tests assert absent-then-present around a `[data-expandable]` click. +- Deleted: `read-row.module.css`, `search-row.module.css`, `web-row.module.css`, `file-mutation-row.module.css`, `GenericToolCard.module.css`. The rows carry no CSS of their own; ToolRow's module owns the chrome and the card-body indentation. +- The cardless failure paths (an errored mutation, an errored/nested/legacy search) no longer draw their own `.failure`/recovery `
`; they ride ToolRow's `output` (Output section) and `errorSummary` (collapsed summary first line), which already flatten the result text with the `error.name: error.code` fallback. +- `bash-sample` keeps its own local expand chrome deliberately (the third-party-posture exemplar that never imports the chat domain); it was already collapsed, so its behavior is unchanged. + +## Alternatives considered + +- **Keep the rows resident, only unify chrome.** Rejected: the user's requirement is default-collapsed, and resident cards are what made the flow unscannable. +- **A shared `CardRow` wrapper between the rows and ToolRow.** Rejected: ToolRow already is that wrapper once it takes every card kind; a second layer would be the premature extraction the package rules warn against. diff --git a/.agents/notes/implemented/feature/2026-07-31-web-cards-toolrow.zh.md b/.agents/notes/implemented/feature/2026-07-31-web-cards-toolrow.zh.md new file mode 100644 index 0000000000..4e9c429497 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-31-web-cards-toolrow.zh.md @@ -0,0 +1,32 @@ +# Agent Note:卡片工具行通过同一个 ToolRow 折叠 + +Status: implemented + +[English](2026-07-31-web-cards-toolrow.md) | 中文 + +## 问题 + +Web 客户端在连续几个 PR 里长出五种卡片渲染意图——terminal、diff、read、search、web,每一种都作为一个键控 toolview 注册项落在 `packages/client/ui-conversation/src/client/toolviews/` 下。它们在两处出现分歧,之前每个 PR 都承认却推迟处理: + +- **Chrome 重复。** `read-row`、`search-row`、`web-row`、`file-mutation-row` 各自把摘要行(行首状态槽、视觉隐藏状态、标题、分隔点、路径链接/摘要)手绘成自己的 `
`,配一份私有 `.module.css`,而不是组合共享的 `ToolRow`。`read-row` 带着一个 `jscpd:ignore` 标记,点名这处重复并指向"一处针对所有行一次性处理的独立改动"——就是本次改动。 +- **常驻 vs 折叠。** 那四个行把卡片(`ReadBlock`/`SearchBlock`/`WebBlock`/`DiffBlock`)常驻在摘要下方——始终展开——而终端卡片(经 `GenericToolCard`/`BashRow`)与每个文本行都从折叠状态起步,藏在 ToolRow 的整行展开之后。一个有多个 read/search/web/edit 调用的对话就成了一堵始终打开的卡片墙,违背了消息流作为摘要面的目的。 + +## 决策 + +`ToolRow` 拥有每一种卡片,而每个键控卡片行都组合它。ToolRow 原本就接收 `terminal` 与 `diff` 卡片材料;现在还接收 `read`、`search`、`web`,在其默认折叠的展开 body 里用对应原语渲染当前存在的那一种(按 chat 的 `CHAT_*` 上限截断)。一次调用最多携带一种卡片,因此这些 prop 互斥,body 取第一个存在的。 + +四个键控行——`ReadRow`、`SearchRow`、`WebRow`、`FileMutationRow`——丢掉手绘 chrome 与私有 CSS,成为薄薄的 `ToolRow` 组合,与 `AskQuestionRow` 完全一样:推导卡片模型,作为对应的 ToolRow prop 传入,为文件工具转发 `filePath`/`onOpenFile`,为无卡片的失败路径转发 `output`/`errorSummary`。每个行现在是 `ToolRowProps & PropsLocale<'conversation'>` 并以 `locale: NS` 注册,因为 ToolRow 需要对话的 `t` 来渲染其终端/代码 body 文案。`GenericToolCard`(渲染点兜底)对 read/search/web 做同样的事,所以一个没有自己键控行的卡片声明工具也以同样方式折叠。 + +`DetailsPanel` 的 Output 区不变:面板是单次调用的阅读面,因此它以原语的完整高度常驻渲染每张卡片,被截断的搜索也把恢复脚注留在那里。 + +## 后果 + +- 所有工具行共享一套展开交互:折叠时是单行摘要,整行切换卡片。卡片在展开前不在 DOM 里(`DisclosureRow` 只在打开时渲染 `children`),因此测试围绕一次 `[data-expandable]` 点击断言"先无后有"。 +- 已删除:`read-row.module.css`、`search-row.module.css`、`web-row.module.css`、`file-mutation-row.module.css`、`GenericToolCard.module.css`。这些行不再带自己的 CSS;ToolRow 的 module 拥有 chrome 与卡片 body 的缩进。 +- 无卡片的失败路径(出错的改动,出错/嵌套/旧日志的搜索)不再画自己的 `.failure`/恢复 `
`;它们改走 ToolRow 的 `output`(Output 区)与 `errorSummary`(折叠摘要首行),后者已经用 `error.name: error.code` 兜底压平结果文本。 +- `bash-sample` 有意保留自己本地的展开 chrome(第三方姿态的范例,从不引入 chat 域);它本来就是折叠的,因此行为不变。 + +## 考虑过的替代方案 + +- **保持行常驻,只统一 chrome。** 否决:用户的要求是默认折叠,而常驻卡片正是让流不可扫读的原因。 +- **在行与 ToolRow 之间加一层共享的 `CardRow` 包装。** 否决:ToolRow 一旦接收每一种卡片,它本身就是那层包装;再加一层就是 package 规则警告的过早抽取。 diff --git a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.i18n.yaml b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.i18n.yaml new file mode 100644 index 0000000000..d65dae2802 --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.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-07-30-generated-third-party-notices.md +2026-07-30-generated-third-party-notices.md: e480954d29d5dc09ef8ecd4069059a1f0c8b1043 +2026-07-30-generated-third-party-notices.zh.md: 78ba7250e797c57048078d1b4f62b7a9a5d9d561 diff --git a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md new file mode 100644 index 0000000000..e480954d29 --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md @@ -0,0 +1,53 @@ +# Agent Note: Generated third-party notices + +Status: implemented + +English | [中文](2026-07-30-generated-third-party-notices.zh.md) + +## Problem + +Open-sourcing this repository requires disclosing the third-party software it depends on, with each project's license. The disclosure has to be complete, has to stay true as dependencies change, and has to say something a reader can act on — which of these packages end up on a user's machine, and which only build and test the repository. + +A hand-written inventory answers none of those durably. Roughly a hundred rows of names and license strings derived from manifests drift silently the moment a package is added, removed, or relicensed, and nothing would notice. + +## Decision + +[`THIRD_PARTY_NOTICES.md`](../../../../THIRD_PARTY_NOTICES.md) is generated by [`scripts/gen-third-party-notices.ts`](../../../../scripts/gen-third-party-notices.ts) from the workspace manifests, `vendor/README.md`, the `pyproject.toml` files, and `pnpm-workspace.yaml`. The root README pair links the file from its License section. + +**Freshness is maintained, not merely enforced.** A pre-commit job regenerates the file and stages it whenever a generator input is staged — any manifest, a workspace declaration, the root lock file, `vendor/README.md`, a `pyproject.toml`, the generator itself, or the script holding the build-time pin — so an unrelated dependency edit never has to come back and rerun a generator. The committed bytes are then asserted inside [`scripts/gen-third-party-notices.spec.ts`](../../../../scripts/gen-third-party-notices.spec.ts), which the test lane already runs — the check adds no gate process, no scheduler slot, and no separate CI step. `pnpm run verify-third-party-notices` remains available for a standalone check. + +One trigger gap is accepted rather than worked around: lefthook inspects only files present on disk, so **deleting** a manifest runs no job, and removing a package reaches the assertion in the test lane instead. Reconstructing the staged file list to include deletions was tried and does not work — lefthook filters the list against the working tree either way. The assertion is the backstop for exactly this case. + +The file discloses **direct** dependencies only. The complete npm closure with pinned versions already lives in `pnpm-lock.yaml` (`pnpm licenses list` renders it) and the Python closure in `python/sdk/uv.lock`; re-materializing either as prose would be a second, worse copy. + +**Tiering is by declaring area, not by manifest section.** A package is a runtime dependency when any manifest outside `DEV_ONLY_AREAS` — the root manifest, `packages/support/`, `packages/client/test-runtime/`, `website/`, `examples/`, `native/` — names it under `dependencies` or `optionalDependencies`. Section names alone are wrong in both directions: a test-support package declares `vitest` under `dependencies` without shipping it, and the `bin/dsh` launcher execs through `tsx`, which no manifest declares as a runtime dependency at all (the generator marks it runtime explicitly). + +The runtime tier deliberately covers **every mountable plugin**, not just what the CLI, Web UI, and Python runtime load by default. `scripts/install.sh` installs the repository itself, so a user's `cordis.yml` can mount any plugin package; `@modelcontextprotocol/sdk` and the OpenTelemetry packages reach real users even though no default assembly imports them. Under-disclosure is the costly direction for a legal notice. + +The manifest set is derived from the `packages:` members each `pnpm-workspace.yaml` declares — the root one and the nested Landlock workspace's — so a new member area is read the day it is declared rather than the day someone remembers to extend a list. License and repository metadata come from the installed pnpm stores, both the root one and the Landlock workspace's, so the generator requires an installed tree and fails loud when a package resolves to neither, rather than emitting an empty cell. `OVERRIDES` carries the packages whose published manifest cannot answer — Rust-built npm bins that omit `license`, and the `modelcontextprotocol/servers` packages whose repository is mid MIT→Apache-2.0 relicensing, so their effective terms are per-contribution. A runtime dependency whose license is not on the permissive list is a hard error: shipping copyleft is a distribution decision, not something a regenerated table may absorb silently. Vendored packages are cross-checked against `vendor/README.md` and rejected if any is not MIT, and `pnpm-workspace.yaml`'s `patchedDependencies` are listed under the runtime table because pnpm applies those patches at install time — shipped artifacts carry modified copies of `@earendil-works/pi-tui` and `node-pty`, and the patch files are the record of what changed. + +## Testing + +The same spec that asserts freshness pins the tiering rule against fixture manifests — including the two cases that motivate it, a `dependencies` entry of a test-support package and a plugin package no app mounts. It also pins the parsers against the shapes that would otherwise drop a package without a word: a `vendor/README.md` table that stops covering a vendored directory, a requirement array holding extras (`"httpx[http2]"`), a requirement with no version at all, an author-named `[dependency-groups]` table, and a workspace member area absent from any hardcoded list. Each of those is a silent-omission path, which is the failure mode a disclosure file cannot afford. + +## Alternatives considered + +**Keep the hand-written file and review it at release time.** Reviewing a hundred derived rows by eye is exactly the work a generator does correctly, and the file's own claim — that it lists every direct dependency — would be unverified between releases. + +**Verify through a dedicated `doc-sync` gate.** That is how every other generated artifact here is checked, and it was the first shape of this change. It costs a gate process and a scheduler slot in a matrix that is already long, and — worse — its only failure mode is telling a contributor, minutes after they pushed an unrelated dependency bump, to go rerun a generator. Regenerating at commit time removes the interruption, and the assertion inside a spec the test lane already runs keeps the guarantee at no additional CI cost. + +**Enumerate the full transitive closure.** The closure is thousands of packages, already recorded in the lock files with exact versions, and would bury the direct dependencies that a reader actually evaluates. The file points at the lock files and the `pnpm licenses list` renderer instead. + +**Tier by manifest section (`dependencies` vs `devDependencies`).** Mechanically simple and wrong on real data in both directions, as the tiering paragraph above records. + +**Tier by reachability from the shipped assemblies only** (`apps/*` plus `python/sdk-runtime`). This produces a tighter runtime tier, but classifies the MCP client and the OpenTelemetry exporter as development-only even though a user running the installed repository can mount them. It understates the disclosure, which is the wrong direction to err for a legal notice. + +**Emit the notices as a bilingual pair.** Every other root document is paired, but the file is a table of upstream package names, SPDX identifiers, and URLs; the translatable surface is a handful of section blurbs. `scripts/translation-pairing.ts` scopes discovery to `README*`, `.agents/notes/**`, `docs/**`, and `python/**`, so a root non-README file is outside the bilingual corpus by construction, and the README pair carries the bilingual entry points into it. + +## Consequences + +A dependency edit now carries a regenerated notices file into the same commit. Contributors pay one generator run — about a second — on commits that touch a manifest, and nothing on any other commit. Committing with hooks disabled defers the cost to a test-lane failure that names the command. + +The generator needs an installed tree, which makes it heavier than a pure-source generator, and a new package with unusable published metadata needs an `OVERRIDES` entry rather than silently rendering a blank license. Both failures are loud and name the remedy. + +The tiering rule is a policy encoded in one constant. Adding a workspace area that never ships — a second test-infrastructure tier, another site — requires extending `DEV_ONLY_AREAS`, or its dependencies will be disclosed as runtime. diff --git a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md new file mode 100644 index 0000000000..78ba7250e7 --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md @@ -0,0 +1,53 @@ +# Agent Note: Generated third-party notices + +Status: implemented + +[English](2026-07-30-generated-third-party-notices.md) | 中文 + +## Problem + +本仓库开源需要披露所依赖的第三方软件及各自的许可证。这份披露必须完整,必须随依赖变化保持为真,还必须给出读者用得上的信息:哪些包最终会进到用户机器上,哪些只用于构建和测试。 + +手写清单无法长期满足其中任何一条。约一百行从各清单文件推导出来的包名与许可证标识,只要有依赖新增、移除或换用许可证就会悄悄失真,而没有任何检查会察觉。 + +## Decision + +[`THIRD_PARTY_NOTICES.md`](../../../../THIRD_PARTY_NOTICES.md) 由 [`scripts/gen-third-party-notices.ts`](../../../../scripts/gen-third-party-notices.ts) 依据各工作区清单、`vendor/README.md`、`pyproject.toml` 与 `pnpm-workspace.yaml` 生成。根 README 双语两侧都从「许可证」一节链到该文件。 + +**新鲜度靠维护而非拦截。** 只要暂存了生成器的任一输入——任何清单文件、工作区声明、根锁文件、`vendor/README.md`、某个 `pyproject.toml`、生成器自身,或持有构建期 pin 的脚本——pre-commit 任务就会重新生成并一并入库,改依赖的人不必事后再折返跑一次生成器。已提交的字节随后由 [`scripts/gen-third-party-notices.spec.ts`](../../../../scripts/gen-third-party-notices.spec.ts) 断言,而测试 lane 本就会跑这个文件——这项校验不增加门禁进程、不占调度位、也不新增 CI 步骤。需要单独校验时,`pnpm run verify-third-party-notices` 仍然可用。 + +有一处触发缺口是接受而非绕过的:lefthook 只检视磁盘上存在的文件,因此**删除**清单文件不会触发任何任务,移除一个包会落到测试 lane 的断言上。重构暂存文件列表以纳入删除的做法试过,不成立——无论怎么给列表,lefthook 都会拿工作树过滤一遍。这个场景正由断言兜底。 + +文件只披露**直接**依赖。完整的 npm 闭包连同锁定版本已记录在 `pnpm-lock.yaml`(`pnpm licenses list` 可渲染),Python 闭包记录在 `python/sdk/uv.lock`;再用散文誊一遍只会得到一份更差的副本。 + +**分层依据是声明方所在区域,而非清单字段名。** 只要 `DEV_ONLY_AREAS` 之外的任一清单——即根清单、`packages/support/`、`packages/client/test-runtime/`、`website/`、`examples/`、`native/` 之外——在 `dependencies` 或 `optionalDependencies` 里点名某个包,它就是运行时依赖。单看字段名在两个方向上都会出错:测试支撑包把 `vitest` 写在 `dependencies` 里却并不交付它;而 `bin/dsh` 启动器 exec 经过的 `tsx`,根本没有任何清单把它声明为运行时依赖,只能由生成器显式标记。 + +运行时层刻意覆盖**所有可挂载的插件**,而不止 CLI、Web UI 与 Python 运行时默认加载的那些。`scripts/install.sh` 安装的就是仓库本身,用户的 `cordis.yml` 可以挂载任何插件包;`@modelcontextprotocol/sdk` 与 OpenTelemetry 系列即使没有任何默认装配引入,也会触达真实用户。对法务披露而言,披露不足才是代价更高的那个方向。 + +清单集合由两个 `pnpm-workspace.yaml`——根工作区与嵌套的 Landlock 工作区——各自声明的 `packages:` 成员派生,因此新增成员区域在声明当天就会被读取,而不必等谁想起来去补一份列表。许可证与仓库地址取自已安装的 pnpm store,根 store 与 Landlock 工作区的 store 都会查;某个包两处都解析不到时直接失败,而不是留下空单元格。`OVERRIDES` 收录已发布清单答不上来的包:用 Rust 构建、发布时省略 `license` 字段的 npm 可执行包,以及 `modelcontextprotocol/servers` 系列——该仓库正处在 MIT 向 Apache-2.0 的重新许可过程中,实际条款按贡献逐条而定。运行时依赖的许可证若不在宽松清单内即为硬失败:交付 copyleft 是一项分发决策,不该被一次重新生成悄悄吸收。被源码收编的包会与 `vendor/README.md` 交叉核对,出现非 MIT 即报错;`pnpm-workspace.yaml` 的 `patchedDependencies` 列在运行时表格之后,因为 pnpm 在安装期就会打上这些补丁——交付产物携带的是改动过的 `@earendil-works/pi-tui` 与 `node-pty`,补丁文件本身就是改动的完整记录。 + +## Testing + +断言新鲜度的同一个 spec 也用夹具清单钉住分层规则,覆盖促成该规则的两个场景:测试支撑包的 `dependencies` 条目,以及没有任何应用挂载的插件包。它还把各解析器钉在那些原本会让某个包无声消失的形态上:不再覆盖全部收编目录的 `vendor/README.md` 表、含 extras 的依赖数组(`"httpx[http2]"`)、完全不带版本的依赖、作者自取名字的 `[dependency-groups]` 表,以及任何硬编码列表都不含的工作区成员区域。这些都是静默漏报路径——正是披露文件最担不起的失败方式。 + +## Alternatives considered + +**保留手写文件,发版时人工过一遍。** 用肉眼审阅上百行推导数据,恰恰是生成器能做对的活;而且在两次发版之间,文件自称「列出全部直接依赖」这句话无人验证。 + +**用专门的 `doc-sync` 门禁校验。** 仓库里其他生成产物都是这么把关的,本次改动最初也是这个形态。但它要在本已冗长的矩阵里再占一个门禁进程和一个调度位;更糟的是,它唯一的失败方式,就是在别人推完一个无关的依赖升级几分钟后,通知对方回去重跑一次生成器。改为提交时重新生成消除了这次打断,而把断言放进测试 lane 本就会跑的 spec 里,则以零额外 CI 成本保住了这项保证。 + +**列出完整传递闭包。** 闭包有数千个包,锁文件里已带精确版本,铺开只会淹没读者真正要评估的直接依赖。文件转而指向锁文件与 `pnpm licenses list`。 + +**按清单字段分层(`dependencies` 与 `devDependencies`)。** 机械上最省事,但在真实数据上两个方向都会出错,理由见上文分层段落。 + +**只按已交付装配的可达性分层**(`apps/*` 加 `python/sdk-runtime`)。这样得到的运行时层更紧凑,但会把 MCP 客户端与 OpenTelemetry 导出器判为仅开发用途——而运行已安装仓库的用户完全可以挂载它们。这会低估披露,对法务通告来说错在了更危险的一侧。 + +**把披露文件做成双语对。** 其他根文档都是成对的,但这份文件是上游包名、SPDX 标识与网址构成的表格,可翻译的只有寥寥几段章节导语。`scripts/translation-pairing.ts` 的发现范围限定在 `README*`、`.agents/notes/**`、`docs/**` 与 `python/**`,根目录下的非 README 文件在构造上就不属于双语语料;双语入口由 README 对承担。 + +## Consequences + +此后改动依赖时,重新生成的披露文件会随同一个提交入库。触及清单文件的提交多付一次生成器运行——约一秒;其余提交不受影响。若禁用钩子提交,代价推迟为一次测试 lane 失败,其报错会指明补救命令。 + +生成器需要已安装的工作树,因此比纯源码生成器更重;发布元数据不可用的新包需要补一条 `OVERRIDES`,而不是默默渲染出空白许可证。这两类失败都会明确报错并指出补救方式。 + +分层规则是编码在一个常量里的政策。若新增了不参与交付的工作区区域——第二层测试基础设施、另一个站点——就要同步扩展 `DEV_ONLY_AREAS`,否则其依赖会被当作运行时依赖披露出去。 diff --git a/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.i18n.yaml new file mode 100644 index 0000000000..e4491b6c7b --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.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/simplification/2026-07-31-one-route-to-add-a-workspace.md +2026-07-31-one-route-to-add-a-workspace.md: 0d3f4096b11ec2d0bd1339268bfb8567380d3e1a +2026-07-31-one-route-to-add-a-workspace.zh.md: b9b25b15680cb54ae980e217e6218a91cd1e366a diff --git a/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.md b/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.md new file mode 100644 index 0000000000..0d3f4096b1 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.md @@ -0,0 +1,57 @@ +# Agent Note: One route to add a Workspace + +Status: implemented + +English | [中文](2026-07-31-one-route-to-add-a-workspace.zh.md) + +## Problem + +Both Workspace surfaces — the sidebar region header's `+` and the conversation hero's chip — offered two ways to get a Workspace: **Open local folder…**, which raised the composed directory flow, and **Create a new workspace**, which took a name and created `/`. The two overlapped: the browse occupant carries its own **New folder** affordance, so picking a directory already covered creating one. Two entries meant two vocabularies for one outcome, a name dialog with its own duplicate-name rule, and a create target the operator could neither see nor choose. + +Removing the weaker entry leaves the sidebar header with exactly one action, which raised the presentation question this Note also settles: what a popover with a single row should look like. + +## Decision + +Adding a Workspace has one route: pick a host directory through the composed directory flow, new or existing. `menu.addWorkspace` ("添加工作区…" / "Add workspace…") is the entry; the create-by-name dialog and its `create.*` / `menu.createWorkspace` / `workspace.new` strings are gone. The label names the outcome, not the mechanism, because it is now the only door to that outcome — a user looking for "新建" must find it. + +**A menu exists to disambiguate between targets.** When the only entry left is the add action — the add-only sidebar surface, or the hero with an empty list — the anchor gesture *is* that action: the flow opens directly and no popover renders. A one-row popover costs a click and offers nothing to choose between. The rule is one predicate (`addIsTheOnlyEntry`) covering both surfaces rather than a per-surface special case. + +Two boundaries fall out of that rule and are part of it: + +- **An empty list is only final once the baseline lands.** While `phase` is `pending` the hero keeps its menu and loading status instead of jumping into a flow that the arriving workspaces would have made unnecessary. The add-only surface lists nothing and never waits. +- **An unoccupied directory-flow hole leaves nothing to add with.** The sidebar header then renders no button at all rather than a dead one; the hero's menu keeps working as a picker over whatever is listed, and shows nothing when nothing is listed either — an empty popover would claim a choice that does not exist. This is the seam's documented no-flow default reaching its conclusion: with the occupant gone, so is the only creation affordance. The hero's anchor chip belongs to ui-conversation, so this package can suppress the popover but cannot hide the chip. + +The direct-open path carries the busy rule the menu entry states: while a pick is still being adopted (`flowBusy`), the anchor gesture is held exactly as the entry is disabled, so a late outcome cannot race a second flow. + +`WorkspaceCreateFlow` is now `WorkspacePickFlow` and its `createOnly` prop is `addOnly`; the injected `createWorkspace` narrows from `{ name } | { path }` to `{ path }`. + +## Wire and CLI residue + +The host's `workspace.create` still accepts `{ name }`, and `dsh web --workspace-root` still feeds its target directory, but no product surface reaches either any more. The same is true of the client seam that carried the name to the wire: `WorkspaceCreateInput`, `WorkspacesService.create`'s `{ name }` arm, `intentName`'s name branch, and the manager's "name under workspaceRoot" contract. `apps/cli/README.md` and its Chinese counterpart still document `--workspace-root` as creating named Workspaces. The whole set is marked for deletion at the call site in `packages/host/apiproxy/src/api-proxy.ts` and left to a follow-up change: it is backend, client-seam, and CLI surface with its own reviewer and its own test fallout (the api-proxy workspace suite, the runtime workspace suite, the config catalog), and the release-blocking part of this decision is the UI. + +## Testing + +`connectFreshWorkspace` — the helper every web e2e scenario boots through — stages `/workspace` and adopts it through the dialog's path editor, so the produced session cwd stays identical to what create-by-name produced and scenario goldens stay valid. Staging rather than creating in-dialog keeps the helper idempotent across the repeated connects a scenario may make (a second create of the same folder fails, and the create dialog holds the flow open on that failure). Creating a folder from inside the chooser — the other half of the same route — is covered by `workspace-management.e2e.ts`, which owns the focused coverage: two workspaces added on folders the dialog creates, a deleted title reused on a different directory, and the browser-dialog aria golden. + +`smoke-real.e2e.ts` is the one scenario booting the unpatched shipped tree, where the `-auto` row resolves per host; it now pins `-browse` through a `--config` overlay so the developer's display environment cannot decide whether the picker is drivable at all. + +## Alternatives considered + +**Keep `Open local folder…` as the label.** Rejected: after the merge the entry both opens and creates, and naming it after the mechanism hides the creation half from exactly the users whose entry we removed. The counter-argument — "本地" usefully disambiguates the browser's machine from the harness's — is answered one step later by the dialog's own title and breadcrumbs. + +**Keep the two-entry menu and make `Create a new workspace` open the same flow.** Rejected: two labels for one action is the confusion we were asked to remove, not a smaller version of it. + +**Keep a one-row popover for consistency with the hero's menu.** Rejected: a popover that offers no choice is a wasted click and reads as unfinished. Consistency here is the *rule* (menu ⇔ a choice exists), not the widget. + +**Keep the menu shell for entries we might add later (clone a repo, remote directory).** Rejected under "require a current owner and need": no such entry exists, and restoring a menu when one arrives is a smaller change than shipping an empty frame now. + +**Delete the wire's create-by-name branch in the same change.** Rejected for this PR: it is backend/CLI surface with a different reviewer and a wider test fallout, and the urgent decision is the UI. See the residue section — it is marked, not forgotten. + +**Register the workspace through the host in the e2e scaffold instead of driving the dialog.** Rejected: it would have decoupled all 15 scenarios from the picker, so nothing in the lane would prove the surviving route reaches a live composer. Every scenario now walks the real dialog to adopt its directory; only the create-a-folder half is concentrated in one scenario, because repeating it everywhere makes the shared helper non-idempotent for no extra signal. + +## Consequences + +- Creating a Workspace outside the operator's chosen directory is no longer possible from the UI; the server-controlled `--workspace-root` target was the one way to constrain where new workspace folders land, and nothing replaces it. A deployment that needs that constraint has to re-introduce it deliberately. +- The one remaining route browses the host filesystem, so the picker's reach is now the whole host rather than one configured parent. That is already the browse occupant's contract; this change makes it the only contract. +- A composition that mounts `ui-workspace` without any directory-picker package can no longer add a Workspace at all, and now says so by omitting the button instead of offering a create-by-name fallback. +- The hero chip still announces `aria-haspopup="menu"` while the direct-open path raises a dialog instead. Making that truthful means routing the flow's presentation choice up through the `conversation.hero.workspace` owner contract — the flow owns the decision, the chip owns the announcement, and they sit in different packages — so it is a named follow-up rather than a silent inconsistency. The sidebar button this change added makes no popup claim at all. diff --git a/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.zh.md b/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.zh.md new file mode 100644 index 0000000000..b9b25b1568 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.zh.md @@ -0,0 +1,57 @@ +# Agent Note: One route to add a Workspace + +Status: implemented + +[English](2026-07-31-one-route-to-add-a-workspace.md) | 中文 + +## Problem + +两处 Workspace 表层——侧边栏区头的 `+` 与会话主视觉区的 chip——都提供了两条获得 Workspace 的路径:**打开本地文件夹…** 拉起组合的目录流程,**新建工作区** 接收一个名称并创建 `/`。两者重叠:浏览占用者自带 **新建文件夹** 能力,因此"选一个目录"本就覆盖了"建一个目录"。两个入口意味着同一结果有两套词汇、一个自带重名规则的名称对话框,以及一个操作者既看不到也选不了的创建位置。 + +删掉较弱的那个入口后,侧边栏区头只剩一个动作,于是引出了本 Note 一并裁定的展示问题:只有一行的浮层应该长什么样。 + +## Decision + +添加 Workspace 只有一条路径:通过组合的目录流程选一个宿主机目录,新建的或已存在的都可以。入口是 `menu.addWorkspace`("添加工作区…" / "Add workspace…");按名称创建的对话框及其 `create.*`/`menu.createWorkspace`/`workspace.new` 文案全部删除。标签命名的是结果而非机制,因为它现在是通往该结果的唯一一扇门——找"新建"的用户必须能找到它。 + +**菜单的存在是为了在多个目标之间消歧。** 当只剩添加这一个入口时——仅添加的侧边栏表层,或列表为空的主视觉区——锚点手势*就是*该动作:直接拉起流程,不渲染浮层。只有一行的浮层多花一次点击,却没有任何可选项。这条规则是一个谓词(`addIsTheOnlyEntry`)覆盖两处表层,而不是逐表层特判。 + +由该规则派生出两条边界,它们同属这个决定: + +- **列表为空只有在基线落地后才算最终结果。** `phase` 仍为 `pending` 时,主视觉区保留菜单与加载状态,而不是跳进一个即将到达的 workspace 会使其变得多余的流程。仅添加表层不列任何东西,因此从不等待。 +- **目录流程的洞没有占用者时,就没有任何可添加的手段。** 此时侧边栏区头干脆不渲染按钮,而不是留下一个点了没反应的按钮;主视觉区的菜单则继续作为选择器工作,列出已有内容——而当同样没有内容可列时,它什么也不弹:空浮层会宣称一个并不存在的选择。这是 seam 文档化的无流程默认行为走到它的结论:占用者不在,唯一的创建能力也就不在。主视觉区的锚点 chip 归 ui-conversation 所有,因此本包能压掉浮层,却无法隐藏该 chip。 + +直接拉起的这条路径同样遵守菜单项声明的 busy 规则:某次选取仍在接纳中(`flowBusy`)时,锚点手势会被按住,正如该菜单项会被禁用,从而使迟到的结果无法抢开第二个流程。 + +`WorkspaceCreateFlow` 现更名为 `WorkspacePickFlow`,其 `createOnly` prop 更名为 `addOnly`;注入的 `createWorkspace` 从 `{ name } | { path }` 收窄为 `{ path }`。 + +## Wire and CLI residue + +Host 侧的 `workspace.create` 仍接受 `{ name }`,`dsh web --workspace-root` 也仍在为它提供目标目录,但已没有任何产品表层会走到它们。把名称送到 wire 的客户端一段同样如此:`WorkspaceCreateInput`、`WorkspacesService.create` 的 `{ name }` 分支、`intentName` 的名称分支,以及 manager 中"workspaceRoot 下的 name"这一契约。`apps/cli/README.md` 及其中文对照本也仍把 `--workspace-root` 记为"创建具名 Workspace"。这一整套都在 `packages/host/apiproxy/src/api-proxy.ts` 的调用点标记为待删除,并留给后续改动:它横跨 backend、客户端 seam 与 CLI 面,有各自的 reviewer 和各自的测试波及面(api-proxy workspace 套件、runtime workspace 套件、配置目录),而本决定中阻塞发布的部分是 UI。 + +## Testing + +`connectFreshWorkspace`——所有 web e2e 场景启动时都会走的辅助函数——会预先备好 `/workspace`,再经对话框的路径编辑器接纳它,因此产出的会话 cwd 与按名称创建时完全一致,场景 golden 保持有效。选择预先备好而不是在对话框内新建,是为了让该辅助函数在一个场景可能发生的多次连接之间保持幂等(第二次创建同名文件夹会失败,而创建对话框会在失败时把流程停在原地)。在选择器内新建文件夹——同一条路径的另一半——由 `workspace-management.e2e.ts` 覆盖,它承担针对性覆盖:在对话框自己创建的文件夹上添加两个 workspace、在另一个目录上复用已删除的标题、以及浏览对话框的 aria golden。 + +`smoke-real.e2e.ts` 是唯一启动未打补丁的出厂配置树的场景,其中 `-auto` 行会按宿主机解析;它现在通过 `--config` overlay 钉死 `-browse`,使开发机的显示环境无法决定选择器是否可被驱动。 + +## Alternatives considered + +**保留 `打开本地文件夹…` 作为标签。** 否决:合并后该入口既能打开也能创建,用机制命名会恰好对那些入口被我们删掉的用户隐藏创建这一半。反方理由——"本地"二字有效区分了浏览器所在机器与 harness 所在机器——在下一步就由对话框自身的标题和面包屑回答了。 + +**保留双入口菜单,让 `新建工作区` 也打开同一个流程。** 否决:同一动作两个标签正是我们被要求消除的混淆,而不是它的缩小版。 + +**为了与主视觉区菜单保持一致而保留只有一行的浮层。** 否决:不提供选择的浮层是一次浪费的点击,读起来像半成品。这里要一致的是*规则*(有菜单 ⇔ 存在选择),不是控件。 + +**为将来可能新增的入口(克隆仓库、远程目录)保留菜单壳。** 否决,依据"require a current owner and need":这样的入口目前并不存在,而等它到来时再恢复菜单,比现在就发一个空壳的改动更小。 + +**在同一改动中删除 wire 的按名称创建分支。** 本 PR 否决:那是 backend/CLI 面,reviewer 不同、测试波及面更广,而紧急的决定是 UI。见 residue 一节——它是被标记了,不是被遗忘了。 + +**在 e2e scaffold 中经 host 注册 workspace,而不驱动对话框。** 否决:那会让全部 15 个场景与选择器解耦,整条 lane 将无法证明幸存的这条路径能走到可用的 composer。现在每个场景都会走真实对话框来接纳自己的目录;只有"新建文件夹"那一半集中在一个场景里,因为处处重复只会让共享辅助函数失去幂等性,却换不来额外信号。 + +## Consequences + +- 从 UI 已无法在操作者选定目录之外创建 Workspace;服务端控制的 `--workspace-root` 目标曾是约束新 workspace 文件夹落点的唯一手段,现在没有替代品。需要该约束的部署必须有意识地重新引入它。 +- 仅存的这条路径会浏览宿主机文件系统,因此选择器的可达范围现在是整台宿主机,而非一个配置好的父目录。这本就是浏览占用者的契约,本改动使它成为唯一的契约。 +- 挂载了 `ui-workspace` 但未挂任何 directory-picker 包的组合,已完全无法添加 Workspace;现在它通过不渲染按钮来说明这一点,而不是提供一个按名称创建的兜底。 +- 主视觉区的 chip 仍声明 `aria-haspopup="menu"`,而直接拉起的路径实际弹出的是对话框。要让它如实,需要把流程的展示方式经 `conversation.hero.workspace` 的 owner 契约上报——决定权在流程,播报权在 chip,两者分属不同的包——因此这被列为一项具名的后续工作,而不是一处无声的不一致。本次新增的侧边栏按钮完全不作任何 popup 声明。 diff --git a/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.i18n.yaml b/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.i18n.yaml index 4603c65221..0c5c704684 100644 --- a/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.i18n.yaml +++ b/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.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/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.md -2026-07-28-storage-root-and-derived-medium-recovery.md: 7759d0b6a8d522d6f853be0cd31a0b3909292446 -2026-07-28-storage-root-and-derived-medium-recovery.zh.md: 893b91c2ad3943cce9f64702e52afbb8075b907c +2026-07-28-storage-root-and-derived-medium-recovery.md: 2edb60d204da736bf14ace6278fa4ee7b9e4b8f7 +2026-07-28-storage-root-and-derived-medium-recovery.zh.md: daef4f7661df069df3438f909f2744880a93a873 diff --git a/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.md b/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.md index 7759d0b6a8..2edb60d204 100644 --- a/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.md +++ b/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.md @@ -18,7 +18,7 @@ Two independent changes, one per gap. ### One global storage root (shipped, amended form); resolved once at construction (still open) -- **Shipped**: the Web overlay anchors `storage-json.root` to `$DSH_HOME/storages` directly in the row, with the same `!!js` IIFE the session root uses (`~/.dsh/storages` by default, beside `~/.dsh/sessions`; no leading dot — the home is already a hidden tree). The user ruled this form in over this section's original launcher-patch + `storageRoot` profile key (see Alternatives); per-row overrides ride the personal `~/.dsh/config.yaml` patch layer. The verbatim duplication of the expression against the session root (`base.cordis.yml`) is a known cost — acceptable at two consumers; a third `$DSH_HOME`-derived root triggers extracting a single source (a launcher variable or a shared expression). The web e2e scaffold already patches the row to an absolute temp root, so tests never touch the user's home. +- **Shipped**: the Web overlay anchors `storage-json.root` to `$DSH_HOME/storages` directly in the row through the app-boot-provided `dshHomePath('storages')` (`~/.dsh/storages` by default, beside `~/.dsh/sessions`; no leading dot — the home is already a hidden tree). The helper delegates to the canonical `dsh-paths` resolver, and the session root uses the same function without duplicating its fallback and tilde rules. The user ruled the per-row form in over this section's original launcher-patch + `storageRoot` profile key (see Alternatives); per-row overrides ride the personal `~/.dsh/config.yaml` patch layer. The web e2e scaffold already patches the row to an absolute temp root, so tests never touch the user's home. - **Still open**: `JsonStorageBackend` resolves its configured root once at construction (`resolve(config.root)`), adopting the JSONL backend's recorded rationale verbatim: a later `process.cwd()` change must not split one backend across roots. The SQLite storage backend already resolves its path. - Pre-release stance applies (and was executed): no migration shim. A deployment that cached under `/.storages` re-derives everything (workspace re-bootstraps from the header index; the projection cache refolds lazily) or moves the two json files by hand once. diff --git a/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.zh.md b/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.zh.md index 893b91c2ad..daef4f7661 100644 --- a/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.zh.md +++ b/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.zh.md @@ -18,7 +18,7 @@ Status: proposed ### 全局唯一存储根(已落地,形态修正);构造时 resolve 一次(仍开放) -- **已落地**:出厂 Web overlay 直接在 `storage-json` 行内用与会话根同一段 `!!js` IIFE 把 `root` 锚定到 `$DSH_HOME/storages`(默认 `~/.dsh/storages`,与 `~/.dsh/sessions` 并肩;目录名不带点——home 本身已是隐藏树)。用户拍板采用此形态取代本节初版的「launcher patch + `storageRoot` profile 键」(见 Alternatives);按行覆盖仍走个人 `~/.dsh/config.yaml` patch 层。该表达式与 sessions 根(`base.cordis.yml`)逐字重复是已知代价——两个消费者尚可接受;出现第三个 `$DSH_HOME` 派生根时提取单一来源(launcher 变量或共享表达式)。web e2e scaffold 本就把该行 patch 到临时绝对根,测试不触用户 home。 +- **已落地**:出厂 Web overlay 通过 app-boot 提供的 `dshHomePath('storages')`,直接在 `storage-json` 行内把 `root` 锚定到 `$DSH_HOME/storages`(默认 `~/.dsh/storages`,与 `~/.dsh/sessions` 并肩;目录名不带点——home 本身已是隐藏树)。该辅助函数委托给规范的 `dsh-paths` 解析器,会话根也使用同一个函数,无需重复其回退和波浪号规则。用户拍板采用这种按行形态取代本节初版的「launcher patch + `storageRoot` profile 键」(见 Alternatives);按行覆盖仍走个人 `~/.dsh/config.yaml` patch 层。web e2e scaffold 本就把该行 patch 到临时绝对根,测试不触用户 home。 - **仍开放**:`JsonStorageBackend` 在构造时对配置根 `resolve` 一次,原样采纳 JSONL 后端已记录的理由:后续 `process.cwd()` 变化不得把一个后端劈到多个根下。SQLite 存储后端已经 resolve 其路径。 - 适用 pre-release 立场(已按此执行):不做迁移垫片。曾在 `/.storages` 下缓存过的部署要么全部重新派生(工作区从 header 索引重新 bootstrap;投影缓存惰性重折),要么手动把两个 json 文件挪一次。 diff --git a/README.i18n.yaml b/README.i18n.yaml index 6fbb7f5299..b492ed9c37 100644 --- a/README.i18n.yaml +++ b/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write README.md -README.md: 08ae0b3d5d2d7ad8e7cb62bd5e8b3242426735dc -README.zh.md: 9587215b6b17250504877b8930fdf431fd24b2ac +README.md: b447c9634189353854e8be9d0bf597a8b0c7e371 +README.zh.md: f8bbbc36bc670403c0b9a40977f32f598e77ee46 diff --git a/README.md b/README.md index 08ae0b3d5d..b447c96341 100644 --- a/README.md +++ b/README.md @@ -94,3 +94,5 @@ DeepSeek Harness is currently in internal testing. ## License [BSD 3-Clause](LICENSE) + +Third-party dependencies and their licenses are disclosed in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md). diff --git a/README.zh.md b/README.zh.md index 9587215b6b..f8bbbc36bc 100644 --- a/README.zh.md +++ b/README.zh.md @@ -98,3 +98,5 @@ DeepSeek Harness 目前处于内测阶段。 ## 许可证 [BSD 3-Clause](LICENSE) + +第三方依赖及其许可证在 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) 中披露。 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000000..075e1b34c4 --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,165 @@ + + +# Third-Party Notices + +DeepSeek Harness is licensed under [BSD 3-Clause](LICENSE). It depends on the third-party open-source software listed below. Each project remains under its own license; nothing in this file changes those terms. + +This file lists **direct** dependencies declared by the workspace. It is generated from the workspace manifests by `scripts/gen-third-party-notices.ts`: a pre-commit hook regenerates it whenever a staged file changes one of its inputs, and `scripts/gen-third-party-notices.spec.ts` asserts in the test lane that the committed bytes match. Deleting a manifest runs no hook, so that case is caught by the assertion instead. Run `pnpm run verify-third-party-notices` for the standalone check. + +The complete npm transitive closure, with exact pinned versions, is recorded in [`pnpm-lock.yaml`](pnpm-lock.yaml) — inspect it with `pnpm licenses list`. The Python closure is recorded in [`python/sdk/uv.lock`](python/sdk/uv.lock), and the Landlock launcher workspace keeps its own in [`native/landlock-run/pnpm-lock.yaml`](native/landlock-run/pnpm-lock.yaml). + +## Vendored source (`vendor/`) + +The Cordis framework and its foundation libraries are source-vendored into this repository rather than consumed from npm. All are MIT-licensed; each directory preserves its upstream `LICENSE` file. Exact upstream commits and local modifications are recorded in [`vendor/README.md`](vendor/README.md). + +| Package | Upstream | License | +| --- | --- | --- | +| `cosmokit` | [github.com/deepseek-harness/cosmokit](https://github.com/deepseek-harness/cosmokit) | MIT | +| `schemastery` | [github.com/deepseek-harness/schemastery](https://github.com/deepseek-harness/schemastery) | MIT | +| `cordis` | [github.com/cordiverse/cordis](https://github.com/cordiverse/cordis) | MIT | +| `@cordisjs/plugin-loader` | [github.com/cordiverse/cordis](https://github.com/cordiverse/cordis) | MIT | +| `@cordisjs/plugin-include` | [github.com/deepseek-harness/cordis](https://github.com/deepseek-harness/cordis) | MIT | +| `@cordisjs/plugin-group` | [github.com/deepseek-harness/cordis](https://github.com/deepseek-harness/cordis) | MIT | +| `@cordisjs/plugin-timer` | [github.com/deepseek-harness/cordis](https://github.com/deepseek-harness/cordis) | MIT | +| `@cordisjs/plugin-hmr` | [github.com/deepseek-harness/cordis](https://github.com/deepseek-harness/cordis) | MIT | +| `@cordisjs/plugin-logger-console` | [github.com/deepseek-harness/cordis](https://github.com/deepseek-harness/cordis) | MIT | + +## Runtime npm dependencies + +External packages that a workspace package resolves at runtime. `scripts/install.sh` installs this repository itself, so the tier covers every plugin a user can mount from `cordis.yml` — not only what the `dsh` CLI/TUI, the Web UI, and the Python SDK runtime load by default. + +| Package | License | +| --- | --- | +| [`@agentclientprotocol/sdk`](https://github.com/agentclientprotocol/typescript-sdk) | Apache-2.0 | +| [`@babel/code-frame`](https://github.com/babel/babel) | MIT | +| [`@clack/core`](https://github.com/bombshell-dev/clack) | MIT | +| [`@clack/prompts`](https://github.com/bombshell-dev/clack) | MIT | +| [`@earendil-works/pi-ai`](https://github.com/earendil-works/pi) | MIT | +| [`@earendil-works/pi-tui`](https://github.com/earendil-works/pi) | MIT | +| [`@joplin/turndown-plugin-gfm`](https://github.com/laurent22/joplin-turndown-plugin-gfm) | MIT | +| [`@modelcontextprotocol/sdk`](https://github.com/modelcontextprotocol/typescript-sdk) | MIT | +| [`@opentelemetry/api`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 | +| [`@opentelemetry/api-logs`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 | +| [`@opentelemetry/exporter-logs-otlp-http`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 | +| [`@opentelemetry/otlp-exporter-base`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 | +| [`@opentelemetry/resources`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 | +| [`@opentelemetry/sdk-logs`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 | +| [`@shikijs/langs`](https://github.com/shikijs/shiki) | MIT | +| [`@standard-schema/spec`](https://github.com/standard-schema/standard-schema) | MIT | +| [`anser`](https://github.com/IonicaBizau/anser) | MIT | +| [`chokidar`](https://github.com/paulmillr/chokidar) | MIT | +| [`clsx`](https://github.com/lukeed/clsx) | MIT | +| [`commander`](https://github.com/tj/commander.js) | MIT | +| [`diff`](https://github.com/kpdecker/jsdiff) | BSD-3-Clause | +| [`dotenv`](https://github.com/motdotla/dotenv) | BSD-2-Clause | +| [`eventsource-parser`](https://github.com/rexxars/eventsource-parser) | MIT | +| [`handlebars`](https://github.com/handlebars-lang/handlebars.js) | MIT | +| [`immer`](https://github.com/immerjs/immer) | MIT | +| [`js-yaml`](https://github.com/nodeca/js-yaml) | MIT | +| [`jsonc-parser`](https://github.com/microsoft/node-jsonc-parser) | MIT | +| [`koffi`](https://github.com/Koromix/koffi) | MIT | +| [`mdast-util-from-markdown`](https://github.com/syntax-tree/mdast-util-from-markdown) | MIT | +| [`mdast-util-gfm`](https://github.com/syntax-tree/mdast-util-gfm) | MIT | +| [`micromark-extension-gfm`](https://github.com/micromark/micromark-extension-gfm) | MIT | +| [`node-addon-require-builtin`](https://www.npmjs.com/package/node-addon-require-builtin) | MIT | +| [`node-pty`](https://github.com/microsoft/node-pty) | MIT | +| [`picomatch`](https://github.com/micromatch/picomatch) | MIT | +| [`react`](https://github.com/facebook/react) | MIT | +| [`react-dom`](https://github.com/facebook/react) | MIT | +| [`react-markdown`](https://github.com/remarkjs/react-markdown) | MIT | +| [`remark-gfm`](https://github.com/remarkjs/remark-gfm) | MIT | +| [`saxes`](https://github.com/lddubeau/saxes) | ISC | +| [`shiki`](https://github.com/shikijs/shiki) | MIT | +| [`supports-color`](https://github.com/chalk/supports-color) | MIT | +| [`tsx`](https://github.com/privatenumber/tsx) | MIT | +| [`turndown`](https://github.com/mixmark-io/turndown) | MIT | +| [`typescript`](https://github.com/microsoft/TypeScript) | Apache-2.0 | +| [`use-sync-external-store`](https://github.com/facebook/react) | MIT | +| [`yaml`](https://github.com/eemeli/yaml) | ISC | +| [`zod`](https://github.com/colinhacks/zod) | MIT | +| [`zustand`](https://github.com/pmndrs/zustand) | MIT | + +pnpm applies local patches to the following packages at install time, so shipped artifacts carry modified copies; each patch file is the complete record of the modification: + +- `@earendil-works/pi-tui@0.80.7` — [`patches/@earendil-works__pi-tui@0.80.7.patch`](patches/@earendil-works__pi-tui@0.80.7.patch) +- `node-pty@1.1.0` — [`patches/node-pty@1.1.0.patch`](patches/node-pty@1.1.0.patch) + +## Development-only npm dependencies + +External packages **directly declared** only by repository tooling, test infrastructure, the documentation site, the demo leaves, or the native launcher's build workspace. No shipped surface names them itself. A package here may still be pulled in transitively by a runtime dependency — `pnpm-lock.yaml` is the authority on the full closure — so this tier records who declares a package, not what a build ultimately bundles. + +| Package | License | +| --- | --- | +| [`@braintree/sanitize-url`](https://github.com/braintree/sanitize-url) | MIT | +| [`@modelcontextprotocol/server-everything`](https://github.com/modelcontextprotocol/servers) | MIT / Apache-2.0 | +| [`@modelcontextprotocol/server-filesystem`](https://github.com/modelcontextprotocol/servers) | MIT / Apache-2.0 | +| [`@stylistic/eslint-plugin`](https://github.com/eslint-stylistic/eslint-stylistic) | MIT | +| [`@testing-library/dom`](https://github.com/testing-library/dom-testing-library) | MIT | +| [`@testing-library/react`](https://github.com/testing-library/react-testing-library) | MIT | +| [`@types/babel__code-frame`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | +| [`@types/js-yaml`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | +| [`@types/jsdom`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | +| [`@types/mdast`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | +| [`@types/node`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | +| [`@types/picomatch`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | +| [`@types/react`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | +| [`@types/react-dom`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | +| [`@types/spdx-expression-parse`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | +| [`@types/turndown`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | +| [`@typescript-eslint/parser`](https://github.com/typescript-eslint/typescript-eslint) | MIT | +| [`@vitejs/plugin-react`](https://github.com/vitejs/vite-plugin-react) | MIT | +| [`@vitest/coverage-v8`](https://github.com/vitest-dev/vitest) | MIT | +| [`@xterm/headless`](https://github.com/xtermjs/xterm.js) | MIT | +| [`@yarnpkg/cli-dist`](https://github.com/yarnpkg/berry) | BSD-2-Clause | +| [`cytoscape`](https://github.com/cytoscape/cytoscape.js) | MIT | +| [`cytoscape-cose-bilkent`](https://github.com/cytoscape/cytoscape.js-cose-bilkent) | MIT | +| [`dayjs`](https://github.com/iamkun/dayjs) | MIT | +| [`debug`](https://github.com/debug-js/debug) | MIT | +| [`esbuild`](https://github.com/evanw/esbuild) | MIT | +| [`eslint`](https://github.com/eslint/eslint) | MIT | +| [`eslint-plugin-sonarjs`](https://github.com/SonarSource/SonarJS) | LGPL-3.0-only | +| [`execa`](https://github.com/sindresorhus/execa) | MIT | +| [`fast-check`](https://github.com/dubzzz/fast-check) | MIT | +| [`jscpd`](https://github.com/kucherenko/jscpd) | MIT | +| [`jsdom`](https://github.com/jsdom/jsdom) | MIT | +| [`knip`](https://github.com/webpro-nl/knip) | ISC | +| [`lefthook`](https://github.com/evilmartians/lefthook) | MIT | +| [`lightningcss`](https://github.com/parcel-bundler/lightningcss) | MPL-2.0 | +| [`mermaid`](https://github.com/mermaid-js/mermaid) | MIT | +| [`oxlint`](https://github.com/oxc-project/oxc) | MIT | +| [`oxlint-tsgolint`](https://github.com/oxc-project/tsgolint) | MIT | +| [`playwright`](https://github.com/microsoft/playwright) | Apache-2.0 | +| [`publint`](https://github.com/publint/publint) | MIT | +| [`smol-toml`](https://github.com/squirrelchat/smol-toml) | BSD-3-Clause | +| [`spdx-expression-parse`](https://github.com/jslicense/spdx-expression-parse.js) | MIT | +| [`tsdown`](https://github.com/rolldown/tsdown) | MIT | +| [`typescript-language-server`](https://github.com/typescript-language-server/typescript-language-server) | Apache-2.0 | +| [`vite`](https://github.com/vitejs/vite) | MIT | +| [`vite-tsconfig-paths`](https://github.com/aleclarson/vite-tsconfig-paths) | MIT | +| [`vitepress`](https://github.com/vuejs/vitepress) | MIT | +| [`vitepress-plugin-mermaid`](https://github.com/emersonbottero/vitepress-plugin-mermaid) | MIT | +| [`vitest`](https://github.com/vitest-dev/vitest) | MIT | + +`eslint-plugin-sonarjs` (LGPL-3.0-only) and `lightningcss` (MPL-2.0) run only as development tooling; their code is not linked into or distributed with any DeepSeek Harness artifact. + +## Python SDK dependencies (`python/`) + +Direct dependencies of the `pyproject.toml` manifests, plus `uv` as the development workflow tool. + +| Package | License | Role | +| --- | --- | --- | +| [`hatchling`](https://github.com/pypa/hatch) | MIT | build backend | +| [`pydantic`](https://github.com/pydantic/pydantic) | MIT | runtime dependency of `deepseek-harness` | +| [`pytest`](https://github.com/pytest-dev/pytest) | MIT | test-only | +| [`uv`](https://github.com/astral-sh/uv) | MIT / Apache-2.0 | development workflow tool | + +## Fetched at build time + +| Package | License | Role | +| --- | --- | --- | +| [`@yao-pkg/pkg`](https://github.com/yao-pkg/pkg) | MIT | invoked by `scripts/build-exe-for-python-sdk.ts` to assemble the single-file SDK runtime executable | + +## First-party sibling releases + +`node-addon-landlock-run` (and its platform packages) is released from a DeepSeek Harness sibling repository under BSD 3-Clause. It is listed here for completeness; it is first-party, not third-party. diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index 3836ea1260..97eb7c8c93 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: 3b2b8808b55e3882de9832029a0d5f1ac457d169 -README.zh.md: c2f998f1eb2cac737e8822205e0b71b1a951bbb1 +README.md: 0a684d49b6aba41b39233c5fecb14fb4216b470e +README.zh.md: 264326b68fbcb889ec9e85cdd004655edffae872 diff --git a/apps/cli/README.md b/apps/cli/README.md index 3b2b8808b5..0a684d49b6 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -27,6 +27,14 @@ Every surface also registers `web_search` and only `web_search`. Search uses Dee `DSH_TOOLS_MODE` selects the tool presentation mode for the whole Web/headless process: `native` (the schema default when unset), `code` (the `run_code`-only Code Mode wire), or `both`; any other value fails loud at boot through the `dsh-tools` config schema. It is a TEMPORARY seam — process-wide because Loader composition is static — and is removed once the web UI owns per-session tool-mode selection; the TUI surface ignores it and pins `native`. +[`core-web.cordis.yml`](config/core-web.cordis.yml) is an opt-in `dsh web --config` overlay that keeps the shipped Web host, browser, Workspace, persistence, and permission composition while reducing the default native model surface to owner-scoped persistent `bash` and `str_replace_editor`. The PTY backend and editor consume the existing Web sandbox and filesystem providers. An open persistent shell prevents changing that session's permission mode until the shell closes, so a shell created under wider access cannot survive a downgrade. `DSH_TOOLS_MODE` still controls native/Code Mode presentation for the resulting two-tool registry. + +From a source checkout, start this minimal Web profile with: + +```sh +pnpm run dsh web --config apps/cli/config/core-web.cordis.yml +``` + Every `dsh` surface — TUI, Web, and headless — reports session telemetry by default (the row lives in the shared `base.cordis.yml`): every session-log event streams as OTLP/HTTP log records to `https://harness-telemetry.deepseeksvc.com/v1/logs` on a 10-second batch cadence. `DSH_TELEMETRY_OTLP_URL` points the exporter at a different collector; setting `DSH_TELEMETRY_DISABLED` to ANY non-empty value — including `0` or `false` — disables the row before it loads (a privacy switch prefers off-by-mistake over on-by-mistake). No redaction rule is mounted in this composition yet: exported records are the raw captured copy, including message text, tool arguments and results, and the session's working-directory path. The deployment rulings live in the [web-telemetry-default-mount Agent Note](../../.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md). MCP servers are not a shipped default, because a default would have to name one: `@deepseek-ai/dsh-mcp-client` mounts exactly one server per row and spawns it as a child process, outside `ctx.bash` and so outside the sandbox policy. The package is a runtime dependency of this CLI, so an installed `dsh` can mount your own servers from `$DSH_HOME/config.yaml` or a `--config` overlay without a source checkout: diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index c2f998f1eb..264326b68f 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -27,6 +27,14 @@ Web 和无头界面启动 `base.cordis.yml` 与 `web.cordis.yml`,随后应用 `DSH_TOOLS_MODE` 为整个 Web/无头进程选择工具呈现模式:`native`(未设置时的 schema 默认值)、`code`(仅含 `run_code` 的 Code Mode 线路)或 `both`;任何其他值都会经由 `dsh-tools` 配置 schema 在启动时明确报错。它是一个临时 seam——Loader 组合是静态的,因此该设置作用于整个进程——待 Web UI 负责逐会话工具模式选择后便会移除;TUI 界面会忽略该变量并固定为 `native`。 +[`core-web.cordis.yml`](config/core-web.cordis.yml) 是一个可选启用的 `dsh web --config` 覆盖层:它保留已交付的 Web 宿主、浏览器、Workspace、持久化与权限组合,同时将默认的原生模型界面精简为以所有者为作用域的持久 `bash` 以及 `str_replace_editor`。PTY 后端和编辑器分别消费现有的 Web 沙箱与文件系统提供方。持久 shell 处于打开状态时,会阻止所属会话更改权限模式;因此,在较宽权限下创建的 shell 无法在降权后继续存活。`DSH_TOOLS_MODE` 仍控制由此得到的双工具注册表采用原生/Code Mode 呈现。 + +在源码 checkout 中,用以下命令启动这个精简 Web profile: + +```sh +pnpm run dsh web --config apps/cli/config/core-web.cordis.yml +``` + 每个 `dsh` 界面——TUI、Web 与无头——都默认上报会话遥测(该行位于共享的 `base.cordis.yml`):每条会话日志事件以 OTLP/HTTP 日志记录的形式、按 10 秒批处理节奏流向 `https://harness-telemetry.deepseeksvc.com/v1/logs`。`DSH_TELEMETRY_OTLP_URL` 可将 exporter 指向其他 collector;将 `DSH_TELEMETRY_DISABLED` 设为**任意非空值**——包括 `0` 或 `false`——都会在该行加载前将其关停(隐私开关取「宁可误关、不可误开」)。该组合当前未挂载任何脱敏规则:导出记录即原始捕获副本,包含消息正文、工具参数与结果、以及会话工作目录路径。部署口径见 [web-telemetry-default-mount Agent Note](../../.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md)。 MCP 服务器不是交付默认值,因为默认值必须点名一台:`@deepseek-ai/dsh-mcp-client` 每一行只挂载一台服务器,并把它作为子进程 spawn,该进程不经 `ctx.bash`,因此也不受沙箱策略约束。该包是本 CLI 的运行时依赖,所以已安装的 `dsh` 无需源码检出即可从 `$DSH_HOME/config.yaml` 或 `--config` 覆盖层挂载你自己的服务器: diff --git a/apps/cli/config/base.cordis.yml b/apps/cli/config/base.cordis.yml index 4817e0921a..5a671c39d8 100644 --- a/apps/cli/config/base.cordis.yml +++ b/apps/cli/config/base.cordis.yml @@ -84,8 +84,7 @@ - id: session-persistence-jsonl name: '@deepseek-ai/dsh-session-persistence-jsonl' config: - root: !!js >- - (() => { const path = process.getBuiltinModule('node:path'); const home = process.getBuiltinModule('node:os').homedir(); const configured = process.env.DSH_HOME; const selected = configured !== undefined && configured.trim().length > 0 ? configured : path.join(home, '.dsh'); const expanded = selected === '~' ? home : selected.startsWith('~/') || selected.startsWith('~\\') ? path.join(home, selected.slice(2)) : selected; return path.join(path.resolve(expanded), 'sessions') })() + root: !!js dshHomePath('sessions') # TUI consumes this shared session capability. Its launcher supplies a unique # process-local path; other surfaces repoint or disable the row in their diff --git a/apps/cli/config/core-web.cordis.yml b/apps/cli/config/core-web.cordis.yml new file mode 100644 index 0000000000..ef03b23fca --- /dev/null +++ b/apps/cli/config/core-web.cordis.yml @@ -0,0 +1,86 @@ +# Opt-in two-tool profile over the shipped Web composition. The default native +# model surface is exactly persistent `bash` plus `str_replace_editor`; the +# Web host, browser shell, workspace, persistence, and permission stack remain. + +# Disable every model-facing consumer in the base/Web tree. plan-mode owns the +# always-registered exit_plan_mode tool even while the session is not planning. +- id: tool-bash + disabled: true + +- id: tool-tasks + disabled: true + +- id: tool-fs + disabled: true + +- id: tool-fs-search + disabled: true + +- id: tool-web + disabled: true + +- id: tool-skill + disabled: true + +- id: plan-mode + disabled: true + +- id: tool-subagent + disabled: true + +- id: tool-subagent-fork + disabled: true + +- id: tool-workflow + disabled: true + +- id: tool-todo + disabled: true + +# These consumers are shared defaults on the ordinary shipped surfaces, but +# this opt-in profile keeps exactly its two named tools. +- id: tool-goal + disabled: true + +- id: tool-ralph + disabled: true + +- id: tool-session-query + disabled: true + +- id: tool-str-replace-editor + disabled: true + +# The matching browser controls must not offer host tools that this profile +# omits. ui-question's host half owns the ask_user_question registration. +- id: ui-plan + disabled: true + +- id: ui-question + disabled: true + +- insert: + - id: pty + name: '@deepseek-ai/dsh-pty' + + # This backend consumes the existing Web sandbox and permission policy. + # It loads only on Linux/macOS; Windows and other platforms fail at boot. + # Its 300s send wait matches the persistent Bash command timeout instead of + # pty-local's 30s default. An open persistent shell fences permission-mode + # changes until it closes. + - id: pty-local + name: '@deepseek-ai/dsh-pty-local' + config: + timeoutMs: 300000 + + - id: persistent-bash + name: '@deepseek-ai/dsh-tool-bash-persistent' + config: + timeoutMs: 300000 + + # The editor consumes the Web fs-sandbox provider and therefore retains + # the selected session permission mode. + - id: str-replace-editor + name: '@deepseek-ai/dsh-tool-str-replace-editor' + config: + maxOutputChars: 16000 diff --git a/apps/cli/config/web.cordis.yml b/apps/cli/config/web.cordis.yml index 10c2209184..f0c2e5ea28 100644 --- a/apps/cli/config/web.cordis.yml +++ b/apps/cli/config/web.cordis.yml @@ -97,8 +97,7 @@ - id: storage-json name: '@deepseek-ai/dsh-storage-json' config: - root: !!js >- - (() => { const path = process.getBuiltinModule('node:path'); const home = process.getBuiltinModule('node:os').homedir(); const configured = process.env.DSH_HOME; const selected = configured !== undefined && configured.trim().length > 0 ? configured : path.join(home, '.dsh'); const expanded = selected === '~' ? home : selected.startsWith('~/') || selected.startsWith('~\\') ? path.join(home, selected.slice(2)) : selected; return path.join(path.resolve(expanded), 'storages') })() + root: !!js dshHomePath('storages') - id: storage-domain name: '@deepseek-ai/dsh-storage-domain' diff --git a/apps/cli/package.json b/apps/cli/package.json index ff0c34f306..35763de03e 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -73,6 +73,8 @@ "@deepseek-ai/dsh-permission": "workspace:^", "@deepseek-ai/dsh-plan-mode": "workspace:^", "@deepseek-ai/dsh-repeat-tool-guard": "workspace:^", + "@deepseek-ai/dsh-pty": "workspace:^", + "@deepseek-ai/dsh-pty-local": "workspace:^", "@deepseek-ai/dsh-sandbox-local": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", @@ -106,6 +108,7 @@ "@deepseek-ai/dsh-token-meter": "workspace:^", "@deepseek-ai/dsh-tool-ask-user": "workspace:^", "@deepseek-ai/dsh-tool-bash": "workspace:^", + "@deepseek-ai/dsh-tool-bash-persistent": "workspace:^", "@deepseek-ai/dsh-tool-cordis": "workspace:^", "@deepseek-ai/dsh-tool-fs": "workspace:^", "@deepseek-ai/dsh-tool-fs-search": "workspace:^", diff --git a/apps/cli/src/bin.ts b/apps/cli/src/bin.ts index aeaaf42692..a1e46ccb1e 100644 --- a/apps/cli/src/bin.ts +++ b/apps/cli/src/bin.ts @@ -49,13 +49,13 @@ switch (invocation.mode) { break } case 'meta': { - const { runMeta } = await import('./tui.ts') - await runMeta() + const { runTui, SOURCE_ROOT } = await import('./tui.ts') + await runTui(undefined, undefined, SOURCE_ROOT) break } case 'upgrade': { - const { runSkillSession } = await import('./tui.ts') - await runSkillSession(`dsh-${invocation.mode}`) + const { runTui } = await import('./tui.ts') + await runTui(undefined, undefined, undefined, `dsh-${invocation.mode}`) break } default: diff --git a/apps/cli/src/tui.ts b/apps/cli/src/tui.ts index dee865a0e1..7809e9ec1c 100644 --- a/apps/cli/src/tui.ts +++ b/apps/cli/src/tui.ts @@ -8,12 +8,10 @@ * from it, so `dsh` acts on whatever project it is launched in. Session storage * is the exception — it lives under the Harness home so `/resume` reaches every * workspace, and an in-place resume enters the selected session's own directory. - * `dsh meta` - * ({@link runMeta}) is the one exception — it makes this harness checkout the - * workspace. `dsh upgrade` ({@link runSkillSession}) is a fresh - * session whose first turn auto-invokes a bundled skill. After boot, the - * agent's system prompt is told the path to this harness checkout so it can - * find its own source. + * `dsh meta` is the one exception — it makes this harness checkout the + * workspace. `dsh upgrade` is a fresh session whose first turn auto-invokes a + * bundled skill. After boot, the agent's system prompt is told the path to this + * harness checkout so it can find its own source. * @module @deepseek-ai/dsh/tui */ @@ -61,30 +59,11 @@ const SESSION_QUERY_DB = `session-query-${String(process.pid)}-${randomUUID()}.d // The harness checkout root: three hops up from apps/cli/{src,lib}, resolved // from this bin's location so it holds however `dsh` is launched (a PATH // symlink, an arbitrary cwd). The agent is told where its own source lives. -const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url)) +/** The harness checkout used as the `dsh meta` workspace and source prompt path. */ +export const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url)) /* v8 ignore start -- composition over the unit-tested dsh-app-boot helpers; the CLI PTY smoke drives this path end to end, personal overlay included */ -/** - * Run the interactive TUI with this harness checkout as the workspace - * (`dsh meta`), whatever directory it was launched from. - */ -export async function runMeta(): Promise { - return runTui(undefined, undefined, SOURCE_ROOT) -} - -/** - * Run the interactive TUI as a guided fresh session whose first turn invokes a - * bundled skill (`dsh upgrade` → `dsh-upgrade`). - * Always mints a fresh session in the invoking directory; the skill is seeded - * only on this first launch, so a later `--resume` of the session is an ordinary - * TUI session with no re-injection. - * @param skill - the bundled skill name to auto-invoke as the first turn. - */ -export async function runSkillSession(skill: string): Promise { - return runTui(undefined, undefined, undefined, skill) -} - /** * Run the interactive TUI from the invoking directory. * @param config - an overlay patch list applied over the shared base and the @@ -99,9 +78,8 @@ export async function runSkillSession(skill: string): Promise { * @param workspace - a directory to make the workspace instead of the invoking * one, or `undefined` to keep the cwd. Only `dsh meta` passes it. * @param initialSkill - a bundled skill to auto-invoke as a fresh session's - * first turn, or `undefined`. Set only by {@link runSkillSession} and ignored - * on a resume, so it never re-fires; reaches the app through - * {@link INITIAL_SKILL_KEY}. + * first turn, or `undefined`. Set only by `dsh upgrade` and ignored on a resume, + * so it never re-fires; reaches the app through {@link INITIAL_SKILL_KEY}. * @param configReplace - a config path to boot as the ENTIRE tree, bypassing the * shared base, the TUI overlay, and the personal overlay alike, or `undefined` * to compose them; already parsed from `--config-replace`. diff --git a/apps/cli/tests/fixtures/memory-mcp-base.cordis.yml b/apps/cli/tests/fixtures/memory-mcp-base.cordis.yml new file mode 100644 index 0000000000..00878d6c98 --- /dev/null +++ b/apps/cli/tests/fixtures/memory-mcp-base.cordis.yml @@ -0,0 +1,8 @@ +# Minimal keyless composition for loading example MCP overlays against the +# package-owned fixture server in memory-mcp-configs.spec.ts. Source builtins +# keep this unit test independent of prebuilt workspace artifacts. +- id: system-prompt + name: cordis:memory-test-system-prompt + +- id: tools + name: cordis:memory-test-tools diff --git a/apps/cli/tests/memory-mcp-configs.spec.ts b/apps/cli/tests/memory-mcp-configs.spec.ts new file mode 100644 index 0000000000..7069b22738 --- /dev/null +++ b/apps/cli/tests/memory-mcp-configs.spec.ts @@ -0,0 +1,132 @@ +/** + * The third-party memory examples stay config-only. This suite parses every + * checked-in overlay, verifies its pin/transport/secret boundary, then replaces + * only the upstream endpoint with the package-owned keyless MCP fixture and + * proves the real Cordis Loader discovers a tool through the generic bridge. + */ + +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import type { Context } from 'cordis' +import type { PatchOptions } from '@cordisjs/plugin-include' +import { boot, loadOverlayPatches } from '@deepseek-ai/dsh-app-boot' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import * as McpClient from '@deepseek-ai/dsh-mcp-client/src/index.ts' + +interface ExampleContract { + file: string + id: string + serverName: string + transport: 'stdio' | 'streamable-http' + pin: string +} + +interface InsertedRow { + id?: string + name?: string + config?: Record +} + +const root = resolve(import.meta.dirname, '../../..') +const exampleDir = resolve(root, 'examples/mcp-memory') +const baseConfig = resolve(import.meta.dirname, 'fixtures/memory-mcp-base.cordis.yml') +const fixtureServer = resolve(root, 'packages/mcp/mcp-client/tests/fixture-server.ts') + +const examples: ExampleContract[] = [ + { + file: 'memorix.cordis.yml', + id: 'memory-memorix', + serverName: 'memorix', + transport: 'stdio', + pin: '1.3.0', + }, + { + file: 'mcp-reference-memory.cordis.yml', + id: 'memory-mcp-reference', + serverName: 'reference_memory', + transport: 'stdio', + pin: '2026.7.4', + }, + { + file: 'engram.cordis.yml', + id: 'memory-engram', + serverName: 'engram', + transport: 'stdio', + pin: '1.20.0', + }, +] + +const liveContexts = new Set() + +afterEach(async () => { + await Promise.all([...liveContexts].map(async ctx => ctx.fiber.dispose())) + liveContexts.clear() +}) + +function insertedRow(patches: PatchOptions[]): InsertedRow { + expect(patches).toHaveLength(1) + const insert = patches[0]?.insert + expect(insert).toHaveLength(1) + return insert?.[0] as InsertedRow +} + +async function waitForTool(ctx: Context, name: string): Promise { + const deadline = Date.now() + 10_000 + while (!ctx.tools.schemas().some(schema => schema.name === name)) { + if (Date.now() >= deadline) throw new Error(`timed out waiting for ${name}`) + await new Promise(resolveWait => setTimeout(resolveWait, 25)) + } +} + +describe('third-party memory MCP example overlays', () => { + it.each(examples)('parses $file with the documented generic boundary', (contract) => { + const file = resolve(exampleDir, contract.file) + const source = readFileSync(file, 'utf8') + const row = insertedRow(loadOverlayPatches('memory-mcp-config-test', file)) + + expect(row.id).toBe(contract.id) + expect(row.name).toBe('@deepseek-ai/dsh-mcp-client') + expect(row.config?.serverName).toBe(contract.serverName) + expect(row.config?.transport).toBe(contract.transport) + expect(source.split('\n', 1)[0]).toContain(contract.pin) + expect(source).not.toMatch(/\bsk-[A-Za-z0-9_-]{8,}\b/) + expect(source).not.toContain('DEEPSEEK_API_KEY') + }) + + it.each(examples)('loads $file and discovers a keyless fixture tool', async (contract) => { + const patches = loadOverlayPatches( + 'memory-mcp-config-test', + resolve(exampleDir, contract.file), + ) + // The static config gate verifies the checked-in bare package specifier. + // The unit test maps it to the source module so a clean checkout needs no + // prebuilt `lib/` artifacts before proving the Loader/MCP behavior. + insertedRow(patches).name = 'cordis:memory-test-mcp-client' + const fixturePatch: PatchOptions = { + id: contract.id, + config: { + serverName: contract.serverName, + transport: 'stdio', + command: process.execPath, + args: [fixtureServer], + env: {}, + cwd: root, + toolCallTimeoutMs: 5_000, + }, + } + const ctx = await boot( + 'memory-mcp-config-test', + baseConfig, + [...patches, fixturePatch], + (ctx) => { + liveContexts.add(ctx) + ctx.loader.builtins['memory-test-system-prompt'] = SystemPrompt + ctx.loader.builtins['memory-test-tools'] = ToolRegistry + ctx.loader.builtins['memory-test-mcp-client'] = McpClient + }, + ) + await waitForTool(ctx, `mcp__${contract.serverName}__greet`) + }, 15_000) +}) diff --git a/apps/web/tests/access-confirmation.e2e.ts b/apps/web/tests/access-confirmation.e2e.ts index d4669bc142..fae329362b 100644 --- a/apps/web/tests/access-confirmation.e2e.ts +++ b/apps/web/tests/access-confirmation.e2e.ts @@ -2,6 +2,7 @@ // the same locale-aware, in-page risk confirmation. Zero model calls: the // scenario boots the shipped Web composition and exercises the real // permission projection, client command path, HTTP RPC, and pushed update. +import { mkdirSync } from 'node:fs' import { fileURLToPath } from 'node:url' import { join } from 'node:path' import type { Browser, Page } from 'playwright' @@ -19,13 +20,16 @@ import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts' * boots; this scenario deliberately keeps zh, so the localized picker * copy is the anchor set). */ -async function connectFreshWorkspaceZh(page: Page, name = 'workspace'): Promise { +async function connectFreshWorkspaceZh(page: Page, root: string, name = 'workspace'): Promise { + mkdirSync(join(root, name), { recursive: true }) await page.getByRole('button', { name: '选择工作区' }).click() - await page.getByRole('menuitem', { name: '新建工作区' }).click() - const dialog = page.getByRole('dialog', { name: '新建工作区' }) + const dialog = page.getByRole('dialog', { name: '选择工作区目录' }) await dialog.waitFor({ timeout: 10_000 }) - await dialog.getByLabel('新工作区名称').fill(name) - await dialog.getByRole('button', { name: '创建工作区' }).click() + await dialog.getByRole('button', { name: '编辑路径' }).click() + const pathInput = dialog.getByRole('textbox', { name: '编辑路径' }) + await pathInput.fill(join(root, name)) + await pathInput.press('Enter') + await dialog.getByRole('button', { name: '打开', exact: true }).click() await page.locator('textarea:enabled[placeholder="描述你想要构建的内容"]') .waitFor({ timeout: 15_000 }) } @@ -53,7 +57,7 @@ describe('web e2e: Full access confirmation', () => { tripwire = watchConsole(page) await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) - await connectFreshWorkspaceZh(page) + await connectFreshWorkspaceZh(page, scaffold.workspaceCwd) }, 120_000) afterAll(async () => { diff --git a/apps/web/tests/approval-composer.e2e.ts b/apps/web/tests/approval-composer.e2e.ts index b66277e0b2..7a2a684765 100644 --- a/apps/web/tests/approval-composer.e2e.ts +++ b/apps/web/tests/approval-composer.e2e.ts @@ -61,7 +61,7 @@ describe('web e2e: approval takeover keeps its actions reachable', () => { tripwire = watchConsole(page) await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) - await connectFreshWorkspace(page) + await connectFreshWorkspace(page, scaffold.workspaceCwd) }, 120_000) afterAll(async () => { diff --git a/apps/web/tests/built-boot.snapshot.ts b/apps/web/tests/built-boot.snapshot.ts index 6fa5aec1ab..d2f6d913dd 100644 --- a/apps/web/tests/built-boot.snapshot.ts +++ b/apps/web/tests/built-boot.snapshot.ts @@ -112,11 +112,19 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn }, { timeout: 10_000 }) // The write/edit turns render a real diff card through the assembled graph - // (the keyed FileMutationRow + DiffBlock), not just the fixture's raw text. - // The write turn's `hello fixture\n` proves the terminator rule end to end: a - // trailing newline terminates its line, so the footer reads `+1` (not a - // phantom `+2`) and one distinct file. The `+ ` prefix is a CSS ::before, so - // it is absent from textContent — assert on the line body and the footer. + // (the keyed FileMutationRow composing ToolRow + DiffBlock), not just the + // fixture's raw text. The card is collapsed by default, so expand each edit/ + // write row first. The write turn's `hello fixture\n` proves the terminator + // rule end to end: a trailing newline terminates its line, so the footer reads + // `+1` (not a phantom `+2`) and one distinct file. The `+ ` prefix is a CSS + // ::before, so it is absent from textContent — assert on the line body and the + // footer. + const mutationRows = [...document.querySelectorAll('[data-variant="write"],[data-variant="edit"]')] + expect(mutationRows.length).toBeGreaterThan(0) + for (const row of mutationRows) { + const toggle = row.querySelector('[data-expandable]') + if (toggle !== null) act(() => { fireEvent.click(toggle) }) + } const diffCards = [...document.querySelectorAll('[data-diff]')] expect(diffCards.length).toBeGreaterThan(0) const footers = diffCards.map(card => card.textContent ?? '') @@ -125,13 +133,20 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn // The web render intent reaches the assembled boot graph: the fixture's // web_search / web_fetch turns render their keyed WebRow cards, proving the // registration, wire projection, and card rendering survive the real bundle - // path (not just the per-package src benches). The selector pins the KEYED - // WebRow (its own `data-variant="web"` wrapper), not the `[data-web]` attribute - // WebBlock draws — the generic fallback renders the same WebBlock, so a silent - // keyed-registration failure would still satisfy a bare `[data-web]` check. + // path (not just the per-package src benches). WebRow composes ToolRow, so the + // card is collapsed behind the row; the keyed row is pinned by its `data-tool` + // (ToolRow sets it from the wire tool name). + const webSearchRow = await waitFor(() => { + const row = document.querySelector('[data-tool="web_search"]') + expect(row).not.toBeNull() + expect(document.querySelector('[data-tool="web_fetch"]')).not.toBeNull() + return row! + }, { timeout: 10_000 }) + // Expand the web_search row to prove its WebBlock card renders end to end. + const webToggle = webSearchRow.querySelector('[data-expandable]') + if (webToggle !== null) act(() => { fireEvent.click(webToggle) }) await waitFor(() => { - expect(document.querySelector('[data-variant="web"][data-tool="web_search"]')).not.toBeNull() - expect(document.querySelector('[data-variant="web"][data-tool="web_fetch"]')).not.toBeNull() + expect(webSearchRow.querySelector('[data-web]')).not.toBeNull() }, { timeout: 10_000 }) // Every bundle injected its plugin-owned style tag (the loader's CSS path). diff --git a/apps/web/tests/code-mode-round.e2e.ts b/apps/web/tests/code-mode-round.e2e.ts index 78f27acaca..6a0379de94 100644 --- a/apps/web/tests/code-mode-round.e2e.ts +++ b/apps/web/tests/code-mode-round.e2e.ts @@ -49,7 +49,7 @@ describe('web e2e: Code Mode round renders nested sub-calls', () => { await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) // Fresh world: connect a Workspace so the composer scenarios start live. - await connectFreshWorkspace(page) + await connectFreshWorkspace(page, scaffold.workspaceCwd) }, 120_000) afterAll(async () => { diff --git a/apps/web/tests/composer-draft-scroll.e2e.ts b/apps/web/tests/composer-draft-scroll.e2e.ts index 34c552401e..e7dc1da238 100644 --- a/apps/web/tests/composer-draft-scroll.e2e.ts +++ b/apps/web/tests/composer-draft-scroll.e2e.ts @@ -232,7 +232,7 @@ describe('web e2e: composer draft scrolling', () => { tripwire = watchConsole(page) await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) - await connectFreshWorkspace(page, 'composer-draft-scroll') + await connectFreshWorkspace(page, scaffold.workspaceCwd, 'composer-draft-scroll') await page.locator('textarea:enabled').first().fill(DRAFT) }, 180_000) diff --git a/apps/web/tests/cordis-tool-round.e2e.ts b/apps/web/tests/cordis-tool-round.e2e.ts index 66bc9c6900..a806fcaa67 100644 --- a/apps/web/tests/cordis-tool-round.e2e.ts +++ b/apps/web/tests/cordis-tool-round.e2e.ts @@ -66,7 +66,7 @@ describe('web e2e: Cordis tools use the generic row variants', () => { tripwire = watchConsole(page) await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) - await connectFreshWorkspace(page) + await connectFreshWorkspace(page, scaffold.workspaceCwd) }, 120_000) afterAll(async () => { diff --git a/apps/web/tests/core-web-profile.snapshot.ts b/apps/web/tests/core-web-profile.snapshot.ts new file mode 100644 index 0000000000..58f2a34858 --- /dev/null +++ b/apps/web/tests/core-web-profile.snapshot.ts @@ -0,0 +1,84 @@ +import { writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import type { AgentHandle } from '@deepseek-ai/dsh-agent' +import { CallId } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' +import { launchWebScaffold, type WebScaffold } from './scaffold.ts' + +const CORE_WEB_OVERLAY = fileURLToPath(new URL('../../cli/config/core-web.cordis.yml', import.meta.url)) + +describe('core Web profile', () => { + let scaffold: WebScaffold + let agentHandle: AgentHandle + + beforeAll(async () => { + scaffold = await launchWebScaffold({ + extraOverlayPath: CORE_WEB_OVERLAY, + toolsMode: 'native', + }) + agentHandle = await scaffold.ctx.agents.create({ + sessionId: SessionId('core-web-profile-smoke'), + meta: { cwd: scaffold.workspaceCwd }, + agentOptions: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + }) + }) + + afterAll(async () => { + const failures: unknown[] = [] + await agentHandle?.dispose().catch((error: unknown) => failures.push(error)) + await scaffold?.close().catch((error: unknown) => failures.push(error)) + if (failures.length === 1) throw failures[0] + if (failures.length > 1) throw new AggregateError(failures, 'core Web profile smoke teardown failed') + }) + + it('boots and executes both tools through the shipped Web composition', async () => { + const seedPath = join(scaffold.workspaceCwd, 'profile-smoke.txt') + await writeFile(seedPath, 'CORE_WEB_EDITOR_OK\n') + const signal = new AbortController().signal + const bash = await scaffold.ctx.tools.execute({ + signal, + callId: CallId('core-web-bash-smoke'), + name: 'bash', + arguments: { command: "printf 'CORE_WEB_BASH_OK\\n'" }, + agent: agentHandle.agent, + }) + const editor = await scaffold.ctx.tools.execute({ + signal, + callId: CallId('core-web-editor-smoke'), + name: 'str_replace_editor', + arguments: { command: 'view', path: seedPath }, + agent: agentHandle.agent, + }) + + const text = (result: typeof bash): string => result.content + .filter(block => block.type === 'text') + .map(block => block.text) + .join('') + .replaceAll(scaffold.workspaceCwd, '{{cwd}}') + .trimEnd() + + expect({ + tools: scaffold.ctx.tools.schemas().map(tool => tool.name), + bash: text(bash), + editor: text(editor), + }).toMatchInlineSnapshot(` + { + "bash": "CORE_WEB_BASH_OK", + "editor": "Here's the content of {{cwd}}/profile-smoke.txt with line numbers (which has a total of 2 lines): + 1 CORE_WEB_EDITOR_OK + 2", + "tools": [ + "bash", + "str_replace_editor", + ], + } + `) + + const entries = [...scaffold.ctx.loader.entries()] + expect(entries.find(entry => entry.options.id === 'persistent-bash')?.fiber).toBeDefined() + expect(entries.find(entry => entry.options.id === 'pty-local')?.fiber).toBeDefined() + expect(entries.find(entry => entry.options.id === 'str-replace-editor')?.fiber).toBeDefined() + }) +}) diff --git a/apps/web/tests/details-session-lifecycle.e2e.ts b/apps/web/tests/details-session-lifecycle.e2e.ts index ce97cfb2a1..cb6c9ba914 100644 --- a/apps/web/tests/details-session-lifecycle.e2e.ts +++ b/apps/web/tests/details-session-lifecycle.e2e.ts @@ -80,7 +80,7 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S tripwire = watchConsole(page) await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) await appFrame(page).waitFor({ timeout: 30_000 }) - await connectFreshWorkspace(page) + await connectFreshWorkspace(page, scaffold.workspaceCwd) }, 120_000) afterAll(async () => { diff --git a/apps/web/tests/lifecycle-chrome.e2e.ts b/apps/web/tests/lifecycle-chrome.e2e.ts index 97ffefccf7..e285b0f0ac 100644 --- a/apps/web/tests/lifecycle-chrome.e2e.ts +++ b/apps/web/tests/lifecycle-chrome.e2e.ts @@ -50,7 +50,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) // Fresh world: connect a Workspace so the composer scenarios start live. - await connectFreshWorkspace(page) + await connectFreshWorkspace(page, scaffold.workspaceCwd) }, 120_000) afterAll(async () => { @@ -93,7 +93,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () try { await activePage.goto(activeScaffold.baseUrl, { waitUntil: 'load' }) await activePage.waitForSelector('[class*="frame"]', { timeout: 30_000 }) - await connectFreshWorkspace(activePage) + await connectFreshWorkspace(activePage, activeScaffold.workspaceCwd) const input = activePage.locator('textarea').first() await activePage.getByRole('button', { name: 'Commands' }).click() const menu = activePage.getByRole('listbox', { name: 'Trigger suggestions' }) @@ -172,10 +172,10 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () await expect.poll(() => page.getByText('1 session', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1) await expect.poll(() => page.locator('[role="treeitem"][aria-selected="true"]').count(), { timeout: 10_000 }).toBe(1) await expect.poll(() => page.getByText('LIGHTHOUSE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1) - // Host: the session's durable header cwd is the workspace flow's - // create-by-name target (/workspace, the composer's - // default draft name) — the proof the send went through workspace - // materialization rather than a bare default-cwd session. + // Host: the session's durable header cwd is the folder the workspace + // flow created and adopted (/workspace) — the proof the + // send went through workspace materialization rather than a bare + // default-cwd session. const cwds = scaffold.ctx.sessions.list().map(session => session.header.cwd) expect(cwds).toEqual([join(scaffold.workspaceCwd, 'workspace')]) const turnEnds = sessionEvents.filter(e => e.type === 'turn/end') diff --git a/apps/web/tests/live-interactions.e2e.ts b/apps/web/tests/live-interactions.e2e.ts index 1707a6478f..186c71be11 100644 --- a/apps/web/tests/live-interactions.e2e.ts +++ b/apps/web/tests/live-interactions.e2e.ts @@ -96,7 +96,7 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => { await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) // Fresh world: connect a Workspace so the composer scenarios start live. - await connectFreshWorkspace(page) + await connectFreshWorkspace(page, scaffold.workspaceCwd) } /** diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts index 8ffeb25ad1..87e19a6915 100644 --- a/apps/web/tests/navigation-panes.e2e.ts +++ b/apps/web/tests/navigation-panes.e2e.ts @@ -43,7 +43,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { beforeAll(async () => { scaffold = await launchWebScaffold({}) - // The workspace-aware flow runs sessions in /workspace; + // The workspace-aware flow runs sessions in /workspace; // the read targets must live in that session cwd (pre-creation is safe: // create-by-name adopts an existing directory). const sessionCwd = join(scaffold.workspaceCwd, 'workspace') diff --git a/apps/web/tests/permission-policy-context.e2e.ts b/apps/web/tests/permission-policy-context.e2e.ts index 3dec5d1784..867685678f 100644 --- a/apps/web/tests/permission-policy-context.e2e.ts +++ b/apps/web/tests/permission-policy-context.e2e.ts @@ -79,7 +79,7 @@ describe('web e2e: current sandbox policy reaches the model before tools', () => tripwire = watchConsole(page) await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) - await connectFreshWorkspace(page) + await connectFreshWorkspace(page, scaffold.workspaceCwd) }, 120_000) afterAll(async () => { diff --git a/apps/web/tests/pin-browse-picker.overlay.yml b/apps/web/tests/pin-browse-picker.overlay.yml new file mode 100644 index 0000000000..266c35e94b --- /dev/null +++ b/apps/web/tests/pin-browse-picker.overlay.yml @@ -0,0 +1,11 @@ +# Loader overlay for the W5 real-host smoke (`dsh web --config`): pin the +# in-browser directory picker. The shipped row is `-auto`, which resolves to +# the native OS chooser on a loopback bind with a local display — an +# interaction a Playwright page cannot drive, so the resolved backend would +# otherwise decide whether the smoke passes. The disable+insert pair mirrors +# apps/web/tests/scaffold.ts. +- id: directory-picker + disabled: true +- insert: + - id: directory-picker-browse + name: '@deepseek-ai/dsh-host-directory-picker-browse' diff --git a/apps/web/tests/plan-review.e2e.ts b/apps/web/tests/plan-review.e2e.ts index 28272777fb..e37e3954ab 100644 --- a/apps/web/tests/plan-review.e2e.ts +++ b/apps/web/tests/plan-review.e2e.ts @@ -54,7 +54,7 @@ describe('web e2e: plan review takeover round trip', () => { tripwire = watchConsole(page) await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) - await connectFreshWorkspace(page) + await connectFreshWorkspace(page, scaffold.workspaceCwd) }, 120_000) afterAll(async () => { diff --git a/apps/web/tests/question-composer.e2e.ts b/apps/web/tests/question-composer.e2e.ts index ac4be25299..37f6964c73 100644 --- a/apps/web/tests/question-composer.e2e.ts +++ b/apps/web/tests/question-composer.e2e.ts @@ -49,7 +49,7 @@ describe('web e2e: resident question composer round trip', () => { await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) // Fresh world: connect a Workspace so the composer scenarios start live. - await connectFreshWorkspace(page) + await connectFreshWorkspace(page, scaffold.workspaceCwd) }, 120_000) afterAll(async () => { diff --git a/apps/web/tests/queue-actions.e2e.ts b/apps/web/tests/queue-actions.e2e.ts index 069ef398d9..57b8afb4ff 100644 --- a/apps/web/tests/queue-actions.e2e.ts +++ b/apps/web/tests/queue-actions.e2e.ts @@ -68,7 +68,7 @@ describe('web e2e: queue row actions', () => { const tripwire = watchConsole(page) await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) - await connectFreshWorkspace(page) + await connectFreshWorkspace(page, scaffold.workspaceCwd) onTestFailed(() => saveFailureShot(page, 'web-e2e-queue-actions')) const input = page.locator('textarea').first() diff --git a/apps/web/tests/replay-round-trip.e2e.ts b/apps/web/tests/replay-round-trip.e2e.ts index 01544ad71f..cf1121599b 100644 --- a/apps/web/tests/replay-round-trip.e2e.ts +++ b/apps/web/tests/replay-round-trip.e2e.ts @@ -48,7 +48,7 @@ describe('web e2e: fresh round trip through the real assembly', () => { await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) // Fresh world: connect a Workspace so the composer scenarios start live. - await connectFreshWorkspace(page) + await connectFreshWorkspace(page, scaffold.workspaceCwd) }, 120_000) afterAll(async () => { diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 49a6b0709e..ea39e2d29b 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -33,6 +33,7 @@ import Loader from '@cordisjs/plugin-loader' import Include, { type PatchOptions } from '@cordisjs/plugin-include' import { scrubRequestHeaders } from '@deepseek-ai/dsh-acp-snapshot' import { assertEntriesLoaded, loadOverlayPatches } from '@deepseek-ai/dsh-app-boot' +import { dshHomePath } from '@deepseek-ai/dsh-paths' import { WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_SETTINGS_NAMESPACE, WELCOME_NOTICE_VERSION, } from '@deepseek-ai/dsh-client-ui-settings-general' @@ -105,6 +106,12 @@ export interface WebScaffold { /** Options for {@link launchWebScaffold}. */ export interface LaunchOptions { + /** + * Optional product overlay applied after the shipped Web surface and before + * the scaffold's hermetic test patches, matching AppCLIEntry's `--config` + * ordering. + */ + extraOverlayPath?: string /** * Replay fixture (session.jsonl) served by the inserted dsh-llm-replay row * in replay/refresh modes; ignored in record mode (the real adapter @@ -214,8 +221,12 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise { await waitFor(() => { expect(document.querySelector('[data-sample="bash-global"]')).not.toBeNull() }, { timeout: 10_000 }) - // The grep turn's keyed SearchRow renders the card resident: wait for it. + // The grep turn's keyed SearchRow composes ToolRow: the card is collapsed + // by default, so wait for the summary row, then expand it to reach the card. await waitFor(() => { const tools = [...document.querySelectorAll('[data-tool]')].map(el => el.getAttribute('data-tool')) expect(tools, `tools present: ${tools.join(', ')}`).toContain('grep') }, { timeout: 10_000 }) - // `data-tool` sits on the summary row; the card and recovery footer are its - // siblings inside the SearchRow wrapper, so shape the wrapper (its parent). - const grepRow = document.querySelector('[data-tool="grep"]')!.parentElement! + // `data-tool` sits on the ToolRow root; the collapsed row is the expand + // toggle. Click it so the card and its recovery footer mount, then shape the + // whole row (the card lives inside ToolRow's body wrapper). + const grepRow = document.querySelector('[data-tool="grep"]')! + act(() => { fireEvent.click(grepRow.querySelector('[data-expandable]') ?? grepRow) }) + await waitFor(() => { + expect(grepRow.querySelector('[data-search]')).not.toBeNull() + }, { timeout: 10_000 }) const shape = cardShape(grepRow) if (refreshing) { mkdirSync(dirname(EXPECTED), { recursive: true }) diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index 56d911419a..9acb0fa136 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -127,7 +127,7 @@ describe('web e2e: seeded history renders through cold resume', () => { beforeAll(async () => { scaffold = await launchWebScaffold({}) - // The workspace-aware flow runs sessions in /workspace + // The workspace-aware flow runs sessions in /workspace // (the composer's default draft name); the read-tool targets must live in // that session cwd. Pre-creating the directory is safe: create-by-name // adopts an existing directory. diff --git a/apps/web/tests/skill-invocation-policy.e2e.ts b/apps/web/tests/skill-invocation-policy.e2e.ts index cb252c724b..143bc0d4db 100644 --- a/apps/web/tests/skill-invocation-policy.e2e.ts +++ b/apps/web/tests/skill-invocation-policy.e2e.ts @@ -84,7 +84,7 @@ describe('web e2e: skill invocation policy through the real host', () => { tripwire = watchConsole(page) await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) - await connectFreshWorkspace(page) + await connectFreshWorkspace(page, scaffold.workspaceCwd) }, 120_000) afterAll(async () => { diff --git a/apps/web/tests/smoke-real.e2e.ts b/apps/web/tests/smoke-real.e2e.ts index e19d7cfab1..4a104724fb 100644 --- a/apps/web/tests/smoke-real.e2e.ts +++ b/apps/web/tests/smoke-real.e2e.ts @@ -20,7 +20,7 @@ import { createServer } from 'node:http' import { createRequire } from 'node:module' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { pathToFileURL } from 'node:url' +import { fileURLToPath, pathToFileURL } from 'node:url' import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' @@ -472,7 +472,12 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke const tsxLoader = pathToFileURL(createRequire(join(REPO_ROOT, 'package.json')).resolve('tsx')).href child = spawn( process.execPath, - ['--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', '--port', String(port)], + [ + '--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', '--port', String(port), + // Pin the in-browser picker: the shipped `-auto` row would resolve to + // the native OS chooser on this bind, and no page can drive that. + '--config', fileURLToPath(new URL('./pin-browse-picker.overlay.yml', import.meta.url)), + ], { cwd: sessionsDir, env: { @@ -513,8 +518,18 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke it('2+3 empty-state first send completes a real model round', async () => { onTestFailed(() => saveFailureShot(page, 'w5-first-round')) + // This scenario spawns its own server against a fresh $DSH_HOME, so the + // first-run welcome notice is unacknowledged and its overlay owns pointer + // events (the shared scaffold acknowledges it before boot instead). The + // notice is anchored structurally, not by its copy: this spec sits in the + // client TypeScript program, which does not reference the package that + // owns the strings. + const welcome = page.locator('[class*="onboardingOverlay"]') + await welcome.waitFor({ timeout: 15_000 }) + await welcome.getByRole('button').click() + await welcome.waitFor({ state: 'detached', timeout: 15_000 }) // Fresh world: connect a Workspace so the composer starts live. - await connectFreshWorkspace(page) + await connectFreshWorkspace(page, sessionsDir) const input = page.locator('textarea').first() await input.waitFor({ timeout: 10_000 }) await screen(page, '02-empty-state') diff --git a/apps/web/tests/snapshots/code-mode-round/ui.expected.md b/apps/web/tests/snapshots/code-mode-round/ui.expected.md index 5095ea278d..183bd366a0 100644 --- a/apps/web/tests/snapshots/code-mode-round/ui.expected.md +++ b/apps/web/tests/snapshots/code-mode-round/ui.expected.md @@ -22,8 +22,10 @@ - img - text: Code Run bash echo and catch missing file read - img -- text: Bash Echo CODE_ROUND_OK 失败 Read -- button "missing.txt" +- text: Bash Echo CODE_ROUND_OK Failed +- 'button "Read Error: cannot read \"{{cwd}}/workspace/missing.txt\": not found"': + - img + - text: "Read Error: cannot read \"{{cwd}}/workspace/missing.txt\": not found" - button "Think The program ran successfully. Let me now reply DONE as instructed.": - img - img diff --git a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md index dc4cbca241..7b6432ef87 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md @@ -7,7 +7,7 @@ - text: Workspaces - button "Group by": - img -- button "Create workspace": +- button "Add workspace": - img - button "Search sessions": - img diff --git a/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md index 7f62e80503..4aad3112e1 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md @@ -7,7 +7,7 @@ - text: Workspaces - button "Group by": - img -- button "Create workspace": +- button "Add workspace": - img - button "Search sessions": - img diff --git a/apps/web/tests/snapshots/message-actions/ui.expected.md b/apps/web/tests/snapshots/message-actions/ui.expected.md index 34ce19a69f..613e9a3605 100644 --- a/apps/web/tests/snapshots/message-actions/ui.expected.md +++ b/apps/web/tests/snapshots/message-actions/ui.expected.md @@ -14,12 +14,16 @@ - img - img - text: Think The user wants me to read a.txt and b.txt, then reply with "DONE". Let me do both reads in parallel. -- img -- text: Read -- button "a.txt" -- img -- text: Read -- button "b.txt" +- button "Read a.txt": + - img + - img + - text: Read + - button "a.txt" +- button "Read b.txt": + - img + - img + - text: Read + - button "b.txt" - button "Think Both files have been read. a.txt contains \"alpha\" and b.txt contains \"beta\". I'll now reply with DONE as instructed.": - img - img diff --git a/apps/web/tests/snapshots/search-card/grep-card.expected.txt b/apps/web/tests/snapshots/search-card/grep-card.expected.txt index 3d0efb3ecd..ce95519f0b 100644 --- a/apps/web/tests/snapshots/search-card/grep-card.expected.txt +++ b/apps/web/tests/snapshots/search-card/grep-card.expected.txt @@ -5,7 +5,23 @@ file=packages/client/ui-conversation/src/client/toolviews/search-row.tsx4 line=16: export const DEFAULT_SEARCH_MAX_LINES = 16 line=138: export function SearchBlock(props: SearchBlockProps) { line=141: const [collapsed, setCollapsed] = useState>(() => new Set()) -line=73: const search = searchCardModel(block) -line=90: -line=113: ctx.slots.register({ name: 'conversation.chat.toolview', key: 'grep' }, SearchRow) -expand=… 其余 4 行 \ No newline at end of file +line=35: const search = searchCardModel(block) +line=52: search={search} +line=73: ctx.slots.register({ name: 'conversation.chat.toolview', key: 'grep', locale: NS }, SearchRow) +expand=… 其余 4 行 +recovery=Found 9 of 42 matches + +packages/client/ui-primitives/src/SearchBlock.tsx +Line 16: export const DEFAULT_SEARCH_MAX_LINES = 16 +Line 138: export function SearchBlock(props: SearchBlockProps) { +Line 141: const [collapsed, setCollapsed] = useState>(() => new Set()) +packages/client/ui-conversation/src/client/contract/search-card-model.ts +Line 24: export const CHAT_SEARCH_MAX_LINES = 8 +Line 60: export function searchCardModel(block: ToolCallBlock): SearchCardModel | null { +packages/client/ui-conversation/src/client/toolviews/search-row.tsx +Line 33: export function SearchRow({ toolName, block, inspect, t }: SearchRowProps) { +Line 35: const search = searchCardModel(block) +Line 52: search={search} +Line 73: ctx.slots.register({ name: 'conversation.chat.toolview', key: 'grep', locale: NS }, SearchRow) + +(Full grep result stored at: fixture://spill/grep-66. Read it to see every match.) \ No newline at end of file diff --git a/apps/web/tests/snapshots/seeded-history/command-row.expected.md b/apps/web/tests/snapshots/seeded-history/command-row.expected.md index bcbac2622e..0173726c38 100644 --- a/apps/web/tests/snapshots/seeded-history/command-row.expected.md +++ b/apps/web/tests/snapshots/seeded-history/command-row.expected.md @@ -13,12 +13,16 @@ - img - img - text: Think The user wants me to read a.txt and b.txt, then reply with "DONE". Let me do both reads in parallel. -- img -- text: Read -- button "a.txt" -- img -- text: Read -- button "b.txt" +- button "Read a.txt": + - img + - img + - text: Read + - button "a.txt" +- button "Read b.txt": + - img + - img + - text: Read + - button "b.txt" - button "Think Both files have been read. a.txt contains \"alpha\" and b.txt contains \"beta\". I'll now reply with DONE as instructed.": - img - img diff --git a/apps/web/tests/snapshots/seeded-history/ui.expected.md b/apps/web/tests/snapshots/seeded-history/ui.expected.md index e29d1e8b30..0062b6cfab 100644 --- a/apps/web/tests/snapshots/seeded-history/ui.expected.md +++ b/apps/web/tests/snapshots/seeded-history/ui.expected.md @@ -13,12 +13,16 @@ - img - img - text: Think The user wants me to read a.txt and b.txt, then reply with "DONE". Let me do both reads in parallel. -- img -- text: Read -- button "a.txt" -- img -- text: Read -- button "b.txt" +- button "Read a.txt": + - img + - img + - text: Read + - button "a.txt" +- button "Read b.txt": + - img + - img + - text: Read + - button "b.txt" - button "Think Both files have been read. a.txt contains \"alpha\" and b.txt contains \"beta\". I'll now reply with DONE as instructed.": - img - img diff --git a/apps/web/tests/snapshots/steering/mid-steer.expected.md b/apps/web/tests/snapshots/steering/mid-steer.expected.md index b41f6baae2..27b40ef442 100644 --- a/apps/web/tests/snapshots/steering/mid-steer.expected.md +++ b/apps/web/tests/snapshots/steering/mid-steer.expected.md @@ -17,6 +17,7 @@ - img - img - text: Think The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that. +- text: Running - button "Ask question waiting": - img - img diff --git a/apps/web/tests/snapshots/web-search-round/ui.expected.md b/apps/web/tests/snapshots/web-search-round/ui.expected.md index 09718300f2..37d53a0df6 100644 --- a/apps/web/tests/snapshots/web-search-round/ui.expected.md +++ b/apps/web/tests/snapshots/web-search-round/ui.expected.md @@ -13,13 +13,10 @@ - img - img - text: Context injection -- img -- text: Search DeepSeek Harness snapshot search -- list: - - listitem: - - link "Snapshot Search Result": - - /url: https://docs.example.test/search - - text: Snapshot search excerpt. 2026-07-31 +- button "Search DeepSeek Harness snapshot search": + - img + - img + - text: Search DeepSeek Harness snapshot search - paragraph: SEARCH_DONE - button "Copy": - img diff --git a/apps/web/tests/steering.e2e.ts b/apps/web/tests/steering.e2e.ts index 3cbae0b9ee..6bd369ea2c 100644 --- a/apps/web/tests/steering.e2e.ts +++ b/apps/web/tests/steering.e2e.ts @@ -72,7 +72,7 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => { await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) // Fresh world: connect a Workspace so the composer scenarios start live. - await connectFreshWorkspace(page) + await connectFreshWorkspace(page, scaffold.workspaceCwd) }, 120_000) afterAll(async () => { diff --git a/apps/web/tests/support.ts b/apps/web/tests/support.ts index d03915f851..4ccc18de9f 100644 --- a/apps/web/tests/support.ts +++ b/apps/web/tests/support.ts @@ -1,6 +1,7 @@ // Shared plumbing for the web smoke tests (dist location, free port, failure shots). import { existsSync, mkdirSync } from 'node:fs' import { createServer } from 'node:net' +import { join } from 'node:path' import { fileURLToPath } from 'node:url' import type { Browser, Page } from 'playwright' @@ -55,23 +56,32 @@ export function probeFreePort(): Promise { } /** - * Drive the hero's workspace picker through its create-by-name dialog until - * the live composer unlocks. A fresh world has no Workspace, so the boot + * Drive the hero's workspace picker through the composed directory dialog + * until the live composer unlocks. A fresh world has no Workspace, so the boot * lands in the locked view state (startup auto-selection has nothing to * select); every scenario that types into the composer must connect one - * first. The default name 'workspace' keeps the session header cwd at - * /workspace — the materialization proof several scenarios + * first. With nothing to list, the chip gesture raises the dialog directly — + * adding a workspace is the picker's only entry. The directory is staged here + * and adopted through the path editor, which is idempotent across the repeated + * connects a scenario may make; creating a folder from inside the dialog (the + * product's other half of the same route) is covered by + * workspace-management.e2e.ts. The default name 'workspace' keeps the session + * header cwd at /workspace, the materialization proof several scenarios * assert. * @param page - the page under test. - * @param name - workspace name typed into the create dialog. + * @param root - host directory the workspace folder is staged in (the scaffold's `workspaceCwd`). + * @param name - folder name staged and adopted as the workspace. */ -export async function connectFreshWorkspace(page: Page, name = 'workspace'): Promise { +export async function connectFreshWorkspace(page: Page, root: string, name = 'workspace'): Promise { + mkdirSync(join(root, name), { recursive: true }) await page.getByRole('button', { name: 'Choose workspace' }).click() - await page.getByRole('menuitem', { name: 'Create a new workspace' }).click() - const dialog = page.getByRole('dialog', { name: 'Create a new workspace' }) + const dialog = page.getByRole('dialog', { name: 'Select Workspace Directory' }) await dialog.waitFor({ timeout: 10_000 }) - await dialog.getByLabel('New workspace name').fill(name) - await dialog.getByRole('button', { name: 'Create workspace' }).click() + await dialog.getByRole('button', { name: 'Edit path' }).click() + const pathInput = dialog.getByRole('textbox', { name: 'Edit path' }) + await pathInput.fill(join(root, name)) + await pathInput.press('Enter') + await dialog.getByRole('button', { name: 'Open', exact: true }).click() // The pick connected the workspace: the blank session's live composer // replaces the locked placeholder and enables. await page.locator('textarea:enabled[placeholder="Describe what you want to build"]') diff --git a/apps/web/tests/web-search-round.e2e.ts b/apps/web/tests/web-search-round.e2e.ts index 804c7a70ca..e887fb8e35 100644 --- a/apps/web/tests/web-search-round.e2e.ts +++ b/apps/web/tests/web-search-round.e2e.ts @@ -109,7 +109,7 @@ describe('web e2e: shipped default web search', () => { tripwire = watchConsole(page) await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) - await connectFreshWorkspace(page) + await connectFreshWorkspace(page, scaffold.workspaceCwd) }, 120_000) afterAll(async () => { diff --git a/apps/web/tests/workspace-management.e2e.ts b/apps/web/tests/workspace-management.e2e.ts index 48483e512d..95d44d4162 100644 --- a/apps/web/tests/workspace-management.e2e.ts +++ b/apps/web/tests/workspace-management.e2e.ts @@ -1,17 +1,18 @@ -// Web e2e scenarios: workspace management — the create-by-name dialog, the -// rename round trip over the real wire (workspace.rename RPC + durable -// registry), duplicate-name pre-check, the flat "In one list" view with its -// persisted group-by preference, the session hover card, and the session -// archive round trip (row menu → workspace.archiveSession RPC → durable -// global set → row hidden across reload). Zero model calls: -// workspace.create/rename/archiveSession are host RPCs with no model -// involvement, and the one session row the flat/hover/archive scenarios need -// comes from a seeded fixture (the seeded-history seed reused verbatim — no -// new recording). +// Web e2e scenarios: workspace management — adding a workspace through the +// composed directory dialog (its own New folder affordance is the product's +// one creation route), the rename round trip over the real wire +// (workspace.rename RPC + durable registry), duplicate-name pre-check, the +// flat "In one list" view with its persisted group-by preference, the session +// hover card and row action menu, and the session archive round trip (row +// menu → workspace.archiveSession RPC → durable global set → row hidden +// across reload). Zero model calls: workspace.create/rename/archiveSession +// are host RPCs with no model involvement, and the one session row the +// flat/hover/menu/archive scenarios need comes from a seeded fixture (the +// seeded-history seed reused verbatim — no new recording). import { mkdir, readFile, stat, writeFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import { join } from 'node:path' -import type { Browser, Page } from 'playwright' +import type { Browser, Locator, Page } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import { SessionId } from '@deepseek-ai/dsh-session' @@ -28,29 +29,59 @@ const SEED = fileURLToPath(new URL('./snapshots/seeded-history/seed.jsonl', impo const MODE = webSnapshotMode() const BROWSER_EXPECTED = join(SNAPSHOT_DIR, 'directory-browser.expected.md') const SEED_ID = 'workspace-management-web-e2e' +// Both waits exceed ui-primitives' 200ms POINTER_GRACE_MS. Keep them coupled +// to that contract if the shared grace tuning changes. +const POINTER_TRANSIT_MS = 300 +const POINTER_HOLD_MS = 600 -describe('web e2e: workspace management (create / rename / flat view / hover card)', () => { +describe('web e2e: workspace management (create / rename / flat view / hover affordances)', () => { let scaffold: WebScaffold let browser: Browser let page: Page let tripwire: ReturnType /** - * Drive the in-app browser to a directory via its path-edit affordance, - * confirm it, and wait for the adoption to settle host-side (workspace - * registered + the flow's New-Session agent up), so later test steps can't - * race the in-flight blank-session attach. + * Raise the region header's directory dialog and drive it to a directory via + * the path-edit affordance. Adding is the header button's only action, so + * the click lands in the dialog with no menu in between. */ - async function openLocalFolder(path: string, options: { waitForAgent?: boolean } = {}): Promise { - const agentsBefore = scaffold.ctx.agents.list().length - await page.getByRole('button', { name: 'Create workspace' }).click() - await page.getByRole('menuitem', { name: 'Open local folder…' }).click() + async function browseTo(path: string): Promise { + await page.getByRole('button', { name: 'Add workspace' }).click() const dialog = page.getByRole('dialog', { name: 'Select Workspace Directory' }) await dialog.waitFor({ timeout: 10_000 }) await dialog.getByRole('button', { name: 'Edit path' }).click() await dialog.getByLabel('Edit path').fill(path) await dialog.getByLabel('Edit path').press('Enter') - await dialog.getByRole('button', { name: 'Open' }).click() + return dialog + } + + /** + * Create a folder inside `parent` through the dialog and adopt it — the + * product's only route to a brand-new workspace directory. + */ + async function addNewFolderWorkspace(parent: string, name: string): Promise { + const dialog = await browseTo(parent) + await dialog.getByRole('button', { name: 'New folder' }).click() + await page.getByLabel('Folder name').fill(name) + await page.getByRole('button', { name: 'Create', exact: true }).click() + // Creating selects the new folder in the listing; Open adopts it. + await dialog.getByRole('button', { name: 'Open', exact: true }).click() + await dialog.waitFor({ state: 'hidden', timeout: 10_000 }) + await expect.poll( + () => scaffold.ctx.workspace.resolveByPath(join(parent, name)), + { timeout: 10_000 }, + ).not.toBeUndefined() + } + + /** + * Adopt an existing directory, waiting for the adoption to settle host-side + * (workspace registered + the flow's New-Session agent up), so later test + * steps can't race the in-flight blank-session attach. + */ + async function adoptDirectory(path: string, options: { waitForAgent?: boolean } = {}): Promise { + const agentsBefore = scaffold.ctx.agents.list().length + const dialog = await browseTo(path) + await dialog.getByRole('button', { name: 'Open', exact: true }).click() await dialog.waitFor({ state: 'hidden', timeout: 10_000 }) await expect.poll( () => scaffold.ctx.workspace.resolveByPath(path), @@ -86,22 +117,17 @@ describe('web e2e: workspace management (create / rename / flat view / hover car await scaffold?.close() }) - it('creates two workspaces by name through the region-header dialog', async () => { + it('adds two workspaces through the dialog, each on a folder it created', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-create')) - const createByName = async (name: string): Promise => { - await page.getByRole('button', { name: 'Create workspace' }).click() - await page.getByRole('menuitem', { name: 'Create a new workspace' }).click() - const dialog = page.getByRole('dialog', { name: 'Create a new workspace' }) - await dialog.waitFor({ timeout: 10_000 }) - await dialog.getByLabel('New workspace name').fill(name) - await dialog.getByRole('button', { name: 'Create workspace' }).click() - await expect.poll(() => page.getByRole('dialog', { name: 'Create a new workspace' }).count(), { timeout: 10_000 }).toBe(0) + const add = async (name: string): Promise => { + await addNewFolderWorkspace(scaffold.workspaceCwd, name) // The real workspace materializes in the tree as a group row. await expect.poll(() => page.getByText(name, { exact: true }).count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1) } - await createByName('alpha-ws') - await createByName('beta-ws') - // Durable on the host: both registered, newest first (create prepends). + await add('alpha-ws') + await add('beta-ws') + // Durable on the host: both registered, newest first (create prepends), + // each titled after the folder the dialog made. const titles = scaffold.ctx.workspace.list().map(workspace => workspace.title) expect(titles.slice(0, 2)).toEqual(['beta-ws', 'alpha-ws']) expect(tripwire.pageErrors).toEqual([]) @@ -167,7 +193,7 @@ describe('web e2e: workspace management (create / rename / flat view / hover car collect() }) // Register the scaffold's existing project directory through the real UI. - await openLocalFolder(scaffold.workspaceCwd, { waitForAgent: true }) + await adoptDirectory(scaffold.workspaceCwd, { waitForAgent: true }) const workspace = await scaffold.ctx.workspace.resolveByPath(scaffold.workspaceCwd) if (workspace === undefined) throw new Error('GUI did not register the existing project directory') await workspace.attachSession(SessionId(SEED_ID)) @@ -225,7 +251,7 @@ describe('web e2e: workspace management (create / rename / flat view / hover car // Re-registering the exact deleted path immediately, without a reload, is // a supported reversible flow. It creates a fresh Workspace id without // re-adopting the retained Session. - await openLocalFolder(scaffold.workspaceCwd) + await adoptDirectory(scaffold.workspaceCwd) await expect.poll( () => scaffold.ctx.workspace.resolveByPath(scaffold.workspaceCwd), { timeout: 10_000 }, @@ -295,7 +321,7 @@ describe('web e2e: workspace management (create / rename / flat view / hover car collect() }) - await openLocalFolder(oldPath) + await adoptDirectory(oldPath) await expect.poll( () => scaffold.ctx.workspace.resolveByPath(oldPath), { timeout: 10_000 }, @@ -311,12 +337,7 @@ describe('web e2e: workspace management (create / rename / flat view / hover car .getByRole('button', { name: 'Delete workspace' }).click() await expect.poll(() => scaffold.ctx.workspace.get(oldWorkspace.id), { timeout: 10_000 }).toBeUndefined() - await page.getByRole('button', { name: 'Create workspace' }).click() - await page.getByRole('menuitem', { name: 'Create a new workspace' }).click() - const create = page.getByRole('dialog', { name: 'Create a new workspace' }) - await create.getByLabel('New workspace name').fill(title) - await create.getByRole('button', { name: 'Create workspace' }).click() - await expect.poll(() => create.count(), { timeout: 10_000 }).toBe(0) + await addNewFolderWorkspace(scaffold.workspaceCwd, title) const fresh = scaffold.ctx.workspace.list().find(workspace => workspace.title === title) expect(fresh?.id).toBeDefined() expect(fresh?.id).not.toBe(oldWorkspace.id) @@ -366,13 +387,7 @@ describe('web e2e: workspace management (create / rename / flat view / hover car process.env.HOME = scaffold.workspaceCwd process.env.USERPROFILE = scaffold.workspaceCwd try { - await page.getByRole('button', { name: 'Create workspace' }).click() - await page.getByRole('menuitem', { name: 'Open local folder…' }).click() - const dialog = page.getByRole('dialog', { name: 'Select Workspace Directory' }) - await dialog.waitFor({ timeout: 10_000 }) - await dialog.getByRole('button', { name: 'Edit path' }).click() - await dialog.getByLabel('Edit path').fill(staged) - await dialog.getByLabel('Edit path').press('Enter') + const dialog = await browseTo(staged) await expect.poll(() => dialog.getByText('alpha', { exact: true }).count(), { timeout: 10_000 }).toBe(1) const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd) await compareOrRefreshGolden(BROWSER_EXPECTED, snapshot, MODE) @@ -387,14 +402,17 @@ describe('web e2e: workspace management (create / rename / flat view / hover car expect(tripwire.pageErrors).toEqual([]) }, 60_000) - it('shows the session hover card after a dwell on the row', async () => { - onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-hover')) - // Expand Ungrouped to reveal the seeded session row, then dwell on it - // (the card opens after a 500ms hover delay, portaled to body). + /** + * Expand Ungrouped and return its seeded session row. The only visible child + * is the non-blank persisted Session; the blank Session created while + * adopting the Workspace stays hidden. + * @returns the session row locator, already present. + */ + async function seededSessionRow() { const ungroupedRow = page.getByText('Ungrouped', { exact: true }).locator('..').locator('..') const ungroupedSection = ungroupedRow.locator('..') - // Initial-current auto-expansion can race this following test's gesture; - // converge on expanded rather than assuming which update wins first. + // Initial-current auto-expansion can race this gesture; converge on + // expanded rather than assuming which update wins first. await expect.poll(async () => { if (await ungroupedRow.getAttribute('aria-expanded') !== 'true') { await page.getByText('Ungrouped', { exact: true }).click() @@ -402,17 +420,72 @@ describe('web e2e: workspace management (create / rename / flat view / hover car } return await ungroupedRow.getAttribute('aria-expanded') }, { timeout: 5_000 }).toBe('true') - // The only visible child is the non-blank persisted Session; the blank - // Session created while adopting the Workspace remains hidden. - const sessionRow = ungroupedSection.locator('[role="treeitem"]').nth(1) - await sessionRow.waitFor({ timeout: 10_000 }) + const row = ungroupedSection.locator('[role="treeitem"]').nth(1) + await row.waitFor({ timeout: 10_000 }) + return row + } + + it('shows the session hover card after a dwell on the row', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-hover')) + // Dwell on the seeded row; the card opens after a 500ms hover delay, + // portaled to body. + const sessionRow = await seededSessionRow() + const rowTitle = await sessionRow.locator('[class*="title"]').innerText() await sessionRow.hover() - // Card content: the full title plus the Idle status line (display-only - // card; no aria role — text anchors are the stable selector). + // Card content: the full title plus the Idle status line (no aria role — + // text anchors are the stable selector). await expect.poll(() => page.getByText('Idle', { exact: true }).count(), { timeout: 5_000 }).toBeGreaterThanOrEqual(1) - // Leaving the anchor closes it with no delay. + // The card is REACHABLE: it sits 8px off the row, so getting to it means + // crossing ground that belongs to neither. Hovering it must not dismiss + // it — the regression this scenario guards. + const card = page.getByRole('button', { name: `Copy: ${rowTitle}` }) + await card.hover() + await page.waitForTimeout(POINTER_HOLD_MS) + expect(await page.getByText('Idle', { exact: true }).count()).toBeGreaterThanOrEqual(1) + // The full title is the card's primary value: activating anywhere on the + // card writes it through the browser clipboard and localizes the success + // feedback through the English locale seat. + await page.context().grantPermissions(['clipboard-read', 'clipboard-write']) + const cardHeight = (await card.boundingBox())?.height + await card.click() + const copied = page.getByRole('status').getByText('Copied', { exact: true }) + await copied.waitFor({ timeout: 5_000 }) + await page.waitForTimeout(POINTER_HOLD_MS) + expect((await card.boundingBox())?.height).toBe(cardHeight) + expect(await copied.isVisible()).toBe(true) + expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(rowTitle) + // Leaving anchor and card together closes it after the grace. await page.getByRole('button', { name: 'Settings' }).hover() - await expect.poll(() => page.getByText('Idle', { exact: true }).count(), { timeout: 5_000 }).toBe(0) + await expect.poll(() => card.count(), { timeout: 5_000 }).toBe(0) + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) + + it('keeps an open row menu up while the pointer moves between trigger and list', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-row-menu')) + const sessionRow = await seededSessionRow() + // The trigger is display:none until its row hovers. + await sessionRow.hover() + const trigger = sessionRow.locator('button[aria-label^="Session actions for "]') + await trigger.click() + const item = page.getByRole('menuitem', { name: 'Rename' }) + await item.waitFor({ timeout: 5_000 }) + // Into the list, then back up to the trigger across the 4px gap below it: + // that return trip used to fire the list's pointerleave and close the + // menu, so a hesitating pointer lost it. Order matters — clicking leaves + // the pointer ON the trigger, so entering the list has to come first for + // the return to be a real departure. + await item.hover() + await page.waitForTimeout(POINTER_TRANSIT_MS) + await trigger.hover() + await page.waitForTimeout(POINTER_HOLD_MS) + expect(await page.getByRole('menuitem', { name: 'Rename' }).count()).toBe(1) + // ...and back down into the list, which must still be there to enter. + await item.hover() + await page.waitForTimeout(POINTER_HOLD_MS) + expect(await page.getByRole('menuitem', { name: 'Rename' }).count()).toBe(1) + // Pointer-leave dismissal still applies once the pointer genuinely leaves. + await page.getByRole('button', { name: 'Settings' }).hover() + await expect.poll(() => page.getByRole('menuitem', { name: 'Rename' }).count(), { timeout: 5_000 }).toBe(0) expect(tripwire.pageErrors).toEqual([]) }, 60_000) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 804044d1e8..79cc46238d 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -24,6 +24,7 @@ "exclude": [ "tests/scaffold.ts", "tests/scaffold-hermetic.e2e.ts", + "tests/core-web-profile.snapshot.ts", "tests/live-interactions.e2e.ts", "tests/question-composer.e2e.ts", "tests/approval-composer.e2e.ts", diff --git a/bin/dsh b/bin/dsh index f85f28a5cd..c578d78e74 100755 --- a/bin/dsh +++ b/bin/dsh @@ -20,6 +20,7 @@ root=$(CDPATH='' cd -- "$(dirname -- "$script")/.." && pwd) # ESM-only and the CJS resolver costs ~0.4s of startup). Absolute paths keep # both the hook and the tsconfig anchored to this checkout when the launcher # runs from any cwd, where bare `tsx/esm` would not resolve. -TSX_TSCONFIG_PATH="$root/tsconfig.json" \ +NODE_USE_ENV_PROXY=1 \ + TSX_TSCONFIG_PATH="$root/tsconfig.json" \ exec node --import "$root/node_modules/tsx/dist/esm/index.mjs" \ "$root/apps/cli/src/bin.ts" "$@" diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index c456a02a31..7d71b529fa 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/development.md -development.md: f58cad7d361def14667fa66017cb003b74d70749 -development.zh.md: 88ddd8483c234bdf1c1fd0bcda9df3ca02ea6fa4 +development.md: 22eb7915f621883a84688d70e2ccad2fee2dbbba +development.zh.md: 480cd323d4d2325974f472c734edab9ce7459e89 diff --git a/docs/development.md b/docs/development.md index f58cad7d36..22eb7915f6 100644 --- a/docs/development.md +++ b/docs/development.md @@ -83,7 +83,7 @@ DEEPSEEK_BASE_URL=https://... # optional lefthook is configured in `lefthook.yml` as a fast local checkpoint: -- `pre-commit` applies formatting-only ESLint fixes, validates the staged files with Oxlint and applies its native fixes, checks the staged diff for whitespace errors, and runs the vendor manifest guard. +- `pre-commit` applies formatting-only ESLint fixes, validates the staged files with Oxlint and applies its native fixes, regenerates `THIRD_PARTY_NOTICES.md` when a staged file is one of its inputs, checks the staged diff for whitespace errors, and runs the vendor manifest guard. - `pre-push` runs only the incremental repository typecheck (`tsc -b` over the root solution, covering both the host and client aggregates). The vendor manifest guard checks that changes under `vendor/*/src` are staged with the matching `vendor/README.md` manifest update. See `vendor/README.md` before editing vendored code. diff --git a/docs/development.zh.md b/docs/development.zh.md index 88ddd8483c..480cd323d4 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -83,7 +83,7 @@ DEEPSEEK_BASE_URL=https://... # optional lefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点: -- `pre-commit` 应用仅用于格式化的 ESLint 修复,使用 Oxlint 验证暂存文件并应用其原生修复,然后检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫; +- `pre-commit` 应用仅用于格式化的 ESLint 修复,使用 Oxlint 验证暂存文件并应用其原生修复,在暂存文件属于 `THIRD_PARTY_NOTICES.md` 的输入时重新生成该文件,然后检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫; - `pre-push` 只运行仓库增量类型检查(对根 solution 执行 `tsc -b`,覆盖 host 与 client 两个聚合)。 vendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`。 diff --git a/examples/README.i18n.yaml b/examples/README.i18n.yaml index 1956f0fd5e..ead468e816 100644 --- a/examples/README.i18n.yaml +++ b/examples/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 examples/README.md -README.md: a502f34128da497586d593f64d0ce1c05f68a067 -README.zh.md: e3c111bb6a8f67899b6f434345baa3b47640b7ee +README.md: 64e9804eb69367588e926791039c453b0a9aede9 +README.zh.md: dd26b9e3f35c2d3350da77ce04bd77b4660ced1b diff --git a/examples/README.md b/examples/README.md index a502f34128..64e9804eb6 100644 --- a/examples/README.md +++ b/examples/README.md @@ -4,6 +4,10 @@ English | [中文](README.zh.md) Runnable demos (not workspaces) that showcase how the harness is wired. Each example is a **thin leaf**: either a `cordis.yml` tree that picks swappable backends and loads one app package, or an **overlay** — a patch list `dsh --config` applies over the shipped composition ([`apps/cli/config/base.cordis.yml`](../apps/cli/config/base.cordis.yml) plus a surface overlay). Bundled compositions live in [`@deepseek-ai/dsh-cli-demo`](../packages/examples/cli-demo), [`@deepseek-ai/dsh-acp-demo`](../packages/examples/acp-demo), and their shared [`@deepseek-ai/dsh-agent-spine-demo`](../packages/examples/agent-spine-demo) bundle; the `dsh` surfaces use flat config trees instead. There is no `start.ts`; the terminal `demo:*` scripts boot through the [`dsh`](../apps/cli/README.md) CLI, and the headless/ACP scripts invoke the `cli-demo`/`acp-demo` bins. +## mcp-memory + +Three default-off reference overlays connect a memory MCP server through the generic MCP client. Pick one file and pass it to `dsh --config`; DSH does not install or configure the upstream memory system. See [mcp-memory/README.md](mcp-memory/README.md) for pinned prerequisites, identity mapping, the shared optional prompt, and the write → fresh-session recall → use verification recipe. + ## headless-agent A non-interactive agent demo that accepts one positional task, runs one complete model/tool turn on the `@deepseek-ai/dsh-cli-demo` app, persists a fresh session, prints `text`, `json`, or `stream-json`, and exits. diff --git a/examples/README.zh.md b/examples/README.zh.md index e3c111bb6a..dd26b9e3f3 100644 --- a/examples/README.zh.md +++ b/examples/README.zh.md @@ -4,6 +4,10 @@ 展示 harness 如何接线的可运行演示(不是 workspace)。每个示例都是一个 **轻量叶节点**:要么是一份选择可替换后端、加载一个应用包(package)的 `cordis.yml` 配置树,要么是一个 **overlay**——由 `dsh --config` 叠加到交付组合([`apps/cli/config/base.cordis.yml`](../apps/cli/config/base.cordis.yml) 加一份 surface overlay)之上的 patch 列表。成组的组合位于 [`@deepseek-ai/dsh-cli-demo`](../packages/examples/cli-demo)、[`@deepseek-ai/dsh-acp-demo`](../packages/examples/acp-demo) 及它们共享的 [`@deepseek-ai/dsh-agent-spine-demo`](../packages/examples/agent-spine-demo) 组合包中;`dsh` 的各 surface 则改用平铺 config tree。没有 `start.ts`;终端 `demo:*` 脚本通过 [`dsh`](../apps/cli/README.md) CLI(命令行界面)启动,无头/ACP(Agent Client Protocol)脚本则调用 `cli-demo`/`acp-demo` bin。 +## mcp-memory + +三份默认关闭的参考 overlay 通过通用 MCP 客户端连接一个记忆 MCP 服务器。选择其中一份文件传给 `dsh --config`;DSH 不负责安装或配置上游记忆系统。版本固定的前置条件、身份映射、可选的共用提示词,以及「写入 → 新会话召回 → 使用」验证流程详见 [mcp-memory/README.md](mcp-memory/README.md)。 + ## headless-agent 非交互式 agent(智能体)演示:接受一个位置任务,在 `@deepseek-ai/dsh-cli-demo` 应用上运行一个完整模型/工具轮次,持久化新会话,打印 `text`、`json` 或 `stream-json`,然后退出。 diff --git a/examples/mcp-memory/README.i18n.yaml b/examples/mcp-memory/README.i18n.yaml new file mode 100644 index 0000000000..def44e65e3 --- /dev/null +++ b/examples/mcp-memory/README.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 examples/mcp-memory/README.md +README.md: b5dd7ffc4ad248d38e108d9aa28c7c26e0c76913 +README.zh.md: 1249ae40bb344fc81836cb49d71dd5656457b1b3 diff --git a/examples/mcp-memory/README.md b/examples/mcp-memory/README.md new file mode 100644 index 0000000000..b5dd7ffc4a --- /dev/null +++ b/examples/mcp-memory/README.md @@ -0,0 +1,113 @@ +# Third-party memory MCP examples + +English | [中文](README.zh.md) + +These three **default-off reference configurations** connect one memory system to DSH through [`@deepseek-ai/dsh-mcp-client`](../../packages/mcp/mcp-client/README.md). Pick one, or copy the same generic MCP row for another server. + +These third-party configurations are provided as interoperability examples only. Their inclusion does not imply endorsement, recommendation, partnership, or ongoing support by DeepSeek. + +## What DSH does + +DSH parses the selected Cordis overlay, starts a configured stdio command or connects to a configured Streamable HTTP URL, discovers MCP tools, and exposes them as `mcp____`. DSH does **not** download the server, initialize its database, choose its model or embedding provider, create a cloud account, migrate vendor data, or supervise a separate HTTP service. For stdio, the generic client launches and stops the child with the DSH plugin lifecycle; for HTTP, the upstream service must already be running. + +The stdio bridge deliberately removes ambient credential-shaped and `DSH_*` variables before launching a child; other ambient variables remain inherited. Each example adds only the baseline override it needs. If an optional upstream feature needs another secret, add that variable to the row's `config.env` instead of putting the secret directly in YAML. + +## Choose one + +| System | Tested pin | Transport | Upstream prerequisite | +|---|---:|---|---| +| [Memorix](https://github.com/AVIDS2/memorix) | `memorix@1.3.0` (`500792cad3144142293bfbb20acb4841c9f7fcfa`) | stdio | Node 22.18+ and `npm install --global memorix@1.3.0` | +| [MCP Reference Memory](https://github.com/modelcontextprotocol/servers/tree/main/src/memory) | `@modelcontextprotocol/server-memory@2026.7.4` (`6dd0a683e198783e30feabf7abaf42f925bd18b1`) | stdio | `npm install --global @modelcontextprotocol/server-memory@2026.7.4` | +| [Engram](https://github.com/Gentleman-Programming/engram) | `v1.20.0` (`ba9e46ced152c37a7cb9e576153c41995873e2fc`) | stdio | Go 1.25.10+ and `go install github.com/Gentleman-Programming/engram/cmd/engram@v1.20.0`, or the matching release binary | + +## Enable one + +Pass one overlay to DSH: + +```sh +dsh --config "$PWD/examples/mcp-memory/memorix.cordis.yml" +``` + +Replace the filename with `mcp-reference-memory.cordis.yml` or `engram.cordis.yml`. The path may point to a copied file anywhere on disk. No memory server is present in the shipped composition, so omitting `--config` keeps all three disabled. + +Without a repository checkout, download the selected overlay directly: + +```sh +mkdir -p "${DSH_HOME:-$HOME/.dsh}" +curl --fail --location \ + --output "${DSH_HOME:-$HOME/.dsh}/memory.cordis.yml" \ + https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/master/examples/mcp-memory/memorix.cordis.yml +dsh --config "${DSH_HOME:-$HOME/.dsh}/memory.cordis.yml" +``` + +Replace `memorix.cordis.yml` in the URL with either of the other filenames to select it. Review a downloaded overlay before running it: Cordis configuration can contain executable `!!js` expressions. + +To keep the selection in personal configuration, merge the chosen file's single `insert` patch into `$DSH_HOME/config.yaml` (normally `~/.dsh/config.yaml`). Do not copy over an existing file: it may already contain unrelated personal patches. + +## Provider setup + +### Memorix + +```sh +npm install --global memorix@1.3.0 +dsh --config "$PWD/examples/mcp-memory/memorix.cordis.yml" +``` + +Memorix works in local heuristic mode without an LLM or embedding service. Configure optional providers in Memorix's own `~/.memorix/config.toml` or project `memorix.toml`. The example keeps Memorix's Git-project identity from the DSH working directory and uses Memorix's own `~/.memorix/data` default. Set `MEMORIX_DATA_DIR` before starting DSH to override it. + +### MCP Reference Memory + +```sh +npm install --global @modelcontextprotocol/server-memory@2026.7.4 +dsh --config "$PWD/examples/mcp-memory/mcp-reference-memory.cordis.yml" +``` + +This reference server stores a local knowledge graph and exposes entity, relation, observation, read, search, and open tools. It needs no model or embedding service. The example stores its JSONL at `$HOME/.dsh-mcp-reference-memory.jsonl` instead of the installed npm package directory. Set `MEMORY_FILE_PATH` before starting DSH to override it. + +Search is case-insensitive substring matching over entity names, types, and observations, not semantic retrieval. The server does not add embeddings, automatic summarization, conflict resolution, or a forgetting policy. + +### Engram + +```sh +go install github.com/Gentleman-Programming/engram/cmd/engram@v1.20.0 +dsh --config "$PWD/examples/mcp-memory/engram.cordis.yml" +``` + +Engram owns storage and project selection: it uses `~/.engram` by default, detects the Git project from the DSH working directory, and accepts `ENGRAM_DATA_DIR` or `ENGRAM_PROJECT` as ambient overrides. + +## Optional shared model instruction + +Add this short, vendor-neutral instruction to your existing model instructions if the server's tool descriptions do not trigger memory use reliably: + +> When the user asks you to remember something, call a memory write tool. When historical information may be relevant, search memory and use relevant results. + +This is additive guidance only. The examples do not replace DSH's system-prompt persona. + +## Verify write, fresh-session recall, and use + +Use one unique value and keep the provider's storage scope unchanged throughout: + +1. In DSH session A, ask: `Remember that my validation drink is lapsang-.` Confirm the model called the provider's write tool and the tool returned success. +2. Create DSH session B in the same running Host. Do not copy session A's conversation. Ask: `What is my validation drink? Check memory.` Confirm the model called the provider's search or recall tool and returned the value. +3. Still in session B, ask: `Use that preference to suggest one drink for the meeting.` Confirm the answer uses the recalled value. + +A new DSH session is required; a Host restart is not. Restart or HMR is needed only after an MCP child crashes because the current generic client does not auto-reconnect; its tool registrations remain until plugin disposal or a successful re-sync, and calls can fail against the closed transport. Initial discovery is asynchronous, so wait for the provider's `mcp__...` tools before sending the first validation prompt. + +## Bring another MCP server + +Copy the same generic shape and use a unique `id` and `serverName`: + +```yaml +- insert: + - id: memory-my-server + name: '@deepseek-ai/dsh-mcp-client' + config: + serverName: my-memory + transport: stdio + command: my-memory-mcp + args: [] + env: {} + cwd: !!js process.cwd() +``` + +For a remote server, use `transport: streamable-http`, `url`, and `headers` instead. Provider-specific installation, identity, authentication, models, embeddings, persistence, and licensing remain the provider's responsibility. diff --git a/examples/mcp-memory/README.zh.md b/examples/mcp-memory/README.zh.md new file mode 100644 index 0000000000..1249ae40bb --- /dev/null +++ b/examples/mcp-memory/README.zh.md @@ -0,0 +1,113 @@ +# 第三方记忆 MCP 示例 + +[English](README.md) | 中文 + +这三份**默认关闭的参考配置** 通过 [`@deepseek-ai/dsh-mcp-client`](../../packages/mcp/mcp-client/README.md) 将一个记忆系统连接到 DSH。请选择其中一份,或复制相同的通用 MCP 配置项来连接其他服务器。 + +这些第三方配置仅作为互操作参考;收录不代表 DeepSeek 的认可、推荐、合作关系或持续支持承诺。 + +## DSH 负责什么 + +DSH 解析选中的 Cordis overlay,启动已配置的 stdio 命令或连接已配置的 Streamable HTTP URL,发现 MCP 工具,并以 `mcp____` 的形式公开这些工具。DSH **不负责** 下载服务器、初始化其数据库、选择模型或 embedding 提供方、创建云端账户、迁移提供方数据,也不监管独立的 HTTP 服务。对于 stdio,通用客户端会随 DSH 插件生命周期启动和停止子进程;对于 HTTP,上游服务必须已经运行。 + +stdio 桥接器在启动子进程前会主动移除环境中名称类似凭据的变量和 `DSH_*` 变量;其余环境变量仍会继承。每份示例仅添加其基线所需的覆盖项。如果某个可选的上游功能还需要其他密钥,请将该变量添加到配置项的 `config.env`,不要把密钥直接写进 YAML。 + +## 选择一个 + +| 系统 | 已测试版本 | 传输方式 | 上游前置条件 | +|---|---:|---|---| +| [Memorix](https://github.com/AVIDS2/memorix) | `memorix@1.3.0`(`500792cad3144142293bfbb20acb4841c9f7fcfa`) | stdio | Node 22.18+,并执行 `npm install --global memorix@1.3.0` | +| [MCP Reference Memory](https://github.com/modelcontextprotocol/servers/tree/main/src/memory) | `@modelcontextprotocol/server-memory@2026.7.4`(`6dd0a683e198783e30feabf7abaf42f925bd18b1`) | stdio | `npm install --global @modelcontextprotocol/server-memory@2026.7.4` | +| [Engram](https://github.com/Gentleman-Programming/engram) | `v1.20.0`(`ba9e46ced152c37a7cb9e576153c41995873e2fc`) | stdio | Go 1.25.10+,并执行 `go install github.com/Gentleman-Programming/engram/cmd/engram@v1.20.0`,或安装匹配的发布版二进制文件 | + +## 启用一个 + +将一份 overlay 传给 DSH: + +```sh +dsh --config "$PWD/examples/mcp-memory/memorix.cordis.yml" +``` + +请将文件名替换为 `mcp-reference-memory.cordis.yml` 或 `engram.cordis.yml`。该路径可以指向磁盘任意位置的一份复制文件。交付组合不包含任何记忆服务器,因此不传 `--config` 就会让这三项全部保持关闭。 + +如果本地没有仓库 checkout,可直接下载所选 overlay: + +```sh +mkdir -p "${DSH_HOME:-$HOME/.dsh}" +curl --fail --location \ + --output "${DSH_HOME:-$HOME/.dsh}/memory.cordis.yml" \ + https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/master/examples/mcp-memory/memorix.cordis.yml +dsh --config "${DSH_HOME:-$HOME/.dsh}/memory.cordis.yml" +``` + +若要选择另外任一配置,请将 URL 中的 `memorix.cordis.yml` 替换为对应文件名。运行下载的 overlay 前,请先审阅其内容:Cordis 配置可以包含可执行的 `!!js` 表达式。 + +如果要把所选配置保存在个人配置中,请将对应文件中的单个 `insert` patch 合并到 `$DSH_HOME/config.yaml`(通常是 `~/.dsh/config.yaml`)。不要覆盖已有文件,其中可能已经包含无关的个人 patch。 + +## 提供方设置 + +### Memorix + +```sh +npm install --global memorix@1.3.0 +dsh --config "$PWD/examples/mcp-memory/memorix.cordis.yml" +``` + +Memorix 无需 LLM(大语言模型)或 embedding 服务,即可在本地启发式模式下运行。请在 Memorix 自己的 `~/.memorix/config.toml` 或项目 `memorix.toml` 中配置可选提供方。该示例沿用 DSH 工作目录中的 Git 项目标识,并使用 Memorix 自身的默认目录 `~/.memorix/data`。若要覆盖该目录,请在启动 DSH 前设置 `MEMORIX_DATA_DIR`。 + +### MCP Reference Memory + +```sh +npm install --global @modelcontextprotocol/server-memory@2026.7.4 +dsh --config "$PWD/examples/mcp-memory/mcp-reference-memory.cordis.yml" +``` + +该参考服务器存储本地知识图谱,并公开实体、关系、观察、读取、搜索和打开工具。它不需要模型或 embedding 服务。该示例将 JSONL 存储在 `$HOME/.dsh-mcp-reference-memory.jsonl`,而不是已安装的 npm 包(package)目录中。若要覆盖该路径,请在启动 DSH 前设置 `MEMORY_FILE_PATH`。 + +搜索只对实体名称、类型和 observation 进行不区分大小写的子字符串匹配,不是语义检索。该服务器不提供 embedding、自动摘要、冲突消解或遗忘策略。 + +### Engram + +```sh +go install github.com/Gentleman-Programming/engram/cmd/engram@v1.20.0 +dsh --config "$PWD/examples/mcp-memory/engram.cordis.yml" +``` + +Engram 负责存储和项目选择:它默认使用 `~/.engram`,从 DSH 工作目录检测 Git 项目,并接受 `ENGRAM_DATA_DIR` 或 `ENGRAM_PROJECT` 作为环境覆盖项。 + +## 可选的共用模型指令 + +如果服务器的工具描述无法可靠触发记忆使用,请将以下简短、与提供方无关的指令添加到你现有的模型指令中: + +> 用户要求记住时调用写入工具;涉及历史信息时,主动检索并使用相关记忆。 + +这只是附加指导。示例不会替换 DSH 系统提示词中的 persona。 + +## 验证写入、新会话召回和使用 + +请在整个过程中使用一个唯一值,并保持提供方的存储范围不变: + +1. 在 DSH 会话 A 中提出:`Remember that my validation drink is lapsang-.`。确认模型调用了提供方的写入工具,并且工具返回成功。 +2. 在同一个仍在运行的 Host 中创建 DSH 会话 B。不要复制会话 A 的对话。提出:`What is my validation drink? Check memory.`。确认模型调用了提供方的搜索或召回工具,并返回该值。 +3. 继续在会话 B 中提出:`Use that preference to suggest one drink for the meeting.`。确认回答使用了召回的值。 + +必须新建 DSH 会话,但不需要重启 Host。只有 MCP 子进程崩溃后才需要重启或执行 HMR(热模块替换),因为当前的通用客户端不会自动重连;其工具注册会一直保留到插件完成资源释放或成功重新同步,针对已关闭传输的调用可能失败。初始发现过程是异步的,因此发送第一条验证提示词前,请等待提供方的 `mcp__...` 工具出现。 + +## 接入其他 MCP 服务器 + +复制相同的通用结构,并使用唯一的 `id` 和 `serverName`: + +```yaml +- insert: + - id: memory-my-server + name: '@deepseek-ai/dsh-mcp-client' + config: + serverName: my-memory + transport: stdio + command: my-memory-mcp + args: [] + env: {} + cwd: !!js process.cwd() +``` + +对于远程服务器,请改用 `transport: streamable-http`、`url` 和 `headers`。提供方专属的安装、身份、认证、模型、embedding、持久化和许可仍由提供方负责。 diff --git a/examples/mcp-memory/engram.cordis.yml b/examples/mcp-memory/engram.cordis.yml new file mode 100644 index 0000000000..018df88b6f --- /dev/null +++ b/examples/mcp-memory/engram.cordis.yml @@ -0,0 +1,11 @@ +# Opt-in reference for Engram 1.20.0. Install the pinned `engram` executable +# first; storage and project selection remain Engram-owned. +- insert: + - id: memory-engram + name: '@deepseek-ai/dsh-mcp-client' + config: + serverName: engram + transport: stdio + command: engram + args: [mcp] + cwd: !!js process.cwd() diff --git a/examples/mcp-memory/mcp-reference-memory.cordis.yml b/examples/mcp-memory/mcp-reference-memory.cordis.yml new file mode 100644 index 0000000000..d89fbe2673 --- /dev/null +++ b/examples/mcp-memory/mcp-reference-memory.cordis.yml @@ -0,0 +1,13 @@ +# Opt-in reference for @modelcontextprotocol/server-memory 2026.7.4. Install +# the pinned executable first; DSH starts it but does not run a package manager. +- insert: + - id: memory-mcp-reference + name: '@deepseek-ai/dsh-mcp-client' + config: + serverName: reference_memory + transport: stdio + command: mcp-server-memory + cwd: !!js process.cwd() + env: + MEMORY_FILE_PATH: !!js >- + process.env.MEMORY_FILE_PATH?.trim() || process.getBuiltinModule('node:path').join(process.getBuiltinModule('node:os').homedir(), '.dsh-mcp-reference-memory.jsonl') diff --git a/examples/mcp-memory/memorix.cordis.yml b/examples/mcp-memory/memorix.cordis.yml new file mode 100644 index 0000000000..c993581eae --- /dev/null +++ b/examples/mcp-memory/memorix.cordis.yml @@ -0,0 +1,11 @@ +# Opt-in reference for Memorix 1.3.0. Install the pinned `memorix` executable +# first; DSH starts it but does not run a package manager. +- insert: + - id: memory-memorix + name: '@deepseek-ai/dsh-mcp-client' + config: + serverName: memorix + transport: stdio + command: memorix + args: [serve] + cwd: !!js process.cwd() diff --git a/lefthook.yml b/lefthook.yml index 1a4e004842..bdab57a9a3 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -18,6 +18,16 @@ pre-commit: run: node_modules/.bin/tsx scripts/run-oxlint.ts --fix --no-error-on-unmatched-pattern {staged_files} stage_fixed: true + # Regenerate rather than reject: a dependency edit that forgot the notices + # would otherwise fail the test lane long after the commit. The glob matches + # every input the generator reads, including the generator itself and the + # build-time pin source. Deleting a manifest cannot trigger this job — + # lefthook only inspects files present on disk — so that one case still + # falls through to the freshness assertion in the test lane. + - name: third-party notices (staged) + glob: '{package.json,*/package.json,*/*/package.json,*/*/*/package.json,*/*/*/*/package.json,pnpm-workspace.yaml,*/*/pnpm-workspace.yaml,pnpm-lock.yaml,vendor/README.md,python/*/pyproject.toml,scripts/gen-third-party-notices.ts,scripts/build-exe-for-python-sdk.ts}' + run: node_modules/.bin/tsx scripts/gen-third-party-notices.ts && git add THIRD_PARTY_NOTICES.md + - name: whitespace (staged) run: git diff --cached --check diff --git a/package.json b/package.json index 96f72d1c1c..a5f76a0612 100644 --- a/package.json +++ b/package.json @@ -92,6 +92,8 @@ "verify-doc-graphs": "tsx scripts/gen-doc-graphs.ts --check", "gen-persistence-catalog": "tsx scripts/gen-persistence-catalog.ts", "verify-persistence-catalog": "tsx scripts/gen-persistence-catalog.ts --check", + "gen-third-party-notices": "tsx scripts/gen-third-party-notices.ts", + "verify-third-party-notices": "tsx scripts/gen-third-party-notices.ts --check", "gen-module-graph": "tsx scripts/gen-module-graph.ts", "gen-scoped-events": "tsx scripts/gen-scoped-events.ts", "verify-scoped-events": "tsx scripts/gen-scoped-events.ts --check", @@ -120,6 +122,7 @@ "@types/jsdom": "^28.0.3", "@types/mdast": "^4.0.4", "@types/node": "^22.20.0", + "@types/spdx-expression-parse": "^4.0.0", "@typescript-eslint/parser": "8.61.0", "@vitest/coverage-v8": "^4.1.8", "@yarnpkg/cli-dist": "4.17.1", @@ -140,6 +143,8 @@ "oxlint": "1.76.0", "oxlint-tsgolint": "7.0.2001", "publint": "^0.3.21", + "smol-toml": "^1.7.1", + "spdx-expression-parse": "^5.0.0", "tsdown": "^0.22.2", "tsx": "^4.22.4", "typescript": "^6.0.3", diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 4bdeeec7dc..9f091b26c5 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -164,10 +164,10 @@ const SEARCH_MATCHES_FIXTURE: { path: string; matches: { lineNumber: number; lin { path: 'packages/client/ui-conversation/src/client/toolviews/search-row.tsx', matches: [ - { lineNumber: 71, line: 'export function SearchRow({ toolName, block }: ToolRowProps) {' }, - { lineNumber: 73, line: ' const search = searchCardModel(block)' }, - { lineNumber: 90, line: ' ' }, - { lineNumber: 113, line: " ctx.slots.register({ name: 'conversation.chat.toolview', key: 'grep' }, SearchRow)" }, + { lineNumber: 33, line: 'export function SearchRow({ toolName, block, inspect, t }: SearchRowProps) {' }, + { lineNumber: 35, line: ' const search = searchCardModel(block)' }, + { lineNumber: 52, line: ' search={search}' }, + { lineNumber: 73, line: " ctx.slots.register({ name: 'conversation.chat.toolview', key: 'grep', locale: NS }, SearchRow)" }, ], }, ] @@ -197,7 +197,7 @@ const SEARCH_PATHS_FIXTURE = [ 'packages/client/ui-primitives/src/SearchBlock.module.css', 'packages/client/ui-conversation/src/client/contract/search-card-model.ts', 'packages/client/ui-conversation/src/client/toolviews/search-row.tsx', - 'packages/client/ui-conversation/src/client/toolviews/search-row.module.css', + 'packages/client/ui-conversation/tests/search-card.spec.tsx', ] /** diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index db9786931e..ac0bd88de1 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: 66120f222de4b4d4707430a1ec310c6fe801c0c5 -README.zh.md: f5e953f363733341400a292a1946b4e298858b77 +README.md: f7279d2c640d447609c4e9804af633d026b35ed9 +README.zh.md: 2e8f3483c160689cdf5ff283f2955a5c0be4fd8e diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 66120f222d..f7279d2c64 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -18,13 +18,15 @@ Generic tool rows classify the built-in bash, read, search, write, edit, and run A tool call declaring the `terminal` render intent renders its command output inline, at both conversation render sites, through ui-primitives' `TerminalBlock`. `contract/terminal-card-model.ts` is the single derivation from the snapshot's `callView`/`resultView` pair, so the sites cannot disagree about a command, its cwd, or its exit status; it yields null — the generic path — for any other card tag, including one this client version does not know. Both sites therefore also show the card's run-state dot, which is the same `StateDot` semantic a tool row's leading icon carries, so a row and its own card always agree about one command's state. A multi-line command gets one prompt row per line, with the dot marking the call once on the first row — the exit status is the whole call's, so a dot per line would claim a per-line outcome bash does not report. The keyed `BashRow` carries the card resident below its summary row; since tool rows are no longer details-panel click targets, the card's copy and expand controls are the row's only interactions. The render-site fallback row keeps the card behind its existing expand control. Rows cap at `CHAT_TERMINAL_MAX_LINES` (8) against the panel's 16, which is what keeps a summary surface bounded — the panel stays the single-call reading surface. Inline output is licensed per render intent — the terminal and web cards, each with its own bound; a generic tool's content remains panel-only ([decision](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)). -A tool call declaring the `web` render intent renders its web retrieval inline, at both conversation render sites, through ui-primitives' `WebBlock`. `contract/web-card-model.ts` is the single derivation from the snapshot's `resultView`, mirroring the terminal card, so the sites cannot disagree about what a web call shows; it yields null — the generic path — for a running call, a non-web result view, a generic result view, a `card` tag this client version does not know, or a web card whose `kind` this client version does not know (a newer host's value, which the wire cannot be trusted to be `search` or `fetch`). The keyed `WebRow` registers one component under both `web_search` and `web_fetch`, discriminating on the tool name only for its icon and title; a web-declaring tool without a keyed row lands on the `GenericToolCard` fallback, which grows the same resident card, and the details panel renders it at the primitive's full source allowance and, below the card, the flattened model-visible result content — a fetch body is readable only there, since its card carries only the URL and status. Rows cap at `CHAT_WEB_MAX_SOURCES` (8) against the panel's 16, the same summary-versus-reading split the terminal card draws ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md)). +A tool call declaring the `web` render intent renders its web retrieval inline, at both conversation render sites, through ui-primitives' `WebBlock`. `contract/web-card-model.ts` is the single derivation from the snapshot's `resultView`, mirroring the terminal card, so the sites cannot disagree about what a web call shows; it yields null — the generic path — for a running call, a non-web result view, a generic result view, a `card` tag this client version does not know, or a web card whose `kind` this client version does not know (a newer host's value, which the wire cannot be trusted to be `search` or `fetch`). The keyed `WebRow` registers one component under both `web_search` and `web_fetch`, discriminating on the tool name only for its icon and title; it composes the shared `ToolRow`, feeding the card as ToolRow's `web` body, so the retrieval is the row's collapsed-by-default expanded card (the same unified expand every card row has). A web-declaring tool without a keyed row lands on the `GenericToolCard` fallback, which routes the card through ToolRow the same way, and the details panel renders it at the primitive's full source allowance and, below the card, the flattened model-visible result content — a fetch body is readable only there, since its card carries only the URL and status. Rows cap at `CHAT_WEB_MAX_SOURCES` (8) against the panel's 16, the same summary-versus-reading split the terminal card draws ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md)). -A tool call declaring the `diff` render intent (the `write`/`edit` tools) renders its applied change inline through ui-primitives' `DiffBlock`, the same four-layer shape. `contract/diff-card-model.ts` is the single derivation from the `callView`/`resultView` pair; the settled result's hunks replace the call-time diff, and it yields null — the generic path — for any other card tag or a generic result view (write/edit's execution errors). The keyed `FileMutationRow` (registered under both `write` and `edit`) carries the card resident below its summary, whose path link still opens the file through the host; the render-site fallback and the details panel are diff-aware too. Rows cap at `CHAT_DIFF_MAX_LINES` (8) against the panel's 16 ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.md)). +A `read` call declaring the `read` render intent renders the returned file window inline, at both conversation render sites, through ui-primitives' `ReadBlock` — the line-numbered, syntax-highlighted content the tool projects. `contract/read-card-model.ts` is the single derivation from the snapshot's `resultView`; the read card is result-side only (a call carries no file content until `execute` returns), so a running read shows its summary alone and it yields null — the generic path — for a non-read result view or a `card` tag this client version does not know. The keyed `ReadRow` composes the shared `ToolRow`, feeding the card as ToolRow's `read` body, so it is the row's collapsed-by-default expanded card; the summary stays a path link that opens the file through the host. The render-site fallback and the details panel are read-aware too. Rows cap at `CHAT_READ_MAX_LINES` (8) against the panel's 16 ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.md)). + +A tool call declaring the `diff` render intent (the `write`/`edit` tools) renders its applied change inline through ui-primitives' `DiffBlock`, the same four-layer shape. `contract/diff-card-model.ts` is the single derivation from the `callView`/`resultView` pair; the settled result's hunks replace the call-time diff, and it yields null — the generic path — for any other card tag or a generic result view (write/edit's execution errors). The keyed `FileMutationRow` (registered under both `write` and `edit`) composes the shared `ToolRow`, feeding the diff as ToolRow's `diff` body, so it is the row's collapsed-by-default expanded card; the summary path link still opens the file through the host, and an errored mutation (no diff card) surfaces its error text through ToolRow's Output section with the first line in the collapsed summary. The render-site fallback and the details panel are diff-aware too. Rows cap at `CHAT_DIFF_MAX_LINES` (8) against the panel's 16 ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.md)). The chat flow projects consecutive model-retry nodes across retry turns into one stable, muted status row updated to the latest attempt; every retry event remains in the runtime snapshot and session log. Its frontend countdown anchors the scheduled delay to client receipt, avoiding host/browser clock skew, rounds remaining time up to seconds, and has a one-second floor. The latest unresolved retry uses a left-to-right text shimmer. Subsequent turn facts distinguish an attempt that started from one cancelled during backoff, while the Host running bit only controls the live animation; the row then shows a static completed or cancelled label. Normal policy rows show the finite retry maximum; always policy rows show `∞`. Activating the row reveals the latest exact retry delay and failure message. The client runtime removes each failed step's streaming tail before its retry node arrives, while the status remains visible after a later attempt succeeds. -A `grep`/`glob` call declaring the `search` render intent renders its result inline, at the same render sites, through ui-primitives' `SearchBlock` — grep's matches grouped by file (each a collapsible header of `lineNumber: line` rows), glob's flat path list. `contract/search-card-model.ts` is the single derivation from the snapshot's `resultView`; unlike the terminal card it reads no `callView`, since a search has no matches or paths before `execute`, so a running search shows its summary alone. It yields null — the generic path — for any non-search result view, a `card` or `kind` this client version does not compile, and (because those ride the untrusted wire frame) a known kind whose `files`/`paths` is malformed. The keyed `SearchRow`, registered under both `grep` and `glob` since the derived `kind` decides the shape, carries the card resident below its summary; the render-site fallback keeps it behind the expand control. Both cap at `CHAT_SEARCH_MAX_LINES` (8) against the panel's 16. A capped search drops rows from the card, but the locator to the rest — grep/glob's `Full … stored at …` footer — lives only in the result text, so the derivation surfaces that as a recovery footer below the card when (and only when) the result was truncated; a settled call with no card at all (an errored search, a nested `run_code` sub-dispatch, a legacy generic result) falls back to its flattened result text so nothing is lost behind a bare summary ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md)). +A `grep`/`glob` call declaring the `search` render intent renders its result inline, at the same render sites, through ui-primitives' `SearchBlock` — grep's matches grouped by file (each a collapsible header of `lineNumber: line` rows), glob's flat path list. `contract/search-card-model.ts` is the single derivation from the snapshot's `resultView`; unlike the terminal card it reads no `callView`, since a search has no matches or paths before `execute`, so a running search shows its summary alone. It yields null — the generic path — for any non-search result view, a `card` or `kind` this client version does not compile, and (because those ride the untrusted wire frame) a known kind whose `files`/`paths` is malformed. The keyed `SearchRow`, registered under both `grep` and `glob` since the derived `kind` decides the shape, composes the shared `ToolRow`, feeding the card as ToolRow's `search` body, so it is the row's collapsed-by-default expanded card; the render-site fallback routes it the same way. Both cap at `CHAT_SEARCH_MAX_LINES` (8) against the panel's 16. A capped search drops rows from the card, but the locator to the rest — grep/glob's `Full … stored at …` footer — lives only in the result text, so the derivation surfaces that as a recovery footer below the card when (and only when) the result was truncated; a settled call with no card at all (an errored search, a nested `run_code` sub-dispatch, a legacy generic result) surfaces its flattened result text through ToolRow's Output section so nothing is lost behind a bare summary ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md)). Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.toolviews`/outlet) is retired. The chat entry declares the keyed `'conversation.chat.toolview'` hole (session scope; the key space is runtime-open); its render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openFile`) and `ToolRowProps` pre-composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam (apply mounts ConversationService after the chat registration, so the service being present guarantees the slot is declared); session differentiation happens inside the component (`useSessions` reading `parentId` — the bash sample is the third-party-posture exemplar). Trajectory/waterfall toolview slots share this shape and land with their own render sites (RendersCheck rejects a declaration nobody renders). diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index f5e953f363..2e8f3483c1 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -16,13 +16,15 @@ 声明 `terminal` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `TerminalBlock` 内联渲染其命令输出。`contract/terminal-card-model.ts` 是从快照的 `callView`/`resultView` 对推导的唯一位置,因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧;对任何其他 card 标签——包括当前客户端版本不认识的标签——它返回 null,落回通用路径。因此两个渲染点也都显示卡片的运行状态点,它与工具行行首图标承载同一套 `StateDot` 语义,所以一行与其自身的卡片对同一条命令的状态总是一致。多行命令的每一行各占一个提示行,状态点只在第一行为整次调用标记一次——退出状态属于整次调用,因此每行一枚就会声称一个 bash 并不报告的逐行结果。键控的 `BashRow` 把卡片常驻在摘要行下方;由于工具行已不再是详情面板的点击目标,卡片的复制与展开控件就是该行唯一的交互。渲染点兜底行则保持其既有的展开控件。行的上限是 `CHAT_TERMINAL_MAX_LINES`(8),面板为 16,正是这一点让摘要面保持有界——面板仍是单次调用的阅读面。内联输出按渲染意图开放——终端卡片与 web 卡片,各有自己的上限;通用工具的内容仍然只在面板中呈现([决策](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md))。 -声明 `web` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `WebBlock` 内联渲染其 web 检索。`contract/web-card-model.ts` 是从快照的 `resultView` 推导的唯一位置,镜像终端卡片,因此两个渲染点不可能对一次 web 调用的显示产生分歧;对运行中的调用、非 web 的 result view、generic result view、本客户端版本不认识的 `card` 标签,或本客户端版本不认识 `kind` 的 web 卡片(更新的 host 发来的值,wire 上不可信其为 `search` 或 `fetch`),它返回 null,落回通用路径。键控的 `WebRow` 把一个组件注册在 `web_search` 与 `web_fetch` 两个键下,仅根据工具名判别以选取图标与标题;没有自己键控行的 web 声明工具落到 `GenericToolCard` 兜底,它长出同一张常驻卡片,详情面板则以原语的完整 source 额度渲染它,并在卡片下方渲染摊平的模型可见结果内容——fetch 正文只在此处可读,因为其卡片只携带 URL 和状态。行的上限是 `CHAT_WEB_MAX_SOURCES`(8),面板为 16,与终端卡片所画的摘要面对阅读面的同一划分([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md))。 +声明 `web` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `WebBlock` 内联渲染其 web 检索。`contract/web-card-model.ts` 是从快照的 `resultView` 推导的唯一位置,镜像终端卡片,因此两个渲染点不可能对一次 web 调用的显示产生分歧;对运行中的调用、非 web 的 result view、generic result view、本客户端版本不认识的 `card` 标签,或本客户端版本不认识 `kind` 的 web 卡片(更新的 host 发来的值,wire 上不可信其为 `search` 或 `fetch`),它返回 null,落回通用路径。键控的 `WebRow` 把一个组件注册在 `web_search` 与 `web_fetch` 两个键下,仅根据工具名判别以选取图标与标题;它组合共享的 `ToolRow`,把卡片作为 ToolRow 的 `web` body 传入,因此检索成为该行默认折叠的展开卡片(与每个卡片行相同的统一展开交互)。没有自己键控行的 web 声明工具落到 `GenericToolCard` 兜底,它以同样方式经 ToolRow 渲染卡片,详情面板则以原语的完整 source 额度渲染它,并在卡片下方渲染摊平的模型可见结果内容——fetch 正文只在此处可读,因为其卡片只携带 URL 和状态。行的上限是 `CHAT_WEB_MAX_SOURCES`(8),面板为 16,与终端卡片所画的摘要面对阅读面的同一划分([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md))。 -声明 `diff` 渲染意图的工具调用(`write`/`edit` 工具),通过 ui-primitives 的 `DiffBlock` 内联渲染其已应用的改动,采用同一套四层结构。`contract/diff-card-model.ts` 是从 `callView`/`resultView` 对推导的唯一位置;已结算 result 的 hunk 替换 call 时 diff,对任何其他 card 标签或 generic result view(write/edit 的执行错误)它返回 null,落回通用路径。键控的 `FileMutationRow`(在 `write` 与 `edit` 下都注册)把卡片常驻在摘要之下,其路径链接仍经 host 打开文件;渲染点兜底行与详情面板同样感知 diff。行的上限是 `CHAT_DIFF_MAX_LINES`(8),面板为 16([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.md))。 +声明 `read` 渲染意图的 `read` 调用,会在两个对话渲染点上都通过 ui-primitives 的 `ReadBlock` 内联渲染返回的文件窗口——工具投影出的带行号、语法高亮的内容。`contract/read-card-model.ts` 是从快照的 `resultView` 推导的唯一位置;read 卡片是仅结果侧的(调用在 `execute` 返回前不携带文件内容),所以运行中的 read 只显示摘要,且对非 read 的 result view 或本客户端版本不认识的 `card` 标签返回 null,落回通用路径。键控的 `ReadRow` 组合共享的 `ToolRow`,把卡片作为 ToolRow 的 `read` body 传入,因此它是该行默认折叠的展开卡片;摘要仍是一个经 host 打开文件的路径链接。渲染点兜底行与详情面板同样感知 read。行的上限是 `CHAT_READ_MAX_LINES`(8),面板为 16([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.md))。 + +声明 `diff` 渲染意图的工具调用(`write`/`edit` 工具),通过 ui-primitives 的 `DiffBlock` 内联渲染其已应用的改动,采用同一套四层结构。`contract/diff-card-model.ts` 是从 `callView`/`resultView` 对推导的唯一位置;已结算 result 的 hunk 替换 call 时 diff,对任何其他 card 标签或 generic result view(write/edit 的执行错误)它返回 null,落回通用路径。键控的 `FileMutationRow`(在 `write` 与 `edit` 下都注册)组合共享的 `ToolRow`,把 diff 作为 ToolRow 的 `diff` body 传入,因此它是该行默认折叠的展开卡片;摘要路径链接仍经 host 打开文件,而出错的改动(没有 diff 卡片)经 ToolRow 的 Output 区呈现其错误文本,首行进入折叠摘要。渲染点兜底行与详情面板同样感知 diff。行的上限是 `CHAT_DIFF_MAX_LINES`(8),面板为 16([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.md))。 聊天流会将跨重试轮次连续出现的模型重试节点投影为一个稳定的弱化状态行,并用最新一次尝试更新该行;每个重试事件仍保留在运行时快照与会话日志中。前端倒计时以客户端收到事件的时刻为计划延迟的起点,避免 Host 与浏览器的时钟偏差;剩余时间向上取整到秒,且下限为 1 秒。最近一次尚未完成的重试会显示从左到右的文字渐变动画。后续轮次事实用于区分已开始的尝试与在退避期间取消的尝试,Host 的 running 位只控制实时动画;随后该行会显示静态的已完成或已取消标签。normal 策略行显示有限重试上限;always 策略行显示 `∞`。激活该行会显示最近一次重试的精确延迟和失败消息。客户端运行时会在相应重试节点到达前移除每个失败步骤的流式输出尾部;后续某次尝试成功后,该状态仍保持可见。 -声明 `search` 渲染意图的 `grep`/`glob` 调用,会在同样的渲染点上通过 ui-primitives 的 `SearchBlock` 内联渲染其结果——grep 的匹配按文件分组(每个是一个可折叠的头,下辖 `lineNumber: line` 行),glob 是扁平路径列表。`contract/search-card-model.ts` 是从快照的 `resultView` 推导的唯一位置;与终端卡片不同,它不读 `callView`,因为搜索在 `execute` 前没有匹配或路径,所以运行中的搜索只显示摘要。对任何非搜索的结果视图、当前客户端版本无法编译的 `card` 或 `kind`、以及(因为这些都与不可信的 wire 帧同行)一个 `files`/`paths` 格式错误的已知 kind,它都返回 null,落回通用路径。键控的 `SearchRow` 因推导出的 `kind` 决定形态而同时注册在 `grep` 与 `glob` 下,把卡片常驻在摘要行下方;渲染点兜底行则把它保持在展开控件之后。两者上限都是 `CHAT_SEARCH_MAX_LINES`(8),面板为 16。被截断的搜索会从卡片里丢掉一些行,但通往其余部分的定位符——grep/glob 的 `Full … stored at …` 脚注——只存在于结果文本里,因此推导在(且仅在)结果被截断时把它作为恢复脚注画在卡片下方;一个完全没有卡片的已结算调用(出错的搜索、嵌套 `run_code` 子派发、旧日志的 generic 结果)则回退到其压平后的结果文本,从而不让任何内容丢失在一个光秃秃的摘要之后([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md))。 +声明 `search` 渲染意图的 `grep`/`glob` 调用,会在同样的渲染点上通过 ui-primitives 的 `SearchBlock` 内联渲染其结果——grep 的匹配按文件分组(每个是一个可折叠的头,下辖 `lineNumber: line` 行),glob 是扁平路径列表。`contract/search-card-model.ts` 是从快照的 `resultView` 推导的唯一位置;与终端卡片不同,它不读 `callView`,因为搜索在 `execute` 前没有匹配或路径,所以运行中的搜索只显示摘要。对任何非搜索的结果视图、当前客户端版本无法编译的 `card` 或 `kind`、以及(因为这些都与不可信的 wire 帧同行)一个 `files`/`paths` 格式错误的已知 kind,它都返回 null,落回通用路径。键控的 `SearchRow` 因推导出的 `kind` 决定形态而同时注册在 `grep` 与 `glob` 下,组合共享的 `ToolRow`,把卡片作为 ToolRow 的 `search` body 传入,因此它是该行默认折叠的展开卡片;渲染点兜底行以同样方式渲染它。两者上限都是 `CHAT_SEARCH_MAX_LINES`(8),面板为 16。被截断的搜索会从卡片里丢掉一些行,但通往其余部分的定位符——grep/glob 的 `Full … stored at …` 脚注——只存在于结果文本里,因此推导在(且仅在)结果被截断时把它作为恢复脚注画在卡片下方;一个完全没有卡片的已结算调用(出错的搜索、嵌套 `run_code` 子派发、旧日志的 generic 结果)则经 ToolRow 的 Output 区呈现其压平后的结果文本,从而不让任何内容丢失在一个光秃秃的摘要之后([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md))。 工具行同样是 slot:独立工具环(`ToolViewRegistry`/`ctx.toolviews`/outlet)已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位(Session scope;key 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openFile`),`ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seam(apply 在聊天注册后挂载 ConversationService,因此服务存在即可保证 slot 已声明);Session 区分在组件内部完成(`useSessions` 读取 `parentId`,bash 示例是第三方姿态的范例)。Trajectory/waterfall(瀑布式事件)工具视图 slot 共享此形状,并随各自的渲染点落地(RendersCheck 会拒绝没有任何渲染方的声明)。 diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 9d569eba29..489ad34e3f 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -20,9 +20,9 @@ import { InputBar } from './skeleton/InputBar.tsx' import { ChatView } from './chat/ChatView.tsx' import { StatsLine } from './chat/StatsLine.tsx' import { bashToolviewSample } from './toolviews/bash-sample.tsx' -import { searchToolview } from './toolviews/search-row.tsx' import { readToolview } from './toolviews/read-row.tsx' import { fileMutationToolview } from './toolviews/file-mutation-row.tsx' +import { searchToolview } from './toolviews/search-row.tsx' import { webToolview } from './toolviews/web-row.tsx' import { ApprovalPanel } from './skeleton/ApprovalPanel.tsx' import { todoToolview } from './toolviews/todo-row.tsx' @@ -322,10 +322,6 @@ export function apply(ctx: Context): void { // (ToolRow-matching Bash · {description} chrome; scoped badge in child sessions). ctx.plugin(bashToolviewSample) - // The grep/glob search row rides the same seam: one component registered - // under both tool names, since both declare the same search render intent. - ctx.plugin(searchToolview) - // The read row rides the same seam (a product registration, not a sample): // Read · {path} chrome with the file's read card resident below it. ctx.plugin(readToolview) @@ -334,6 +330,11 @@ export function apply(ctx: Context): void { // diff render intent, so these rows stack the applied diff card under their // path-link summary (the terminal card's posture, applied to diffs). ctx.plugin(fileMutationToolview) + + // The grep/glob search row rides the same seam: one component registered + // under both tool names, since both declare the same search render intent. + ctx.plugin(searchToolview) + // The web rows ride the same seam: one WebRow registered under both // web_search and web_fetch, rendering the completed retrieval's web card // resident under the summary (a product registration, not a sample). diff --git a/packages/client/ui-conversation/src/client/chat/GenericToolCard.module.css b/packages/client/ui-conversation/src/client/chat/GenericToolCard.module.css deleted file mode 100644 index d21f6d3712..0000000000 --- a/packages/client/ui-conversation/src/client/chat/GenericToolCard.module.css +++ /dev/null @@ -1,17 +0,0 @@ -/* GenericToolCard resident cards: a read-declaring or web-declaring tool - without its own keyed row (e.g. web_fetch) grows a resident card under its - summary row. A column around the ToolRow keeps the row's own 24px height, so - the read card renders identically to the keyed ReadRow and the web card to - the web_search/web_fetch WebRow. */ - -.card { - display: flex; - flex-direction: column; -} - -/* Row indentation matches ToolRow's expanded bodies (16px leading + 6px gap), - and replaces the primitive's standalone vertical margin with the flow's. */ -.read, -.web { - margin: 4px 0 4px 22px; -} diff --git a/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx b/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx index 40c209627b..705d1d039f 100644 --- a/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx +++ b/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx @@ -7,17 +7,16 @@ import type { ReactNode } from 'react' import { IconApiOutline14, IconBrowseOutline16, IconCodeOutline16, IconEditOutline16, IconSearchOutline16, IconSparkle16, - IconThinkOutline14, ReadBlock, WebBlock, + IconThinkOutline14, } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatViewSlotProps, ToolRowOwnerProps } from '../contract/slots.ts' -import { searchCardModel } from '../contract/search-card-model.ts' -import { CHAT_READ_MAX_LINES, readCardModel } from '../contract/read-card-model.ts' +import { readCardModel } from '../contract/read-card-model.ts' import { diffCardModel } from '../contract/diff-card-model.ts' +import { searchCardModel } from '../contract/search-card-model.ts' import { terminalCardModel, terminalFailed } from '../contract/terminal-card-model.ts' -import { CHAT_WEB_MAX_SOURCES, webCardModel } from '../contract/web-card-model.ts' +import { webCardModel } from '../contract/web-card-model.ts' import { toolRowModel, type ToolRowVariant } from '../contract/tool-call-model.ts' import { ToolRow } from './ToolRow.tsx' -import css from './GenericToolCard.module.css' /** Variant leading icons (figma table); all glyphs render at 14 inside the 16px leading box. */ const VARIANT_ICONS: Record = { @@ -39,9 +38,9 @@ export interface GenericToolCardProps extends ToolRowOwnerProps { export function GenericToolCard({ toolName, block, cwd, openFile, inspect, t }: GenericToolCardProps) { const model = toolRowModel(toolName, block, cwd) const terminal = terminalCardModel(block, cwd) - const search = searchCardModel(block) const read = readCardModel(block, cwd) const diff = diffCardModel(block) + 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. @@ -49,7 +48,7 @@ export function GenericToolCard({ toolName, block, cwd, openFile, inspect, t }: ? 'error' : model.state const singleFile = model.filePath !== undefined - const row = ( + return ( ) - // A read-declaring tool without its own keyed row lands here (e.g. web_fetch), - // so the file's read card is resident below the summary row exactly as the - // keyed ReadRow draws it. Only wrap when a card is present, so every other - // tool keeps the bare ToolRow. - if (read !== null) { - return ( -
- {row} - -
- ) - } - // A web-declaring tool without its own keyed row lands here; its card is - // resident under the summary, mirroring WebRow (and BashRow's terminal card). - if (web === null) return row - return ( -
- {row} - -
- ) } diff --git a/packages/client/ui-conversation/src/client/chat/ToolRow.module.css b/packages/client/ui-conversation/src/client/chat/ToolRow.module.css index 44511ee03d..81e41b066f 100644 --- a/packages/client/ui-conversation/src/client/chat/ToolRow.module.css +++ b/packages/client/ui-conversation/src/client/chat/ToolRow.module.css @@ -248,13 +248,18 @@ /* The block-shaped expanded bodies: the code variant's run_code program through CodeBlock (shiki-highlighted TypeScript), a terminal card's command - output through TerminalBlock, and a search card's grouped matches or path - list through SearchBlock. All are drawn by the shared primitive, so only the - row's indentation is this file's concern — the margin also replaces each + output through TerminalBlock, a diff card through DiffBlock, a read card's + line-numbered window through ReadBlock, a search card's grouped matches or + path list through SearchBlock, and a web card's citation/source list through + WebBlock. All are drawn by the shared primitive, so only the row's + indentation is this file's concern — the margin also replaces each primitive's own standalone vertical spacing with the flow's row rhythm. */ .codeBody, .terminalBody, -.searchBody { +.diffBody, +.readBody, +.searchBody, +.webBody { margin: 4px 0 4px 4px; } @@ -269,12 +274,6 @@ color: var(--dsw-alias-label-tertiary); } -/* A write/edit diff renders through DiffBlock; like the terminal card it draws - its own surface, so only the row indentation is this file's concern. */ -.diffBody { - margin: 4px 0 4px 4px; -} - /* In-row code renders at the smaller code size (12/18) via each primitive's rebindable content-font seam; standalone markdown code blocks keep 13/22. */ .codeBody { @@ -290,3 +289,15 @@ --dsl-terminal-output-max-height: 224px; border: 1px 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. */ +.visuallyHidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; +} diff --git a/packages/client/ui-conversation/src/client/chat/ToolRow.tsx b/packages/client/ui-conversation/src/client/chat/ToolRow.tsx index 95e14771d5..46cdbda191 100644 --- a/packages/client/ui-conversation/src/client/chat/ToolRow.tsx +++ b/packages/client/ui-conversation/src/client/chat/ToolRow.tsx @@ -3,26 +3,33 @@ // separator dot + FILL-truncated summary, drawn through the shared // DisclosureRow chrome with the whole row as the expand toggle (click / // Enter / Space, icon→chevron hover preview). The collapsed row is always -// one line; every row with body, output, terminal, or search material is -// expandable; the summary stays inline while open, except Think, whose body -// opens with the same first line and would repeat it. +// one line; every row with body, output, or a card material (terminal, diff, +// read, search, web) is expandable; the summary stays inline while open, +// except Think, whose body opens with the same first line and would repeat it. // The expanded body — an IN/OUT gutter-labeled card (figma 1249:35657) for -// text input/output, the run_code program through CodeBlock, a terminal -// card's command output through TerminalBlock, or a search card's grouped -// matches / path list through SearchBlock (capped at CHAT_SEARCH_MAX_LINES) — -// lives in a max-height scroll container so a long payload scrolls internally -// instead of taking over the message flow; Think's prose is the exception and -// flows uncapped like message text. Expand state is component-local view state. -// File-tool summaries are path links that open through the host (stopPropagation -// keeps the two gestures independent); an error row's collapsed summary is the -// failure's first line in the error color. +// text input/output, the run_code program through CodeBlock, or a card +// primitive (TerminalBlock, DiffBlock, ReadBlock, SearchBlock, WebBlock) for a +// call that declared that render intent — lives in a max-height scroll +// container so a long payload scrolls internally instead of taking over the +// message flow; Think's prose is the exception and flows uncapped like message +// text. Every card kind starts collapsed, so a run of tool calls stays +// scannable; the details panel is the single-call full-height reading surface. +// Expand state is component-local view state. File-tool summaries are path +// links that open through the host (stopPropagation keeps the two gestures +// independent); an error row's collapsed summary is the failure's first line in +// the error color. -import { useState, type MouseEvent, type ReactNode } from 'react' +import { useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react' import clsx from 'clsx' -import { CodeBlock, DiffBlock, SearchBlock, StateDot, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives' +import { + CodeBlock, DiffBlock, ReadBlock, SearchBlock, StateDot, TerminalBlock, WebBlock, +} from '@deepseek-ai/dsh-client-ui-primitives' +import type { WebBlockProps } from '@deepseek-ai/dsh-client-ui-primitives' import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' -import { CHAT_SEARCH_MAX_LINES, type SearchCardModel } from '../contract/search-card-model.ts' import { CHAT_DIFF_MAX_LINES, type DiffCardModel } from '../contract/diff-card-model.ts' +import { CHAT_READ_MAX_LINES, type ReadCardModel } from '../contract/read-card-model.ts' +import { CHAT_SEARCH_MAX_LINES, type SearchCardModel } from '../contract/search-card-model.ts' +import { CHAT_WEB_MAX_SOURCES } from '../contract/web-card-model.ts' import { terminalBlockLabels, type TerminalCardModel } from '../contract/terminal-card-model.ts' import type { ToolRowState, ToolRowVariant } from '../contract/tool-call-model.ts' import { DisclosureRow } from './DisclosureRow.tsx' @@ -47,24 +54,34 @@ export interface ToolRowProps { /** * Terminal-card material for a call whose render intent is a terminal card * (derived by `terminalCardModel`); it replaces the text sections when - * present. A row with no body, no output, and no terminal material is not - * expandable. + * present. A call carries at most one card kind, so the card props below are + * mutually exclusive. */ terminal?: TerminalCardModel | null | undefined + /** + * Diff-card material for a call whose render intent is a diff card (derived by + * `diffCardModel`); it replaces the text body when present, the same way + * `terminal` does. + */ + diff?: DiffCardModel | null | undefined + /** + * Read-card material for a call whose render intent is a read card (derived by + * `readCardModel`); it replaces the text body with the file's line-numbered, + * syntax-highlighted window when present. + */ + read?: ReadCardModel | null | undefined /** * Search-card material for a call whose render intent is a search card - * (derived by `searchCardModel`); it replaces the text body when present. - * Null or absent leaves the text body. A call carries at most one card kind, - * so `terminal`, `search`, and `diff` are never both present on the same row. + * (derived by `searchCardModel`); it replaces the text body with grouped + * matches or a path list when present. */ search?: SearchCardModel | null | undefined /** - * Diff-card material for a call whose render intent is a diff card (derived by - * `diffCardModel`); it replaces the text body when present, the same way - * `terminal` does. A call carries at most one card intent, so the cards are - * never both set. + * Web-card material for a call whose render intent is a web card (derived by + * `webCardModel`); it replaces the text body with the retrieval's citation + * list or fetched-source card when present. */ - diff?: DiffCardModel | null | undefined + web?: WebBlockProps | null | undefined state: ToolRowState /** * Filesystem path from tool args; when set with onOpenFile, the summary @@ -101,6 +118,19 @@ function leadingFor(state: ToolRowState, icon: ReactNode): ReactNode { } } +/** 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). */ +function stateStatus(state: ToolRowState, t: TranslateNS<'conversation'>): 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 + } +} + export function ToolRow({ t, variant, @@ -112,8 +142,10 @@ export function ToolRow({ output, errorSummary, terminal, - search, diff, + read, + search, + web, state, filePath, onOpenFile, @@ -121,13 +153,20 @@ export function ToolRow({ }: ToolRowProps) { const [expanded, setExpanded] = useState(false) const terminalBody = terminal ?? null - const searchBody = search ?? null const diffBody = diff ?? null + const readBody = read ?? null + const searchBody = search ?? null + const webBody = web ?? null const outputText = output ?? null - // A search or diff card replaces the text body; a call carries at most one - // card kind, so terminal, search, and diff are never both present on a row. - const expandable = body !== null || outputText !== null || terminalBody !== null || searchBody !== null || diffBody !== null + // A card replaces the text body; a call carries at most one card kind, so the + // card props are mutually exclusive. Any of them, or a text body/output, + // makes the row expandable. + const card = terminalBody ?? diffBody ?? readBody ?? searchBody ?? webBody + const expandable = body !== null || outputText !== null || card !== null const open = expanded && expandable + // The run-state label AT needs: the StateDot and the running sweep are both + // aria-hidden / colour-only, so a stopped or running row is otherwise silent. + const status = stateStatus(state, t) // An error row's collapsed summary IS the failure: the first error line in // the error color outranks both the args summary and a terminal description. const failureLine = state === 'error' ? errorSummary ?? null : null @@ -141,6 +180,13 @@ export function ToolRow({ event.stopPropagation() if (filePath !== undefined) onOpenFile?.(filePath) } + // Keep Enter/Space on the focused path link from bubbling to the row's + // keydown handler, which would preventDefault() the key and toggle expand + // instead of activating the link — the keyboard analogue of openFile's + // stopPropagation. The native button still fires its own onClick from the key. + const fileLinkKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Enter' || event.key === ' ') event.stopPropagation() + } // Think reasoning is prose, not an input payload: expanded, it renders as // plain indented text (no IN/OUT card) and the inline summary — the body's // own first line — yields to avoid repeating itself. @@ -153,6 +199,7 @@ export function ToolRow({ // of losing it with the icon. return (
+ {status !== null && {status}} {summaryText} @@ -198,51 +246,55 @@ export function ToolRow({ className={css.terminalBody} /> ) - : searchBody !== null - ? ( - <> - - {/* A capped search's recovery locator lives only in the result - text; show it below the card so the dropped rows survive. */} - {searchBody.recovery !== undefined && ( -
{searchBody.recovery}
- )} - - ) - : diffBody !== null - ? - : isThink - ?
{body}
- : ( + : diffBody !== null + ? + : readBody !== null + ? + : searchBody !== null + ? ( <> - {variant === 'code' && body !== null && ( -
- -
- )} - {(cardBody !== null || outputText !== null) && ( -
- {cardBody !== null && ( -
- IN - {cardBody} -
- )} - {cardBody !== null && outputText !== null && ( - - )} - {outputText !== null && ( -
- OUT - - {outputText} - -
- )} -
+ + {/* A capped search's recovery locator lives only in the result + text; show it below the card so the dropped rows survive. */} + {searchBody.recovery !== undefined && ( +
{searchBody.recovery}
)} - )} + ) + : webBody !== null + ? + : isThink + ?
{body}
+ : ( + <> + {variant === 'code' && body !== null && ( +
+ +
+ )} + {(cardBody !== null || outputText !== null) && ( +
+ {cardBody !== null && ( +
+ IN + {cardBody} +
+ )} + {cardBody !== null && outputText !== null && ( + + )} + {outputText !== null && ( +
+ OUT + + {outputText} + +
+ )} +
+ )} + + )} {inspect !== undefined && ( - ) : ( - {model.summary} - )} -
- {diff !== null && ( - - )} - {failure !== null &&
{failure}
} -
+ } + title={model.title} + summary={model.summary} + body={null} + output={model.output} + errorSummary={model.errorSummary} + diff={diff} + state={model.state} + filePath={model.filePath} + onOpenFile={openFile} + inspect={inspect} + /> ) } @@ -87,7 +67,7 @@ export const fileMutationToolview = { * @param ctx - registrant context (disposal rides ctx.effect inside slots.register). */ apply(ctx: Context): void { - ctx.slots.register({ name: 'conversation.chat.toolview', key: 'edit' }, FileMutationRow) - ctx.slots.register({ name: 'conversation.chat.toolview', key: 'write' }, FileMutationRow) + ctx.slots.register({ name: 'conversation.chat.toolview', key: 'edit', locale: NS }, FileMutationRow) + ctx.slots.register({ name: 'conversation.chat.toolview', key: 'write', locale: NS }, FileMutationRow) }, } diff --git a/packages/client/ui-conversation/src/client/toolviews/read-row.module.css b/packages/client/ui-conversation/src/client/toolviews/read-row.module.css deleted file mode 100644 index a03a949451..0000000000 --- a/packages/client/ui-conversation/src/client/toolviews/read-row.module.css +++ /dev/null @@ -1,119 +0,0 @@ -/* Read toolview: same geometry/tokens as ToolRow (figma Read · {path}), plus - the read card the row stacks under its summary line. */ - -/* Summary line over the read card; the summary row keeps its own 24px height, - so the card is a column around it rather than a change to it. */ -.card { - display: flex; - flex-direction: column; -} - -/* Row indentation matches ToolRow's expanded bodies (16px leading + 6px gap), - and replaces the primitive's standalone vertical margin with the flow's. */ -.read { - margin: 4px 0 4px 22px; -} - -.root { - position: relative; /* sweep-glare overlay anchor */ - overflow: hidden; - display: flex; - align-items: center; - height: 24px; - min-width: 0; -} - -/* Running sweep glare — same pattern as BashRow/ToolRow, so a running read row - gives the same executing feedback a running command row does. The leading - read icon stays static (a read has no per-step state to animate); the sweep - is the row-level running signal. */ -.root[data-state='running']::after { - content: ''; - position: absolute; - top: 0; - bottom: 0; - left: 0; - width: 300px; - background: linear-gradient( - 90deg, - transparent 0%, - color-mix(in srgb, var(--dsw-alias-bg-base) 60%, transparent) 55%, - transparent 100% - ); - animation: dsh-read-row-sweep 2.6s ease-out infinite; - pointer-events: none; -} - -@keyframes dsh-read-row-sweep { - 0% { left: -300px; } - 90%, 100% { left: 100%; } -} - -.leading { - flex: none; - width: 16px; - height: 16px; - display: inline-flex; - align-items: center; - justify-content: center; - margin-right: 6px; - color: var(--dsw-alias-label-tertiary); -} - -.title { - flex: none; - font-size: 14px; - line-height: 24px; - color: var(--dsw-alias-label-secondary); -} - -.sep { - flex: none; - width: 2px; - height: 2px; - border-radius: 1px; - margin: 0 8px; - background: var(--dsw-alias-label-caption); -} - -.summary { - flex: 1 1 auto; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-size: 14px; - line-height: 24px; - color: var(--dsw-alias-label-tertiary); -} - -/* File path: same geometry as .summary; hover underline + pointer. */ -.fileLink { - flex: 1 1 auto; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - margin: 0; - padding: 0; - border: none; - background: none; - text-align: left; - font-size: 14px; - line-height: 24px; - color: var(--dsw-alias-label-tertiary); - cursor: pointer; -} - -.fileLink:hover { - text-decoration: underline; -} - -.visuallyHidden { - position: absolute; - width: 1px; - height: 1px; - overflow: hidden; - clip: rect(0 0 0 0); - white-space: nowrap; -} diff --git a/packages/client/ui-conversation/src/client/toolviews/read-row.tsx b/packages/client/ui-conversation/src/client/toolviews/read-row.tsx index 4527f85537..8d3694eeef 100644 --- a/packages/client/ui-conversation/src/client/toolviews/read-row.tsx +++ b/packages/client/ui-conversation/src/client/toolviews/read-row.tsx @@ -1,88 +1,49 @@ -// Read toolview registrant: the keyed toolview hole for the read tool -// (ctx.slots.register + ToolRowProps only — never imports the chat domain). -// Product chrome matches ToolRow (figma: Read · {path}); the summary is the -// file path as an openable link, exactly as the generic read row draws it. -// -// A read RESULT declares the read render intent, so this row renders the file's -// own line-numbered, syntax-highlighted content through ReadBlock resident -// below its summary line — the same posture BashRow gives a terminal card. The -// card is capped at CHAT_READ_MAX_LINES (the chat flow's tighter cap over the -// block's own default of 16) with the block's internal expander keeping a long -// read from taking over the message flow. A running read (no result yet) and a -// non-read result both render the summary row alone. The read intent is +// Read toolview registrant: the keyed toolview hole for the read tool. The row +// composes the shared ToolRow (chrome, running sweep, whole-row expand) and +// feeds it the file's line-numbered, syntax-highlighted content as ToolRow's +// `read` card material, so it renders through ReadBlock in the collapsed-by- +// default expanded body — the same unified interaction every other card row +// has. The summary path is an openable host link. A running read (no result +// yet) and a non-read result render the summary row alone: the read intent is // result-side only, so there is no running-state read card to draw. import type { Context } from 'cordis' -import { IconBrowseOutline16, ReadBlock, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconBrowseOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { ToolRowProps } from '../contract/slots.ts' -import { CHAT_READ_MAX_LINES, readCardModel } from '../contract/read-card-model.ts' -import { toolRowModel, type ToolRowState } from '../contract/tool-call-model.ts' -import css from './read-row.module.css' +import { readCardModel } from '../contract/read-card-model.ts' +import { toolRowModel } from '../contract/tool-call-model.ts' +import { ToolRow } from '../chat/ToolRow.tsx' +import { NS } from '../locales.ts' -/** Leading-slot state substitution: the tool icon yields to the state dot - * (error = red, interrupted = amber). Running keeps the icon. */ -function leadingFor(state: ToolRowState) { - switch (state) { - case 'error': return - case 'stopped': return - default: return - } -} - -/** Visually hidden status — StateDot is aria-hidden; AT needs a text label. */ -function stateStatus(state: ToolRowState): string | null { - switch (state) { - case 'running': return '运行中' - case 'error': return '失败' - case 'stopped': return '已停止' - default: return null - } -} +/** Full row props: the toolview runtime share plus the standard locale seat. */ +type ReadRowProps = ToolRowProps & PropsLocale<'conversation'> /** * Read row: icon + Read · {path} in the shared ToolRow chrome, with the file's - * read card resident below it. The summary path is an openable host link when - * the row names a single file; the card's copy and expand controls plus that - * link are the row's only interactions (tool rows are not details-panel - * targets). + * read card as the row's collapsed-by-default card body. The summary path is an + * openable host link when the row names a single file. */ -export function ReadRow({ toolName, block, sessionId, useSessions, openFile }: ToolRowProps) { - // Session workspace root: the read view's path relativizes against it (a - // workspace-rooted absolute path shows its short form), which the pure - // presenter cannot do. - const cwd = useSessions(list => list.byId[sessionId]?.cwd) +export function ReadRow({ toolName, block, cwd, openFile, inspect, t }: ReadRowProps) { const model = toolRowModel(toolName, block, cwd) const read = readCardModel(block, cwd) - const status = stateStatus(model.state) - const filePath = model.filePath return ( -
- {/* jscpd:ignore-start — the summary-line chrome (leading, status, title, - sep, path-link/summary) is the shared ToolRow row shape every keyed - toolview draws; extracting it into one component is a separate change - tracked for all rows at once, not this read-card PR. */} -
- {leadingFor(model.state)} - {status !== null && {status}} - {model.title} - - {filePath !== undefined ? ( - - ) : ( - {model.summary} - )} -
- {/* jscpd:ignore-end */} - {read !== null && ( - - )} -
+ } + title={model.title} + summary={model.summary} + body={null} + output={model.output} + errorSummary={model.errorSummary} + read={read} + state={model.state} + filePath={model.filePath} + onOpenFile={openFile} + inspect={inspect} + /> ) } @@ -100,6 +61,6 @@ export const readToolview = { * @param ctx - registrant context (disposal rides ctx.effect inside slots.register). */ apply(ctx: Context): void { - ctx.slots.register({ name: 'conversation.chat.toolview', key: 'read' }, ReadRow) + ctx.slots.register({ name: 'conversation.chat.toolview', key: 'read', locale: NS }, ReadRow) }, } diff --git a/packages/client/ui-conversation/src/client/toolviews/search-row.module.css b/packages/client/ui-conversation/src/client/toolviews/search-row.module.css deleted file mode 100644 index 21908bd9e1..0000000000 --- a/packages/client/ui-conversation/src/client/toolviews/search-row.module.css +++ /dev/null @@ -1,117 +0,0 @@ -/* Search toolview: same geometry/tokens as ToolRow and BashRow (figma - Search · summary), plus the search card the row stacks resident under its - summary line. */ - -/* Summary line over the search card; the summary row keeps its own 24px - height, so the card is a column around it rather than a change to it. */ -.card { - display: flex; - flex-direction: column; -} - -/* Row indentation matches ToolRow's expanded bodies (16px leading + 6px gap), - and replaces the primitive's standalone vertical margin with the flow's. */ -.search { - margin: 4px 0 4px 22px; -} - -.root { - position: relative; /* sweep-glare overlay anchor */ - overflow: hidden; - display: flex; - align-items: center; - height: 24px; - min-width: 0; -} - -/* Running sweep glare — same deepsuite ShimmerText pattern as ToolRow / BashRow. */ -.root[data-state='running']::after { - content: ''; - position: absolute; - top: 0; - bottom: 0; - left: 0; - width: 300px; - background: linear-gradient( - 90deg, - transparent 0%, - color-mix(in srgb, var(--dsw-alias-bg-base) 60%, transparent) 55%, - transparent 100% - ); - animation: dsh-search-row-sweep 2.6s ease-out infinite; - pointer-events: none; -} - -@keyframes dsh-search-row-sweep { - 0% { left: -300px; } - 90%, 100% { left: 100%; } -} - -.leading { - flex: none; - width: 16px; - height: 16px; - display: inline-flex; - align-items: center; - justify-content: center; - margin-right: 6px; - color: var(--dsw-alias-label-tertiary); -} - -.title { - flex: none; - font-size: 14px; - line-height: 24px; - color: var(--dsw-alias-label-secondary); -} - -.sep { - flex: none; - width: 2px; - height: 2px; - border-radius: 1px; - margin: 0 8px; - background: var(--dsw-alias-label-caption); -} - -.summary { - flex: 1 1 auto; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-size: 14px; - line-height: 24px; - color: var(--dsw-alias-label-tertiary); -} - -.visuallyHidden { - position: absolute; - width: 1px; - height: 1px; - overflow: hidden; - clip: rect(0 0 0 0); - white-space: nowrap; -} - -/* The result text for an errored search, indented to the card's own column and - in the error tone, standing in for the search card the failure path does not - produce. */ -.failure { - margin: 4px 0 4px 22px; - white-space: pre-wrap; - overflow-wrap: anywhere; - font: var(--dsw-font-xs-13); - color: var(--dsw-alias-state-error-primary); -} - -/* The recovery footer for a capped search: the model-facing result text (its - `Full … stored at …` locator) shown below the card in the muted tone, since - the card holds only the retained rows. Same column indent as the card body. */ -.recovery { - margin: 4px 0 4px 22px; - white-space: pre-wrap; - overflow-wrap: anywhere; - font: var(--dsw-font-xs-13); - color: var(--dsw-alias-label-tertiary); -} diff --git a/packages/client/ui-conversation/src/client/toolviews/search-row.tsx b/packages/client/ui-conversation/src/client/toolviews/search-row.tsx index 5ea72e4a25..0e8a90ed7a 100644 --- a/packages/client/ui-conversation/src/client/toolviews/search-row.tsx +++ b/packages/client/ui-conversation/src/client/toolviews/search-row.tsx @@ -1,76 +1,62 @@ -// Search toolview registrant: the keyed toolview hole (ctx.slots.register + -// ToolRowProps only — never imports the chat domain). One SearchRow component -// registered under both `grep` and `glob`, since both tools declare the same -// `card: 'search'` render intent and render as one visual object; the row reads -// the `kind` discriminant off the derived model to draw grouped matches or a -// path list. Product chrome matches ToolRow / BashRow (Search · {summary}). -// -// A search call declares its render intent result-time only, so this row's -// search card is resident below the summary rather than expand-gated: the row -// itself has no expand control, and the card's own copy, per-file collapse, and -// head/tail expand are the row's only interactions. CHAT_SEARCH_MAX_LINES is -// passed as `maxLines` — the chat flow's tighter cap over the block's own -// default of 16 — so a large result stays bounded in the message flow. +// Search toolview registrant: the keyed toolview hole for the `grep` and `glob` +// tools. One SearchRow component registered under both, since both declare the +// same `card: 'search'` render intent and render as one visual object; the +// derived model's `kind` decides the card shape (grouped matches or a path +// list). The row composes the shared ToolRow (chrome, running sweep, whole-row +// expand) and feeds it the completed search as ToolRow's `search` card +// material, so it renders through SearchBlock in the collapsed-by-default +// expanded body — with a capped search's recovery footer below the card. A +// search declares its render intent result-time only, so a running row is the +// summary line alone; a settled call with no search card (an errored search, a +// nested run_code sub-dispatch, a legacy generic result) surfaces its +// model-facing text through ToolRow's Output section instead. import type { Context } from 'cordis' -import { IconSearchOutline16, SearchBlock, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconSearchOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { ToolRowProps } from '../contract/slots.ts' -import { CHAT_SEARCH_MAX_LINES, searchCardModel } from '../contract/search-card-model.ts' -import { toolRowModel, type ToolRowState } from '../contract/tool-call-model.ts' -import { rowResultText, rowStateStatus } from '../contract/toolview-status.ts' -import css from './search-row.module.css' +import { searchCardModel } from '../contract/search-card-model.ts' +import { toolRowModel } from '../contract/tool-call-model.ts' +import { ToolRow } from '../chat/ToolRow.tsx' +import { NS } from '../locales.ts' -/** Leading-slot glyph substitution: the search icon yields to the terminal - * state semantic (error = red, interrupted = amber). Running keeps the icon — - * the row sweep carries the in-flight signal. */ -function leadingFor(state: ToolRowState) { - switch (state) { - case 'error': return - case 'stopped': return - default: return - } -} +/** Full row props: the toolview runtime share plus the standard locale seat. */ +type SearchRowProps = ToolRowProps & PropsLocale<'conversation'> /** * Search row: icon + Search · {summary} in the shared ToolRow chrome, with the - * completed search's card resident below it, and — when the result was capped — - * the recovery footer below the card. The summary row is not a details-panel - * control, so the card's copy, per-file collapse, and expand controls are the - * row's only interactions. Registered under both `grep` and `glob`; the derived - * model's `kind` decides the card shape. + * completed search's card as the row's collapsed-by-default card body (a capped + * search's recovery footer rides below it, inside ToolRow). Registered under + * both `grep` and `glob`; the derived model's `kind` decides the card shape. A + * settled call with no search card surfaces its model-facing text through + * ToolRow's Output section, since the keyed SearchRow owns this render slot. */ -export function SearchRow({ toolName, block }: ToolRowProps) { +export function SearchRow({ toolName, block, inspect, t }: SearchRowProps) { const model = toolRowModel(toolName, block) const search = searchCardModel(block) - const status = rowStateStatus(model.state) - // A settled call with no search card — an errored search (grep/glob emit no - // result view on error), a successful nested run_code sub-dispatch, or a - // legacy generic result — has its model-facing text nowhere else to go, since - // the keyed SearchRow owns this render slot. Surface it as the fallback body. - // A running call ('kind' absent) has no result to flatten; rowResultText - // returns null for it, so the arm stays closed until settle. - const settled = 'kind' in block - const fallback = search === null && settled ? rowResultText(block) : null return ( -
-
- {leadingFor(model.state)} - {status !== null && {status}} - {model.title} - - {/* The result view's replacement title outranks the args-derived - summary, matching the terminal card's description precedence. */} - {search?.title ?? model.summary} -
- {search !== null && ( - - )} - {/* A capped search drops rows from the card; its recovery locator (the - `Full … stored at …` footer) lives only in the result text, so show it - below the card so the one path to the dropped rows survives. */} - {search?.recovery !== undefined &&
{search.recovery}
} - {fallback !== null &&
{fallback}
} -
+ } + title={model.title} + // The result view's replacement title outranks the args-derived summary, + // matching the terminal card's description precedence. + summary={search?.title ?? model.summary} + body={null} + // A settled call with no search card (errored search, nested run_code + // sub-dispatch, legacy generic result) has its text nowhere else to go; + // ToolRow's Output section carries it, and errorSummary its first line. + // When a card is present ToolRow renders it instead of the output, so + // passing model.output unconditionally is safe and keeps the four card + // rows symmetric. + output={model.output} + errorSummary={model.errorSummary} + search={search} + state={model.state} + inspect={inspect} + /> ) } @@ -90,7 +76,7 @@ export const searchToolview = { * @param ctx - registrant context (disposal rides ctx.effect inside slots.register). */ apply(ctx: Context): void { - ctx.slots.register({ name: 'conversation.chat.toolview', key: 'grep' }, SearchRow) - ctx.slots.register({ name: 'conversation.chat.toolview', key: 'glob' }, SearchRow) + ctx.slots.register({ name: 'conversation.chat.toolview', key: 'grep', locale: NS }, SearchRow) + ctx.slots.register({ name: 'conversation.chat.toolview', key: 'glob', locale: NS }, SearchRow) }, } diff --git a/packages/client/ui-conversation/src/client/toolviews/web-row.module.css b/packages/client/ui-conversation/src/client/toolviews/web-row.module.css deleted file mode 100644 index 0b1519e218..0000000000 --- a/packages/client/ui-conversation/src/client/toolviews/web-row.module.css +++ /dev/null @@ -1,95 +0,0 @@ -/* Web toolview: same geometry/tokens as ToolRow (figma icon · summary), plus - the web card the row stacks under its summary line, mirroring the bash row's - resident terminal card. */ - -/* Summary line over the web card; the summary row keeps its own 24px height, - so the card is a column around it rather than a change to it. */ -.card { - display: flex; - flex-direction: column; -} - -/* Row indentation matches ToolRow's expanded bodies (16px leading + 6px gap), - and replaces the primitive's standalone vertical margin with the flow's. */ -.web { - margin: 4px 0 4px 22px; -} - -.root { - position: relative; /* sweep-glare overlay anchor */ - overflow: hidden; - display: flex; - align-items: center; - height: 24px; - min-width: 0; -} - -/* Running sweep glare — same deepsuite ShimmerText pattern as ToolRow. */ -.root[data-state='running']::after { - content: ''; - position: absolute; - top: 0; - bottom: 0; - left: 0; - width: 300px; - background: linear-gradient( - 90deg, - transparent 0%, - color-mix(in srgb, var(--dsw-alias-bg-base) 60%, transparent) 55%, - transparent 100% - ); - animation: dsh-web-row-sweep 2.6s ease-out infinite; - pointer-events: none; -} - -@keyframes dsh-web-row-sweep { - 0% { left: -300px; } - 90%, 100% { left: 100%; } -} - -.leading { - flex: none; - width: 16px; - height: 16px; - display: inline-flex; - align-items: center; - justify-content: center; - margin-right: 6px; - color: var(--dsw-alias-label-tertiary); -} - -.title { - flex: none; - font-size: 14px; - line-height: 24px; - color: var(--dsw-alias-label-secondary); -} - -.sep { - flex: none; - width: 2px; - height: 2px; - border-radius: 1px; - margin: 0 8px; - background: var(--dsw-alias-label-caption); -} - -.summary { - flex: 1 1 auto; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-size: 14px; - line-height: 24px; - color: var(--dsw-alias-label-tertiary); -} - -.visuallyHidden { - position: absolute; - width: 1px; - height: 1px; - overflow: hidden; - clip: rect(0 0 0 0); - white-space: nowrap; -} diff --git a/packages/client/ui-conversation/src/client/toolviews/web-row.tsx b/packages/client/ui-conversation/src/client/toolviews/web-row.tsx index b86c523a26..4d8ab5f59f 100644 --- a/packages/client/ui-conversation/src/client/toolviews/web-row.tsx +++ b/packages/client/ui-conversation/src/client/toolviews/web-row.tsx @@ -1,24 +1,25 @@ -// Web toolview registrant: third-party posture over the keyed toolview hole -// (ctx.slots.register + ToolRowProps only — never imports the chat domain). -// Registered under BOTH web_search and web_fetch, since both declare the one -// `web` render intent and render through the one WebBlock family; the row -// discriminates on the toolName only to pick its icon and title. -// -// A web tool declares the `web` render intent at result time, so this row -// renders the completed retrieval through WebBlock resident below its summary, -// the same posture BashRow uses for the terminal card: no expand control on the -// row itself, not a details-panel target, and the block's own expander keeps a -// long source list from taking over the message flow (CHAT_WEB_MAX_SOURCES is -// passed as maxSources — the chat flow's tighter cap over the block's default -// of 16). Until the call settles there is no web card (the tools keep a generic -// pending view), so a running row is the summary line alone. +// Web toolview registrant: the keyed toolview hole for the `web_search` and +// `web_fetch` tools. Registered under BOTH, since both declare the one `web` +// render intent and render through the one WebBlock family; the row +// discriminates on the toolName only to pick its icon and title. The row +// composes the shared ToolRow (chrome, running sweep, whole-row expand) and +// feeds it the completed retrieval as ToolRow's `web` card material, so it +// renders through WebBlock in the collapsed-by-default expanded body — the same +// unified interaction every other card row has. Until the call settles there is +// no web card (the tools keep a generic pending view), so a running row is the +// summary line alone. import type { Context } from 'cordis' -import { IconBrowseOutline16, IconSearchOutline16, StateDot, WebBlock } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconBrowseOutline16, IconSearchOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { ToolRowProps } from '../contract/slots.ts' -import { CHAT_WEB_MAX_SOURCES, webCardModel } from '../contract/web-card-model.ts' -import { toolRowModel, type ToolRowState } from '../contract/tool-call-model.ts' -import css from './web-row.module.css' +import { webCardModel } from '../contract/web-card-model.ts' +import { toolRowModel } from '../contract/tool-call-model.ts' +import { ToolRow } from '../chat/ToolRow.tsx' +import { NS } from '../locales.ts' + +/** Full row props: the toolview runtime share plus the standard locale seat. */ +type WebRowProps = ToolRowProps & PropsLocale<'conversation'> /** web_fetch reads one URL; web_search queries. Titles are figma literals. */ const WEB_TITLES: Record = { @@ -26,49 +27,30 @@ const WEB_TITLES: Record = { web_fetch: 'Fetch', } -/** Leading icon per tool, yielding to the state semantic while failed/stopped. */ -function leadingFor(toolName: string, state: ToolRowState) { - switch (state) { - case 'error': return - case 'stopped': return - // Running keeps the icon — the row sweep carries the in-flight signal. - default: return toolName === 'web_fetch' ? : - } -} - -/** Visually hidden status — StateDot is aria-hidden; AT needs a text label. */ -function stateStatus(state: ToolRowState): string | null { - switch (state) { - case 'running': return '运行中' - case 'error': return '失败' - case 'stopped': return '已停止' - default: return null - } -} - /** * Web row: icon + Search/Fetch · {summary} in the shared ToolRow chrome, with - * the completed retrieval's web card resident below it. The summary row is not - * a details-panel control (tool rows stopped being one), so the card's own - * links and expander are the row's only interactions. + * the completed retrieval's web card as the row's collapsed-by-default card + * body. The row discriminates on `toolName` only to pick its icon and title. */ -export function WebRow({ toolName, block }: ToolRowProps) { +export function WebRow({ toolName, block, inspect, t }: WebRowProps) { const model = toolRowModel(toolName, block) const web = webCardModel(block) - const status = stateStatus(model.state) + const icon = toolName === 'web_fetch' ? : return ( -
-
- {leadingFor(toolName, model.state)} - {status !== null && {status}} - {WEB_TITLES[toolName] ?? model.title} - - {model.summary} -
- {web !== null && ( - - )} -
+ ) } @@ -86,7 +68,7 @@ export const webToolview = { * @param ctx - registrant context (disposal rides ctx.effect inside slots.register). */ apply(ctx: Context): void { - ctx.slots.register({ name: 'conversation.chat.toolview', key: 'web_search' }, WebRow) - ctx.slots.register({ name: 'conversation.chat.toolview', key: 'web_fetch' }, WebRow) + ctx.slots.register({ name: 'conversation.chat.toolview', key: 'web_search', locale: NS }, WebRow) + ctx.slots.register({ name: 'conversation.chat.toolview', key: 'web_fetch', locale: NS }, WebRow) }, } diff --git a/packages/client/ui-conversation/tests/chat-apply.spec.tsx b/packages/client/ui-conversation/tests/chat-apply.spec.tsx index 33342a49e5..d3fabd9bee 100644 --- a/packages/client/ui-conversation/tests/chat-apply.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-apply.spec.tsx @@ -88,15 +88,15 @@ describe('apply wiring', () => { await b.runtime.dispose() }) - it('mounts the bash sample, the search rows, the read row, the file-mutation rows, the web rows, and the product rows as keyed entries through the load-order seam', async () => { + it('mounts the bash sample, the read row, the file-mutation rows, the search rows (grep + glob), the web rows, and the product rows as keyed entries through the load-order seam', async () => { const b = await bench() // Every registrant plugin's inject: ['slots', 'conversation'] resolved — the // service being present implies the chat entry declared the hole first. The - // one search row registers under both grep and glob; the file-mutation - // registrant claims both write and edit for the diff card; the web rows - // register one component under both web tool names. + // file-mutation registrant claims both write and edit for the diff card; the + // one search row registers under both grep and glob; the web rows register + // one component under both web tool names. const entries = b.slots.entries('conversation.chat.toolview') - expect(entries.map(e => e.options.key)).toEqual(['bash', 'grep', 'glob', 'read', 'edit', 'write', 'web_search', 'web_fetch', 'todo_write', 'ask_user_question']) + expect(entries.map(e => e.options.key)).toEqual(['bash', 'read', 'edit', 'write', 'grep', 'glob', 'web_search', 'web_fetch', 'todo_write', 'ask_user_question']) // Stats stick with the composer (not inside ChatView). expect(b.slots.entries('conversation.composer.dock').map(e => e.options.id)).toEqual(['stats']) await b.runtime.dispose() diff --git a/packages/client/ui-conversation/tests/diff-card.spec.tsx b/packages/client/ui-conversation/tests/diff-card.spec.tsx index 4b23ff6f20..8744e6b802 100644 --- a/packages/client/ui-conversation/tests/diff-card.spec.tsx +++ b/packages/client/ui-conversation/tests/diff-card.spec.tsx @@ -12,7 +12,7 @@ import type { ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, WorkspaceListState, } from '@deepseek-ai/dsh-client-runtime/client' import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client' -import type { SelectionTarget, ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { CHAT_DIFF_MAX_LINES, diffCardModel } from '../src/client/contract/diff-card-model.ts' @@ -24,6 +24,9 @@ import { zh } from '../src/client/locales.ts' afterEach(cleanup) +/** FileMutationRow's full prop shape (ToolRow runtime share + conversation locale seat). */ +type FileMutationRowProps = Parameters[0] + const SID = 's1' as SessionId const t = makeTranslate(zh, commonZh) @@ -155,14 +158,23 @@ describe('FileMutationRow diff card', () => { phase: 'ready', }) - const rowProps = (block: RunningToolCall | ToolResultNode, toolName = 'edit'): ToolRowProps => ({ + const rowProps = (block: RunningToolCall | ToolResultNode, toolName = 'edit'): FileMutationRowProps => ({ callId: 'c1', toolName, block, openFile: vi.fn(), cwd: '/w/app', sessionId: SID, useSessions: bindSnapshotSelector(list()), - } as unknown as ToolRowProps) + t, + } as unknown as FileMutationRowProps) - it('renders the applied diff under the summary row, without an expand gesture', () => { + /** The whole summary row is the expand toggle (ToolRow's unified interaction). */ + const toggleRow = (view: { container: HTMLElement }) => { + fireEvent.click(view.container.querySelector('[data-expandable]')!) + } + + it('collapses to the summary row; expanding reveals the applied diff card', () => { const view = render() - // The diff card is resident (no expand toggle needed). + // The diff card is collapsed by default — not in the DOM until expanded. + expect(view.container.querySelector('[data-diff]')).toBeNull() + expect(view.queryByText('hello fixture')).toBeNull() + toggleRow(view) expect(view.container.querySelector('[data-diff]')).not.toBeNull() expect(view.getByText('hello fixture')).toBeTruthy() expect(view.getByText('复制')).toBeTruthy() @@ -171,6 +183,7 @@ describe('FileMutationRow diff card', () => { it('the summary is a path link that opens the tool path through the host', () => { const openFile = vi.fn() const view = render() + // The path link rides the collapsed summary, so it opens without expanding. fireEvent.click(view.getByRole('button', { name: 'notes/demo.txt' })) // The row passes the tool's own path; the injected openFile resolves it // against the session cwd (apply.ts), so the row must not resolve twice. @@ -184,6 +197,8 @@ describe('FileMutationRow diff card', () => { callView: { card: 'diff', title: 'Write notes/new.txt', diffs: [{ path: 'notes/new.txt', oldText: null, newText: 'hello fixture' }] }, resultView: { card: 'diff', title: 'Write notes/new.txt', diffs: [{ path: 'notes/new.txt', oldText: null, newText: 'hello fixture' }] }, }), 'write')} />) + // The footer counts live inside the collapsed diff card. + toggleRow(view) expect(view.getByText('└ +1 -0 · 1 file')).toBeTruthy() }) @@ -197,13 +212,16 @@ describe('FileMutationRow diff card', () => { it('a mutation call with no diff view renders the summary row alone', () => { const view = render() + // No diff material: expanding shows the args-JSON body, never a diff card. + expect(view.container.querySelector('[data-diff]')).toBeNull() + toggleRow(view) expect(view.container.querySelector('[data-diff]')).toBeNull() }) it('surfaces the result text when an errored mutation has no diff card', () => { // write/edit return undefined from presentResult on isError, so the failure - // has no diff — the row shows the model-facing error text instead of a bare - // red dot. + // has no diff — ToolRow shows the model-facing error text as the collapsed + // summary's first line (errorSummary) instead of a bare red dot. const view = render( { expect(view.getByText('ToolError: sandbox_denied')).toBeTruthy() }) - it('shows no failure text for a successful diff or a running call', () => { + it('shows no error summary for a successful diff or a running call', () => { + // ToolRow's error-color summary line is set only on the error state. const ok = render() - expect(ok.container.querySelector('[class*="_failure_"]')).toBeNull() + expect(ok.container.querySelector('[class*="_errorSummary_"]')).toBeNull() cleanup() const run = render() - expect(run.container.querySelector('[class*="_failure_"]')).toBeNull() + expect(run.container.querySelector('[class*="_errorSummary_"]')).toBeNull() }) it('shows the stopped state when the call was interrupted', () => { @@ -234,7 +253,8 @@ describe('FileMutationRow diff card', () => { error: { name: 'ToolError', code: 'interrupted' }, }))} />) expect(view.container.querySelector('[data-state="stopped"]')).not.toBeNull() - // The visually-hidden status label carries the stopped semantic for AT. + // 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() }) @@ -250,12 +270,12 @@ describe('FileMutationRow diff card', () => { describe('fileMutationToolview registration', () => { it('registers one component under both edit and write, and each disposes', () => { - const registered: { key: string; disposed: boolean }[] = [] + const registered: { key: string; locale: unknown; disposed: boolean }[] = [] const disposers: (() => void)[] = [] const ctx = { slots: { - register: ({ key }: { name: string; key: string }) => { - const entry = { key, disposed: false } + register: ({ key, locale }: { name: string; key: string; locale?: string }) => { + const entry = { key, locale, disposed: false } registered.push(entry) const dispose = () => { entry.disposed = true } disposers.push(dispose) @@ -265,6 +285,8 @@ describe('fileMutationToolview registration', () => { } fileMutationToolview.apply(ctx as never) expect(registered.map(r => r.key).sort()).toEqual(['edit', 'write']) + // Both keys claim the conversation locale seat ToolRow's body copy needs. + expect(registered.map(r => r.locale)).toEqual(['conversation', 'conversation']) // The registrant's inject seam is the load-order contract the row relies on. expect(fileMutationToolview.inject).toEqual(['slots', 'conversation']) // Disposal removes each contribution (packages/AGENTS.md registry contract). diff --git a/packages/client/ui-conversation/tests/read-card.spec.tsx b/packages/client/ui-conversation/tests/read-card.spec.tsx index 11354413b7..b8c08a2bb6 100644 --- a/packages/client/ui-conversation/tests/read-card.spec.tsx +++ b/packages/client/ui-conversation/tests/read-card.spec.tsx @@ -2,8 +2,9 @@ // The read render intent on the web side: the pure readCardModel derivation // over the settled result view, and both conversation render sites that consume // it — the chat tool row (the keyed ReadRow and the GenericToolCard fallback, -// each with the read card resident under the summary) and the details panel's -// Output section. Also pins the keyed 'read' toolview registration. +// each composing ToolRow with the read card as its collapsed-by-default expanded +// body) and the details panel's Output section (resident, full height). Also +// pins the keyed 'read' toolview registration. import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render } from '@testing-library/react' @@ -16,7 +17,7 @@ import type { ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, WorkspaceListState, } from '@deepseek-ai/dsh-client-runtime/client' import type { ToolResultView } from '@deepseek-ai/dsh-client-connection/client' -import type { SelectionTarget, ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client' import { CHAT_READ_MAX_LINES, readCardModel } from '../src/client/contract/read-card-model.ts' import { createChatStore } from '../src/client/stores.ts' import { GenericToolCard, type GenericToolCardProps } from '../src/client/chat/GenericToolCard.tsx' @@ -128,11 +129,19 @@ describe('GenericToolCard read body', () => { callId: 'c1', toolName: 'web_fetch', block, openFile: vi.fn(), t, }) - it('renders the read card resident under the summary, capped tighter than the panel', () => { + /** The whole summary row is the expand toggle (ToolRow's unified interaction). */ + const toggleRow = (view: { container: HTMLElement }) => { + fireEvent.click(view.container.querySelector('[data-expandable]')!) + } + + it('expands to the read card, capped tighter than the panel', () => { expect(CHAT_READ_MAX_LINES).toBeLessThan(16) // web_fetch lands on the read variant without its own keyed row, so the - // fallback card owns the resident read block. + // fallback card owns the read block once expanded. const view = render() + // Collapsed: no read card in the DOM yet. + expect(view.container.querySelector('[data-read]')).toBeNull() + toggleRow(view) expect(view.container.querySelector('[data-read]')).not.toBeNull() expect(contentTexts(view.container)).toContain('export const a = 1') // The gutter keeps the file's own line numbers. @@ -145,6 +154,7 @@ describe('GenericToolCard read body', () => { call: { name: 'echo', argsRaw: '{"text":"x"}' }, callView: null, resultView: null, }), openFile: vi.fn(), t, })} />) + toggleRow(view) expect(view.container.querySelector('[data-read]')).toBeNull() }) @@ -162,19 +172,34 @@ describe('ReadRow keyed toolview', () => { phase: 'ready', }) - const rowProps = (block: RunningToolCall | ToolResultNode): ToolRowProps => ({ + const rowProps = (block: RunningToolCall | ToolResultNode): Parameters[0] => ({ callId: 'c1', toolName: 'read', block, openFile: vi.fn(), sessionId: SID, useSessions: bindSnapshotSelector(list()), - } as unknown as ToolRowProps) + t, + } as unknown as Parameters[0]) - it('renders the file path summary and the resident read card', () => { + /** The whole summary row is the expand toggle (ToolRow's unified interaction). */ + const toggleRow = (view: { container: HTMLElement }) => { + fireEvent.click(view.container.querySelector('[data-expandable]')!) + } + + it('collapses to the path summary; the whole row toggles the read card', () => { const view = render() expect(view.getByText('Read')).toBeTruthy() - // The path appears twice: the row summary link and the card's banner label. + // Collapsed: the path is the summary link alone, and the card is absent. + expect(view.getAllByText('src/a.ts').length).toBe(1) + expect(view.container.querySelector('[data-read]')).toBeNull() + toggleRow(view) + // Expanded: the summary link stays inline and the card's banner label adds a + // second occurrence of the path. expect(view.getAllByText('src/a.ts').length).toBe(2) expect(view.container.querySelector('[data-read]')).not.toBeNull() expect(contentTexts(view.container)).toContain('export const a = 1') expect(view.getByText('显示 3 / 180 行')).toBeTruthy() + // Collapse back in place: the card unmounts, the summary link returns. + toggleRow(view) + expect(view.container.querySelector('[data-read]')).toBeNull() + expect(view.getAllByText('src/a.ts').length).toBe(1) }) it('the path summary opens the file through the host', () => { @@ -212,7 +237,8 @@ describe('ReadRow keyed toolview', () => { const registered: { name: unknown; key?: unknown }[] = [] const ctx = { slots: { register: (options: { name: unknown; key?: unknown }) => { registered.push(options) } } } as unknown as Context readToolview.apply(ctx) - expect(registered).toEqual([{ name: 'conversation.chat.toolview', key: 'read' }]) + // The row composes ToolRow, so it declares its locale namespace at the seat. + expect(registered).toEqual([{ name: 'conversation.chat.toolview', key: 'read', locale: 'conversation' }]) expect(readToolview.inject).toContain('conversation') }) }) diff --git a/packages/client/ui-conversation/tests/search-card.spec.tsx b/packages/client/ui-conversation/tests/search-card.spec.tsx index 4856a7ff1e..75114ae9a2 100644 --- a/packages/client/ui-conversation/tests/search-card.spec.tsx +++ b/packages/client/ui-conversation/tests/search-card.spec.tsx @@ -1,9 +1,10 @@ // @vitest-environment jsdom // The search render intent on the web side: the pure searchCardModel derivation // over resultView, and the conversation render sites that consume it — the chat -// tool row (GenericToolCard's expand-gated body and SearchRow's resident card) -// and the details panel's Output section. The keyed registration under both grep -// and glob is pinned here too. +// tool row (GenericToolCard's fallback body and SearchRow, both composing the +// shared ToolRow with the search card collapsed by default) and the details +// panel's Output section (resident, full height). The keyed registration under +// both grep and glob is pinned here too. import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render } from '@testing-library/react' @@ -13,7 +14,7 @@ import type { ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, WorkspaceListState, } from '@deepseek-ai/dsh-client-runtime/client' import type { ToolResultView } from '@deepseek-ai/dsh-client-connection/client' -import type { SelectionTarget, ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { CHAT_SEARCH_MAX_LINES, searchCardModel } from '../src/client/contract/search-card-model.ts' @@ -23,6 +24,9 @@ import { GenericToolCard, type GenericToolCardProps } from '../src/client/chat/G import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx' import { SearchRow, searchToolview } from '../src/client/toolviews/search-row.tsx' +/** SearchRow now composes ToolRow, so its props include the locale `t` seat. */ +type SearchRowProps = Parameters[0] + afterEach(cleanup) /** Conversation-locale translate stub for the render sites' `t` seat. */ @@ -228,21 +232,32 @@ describe('chat row search body (GenericToolCard fallback)', () => { }) describe('SearchRow keyed card', () => { - const rowProps = (block: RunningToolCall | ToolResultNode, toolName: string): ToolRowProps => ({ - callId: 'c1', toolName, block, openFile: vi.fn(), sessionId: SID, - } as unknown as ToolRowProps) + const rowProps = (block: RunningToolCall | ToolResultNode, toolName: string): SearchRowProps => ({ + callId: 'c1', toolName, block, openFile: vi.fn(), sessionId: SID, t, + } as unknown as SearchRowProps) - it('renders the grep card resident under the summary row, without an expand gesture', () => { + /** The whole summary row is the expand toggle (ToolRow's unified interaction). */ + const toggleRow = (view: { container: HTMLElement }) => { + fireEvent.click(view.container.querySelector('[data-expandable]')!) + } + + it('collapses to the summary row; expanding reveals the grep card', () => { const view = render() expect(view.getByText('Search')).toBeTruthy() + // Collapsed: the card is not in the DOM until the row is expanded. + expect(searchKindOf(view.container)).toBeNull() + expect(view.queryByText(/const foo = 1/)).toBeNull() + toggleRow(view) expect(searchRows(view.container)).toContain('12: const foo = 1') expect(searchKindOf(view.container)).toBe('matches') - // The card's controls are the row's only interactions. + // The card's copy control lives inside the expanded body. expect(view.getByText('复制')).toBeTruthy() }) - it('renders the glob path card resident', () => { + it('expands to the glob path card', () => { const view = render() + expect(searchKindOf(view.container)).toBeNull() + toggleRow(view) expect(view.getByText('src/a.ts')).toBeTruthy() expect(searchKindOf(view.container)).toBe('paths') }) @@ -250,7 +265,7 @@ describe('SearchRow keyed card', () => { it('agrees with the summary row about the run state', () => { const runningView = render() expect(runningView.container.querySelector('[data-variant="search"]')?.getAttribute('data-state')).toBe('running') - // No result view yet, so no resident card. + // No result view yet, so no card even once material could expand. expect(searchKindOf(runningView.container)).toBeNull() cleanup() const errorView = render( { expect(errorView.container.querySelector('[data-variant="search"]')?.getAttribute('data-state')).toBe('error') }) - it('surfaces the result text when an errored search has no card', () => { + it('surfaces the result text through the Output section when an errored search has no card', () => { // grep/glob return no presentResult on error → no card; the row shows the - // model-facing error text instead of a bare red dot. + // first error line as the collapsed summary and the full text once expanded. const view = render() expect(searchKindOf(view.container)).toBeNull() + // Error state: the first line is the collapsed summary. expect(view.getByText('grep: invalid regular expression')).toBeTruthy() + toggleRow(view) + // Now in ToolRow's Output section too (the kept summary makes it appear twice). + expect(view.container.querySelector('[data-error]')?.textContent).toBe('grep: invalid regular expression') }) - it('surfaces the result text for a settled non-error call with no card', () => { + it('surfaces the result text for a settled non-error call with no card once expanded', () => { // A successful nested run_code sub-dispatch (backend computes no // presentationMeta, so resultView is null) or a legacy generic result settles // with search === null and state ok. The keyed SearchRow owns the slot, so - // without the widened arm the content would be lost behind a bare summary. + // ToolRow's Output section carries the text; it is only visible expanded. const view = render() expect(view.container.querySelector('[data-variant="search"]')?.getAttribute('data-state')).toBe('ok') expect(searchKindOf(view.container)).toBeNull() + // Collapsed: the ok row shows its args summary, not the output text. + expect(view.queryByText('nested run_code output line')).toBeNull() + toggleRow(view) expect(view.getByText('nested run_code output line')).toBeTruthy() }) @@ -290,12 +312,15 @@ describe('SearchRow keyed card', () => { content: [{ type: 'text', text: recovery }], resultView: resultMatches({ truncated: true, total: 42 }), }), 'grep')} />) + toggleRow(view) expect(searchKindOf(view.container)).toBe('matches') expect(view.getByText(/Full grep result stored at: spill:\/\/grep-1/)).toBeTruthy() }) it('shows no recovery footer for an uncapped search', () => { const view = render() + toggleRow(view) + expect(searchKindOf(view.container)).toBe('matches') expect(view.container.textContent).not.toMatch(/stored at/) }) @@ -304,6 +329,7 @@ describe('SearchRow keyed card', () => { isError: true, resultView: null, content: [], error: { name: 'ToolError', code: 'timeout' }, }), 'grep')} />) + // Error state: the derived name/code line is the collapsed summary. expect(view.getByText('ToolError: timeout')).toBeTruthy() }) @@ -320,16 +346,18 @@ describe('SearchRow keyed card', () => { }) it('registers the one row component under both grep and glob keys', () => { - const registered: { key: unknown; component: unknown }[] = [] + const registered: { key: unknown; locale: unknown; component: unknown }[] = [] const ctx = { slots: { - register: (options: { name: string; key: string }, component: unknown) => { - registered.push({ key: options.key, component }) + register: (options: { name: string; key: string; locale?: string }, component: unknown) => { + registered.push({ key: options.key, locale: options.locale, component }) }, }, } as never searchToolview.apply(ctx) expect(registered.map(r => r.key)).toEqual(['grep', 'glob']) + // Both keys claim the conversation locale seat ToolRow's body copy needs. + expect(registered.map(r => r.locale)).toEqual(['conversation', 'conversation']) // One component, two keys. expect(registered[0]!.component).toBe(SearchRow) expect(registered[1]!.component).toBe(SearchRow) diff --git a/packages/client/ui-conversation/tests/web-card.spec.tsx b/packages/client/ui-conversation/tests/web-card.spec.tsx index 0cb4f93f92..42dc729e41 100644 --- a/packages/client/ui-conversation/tests/web-card.spec.tsx +++ b/packages/client/ui-conversation/tests/web-card.spec.tsx @@ -4,17 +4,19 @@ // WebRow (registered under both web_search and web_fetch), the GenericToolCard // render-site fallback, and the details panel's Output section. Mirrors // terminal-card.spec.tsx: model derivation + null arms, both kinds, the chat -// row's resident card, the panel arm, and the keyed registration. +// row's collapsed-by-default ToolRow card, the panel arm, and the keyed +// registration. WebRow now composes the shared ToolRow, so its web card is +// collapsed by default and appears only once the whole row is expanded. import { afterEach, describe, expect, it, vi } from 'vitest' -import { cleanup, render } from '@testing-library/react' +import { cleanup, fireEvent, render } from '@testing-library/react' import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import type { ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, WorkspaceListState, } from '@deepseek-ai/dsh-client-runtime/client' import type { ToolResultView } from '@deepseek-ai/dsh-client-connection/client' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' -import type { SelectionTarget, ToolRowOwnerProps, ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { SelectionTarget, ToolRowOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client' import { CHAT_WEB_MAX_SOURCES, webCardModel } from '../src/client/contract/web-card-model.ts' import { createChatStore } from '../src/client/stores.ts' import { GenericToolCard } from '../src/client/chat/GenericToolCard.tsx' @@ -122,36 +124,49 @@ describe('chat row web body', () => { const ownerProps = (block: RunningToolCall | ToolResultNode, toolName: string): ToolRowOwnerProps => ({ callId: block.callId, toolName, block, openFile: vi.fn(), }) - // WebRow reads only toolName/block off the full runtime share; the standard - // kit is unused, so the cast supplies the owner slice alone (as BashRow's - // tests do for the terminal card). - const rowProps = (block: RunningToolCall | ToolResultNode, toolName: string): ToolRowProps => - ownerProps(block, toolName) as unknown as ToolRowProps + // WebRow reads only toolName/block off the full runtime share plus the locale + // seat; the standard kit is unused, so the cast supplies the owner slice and + // `t` alone (as BashRow's tests do for the terminal card). + const rowProps = (block: RunningToolCall | ToolResultNode, toolName: string): Parameters[0] => + ({ ...ownerProps(block, toolName), t } as unknown as Parameters[0]) - it('the WebRow renders the search card resident under the summary, capped tighter than the panel', () => { + /** The whole summary row is the expand toggle (ToolRow's unified interaction). */ + const toggleRow = (view: { container: HTMLElement }) => { + fireEvent.click(view.container.querySelector('[data-expandable]')!) + } + + it('the WebRow collapses to the summary row, expanding to the search card capped tighter than the panel', () => { expect(CHAT_WEB_MAX_SOURCES).toBeLessThan(16) const view = render() - // The summary row plus the resident card, without any expand gesture on the row itself. + // Collapsed: the summary row alone, no card in the DOM. expect(view.getByText('Search')).toBeTruthy() + expect(view.queryByText('Titled')).toBeNull() + expect(view.container.querySelector('[data-web]')).toBeNull() + toggleRow(view) + // Expanded: the resident search card with every source field. expect(view.getByText('Titled')).toBeTruthy() expect(view.getByText('excerpt')).toBeTruthy() // hostname fallback for the source with no title expect(view.getByText('plain.example.org')).toBeTruthy() }) - it('the WebRow renders the fetch card resident, titled Fetch', () => { + it('the WebRow expands to the fetch card, titled Fetch', () => { const view = render() expect(view.getByText('Fetch')).toBeTruthy() - // The url shows in the summary row and as the card's link; scope to the card. + expect(view.container.querySelector('[data-web]')).toBeNull() + toggleRow(view) + // The url shows as the card's link; scope to the card. const card = view.container.querySelector('[data-web="fetch"]') expect(card?.querySelector('a')?.getAttribute('href')).toBe('https://example.com/page') expect(view.getByText('HTTP 200')).toBeTruthy() }) - it('a running web call is the summary row alone (no card until it settles)', () => { + it('a running web call is the summary row alone, with nothing to expand', () => { const view = render() expect(view.getByText('Search')).toBeTruthy() expect(view.queryByText('Titled')).toBeNull() + // No card material and no expandable body: clicking the row reveals nothing. + expect(view.container.querySelector('[data-expandable]')).toBeNull() expect(view.container.querySelector('[data-web]')).toBeNull() }) @@ -165,12 +180,14 @@ describe('chat row web body', () => { expect(view.container.querySelector('[data-state="error"]')).not.toBeNull() }) - it('the GenericToolCard fallback also renders a resident web card for a web-declaring tool', () => { + it('the GenericToolCard fallback also expands to a web card for a web-declaring tool', () => { // A web-declaring tool without its own keyed row lands on the fallback; its - // card is resident there too. + // card routes through the same collapsed-by-default ToolRow. const view = render() + expect(view.container.querySelector('[data-web]')).toBeNull() + toggleRow(view) expect(view.getByText('Titled')).toBeTruthy() expect(view.container.querySelector('[data-web="search"]')).not.toBeNull() }) @@ -250,17 +267,19 @@ describe('DetailsPanel web Output section', () => { describe('web toolview registration', () => { it('registers one WebRow under both web_search and web_fetch', () => { - const registered: { key: string; component: unknown }[] = [] + const registered: { key: string; locale: unknown; component: unknown }[] = [] const ctx = { slots: { - register: (options: { name: string; key: string }, component: unknown) => { - registered.push({ key: options.key, component }) + register: (options: { name: string; key: string; locale?: string }, component: unknown) => { + registered.push({ key: options.key, locale: options.locale, component }) return () => {} }, }, } as unknown as import('cordis').Context webToolview.apply(ctx) expect(registered.map(r => r.key)).toEqual(['web_search', 'web_fetch']) + // Both keys claim the conversation locale seat ToolRow's body copy needs. + expect(registered.map(r => r.locale)).toEqual(['conversation', 'conversation']) // One component under both keys, not two thin rows. expect(registered[0]?.component).toBe(WebRow) expect(registered[1]?.component).toBe(WebRow) diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index af576fed26..b5bb7f0adb 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md -README.md: 6430a789c15634538a38d6581df50a489522db55 -README.zh.md: 78249612cce3148fcececded40c529682450460a +README.md: f06e8c014c19d17197980a232e8b42080aac2901 +README.zh.md: feb97ee9fa834fbff10052909630ffcbbce9c78a diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 6430a789c1..f06e8c014c 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -2,7 +2,11 @@ English | [中文](README.zh.md) -Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/Input, the markdown family (MessageText/MarkdownText/JsonBlock), the read-only JsonTree inspector, the `useAnchoredMaxHeight` hook that clamps a bottom-anchored overlay to the viewport space above its anchor (re-measured on resize, scroll, and a caller-supplied dependency), TerminalBlock, SearchBlock, DiffBlock, and WebBlock. Contract: api-contracts v3 §8. +Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/Input, the markdown family (MessageText/MarkdownText/JsonBlock), the read-only JsonTree inspector, the `useAnchoredMaxHeight` hook that clamps a bottom-anchored overlay to the viewport space above its anchor (re-measured on resize, scroll, and a caller-supplied dependency), TerminalBlock, DiffBlock, ReadBlock, SearchBlock, and WebBlock. Contract: api-contracts v3 §8. + +## Hover cards + +`HoverCard` keeps its portaled preview reachable across the anchor gap with a pointer-leave grace. A consumer may also pass `copyText`: the card then exposes button semantics for pointer and keyboard activation, includes that value after the `copyLabel` prefix in its accessible name, writes the exact value through the package clipboard helper, and temporarily replaces its content with `copiedLabel` only after the host accepts the write. A non-collapsed text selection intersecting the card suppresses pointer-click activation, while success feedback retains the original card height and clears when the card closes or after one second. `copyLabel` and `copiedLabel` are label props because this zero-cordis atom cannot read the application locale; omitting `copyText` preserves the read/select-only card. Rationale: [the hover-card copy note](../../../.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.md). ## Markdown rendering @@ -12,14 +16,18 @@ Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/ `TerminalBlock` renders a shell command as a terminal surface: one prompt row per line of the command (the shortened `cwd` label on the first row only, since the view knows one working directory and a `cd` moves later lines elsewhere, then that line), the command's output, a status pill for a non-zero exit code or a terminating signal, and a copy control that writes the raw `output` prop. A run-state `StateDot` marks the call once, on the first row, out of flow in a gutter the card reserves as its own left padding, so the dot sits inside the card box yet left of the prompt text. It reaches three of `StateDot`'s states — the chase while `running`, red for the same exit status that renders the pill, green otherwise — so a card states whether its command is still running rather than leaving that to be inferred from the presence of output; it carries one visually hidden text label because `StateDot` is `aria-hidden`. One dot regardless of line count is deliberate: the exit status is the whole call's, so a dot per line would claim a per-line outcome the view does not carry. Command text is `white-space: pre`, so repeated spaces, tabs, and an indented continuation render verbatim while the row stays single-line and ellipsizes. ANSI escape sequences are parsed with the `anser` runtime dependency into React spans; cursor movements replay into a per-line column buffer before inert controls are stripped, since carriage return and backspace only MOVE the cursor: `100%` + CR + `OK` alone shows `OK0%`, while the `\x1b[K` a spinner writes with its redraw erases the tail so `100%\r\x1b[KOK` shows `OK`. Erase-in-line is honored in all three parameter forms, the cursor advances by terminal columns (8-column tab stops, two for emoji and CJK, none for a combining mark), and SGR state is normalized per cell as a terminal stores it, threading across lines and closing at the state the line ended in; basic-16 foreground colors map onto `--dsw-*` tokens, while 256-palette and truecolor values pass through as literal rgb. Output keeps `white-space: pre` with horizontal scrolling, so column-aligned output holds its alignment instead of soft-wrapping, and collapses to a head slice plus a tail slice past `maxLines` (default 16, the TUI transcript's split arithmetic) behind an expand button. Rationale: [the web terminal card note](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md). -## Search results +## Read rendering -`SearchBlock` renders a completed search, one component for both kinds (discriminated by `kind`). A `matches` (grep) shows each file as a bold path header with its `lineNumber: line` rows, the per-file group collapsible; a `paths` (glob) shows a flat path list. Both flatten to one row list the height cap slices head/tail over (default 16, the TerminalBlock split arithmetic), and neither soft-wraps — a long match line or path scrolls horizontally instead of folding. The banner summary folds the pre-cap total in when the tool capped the result (`显示 X / 共 N 处匹配 · K 个文件` for grep, `显示 X / 共 N 个路径` for glob), so the card never presents a capped result as complete; a copy control writes the whole structured result regardless of the cap or which groups are collapsed. Geometry mirrors CodeBlock/TerminalBlock. Rationale: [the web search card note](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md). +`ReadBlock` renders a returned file window as a line-numbered, syntax-highlighted code surface: a bold path (or presenter-supplied title) banner with a copy control, then the content lines with their file line numbers in a gutter (a windowed read keeps the file's own numbering, so a read past an offset starts above 1). A `totalLines` exceeding the window count draws a `showing N of M` note, and the body collapses to a head slice plus a tail slice past `maxLines` (default 16, the TerminalBlock split arithmetic) behind an expand button. Highlighting runs through the same shiki path as `CodeBlock`. Rationale: [the web read card note](../../../.agents/notes/implemented/feature/2026-07-30-web-read-card.md). ## Diff rendering `DiffBlock` renders a file mutation as an inline diff surface: one bold path header per file, the removed lines (`- `, error token) above the added lines (`+ `, success token), a `⋯` gap before a same-file second hunk, and a dim `└ +A -R · N file(s)` footer. Lines are `white-space: pre` with horizontal scrolling, so a source line holds its indentation instead of soft-wrapping, and the body collapses to a head slice plus a tail slice past `maxLines` (default 16, `TerminalBlock`'s split arithmetic) behind an expand button. A create (`oldText: null`) has no removed side. The copy control writes the prefixed diff text (path headers, `- `/`+ ` lines, the gap) so a multi-file copy stays attributable, and floats in the top-right corner rather than on a banner row of its own. Geometry mirrors `CodeBlock`/`TerminalBlock`. The `+`/`-` block form mirrors the TUI transcript's diff card so a diff reads the same across front ends. Rationale: [the web diff card note](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.md). +## Search results + +`SearchBlock` renders a completed search, one component for both kinds (discriminated by `kind`). A `matches` (grep) shows each file as a bold path header with its `lineNumber: line` rows, the per-file group collapsible; a `paths` (glob) shows a flat path list. Both flatten to one row list the height cap slices head/tail over (default 16, the TerminalBlock split arithmetic), and neither soft-wraps — a long match line or path scrolls horizontally instead of folding. The banner summary folds the pre-cap total in when the tool capped the result (`显示 X / 共 N 处匹配 · K 个文件` for grep, `显示 X / 共 N 个路径` for glob), so the card never presents a capped result as complete; a copy control writes the whole structured result regardless of the cap or which groups are collapsed. Geometry mirrors CodeBlock/TerminalBlock. Rationale: [the web search card note](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md). + ## Web retrieval `WebBlock` renders a completed web retrieval, one component for both kinds of the `web` render intent (discriminated by `kind`). A `search` shows an optional provider answer (through `MarkdownText`) above an ordered citation list: each source is a safe external link labelled by its title, or its hostname, falling back to the raw URL when the URL does not parse or has no hostname (a `file:`/`data:` URL) so a label is never blank; its snippet and publication date render below it. Only http(s) URLs become anchors (`target`/`rel` set) — the http(s) subset of the allowlist `MarkdownText` applies to untrusted links (it also permits `mailto:`, excluded here); any other URL renders as plain text. A long list caps at `maxSources` (default 16, the TerminalBlock split arithmetic) with a head/tail collapse; the collapsed tail keeps each source's original citation number via `
  • `, and the expand control is a marker-less `
  • ` so the `
      ` stays valid HTML. When a search legitimately returns no answer and no sources, the card shows an explicit empty-state note rather than a blank `
        ` (the chat row does not surface the raw result content). A `fetch` shows a compact summary: the linked final URL and its HTTP status. Both mark a capped retrieval. Rationale: [the web result card note](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md). @@ -37,5 +45,5 @@ None; this package neither assembles nor sends a provider request. - **Glyph-level icons are redrawn approximations** — the fish logo (and the sparkle held by ui-conversation) come from font glyphs whose vector geometry is not exportable from the local design data; hand-authored recreations stand in until an exact export path exists. - **Pill and Input have no design source** — both atoms are self-defined; the sidebar search field and view-tab strip that resemble them are consumer-owned compositions, not these atoms. - **StateDot `Active` variant is a hidden placeholder in the design** — not implemented; the four shipped states (done/warning/ongoing/error) are the complete P-I surface. -- **User-facing copy localizes through label props, defaulting to the original Chinese literals** — the atoms are zero-cordis and cannot reach `ctx.locale`, so `TerminalBlock` (`labels`), `JsonTree` (`labels`), `CodeBlock` (`copyLabel`/`copiedLabel`), `MarkdownText` (`codeLabels`), `JsonBlock` (`truncatedLabel`), `ConnectionBanner` (`label`), and `Modal` (`closeLabel`) take their copy as optional props with the previous hardcoded strings as defaults. Localized plugins pass dictionary-driven labels from their own `t` seat; a consumer that passes nothing renders exactly the pre-localization output. `WebBlock` does not yet follow this pattern: its source expand/collapse controls, source-list and fetch truncation notes, and empty-search note stay inline Chinese, pending the same label-prop treatment. +- **User-facing copy localizes through label props, defaulting to the original Chinese literals** — the atoms are zero-cordis and cannot reach `ctx.locale`, so `HoverCard` (`copyLabel`/`copiedLabel`), `TerminalBlock` (`labels`), `JsonTree` (`labels`), `CodeBlock` (`copyLabel`/`copiedLabel`), `MarkdownText` (`codeLabels`), `JsonBlock` (`truncatedLabel`), `ConnectionBanner` (`label`), and `Modal` (`closeLabel`) take their copy as optional props with the previous hardcoded strings as defaults. Localized plugins pass dictionary-driven labels from their own `t` seat; a consumer that passes nothing renders exactly the pre-localization output. `WebBlock` does not yet follow this pattern: its source expand/collapse controls, source-list and fetch truncation notes, and empty-search note stay inline Chinese, pending the same label-prop treatment. - **`TerminalBlock` is not a terminal emulator** — it renders settled or still-running command output, not an interactive session: SGR color and attributes are honored, and so are the in-line cursor movements a progress line uses — carriage return, backspace, erase-in-line, tab stops and character width. Absolute cursor positioning, screen clearing, and alternate-screen sequences are stripped. Basic-16 magenta and cyan have no token equivalent and stay literal rgb. diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 78249612cc..feb97ee9fa 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -2,23 +2,32 @@ [English](README.md) | 中文 -纯 React 原子组件(零 cordis):StateDot、ic_ds_* 图标、Button/Pill/Menu/Modal/Input、markdown 家族(MessageText/MarkdownText/JsonBlock)、只读 JsonTree 检查器、`useAnchoredMaxHeight` hook(把底部锚定的浮层高度收敛到锚点上方的视口空间,并在 resize、scroll 与调用方提供的依赖变化时重新测量)、TerminalBlock、SearchBlock、DiffBlock,以及 WebBlock。契约:api-contracts v3 §8。 +纯 React 原子组件(零 cordis):StateDot、ic_ds_* 图标、Button/Pill/Menu/Modal/Input、markdown 家族(MessageText/MarkdownText/JsonBlock)、只读 JsonTree 检查器、`useAnchoredMaxHeight` hook(把底部锚定的浮层高度收敛到锚点上方的视口空间,并在 resize、scroll 与调用方提供的依赖变化时重新测量)、TerminalBlock、DiffBlock、ReadBlock、SearchBlock,以及 WebBlock。契约:api-contracts v3 §8。 + +## 悬浮卡片 + +`HoverCard` 通过指针离开宽限期,让采用 portal 渲染的预览在跨越与锚点之间的间隙时仍可抵达。消费方还可传入 `copyText`:此时卡片为指针与键盘激活提供按钮语义,其无障碍名称会在 `copyLabel` 前缀后包含该值,通过包内剪贴板辅助函数原样写入该值,并且只有宿主接受写入后,才会临时将内容替换为 `copiedLabel`。与卡片相交的非折叠文本选区会阻止指针点击激活;成功反馈保持卡片原有高度,并随卡片关闭或在一秒后清除。`copyLabel` 和 `copiedLabel` 采用 label prop,是因为这个 zero-cordis 原子组件无法读取应用 locale;省略 `copyText` 时,卡片维持只读且可选择文本的行为。理由见[悬浮卡片复制 Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.md)。 ## Markdown 渲染 `MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM。它会省略原始 HTML,使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并只渲染图片 alt 文本而不加载远程资源;`MessageText` 仍是用户创作内容使用的字面文本原语。`extractMarkdownPlainText` 会移除 Markdown 呈现标记以用于紧凑标签,同时将原始 HTML 保留为字面文本。元素间距、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki)。 + ## 终端输出 `TerminalBlock` 将一条 shell 命令渲染为终端表层:命令的每一行各占一个提示行(缩短后的 `cwd` 标签只出现在第一行,因为视图只知道一个工作目录,而一个 `cd` 就会让后面的行去到别处,标签之后是该行)、命令输出、非零退出码或终止信号对应的状态胶囊,以及写入原始 `output` prop 的复制控件。一枚运行状态 `StateDot` 为整次调用标记一次,位于第一行,以脱离文档流的方式落在卡片以自身左内边距预留的落区中,因此它位于卡片盒之内、提示文字之左。它用到 `StateDot` 的三种状态——`running` 期间为追逐动画,与渲染状态胶囊相同的退出状态为红色,其余为绿色——因此卡片直接陈述其命令是否仍在运行,而不是让人从有无输出中推断;由于 `StateDot` 是 `aria-hidden`,它携带一处视觉隐藏的文本标签。无论多少行都只有一枚状态点是有意为之:退出状态属于整次调用,因此每行一枚就会声称一个视图并不携带的逐行结果。命令文本使用 `white-space: pre`,因此重复空格、制表符与缩进续行都原样呈现,同时该行仍保持单行并以省略号截断。ANSI 转义序列通过运行时依赖 `anser` 解析为 React span;光标移动在剥除无显示意义控制符之前先重放进逐行的列缓冲,因为回车与退格**只移动**光标:单是 `100%` 加回车再加 `OK` 显示为 `OK0%`,而 spinner 随重绘写出的 `\x1b[K` 会擦掉尾巴,因此 `100%\r\x1b[KOK` 显示为 `OK`。行内擦除的三种参数形式都被遵循,光标按终端列推进(8 列制表位;emoji 与 CJK 占两列;组合标记不占列),SGR 状态按单元格归一化存储,与终端一致,并跨行延续、在行结束时的状态处收束;基础 16 色前景色映射到 `--dsw-*` token,而 256 色板与真彩色值按字面 rgb 透传。输出保持 `white-space: pre` 并支持横向滚动,因此按列对齐的输出保留其对齐而不会软换行;超过 `maxLines`(默认 16,与 TUI 转录相同的切分算法)时折叠为头部切片加尾部切片,由展开按钮控制。原理:[Web 终端卡片笔记](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)。 -## 搜索结果 +## Read 渲染 -`SearchBlock` 渲染一次已完成的搜索,一个组件绘制两种 kind(由 `kind` 判别)。`matches`(grep)把每个文件渲染为粗体路径头加其 `lineNumber: line` 行,每个文件组可折叠;`paths`(glob)渲染扁平路径列表。两者都摊平成一个行列表,由高度上限做头/尾切片(默认 16,与 TerminalBlock 相同的切分算法),且都不软换行——长匹配行或路径横向滚动而非折行。当工具截断结果时,banner 摘要把截断前总数折入(grep 为 `显示 X / 共 N 处匹配 · K 个文件`,glob 为 `显示 X / 共 N 个路径`),使卡片绝不把截断结果呈现为完整;复制控件写入完整结构化结果,无论是否触及上限或哪些组被折叠。几何镜像 CodeBlock/TerminalBlock。原理:[Web 搜索卡片笔记](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md)。 +`ReadBlock` 将返回的文件窗口渲染为带行号、语法高亮的代码表层:一个粗体路径(或 presenter 提供的标题)横幅加复制控件,其下是内容行,行号槽里是文件自身的行号(窗口化的 read 保留文件本身的编号,因此偏移之后的 read 从大于 1 处起始)。`totalLines` 超过窗口行数时画出 `showing N of M` 提示;超过 `maxLines`(默认 16,与 TerminalBlock 相同的切分算法)时折叠为头部切片加尾部切片,由展开按钮控制。高亮走与 `CodeBlock` 相同的 shiki 路径。原理:[Web read 卡片笔记](../../../.agents/notes/implemented/feature/2026-07-30-web-read-card.md)。 ## Diff 渲染 `DiffBlock` 将一次文件改动渲染为内联 diff 表层:每个文件一个粗体路径头、删除行(`- `,error token)在新增行(`+ `,success token)之上、同文件第二个 hunk 前一个 `⋯` gap,以及暗色 `└ +A -R · N file(s)` 页脚。各行使用 `white-space: pre` 并横向滚动,因此源码行保留其缩进而不软换行;超过 `maxLines`(默认 16,与 `TerminalBlock` 相同的切分算法)时折叠为头部切片加尾部切片,由展开按钮控制。新建(`oldText: null`)没有删除侧。复制控件写入带前缀的 diff 文本(路径头、`- `/`+ ` 行、gap),使多文件复制保持可归属,并浮在右上角而非占据自己的 banner 行。几何镜像 `CodeBlock`/`TerminalBlock`。`+`/`-` 块形式镜像 TUI 转录的 diff 卡片,使 diff 在两个前端读起来一致。原理:[Web diff 卡片笔记](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.md)。 +## 搜索结果 + +`SearchBlock` 渲染一次已完成的搜索,并通过 `kind` 判别,由一个组件处理两种结果。`matches`(grep)将每个文件显示为粗体路径头及其 `lineNumber: line` 行,各文件组均可折叠;`paths`(glob)显示扁平的路径列表。两者都摊平成一个行列表,由高度上限对其做头尾切片(默认 16,与 `TerminalBlock` 相同的切分算法),且都不软换行:较长的匹配行或路径会横向滚动而非折行。当工具截断结果时,banner 摘要会包含截断前的总数(grep 为 `显示 X / 共 N 处匹配 · K 个文件`,glob 为 `显示 X / 共 N 个路径`),使卡片绝不把截断后的结果呈现为完整结果;无论是否触及上限或哪些组处于折叠状态,复制控件都会写入完整的结构化结果。几何结构与 `CodeBlock`/`TerminalBlock` 一致。原理:[Web 搜索卡片笔记](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md)。 + ## Web 检索 `WebBlock` 渲染一次已完成的 web 检索,用一个组件绘制 `web` 渲染意图的两种 kind(由 `kind` 判别)。`search` 在有序引用列表上方显示可选的 provider answer(通过 `MarkdownText`):每个 source 是一个安全外链,以其标题为标签,或以其主机名为标签,当 URL 无法解析或没有主机名(`file:`/`data:` URL)时回退到原始 URL,因此标签绝不为空;其下渲染 snippet 与发布日期。只有 http(s) URL 会成为锚点(设置 `target`/`rel`)——这是 `MarkdownText` 对不受信任链接所用 allowlist 的 http(s) 子集(该 allowlist 还允许 `mailto:`,此处排除);任何其他 URL 渲染为纯文本。长列表在 `maxSources`(默认 16,即 TerminalBlock 的切分算术)处折叠为头部/尾部;折叠的尾部通过 `
      1. ` 保留每个 source 原始的引用编号,展开控件是无 marker 的 `
      2. `,使 `
          ` 保持为合法 HTML。当一次 search 合法地返回无 answer 且无 source 时,卡片显示一个明确的空状态提示,而不是空的 `
            `(chat 行不呈现原始 result content)。`fetch` 显示一个紧凑摘要:带链接的最终 URL 及其 HTTP 状态。两者都会标记一次被截断的检索。原理:[Web result 卡片笔记](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md)。 @@ -36,5 +45,5 @@ - **字形级图标是重新绘制的近似版本**:鱼形标志(以及 ui-conversation 持有的闪光图标)来自字体字形,而本地设计数据无法导出其矢量几何;在获得精确导出路径前,使用手工重建版本代替。 - **Pill 与 Input 没有设计来源**:两个原子组件均自行定义;与其相似的侧边栏搜索字段和视图标签条由消费方组合,不是这些原子组件。 - **StateDot 的 `Active` 变体是设计中的隐藏占位符**:尚未实现;已交付的四种状态(done/warning/ongoing/error)构成完整的 P-I 表层。 -- **面向用户的文案经 label props 本地化,默认值为原中文字面量**:这些原子组件是 zero-cordis 的,拿不到 `ctx.locale`,因此 `TerminalBlock`(`labels`)、`JsonTree`(`labels`)、`CodeBlock`(`copyLabel`/`copiedLabel`)、`MarkdownText`(`codeLabels`)、`JsonBlock`(`truncatedLabel`)、`ConnectionBanner`(`label`)和 `Modal`(`closeLabel`)都把文案作为可选 props 接收,默认值即此前的硬编码字符串。已本地化的插件用自己的 `t` 席位传入字典驱动的 label;什么都不传的消费者渲染与本地化之前逐字节一致。`WebBlock` 尚未跟进这一模式:它的来源展开/收起控件、来源列表与 fetch 截断提示、以及空搜索提示仍是内联中文,待同样的 label-prop 处理。 +- **面向用户的文案经 label props 本地化,默认值为原中文字面量**:这些原子组件是 zero-cordis 的,拿不到 `ctx.locale`,因此 `HoverCard`(`copyLabel`/`copiedLabel`)、`TerminalBlock`(`labels`)、`JsonTree`(`labels`)、`CodeBlock`(`copyLabel`/`copiedLabel`)、`MarkdownText`(`codeLabels`)、`JsonBlock`(`truncatedLabel`)、`ConnectionBanner`(`label`)和 `Modal`(`closeLabel`)都把文案作为可选 props 接收,默认值即此前的硬编码字符串。已本地化的插件用自己的 `t` 席位传入字典驱动的 label;什么都不传的消费者渲染与本地化之前逐字节一致。`WebBlock` 尚未跟进这一模式:它的来源展开/收起控件、来源列表与 fetch 截断提示、以及空搜索提示仍是内联中文,待同样的 label-prop 处理。 - **`TerminalBlock` 不是终端模拟器**:它渲染已结束或仍在运行的命令输出,而不是交互式会话:SGR 颜色与属性会被遵循,进度行所用的行内光标移动同样被遵循——回车、退格、行内擦除、制表位与字符宽度。绝对光标定位、清屏与备用屏幕序列会被剥离。基础 16 色中的洋红与青色没有对应 token,保持字面 rgb。 diff --git a/packages/client/ui-primitives/src/HoverCard.module.css b/packages/client/ui-primitives/src/HoverCard.module.css index 8d8a52100e..8d425ca85c 100644 --- a/packages/client/ui-primitives/src/HoverCard.module.css +++ b/packages/client/ui-primitives/src/HoverCard.module.css @@ -7,7 +7,9 @@ /* Preview card (figma session hover card): 244 wide, r12, pad 12/16, the * menu card's elevation. Surface is #2C2C2E in both themes (figma value, - * light/dark identical), so a component-level variable, not a theme token. */ + * light/dark identical), so a component-level variable, not a theme token. + * Hit-testable on purpose: resting the pointer on the card holds it open + * (HoverCard's grace close), which a `pointer-events: none` card cannot do. */ .card { --dsw-hovercard-bg: #2C2C2E; position: fixed; @@ -18,5 +20,35 @@ border-radius: 12px; background: var(--dsw-hovercard-bg); box-shadow: var(--dsw-shadow-lv3); - pointer-events: none; +} + +.copyable { + cursor: pointer; +} + +.copyable:focus-visible { + outline: 2px solid var(--dsw-alias-state-business-primary); + outline-offset: 2px; +} + +.feedback { + display: flex; + align-items: center; + justify-content: center; +} + +.copied { + color: #FFFFFF; + font-size: 14px; + line-height: 20px; + text-align: center; +} + +.status { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; } diff --git a/packages/client/ui-primitives/src/HoverCard.tsx b/packages/client/ui-primitives/src/HoverCard.tsx index 1720a0b79c..ea14afad1f 100644 --- a/packages/client/ui-primitives/src/HoverCard.tsx +++ b/packages/client/ui-primitives/src/HoverCard.tsx @@ -1,33 +1,73 @@ // HoverCard: delayed hover-preview card portaled to document.body. // Same portal mechanics as Menu: the wrapper span supplies the anchor rect, // the card is fixed-positioned at its right edge and repositions on -// scroll/resize while open. Display-only — the card ignores pointer events -// and closes the instant the pointer leaves the anchor (no close delay). +// scroll/resize while open. The card is reachable: it takes pointer events, +// and leaving the anchor only arms a grace-delayed close, so the pointer can +// cross the 8px gap and settle on the card to read a clipped path or title. +// The portaled card is a React child of the wrapper, so React's enter/leave +// traversal already treats it as inside — one pair of wrapper handlers covers +// anchor and card alike. -import { useEffect, useLayoutEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' import type { ReactNode } from 'react' import { createPortal } from 'react-dom' +import { writeClipboard } from './clipboard.ts' +import { usePointerGrace } from './pointer-grace.ts' import css from './HoverCard.module.css' /** * Render an anchor with a hover-triggered preview card. * @param props.anchor - the hover target (rendered in place inside a wrapper span). - * @param props.content - card content (display-only, no pointer interaction). + * @param props.content - card content; the pointer may rest on it, so it is + * readable and selectable, but it carries no dismissal affordance of its own. * @param props.openDelayMs - hover dwell before the card shows (default 500). * @param props.disabled - suppress opening; turning true closes an open card. + * @param props.copyText - optional primary value copied by activation and + * included in the card's accessible name. + * @param props.copyLabel - accessible activation-label prefix (default "复制"). + * @param props.copiedLabel - visible success label (default "复制成功"). * @returns anchor wrapper with the conditional portaled card. */ -export function HoverCard({ anchor, content, openDelayMs = 500, disabled = false }: { +export function HoverCard({ + anchor, content, openDelayMs = 500, disabled = false, + copyText, copyLabel = '复制', copiedLabel = '复制成功', +}: { anchor: ReactNode content: ReactNode openDelayMs?: number disabled?: boolean + copyText?: string | undefined + copyLabel?: string | undefined + copiedLabel?: string | undefined }) { const rootRef = useRef(null) const cardRef = useRef(null) const timerRef = useRef | null>(null) + const copyTimerRef = useRef | null>(null) + const copyHeightRef = useRef(null) + const copyEpochRef = useRef(0) + const copyingRef = useRef(false) + const mountedRef = useRef(true) const [open, setOpen] = useState(false) const [pos, setPos] = useState<{ left: number; top: number } | null>(null) + const [copied, setCopied] = useState(false) + + const clearCopied = useCallback(() => { + if (copyTimerRef.current !== null) { + clearTimeout(copyTimerRef.current) + copyTimerRef.current = null + } + copyHeightRef.current = null + setCopied(false) + }, []) + + const close = useCallback(() => { + copyEpochRef.current += 1 + clearCopied() + setOpen(false) + }, [clearCopied]) + + const { arm: armClose, cancel: cancelClose } = usePointerGrace(close) const clearTimer = () => { if (timerRef.current !== null) { @@ -40,10 +80,22 @@ export function HoverCard({ anchor, content, openDelayMs = 500, disabled = false useEffect(() => { if (!disabled) return clearTimer() - setOpen(false) - }, [disabled]) + cancelClose() + close() + }, [disabled, cancelClose, close]) - useEffect(() => clearTimer, []) + useEffect(() => { + mountedRef.current = true + return () => { + mountedRef.current = false + copyEpochRef.current += 1 + clearTimer() + if (copyTimerRef.current !== null) { + clearTimeout(copyTimerRef.current) + copyTimerRef.current = null + } + } + }, []) // Fixed-position from the anchor rect before paint; track the anchor while // open (capture-phase scroll catches nested panes), as in Menu portal mode. @@ -79,9 +131,49 @@ export function HoverCard({ anchor, content, openDelayMs = 500, disabled = false } }, [open, pos]) + const copy = async (text: string): Promise => { + if (copied || copyingRef.current) return + copyingRef.current = true + const copyEpoch = copyEpochRef.current + const accepted = await writeClipboard(text) + copyingRef.current = false + const card = cardRef.current + if (!accepted || !mountedRef.current || copyEpoch !== copyEpochRef.current || card === null) return + const height = card.offsetHeight + copyHeightRef.current = height > 0 ? height : null + setCopied(true) + copyTimerRef.current = setTimeout(clearCopied, 1000) + } + + const copyable = copyText !== undefined const card = open && pos !== null && ( -
            - {content} +
            { + const selection = window.getSelection() + if (selection !== null && !selection.isCollapsed) { + for (let i = 0; i < selection.rangeCount; i += 1) { + if (selection.getRangeAt(i).intersectsNode(e.currentTarget)) return + } + } + void copy(copyText) + } + : undefined} + onKeyDown={copyable + ? (e) => { + if (e.key !== 'Enter' && e.key !== ' ') return + e.preventDefault() + void copy(copyText) + } + : undefined} + > + {copied ? : content}
            ) @@ -91,21 +183,33 @@ export function HoverCard({ anchor, content, openDelayMs = 500, disabled = false className={css.root} onPointerEnter={() => { if (disabled) return + // Coming back inside during the grace (the gap, or the card itself) + // keeps the current card rather than restarting the dwell. + cancelClose() + if (open) return clearTimer() timerRef.current = setTimeout(() => { setOpen(true) }, openDelayMs) }} onPointerLeave={() => { clearTimer() - setOpen(false) + // Leaving a closed card schedules a no-op close; only arm while + // open, matching Menu's shape. + if (open) armClose() }} - // Any press inside the anchor (row click, menu trigger) dismisses the + // A press inside the anchor (row click, menu trigger) dismisses the // card immediately, without waiting for the owner to flip `disabled`. - onPointerDownCapture={() => { + // Capture presses reach this handler from the card too — it is a React + // child of the wrapper — but a press there starts a selection, so the + // card must stay mounted under it (and the browser's click with it). + onPointerDownCapture={(e) => { + if (cardRef.current?.contains(e.target as Node)) return clearTimer() - setOpen(false) + cancelClose() + close() }} > {anchor} + {open && copyable && {copied ? copiedLabel : ''}} {card !== false && createPortal(card, document.body)} ) diff --git a/packages/client/ui-primitives/src/Menu.tsx b/packages/client/ui-primitives/src/Menu.tsx index 747750363d..ea7e51b478 100644 --- a/packages/client/ui-primitives/src/Menu.tsx +++ b/packages/client/ui-primitives/src/Menu.tsx @@ -13,6 +13,7 @@ import type { CSSProperties, ReactNode } from 'react' import { createPortal } from 'react-dom' import clsx from 'clsx' import { IconCheckOutline16 } from './icons/index.tsx' +import { usePointerGrace } from './pointer-grace.ts' import css from './Menu.module.css' /** Selectable row (optionally with a nested submenu). */ @@ -69,8 +70,10 @@ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 } * from the anchor rect (repositions on scroll/resize while open). Use when an * ancestor's overflow clipping would crop the in-place list; default false * keeps the pure-CSS in-place behavior. - * @param props.closeOnPointerLeave - close the list when the pointer leaves - * it (default false keeps it open until outside click/Escape/selection). + * @param props.closeOnPointerLeave - close the list once the pointer has left + * both trigger and list for the pointer grace (default false keeps it open + * until outside click/Escape/selection). The grace makes the 4px trigger->list + * gap and a brief overshoot survivable; coming back cancels the close. * @param props.compact - use reduced menu typography and spacing. * @param props.getAnchorRect - portal mode only: supply the anchor rect * directly (e.g. from a host-owned trigger button) instead of measuring the @@ -102,6 +105,7 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align const listRef = useRef(null) const [openSubmenuId, setOpenSubmenuId] = useState(null) const [fixedPos, setFixedPos] = useState(null) + const { arm: armClose, cancel: cancelClose } = usePointerGrace(onClose) // Portal mode: fixed-position the list from the anchor rect before paint; // track the anchor while open (capture-phase scroll catches nested panes). @@ -179,6 +183,14 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align } }, [open, onClose]) + // A close from selection/Escape/outside click outruns a pending grace close; + // left armed it would shut a list reopened inside the grace window. Its own + // effect, not the listener effect above: that one re-runs on every `onClose` + // identity change and would cancel the grace mid-transit. + useEffect(() => { + if (!open) cancelClose() + }, [open, cancelClose]) + // The submenu card is absolutely positioned outside the list box; the // scroll clip would crop it, so only submenu-free menus get the height cap. const scrollable = !items.some(entry => !isSeparator(entry) && !isLabel(entry) && entry.submenu !== undefined && entry.submenu.length > 0) @@ -251,7 +263,6 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align className={clsx(css.list, compact && css.compactList, scrollable && css.scrollable, portal && css.portal, side === 'top' && !portal && css.sideTop, align === 'end' && !portal && css.alignEnd)} style={portal ? fixedPos ?? MEASURE_STYLE : undefined} role="menu" - onPointerLeave={closeOnPointerLeave ? () => { onClose() } : undefined} // React portals bubble synthetic events through the REACT tree: without // this stop, an item click re-fires the anchor row's own onClick // (open/toggle) after onSelect. @@ -268,8 +279,17 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
            ) + // Pointer-leave dismissal watches the WRAPPER, not the list: React's + // enter/leave traversal runs over the React tree, so trigger and portaled + // list are one region here. Aiming back at the trigger, or crossing the 4px + // gap between them, therefore never counts as leaving. return ( - + { if (open) armClose() } : undefined} + > {anchor} {portal ? (list !== false && createPortal(list, document.body)) : list} diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts index e5100b2db5..b19147a1cc 100644 --- a/packages/client/ui-primitives/src/index.ts +++ b/packages/client/ui-primitives/src/index.ts @@ -24,14 +24,14 @@ export { JsonTree } from './JsonTree.tsx' export type { JsonTreeProps, JsonTreeLabels } from './JsonTree.tsx' export { TerminalBlock, DEFAULT_TERMINAL_MAX_LINES } from './TerminalBlock.tsx' export type { TerminalBlockProps, TerminalBlockLabels } from './TerminalBlock.tsx' -export { SearchBlock, DEFAULT_SEARCH_MAX_LINES } from './SearchBlock.tsx' -export type { - SearchBlockProps, SearchMatchesBlockProps, SearchPathsBlockProps, SearchFileGroup, SearchBlockLineMatch, -} from './SearchBlock.tsx' export { ReadBlock, DEFAULT_READ_MAX_LINES } from './ReadBlock.tsx' export type { ReadBlockProps, ReadBlockLine } from './ReadBlock.tsx' export { DiffBlock, DEFAULT_DIFF_MAX_LINES } from './DiffBlock.tsx' export type { DiffBlockProps, DiffHunk } from './DiffBlock.tsx' +export { SearchBlock, DEFAULT_SEARCH_MAX_LINES } from './SearchBlock.tsx' +export type { + SearchBlockProps, SearchMatchesBlockProps, SearchPathsBlockProps, SearchFileGroup, SearchBlockLineMatch, +} from './SearchBlock.tsx' export { WebBlock, DEFAULT_WEB_MAX_SOURCES } from './WebBlock.tsx' export type { WebBlockProps, WebSearchBlockProps, WebFetchBlockProps, WebSourceView } from './WebBlock.tsx' export { CodeBlock } from './markdown/CodeBlock.tsx' diff --git a/packages/client/ui-primitives/src/pointer-grace.ts b/packages/client/ui-primitives/src/pointer-grace.ts new file mode 100644 index 0000000000..1619cfe66e --- /dev/null +++ b/packages/client/ui-primitives/src/pointer-grace.ts @@ -0,0 +1,53 @@ +// Shared close timing for pointer-dismissed popups (HoverCard, hover-closing +// Menu). Both float free of their anchor, so the pointer has to cross ground +// that belongs to neither on its way in; closing on the first pointerleave +// makes the popup unreachable. The grace turns that transit into a cancelable +// pending close. + +import { useCallback, useEffect, useRef } from 'react' + +/** + * Grace before a pointer-dismissed popup closes. Covers the anchor->popup gap + * (8px for HoverCard, 4px for Menu) at a hand's travel speed without leaving a + * popup lingering once the pointer has genuinely moved on. + */ +export const POINTER_GRACE_MS = 200 + +/** Cancelable delayed close for a pointer-dismissed popup. */ +export interface PointerGrace { + /** Schedule the close {@link POINTER_GRACE_MS} from now, replacing any pending one. */ + arm: () => void + /** Abort a pending close (the pointer came back). */ + cancel: () => void +} + +/** + * Delay a pointer-dismissed popup's close so the pointer can cross the gap + * between anchor and popup. A pending close is dropped on unmount. + * @param close - runs when the grace elapses with no re-entry; read at fire + * time, so callers may pass a fresh closure each render. + * @returns the {@link PointerGrace} handle. + */ +export function usePointerGrace(close: () => void): PointerGrace { + const timerRef = useRef | null>(null) + const closeRef = useRef(close) + closeRef.current = close + + const cancel = useCallback(() => { + if (timerRef.current === null) return + clearTimeout(timerRef.current) + timerRef.current = null + }, []) + + const arm = useCallback(() => { + cancel() + timerRef.current = setTimeout(() => { + timerRef.current = null + closeRef.current() + }, POINTER_GRACE_MS) + }, [cancel]) + + useEffect(() => cancel, [cancel]) + + return { arm, cancel } +} diff --git a/packages/client/ui-primitives/tests/atoms.spec.tsx b/packages/client/ui-primitives/tests/atoms.spec.tsx index 4349e2ddce..568853d0cb 100644 --- a/packages/client/ui-primitives/tests/atoms.spec.tsx +++ b/packages/client/ui-primitives/tests/atoms.spec.tsx @@ -1,7 +1,8 @@ // @vitest-environment jsdom -import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { Button, ConnectionBanner, Input, Menu, Modal, Pill } from '@deepseek-ai/dsh-client-ui-primitives' +import { POINTER_GRACE_MS } from '../src/pointer-grace.ts' afterEach(cleanup) @@ -160,16 +161,77 @@ describe('Menu', () => { expect(onSelect).toHaveBeenCalledWith('del') }) - it('closeOnPointerLeave closes when the pointer leaves the list; default stays open', () => { - const onClose = vi.fn() - const { rerender } = render( - trigger} items={items} onSelect={() => {}} onClose={onClose} />) - fireEvent.pointerLeave(screen.getByRole('menu')) - expect(onClose).toHaveBeenCalledTimes(1) - rerender( - trigger} items={items} onSelect={() => {}} onClose={onClose} />) - fireEvent.pointerLeave(screen.getByRole('menu')) - expect(onClose).toHaveBeenCalledTimes(1) + it('closeOnPointerLeave closes a grace after the pointer leaves trigger and list; default never does', () => { + vi.useFakeTimers() + try { + const onClose = vi.fn() + const { rerender } = render( + trigger} items={items} onSelect={() => {}} onClose={onClose} />) + const wrapper = screen.getByText('trigger').parentElement as HTMLElement + fireEvent.pointerLeave(wrapper) + // Still open through the grace: the pointer may be crossing the gap. + act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS - 1) }) + expect(onClose).not.toHaveBeenCalled() + act(() => { vi.advanceTimersByTime(1) }) + expect(onClose).toHaveBeenCalledTimes(1) + rerender( + trigger} items={items} onSelect={() => {}} onClose={onClose} />) + fireEvent.pointerLeave(wrapper) + act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS * 10) }) + expect(onClose).toHaveBeenCalledTimes(1) + } finally { + vi.useRealTimers() + } + }) + + it('coming back inside the grace keeps the list open (trigger and list are one region)', () => { + vi.useFakeTimers() + try { + const onClose = vi.fn() + render( + trigger} items={items} onSelect={() => {}} onClose={onClose} />) + const wrapper = screen.getByText('trigger').parentElement as HTMLElement + fireEvent.pointerLeave(wrapper) + act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS - 50) }) + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS * 10) }) + expect(onClose).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + + it('a close from selection disarms the pending grace close', () => { + vi.useFakeTimers() + try { + const onClose = vi.fn() + const { rerender } = render( + trigger} items={items} onSelect={() => {}} onClose={onClose} />) + const wrapper = screen.getByText('trigger').parentElement as HTMLElement + fireEvent.pointerLeave(wrapper) + // The owner closes for its own reason (selection/Escape) mid-grace; the + // armed timer must not survive to shut a list reopened right after. + rerender( + trigger} items={items} onSelect={() => {}} onClose={onClose} />) + act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS * 10) }) + expect(onClose).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + + it('leaving a closed list arms nothing', () => { + vi.useFakeTimers() + try { + const onClose = vi.fn() + render( + trigger} items={items} onSelect={() => {}} onClose={onClose} />) + fireEvent.pointerLeave(screen.getByText('trigger').parentElement as HTMLElement) + act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS * 10) }) + expect(onClose).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } }) it('a list click does not bubble to the anchor row (portal synthetic-event path)', () => { diff --git a/packages/client/ui-primitives/tests/hover-card.spec.tsx b/packages/client/ui-primitives/tests/hover-card.spec.tsx index ce599c0258..d5b580045a 100644 --- a/packages/client/ui-primitives/tests/hover-card.spec.tsx +++ b/packages/client/ui-primitives/tests/hover-card.spec.tsx @@ -2,6 +2,7 @@ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { HoverCard } from '@deepseek-ai/dsh-client-ui-primitives' +import { POINTER_GRACE_MS } from '../src/pointer-grace.ts' afterEach(cleanup) beforeEach(() => { vi.useFakeTimers() }) @@ -16,7 +17,13 @@ function stubAnchorRect(anchor: HTMLElement, rect: { top: number; right: number }) } -function mount(props: { openDelayMs?: number; disabled?: boolean } = {}) { +function mount(props: { + openDelayMs?: number + disabled?: boolean + copyText?: string + copyLabel?: string + copiedLabel?: string +} = {}) { const view = render( row} content={
            card body
            } {...props} />, ) @@ -25,6 +32,19 @@ function mount(props: { openDelayMs?: number; disabled?: boolean } = {}) { return { view, anchor, wrapper: anchor.parentElement as HTMLElement } } +/** Install the async browser clipboard and restore its prior host shape. */ +function installClipboard(writeText: (text: string) => Promise): () => void { + const prior = Object.getOwnPropertyDescriptor(navigator, 'clipboard') + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText }, + }) + return () => { + if (prior === undefined) Reflect.deleteProperty(navigator, 'clipboard') + else Object.defineProperty(navigator, 'clipboard', prior) + } +} + describe('HoverCard', () => { it('opens after the dwell delay, positioned right of the anchor', () => { const { wrapper } = mount() @@ -54,18 +74,47 @@ describe('HoverCard', () => { expect(screen.queryByText('card body')).toBeNull() }) - it('pointerleave closes an open card immediately; re-enter restarts the dwell', () => { + it('pointerleave closes an open card a grace later; re-enter after that restarts the dwell', () => { const { wrapper } = mount() fireEvent.pointerEnter(wrapper) act(() => { vi.advanceTimersByTime(500) }) expect(screen.getByText('card body')).toBeTruthy() fireEvent.pointerLeave(wrapper) + act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS - 1) }) + expect(screen.getByText('card body')).toBeTruthy() + act(() => { vi.advanceTimersByTime(1) }) expect(screen.queryByText('card body')).toBeNull() fireEvent.pointerEnter(wrapper) act(() => { vi.advanceTimersByTime(500) }) expect(screen.getByText('card body')).toBeTruthy() }) + it('reaching the card inside the grace keeps it open without restarting the dwell', () => { + // The portaled card is a React child of the wrapper, so the pointer + // arriving on it re-enters the wrapper — the gesture the 8px anchor gap + // used to make impossible. + const { wrapper } = mount() + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + fireEvent.pointerLeave(wrapper) + act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS - 50) }) + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS * 10) }) + expect(screen.getByText('card body')).toBeTruthy() + }) + + it('re-entering while open does not queue a second dwell', () => { + const { wrapper } = mount() + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + fireEvent.pointerEnter(wrapper) + fireEvent.pointerLeave(wrapper) + act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) }) + // A dwell restarted by the redundant enter would reopen the card here. + act(() => { vi.advanceTimersByTime(500) }) + expect(screen.queryByText('card body')).toBeNull() + }) + it('a press inside the anchor dismisses the card without waiting for disabled', () => { const { wrapper } = mount() fireEvent.pointerEnter(wrapper) @@ -78,6 +127,237 @@ describe('HoverCard', () => { expect(screen.queryByText('card body')).toBeNull() }) + it('a press on the card starts a selection instead of dismissing it', () => { + // The card is a React child of the wrapper, so capture-phase presses on + // it reach the wrapper's dismissal handler too; they must not close it, + // or the first pointerdown of a text-selection drag would kill the card. + const { wrapper } = mount() + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + fireEvent.pointerDown(screen.getByText('card body')) + // Still mounted after a grace's worth of time: no close was armed either. + act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) }) + expect(screen.getByText('card body')).toBeTruthy() + }) + + it('keeps a completed card selection instead of treating its click as copy', async () => { + const writeText = vi.fn(async () => {}) + const restoreClipboard = installClipboard(writeText) + const selection = window.getSelection() + if (selection === null) throw new Error('jsdom selection API unavailable') + try { + const { wrapper } = mount({ copyText: 'card body', copyLabel: 'Copy' }) + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + const card = screen.getByRole('button', { name: 'Copy: card body' }) + const selectedText = screen.getByText('card body') + const cardRange = document.createRange() + cardRange.selectNodeContents(selectedText) + selection.addRange(cardRange) + await act(async () => { fireEvent.click(card) }) + expect(writeText).not.toHaveBeenCalled() + expect(selection.toString()).toBe('card body') + expect(screen.getByText('card body')).toBeTruthy() + + // Firefox supports multiple selection ranges: any range intersecting + // this card wins, not only the first. + selection.removeAllRanges() + const getSelection = vi.spyOn(window, 'getSelection').mockReturnValue({ + isCollapsed: false, + rangeCount: 2, + getRangeAt: vi.fn((index: number) => ({ + intersectsNode: () => index === 1, + })), + } as unknown as Selection) + await act(async () => { fireEvent.click(card) }) + expect(writeText).not.toHaveBeenCalled() + getSelection.mockRestore() + + // A non-collapsed selection elsewhere does not block this card. + const anchorRange = document.createRange() + anchorRange.selectNodeContents(screen.getByText('row')) + selection.addRange(anchorRange) + await act(async () => { fireEvent.click(card) }) + expect(writeText).toHaveBeenCalledWith('card body') + } finally { + selection.removeAllRanges() + restoreClipboard() + } + }) + + it('a press while closed leaves the card closed', () => { + mount() + fireEvent.pointerDown(screen.getByText('row')) + act(() => { vi.advanceTimersByTime(1000) }) + expect(screen.queryByText('card body')).toBeNull() + }) + + it('copies its configured value and shows success only for the feedback window', async () => { + const writeText = vi.fn(async () => {}) + const restoreClipboard = installClipboard(writeText) + try { + const { wrapper } = mount({ + copyText: '/full/path', + copyLabel: 'Copy path', + copiedLabel: 'Copied', + }) + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + const card = screen.getByRole('button', { name: 'Copy path: /full/path' }) + const status = screen.getByRole('status') + expect(status.textContent).toBe('') + expect(card.contains(status)).toBe(false) + Object.defineProperty(card, 'offsetHeight', { configurable: true, value: 96 }) + await act(async () => { fireEvent.click(card) }) + expect(writeText).toHaveBeenCalledWith('/full/path') + expect(status.textContent).toBe('Copied') + expect(screen.getByRole('button', { name: 'Copy path: /full/path' })).toBe(card) + expect(card.style.minHeight).toBe('96px') + // Repeated activation while feedback is visible neither rewrites nor + // extends the one-second success window. + await act(async () => { fireEvent.click(card) }) + expect(writeText).toHaveBeenCalledOnce() + act(() => { vi.advanceTimersByTime(999) }) + expect(status.textContent).toBe('Copied') + act(() => { vi.advanceTimersByTime(1) }) + expect(screen.getByRole('button', { name: 'Copy path: /full/path' })).toBe(card) + expect(card.style.minHeight).toBe('') + expect(status.textContent).toBe('') + expect(screen.getByText('card body')).toBeTruthy() + } finally { + restoreClipboard() + } + }) + + it('supports button keys and ignores unrelated keys', async () => { + const writeText = vi.fn(async () => {}) + const restoreClipboard = installClipboard(writeText) + try { + const { wrapper } = mount({ copyText: 'value', copiedLabel: 'Copied' }) + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + const card = screen.getByRole('button') + fireEvent.keyDown(card, { key: 'Escape' }) + expect(writeText).not.toHaveBeenCalled() + await act(async () => { fireEvent.keyDown(card, { key: 'Enter' }) }) + expect(writeText).toHaveBeenCalledOnce() + act(() => { vi.advanceTimersByTime(1000) }) + await act(async () => { fireEvent.keyDown(card, { key: ' ' }) }) + expect(writeText).toHaveBeenCalledTimes(2) + } finally { + restoreClipboard() + } + }) + + it('keeps its content when the clipboard rejects the write', async () => { + const writeText = vi.fn(async () => { throw new Error('denied') }) + const restoreClipboard = installClipboard(writeText) + try { + const { wrapper } = mount({ copyText: 'value', copiedLabel: 'Copied' }) + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + await act(async () => { fireEvent.click(screen.getByRole('button')) }) + expect(screen.queryByText('Copied')).toBeNull() + expect(screen.getByText('card body')).toBeTruthy() + } finally { + restoreClipboard() + } + }) + + it('unmount clears copied feedback', async () => { + const writeText = vi.fn(async () => {}) + const restoreClipboard = installClipboard(writeText) + try { + const { view, wrapper } = mount({ copyText: 'value' }) + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + await act(async () => { fireEvent.click(screen.getByRole('button')) }) + expect(vi.getTimerCount()).toBe(1) + view.unmount() + expect(vi.getTimerCount()).toBe(0) + } finally { + restoreClipboard() + } + }) + + it('clears copied feedback when the card closes', async () => { + const writeText = vi.fn(async () => {}) + const restoreClipboard = installClipboard(writeText) + try { + const { wrapper } = mount({ copyText: 'value', copiedLabel: 'Copied' }) + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + await act(async () => { fireEvent.click(screen.getByRole('button')) }) + expect(screen.getByRole('status').textContent).toBe('Copied') + fireEvent.pointerLeave(wrapper) + act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) }) + expect(screen.queryByText('Copied')).toBeNull() + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + expect(screen.getByText('card body')).toBeTruthy() + } finally { + restoreClipboard() + } + }) + + it('does not create copied feedback after an in-flight write unmounts', async () => { + let acceptWrite: (() => void) | undefined + const writeText = vi.fn(() => new Promise((resolve) => { acceptWrite = resolve })) + const restoreClipboard = installClipboard(writeText) + try { + const { view, wrapper } = mount({ copyText: 'value' }) + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + fireEvent.click(screen.getByRole('button')) + expect(writeText).toHaveBeenCalledOnce() + view.unmount() + await act(async () => { acceptWrite?.() }) + expect(vi.getTimerCount()).toBe(0) + } finally { + restoreClipboard() + } + }) + + it('does not restore copied feedback after an in-flight card closes', async () => { + let acceptWrite: (() => void) | undefined + const writeText = vi.fn(() => new Promise((resolve) => { acceptWrite = resolve })) + const restoreClipboard = installClipboard(writeText) + try { + const { wrapper } = mount({ copyText: 'value', copiedLabel: 'Copied' }) + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + fireEvent.click(screen.getByRole('button')) + fireEvent.pointerLeave(wrapper) + act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) }) + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + await act(async () => { acceptWrite?.() }) + expect(vi.getTimerCount()).toBe(0) + expect(screen.getByText('card body')).toBeTruthy() + } finally { + restoreClipboard() + } + }) + + it('coalesces activations while the clipboard write is in flight', async () => { + let acceptWrite: (() => void) | undefined + const writeText = vi.fn(() => new Promise((resolve) => { acceptWrite = resolve })) + const restoreClipboard = installClipboard(writeText) + try { + const { wrapper } = mount({ copyText: 'value', copiedLabel: 'Copied' }) + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + const card = screen.getByRole('button') + fireEvent.click(card) + fireEvent.click(card) + expect(writeText).toHaveBeenCalledOnce() + await act(async () => { acceptWrite?.() }) + expect(screen.getByRole('status').textContent).toBe('Copied') + } finally { + restoreClipboard() + } + }) + it('disabled suppresses opening entirely', () => { const { wrapper } = mount({ disabled: true }) fireEvent.pointerEnter(wrapper) @@ -135,6 +415,7 @@ describe('HoverCard', () => { expect(card.style.left).toBe('308px') expect(card.style.top).toBe('90px') fireEvent.pointerLeave(wrapper) + act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) }) expect(screen.queryByText('card body')).toBeNull() }) diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index a21fd21697..a09f290860 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: cc73214a281c6950acf8846f0bae3214c8726934 -README.zh.md: c0b6472c7db74dbfd4b0afd19a258e0132a7c534 +README.md: 8ebc55d7ad202622bbbc50b3d91a6985a1be29b9 +README.zh.md: d13a8941889654f05b8cafd3f6f4251a13e0b694 diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index cc73214a28..8ebc55d7ad 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -2,11 +2,13 @@ English | [中文](README.zh.md) -Shared Workspace browser and picker plugin. `WorkspaceBrowser` fills the sidebar's `sidebar.workspaces` slot, while `WorkspacePicker` fills the page-local Session Intent hero's `conversation.hero.workspace` slot; both surfaces use the same Workspace menu and creation flow. +Shared Workspace browser and picker plugin. `WorkspaceBrowser` fills the sidebar's `sidebar.workspaces` slot, while `WorkspacePicker` fills the page-local Session Intent hero's `conversation.hero.workspace` slot; both surfaces use the same Workspace menu and add flow. -The browser renders grouped or flat Session rows from the global runtime hooks and owns the Workspace create/rename and in-Workspace reorder flows. A non-blank search query replaces either browsing mode with one flat result list: case-insensitive title and Workspace substring matches appear immediately, while a 250 ms debounced Host request adds ranked current-conversation content matches and snippets. The English search input and its defensive request path remove NUL, cap the query at the wire schema's 500 UTF-16 code units without splitting a surrogate pair, and preserve the existing debounce and cancellation behavior. Each new query aborts the preceding request; a failed content search leaves metadata matches visible with a warning. The list is capped at 20, asks the user to narrow broader queries, and opens the selected Session without clearing the query or jumping to a specific event. +The browser renders grouped or flat Session rows from the global runtime hooks and owns the Workspace add/rename and in-Workspace reorder flows. A non-blank search query replaces either browsing mode with one flat result list: case-insensitive title and Workspace substring matches appear immediately, while a 250 ms debounced Host request adds ranked current-conversation content matches and snippets. The English search input and its defensive request path remove NUL, cap the query at the wire schema's 500 UTF-16 code units without splitting a surrogate pair, and preserve the existing debounce and cancellation behavior. Each new query aborts the preceding request; a failed content search leaves metadata matches visible with a warning. The list is capped at 20, asks the user to narrow broader queries, and opens the selected Session without clearing the query or jumping to a specific event. -The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object. Each registration declares a **directory-flow child hole** (`single` kind: `conversation.hero.workspace.directoryFlow` / `sidebar.workspaces.directoryFlow`) that the composed picker package's client half fills with its picking interaction — the [`-native`](../../host/directory-picker-native/README.md) backend's renderless OS-chooser driver today, an in-app browsing dialog under a `-browse` composition. The flat **Open local folder...** action renders only while the surface's hole is occupied (occupancy read per menu render; an empty hole means the composition has no picking affordance — the seam's documented no-flow default). This package owns the trigger and the adoption: the occupant reports one picked path per open through the hole's owner conversation (`open`/`busy`/`onPicked`/`onCancel`/`onError`), and the owner adopts it through the object layer, selecting the committed Workspace only after its list projection has refreshed; cancellation is silent, and errors land in the retryable folder dialog whose **Choose again** reopens the flow. **Create a new workspace** retains the name dialog and disables names already present in that list, while the Host remains authoritative for concurrent or non-UI callers. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped. The Session row's Rename action opens the same browser-owned dialog pattern prefilled with the row's display title: no client-side conflict rule exists (the host normalizes and may reject with `title-invalid`, rendered in the dialog alert), and confirming an unchanged title is deliberately allowed — it pins the current automatic title against regeneration. The Session row's Archive action commits without a confirmation dialog (non-destructive: the log and the workspace accounting slot remain) through `ctx.workspaces.archiveSession`; the row disappears from every grouping surface — workspace groups, Ungrouped, content search, and the flat list — when the archive-set echo lands, and failures are console diagnostics that leave the tree unchanged. A blank New Session row is a pure placeholder: it renders no row menu and no time label (nothing has happened in it yet), so rename, fork, and archive first apply once the first prompt lands. +The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object. Each registration declares a **directory-flow child hole** (`single` kind: `conversation.hero.workspace.directoryFlow` / `sidebar.workspaces.directoryFlow`) that the composed picker package's client half fills with its picking interaction — the [`-native`](../../host/directory-picker-native/README.md) backend's renderless OS-chooser driver today, an in-app browsing dialog under a `-browse` composition. The flat **Add workspace...** action renders only while the surface's hole is occupied (occupancy read per menu render; an empty hole means the composition has no picking affordance — the seam's documented no-flow default, under which the sidebar header drops its add button rather than offering a dead one). This package owns the trigger and the adoption: the occupant reports one picked path per open through the hole's owner conversation (`open`/`busy`/`onPicked`/`onCancel`/`onError`), and the owner adopts it through the object layer, selecting the committed Workspace only after its list projection has refreshed; cancellation is silent, and errors land in the retryable folder dialog whose **Choose again** reopens the flow. Adding has exactly one route: the occupant's own create-folder affordance already covers a brand-new directory, so no separate create-by-name dialog exists. A menu only appears where there is something to choose between — with no Workspace listed, the anchor gesture raises the flow directly instead of a one-row popover, and it waits for the list baseline before treating an empty list as final. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped. The Session row's Rename action opens the same browser-owned dialog pattern prefilled with the row's display title: no client-side conflict rule exists (the host normalizes and may reject with `title-invalid`, rendered in the dialog alert), and confirming an unchanged title is deliberately allowed — it pins the current automatic title against regeneration. The Session row's Archive action commits without a confirmation dialog (non-destructive: the log and the workspace accounting slot remain) through `ctx.workspaces.archiveSession`; the row disappears from every grouping surface — workspace groups, Ungrouped, content search, and the flat list — when the archive-set echo lands, and failures are console diagnostics that leave the tree unchanged. A blank New Session row is a pure placeholder: it renders no row menu and no time label (nothing has happened in it yet), so rename, fork, and archive first apply once the first prompt lands. + +Workspace and Session hover cards copy the value their row clips: activating a Workspace card writes its full directory path, while activating a non-blank Session card writes its full display title. A provisional blank New Session card remains read-only because its localized label is a placeholder rather than session content. The card reports the dictionary-driven copied state only after the browser accepts the clipboard write. The Session row's Fork action forks at the source's last completed turn, increments the inherited persisted title on the client, and then opens the child; a trailing ASCII or fullwidth parenthesized number is incremented in the same style, while an unnumbered title gets ` (1)` appended. The source and child always appear as peer rows within a workspace group, with lineage retained only as session data. A fork or rename failure leaves the current selection unchanged; after a rename failure, the created child remains in the list. diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index c0b6472c7d..d13a894188 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -2,11 +2,13 @@ [English](README.md) | 中文 -共享 Workspace 浏览器与选择器插件。`WorkspaceBrowser` 填充侧边栏的 `sidebar.workspaces` slot,`WorkspacePicker` 则填充页面局部 Session Intent 主视觉区的 `conversation.hero.workspace` slot;两个表层使用同一套 Workspace 菜单和创建流程。 +共享 Workspace 浏览器与选择器插件。`WorkspaceBrowser` 填充侧边栏的 `sidebar.workspaces` slot,`WorkspacePicker` 则填充页面局部 Session Intent 主视觉区的 `conversation.hero.workspace` slot;两个表层使用同一套 Workspace 菜单和添加流程。 -该浏览器通过全局运行时钩子将 Session 行渲染为分组或扁平形式,并负责 Workspace 创建/重命名和 Workspace 内的重排序流程。非空白查询会以单一扁平结果列表替代任一浏览模式:不区分大小写的标题和 Workspace 子串匹配项会立即显示,经 250 ms 防抖的 Host 请求则会加入经过排序的当前对话内容匹配项及其摘要片段。英文搜索输入框及其防御性请求路径会移除 NUL,将查询限制在传输 schema 规定的 500 个 UTF-16 code unit 内且不会拆分 surrogate pair,并保留现有的防抖与取消行为。每次新查询都会中止前一个请求;内容搜索失败时,元数据匹配项仍会显示,同时给出警告。列表最多显示 20 条结果,并会在查询过宽时提示用户缩小范围;打开所选 Session 时既不会清除查询,也不会跳转至特定事件。 +该浏览器通过全局运行时钩子将 Session 行渲染为分组或扁平形式,并负责 Workspace 添加/重命名和 Workspace 内的重排序流程。非空白查询会以单一扁平结果列表替代任一浏览模式:不区分大小写的标题和 Workspace 子串匹配项会立即显示,经 250 ms 防抖的 Host 请求则会加入经过排序的当前对话内容匹配项及其摘要片段。英文搜索输入框及其防御性请求路径会移除 NUL,将查询限制在传输 schema 规定的 500 个 UTF-16 code unit 内且不会拆分 surrogate pair,并保留现有的防抖与取消行为。每次新查询都会中止前一个请求;内容搜索失败时,元数据匹配项仍会显示,同时给出警告。列表最多显示 20 条结果,并会在查询过宽时提示用户缩小范围;打开所选 Session 时既不会清除查询,也不会跳转至特定事件。 -该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。每个注册各自声明一个**目录流子洞**(`single` kind:`conversation.hero.workspace.directoryFlow`/`sidebar.workspaces.directoryFlow`),由组合的选择器包 client half 填入其选取交互——今天是 [`-native`](../../host/directory-picker-native/README.md) 后端的无渲染 OS 选择器驱动,`-browse` 组合下则是应用内浏览对话框。平铺显示的 **打开本地文件夹…** 操作仅在本表层的洞被占用时渲染(每次菜单渲染读取占用状态;洞为空意味着该组合没有选目录能力——seam 文档化的无流程默认行为)。本包持有触发与接纳:占用者经洞的 owner 会话(`open`/`busy`/`onPicked`/`onCancel`/`onError`)每次打开上报一个所选路径,owner 通过对象层接纳它,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace;取消操作不会显示提示,错误落入可重试的文件夹对话框,其 **重新选择** 会重新打开流程。**创建新工作区** 操作保留名称对话框,并禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。Session 行内的 Rename 操作打开同款浏览器持有的对话框,并以该行的显示标题预填:客户端不设名称冲突规则(host 负责规范化,可能以 `title-invalid` 拒绝,错误渲染在对话框告警区);确认未修改的标题是有意允许的——这正是把当前自动标题钉住、不再被重新生成覆盖的手势。Session 行内的 Archive 操作不经确认对话框直接提交(非破坏性:日志和 workspace 记账席位保持不变),通过 `ctx.workspaces.archiveSession` 归档;归档集合回声落地后,该行从所有分组视图——workspace 分组、Ungrouped、内容搜索和平铺列表——中消失,失败只作为控制台诊断输出,树保持不变。blank「新会话」行是纯占位:不渲染行菜单和时间标签(其中还没有发生任何事),rename/fork/归档都从首条 prompt 落地后才可用。 +该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。每个注册各自声明一个**目录流子洞**(`single` kind:`conversation.hero.workspace.directoryFlow`/`sidebar.workspaces.directoryFlow`),由组合的选择器包 client half 填入其选取交互——今天是 [`-native`](../../host/directory-picker-native/README.md) 后端的无渲染 OS 选择器驱动,`-browse` 组合下则是应用内浏览对话框。平铺显示的 **添加工作区…** 操作仅在本表层的洞被占用时渲染(每次菜单渲染读取占用状态;洞为空意味着该组合没有选目录能力——seam 文档化的无流程默认行为,此时侧边栏区头直接不渲染添加按钮,而非留下一个点了没反应的按钮)。本包持有触发与接纳:占用者经洞的 owner 会话(`open`/`busy`/`onPicked`/`onCancel`/`onError`)每次打开上报一个所选路径,owner 通过对象层接纳它,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace;取消操作不会显示提示,错误落入可重试的文件夹对话框,其 **重新选择** 会重新打开流程。添加只有一条路径:占用者自带的新建文件夹能力已经覆盖了全新目录,因此不再单设按名称创建的对话框。菜单只在确有多个目标可选时出现——没有 Workspace 可列时,锚点手势直接拉起流程,而不是弹出只有一行的浮层;在列表基线落地前,空列表不算最终结果。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。Session 行内的 Rename 操作打开同款浏览器持有的对话框,并以该行的显示标题预填:客户端不设名称冲突规则(host 负责规范化,可能以 `title-invalid` 拒绝,错误渲染在对话框告警区);确认未修改的标题是有意允许的——这正是把当前自动标题钉住、不再被重新生成覆盖的手势。Session 行内的 Archive 操作不经确认对话框直接提交(非破坏性:日志和 workspace 记账席位保持不变),通过 `ctx.workspaces.archiveSession` 归档;归档集合回声落地后,该行从所有分组视图——workspace 分组、Ungrouped、内容搜索和平铺列表——中消失,失败只作为控制台诊断输出,树保持不变。blank「新会话」行是纯占位:不渲染行菜单和时间标签(其中还没有发生任何事),rename/fork/归档都从首条 prompt 落地后才可用。 + +Workspace 和 Session 悬浮卡片会复制对应行被截断的值:激活 Workspace 卡片会写入其完整目录路径,激活非空白 Session 卡片则会写入其完整显示标题。临时的空白「新会话」卡片保持只读,因为其本地化标签是占位文案,并非会话内容。只有浏览器接受剪贴板写入后,卡片才会显示由字典提供的已复制状态。 Session 行内的 Fork 操作在源会话最后一个已完成轮次处 fork,在 client 端递增继承的持久化标题后再打开子会话;尾部半角或全角括号编号会原样式递增,无编号标题追加 ` (1)`。源会话与子会话在 workspace 组内始终作为同级行展示,谱系只保留为 session 数据。Fork 或改名失败都不会改变当前选中项,改名失败时已创建的子会话仍会留在列表中。 diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx index 97cc2116a4..93d432bd89 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx @@ -1,11 +1,13 @@ /** * The workspace/session browsing region filling the sidebar shell's - * `sidebar.workspaces` hole: section header (title + group-by + new + * `sidebar.workspaces` hole: section header (title + group-by + add * workspace), search, the grouped tree or flat list, and the workspace * dialogs. Wide state renders the full browser; rail state renders the two - * region icons (search / new workspace), each requesting shell expansion - * through the owner share. The picker menu and create dialogs live in - * WorkspacePicker (same package — direct composition, no slot between them). + * region icons (search / add workspace), each requesting shell expansion + * through the owner share. Adding is the header button's one action, so it + * raises the directory flow with no menu in between; the flow and its error + * dialog live in WorkspacePicker (same package — direct composition, no slot + * between them). */ import { useEffect, useMemo, useRef, useState } from 'react' import clsx from 'clsx' @@ -20,7 +22,7 @@ import type { WorkspaceBrowserProps } from './contract/slots.ts' import type { SessionNode } from './tree.ts' import { deriveFlat, deriveGroups, deriveSearchResults, UNGROUPED_KEY } from './tree.ts' import { ProjectRowItem, SearchResultItem, SessionNodeItem } from './rows/Rows.tsx' -import { WorkspaceCreateFlow } from './WorkspacePicker.tsx' +import { WorkspacePickFlow } from './WorkspacePicker.tsx' import css from './WorkspaceBrowser.module.css' /** @@ -358,6 +360,9 @@ export function WorkspaceBrowser({ }: WorkspaceBrowserProps) { const workspaces = useWorkspaces(state => state.items) const archivedSessionIds = useWorkspaces(state => state.archivedSessionIds) + // Live occupancy of this surface's directory-flow hole (the same source the + // flow reads): a composition without a picking affordance can add nothing. + const directoryFlowAvailable = useDirectoryFlow(occupied => occupied) const groupBy = useStore(s => s.groupBy) // The query outlives the tree and the input (both wide-only) so collapsing // does not silently drop an in-progress filter. @@ -541,21 +546,26 @@ export function WorkspaceBrowser({ )} {wide && { actions.setGroupBy(mode) }} t={t} />} - - - - {/* Picker menu + create dialogs (same package — direct composition). */} - + + + )} + {/* Add flow + its error dialog (same package — direct composition). */} + renderSlot('sidebar.workspaces.directoryFlow', owner)} - createOnly + addOnly side="right" onPick={(workspaceId) => { setWsPickerOpen(false) diff --git a/packages/client/ui-workspace/src/client/WorkspacePicker.module.css b/packages/client/ui-workspace/src/client/WorkspacePicker.module.css index e439d30f27..c99164aacf 100644 --- a/packages/client/ui-workspace/src/client/WorkspacePicker.module.css +++ b/packages/client/ui-workspace/src/client/WorkspacePicker.module.css @@ -1,35 +1,10 @@ -/* Modal form styles mirror the empty state's path/create modals (same figma - * dialog family: field h44, r22, hairline border, pad 14/7) so the two - * entries stay visually identical. */ -.modalInput { - box-sizing: border-box; - width: 100%; - height: 44px; - padding: 7px 14px; - border: 1px solid var(--dsw-alias-border-l2); - border-radius: 22px; - outline: none; - background: transparent; - font-size: 14px; - font-weight: 400; - line-height: 22px; - color: var(--dsw-alias-label-primary); -} - -.modalInput::placeholder { - color: var(--dsw-alias-label-caption); -} - -.modalInput:disabled { - color: var(--dsw-alias-label-dimmed); -} - +/* The adoption error dialog's footer and message styles; the dialog itself is + * the shared Modal (same figma dialog family as the browser's own dialogs). */ .modalAction { min-width: 72px; } .modalError, -.modalStatus, .menuStatus { margin-top: 8px; font-size: 12px; @@ -40,7 +15,6 @@ color: var(--dsw-alias-state-error-primary); } -.modalStatus, .menuStatus { color: var(--dsw-alias-label-secondary); } diff --git a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx index 36fa9b0948..fe5bc1d92a 100644 --- a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx +++ b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx @@ -1,13 +1,15 @@ /** - * Workspace pick/create flow. WorkspaceCreateFlow is the reusable core - * (menu + path/create dialogs) consumed directly by WorkspaceBrowser (same - * package) and wrapped by WorkspacePicker for the conversation empty-state - * slot registration. Directory picking itself lives in the composed flow - * package's slot occupant (see the contract module doc): this core only - * opens the flow, adopts the picked path, and owns the error surface. + * Workspace pick/add flow. WorkspacePickFlow is the reusable core (menu + + * path error dialog) consumed directly by WorkspaceBrowser (same package) and + * wrapped by WorkspacePicker for the conversation empty-state slot + * registration. Directory picking itself lives in the composed flow package's + * slot occupant (see the contract module doc): this core only opens the flow, + * adopts the picked path, and owns the error surface. Adding a workspace has + * exactly one route — pick a host directory, new or existing — because the + * occupant's own create-folder affordance already covers creating one. */ import type { ReactNode, RefObject } from 'react' -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { Button, IconFolderClose16, IconPlusOutline16, Menu, Modal, type MenuEntry, } from '@deepseek-ai/dsh-client-ui-primitives' @@ -19,13 +21,10 @@ import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' import type { DirectoryFlowOwnerProps, WorkspacePickerProps } from './contract/slots.ts' import css from './WorkspacePicker.module.css' -const OPEN_LOCAL_FOLDER = '::open-local-folder' -const CREATE_NEW = '::create-new' - -type ModalKind = 'create' | 'folder-error' | null +const ADD_WORKSPACE = '::add-workspace' /** Core flow props: the owner supplies popover control and pick semantics. */ -export interface WorkspaceCreateFlowProps { +export interface WorkspacePickFlowProps { /** The standard locale seat, forwarded by whichever slot entry hosts the flow. */ t: WorkspacePickerProps['t'] /** Popover visibility (anchor button toggle state, owner-local). */ @@ -34,9 +33,9 @@ export interface WorkspaceCreateFlowProps { anchorRef?: RefObject | undefined /** Selector hook over the workspace list (framework standard hook). */ useWorkspaces: (selector: (state: WorkspaceListState) => S) => S - /** Create or adopt a real Host Workspace. */ - createWorkspace: (input: { name: string } | { path: string }) => Promise - /** Bound occupancy selector hook for this surface's directory-flow hole (empty hides the local-folder entry). */ + /** Adopt a picked host directory as a real Workspace. */ + createWorkspace: (input: { path: string }) => Promise + /** Bound occupancy selector hook for this surface's directory-flow hole (empty leaves the surface with no add action). */ useDirectoryFlow: SnapshotSelectorHook /** Render this surface's directory-flow hole with the owner conversation (the entry's narrowed renderSlot). */ renderDirectoryFlow: (owner: DirectoryFlowOwnerProps) => ReactNode @@ -44,8 +43,8 @@ export interface WorkspaceCreateFlowProps { onPick: (workspaceId: WorkspaceId) => void /** Close the popover (outside click / Escape / post-pick). */ onClose: () => void - /** Only show create actions (open folder / create new), hide existing workspaces. */ - createOnly?: boolean + /** Only offer the add action, hide existing workspaces. */ + addOnly?: boolean /** Menu opening direction relative to the anchor. */ side?: 'bottom' | 'top' | 'right' /** Currently active workspace (trailing check in the picker list). */ @@ -53,11 +52,11 @@ export interface WorkspaceCreateFlowProps { } /** - * Render the pick menu plus the two create dialogs. + * Render the pick menu plus the adoption error dialog. * @param props - owner-controlled flow props. * @returns menu + dialog elements. */ -export function WorkspaceCreateFlow({ +export function WorkspacePickFlow({ t, open, anchorRef, @@ -67,32 +66,26 @@ export function WorkspaceCreateFlow({ renderDirectoryFlow, onPick, onClose, - createOnly = false, + addOnly = false, side = 'bottom', selectedId, -}: WorkspaceCreateFlowProps) { +}: WorkspacePickFlowProps) { const workspaceSnapshot = useWorkspaces(state => state) const workspaces = workspaceSnapshot.items const getAnchorRect = useCallback( () => anchorRef?.current?.getBoundingClientRect() ?? null, [anchorRef], ) - const [modalKind, setModalKind] = useState(null) - const [workspaceName, setWorkspaceName] = useState('') - const [creating, setCreating] = useState(false) + const [errorOpen, setErrorOpen] = useState(false) const [modalError, setModalError] = useState(null) const [flowOpen, setFlowOpen] = useState(false) const [pickingFolder, setPickingFolder] = useState(false) const [folderConflict, setFolderConflict] = useState(false) - const composingRef = useRef(false) // One picking interaction at a time: while the flow is open (native chooser // pending, browse dialog up) or its pick is being adopted, every other // menu action stays disabled — a late outcome must not race a concurrent - // selection or creation. + // selection or adoption. const flowBusy = flowOpen || pickingFolder - const normalizedWorkspaceName = workspaceName.trim() - const duplicateWorkspaceName = !creating && normalizedWorkspaceName !== '' - && workspaces.some(workspace => workspace.title === normalizedWorkspaceName) // The occupied hole gates the picking affordance: with no composed flow the // entry simply is not there (the seam's documented no-flow default). The @@ -107,27 +100,27 @@ export function WorkspaceCreateFlow({ useEffect(() => { if (flowOpen && !flowAvailable) setFlowOpen(false) }, [flowOpen, flowAvailable]) - const createEntries: MenuEntry[] = [ - ...(flowAvailable - ? [{ id: OPEN_LOCAL_FOLDER, label: t('menu.openFolder'), icon: , disabled: flowBusy }] - : []), - { id: CREATE_NEW, label: t('menu.createWorkspace'), icon: , disabled: flowBusy }, - ] - // With workspaces listed, the create actions pin below the scroll region - // (divider + always visible); otherwise they ARE the menu. - const pinCreate = !createOnly && workspaces.length > 0 - const items: MenuEntry[] = pinCreate + const addEntries: MenuEntry[] = flowAvailable + ? [{ 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. + const pinAdd = !addOnly && workspaces.length > 0 + const items: MenuEntry[] = pinAdd ? workspaces.map(workspace => ({ id: workspace.workspaceId, label: workspace.title, icon: , disabled: flowBusy, })) - : createEntries + : addEntries + // Nothing listed and nothing to add with (a composition that mounts this + // package without any directory-picker): an empty popover would claim a + // choice that does not exist, so the anchor gesture shows nothing at all. + const menuIsEmpty = items.length === 0 const closeModal = (): void => { - if (creating) return - setModalKind(null) + setErrorOpen(false) setModalError(null) } @@ -143,16 +136,32 @@ export function WorkspaceCreateFlow({ ) setModalError(reason instanceof Error ? reason.message : String(reason)) setFlowOpen(false) - setModalKind('folder-error') + setErrorOpen(true) }) - const openLocalFolder = (): void => { + const openDirectoryFlow = useCallback((): void => { onClose() - setModalKind(null) + setErrorOpen(false) setModalError(null) setFolderConflict(false) setFlowOpen(true) - } + }, [onClose]) + + // A menu exists to disambiguate between targets. With no workspaces listed + // and the add action the only entry left, the anchor gesture IS that action: + // a one-row popover would cost a click and offer nothing to choose between. + // The owner's open request is consumed the same way selecting the entry + // would consume it (close the popover, raise the flow). An empty list is + // only final once the baseline lands — until then the menu stays up with its + // loading status instead of jumping into a flow the arriving list would have + // made unnecessary; the add-only surface lists nothing and never waits. + const listSettled = addOnly || workspaceSnapshot.phase === 'ready' + const addIsTheOnlyEntry = !pinAdd && listSettled && addEntries.length === 1 + // `flowBusy` gates this exactly as it disables the equivalent menu entry: a + // pick still being adopted owns the surface until it settles. + useEffect(() => { + if (open && addIsTheOnlyEntry && !flowBusy) openDirectoryFlow() + }, [open, addIsTheOnlyEntry, flowBusy, openDirectoryFlow]) /** Owner side of the flow conversation: adopt keeps the flow open (busy) until the Host answers. */ const flowOwner: DirectoryFlowOwnerProps = { @@ -167,53 +176,25 @@ export function WorkspaceCreateFlow({ setFlowOpen(false) setFolderConflict(false) setModalError(message) - setModalKind('folder-error') + setErrorOpen(true) }, } const handleSelect = (id: string): void => { - if (id === OPEN_LOCAL_FOLDER) { - openLocalFolder() - return - } - if (id === CREATE_NEW) { - onClose() - setWorkspaceName('') - setModalError(null) - setModalKind('create') + if (id === ADD_WORKSPACE) { + openDirectoryFlow() return } onPick(id as WorkspaceId) } - const create = (input: { name: string } | { path: string }): void => { - if (creating) return - setCreating(true) - setModalError(null) - void createWorkspace(input).then((workspace) => { - setCreating(false) - setModalKind(null) - onPick(workspace.workspaceId) - }).catch((reason: unknown) => { - const message = reason instanceof Error ? reason.message : String(reason) - setModalError(`Workspace creation failed: ${message}`) - setCreating(false) - }) - } - - const confirmCreate = (): void => { - if (normalizedWorkspaceName !== '' && !duplicateWorkspaceName) { - create({ name: normalizedWorkspaceName }) - } - } - return ( <> - {open && workspaceSnapshot.phase === 'pending' &&
            {t('picker.loading')}
            } + {open && !addIsTheOnlyEntry && !menuIsEmpty && workspaceSnapshot.phase === 'pending' &&
            {t('picker.loading')}
            } {renderDirectoryFlow(flowOwner)} {t('cancel')} {/* Retrying needs an occupant to serve the flow; without one the * button would open a flow nobody can answer or cancel. */} - + )} > @@ -243,49 +224,6 @@ export function WorkspaceCreateFlow({ : modalError}
  • - - - - - )} - > - { setWorkspaceName(event.target.value); setModalError(null) }} - onCompositionStart={() => { composingRef.current = true }} - onCompositionEnd={() => { composingRef.current = false }} - onKeyDown={(event) => { - if (event.key === 'Enter' && !composingRef.current) { - event.preventDefault() - confirmCreate() - } - }} - /> - {creating &&
    {t('create.pending')}
    } - {duplicateWorkspaceName && ( -
    {t('conflict.named', { name: normalizedWorkspaceName })}
    - )} - {modalError !== null &&
    {modalError}
    } -
    ) } @@ -309,7 +247,7 @@ export function WorkspacePicker({ t, }: WorkspacePickerProps) { return ( - Promise - /** Explicitly create or adopt a real Workspace before targeting a Session. */ - createWorkspace: (input: { name: string } | { path: string }) => Promise + /** Adopt a picked host directory as a real Workspace before targeting a Session. */ + createWorkspace: (input: { path: string }) => Promise } /** Full browser props: shell owner share + viewing store + injected actions + the locale seat. */ @@ -144,8 +147,8 @@ export type WorkspaceBrowserProps = * supplies the implicit index signature required by the registry. */ export type WorkspacePickerInjected = DirectoryPickingInjected & { - /** Explicitly create or adopt a real Workspace before targeting a Session. */ - createWorkspace: (input: { name: string } | { path: string }) => Promise + /** Adopt a picked host directory as a real Workspace before targeting a Session. */ + createWorkspace: (input: { path: string }) => Promise } /** diff --git a/packages/client/ui-workspace/src/client/locales.ts b/packages/client/ui-workspace/src/client/locales.ts index 1ecc244329..edf15975bb 100644 --- a/packages/client/ui-workspace/src/client/locales.ts +++ b/packages/client/ui-workspace/src/client/locales.ts @@ -1,6 +1,6 @@ /** * `workspace` namespace dictionaries: the browsing region (section header, - * search, tree rows, dialogs) and the pick/create flow. Runtime failure + * search, tree rows, dialogs) and the pick/add flow. Runtime failure * messages (wire error strings) pass through untranslated by policy. */ @@ -15,7 +15,7 @@ export const zh = { 'groupBy.flat': '单列表', 'empty.none': '暂无会话', 'empty.noMatches': '无匹配结果', - 'workspace.new': '新建工作区', + 'workspace.add': '添加工作区', 'search.sessions.aria': '搜索会话', 'search.placeholder': '搜索名称、关键词…', 'search.clear': '清除搜索', @@ -24,18 +24,13 @@ export const zh = { 'search.unavailable': '内容搜索暂不可用,仅显示名称匹配。', 'search.noMatches': '无匹配会话', 'search.hasMore': '仅显示前 {n} 条结果,请缩小搜索范围。', - 'menu.openFolder': '打开本地文件夹…', - 'menu.createWorkspace': '新建工作区', + 'menu.addWorkspace': '添加工作区…', 'picker.loading': '正在加载工作区…', 'conflict.title': '已存在同名工作区', 'conflict.hint': '请选择其他名称的文件夹。', 'conflict.named': '已存在名为“{name}”的工作区。', 'folderError.title': '无法打开文件夹', 'folderError.retry': '重新选择', - 'create.confirm': '创建工作区', - 'create.desc': '该名称将同时用于工作区及其新文件夹。', - 'create.name.aria': '新工作区名称', - 'create.pending': '正在创建工作区…', 'rename': '重命名', 'rename.workspace.title': '重命名工作区', 'rename.session.title': '重命名会话', @@ -54,6 +49,7 @@ export const zh = { 'status.running': '进行中', 'status.idle': '空闲', 'hover.created': '创建于 {time}', + 'hover.copied': '已复制', 'date.ymd': '{y}年{m}月{d}日', 'time.now': '刚刚', 'time.minutes': '{n}分钟', @@ -78,7 +74,7 @@ export const en = { 'groupBy.flat': 'In one list', 'empty.none': 'No sessions yet', 'empty.noMatches': 'No matches', - 'workspace.new': 'New Workspace', + 'workspace.add': 'Add workspace', 'search.sessions.aria': 'Search sessions', 'search.placeholder': 'Search name, keywords...', 'search.clear': 'Clear search', @@ -87,18 +83,13 @@ export const en = { 'search.unavailable': 'Content search is temporarily unavailable. Showing name matches.', 'search.noMatches': 'No matching sessions', 'search.hasMore': 'Showing the first {n} results. Narrow your search.', - 'menu.openFolder': 'Open local folder…', - 'menu.createWorkspace': 'Create a new workspace', + 'menu.addWorkspace': 'Add workspace…', 'picker.loading': 'Loading workspaces…', 'conflict.title': 'A workspace with this name already exists', 'conflict.hint': 'Choose a folder with a different name.', 'conflict.named': 'A workspace named “{name}” already exists.', 'folderError.title': 'Couldn’t open folder', 'folderError.retry': 'Choose again', - 'create.confirm': 'Create workspace', - 'create.desc': 'The name is used for both the workspace and its new folder.', - 'create.name.aria': 'New workspace name', - 'create.pending': 'Creating workspace…', 'rename': 'Rename', 'rename.workspace.title': 'Rename workspace', 'rename.session.title': 'Rename session', @@ -117,6 +108,7 @@ export const en = { 'status.running': 'Running', 'status.idle': 'Idle', 'hover.created': 'Created {time}', + 'hover.copied': 'Copied', 'date.ymd': '{y}-{m}-{d}', 'time.now': 'now', 'time.minutes': '{n}min', diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx index 77583140c1..27a105df30 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.tsx +++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx @@ -158,6 +158,9 @@ export function ProjectRowItem({ group, onToggle, onCreate, actions, t }: { anchor={ownRow} content={} disabled={menuOpen} + copyText={row.cwd} + copyLabel={t('copy')} + copiedLabel={t('hover.copied')} /> ) } @@ -347,6 +350,9 @@ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork anchor={ownRow} content={} disabled={menuOpen || drag?.active === true} + copyText={row.blank ? undefined : row.title} + copyLabel={t('copy')} + copiedLabel={t('hover.copied')} /> ) } diff --git a/packages/client/ui-workspace/tests/apply.spec.ts b/packages/client/ui-workspace/tests/apply.spec.ts index 4c62678f4c..d6dfe8d185 100644 --- a/packages/client/ui-workspace/tests/apply.spec.ts +++ b/packages/client/ui-workspace/tests/apply.spec.ts @@ -107,8 +107,8 @@ describe('ui-workspace apply', () => { expect(b.rename).toHaveBeenCalledWith('ws', 'renamed') await browser.insertSessionBefore('ws' as never, 's1' as never, 's2' as never) expect(b.insertSessionBefore).toHaveBeenCalledWith('ws', 's1', 's2') - await browser.createWorkspace({ name: 'project' }) - expect(b.create).toHaveBeenCalledWith({ name: 'project' }) + await browser.createWorkspace({ path: '/tmp/browser-project' }) + expect(b.create).toHaveBeenCalledWith({ path: '/tmp/browser-project' }) const picker = (b.slots.entries('conversation.hero.workspace')[0]!.inject as () => WorkspacePickerInjected)() await picker.createWorkspace({ path: '/tmp/project' }) diff --git a/packages/client/ui-workspace/tests/rows.spec.tsx b/packages/client/ui-workspace/tests/rows.spec.tsx index c0b4959b17..7bef820d4a 100644 --- a/packages/client/ui-workspace/tests/rows.spec.tsx +++ b/packages/client/ui-workspace/tests/rows.spec.tsx @@ -33,6 +33,19 @@ function dragProps(overrides: Partial = {}): RowDragProps { } } +/** Install the async browser clipboard and restore its prior host shape. */ +function installClipboard(writeText: (text: string) => Promise): () => void { + const prior = Object.getOwnPropertyDescriptor(navigator, 'clipboard') + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText }, + }) + return () => { + if (prior === undefined) Reflect.deleteProperty(navigator, 'clipboard') + else Object.defineProperty(navigator, 'clipboard', prior) + } +} + const dataTransfer = { effectAllowed: '', dropEffect: '' } /** jsdom lacks DragEvent — the fireEvent fallback drops clientY, so pin it on the built event. */ @@ -129,8 +142,10 @@ describe('workspace browser rows', () => { expect(screen.queryByRole('menu')).toBeNull() }) - it('workspace hover card shows title, directory path, and creation time after the dwell', () => { + it('workspace hover card shows its details and copies the full directory path', async () => { vi.useFakeTimers() + const writeText = vi.fn(async () => {}) + const restoreClipboard = installClipboard(writeText) try { const group: GroupNode = { key: 'project', workspaceId: wid('project'), cwd: '/projects/project', createdAt: 0, label: 'Project', @@ -143,7 +158,11 @@ describe('workspace browser rows', () => { expect(screen.getAllByText('Project')).toHaveLength(2) expect(screen.getByText('/projects/project')).toBeTruthy() expect(screen.getByText(/^创建于 \d+年\d+月\d+日 /)).toBeTruthy() + await act(async () => { fireEvent.click(screen.getByRole('button', { name: '复制: /projects/project' })) }) + expect(writeText).toHaveBeenCalledWith('/projects/project') + expect(screen.getByRole('status').textContent).toBe('已复制') } finally { + restoreClipboard() vi.useRealTimers() } }) @@ -175,6 +194,7 @@ describe('workspace browser rows', () => { expect(screen.getAllByText('新会话').length).toBeGreaterThanOrEqual(2) expect(screen.getByText('空闲')).toBeTruthy() expect(screen.queryByText('刚刚')).toBeNull() + expect(screen.getByText('空闲').closest('[role="button"]')).toBeNull() } finally { vi.useRealTimers() } diff --git a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx index e5c89ce0de..82284c1e47 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx @@ -500,23 +500,26 @@ describe('WorkspaceBrowser', () => { } }) - it('rail create-workspace toggles the create-only picker in place, without expanding', () => { + it('rail add-workspace raises the directory flow in place, with no menu and no expansion', () => { const expandSidebar = vi.fn() mount({ wide: false, expandSidebar, useWorkspaces: hook(workspaceState([workspace('alpha', [])])) }) - fireEvent.click(screen.getByRole('button', { name: '创建工作区' })) + fireEvent.click(screen.getByRole('button', { name: '添加工作区' })) expect(expandSidebar).not.toHaveBeenCalled() - // createOnly: existing workspaces are not listed, only the create actions. + // Adding is the header's only action, so the gesture IS that action: no + // one-row popover, and existing workspaces stay in the tree below. + expect(screen.queryByRole('menu')).toBeNull() expect(screen.queryByRole('menuitem', { name: 'alpha' })).toBeNull() - expect(screen.getByRole('menuitem', { name: '打开本地文件夹…' })).toBeTruthy() - // Toggle: open and close in place. - fireEvent.click(screen.getByRole('button', { name: '创建工作区' })) - expect(screen.queryByRole('menu')).toBeNull() + expect(screen.getByTestId('directory-flow')).toBeTruthy() + }) - // Escape closes the picker through its own onClose. - fireEvent.click(screen.getByRole('button', { name: '创建工作区' })) - expect(screen.getByRole('menu')).toBeTruthy() - fireEvent.keyDown(document, { key: 'Escape' }) - expect(screen.queryByRole('menu')).toBeNull() + it('hides the add button when no directory-flow occupant is composed', () => { + mount({ + useWorkspaces: hook(workspaceState([workspace('alpha', [])])), + useDirectoryFlow: bindSnapshotSelector({ getSnapshot: () => false, subscribe: () => () => {} }), + }) + // Nothing to add with, so the header offers no dead button. + expect(screen.queryByRole('button', { name: '添加工作区' })).toBeNull() + expect(screen.getByText('alpha')).toBeTruthy() }) it('drag reorder reports the anchor to insertSessionBefore and skips no-op drops', () => { diff --git a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx index b2d1479d17..69cf8b39fb 100644 --- a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx @@ -110,8 +110,8 @@ function mount( } } -function chooseItem(name: '打开本地文件夹…' | '新建工作区'): void { - fireEvent.click(screen.getByRole('menuitem', { name })) +function chooseAdd(): void { + fireEvent.click(screen.getByRole('menuitem', { name: '添加工作区…' })) } describe('WorkspacePicker', () => { @@ -121,24 +121,12 @@ describe('WorkspacePicker', () => { expect(b.onPick).toHaveBeenCalledWith(wid('alpha')) }) - it('creates a real Workspace from a name and focuses its frontend Session target', async () => { - const created = workspace('new', 'New') - const createWorkspace = vi.fn(async () => created) - const b = mount([], createWorkspace) - chooseItem('新建工作区') - const input = screen.getByLabelText('新工作区名称') - fireEvent.change(input, { target: { value: 'project-one' } }) - fireEvent.click(screen.getByRole('button', { name: '创建工作区' })) - expect(createWorkspace).toHaveBeenCalledWith({ name: 'project-one' }) - await waitFor(() => { expect(b.onPick).toHaveBeenCalledWith(created.workspaceId) }) - }) - it('opens the composed directory flow, adopts its picked path, and selects the returned Workspace', async () => { const created = { ...workspace('adopted'), path: '/tmp/project', title: 'project' } const createWorkspace = vi.fn(async () => created) - const b = mount([], createWorkspace) + const b = mount([workspace('alpha', 'Alpha')], createWorkspace) expect(screen.queryByTestId('directory-flow')).toBeNull() - chooseItem('打开本地文件夹…') + chooseAdd() expect(b.onClose).toHaveBeenCalled() expect(screen.getByTestId('directory-flow')).toBeTruthy() await act(async () => { b.probe.owner!.onPicked('/tmp/project') }) @@ -148,9 +136,19 @@ describe('WorkspacePicker', () => { expect(screen.queryByTestId('directory-flow')).toBeNull() }) - it('treats flow cancellation as a silent no-op', () => { + it('raises the flow straight from the anchor gesture when adding is the only entry', () => { + // Nothing to list and one action left: a one-row menu would offer no + // choice, so the owner's open request lands in the flow itself. const b = mount([]) - chooseItem('打开本地文件夹…') + expect(screen.queryByRole('menu')).toBeNull() + expect(screen.queryByRole('menuitem', { name: '添加工作区…' })).toBeNull() + expect(b.onClose).toHaveBeenCalled() + expect(screen.getByTestId('directory-flow')).toBeTruthy() + }) + + it('treats flow cancellation as a silent no-op', () => { + const b = mount([workspace('alpha', 'Alpha')]) + chooseAdd() act(() => { b.probe.owner!.onCancel() }) expect(screen.queryByTestId('directory-flow')).toBeNull() expect(b.createWorkspace).not.toHaveBeenCalled() @@ -164,8 +162,8 @@ describe('WorkspacePicker', () => { code: 'workspace-name-conflict', message: 'project already exists', details: { name: 'project' }, }) }) - const b = mount([], createWorkspace) - chooseItem('打开本地文件夹…') + const b = mount([workspace('alpha', 'Alpha')], createWorkspace) + chooseAdd() await act(async () => { b.probe.owner!.onPicked('/one/project') }) await waitFor(() => { expect(screen.getByRole('dialog', { name: '已存在同名工作区' })).toBeTruthy() @@ -178,103 +176,57 @@ describe('WorkspacePicker', () => { expect(b.onPick).not.toHaveBeenCalled() }) + it('reports a non-Error adoption failure in the folder-error surface', async () => { + const b = mount([workspace('alpha', 'Alpha')], vi.fn(async () => { throw 'permission denied' })) + chooseAdd() + await act(async () => { b.probe.owner!.onPicked('/one/project') }) + await waitFor(() => { + expect(screen.getByRole('dialog', { name: '无法打开文件夹' })).toBeTruthy() + }) + expect(screen.getByRole('alert').textContent).toBe('permission denied') + expect(b.onPick).not.toHaveBeenCalled() + }) + it('disables every menu action from flow open through adoption, and reports busy to the flow', async () => { let resolve!: (workspace: WorkspaceView) => void const pending = new Promise((settle) => { resolve = settle }) const created = workspace('adopted') const b = mount([workspace('alpha', 'Alpha')], vi.fn(() => pending)) - chooseItem('打开本地文件夹…') + chooseAdd() // The flow is open but nothing is picked yet: a chooser pending on the // host display must already block concurrent workspace actions. expect(screen.getByRole('menuitem', { name: 'Alpha' }).disabled).toBe(true) - expect(screen.getByRole('menuitem', { name: '新建工作区' }).disabled).toBe(true) + expect(screen.getByRole('menuitem', { name: '添加工作区…' }).disabled).toBe(true) act(() => { b.probe.owner!.onPicked('/tmp/project') }) expect(b.probe.owner!.busy).toBe(true) - expect(screen.getByRole('menuitem', { name: '打开本地文件夹…' }).disabled).toBe(true) - expect(screen.getByRole('menuitem', { name: '新建工作区' }).disabled).toBe(true) + expect(screen.getByRole('menuitem', { name: 'Alpha' }).disabled).toBe(true) + expect(screen.getByRole('menuitem', { name: '添加工作区…' }).disabled).toBe(true) await act(async () => { resolve(created); await pending }) expect(b.probe.owner!.busy).toBe(false) }) it('shows the flow-reported failure in the folder-error surface', () => { - const b = mount([]) - chooseItem('打开本地文件夹…') + const b = mount([workspace('alpha', 'Alpha')]) + chooseAdd() act(() => { b.probe.owner!.onError('no chooser installed') }) expect(screen.getByRole('alert').textContent).toBe('no chooser installed') expect(screen.queryByTestId('directory-flow')).toBeNull() expect(b.createWorkspace).not.toHaveBeenCalled() }) - it('closes a creation modal when the user cancels', () => { - mount([]) - chooseItem('新建工作区') + it('closes the folder-error surface when the user cancels', () => { + const b = mount([workspace('alpha', 'Alpha')]) + chooseAdd() + act(() => { b.probe.owner!.onError('no chooser installed') }) fireEvent.click(screen.getByRole('button', { name: '取消' })) expect(screen.queryByRole('dialog')).toBeNull() }) - it('blocks a create-new name already present in the Workspace list', () => { - const b = mount([workspace('alpha', 'Alpha')]) - chooseItem('新建工作区') - fireEvent.change(screen.getByLabelText('新工作区名称'), { target: { value: ' Alpha ' } }) - expect(screen.getByRole('alert').textContent).toBe('已存在名为“Alpha”的工作区。') - expect(screen.getByRole('button', { name: '创建工作区' }).disabled).toBe(true) - fireEvent.keyDown(screen.getByLabelText('新工作区名称'), { key: 'Enter' }) - expect(b.createWorkspace).not.toHaveBeenCalled() - }) - - it('does not flash a duplicate alert when the successful create frame arrives before its unary response', async () => { - let resolve!: (workspace: WorkspaceView) => void - const pending = new Promise((settle) => { resolve = settle }) - const created = workspace('fresh', 'same-name') - const b = mount([], vi.fn(() => pending)) - chooseItem('新建工作区') - fireEvent.change(screen.getByLabelText('新工作区名称'), { target: { value: 'same-name' } }) - fireEvent.click(screen.getByRole('button', { name: '创建工作区' })) - - b.rerenderItems([created]) - expect(screen.getByRole('status').textContent).toBe('正在创建工作区…') - expect(screen.queryByRole('alert')).toBeNull() - await act(async () => { resolve(created); await pending }) - expect(b.onPick).toHaveBeenCalledWith(created.workspaceId) - }) - - it('exposes creation phase and error text while retaining the modal for retry', async () => { - let reject!: (reason: unknown) => void - const pending = new Promise((_resolve, rejectPromise) => { reject = rejectPromise }) - const createWorkspace = vi.fn(() => pending) - const b = mount([], createWorkspace) - chooseItem('新建工作区') - const input = screen.getByLabelText('新工作区名称') - fireEvent.keyDown(input, { key: 'ArrowRight' }) - fireEvent.change(input, { target: { value: 'broken' } }) - fireEvent.click(screen.getByRole('button', { name: '创建工作区' })) - expect(screen.getByRole('status').textContent).toBe('正在创建工作区…') - fireEvent.keyDown(input, { key: 'Enter' }) - expect(createWorkspace).toHaveBeenCalledTimes(1) - fireEvent.keyDown(document, { key: 'Escape' }) - expect(screen.getByRole('dialog')).toBeTruthy() - await act(async () => { reject(new Error('disk unavailable')); await pending.catch(() => {}) }) - expect(screen.getByRole('alert').textContent).toBe('Workspace creation failed: disk unavailable') - expect(b.view.getByRole('dialog')).toBeTruthy() - }) - - it('reports non-Error creation failures', async () => { - const b = mount([], vi.fn(async () => { throw 'permission denied' })) - chooseItem('新建工作区') - // The name field starts empty (no prefill); a name is required to submit. - fireEvent.change(screen.getByLabelText('新工作区名称'), { target: { value: 'broken' } }) - fireEvent.click(screen.getByRole('button', { name: '创建工作区' })) - await waitFor(() => { - expect(screen.getByRole('alert').textContent).toBe('Workspace creation failed: permission denied') - }) - expect(b.onPick).not.toHaveBeenCalled() - }) - it('waits to show its menu until an optional anchor is available', () => { const { renderSlot } = flowProbe() render( , @@ -282,7 +234,7 @@ describe('WorkspacePicker', () => { expect(screen.queryByRole('menu')).toBeNull() }) - it('shows list loading through a stable status surface', () => { + it('keeps the menu up while the list baseline is still in flight', () => { const state: WorkspaceListState = { ...workspaceState([]), phase: 'pending', state: 'loading', baselinesReady: false, } @@ -294,26 +246,58 @@ describe('WorkspacePicker', () => { useDirectoryFlow={occupancySource().useDirectoryFlow} renderSlot={renderSlot} t={t} />, ) + // An empty list is not final yet: jumping into the directory flow here + // would pre-empt the workspaces about to arrive. expect(screen.getByRole('status').textContent).toBe('正在加载工作区…') + expect(screen.queryByTestId('directory-flow')).toBeNull() + expect(screen.getByRole('menuitem', { name: '添加工作区…' })).toBeTruthy() }) - it('hides the folder entry while the directory-flow hole is empty', () => { - mount([], vi.fn(), occupancySource(false)) - expect(screen.getByRole('menuitem', { name: '新建工作区' })).toBeTruthy() - expect(screen.queryByRole('menuitem', { name: '打开本地文件夹…' })).toBeNull() - }) - - it('shows the folder entry when a flow package activates after the first paint', () => { + it('shows no popover at all when nothing is listed and nothing can be added', () => { + // A composition mounting this package without any directory-picker: the + // hero anchor has neither a Workspace to pick nor a way to add one, so it + // must not claim a choice with an empty menu. const b = mount([], vi.fn(), occupancySource(false)) - expect(screen.queryByRole('menuitem', { name: '打开本地文件夹…' })).toBeNull() + expect(screen.queryByRole('menu')).toBeNull() + expect(screen.queryByTestId('directory-flow')).toBeNull() + expect(b.createWorkspace).not.toHaveBeenCalled() + }) + + it('holds the anchor gesture while an adoption is still settling', async () => { + // The auto-open path obeys the same busy rule as the disabled menu entry: + // an occupant that re-registers mid-adoption must not raise a second flow. + let resolve!: (workspace: WorkspaceView) => void + const pending = new Promise((settle) => { resolve = settle }) + const created = workspace('adopted') + const b = mount([workspace('alpha', 'Alpha')], vi.fn(() => pending)) + chooseAdd() + act(() => { b.probe.owner!.onPicked('/tmp/project') }) + expect(b.probe.owner!.busy).toBe(true) + // The list empties under the still-settling adoption (the workspace was + // deleted elsewhere), which would otherwise make add the only entry. + act(() => { b.rerenderItems([]) }) + expect(b.createWorkspace).toHaveBeenCalledTimes(1) + await act(async () => { resolve(created); await pending }) + expect(b.probe.owner!.busy).toBe(false) + }) + + it('hides the add entry while the directory-flow hole is empty', () => { + mount([workspace('alpha', 'Alpha')], vi.fn(), occupancySource(false)) + expect(screen.getByRole('menuitem', { name: 'Alpha' })).toBeTruthy() + expect(screen.queryByRole('menuitem', { name: '添加工作区…' })).toBeNull() + }) + + it('shows the add entry when a flow package activates after the first paint', () => { + const b = mount([workspace('alpha', 'Alpha')], vi.fn(), occupancySource(false)) + expect(screen.queryByRole('menuitem', { name: '添加工作区…' })).toBeNull() // Registration changes flow through the subscription, no re-render needed. act(() => { b.occupancy.flip(true) }) - expect(screen.getByRole('menuitem', { name: '打开本地文件夹…' })).toBeTruthy() + expect(screen.getByRole('menuitem', { name: '添加工作区…' })).toBeTruthy() }) it('keeps Choose again inert while the flow occupant is gone, and snaps back a flow opened over an empty hole', async () => { - const b = mount([], vi.fn(async () => { throw new Error('adoption failed') })) - chooseItem('打开本地文件夹…') + const b = mount([workspace('alpha', 'Alpha')], vi.fn(async () => { throw new Error('adoption failed') })) + chooseAdd() await act(async () => { b.probe.owner!.onPicked('/one/project') }) await waitFor(() => { expect(screen.getByRole('dialog', { name: '无法打开文件夹' })).toBeTruthy() }) // The occupant unloads while the error dialog is up: retrying would open @@ -322,18 +306,18 @@ describe('WorkspacePicker', () => { expect(screen.getByRole('button', { name: '重新选择' }).disabled).toBe(true) // Cancel stays the way out, and the menu actions are usable again. fireEvent.click(screen.getByRole('button', { name: '取消' })) - expect(screen.getByRole('menuitem', { name: '新建工作区' }).disabled).toBe(false) + expect(screen.getByRole('menuitem', { name: 'Alpha' }).disabled).toBe(false) }) it('withdraws an open flow when its occupant unloads, re-enabling the menu actions', () => { - const b = mount([]) - chooseItem('打开本地文件夹…') + const b = mount([workspace('alpha', 'Alpha')]) + chooseAdd() expect(screen.getByTestId('directory-flow')).toBeTruthy() // The flow plugin unloads mid-interaction (HMR): nobody is left to // cancel, so the owner withdraws and the actions come back. act(() => { b.occupancy.flip(false) }) expect(b.probe.owner!.open).toBe(false) - expect(screen.getByRole('menuitem', { name: '新建工作区' }).disabled).toBe(false) - expect(screen.queryByRole('menuitem', { name: '打开本地文件夹…' })).toBeNull() + expect(screen.getByRole('menuitem', { name: 'Alpha' }).disabled).toBe(false) + expect(screen.queryByRole('menuitem', { name: '添加工作区…' })).toBeNull() }) }) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index d765ab70df..fb5b4cce6e 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -1595,6 +1595,15 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro // Exactly one of path/name arrives (schema refine). Existing-folder // adoption reuses its canonical path; create-by-name rejects a name // already present in the registry. + // TODO: the create-by-name branch lost its last product consumer when + // the Web picker collapsed onto the directory flow + // (.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.md). + // Delete it with the wire schema's `name` member, this + // `defaults.workspaceRoot`, the client seam that carried the name + // (`WorkspaceCreateInput`, `WorkspacesService.create`'s `{ name }` arm, + // `intentName`'s name branch, the manager's "name under workspaceRoot" + // contract), and the `dsh web --workspace-root` flag plus its apps/cli + // README lines, which exist only to feed it. async create(request) { const { payload } = request let path: string diff --git a/packages/mcp/mcp-client/README.i18n.yaml b/packages/mcp/mcp-client/README.i18n.yaml index 487742d907..9fa4504cca 100644 --- a/packages/mcp/mcp-client/README.i18n.yaml +++ b/packages/mcp/mcp-client/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/mcp/mcp-client/README.md -README.md: 82d974635cb35878d6f0365b1aa7a9745436240e -README.zh.md: e687c620158955f954dcfebf682503225ee710de +README.md: d7966595c68ff1ec4a288caf5d9fe4b0bf580cc5 +README.zh.md: eb9e0dbdb48423cc4bc698fda355e973e42bc7a3 diff --git a/packages/mcp/mcp-client/README.md b/packages/mcp/mcp-client/README.md index 82d974635c..d7966595c6 100644 --- a/packages/mcp/mcp-client/README.md +++ b/packages/mcp/mcp-client/README.md @@ -61,7 +61,7 @@ Every MCP tool has two names: the raw MCP name (sent on the wire in `tools/call` - Tool execute: `client.callTool({ name: rawName, arguments }, { signal })` with timeout + abort support—the public name is never sent to the server. - Canonical success is `{ content: JsonValue[], structuredContent? }`; complete JSON MCP blocks survive for programmatic callers. A supported advertised `outputSchema` validates `structuredContent`; unsupported schema vocabulary falls back to unconstrained `JsonValue`. - Native/model rendering keeps the existing text projection: text blocks join with newlines while image, audio, resource, and unsupported blocks become placeholders. -- On disconnect/crash: all tools are unregistered; no auto-reconnect. +- On disconnect/crash: no auto-reconnect. Registered tools remain until plugin disposal or a successful re-sync, and calls can fail against the closed transport; reload with HMR or restart the Host to reconnect. ## Services consumed @@ -103,6 +103,6 @@ Append-only; newly visible content follows the reusable request prefix and does - **Initial discovery is asynchronous** — plugin load does not wait for connection and `listTools()`, so a turn started immediately after boot or HMR can assemble before the MCP tools are registered. - **Tools are the only bridged MCP capability** — Resources and Prompts have no harness consumption surface and are deferred. -- **Crash recovery is manual** — transport closure unregisters the server's tools, but reconnect requires an HMR reload or harness restart. +- **Crash recovery is manual** — transport closure does not auto-reconnect; registered tools can remain visible but fail against the closed transport until an HMR reload or Host restart. - **Native non-text rendering is lossy** — image, audio, and resource payloads become placeholders in model context even though the execution-local canonical value preserves their JSON blocks. Richer Native multimedia projection is deferred. - **Unsupported MCP output schemas are not enforced** — `structuredContent` falls back to `JsonValue` when the advertised schema uses vocabulary outside the harness subset. diff --git a/packages/mcp/mcp-client/README.zh.md b/packages/mcp/mcp-client/README.zh.md index e687c62015..eb9e0dbdb4 100644 --- a/packages/mcp/mcp-client/README.zh.md +++ b/packages/mcp/mcp-client/README.zh.md @@ -61,7 +61,7 @@ MCP 客户端桥接插件:连接外部 [Model Context Protocol](https://modelc - 工具执行:`client.callTool({ name: rawName, arguments }, { signal })`,支持超时 + 中止;公开名称绝不会发给服务器。 - 规范成功值是 `{ content: JsonValue[], structuredContent? }`;完整的 JSON MCP 块会保留给编程调用方。受支持且已声明的 `outputSchema` 会验证 `structuredContent`;不受支持的 schema 词汇会回退为不受约束的 `JsonValue`。 - Native/模型渲染保留现有文本投影:文本块以换行连接,图片、音频、资源和不受支持的块会变成占位符。 -- 断开/崩溃时:注销所有工具;不自动重新连接。 +- 断开/崩溃时:不自动重新连接。已注册工具会一直保留到插件完成资源释放或成功重新同步,针对已关闭传输的调用可能失败;请通过 HMR 重新加载或重启 Host 来重新连接。 ## 消费的服务 @@ -103,6 +103,6 @@ MCP 客户端桥接插件:连接外部 [Model Context Protocol](https://modelc - **初始发现是异步的**:插件加载不会等待连接和 `listTools()`,因此在启动或 HMR 后立即开始的轮次可能在 MCP 工具注册前完成组装。 - **只桥接 MCP 的工具能力**:资源和提示词没有 harness 消费接口,暂缓实现。 -- **崩溃恢复需要手动触发**:传输关闭会注销服务器工具,但重新连接需要 HMR 重载或重启 harness。 +- **崩溃恢复需要手动触发**:传输关闭后不会自动重新连接;已注册工具可能仍然可见,但会因传输已关闭而调用失败,直到 HMR 重载或重启 Host。 - **Native 非文本渲染有损**:图片、音频与资源载荷在模型上下文中会变成占位符,即使执行局部的规范值保留了其 JSON 块。更丰富的 Native 多媒体投影暂缓实现。 - **不强制执行不受支持的 MCP 输出 schema**:已声明 schema 使用 harness 子集之外的词汇时,`structuredContent` 会回退到 `JsonValue`。 diff --git a/packages/ui/app-boot/README.i18n.yaml b/packages/ui/app-boot/README.i18n.yaml index 619a3ab81d..75b1ec16b2 100644 --- a/packages/ui/app-boot/README.i18n.yaml +++ b/packages/ui/app-boot/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/ui/app-boot/README.md -README.md: 51bc5082512632dd493956b96c605aff47dc872e -README.zh.md: 644d3a3613a9516cb02881fac8ba7531bffa81eb +README.md: ebd8e0842b934f6887e3c122e781c1d0f13bb5d3 +README.zh.md: ccd897d48178482aa74d0eb73505e26ec3a08d6c diff --git a/packages/ui/app-boot/README.md b/packages/ui/app-boot/README.md index 51bc508251..ebd8e0842b 100644 --- a/packages/ui/app-boot/README.md +++ b/packages/ui/app-boot/README.md @@ -13,7 +13,7 @@ Shared boot glue for the app bins ([`dsh`](../../../apps/cli/README.md), [`dsh-c | `assertEntriesActivated(ctx, binName)` | Include the `assertEntriesLoaded` check, then await every enabled entry after the Loader settles; throw with each failed plugin's original stack or each pending plugin's unresolved services | | `loadPersonalPatches(binName, dir?)` | Parse the optional `config.yaml` in the Harness home (default [`resolveDshHome()`](../../util/paths/README.md): `$DSH_HOME`, else `~/.dsh`) — a top-level YAML array of include `PatchOptions` (id-targeted config overrides, `insert` lists, `!!js` allowed); absent file → `undefined`, an unreadable/unparsable/non-array file throws | | `loadOverlayPatches(binName, file)` | Parse a required patch-list file with the same shape as personal config; read or parse failures throw a labelled error | -| `boot(binName, absoluteConfigPath, patches?, prepare?)` | Create the root context, install Loader, run optional host preparation before config-tree entries mount (`prepare` may use Loader and provide launcher-owned context slots such as [`MAIN_SESSION_ID_KEY`](../tui/README.md)), then mount and await the include tree, assert entries loaded and activated, and return the root context | +| `boot(binName, absoluteConfigPath, patches?, prepare?)` | Create the root context, expose `dshHomePath(...segments)` to Loader `!!js` config expressions, install Loader, run optional host preparation before config-tree entries mount (`prepare` may use Loader and provide launcher-owned context slots such as [`MAIN_SESSION_ID_KEY`](../tui/README.md)), then mount and await the include tree, assert entries loaded and activated, and return the root context | | `renderConfigDump(binName, absoluteConfigPath, layers, warn?)` | Compose the base config and labeled overlay layers offline — the include's own parser and patch algorithm (`entryListSchema`/`applyEntryPatches`), so the result equals what `boot()` mounts — and render YAML with `!!js` expressions verbatim; each run of same-provenance rows is preceded by a `# ==` comment naming the contributing file and the layers that patched it, keeping the output one loadable document; a patch matching no row goes to `warn` with its layer label (default: one stderr line), read/parse/shape failures throw | | `addHarnessSourceSection(ctx, sourceRoot)` | Add a global `harness:source` prompt section (ordered just after the harness identity, before the persona) telling the agent the on-disk path to its own source checkout; a no-op returning `undefined` when the booted tree has no `systemPrompt` service. The section is registered against that service's fiber, so a dev HMR reload of the system prompt drops it until the next boot | | `HARNESS_SOURCE_SECTION` | The `'harness:source'` section name `addHarnessSourceSection` registers under | diff --git a/packages/ui/app-boot/README.zh.md b/packages/ui/app-boot/README.zh.md index 644d3a3613..ccd897d481 100644 --- a/packages/ui/app-boot/README.zh.md +++ b/packages/ui/app-boot/README.zh.md @@ -13,7 +13,7 @@ | `assertEntriesActivated(ctx, binName)` | 先执行 `assertEntriesLoaded` 检查,再在 Loader 结算后等待每个已启用配置项;抛出的错误包含每个失败插件的原始错误堆栈,或每个等待中插件尚未解析的服务 | | `loadPersonalPatches(binName, dir?)` | 解析 Harness home 中可选的 `config.yaml`(默认使用 [`resolveDshHome()`](../../util/paths/README.md):先取 `$DSH_HOME`,否则取 `~/.dsh`):其顶层是一个 YAML 数组,内容为 include 的 `PatchOptions`(按 id 定位的配置覆盖、`insert` 列表,允许 `!!js`);文件不存在时返回 `undefined`,文件不可读、不可解析或内容不是数组时抛出异常 | | `loadOverlayPatches(binName, file)` | 解析一份必需的 patch 列表文件,其形状与个人配置相同;读取或解析失败时抛出带标签的错误 | -| `boot(binName, absoluteConfigPath, patches?, prepare?)` | 创建根上下文并安装 Loader,在配置树条目挂载前执行可选的宿主准备操作(`prepare` 可以使用 Loader,也可以提供由启动器拥有的上下文插槽,例如 [`MAIN_SESSION_ID_KEY`](../tui/README.md)),再挂载并等待 include 树结算,断言所有条目均已加载并激活,最后返回根上下文 | +| `boot(binName, absoluteConfigPath, patches?, prepare?)` | 创建根上下文,向 Loader `!!js` 配置表达式暴露 `dshHomePath(...segments)` 并安装 Loader,在配置树条目挂载前执行可选的宿主准备操作(`prepare` 可以使用 Loader,也可以提供由启动器拥有的上下文插槽,例如 [`MAIN_SESSION_ID_KEY`](../tui/README.md)),再挂载并等待 include 树结算,断言所有条目均已加载并激活,最后返回根上下文 | | `renderConfigDump(binName, absoluteConfigPath, layers, warn?)` | 离线合成基础配置与带标签的覆盖层——使用 include 自己的解析器和补丁算法(`entryListSchema`/`applyEntryPatches`),因此结果与 `boot()` 挂载的内容一致——并渲染为 YAML,`!!js` 表达式原样保留;每段来源相同的连续行之前都有一条 `# ==` 注释,标明贡献该段的文件以及修补过它的层,输出仍是一份可加载的文档;未匹配到行的补丁连同其层标签交给 `warn`(默认:一行 stderr),读取/解析/形状失败则抛出 | | `addHarnessSourceSection(ctx, sourceRoot)` | 添加全局 `harness:source` 提示词段落(顺序紧随 harness 身份、位于 persona 之前),告知 agent(智能体)自身源代码 checkout 的磁盘路径;如果已启动树没有此项服务,则不执行操作并返回 `undefined`。这里的服务是 `systemPrompt`;该段落注册到它的 fiber,因此开发环境 HMR(热模块替换)重新加载系统提示词后,它会消失直至下次启动 | | `HARNESS_SOURCE_SECTION` | `'harness:source'` 段落名称,供 `addHarnessSourceSection` 注册使用 | diff --git a/packages/ui/app-boot/src/index.ts b/packages/ui/app-boot/src/index.ts index 58b9d63bf8..982bcc59ed 100644 --- a/packages/ui/app-boot/src/index.ts +++ b/packages/ui/app-boot/src/index.ts @@ -1,8 +1,8 @@ /** * Shared boot glue for the app bins (`dsh`, `dsh-cli-demo`, `dsh-acp-demo`): load the gitignored * `.env`, install the fail-loud Loader guards, resolve the config path (snapshot-aware), load the - * optional personal overlay patches from the Harness home (`~/.dsh`), and drive the cordis Loader - * against a leaf `cordis.yml` until the whole tree has settled. + * optional personal overlay patches from the Harness home (`~/.dsh`), expose its path resolver to + * config expressions, and drive the Cordis Loader against a leaf `cordis.yml` until the tree settles. * @module @deepseek-ai/dsh-app-boot */ @@ -13,10 +13,17 @@ import * as yaml from 'js-yaml' import { Context, type FiberState } from 'cordis' import Loader from '@cordisjs/plugin-loader' import Include, { applyEntryPatches, entryListSchema, type PatchOptions } from '@cordisjs/plugin-include' -import { resolveDshHome } from '@deepseek-ai/dsh-paths' +import { dshHomePath, resolveDshHome } from '@deepseek-ai/dsh-paths' // Side-effect type import: resolves `ctx.get('systemPrompt')` to the service. import type {} from '@deepseek-ai/dsh-system-prompt' +declare module 'cordis' { + interface Context { + /** Harness-home path resolver available to Loader `!!js` config expressions. */ + dshHomePath?: typeof dshHomePath + } +} + /** * Resolve the config to boot. Replay swaps a `cordis.yml` basename for * `cordis.snapshot.yml` in the same directory; every other mode keeps the path. @@ -446,6 +453,7 @@ export async function boot( ): Promise { const ctx = new Context() ctx.baseUrl = pathToFileURL(dirname(absoluteConfigPath)).href + '/' + ctx.provide('dshHomePath', dshHomePath) await ctx.plugin(Loader) ctx.loader.builtins.include = Include await prepare?.(ctx) diff --git a/packages/ui/app-boot/tests/app-boot.spec.ts b/packages/ui/app-boot/tests/app-boot.spec.ts index c756f28b51..7f06016267 100644 --- a/packages/ui/app-boot/tests/app-boot.spec.ts +++ b/packages/ui/app-boot/tests/app-boot.spec.ts @@ -325,6 +325,34 @@ describe('boot', () => { } }) + it('exposes dshHomePath to Loader config expressions', async () => { + const dir = tmp() + const dshHome = join(dir, 'home') + vi.stubEnv('DSH_HOME', dshHome) + writeFileSync(join(dir, 'capture.mjs'), [ + 'export const name = "capture"', + 'export function apply(ctx, config) {', + ' ctx.provide("capturedPath", config.path)', + '}', + '', + ].join('\n')) + writeFileSync(join(dir, 'cordis.yml'), [ + '- id: capture', + ' name: ./capture.mjs', + ' config:', + " path: !!js dshHomePath('sessions')", + '', + ].join('\n')) + let ctx: Context | undefined + try { + ctx = await boot(NAME, join(dir, 'cordis.yml')) + expect(ctx.get('capturedPath')).toBe(join(dshHome, 'sessions')) + } finally { + await ctx?.fiber.dispose() + vi.unstubAllEnvs() + } + }) + it('returns instead of asserting over a tree a surface disposed mid-startup', async () => { // What a TUI `/exit` does (ui-tui's disposeRootAndExit): dispose the root // fiber, which lands while boot() is still awaiting the Loader whenever the diff --git a/packages/util/paths/README.i18n.yaml b/packages/util/paths/README.i18n.yaml index a57d18b601..a61962d853 100644 --- a/packages/util/paths/README.i18n.yaml +++ b/packages/util/paths/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/util/paths/README.md -README.md: b28e684f3183d739c8e229a9b341801dbf345d86 -README.zh.md: ab4e8123d19fd56749e3e7a0d59e8cd6bea0c3d0 +README.md: 2b3272e019ef2f37386da9156b06a5c151836d8c +README.zh.md: 75cc22581e0e0c5ab18573eb0785251243009121 diff --git a/packages/util/paths/README.md b/packages/util/paths/README.md index b28e684f31..2b3272e019 100644 --- a/packages/util/paths/README.md +++ b/packages/util/paths/README.md @@ -8,6 +8,8 @@ Shared filesystem path helpers for DeepSeek Harness user data. `resolveDshHome()` resolves the single-root DeepSeek Harness home. Precedence, highest first: an explicit configured path, `$DSH_HOME`, then `~/.dsh`. The harness keeps all user data under one root. +`dshHomePath(...segments)` joins child segments onto that resolved home with Node's platform path rules. With no segments it returns the home itself. + `dshHomeDisplay()` names an active root symbolically for user-facing paths: `~/.dsh` for the default home, `$DSH_HOME` for any configured home. It never leaks an absolute machine path. `DSH_HOME_DIR_NAME` owns the default user-data directory name: `.dsh`. diff --git a/packages/util/paths/README.zh.md b/packages/util/paths/README.zh.md index ab4e8123d1..75cc22581e 100644 --- a/packages/util/paths/README.zh.md +++ b/packages/util/paths/README.zh.md @@ -8,6 +8,8 @@ DeepSeek Harness 用户数据的共享文件系统路径辅助工具。 `resolveDshHome()` 解析 DeepSeek Harness 的单根主目录。优先级从高到低为:显式配置的路径、`$DSH_HOME`、`~/.dsh`。harness 将所有用户数据保存在同一根目录下。 +`dshHomePath(...segments)` 使用 Node 的平台路径规则,将子路径段拼接到解析后的主目录下。不传入任何路径段时,返回主目录本身。 + `dshHomeDisplay()` 以符号方式表示当前根目录,用于面向用户的路径:默认主目录表示为 `~/.dsh`,任何已配置的主目录表示为 `$DSH_HOME`。它绝不会泄露机器的绝对路径。 `DSH_HOME_DIR_NAME` 定义默认用户数据目录名:`.dsh`。 diff --git a/packages/util/paths/src/index.ts b/packages/util/paths/src/index.ts index c54a5e0a5f..a8f9245c72 100644 --- a/packages/util/paths/src/index.ts +++ b/packages/util/paths/src/index.ts @@ -52,6 +52,15 @@ export function resolveDshHome(configured?: string, env: Record { + vi.unstubAllEnvs() +}) + describe('dsh path helpers', () => { it('owns the shared default DSH home directory name', () => { expect(DSH_HOME_DIR_NAME).toBe('.dsh') @@ -38,6 +43,12 @@ describe('dsh path helpers', () => { expect(resolveDshHome(undefined, { DSH_HOME: ' ' })).toBe(defaultDshHome()) }) + it('joins child segments onto the resolved DSH_HOME', () => { + vi.stubEnv('DSH_HOME', '~/env-dsh') + expect(dshHomePath()).toBe(join(homedir(), 'env-dsh')) + expect(dshHomePath('storages', 'cache')).toBe(join(homedir(), 'env-dsh', 'storages', 'cache')) + }) + it('labels a resolved home by whether it is the default root', () => { expect(dshHomeDisplay(resolve(defaultDshHome()))).toBe('~/.dsh') expect(dshHomeDisplay('/some/other/root')).toBe('$DSH_HOME') diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 69c8eda579..1b19474415 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -39,6 +39,9 @@ importers: '@types/node': specifier: ^22.20.0 version: 22.20.0 + '@types/spdx-expression-parse': + specifier: ^4.0.0 + version: 4.0.0 '@typescript-eslint/parser': specifier: 8.61.0 version: 8.61.0(eslint@10.5.0(jiti@2.7.0))(typescript@6.0.3) @@ -99,6 +102,12 @@ importers: publint: specifier: ^0.3.21 version: 0.3.21 + smol-toml: + specifier: ^1.7.1 + version: 1.7.1 + spdx-expression-parse: + specifier: ^5.0.0 + version: 5.0.0 tsdown: specifier: ^0.22.2 version: 0.22.2(oxc-resolver@11.20.0)(publint@0.3.21)(tsx@4.22.4)(typescript@6.0.3) @@ -291,6 +300,12 @@ importers: '@deepseek-ai/dsh-plan-mode': specifier: workspace:^ version: link:../../packages/plan/plan-mode + '@deepseek-ai/dsh-pty': + specifier: workspace:^ + version: link:../../packages/pty/pty + '@deepseek-ai/dsh-pty-local': + specifier: workspace:^ + version: link:../../packages/pty/pty-local '@deepseek-ai/dsh-repeat-tool-guard': specifier: workspace:^ version: link:../../packages/guard/repeat-tool-guard @@ -393,6 +408,9 @@ importers: '@deepseek-ai/dsh-tool-bash': specifier: workspace:^ version: link:../../packages/bash/tool-bash + '@deepseek-ai/dsh-tool-bash-persistent': + specifier: workspace:^ + version: link:../../packages/pty/tool-bash-persistent '@deepseek-ai/dsh-tool-cordis': specifier: workspace:^ version: link:../../packages/cordis/tool-cordis @@ -1084,15 +1102,15 @@ importers: '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../connection - '@deepseek-ai/dsh-compact': - specifier: workspace:^ - version: link:../../compact/compact '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots '@deepseek-ai/dsh-commands': specifier: workspace:^ version: link:../../ui/commands + '@deepseek-ai/dsh-compact': + specifier: workspace:^ + version: link:../../compact/compact '@deepseek-ai/dsh-host-apiproxy': specifier: workspace:^ version: link:../../host/apiproxy @@ -8819,6 +8837,9 @@ packages: '@types/retry@0.12.0': resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==} + '@types/spdx-expression-parse@4.0.0': + resolution: {integrity: sha512-odQzy87phelGS4inXOzjmusx4hoCVD0IbxUANxHzVkmTzMRTNnUPoq1urIl7S1qf09KcDWKLFIftPmLtgbsAHA==} + '@types/tough-cookie@4.0.5': resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} @@ -11166,6 +11187,10 @@ packages: resolution: {integrity: sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==} engines: {node: '>= 18'} + smol-toml@1.7.1: + resolution: {integrity: sha512-PPlsspAZ4jbMBu5DMFhfUGDQLu/vrL4SyBROVS37x8ynnVmFIs1VPBz1Co8Xks3TvpIaZXmU85y4DrQ+UyVFoQ==} + engines: {node: '>= 18'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -11177,6 +11202,15 @@ packages: space-separated-tokens@2.0.2: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + spdx-exceptions@2.5.0: + resolution: {integrity: sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==} + + spdx-expression-parse@5.0.0: + resolution: {integrity: sha512-vngmw3Rgn+o2arXNbnZaj5UtOEBuWBfvaI+Wc8GFfykIhA5/vdK9/Sp/XkLv63dykz2rxKDvKEHupF5P0FORcQ==} + + spdx-license-ids@3.0.23: + resolution: {integrity: sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==} + speakingurl@14.0.1: resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==} engines: {node: '>=0.10.0'} @@ -13803,6 +13837,8 @@ snapshots: '@types/retry@0.12.0': {} + '@types/spdx-expression-parse@4.0.0': {} + '@types/tough-cookie@4.0.5': {} '@types/trusted-types@2.0.7': @@ -16669,12 +16705,23 @@ snapshots: smol-toml@1.6.1: {} + smol-toml@1.7.1: {} + source-map-js@1.2.1: {} source-map@0.6.1: {} space-separated-tokens@2.0.2: {} + spdx-exceptions@2.5.0: {} + + spdx-expression-parse@5.0.0: + dependencies: + spdx-exceptions: 2.5.0 + spdx-license-ids: 3.0.23 + + spdx-license-ids@3.0.23: {} + speakingurl@14.0.1: {} stackback@0.0.2: {} diff --git a/scripts/gen-third-party-notices.spec.ts b/scripts/gen-third-party-notices.spec.ts new file mode 100644 index 0000000000..707c30ff70 --- /dev/null +++ b/scripts/gen-third-party-notices.spec.ts @@ -0,0 +1,208 @@ +import { readdirSync, readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' +import { collectPythonDependencies, isPermissive, type Manifest, manifestPatterns, parsePyprojectRequirements, parseVendoredRows, render, tierExternalDeps } from './gen-third-party-notices.ts' + +const root = resolve(import.meta.dirname, '..') + +describe('THIRD_PARTY_NOTICES.md', () => { + // Freshness lives here rather than in its own doc-sync gate: this spec file + // already runs in the test lane, so the check costs no extra CI process. + // Pre-commit regenerates the file whenever a manifest is staged, so reaching + // this assertion means the notices were committed without that hook. + it('matches what the generator produces from the current manifests', () => { + expect(readFileSync(resolve(root, 'THIRD_PARTY_NOTICES.md'), 'utf8'), 'stale notices — run `pnpm run gen-third-party-notices`').toBe(render()) + }) +}) + +/** Build the (manifests, names) pair `tierExternalDeps` consumes. */ +function workspace(entries: Record): { manifests: Map; names: Set } { + const manifests = new Map(Object.entries(entries)) + const names = new Set() + for (const manifest of manifests.values()) { + if (manifest.name !== undefined) names.add(manifest.name) + } + return { manifests, names } +} + +describe('tierExternalDeps', () => { + it('tiers by declaring area, not by the declaring section name', () => { + const { manifests, names } = workspace({ + // Root tooling and test infrastructure never ship, whichever section declares them. + 'package.json': { dependencies: { 'root-runtime-looking': '^1' }, devDependencies: { 'lint-tool': '^1' } }, + 'packages/support/loader-smoke/package.json': { name: '@deepseek-ai/dsh-loader-smoke', dependencies: { 'smoke-helper': '^1' } }, + 'packages/client/test-runtime/package.json': { name: '@deepseek-ai/dsh-client-test-runtime', dependencies: { 'test-lib': '^1' } }, + 'website/package.json': { devDependencies: { 'site-tool': '^1' } }, + // A plugin package's runtime dependency ships even when no app mounts it by default. + 'packages/mcp/mcp-client/package.json': { name: '@deepseek-ai/dsh-mcp-client', dependencies: { 'protocol-sdk': '^1' }, devDependencies: { 'protocol-fixture-server': '^1' } }, + 'apps/cli/package.json': { name: '@deepseek-ai/dsh-cli', dependencies: { 'cli-lib': '^1', '@deepseek-ai/dsh-mcp-client': 'workspace:^' } }, + }) + + expect(tierExternalDeps(manifests, names)).toEqual(new Map([ + ['tsx', true], + ['root-runtime-looking', false], + ['lint-tool', false], + ['smoke-helper', false], + ['test-lib', false], + ['site-tool', false], + ['protocol-sdk', true], + ['protocol-fixture-server', false], + ['cli-lib', true], + ])) + }) + + it('keeps a package runtime when any shipping area declares it, and excludes workspace links', () => { + const { manifests, names } = workspace({ + 'package.json': { devDependencies: { shared: '^1' } }, + 'packages/ui/tui/package.json': { name: '@deepseek-ai/dsh-tui', dependencies: { shared: '^1', '@deepseek-ai/dsh-cli': 'workspace:^' } }, + 'apps/cli/package.json': { name: '@deepseek-ai/dsh-cli' }, + }) + + expect(tierExternalDeps(manifests, names).get('shared')).toBe(true) + expect(tierExternalDeps(manifests, names).has('@deepseek-ai/dsh-cli')).toBe(false) + }) +}) + +describe('parseVendoredRows', () => { + it('reads the committed vendor manifest table', () => { + const rows = parseVendoredRows(readFileSync(resolve(root, 'vendor/README.md'), 'utf8')) + + expect(rows.length).toBeGreaterThan(0) + expect(rows).toContainEqual({ npmName: 'cordis', upstream: 'https://github.com/cordiverse/cordis' }) + // The upstream column carries a trailing package path for some rows; it is not part of the URL. + expect(rows.every(row => /^https:\/\/\S+$/.test(row.upstream))).toBe(true) + }) + + it('yields nothing when the table shape changes, so the generator fails loud', () => { + expect(parseVendoredRows('| `cordis/` | cordis | 4.0.0 | https://example.com | `abc123` |\n')).toEqual([]) + }) + + it('covers every vendored directory, so no package can drop out of the notices', () => { + const parsed = new Set(parseVendoredRows(readFileSync(resolve(root, 'vendor/README.md'), 'utf8')).map(row => row.npmName)) + const onDisk = readdirSync(resolve(root, 'vendor'), { withFileTypes: true }) + .filter(entry => entry.isDirectory()) + .map(entry => (JSON.parse(readFileSync(resolve(root, 'vendor', entry.name, 'package.json'), 'utf8')) as Manifest).name) + + expect([...onDisk].sort()).toEqual([...parsed].sort()) + }) +}) + +describe('parsePyprojectRequirements', () => { + it('reads the committed manifests', () => { + expect(parsePyprojectRequirements(readFileSync(resolve(root, 'python/sdk/pyproject.toml'), 'utf8'))).toContain('pydantic') + }) + + it('locates requirement arrays by TOML table, so author-named groups are not missed', () => { + expect(parsePyprojectRequirements([ + '[build-system]', + 'requires = ["hatchling>=1.24.0"]', + '', + '[project]', + 'name = "not-a-requirement"', + 'dependencies = ["pydantic>=2.12"]', + '', + '[project.optional-dependencies]', + 'cli = ["click"]', + '', + '[dependency-groups]', + 'docs = ["sphinx>=7"]', + '', + '[tool.hatch.build.targets.wheel]', + 'packages = ["src/deepseek_harness"]', + '', + '[tool.pytest.ini_options]', + 'testpaths = ["tests"]', + ].join('\n'))).toEqual(['hatchling', 'pydantic', 'click', 'sphinx']) + }) + + it('does not truncate an array at a bracket inside extras', () => { + expect(parsePyprojectRequirements('[project]\ndependencies = ["httpx[http2]", "requests"]\n')) + .toEqual(['httpx', 'requests']) + }) + + it('reads names whether or not requirements carry versions, extras, or markers', () => { + expect(parsePyprojectRequirements("[project]\ndependencies = [\"pydantic>=2.12\", \"requests\", \"httpx[http2]\", \"tomli ; python_version < '3.11'\", \"hatchling >= 1.24.0\"]\n")) + .toEqual(['pydantic', 'requests', 'httpx', 'tomli', 'hatchling']) + }) + + it('reads single-quoted TOML literals and rejects an unreadable requirement', () => { + expect(parsePyprojectRequirements("[project]\ndependencies = ['requests', \"pydantic>=2\"]\n")).toEqual(['requests', 'pydantic']) + expect(() => parsePyprojectRequirements('[project]\ndependencies = ["!!broken"]\n')).toThrow(/cannot read a distribution name/) + }) + + it('reads a multi-line array', () => { + expect(parsePyprojectRequirements('[project]\ndependencies = [\n "pydantic>=2.12",\n "typing-extensions",\n]\n')) + .toEqual(['pydantic', 'typing-extensions']) + }) + + it('obeys TOML comments, quoted keys, and escaped strings', () => { + expect(parsePyprojectRequirements([ + '[project] # a legal header comment', + 'dependencies = [', + ' "pydantic", # ] does not close the array', + ' # "old-package" is not a dependency', + ' "tomli; python_version < \'3.11\'",', + ']', + '', + '[dependency-groups]', + '"test.docs" = ["pytest"]', + ].join('\n'))).toEqual(['pydantic', 'tomli', 'pytest']) + }) + + it('accepts dependency-group includes and rejects unsupported requirement shapes', () => { + expect(parsePyprojectRequirements('[dependency-groups]\nbase = ["pytest"]\nall = [{ include-group = "base" }]\n')) + .toEqual(['pytest']) + expect(() => parsePyprojectRequirements('[project]\ndependencies = "pytest"\n')).toThrow(/must be an array/) + expect(() => parsePyprojectRequirements('[dependency-groups]\ntest = [{ unknown = "pytest" }]\n')).toThrow(/unsupported requirement entry/) + }) +}) + +describe('collectPythonDependencies', () => { + it('excludes normalized local project names without exempting a third-party prefix', () => { + const pyprojects = [ + '[project]\nname = "deepseek-harness-runtime-bin"\ndependencies = ["pydantic"]\n', + '[project]\nname = "deepseek-harness"\ndependencies = ["DeepSeek.Harness_Runtime-Bin", "deepseek-unrelated"]\n', + ] + expect(() => collectPythonDependencies(pyprojects)).toThrow( + 'python dependency deepseek-unrelated is missing from PYTHON_METADATA', + ) + }) +}) + +describe('isPermissive', () => { + it('accepts the licenses this project ships and rejects copyleft or unknown ones', () => { + expect(['MIT', 'ISC', 'BSD-3-Clause', 'Apache-2.0', 'MIT / Apache-2.0', '(MIT OR CC0-1.0)'].every(isPermissive)).toBe(true) + expect(['LGPL-3.0-only', 'MPL-2.0', 'GPL-3.0-or-later', 'SEE LICENSE IN LICENSE'].some(isPermissive)).toBe(false) + }) + + it('requires every operand of an AND, so a copyleft conjunct cannot ride along', () => { + expect(isPermissive('(MIT OR Apache-2.0) AND GPL-3.0-only')).toBe(false) + expect(isPermissive('MIT AND ISC')).toBe(true) + // An exception clause is not a recognized identifier, so it fails closed. + expect(isPermissive('GPL-2.0-only WITH Classpath-exception-2.0')).toBe(false) + }) + + it('honors grouping and SPDX precedence', () => { + expect(isPermissive('MIT OR (GPL-3.0-only AND GPL-2.0-only)')).toBe(true) + expect(isPermissive('(MIT OR Apache-2.0) AND ISC')).toBe(true) + }) + + it('fails closed for malformed expressions, additions, and exceptions', () => { + expect(['MIT)', '((MIT', '(MIT OR GPL-3.0-only', 'MIT OR OR GPL-3.0-only'].some(isPermissive)).toBe(false) + expect(isPermissive('MIT+')).toBe(false) + expect(isPermissive('GPL-2.0-only WITH Classpath-exception-2.0')).toBe(false) + }) +}) + +describe('manifestPatterns', () => { + it('derives globs from the declared members, so a new member area is read', () => { + expect(manifestPatterns(['packages/*/*', 'tools/*'], ['packages/*'])).toEqual([ + 'package.json', + 'packages/*/*/package.json', + 'tools/*/package.json', + 'examples/*/package.json', + 'native/landlock-run/package.json', + 'native/landlock-run/packages/*/package.json', + ]) + }) +}) diff --git a/scripts/gen-third-party-notices.ts b/scripts/gen-third-party-notices.ts new file mode 100644 index 0000000000..5306f9a003 --- /dev/null +++ b/scripts/gen-third-party-notices.ts @@ -0,0 +1,596 @@ +/** + * Generate `THIRD_PARTY_NOTICES.md` from the workspace manifests: every + * external dependency named by a workspace `package.json`, the vendored-package + * manifest in `vendor/README.md`, the Python `pyproject.toml` files, and the + * pnpm patch list. License and repository metadata come from the installed + * store, so the tree must be installed. `--check` verifies the committed + * artifact. Tier policy and ownership live in + * `.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md`. + */ + +import { existsSync, globSync, readdirSync, readFileSync, writeFileSync } from 'node:fs' +import { resolve } from 'node:path' +import * as yaml from 'js-yaml' +import { parse as parseToml, type TomlTableWithoutBigInt, type TomlValueWithoutBigInt } from 'smol-toml' +import parseSpdx from 'spdx-expression-parse' + +const root = resolve(import.meta.dirname, '..') +const OUT = 'THIRD_PARTY_NOTICES.md' + +/** Dependency-declaration kinds a consumer resolves at runtime. */ +const RUNTIME_KINDS = ['dependencies', 'optionalDependencies'] as const +/** All manifest sections that name an external package this file must disclose. */ +const ALL_KINDS = ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies'] as const + +/** + * Workspace areas that never reach a user: repository tooling and gates (the + * root manifest), test infrastructure, the documentation site, the runnable + * demo leaves, and the native launcher's build workspace. A runtime + * declaration by anything outside these areas is a disclosure-relevant + * runtime dependency, because `scripts/install.sh` installs the repository + * itself and any plugin package can be mounted from a user's `cordis.yml`. + */ +const DEV_ONLY_AREAS = [ + 'package.json', + 'packages/support/', + 'packages/client/test-runtime/', + 'website/', + 'examples/', + 'native/', +] as const + +/** + * First-party packages released from sibling repositories under the project's + * own license: reachable from workspace manifests but not third-party. + */ +const FIRST_PARTY = new Set([ + 'node-addon-landlock-run', + 'node-addon-landlock-run-linux-arm64', + 'node-addon-landlock-run-linux-x64', +]) + +/** + * Metadata overrides where the installed manifest is wrong or unreachable. + * Each entry documents why the store cannot answer. + */ +const OVERRIDES: Record = { + // Rust workspaces publishing npm bins without `license` in package.json. + 'oxlint': { license: 'MIT', repo: 'https://github.com/oxc-project/oxc' }, + 'oxlint-tsgolint': { license: 'MIT', repo: 'https://github.com/oxc-project/tsgolint' }, + // `license: SEE LICENSE IN LICENSE`: the servers repo is mid MIT→Apache-2.0 + // relicensing, so the effective terms are per-contribution. + '@modelcontextprotocol/server-everything': { license: 'MIT / Apache-2.0', repo: 'https://github.com/modelcontextprotocol/servers' }, + '@modelcontextprotocol/server-filesystem': { license: 'MIT / Apache-2.0', repo: 'https://github.com/modelcontextprotocol/servers' }, + // No repository field in the published manifest. + 'node-addon-require-builtin': { repo: 'https://www.npmjs.com/package/node-addon-require-builtin' }, +} + +/** + * Python dependencies are few and named directly in `pyproject.toml` files + * without installed metadata to harvest, so license/repo are recorded here and + * the generator fails when a manifest names a package this map misses. + */ +const PYTHON_METADATA: Record = { + pydantic: { license: 'MIT', repo: 'https://github.com/pydantic/pydantic', role: 'runtime dependency of `deepseek-harness`' }, + hatchling: { license: 'MIT', repo: 'https://github.com/pypa/hatch', role: 'build backend' }, + pytest: { license: 'MIT', repo: 'https://github.com/pytest-dev/pytest', role: 'test-only' }, +} + +type PythonMetadata = typeof PYTHON_METADATA + +/** Tools fetched by scripts at build time, keyed by the pin the script owns. */ +const BUILD_TIME_TOOLS = [ + { + name: '@yao-pkg/pkg', + license: 'MIT', + repo: 'https://github.com/yao-pkg/pkg', + role: 'invoked by `scripts/build-exe-for-python-sdk.ts` to assemble the single-file SDK runtime executable', + pinSource: 'scripts/build-exe-for-python-sdk.ts', + }, +] + +/** The `package.json` fields this generator reads. */ +export interface Manifest { + name?: string + private?: boolean + license?: string + dependencies?: Record + devDependencies?: Record + optionalDependencies?: Record + peerDependencies?: Record +} + +/** One disclosed external npm dependency. */ +interface ExternalDep { + name: string + license: string + repo: string + /** True when some shipped workspace consumer reaches it through runtime dependency edges. */ + runtime: boolean +} + +/** Read and parse a workspace-relative `package.json`. */ +function readManifest(rel: string): Manifest { + return JSON.parse(readFileSync(resolve(root, rel), 'utf8')) as Manifest +} + +/** + * Manifest globs, derived from the workspace declarations rather than listed + * here, so a new member area (`tools/*`) is read the day it is declared. + * @returns one glob per manifest-bearing location, repository-relative. + */ +export function manifestPatterns(rootMembers: readonly string[], nativeMembers: readonly string[]): string[] { + return [ + 'package.json', + ...rootMembers.map(member => `${member}/package.json`), + // The demo leaves join the workspace through `examples/package.json`, so + // their own manifests are members of nothing and no glob above reaches them. + 'examples/*/package.json', + // `native/landlock-run` is a nested workspace with its own lock file. + 'native/landlock-run/package.json', + ...nativeMembers.map(member => `native/landlock-run/${member}/package.json`), + ] +} + +/** The `packages:` member globs declared by one pnpm workspace file. */ +function workspaceMembers(rel: string): string[] { + const declared = (yaml.load(readFileSync(resolve(root, rel), 'utf8')) as { packages?: unknown }).packages + if (!Array.isArray(declared) || declared.length === 0) { + throw new Error(`gen-third-party-notices: ${rel} declares no workspace members; the manifest set cannot be derived.`) + } + return declared.map(member => String(member)) +} + +/** Every workspace manifest, keyed by path, plus the set of workspace package names. */ +function loadWorkspaceManifests(): { manifests: Map; names: Set } { + const patterns = manifestPatterns(workspaceMembers('pnpm-workspace.yaml'), workspaceMembers('native/landlock-run/pnpm-workspace.yaml')) + const manifests = new Map() + const names = new Set() + for (const pattern of patterns) { + for (const path of globSync(pattern, { cwd: root })) { + const manifest = readManifest(path) + manifests.set(path, manifest) + if (manifest.name !== undefined) names.add(manifest.name) + } + } + if (manifests.size < 100) throw new Error(`gen-third-party-notices: only ${manifests.size} workspace manifests found; the glob set is stale.`) + return { manifests, names } +} + +/** License and repository URL for an installed external package, from the pnpm store. */ +function installedMetadata(name: string): { license: string; repo: string } { + const override = OVERRIDES[name] + let manifest: (Manifest & { license?: string; repository?: string | { url?: string }; homepage?: string }) | undefined + // The nested Landlock workspace installs into its own store, so a package + // only that workspace depends on is unreachable from the root one. + for (const store of ['node_modules', 'native/landlock-run/node_modules']) { + const direct = resolve(root, store, name, 'package.json') + if (existsSync(direct)) { + manifest = JSON.parse(readFileSync(direct, 'utf8')) as typeof manifest + break + } + const virtual = resolve(root, store, '.pnpm') + if (!existsSync(virtual)) continue + const prefix = `${name.replace('/', '+')}@` + const entry = readdirSync(virtual).find(dir => dir.startsWith(prefix)) + if (entry === undefined) continue + manifest = JSON.parse(readFileSync(resolve(virtual, entry, 'node_modules', name, 'package.json'), 'utf8')) as typeof manifest + break + } + const license = override?.license ?? manifest?.license + const rawRepo = typeof manifest?.repository === 'string' ? manifest.repository : manifest?.repository?.url ?? manifest?.homepage + const repo = override?.repo ?? normalizeRepo(rawRepo) + if (license === undefined || repo === undefined) { + throw new Error(`gen-third-party-notices: cannot resolve ${license === undefined ? 'license' : 'repository'} for ${name}; run \`pnpm install\` (or, for a Landlock-only dependency, \`pnpm --dir native/landlock-run install\`), or add an OVERRIDES entry.`) + } + return { license, repo } +} + +/** Normalize a manifest repository/homepage value to a browsable https URL. */ +function normalizeRepo(raw: string | undefined): string | undefined { + if (raw === undefined || raw === '') return undefined + let url = raw + .replace(/^git\+ssh:\/\/git@/, 'https://') + .replace(/^git\+/, '') + .replace(/^git:\/\//, 'https://') + .replace(/^github:/, 'https://github.com/') + .replace(/\.git$/, '') + if (!url.startsWith('http')) url = `https://github.com/${url}` + return url +} + +/** + * External npm dependencies, tiered by which workspace area declares them at + * runtime: a package is runtime when any manifest outside `DEV_ONLY_AREAS` + * names it in `dependencies`/`optionalDependencies`. A package declared only + * by tooling, test infrastructure, the website, or the demo leaves — whatever + * the declaring section is called — is development-only. + */ +function collectNpmDeps(): ExternalDep[] { + const { manifests, names } = loadWorkspaceManifests() + return [...tierExternalDeps(manifests, names)] + .filter(([name]) => !FIRST_PARTY.has(name)) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([name, runtime]) => ({ name, ...installedMetadata(name), runtime })) +} + +/** + * Tier every external dependency the workspace declares. + * @param manifests - workspace manifests keyed by repository-relative path. + * @param names - every workspace package name, which never counts as external. + * @returns each external package mapped to whether it is a runtime dependency. + */ +export function tierExternalDeps(manifests: Map, names: Set): Map { + const tiers = new Map() + // `tsx` is runtime by fiat: `bin/dsh` execs the CLI through its ESM hook. + tiers.set('tsx', true) + for (const [path, manifest] of manifests) { + const devOnly = DEV_ONLY_AREAS.some(area => (area.endsWith('/') ? path.startsWith(area) : path === area)) + for (const kind of ALL_KINDS) { + for (const [dep, range] of Object.entries(manifest[kind] ?? {})) { + if (names.has(dep) || range.startsWith('workspace:')) continue + const runtime = !devOnly && (RUNTIME_KINDS as readonly string[]).includes(kind) + tiers.set(dep, (tiers.get(dep) ?? false) || runtime) + } + } + } + return tiers +} + +/** A vendored package row parsed out of the `vendor/README.md` manifest table. */ +export interface VendoredRow { + npmName: string + upstream: string +} + +/** + * Parse the vendored-package manifest table out of `vendor/README.md`. + * @param text - the complete `vendor/README.md` contents. + * @returns one row per manifest-table entry, in table order. + */ +export function parseVendoredRows(text: string): VendoredRow[] { + const rows: VendoredRow[] = [] + for (const line of text.split('\n')) { + const match = /^\| \x60\S+\/\x60 \| \x60([^\x60]+)\x60 \| \S+ \| (https:\/\/\S+?)(?: \([^)]*\))? \| \x60[0-9a-f]+\x60 \|$/.exec(line) + if (match === null) continue + const [, npmName, upstream] = match + if (npmName === undefined || upstream === undefined) continue + rows.push({ npmName, upstream }) + } + return rows +} + +/** + * Parse the vendored manifest table and confirm it accounts for every vendored + * directory. The `vendor/` tree — not the table — is the set that must be + * disclosed, so a row that stops matching the table format is a hard error + * rather than a package that quietly vanishes from the notices. + */ +function collectVendored(): VendoredRow[] { + const rows = parseVendoredRows(readFileSync(resolve(root, 'vendor/README.md'), 'utf8')) + const onDisk = new Map() + for (const entry of readdirSync(resolve(root, 'vendor'), { withFileTypes: true })) { + if (!entry.isDirectory()) continue + const manifest = readManifest(`vendor/${entry.name}/package.json`) + if (manifest.name !== undefined) onDisk.set(manifest.name, entry.name) + } + + const parsed = new Set(rows.map(row => row.npmName)) + const missing = [...onDisk.keys()].filter(name => !parsed.has(name)) + if (missing.length > 0) { + throw new Error(`gen-third-party-notices: vendor/README.md has no manifest-table row for ${missing.join(', ')}; its table format changed or the sync is incomplete.`) + } + for (const row of rows) { + const dir = onDisk.get(row.npmName) + if (dir === undefined) throw new Error(`gen-third-party-notices: vendored package ${row.npmName} from vendor/README.md has no vendor/ directory.`) + const license = readManifest(`vendor/${dir}/package.json`).license + if (license !== 'MIT') { + throw new Error(`gen-third-party-notices: vendored ${row.npmName} declares license ${JSON.stringify(license)}; the vendored section assumes MIT throughout.`) + } + } + return rows +} + +/** Whether a parsed TOML value is a table rather than an array or scalar. */ +function isTomlTable(value: TomlValueWithoutBigInt | undefined): value is TomlTableWithoutBigInt { + return value !== undefined && typeof value === 'object' && !Array.isArray(value) +} + +/** Parse one PEP 508 requirement string into its distribution name. */ +function parsePythonRequirement(requirement: string): string { + const name = /^\s*([a-zA-Z][a-zA-Z0-9._-]*)\s*(?:\[[^\]]*\])?\s*(?:[<>=!~;@].*)?$/.exec(requirement)?.[1] + if (name === undefined) { + throw new Error(`gen-third-party-notices: cannot read a distribution name from the requirement ${JSON.stringify(requirement)}.`) + } + return name +} + +/** Add the string requirements from one parsed TOML array. */ +function collectPythonRequirementArray( + names: string[], + value: TomlValueWithoutBigInt | undefined, + location: string, + allowGroupIncludes = false, +): void { + if (value === undefined) return + if (!Array.isArray(value)) { + throw new Error(`gen-third-party-notices: ${location} must be an array.`) + } + for (const item of value) { + if (typeof item === 'string') { + names.push(parsePythonRequirement(item)) + continue + } + if (allowGroupIncludes && isTomlTable(item) && typeof item['include-group'] === 'string' && Object.keys(item).length === 1) { + continue + } + throw new Error(`gen-third-party-notices: ${location} contains an unsupported requirement entry.`) + } +} + +/** Read an optional TOML table and reject a present value of another shape. */ +function optionalTomlTable(value: TomlValueWithoutBigInt | undefined, location: string): TomlTableWithoutBigInt | undefined { + if (value === undefined || isTomlTable(value)) return value + throw new Error(`gen-third-party-notices: ${location} must be a table.`) +} + +/** + * Parse a `pyproject.toml` project identity and every requirement it declares: + * `requires` under + * `[build-system]`, `dependencies` under `[project]`, and every key under + * `[project.optional-dependencies]` and `[dependency-groups]`. A TOML parser + * owns comments, quoted keys, escapes, and array boundaries; unsupported + * requirement shapes fail instead of disappearing from the notices. + * @param text - the complete `pyproject.toml` contents. + * @returns the local project name and declared requirement names. + */ +function parsePyproject(text: string): { projectName?: string; requirements: string[] } { + const names: string[] = [] + const document = parseToml(text, { integersAsBigInt: false }) + const buildSystem = optionalTomlTable(document['build-system'], '[build-system]') + const project = optionalTomlTable(document.project, '[project]') + const projectName = project?.name + if (projectName !== undefined && typeof projectName !== 'string') { + throw new Error('gen-third-party-notices: [project].name must be a string.') + } + collectPythonRequirementArray(names, buildSystem?.requires, '[build-system].requires') + collectPythonRequirementArray(names, project?.dependencies, '[project].dependencies') + + const optional = optionalTomlTable(project?.['optional-dependencies'], '[project.optional-dependencies]') + for (const [group, requirements] of Object.entries(optional ?? {})) { + collectPythonRequirementArray(names, requirements, `[project.optional-dependencies].${group}`) + } + + const groups = optionalTomlTable(document['dependency-groups'], '[dependency-groups]') + for (const [group, requirements] of Object.entries(groups ?? {})) { + collectPythonRequirementArray(names, requirements, `[dependency-groups].${group}`, true) + } + return projectName === undefined + ? { requirements: names } + : { projectName, requirements: names } +} + +/** + * Read every requirement name declared by one `pyproject.toml`. + * @param text - the complete `pyproject.toml` contents. + * @returns each declared requirement's distribution name, in file order. + */ +export function parsePyprojectRequirements(text: string): string[] { + return parsePyproject(text).requirements +} + +/** Normalize a Python distribution name according to the packaging name rule. */ +function normalizePythonDistributionName(name: string): string { + return name.toLowerCase().replace(/[-_.]+/g, '-') +} + +/** + * Resolve external Python dependencies after excluding local project names. + * @param pyprojects - complete local `pyproject.toml` contents. + * @param metadata - disclosure metadata for every external dependency. + * @returns disclosed dependencies in normalized name order. + */ +export function collectPythonDependencies( + pyprojects: string[], + metadata: PythonMetadata = PYTHON_METADATA, +): { name: string; license: string; repo: string; role: string }[] { + const parsed = pyprojects.map(parsePyproject) + const firstParty = new Set(parsed.flatMap(({ projectName }) => ( + projectName === undefined ? [] : [normalizePythonDistributionName(projectName)] + ))) + const found = new Set(parsed + .flatMap(({ requirements }) => requirements.map(normalizePythonDistributionName)) + .filter(name => !firstParty.has(name))) + return [...found].sort((a, b) => a.localeCompare(b)).map((name) => { + const entry = metadata[name] + if (entry === undefined) throw new Error(`gen-third-party-notices: python dependency ${name} is missing from PYTHON_METADATA.`) + return { name, ...entry } + }) +} + +/** Direct Python dependencies named by the `pyproject.toml` manifests under `python/`. */ +function collectPython(): { name: string; license: string; repo: string; role: string }[] { + const manifests = globSync('python/*/pyproject.toml', { cwd: root }) + if (manifests.length === 0) throw new Error('gen-third-party-notices: no python/*/pyproject.toml found; the Python tree moved.') + return collectPythonDependencies(manifests.map(path => readFileSync(resolve(root, path), 'utf8'))) +} + +/** pnpm-patched external packages, from `pnpm-workspace.yaml`. */ +function collectPatched(): { spec: string; patch: string }[] { + const workspace = yaml.load(readFileSync(resolve(root, 'pnpm-workspace.yaml'), 'utf8')) as { patchedDependencies?: Record } + return Object.entries(workspace.patchedDependencies ?? {}).map(([spec, patch]) => ({ spec, patch })) +} + +/** Verify each build-time tool pin still appears in its owning script. */ +function verifyBuildTimePins(): void { + for (const tool of BUILD_TIME_TOOLS) { + const text = readFileSync(resolve(root, tool.pinSource), 'utf8') + if (!text.includes(tool.name)) { + throw new Error(`gen-third-party-notices: ${tool.pinSource} no longer references ${tool.name}; update BUILD_TIME_TOOLS.`) + } + } +} + +/** SPDX identifiers this project may ship without further review. */ +const PERMISSIVE_LICENSES = new Set(['MIT', 'ISC', 'BSD-2-Clause', 'BSD-3-Clause', 'Apache-2.0', '0BSD', 'Unlicense', 'CC0-1.0', 'BlueOak-1.0.0', 'Python-2.0']) + +/** Evaluate a parsed SPDX expression under the repository's license policy. */ +function isPermissiveSpdx(expression: ReturnType): boolean { + if ('conjunction' in expression) { + return expression.conjunction === 'and' + ? isPermissiveSpdx(expression.left) && isPermissiveSpdx(expression.right) + : isPermissiveSpdx(expression.left) || isPermissiveSpdx(expression.right) + } + return expression.plus !== true + && expression.exception === undefined + && PERMISSIVE_LICENSES.has(expression.license) +} + +/** + * Whether an SPDX expression grants terms this project may ship under. + * `OR` needs one permissive alternative, because the consumer chooses; `AND` + * needs all of them, because every obligation applies. Anything that is not a + * recognized permissive identifier — copyleft, an exception clause, or a + * license this list has never seen — evaluates to false, so an unfamiliar + * expression fails closed rather than passing on a partial match. + * @param license - the SPDX expression from the package manifest. + * @returns true when the expression's obligations are all permissive. + */ +export function isPermissive(license: string): boolean { + // Some npm manifests use a slash for a choice despite SPDX requiring `OR`. + const normalized = license.replace(/\s*\/\s*/g, ' OR ').trim() + try { + return isPermissiveSpdx(parseSpdx(normalized)) + } catch { + return false + } +} + +/** + * Render the sentence that isolates non-permissive development tooling, or + * nothing at all when every development dependency is permissive. + * @param deps - development dependencies whose license is not permissive. + * @returns the paragraph to place after the development table. + */ +function renderNonPermissiveNote(deps: ExternalDep[]): string { + if (deps.length === 0) return '' + const named = deps.map(dep => `\`${dep.name}\` (${dep.license})`) + const subject = named.length === 1 ? named[0] : `${named.slice(0, -1).join(', ')} and ${named.at(-1)}` + return `\n${subject} ${named.length === 1 ? 'runs' : 'run'} only as development tooling; their code is not linked into or distributed with any DeepSeek Harness artifact.\n` +} + +/** Render one npm dependency table. */ +function renderNpmTable(deps: ExternalDep[]): string { + const lines = ['| Package | License |', '| --- | --- |'] + for (const dep of deps) lines.push(`| [\`${dep.name}\`](${dep.repo}) | ${dep.license} |`) + return lines.join('\n') +} + +/** + * Render the complete notices document. + * @returns the exact bytes `THIRD_PARTY_NOTICES.md` must hold. + */ +export function render(): string { + verifyBuildTimePins() + const npm = collectNpmDeps() + const runtimeDeps = npm.filter(dep => dep.runtime) + const devDeps = npm.filter(dep => !dep.runtime) + const vendored = collectVendored() + const python = collectPython() + const patched = collectPatched() + + const nonPermissiveDev = devDeps.filter(dep => !isPermissive(dep.license)) + // A copyleft license reaching a shipped surface is a distribution decision, + // not a rendering detail; the notices cannot quietly absorb it. + const nonPermissiveRuntime = runtimeDeps.filter(dep => !isPermissive(dep.license)) + if (nonPermissiveRuntime.length > 0) { + throw new Error(`gen-third-party-notices: runtime ${nonPermissiveRuntime.map(dep => `${dep.name} (${dep.license})`).join(', ')} is not a permissive license; review the distribution terms and record the decision before regenerating.`) + } + const patchedLines = patched.map(({ spec, patch }) => `- \`${spec}\` — [\`${patch}\`](${patch})`) + + return ` + +# Third-Party Notices + +DeepSeek Harness is licensed under [BSD 3-Clause](LICENSE). It depends on the third-party open-source software listed below. Each project remains under its own license; nothing in this file changes those terms. + +This file lists **direct** dependencies declared by the workspace. It is generated from the workspace manifests by \`scripts/gen-third-party-notices.ts\`: a pre-commit hook regenerates it whenever a staged file changes one of its inputs, and \`scripts/gen-third-party-notices.spec.ts\` asserts in the test lane that the committed bytes match. Deleting a manifest runs no hook, so that case is caught by the assertion instead. Run \`pnpm run verify-third-party-notices\` for the standalone check. + +The complete npm transitive closure, with exact pinned versions, is recorded in [\`pnpm-lock.yaml\`](pnpm-lock.yaml) — inspect it with \`pnpm licenses list\`. The Python closure is recorded in [\`python/sdk/uv.lock\`](python/sdk/uv.lock), and the Landlock launcher workspace keeps its own in [\`native/landlock-run/pnpm-lock.yaml\`](native/landlock-run/pnpm-lock.yaml). + +## Vendored source (\`vendor/\`) + +The Cordis framework and its foundation libraries are source-vendored into this repository rather than consumed from npm. All are MIT-licensed; each directory preserves its upstream \`LICENSE\` file. Exact upstream commits and local modifications are recorded in [\`vendor/README.md\`](vendor/README.md). + +| Package | Upstream | License | +| --- | --- | --- | +${vendored.map(row => `| \`${row.npmName}\` | [${row.upstream.replace('https://', '')}](${row.upstream}) | MIT |`).join('\n')} + +## Runtime npm dependencies + +External packages that a workspace package resolves at runtime. \`scripts/install.sh\` installs this repository itself, so the tier covers every plugin a user can mount from \`cordis.yml\` — not only what the \`dsh\` CLI/TUI, the Web UI, and the Python SDK runtime load by default. + +${renderNpmTable(runtimeDeps)} + +pnpm applies local patches to the following packages at install time, so shipped artifacts carry modified copies; each patch file is the complete record of the modification: + +${patchedLines.join('\n')} + +## Development-only npm dependencies + +External packages **directly declared** only by repository tooling, test infrastructure, the documentation site, the demo leaves, or the native launcher's build workspace. No shipped surface names them itself. A package here may still be pulled in transitively by a runtime dependency — \`pnpm-lock.yaml\` is the authority on the full closure — so this tier records who declares a package, not what a build ultimately bundles. + +${renderNpmTable(devDeps)} +${renderNonPermissiveNote(nonPermissiveDev)} +## Python SDK dependencies (\`python/\`) + +Direct dependencies of the \`pyproject.toml\` manifests, plus \`uv\` as the development workflow tool. + +| Package | License | Role | +| --- | --- | --- | +${python.map(dep => `| [\`${dep.name}\`](${dep.repo}) | ${dep.license} | ${dep.role} |`).join('\n')} +| [\`uv\`](https://github.com/astral-sh/uv) | MIT / Apache-2.0 | development workflow tool | + +## Fetched at build time + +| Package | License | Role | +| --- | --- | --- | +${BUILD_TIME_TOOLS.map(tool => `| [\`${tool.name}\`](${tool.repo}) | ${tool.license} | ${tool.role} |`).join('\n')} + +## First-party sibling releases + +\`node-addon-landlock-run\` (and its platform packages) is released from a DeepSeek Harness sibling repository under BSD 3-Clause. It is listed here for completeness; it is first-party, not third-party. +` +} + +/** CLI entry: default writes the notices, `--check` fails if the committed copy + * is stale. Guarded behind an entry-point check so importing this module for + * tests neither regenerates the committed file nor calls process.exit. */ +function main(): void { + const content = render() + if (process.argv.includes('--check')) { + let committed: string | null = null + try { + committed = readFileSync(resolve(root, OUT), 'utf8') + } catch { + // Only ENOENT (not yet generated) is expected; a present-but-unreadable + // file is not a state this repo produces, and the remedy is the same. + committed = null + } + if (committed === content) { + console.log(`gen-third-party-notices: ${OUT} is up to date.`) + process.exit(0) + } + console.error(`gen-third-party-notices: ${OUT} is stale. Run \`pnpm run gen-third-party-notices\` and commit ${OUT}.`) + process.exit(1) + } + + writeFileSync(resolve(root, OUT), content) + console.log(`gen-third-party-notices: wrote ${OUT}.`) +} + +// Run only when invoked as a script, not when imported by a test. +if (process.argv[1] !== undefined && import.meta.filename === resolve(process.argv[1])) { + main() +} diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index f66b45017c..e27a4f5838 100644 --- a/scripts/snapshots/translation-prompt-v4/request-response.expected.json +++ b/scripts/snapshots/translation-prompt-v4/request-response.expected.json @@ -8,19 +8,19 @@ }, { "role": "user", - "content": "# DeepSeek Harness\n\nEnglish | [中文](README.zh.md)\n\nDeepSeek Harness (`dsh`) is an open-source coding agent built on the DeepSeek Harness SDK.\n\nIt uses an architecture where **everything is a plugin**.\n\n## Internal testing notice\n\n感谢您愿意拨冗试用 DeepSeek Harness。当前版本仍处于内部测试阶段,功能仍待完善,体验难免有些粗糙。\n\n“如切如磋,如琢如磨。” 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中发现的问题,也可能促使我们重新审视,甚至推翻已有的设计。\n\n为了帮助我们更准确地还原您真实使用中的问题,内测版本默认会上传所有 Session Log;如需关闭,可以设置环境变量 `DSH_TELEMETRY_DISABLED=1`。另外,如果您有任何反馈与建议,请在企业微信群中留言告诉我们。每一条反馈,都会帮助我们把它打磨得更好。\n\n## Install\n\nInstall `dsh` with one command:\n\n```sh\ncurl -fsSL https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/master/scripts/install.sh | sh\n```\n\nThe installer requires `git` and Node `^22.19 || >=24`, offers to install `pnpm` when it is missing, and prompts for a DeepSeek API key.\n\nThe installer keeps every checkout under `~/.dsh/source`: the master clone at `~/.dsh/source/master` and each install's staging checkout as a git worktree `~/.dsh/source/staging-`. The stable symlink `~/.dsh/source/current` points at the active staging worktree, and `dsh` in `~/.local/bin` links to `current/bin/dsh`, so an upgrade repoints one symlink and the `dsh` on PATH never moves. Re-running the command adds a fresh staging worktree from an updated master and repoints `current` at it. See [`scripts/install.sh`](scripts/install.sh) for alternate install locations and other options.\n\n## Use DeepSeek Harness\n\n### Web UI\n\nFor the recommended local interface, build the active checkout after installation and after each update, then start the Web UI:\n\n```sh\n(cd ~/.dsh/source/current && pnpm run build)\ndsh web\n```\n\nThe full build produces the library and client bundles plus the frontend dist. The path above is the installer's default. If you set `DSH_SOURCE` or `DSH_CURRENT`, or reused an existing checkout, replace `~/.dsh/source/current` with that checkout path; see [`scripts/install.sh`](scripts/install.sh) for details. The Web UI is served at `http://127.0.0.1:3080` by default.\n\n### TUI\n\nStart the full-screen terminal interface:\n\n```sh\ndsh\n```\n\n### Headless\n\nRun one task, print the final answer, and exit:\n\n```sh\ndsh -p \"summarize this workspace\"\n```\n\n### Automation and SDKs\n\nFrom a source checkout with `DEEPSEEK_API_KEY` in the environment or its root `.env`, start the ACP automation server:\n\n```sh\npnpm run demo:acp\n```\n\nThe [Python SDK](python/README.md) drives a bundled JSON-RPC runtime. The [examples](examples/README.md) cover the runnable headless, ACP, JSON-RPC, Code Mode, and self-referential compositions.\n\n## Why DeepSeek Harness\n\nBuilt-in capabilities cover file reading, editing, and search; shell and persistent PTY execution; reusable skills; task tracking, goals, plans, todos, and background tasks; subagents and workflows; sandboxing and approvals; settings and credentials; persistent, resumable, forkable, and queryable sessions; LSP and web access; context compaction; and telemetry. Each composition selects the subset appropriate to its surface. The TUI and Web UI both include Plan Mode.\n\n- **Everything is a plugin.** Models, tools, policies, storage, context management, and interfaces are composable [Cordis plugins](docs/user/develop/basic/index.md), so deployments can extend or replace behavior without forking the agent loop. See the [architecture](docs/architecture.md) for the underlying design.\n- **Runs are reconstructable.** Anything visible to the model is logged in the authoritative session stream; persistence, resume/fork/query, replay, telemetry, and UIs derive from the same events. See the [session-log architecture](docs/architecture.md#session-log).\n- **Code Mode (opt-in).** It exposes a `run_code` tool and a generated TypeScript SDK; only program output re-enters model context. See [Code Mode](packages/core/tools/README.md#code-mode).\n- **Self-referential Cordis tools are opt-in.** They let the agent inspect its live runtime and mount or unmount plugins while it runs. See the [Cordis tools](packages/cordis/tool-cordis/README.md).\n\n## Community\n\nFollow DeepSeek Harness on Twitter for project updates.\n\n## Development\n\n```sh\npnpm install\npnpm run test:coverage\n```\n\nStart with the [development guide](docs/development.md) and read the [architecture](docs/architecture.md) before changing packages.\n\nFor agents, follow [AGENTS.md](AGENTS.md).\n\nDeepSeek Harness is currently in internal testing.\n\n## License\n\n[BSD 3-Clause](LICENSE)\n" + "content": "# DeepSeek Harness\n\nEnglish | [中文](README.zh.md)\n\nDeepSeek Harness (`dsh`) is an open-source coding agent built on the DeepSeek Harness SDK.\n\nIt uses an architecture where **everything is a plugin**.\n\n## Internal testing notice\n\n感谢您愿意拨冗试用 DeepSeek Harness。当前版本仍处于内部测试阶段,功能仍待完善,体验难免有些粗糙。\n\n“如切如磋,如琢如磨。” 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中发现的问题,也可能促使我们重新审视,甚至推翻已有的设计。\n\n为了帮助我们更准确地还原您真实使用中的问题,内测版本默认会上传所有 Session Log;如需关闭,可以设置环境变量 `DSH_TELEMETRY_DISABLED=1`。另外,如果您有任何反馈与建议,请在企业微信群中留言告诉我们。每一条反馈,都会帮助我们把它打磨得更好。\n\n## Install\n\nInstall `dsh` with one command:\n\n```sh\ncurl -fsSL https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/master/scripts/install.sh | sh\n```\n\nThe installer requires `git` and Node `^22.19 || >=24`, offers to install `pnpm` when it is missing, and prompts for a DeepSeek API key.\n\nThe installer keeps every checkout under `~/.dsh/source`: the master clone at `~/.dsh/source/master` and each install's staging checkout as a git worktree `~/.dsh/source/staging-`. The stable symlink `~/.dsh/source/current` points at the active staging worktree, and `dsh` in `~/.local/bin` links to `current/bin/dsh`, so an upgrade repoints one symlink and the `dsh` on PATH never moves. Re-running the command adds a fresh staging worktree from an updated master and repoints `current` at it. See [`scripts/install.sh`](scripts/install.sh) for alternate install locations and other options.\n\n## Use DeepSeek Harness\n\n### Web UI\n\nFor the recommended local interface, build the active checkout after installation and after each update, then start the Web UI:\n\n```sh\n(cd ~/.dsh/source/current && pnpm run build)\ndsh web\n```\n\nThe full build produces the library and client bundles plus the frontend dist. The path above is the installer's default. If you set `DSH_SOURCE` or `DSH_CURRENT`, or reused an existing checkout, replace `~/.dsh/source/current` with that checkout path; see [`scripts/install.sh`](scripts/install.sh) for details. The Web UI is served at `http://127.0.0.1:3080` by default.\n\n### TUI\n\nStart the full-screen terminal interface:\n\n```sh\ndsh\n```\n\n### Headless\n\nRun one task, print the final answer, and exit:\n\n```sh\ndsh -p \"summarize this workspace\"\n```\n\n### Automation and SDKs\n\nFrom a source checkout with `DEEPSEEK_API_KEY` in the environment or its root `.env`, start the ACP automation server:\n\n```sh\npnpm run demo:acp\n```\n\nThe [Python SDK](python/README.md) drives a bundled JSON-RPC runtime. The [examples](examples/README.md) cover the runnable headless, ACP, JSON-RPC, Code Mode, and self-referential compositions.\n\n## Why DeepSeek Harness\n\nBuilt-in capabilities cover file reading, editing, and search; shell and persistent PTY execution; reusable skills; task tracking, goals, plans, todos, and background tasks; subagents and workflows; sandboxing and approvals; settings and credentials; persistent, resumable, forkable, and queryable sessions; LSP and web access; context compaction; and telemetry. Each composition selects the subset appropriate to its surface. The TUI and Web UI both include Plan Mode.\n\n- **Everything is a plugin.** Models, tools, policies, storage, context management, and interfaces are composable [Cordis plugins](docs/user/develop/basic/index.md), so deployments can extend or replace behavior without forking the agent loop. See the [architecture](docs/architecture.md) for the underlying design.\n- **Runs are reconstructable.** Anything visible to the model is logged in the authoritative session stream; persistence, resume/fork/query, replay, telemetry, and UIs derive from the same events. See the [session-log architecture](docs/architecture.md#session-log).\n- **Code Mode (opt-in).** It exposes a `run_code` tool and a generated TypeScript SDK; only program output re-enters model context. See [Code Mode](packages/core/tools/README.md#code-mode).\n- **Self-referential Cordis tools are opt-in.** They let the agent inspect its live runtime and mount or unmount plugins while it runs. See the [Cordis tools](packages/cordis/tool-cordis/README.md).\n\n## Community\n\nFollow DeepSeek Harness on Twitter for project updates.\n\n## Development\n\n```sh\npnpm install\npnpm run test:coverage\n```\n\nStart with the [development guide](docs/development.md) and read the [architecture](docs/architecture.md) before changing packages.\n\nFor agents, follow [AGENTS.md](AGENTS.md).\n\nDeepSeek Harness is currently in internal testing.\n\n## License\n\n[BSD 3-Clause](LICENSE)\n\nThird-party dependencies and their licenses are disclosed in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).\n" }, { "role": "assistant", - "content": "# DeepSeek Harness\n\n[English](README.md) | 中文\n\nDeepSeek Harness(`dsh`)是一款基于 DeepSeek Harness SDK 构建的开源 coding agent(编程智能体)。\n\n它采用了**一切皆插件**的架构。\n\n## 内测声明\n\n感谢您愿意拨冗试用 DeepSeek Harness。当前版本仍处于内部测试阶段,功能仍待完善,体验难免有些粗糙。\n\n“如切如磋,如琢如磨。” 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中发现的问题,也可能促使我们重新审视,甚至推翻已有的设计。\n\n为了帮助我们更准确地还原您真实使用中的问题,内测版本默认会上传所有 Session Log;如需关闭,可以设置环境变量 `DSH_TELEMETRY_DISABLED=1`。另外,如果您有任何反馈与建议,请在企业微信群中留言告诉我们。每一条反馈,都会帮助我们把它打磨得更好。\n\n## 安装\n\n使用一条命令安装 `dsh`:\n\n```sh\ncurl -fsSL https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/master/scripts/install.sh | sh\n```\n\n安装器要求系统已安装 `git` 和 Node `^22.19 || >=24`,缺少 `pnpm` 时可代为安装,并会提示输入 DeepSeek API 密钥。\n\n安装器会把所有检出都放在 `~/.dsh/source` 下:master 克隆位于 `~/.dsh/source/master`,每次安装的 staging 检出是一个 git worktree `~/.dsh/source/staging-<时间戳>`。稳定符号链接 `~/.dsh/source/current` 指向当前生效的 staging worktree,`~/.local/bin` 中的 `dsh` 链接到 `current/bin/dsh`,因此升级只需重指一个符号链接,PATH 上的 `dsh` 从不移动。再次运行该命令会基于更新后的 master 新增一个 staging worktree,并把 `current` 重指到它。其他安装位置和选项见 [`scripts/install.sh`](scripts/install.sh)。\n\n## 使用 DeepSeek Harness\n\n### Web UI\n\n推荐在本地使用 Web UI。安装完成后以及每次更新后,请先构建当前生效的检出,再启动 Web UI:\n\n```sh\n(cd ~/.dsh/source/current && pnpm run build)\ndsh web\n```\n\n完整构建会生成库与客户端 bundle,以及前端 dist。上述路径是安装器的默认位置。如果你设置过 `DSH_SOURCE` 或 `DSH_CURRENT`,或者复用了已有检出,请把 `~/.dsh/source/current` 换成该检出路径;详情见 [`scripts/install.sh`](scripts/install.sh)。Web UI 默认通过 `http://127.0.0.1:3080` 提供服务。\n\n### TUI\n\n启动全屏终端界面:\n\n```sh\ndsh\n```\n\n### Headless\n\n运行一项任务,打印最终答案后退出:\n\n```sh\ndsh -p \"summarize this workspace\"\n```\n\n### 自动化与 SDK\n\n在源码检出中通过环境变量或根目录 `.env` 设置 `DEEPSEEK_API_KEY`,然后启动 ACP(Agent Client Protocol)自动化服务器:\n\n```sh\npnpm run demo:acp\n```\n\n[Python SDK](python/README.md) 驱动随附的 JSON-RPC 运行时。[示例](examples/README.md)涵盖可运行的 headless、ACP、JSON-RPC、Code Mode 和自指组合。\n\n## 为什么选择 DeepSeek Harness\n\n内置功能涵盖文件读取、编辑与搜索、shell 和持久 PTY 执行、可复用 skill(技能)、任务跟踪、目标、计划、待办事项与后台任务、subagent 与工作流、沙箱与审批、设置与凭据、可持久化、恢复、fork 与查询的会话、LSP 与 Web 访问、上下文压缩(context compaction),以及遥测。每个组合只选用适合其使用方式的能力子集。TUI 与 Web UI 均包含 Plan Mode。\n\n- **一切皆插件。** 模型、工具、策略、存储、上下文管理和界面均可组合为 [Cordis 插件](docs/user/develop/basic/index.md),部署方无需 fork agent loop(智能体循环)即可扩展或替换行为。底层设计见[架构文档](docs/architecture.md)。\n- **运行可重建。** 凡是模型可见的内容,都会记录在权威会话流中;持久化、恢复/fork/查询、回放、遥测和 UI 均从同一组事件派生。参见[会话日志架构](docs/architecture.md#session-log)。\n- **Code Mode(需显式启用)。** 它会提供 `run_code` 工具和生成的 TypeScript SDK,只有程序输出会重新进入模型上下文。参见 [Code Mode](packages/core/tools/README.md#code-mode)。\n- **自指 Cordis 工具需显式启用。** 这些工具可让 agent 检查自身的实时运行时,并在运行中挂载或卸载插件。参见 [Cordis 工具](packages/cordis/tool-cordis/README.md)。\n\n## 社区\n\n扫描二维码,或打开 DeepSeek Harness 微信社区申请页面 申请加入。\n\n

    \n \"DeepSeek\n

    \n\n## 开发\n\n```sh\npnpm install\npnpm run test:coverage\n```\n\n请先阅读[开发指南](docs/development.md);修改包之前,请阅读[架构文档](docs/architecture.md)。\n\n面向 agent:遵循 [AGENTS.md](AGENTS.md)。\n\nDeepSeek Harness 目前处于内测阶段。\n\n## 许可证\n\n[BSD 3-Clause](LICENSE)\n" + "content": "# DeepSeek Harness\n\n[English](README.md) | 中文\n\nDeepSeek Harness(`dsh`)是一款基于 DeepSeek Harness SDK 构建的开源 coding agent(编程智能体)。\n\n它采用了**一切皆插件**的架构。\n\n## 内测声明\n\n感谢您愿意拨冗试用 DeepSeek Harness。当前版本仍处于内部测试阶段,功能仍待完善,体验难免有些粗糙。\n\n“如切如磋,如琢如磨。” 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中发现的问题,也可能促使我们重新审视,甚至推翻已有的设计。\n\n为了帮助我们更准确地还原您真实使用中的问题,内测版本默认会上传所有 Session Log;如需关闭,可以设置环境变量 `DSH_TELEMETRY_DISABLED=1`。另外,如果您有任何反馈与建议,请在企业微信群中留言告诉我们。每一条反馈,都会帮助我们把它打磨得更好。\n\n## 安装\n\n使用一条命令安装 `dsh`:\n\n```sh\ncurl -fsSL https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/master/scripts/install.sh | sh\n```\n\n安装器要求系统已安装 `git` 和 Node `^22.19 || >=24`,缺少 `pnpm` 时可代为安装,并会提示输入 DeepSeek API 密钥。\n\n安装器会把所有检出都放在 `~/.dsh/source` 下:master 克隆位于 `~/.dsh/source/master`,每次安装的 staging 检出是一个 git worktree `~/.dsh/source/staging-<时间戳>`。稳定符号链接 `~/.dsh/source/current` 指向当前生效的 staging worktree,`~/.local/bin` 中的 `dsh` 链接到 `current/bin/dsh`,因此升级只需重指一个符号链接,PATH 上的 `dsh` 从不移动。再次运行该命令会基于更新后的 master 新增一个 staging worktree,并把 `current` 重指到它。其他安装位置和选项见 [`scripts/install.sh`](scripts/install.sh)。\n\n## 使用 DeepSeek Harness\n\n### Web UI\n\n推荐在本地使用 Web UI。安装完成后以及每次更新后,请先构建当前生效的检出,再启动 Web UI:\n\n```sh\n(cd ~/.dsh/source/current && pnpm run build)\ndsh web\n```\n\n完整构建会生成库与客户端 bundle,以及前端 dist。上述路径是安装器的默认位置。如果你设置过 `DSH_SOURCE` 或 `DSH_CURRENT`,或者复用了已有检出,请把 `~/.dsh/source/current` 换成该检出路径;详情见 [`scripts/install.sh`](scripts/install.sh)。Web UI 默认通过 `http://127.0.0.1:3080` 提供服务。\n\n### TUI\n\n启动全屏终端界面:\n\n```sh\ndsh\n```\n\n### Headless\n\n运行一项任务,打印最终答案后退出:\n\n```sh\ndsh -p \"summarize this workspace\"\n```\n\n### 自动化与 SDK\n\n在源码检出中通过环境变量或根目录 `.env` 设置 `DEEPSEEK_API_KEY`,然后启动 ACP(Agent Client Protocol)自动化服务器:\n\n```sh\npnpm run demo:acp\n```\n\n[Python SDK](python/README.md) 驱动随附的 JSON-RPC 运行时。[示例](examples/README.md)涵盖可运行的 headless、ACP、JSON-RPC、Code Mode 和自指组合。\n\n## 为什么选择 DeepSeek Harness\n\n内置功能涵盖文件读取、编辑与搜索、shell 和持久 PTY 执行、可复用 skill(技能)、任务跟踪、目标、计划、待办事项与后台任务、subagent 与工作流、沙箱与审批、设置与凭据、可持久化、恢复、fork 与查询的会话、LSP 与 Web 访问、上下文压缩(context compaction),以及遥测。每个组合只选用适合其使用方式的能力子集。TUI 与 Web UI 均包含 Plan Mode。\n\n- **一切皆插件。** 模型、工具、策略、存储、上下文管理和界面均可组合为 [Cordis 插件](docs/user/develop/basic/index.md),部署方无需 fork agent loop(智能体循环)即可扩展或替换行为。底层设计见[架构文档](docs/architecture.md)。\n- **运行可重建。** 凡是模型可见的内容,都会记录在权威会话流中;持久化、恢复/fork/查询、回放、遥测和 UI 均从同一组事件派生。参见[会话日志架构](docs/architecture.md#session-log)。\n- **Code Mode(需显式启用)。** 它会提供 `run_code` 工具和生成的 TypeScript SDK,只有程序输出会重新进入模型上下文。参见 [Code Mode](packages/core/tools/README.md#code-mode)。\n- **自指 Cordis 工具需显式启用。** 这些工具可让 agent 检查自身的实时运行时,并在运行中挂载或卸载插件。参见 [Cordis 工具](packages/cordis/tool-cordis/README.md)。\n\n## 社区\n\n扫描二维码,或打开 DeepSeek Harness 微信社区申请页面 申请加入。\n\n

    \n \"DeepSeek\n

    \n\n## 开发\n\n```sh\npnpm install\npnpm run test:coverage\n```\n\n请先阅读[开发指南](docs/development.md);修改包之前,请阅读[架构文档](docs/architecture.md)。\n\n面向 agent:遵循 [AGENTS.md](AGENTS.md)。\n\nDeepSeek Harness 目前处于内测阶段。\n\n## 许可证\n\n[BSD 3-Clause](LICENSE)\n\n第三方依赖及其许可证在 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) 中披露。\n" }, { "role": "user", - "content": "# Development guide\n\nEnglish | [中文](development.zh.md)\n\nThis onboarding guide helps project contributors get started with the local environment, daily workflow, and CI flow; see the Agent Notes for design rationale and technical trade-offs.\n\n## Prerequisites\n\n- Node.js supports 22.19+ and 24+. CI covers 22.19, 24, and 26; see the [Node engine floor Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md).\n- Corepack-enabled pnpm. The repo pins `pnpm@11.7.0` in `package.json`; run `corepack enable` if `pnpm --version` does not resolve through Corepack.\n- Git 2.26 or newer; hook setup enables Git's worktree-specific configuration extension.\n- Optional: a DeepSeek API key for the TUI, headless, and ACP automation demos and real-API e2e tests.\n\n## First-time setup\n\nInstall dependencies from the repo root:\n\n```sh\npnpm install\n```\n\nThe install also runs the root `postinstall` script, which installs lefthook from the repo dev dependency through `scripts/install-lefthook.mjs`. With `CI=true` or `GITHUB_ACTIONS=true`, the wrapper returns before Git discovery because automated jobs do not consume contributor hooks. Otherwise, it requires Git 2.26 or newer and gives the current worktree an explicit hook directory under its own Git directory; linked worktrees therefore use their own lefthook binary and configuration instead of rewriting common hooks. The first install enables Git's worktree-specific configuration extension and repository format 1; see the [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md).\n\nIf hooks are missing because dependencies were restored from cache or `postinstall` was skipped, install them manually:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\nThe wrapper refuses user-owned `core.hooksPath` values. An inherited system, global, or common-repository path requires `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`. When Git seeds a new worktree with another registered worktree's marker-backed hook path, the wrapper replaces that copied value with the new worktree's own path; command-scoped and other worktree-scoped paths must be integrated or removed explicitly.\n\nBefore enabling worktree config, migrate direct `extensions.*` in a format-0 common config, direct `core.worktree` or `core.bare=true`, and any non-empty dormant `config.worktree`. The common config and every worktree config must be regular files, while the owned hook directory may contain only unaliased regular files.\n\nAfter moving a checkout, rerun the wrapper to relocate its owned path and regenerate hooks. For a stale or invalid installer lock, first confirm no installer is running, then remove the reported lock and retry. If installation and hook-path rollback both fail, inspect the reported worktree config before retrying. The [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) owns the full safety contract.\n\nRun typecheck once after a fresh clone:\n\n```sh\npnpm run typecheck\n```\n\nThat first typecheck runs the whole-repo `tsc -b` graph: it emits every package/vendor `lib/types` and checks examples, tests, and scripts through the two no-emit aggregates described below.\n\n## TypeScript project layout\n\nThe repository's TypeScript configuration has exactly three roles; every tsconfig file plays one of them.\n\n| File | Role | Forms a program? |\n|---|---|---|\n| `tsconfig.json` | Solution root: `extends` base, `files: []`, references to the two aggregates. The whole-repo `tsc -b tsconfig.json` graph, the tsserver discovery entry, and — through the inherited `paths` — the resolution config for tsx running `examples/` and `scripts/` (their nearest tsconfig is this file). | No |\n| `tsconfig.host.json` | Host aggregate: host-side packages (via references), examples, tests, scripts, website. Excludes `packages/client`. | Yes |\n| `tsconfig.client.json` | Client aggregate: `packages/client/*` packages and their tests, `apps/web`. | Yes |\n| `tsconfig.base.json` | Shared compilerOptions and the source `paths` map. Also the resolution facade the vitest configs point vite-tsconfig-paths at: it has no `include`, so its `paths` apply to every importer. | No |\n| `tsconfig.base.client.json` | Browser compiler shape (`jsx`, DOM libs, `types: []`) extended by the client aggregate and every `packages/client/*` package. | No |\n\nHost and client stay two aggregate programs because both sides declaration-merge the cordis `Context` interface under the same keys with different services; one program seeing both merges reports a collision. The collision exists only inside a `ts.Program` — module resolution never triggers it — which is why the solution may reference both aggregates and one paths facade may span both sides. Two disciplines follow:\n\n- `tsconfig.base.json` never gains `include` or `files`: they would leak into every extending package project and narrow the facade's match-all scope.\n- A script that builds a repo-wide `ts.Program` seeds `tsconfig.host.json` or `tsconfig.client.json` explicitly — never the root solution, because flattening both aggregates into one program collides the `Context` merges. Program-backed generators and gates (`scripts/ts-project.ts` consumers, doc-typecheck standalone mode) are host-only by decision; the client side gains program-backed tooling only with a concrete need.\n\nStatic analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Decision record: [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md); the tsc-first emit pipeline is the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md).\n\nIf a relevant local check consumes built package output, build once first:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` includes `publint`, which validates package entrypoints against the built `lib/*.js` files, and `verify-node-next-types`, which validates built declarations against a temporary NodeNext consumer. A fresh worktree has no bundled JS or declarations until `pnpm run build` runs; ordinary commits and pushes do not require that build unless their selected checks consume it.\n\n## Environment variables\n\nThe real DeepSeek adapter and key-backed agent demos read credentials from the environment or from a gitignored `.env` at the repo root:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` is optional and defaults to the public API. Never commit real credentials. The real-API e2e suites self-skip when `DEEPSEEK_API_KEY` is not set.\n\n## Git hooks\n\nlefthook is configured in `lefthook.yml` as a fast local checkpoint:\n\n- `pre-commit` applies formatting-only ESLint fixes, validates the staged files with Oxlint and applies its native fixes, checks the staged diff for whitespace errors, and runs the vendor manifest guard.\n- `pre-push` runs only the incremental repository typecheck (`tsc -b` over the root solution, covering both the host and client aggregates).\n\nThe vendor manifest guard checks that changes under `vendor/*/src` are staged with the matching `vendor/README.md` manifest update. See `vendor/README.md` before editing vendored code.\n\nThe hooks intentionally do not run tests, snapshots, documentation checks, builds, or hygiene. Contributors run the [checks relevant to the changed behavior](../AGENTS.md#run-relevant-checks-locally) once; CI owns exhaustive coverage, built-artifact smokes, and the Node 22.19, 24, and 26 compatibility matrix.\n\nContributors can opt into the comprehensive local gate set with `pnpm run check:all`. The command is independent of both Git hooks and is not an agent instruction.\n\n## CI gates\n\nThe keyless [CI workflow](../.github/workflows/ci.yml) groups independent gates into broad lanes and runs a smaller compatibility signal across supported Node versions. Artifact consumers wait for one build within their lane. The separate real-API workflow runs `pnpm run test:e2e` with its configured worker bound. See [scripts/run-gates.ts](../scripts/run-gates.ts) and the workflow files for the current gate and job inventory.\n\n## Daily commands\n\nUse these from the repo root:\n\n```sh\npnpm run test # unit tests\npnpm run test:coverage # unit tests with per-file coverage gates\npnpm run test:e2e # real-API tests; self-skips without DEEPSEEK_API_KEY\npnpm run check:all # comprehensive opt-in gate set; not wired to Git hooks\npnpm run typecheck # tsc -b over the root solution: emits package/vendor lib/types, checks both aggregates\npnpm run lint # oxlint .\npnpm run lint:fix # formatting-only ESLint, then oxlint . --fix\npnpm run doc-typecheck # compile checked TypeScript snippets in Markdown docs\npnpm run gen-cordis-catalog # regenerate docs/cordis-catalog/events.md + services.md from source\npnpm run verify-cordis-catalog # fail if either cordis catalog is stale\npnpm run verify-export-jsdoc # fail if a module-level package export lacks complete JSDoc\npnpm run gen-doc-graphs # regenerate generated relationship docs from source and curated graph definitions\npnpm run verify-doc-graphs # fail if generated relationship docs are stale\npnpm run verify-md-wrap # fail on hard-wrapped prose paragraphs in docs/README markdown\npnpm run verify-mermaid # fail if a ```mermaid diagram has invalid Mermaid syntax\npnpm run verify-type-equiv # fail if a ```ts type-equiv doc block drifts from its source type\npnpm run verify-doc-budgets # fail if a budgeted standing doc exceeds its word ceiling\npnpm run gen-translation-brief # print the minimal-update briefing for out-of-sync translation pairs (--apply splices code-only edits)\npnpm run doc-sync # all Markdown/doc gates, scheduled concurrently; the doc-sync leaf list in scripts/run-gates.ts is the full list\npnpm run gen-module-graph # regenerate docs/module-graph.md from package peerDeps\npnpm run verify-module-graph # fail if docs/module-graph.md is stale\npnpm run build # emit lib/types intermediates, then bundle lib/index.* runtime files\npnpm run verify-node-next-types # fail if built declarations are not NodeNext-consumable\npnpm run hygiene # knip, publint, workspace constraints, and NodeNext declaration check\n```\n\nWhen changing package public behavior, update the relevant README or JSDoc in the same change. `pnpm run doc-sync` catches checked TypeScript snippets, generated doc freshness, markdown wrap/link drift, type equivalence, translation pairing, Mermaid syntax, and doc budgets, but broader prose/API sync still needs review.\n\n## Demos\n\nThe one-shot Headless coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\nThe full-screen interactive coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm run demo:tui\n```\n\nThe self-referential cordis demo can inspect and modify its live plugin runtime and needs the same credentials (`web` by default, or `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nThe ACP automation server exposes fresh agent sessions over JSON-RPC stdio and also needs `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n## TODO markers\n\nUse one of three comment tags to flag known issues in the code, ordered by urgency:\n\n- `FIXME` — an issue that should block a new release. A release should not ship with an open `FIXME` unless reviewers explicitly agree the change can be merged anyway.\n- `TODO` — an issue that should be fixed soon, once we have the resources.\n- `XXX` — an issue that we may fix someday; lowest priority, no commitment.\n\nPick the tag that matches the urgency so anyone scanning the code can tell a release blocker from a someday-maybe.\n\n## Documenting types verbatim (`ts type-equiv`)\n\nThe [core data structures](core-data-structures/core.md) docs paste source-equivalent declarations together with their original JSDoc so a reader sees the exact shape and source contract. To keep a paste from drifting when source changes, fence it as ` ```ts type-equiv ` (instead of ` ```ts `) and register it in `scripts/type-equiv.manifest.json` with the source file and symbol it mirrors:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. For a class whose implementation bodies do not belong in the catalog, use ` ```ts public-api ` and set `\"projection\": \"public-api\"`; the checked projection retains the public fields, constructor, accessors, methods, and original class/member JSDoc while omitting bodies and private or protected members. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact shape. The gate enforces a 1:1 correspondence by document, symbol, and projection between primary blocks and manifest entries; a paired `.zh.md` block reuses its unsuffixed sibling's entry only when the whole tracked fence sequence is byte-identical and ordered identically. `doc-typecheck` applies the same derivative rule to compilable fences, while skipping both source-equivalence fence kinds from compilation and its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a primary block, update the manifest in the same change.\n\n## Architecture context\n\nRead `docs/architecture.md` before changing anything under `packages/`. The codebase is built around Cordis plugins, event-sourced sessions, typed service seams, and explicit extension points.\n" + "content": "# Development guide\n\nEnglish | [中文](development.zh.md)\n\nThis onboarding guide helps project contributors get started with the local environment, daily workflow, and CI flow; see the Agent Notes for design rationale and technical trade-offs.\n\n## Prerequisites\n\n- Node.js supports 22.19+ and 24+. CI covers 22.19, 24, and 26; see the [Node engine floor Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md).\n- Corepack-enabled pnpm. The repo pins `pnpm@11.7.0` in `package.json`; run `corepack enable` if `pnpm --version` does not resolve through Corepack.\n- Git 2.26 or newer; hook setup enables Git's worktree-specific configuration extension.\n- Optional: a DeepSeek API key for the TUI, headless, and ACP automation demos and real-API e2e tests.\n\n## First-time setup\n\nInstall dependencies from the repo root:\n\n```sh\npnpm install\n```\n\nThe install also runs the root `postinstall` script, which installs lefthook from the repo dev dependency through `scripts/install-lefthook.mjs`. With `CI=true` or `GITHUB_ACTIONS=true`, the wrapper returns before Git discovery because automated jobs do not consume contributor hooks. Otherwise, it requires Git 2.26 or newer and gives the current worktree an explicit hook directory under its own Git directory; linked worktrees therefore use their own lefthook binary and configuration instead of rewriting common hooks. The first install enables Git's worktree-specific configuration extension and repository format 1; see the [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md).\n\nIf hooks are missing because dependencies were restored from cache or `postinstall` was skipped, install them manually:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\nThe wrapper refuses user-owned `core.hooksPath` values. An inherited system, global, or common-repository path requires `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`. When Git seeds a new worktree with another registered worktree's marker-backed hook path, the wrapper replaces that copied value with the new worktree's own path; command-scoped and other worktree-scoped paths must be integrated or removed explicitly.\n\nBefore enabling worktree config, migrate direct `extensions.*` in a format-0 common config, direct `core.worktree` or `core.bare=true`, and any non-empty dormant `config.worktree`. The common config and every worktree config must be regular files, while the owned hook directory may contain only unaliased regular files.\n\nAfter moving a checkout, rerun the wrapper to relocate its owned path and regenerate hooks. For a stale or invalid installer lock, first confirm no installer is running, then remove the reported lock and retry. If installation and hook-path rollback both fail, inspect the reported worktree config before retrying. The [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) owns the full safety contract.\n\nRun typecheck once after a fresh clone:\n\n```sh\npnpm run typecheck\n```\n\nThat first typecheck runs the whole-repo `tsc -b` graph: it emits every package/vendor `lib/types` and checks examples, tests, and scripts through the two no-emit aggregates described below.\n\n## TypeScript project layout\n\nThe repository's TypeScript configuration has exactly three roles; every tsconfig file plays one of them.\n\n| File | Role | Forms a program? |\n|---|---|---|\n| `tsconfig.json` | Solution root: `extends` base, `files: []`, references to the two aggregates. The whole-repo `tsc -b tsconfig.json` graph, the tsserver discovery entry, and — through the inherited `paths` — the resolution config for tsx running `examples/` and `scripts/` (their nearest tsconfig is this file). | No |\n| `tsconfig.host.json` | Host aggregate: host-side packages (via references), examples, tests, scripts, website. Excludes `packages/client`. | Yes |\n| `tsconfig.client.json` | Client aggregate: `packages/client/*` packages and their tests, `apps/web`. | Yes |\n| `tsconfig.base.json` | Shared compilerOptions and the source `paths` map. Also the resolution facade the vitest configs point vite-tsconfig-paths at: it has no `include`, so its `paths` apply to every importer. | No |\n| `tsconfig.base.client.json` | Browser compiler shape (`jsx`, DOM libs, `types: []`) extended by the client aggregate and every `packages/client/*` package. | No |\n\nHost and client stay two aggregate programs because both sides declaration-merge the cordis `Context` interface under the same keys with different services; one program seeing both merges reports a collision. The collision exists only inside a `ts.Program` — module resolution never triggers it — which is why the solution may reference both aggregates and one paths facade may span both sides. Two disciplines follow:\n\n- `tsconfig.base.json` never gains `include` or `files`: they would leak into every extending package project and narrow the facade's match-all scope.\n- A script that builds a repo-wide `ts.Program` seeds `tsconfig.host.json` or `tsconfig.client.json` explicitly — never the root solution, because flattening both aggregates into one program collides the `Context` merges. Program-backed generators and gates (`scripts/ts-project.ts` consumers, doc-typecheck standalone mode) are host-only by decision; the client side gains program-backed tooling only with a concrete need.\n\nStatic analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Decision record: [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md); the tsc-first emit pipeline is the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md).\n\nIf a relevant local check consumes built package output, build once first:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` includes `publint`, which validates package entrypoints against the built `lib/*.js` files, and `verify-node-next-types`, which validates built declarations against a temporary NodeNext consumer. A fresh worktree has no bundled JS or declarations until `pnpm run build` runs; ordinary commits and pushes do not require that build unless their selected checks consume it.\n\n## Environment variables\n\nThe real DeepSeek adapter and key-backed agent demos read credentials from the environment or from a gitignored `.env` at the repo root:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` is optional and defaults to the public API. Never commit real credentials. The real-API e2e suites self-skip when `DEEPSEEK_API_KEY` is not set.\n\n## Git hooks\n\nlefthook is configured in `lefthook.yml` as a fast local checkpoint:\n\n- `pre-commit` applies formatting-only ESLint fixes, validates the staged files with Oxlint and applies its native fixes, regenerates `THIRD_PARTY_NOTICES.md` when a staged file is one of its inputs, checks the staged diff for whitespace errors, and runs the vendor manifest guard.\n- `pre-push` runs only the incremental repository typecheck (`tsc -b` over the root solution, covering both the host and client aggregates).\n\nThe vendor manifest guard checks that changes under `vendor/*/src` are staged with the matching `vendor/README.md` manifest update. See `vendor/README.md` before editing vendored code.\n\nThe hooks intentionally do not run tests, snapshots, documentation checks, builds, or hygiene. Contributors run the [checks relevant to the changed behavior](../AGENTS.md#run-relevant-checks-locally) once; CI owns exhaustive coverage, built-artifact smokes, and the Node 22.19, 24, and 26 compatibility matrix.\n\nContributors can opt into the comprehensive local gate set with `pnpm run check:all`. The command is independent of both Git hooks and is not an agent instruction.\n\n## CI gates\n\nThe keyless [CI workflow](../.github/workflows/ci.yml) groups independent gates into broad lanes and runs a smaller compatibility signal across supported Node versions. Artifact consumers wait for one build within their lane. The separate real-API workflow runs `pnpm run test:e2e` with its configured worker bound. See [scripts/run-gates.ts](../scripts/run-gates.ts) and the workflow files for the current gate and job inventory.\n\n## Daily commands\n\nUse these from the repo root:\n\n```sh\npnpm run test # unit tests\npnpm run test:coverage # unit tests with per-file coverage gates\npnpm run test:e2e # real-API tests; self-skips without DEEPSEEK_API_KEY\npnpm run check:all # comprehensive opt-in gate set; not wired to Git hooks\npnpm run typecheck # tsc -b over the root solution: emits package/vendor lib/types, checks both aggregates\npnpm run lint # oxlint .\npnpm run lint:fix # formatting-only ESLint, then oxlint . --fix\npnpm run doc-typecheck # compile checked TypeScript snippets in Markdown docs\npnpm run gen-cordis-catalog # regenerate docs/cordis-catalog/events.md + services.md from source\npnpm run verify-cordis-catalog # fail if either cordis catalog is stale\npnpm run verify-export-jsdoc # fail if a module-level package export lacks complete JSDoc\npnpm run gen-doc-graphs # regenerate generated relationship docs from source and curated graph definitions\npnpm run verify-doc-graphs # fail if generated relationship docs are stale\npnpm run verify-md-wrap # fail on hard-wrapped prose paragraphs in docs/README markdown\npnpm run verify-mermaid # fail if a ```mermaid diagram has invalid Mermaid syntax\npnpm run verify-type-equiv # fail if a ```ts type-equiv doc block drifts from its source type\npnpm run verify-doc-budgets # fail if a budgeted standing doc exceeds its word ceiling\npnpm run gen-translation-brief # print the minimal-update briefing for out-of-sync translation pairs (--apply splices code-only edits)\npnpm run doc-sync # all Markdown/doc gates, scheduled concurrently; the doc-sync leaf list in scripts/run-gates.ts is the full list\npnpm run gen-module-graph # regenerate docs/module-graph.md from package peerDeps\npnpm run verify-module-graph # fail if docs/module-graph.md is stale\npnpm run build # emit lib/types intermediates, then bundle lib/index.* runtime files\npnpm run verify-node-next-types # fail if built declarations are not NodeNext-consumable\npnpm run hygiene # knip, publint, workspace constraints, and NodeNext declaration check\n```\n\nWhen changing package public behavior, update the relevant README or JSDoc in the same change. `pnpm run doc-sync` catches checked TypeScript snippets, generated doc freshness, markdown wrap/link drift, type equivalence, translation pairing, Mermaid syntax, and doc budgets, but broader prose/API sync still needs review.\n\n## Demos\n\nThe one-shot Headless coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\nThe full-screen interactive coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm run demo:tui\n```\n\nThe self-referential cordis demo can inspect and modify its live plugin runtime and needs the same credentials (`web` by default, or `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nThe ACP automation server exposes fresh agent sessions over JSON-RPC stdio and also needs `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n## TODO markers\n\nUse one of three comment tags to flag known issues in the code, ordered by urgency:\n\n- `FIXME` — an issue that should block a new release. A release should not ship with an open `FIXME` unless reviewers explicitly agree the change can be merged anyway.\n- `TODO` — an issue that should be fixed soon, once we have the resources.\n- `XXX` — an issue that we may fix someday; lowest priority, no commitment.\n\nPick the tag that matches the urgency so anyone scanning the code can tell a release blocker from a someday-maybe.\n\n## Documenting types verbatim (`ts type-equiv`)\n\nThe [core data structures](core-data-structures/core.md) docs paste source-equivalent declarations together with their original JSDoc so a reader sees the exact shape and source contract. To keep a paste from drifting when source changes, fence it as ` ```ts type-equiv ` (instead of ` ```ts `) and register it in `scripts/type-equiv.manifest.json` with the source file and symbol it mirrors:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. For a class whose implementation bodies do not belong in the catalog, use ` ```ts public-api ` and set `\"projection\": \"public-api\"`; the checked projection retains the public fields, constructor, accessors, methods, and original class/member JSDoc while omitting bodies and private or protected members. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact shape. The gate enforces a 1:1 correspondence by document, symbol, and projection between primary blocks and manifest entries; a paired `.zh.md` block reuses its unsuffixed sibling's entry only when the whole tracked fence sequence is byte-identical and ordered identically. `doc-typecheck` applies the same derivative rule to compilable fences, while skipping both source-equivalence fence kinds from compilation and its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a primary block, update the manifest in the same change.\n\n## Architecture context\n\nRead `docs/architecture.md` before changing anything under `packages/`. The codebase is built around Cordis plugins, event-sourced sessions, typed service seams, and explicit extension points.\n" }, { "role": "assistant", - "content": "# 开发指南\n\n[English](development.md) | 中文\n\n本指南覆盖参与 DeepSeek Harness 开发所需的本地环境搭建、日常工作流与 CI 流程;设计动机与技术权衡请查阅相应 Agent Note。\n\n## 前置条件\n\n- Node.js 支持 22.19+ 与 24+。CI 覆盖 22.19、24 和 26;见 [Node 引擎下限 Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md)。\n- 启用了 Corepack 的 pnpm。仓库在 `package.json` 中固定使用 `pnpm@11.7.0`;如果 `pnpm --version` 无法通过 Corepack 解析,请先运行 `corepack enable`。\n- Git 2.26 或更高版本;钩子设置会启用 Git 的 worktree 专属配置扩展。\n- 可选:一个 DeepSeek API key,用于 TUI、headless 和 ACP(Agent Client Protocol)自动化 agent(智能体)演示以及真实 API 的 e2e 测试。\n\n## 首次搭建\n\n在仓库根目录安装依赖:\n\n```sh\npnpm install\n```\n\n安装过程同时会运行根目录的 `postinstall` 脚本,该脚本通过 `scripts/install-lefthook.mjs` 从仓库 dev 依赖安装 lefthook。当 `CI=true` 或 `GITHUB_ACTIONS=true` 时,该脚本会在探测 Git 前返回,因为自动化任务不会使用贡献者钩子。否则,包装脚本要求使用 Git 2.26 或更高版本,并会为当前 worktree 在其自身的 Git 目录下设置显式钩子目录;因此,关联 worktree 会使用各自的 lefthook 二进制文件和配置,而不会改写共用钩子。首次安装会启用 Git 的 worktree 专属配置扩展和仓库格式 1;见 [worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md)。\n\n如果依赖是从缓存恢复或 `postinstall` 被跳过而导致缺少钩子,请手动安装:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\n包装层会拒绝用户自有的 `core.hooksPath` 值。继承自系统、全局或共用仓库配置的路径必须设置 `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`。当 Git 使用另一个已注册 worktree 中由所有权标记佐证的钩子路径初始化新 worktree 时,包装层会将这个复制值替换为新 worktree 自有的路径;命令作用域和其他 worktree 作用域的路径必须显式集成或移除。\n\n启用 worktree 配置之前,请迁移格式 0 共用配置中直接设置的 `extensions.*`,并迁移直接设置的 `core.worktree` 或 `core.bare=true`,以及任何非空且尚未生效的 `config.worktree`。共用配置和每个 worktree 配置都必须是常规文件,而自有钩子目录只能包含不带别名的常规文件。\n\n检出目录移动后,请重新运行包装层,使其重新定位自有路径并重新生成钩子。对于陈旧或无效的安装程序锁,请先确认没有安装程序正在运行,再移除报告的锁并重试。若安装和钩子路径回滚都失败,请在重试前检查报告的 worktree 配置。完整安全契约由 [worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) 统一定义。\n\n新克隆后请先运行一次类型检查:\n\n```sh\npnpm run typecheck\n```\n\n首次类型检查会执行全仓 `tsc -b tsconfig.json` 图:发射每个 package/vendor 的 `lib/types`,并通过下述两个 no-emit 聚合检查示例、测试和脚本。\n\n## TypeScript 项目布局\n\n仓库的 TypeScript 配置只有三种角色;每个 tsconfig 文件恰好扮演其中一种。\n\n| 文件 | 角色 | 是否构成 program? |\n|---|---|---|\n| `tsconfig.json` | solution 根:`extends` base、`files: []`、引用两个聚合。全仓 `tsc -b tsconfig.json` 图、tsserver 发现入口,并经继承的 `paths` 充当 tsx 运行 `examples/` 与 `scripts/` 时的解析配置(它们最近的 tsconfig 就是此文件)。 | 否 |\n| `tsconfig.host.json` | host 聚合:host 侧各包(经 references)、示例、测试、脚本、website。排除 `packages/client`。 | 是 |\n| `tsconfig.client.json` | client 聚合:`packages/client/*` 各包及其测试、`apps/web`。 | 是 |\n| `tsconfig.base.json` | 共享 compilerOptions 与源码 `paths` 映射。同时是各 vitest 配置让 vite-tsconfig-paths 指向的解析门面:它没有 `include`,因此其 `paths` 适用于任何 importer。 | 否 |\n| `tsconfig.base.client.json` | 浏览器编译形状(`jsx`、DOM lib、`types: []`),由 client 聚合和每个 `packages/client/*` 包 extends。 | 否 |\n\nhost 与 client 保持两个聚合 program,是因为两侧在相同键下以不同服务对 cordis `Context` 接口做声明合并;单一 program 同时看到两份合并会报冲突。这种冲突只存在于 `ts.Program` 内部——模块解析永远不会触发它——所以 solution 可以同时引用两个聚合,一个 paths 门面也可以横跨两侧。由此推出两条纪律:\n\n- `tsconfig.base.json` 永不添加 `include` 或 `files`:它们会泄漏进每个 extends 它的包项目,并收窄门面的全匹配范围。\n- 构造全仓 `ts.Program` 的脚本显式种子 `tsconfig.host.json` 或 `tsconfig.client.json`——永不种子根 solution,因为把两个聚合展平进一个 program 会撞上 `Context` 合并冲突。基于 program 的生成器与门禁(`scripts/ts-project.ts` 的消费者、doc-typecheck standalone 模式)按决策仅覆盖 host 侧;client 侧只在出现真实需求时再获得基于 program 的工具。\n\n静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。决策记录:[solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md);tsc-first 发射管线见 [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md)。\n\n如果相关的本地检查需要使用构建后的包产物,请先构建一次:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` 包含 `publint`(用构建出的 `lib/*.js` 文件校验 package 入口点)和 `verify-node-next-types`(用一个临时的 NodeNext 消费方校验构建出的声明文件)。新 worktree 在 `pnpm run build` 运行之前没有打包的 JS 和声明文件;普通提交和推送无需构建,除非所选检查会使用这些产物。\n\n## 环境变量\n\n真实的 DeepSeek 适配器和需要密钥的 agent 演示从环境变量或仓库根目录一个被 gitignore 的 `.env` 文件读取凭证:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` 可选,默认为公开 API。请勿提交真实凭证。未设置 `DEEPSEEK_API_KEY` 时,真实 API 的 e2e 套件会自动跳过。\n\n## Git 钩子\n\nlefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点:\n\n- `pre-commit` 应用仅用于格式化的 ESLint 修复,使用 Oxlint 验证暂存文件并应用其原生修复,然后检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫;\n- `pre-push` 只运行仓库增量类型检查(对根 solution 执行 `tsc -b`,覆盖 host 与 client 两个聚合)。\n\nvendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`。\n\n这些钩子有意不运行测试、快照、文档检查、构建或 `hygiene`。贡献者只运行一次[与改动行为相关的检查](../AGENTS.md#run-relevant-checks-locally);CI 负责全量覆盖率门禁、构建产物冒烟测试,以及 Node 22.19、24 和 26 兼容性矩阵。\n\n贡献者可以选择运行 `pnpm run check:all`,执行全面的本地门禁集。该命令独立于两个 Git 钩子,也不是对 agent 的指令。\n\n## CI 门禁\n\nkeyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若干宽粒度 lane,并在受支持的 Node 版本上运行一组较小的兼容性检查。产物消费方在各自 lane 内等待一次 build。单独的真实 API 工作流按其配置的 worker 上限运行 `pnpm run test:e2e`。当前门禁和 job 清单以 [scripts/run-gates.ts](../scripts/run-gates.ts) 和工作流文件为准。\n\n## 日常命令\n\n在仓库根目录使用:\n\n```sh\npnpm run test # unit tests\npnpm run test:coverage # unit tests with per-file coverage gates\npnpm run test:e2e # real-API tests; self-skips without DEEPSEEK_API_KEY\npnpm run check:all # comprehensive opt-in gate set; not wired to Git hooks\npnpm run typecheck # tsc -b over the root solution: emits package/vendor lib/types, checks both aggregates\npnpm run lint # oxlint .\npnpm run lint:fix # formatting-only ESLint, then oxlint . --fix\npnpm run doc-typecheck # compile checked TypeScript snippets in Markdown docs\npnpm run gen-cordis-catalog # regenerate docs/cordis-catalog/events.md + services.md from source\npnpm run verify-cordis-catalog # fail if either cordis catalog is stale\npnpm run verify-export-jsdoc # fail if a module-level package export lacks complete JSDoc\npnpm run gen-doc-graphs # regenerate generated relationship docs from source and curated graph definitions\npnpm run verify-doc-graphs # fail if generated relationship docs are stale\npnpm run verify-md-wrap # fail on hard-wrapped prose paragraphs in docs/README markdown\npnpm run verify-mermaid # fail if a ```mermaid diagram has invalid Mermaid syntax\npnpm run verify-type-equiv # fail if a ```ts type-equiv doc block drifts from its source type\npnpm run verify-doc-budgets # fail if a budgeted standing doc exceeds its word ceiling\npnpm run gen-translation-brief # print the minimal-update briefing for out-of-sync translation pairs (--apply splices code-only edits)\npnpm run doc-sync # all Markdown/doc gates, scheduled concurrently; the doc-sync leaf list in scripts/run-gates.ts is the full list\npnpm run gen-module-graph # regenerate docs/module-graph.md from package peerDeps\npnpm run verify-module-graph # fail if docs/module-graph.md is stale\npnpm run build # emit lib/types intermediates, then bundle lib/index.* runtime files\npnpm run verify-node-next-types # fail if built declarations are not NodeNext-consumable\npnpm run hygiene # knip, publint, workspace constraints, and NodeNext declaration check\n```\n\n修改 package 的公开行为时,请在同一个变更中更新相关 README 或 JSDoc。`pnpm run doc-sync` 能检测到被检查的 TypeScript 片段、生成文档的新鲜度、Markdown 换行/链接漂移、type-equiv、翻译配对、Mermaid 语法和文档预算,但更广泛的行文/API 同步仍需评审把关。\n\n## 演示\n\n单次运行的 Headless coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\n全屏交互式 coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:tui\n```\n\n自指的 cordis 演示可以检查并修改其实时插件运行时,并需要相同的凭证(默认 `web`,也可用 `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nACP 自动化服务器通过 JSON-RPC stdio 提供全新 agent 会话,同样需要 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n## TODO 标记\n\n请使用以下三种注释标签之一标记代码中的已知问题,按紧急程度排序:\n\n- `FIXME`:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 `FIXME`;\n- `TODO`:应当尽快修复的问题,等资源到位即可处理;\n- `XXX`:也许某天会修复的问题,优先级最低,不作承诺。\n\n请选择与紧急程度匹配的标签,让浏览代码的人一眼分清「发布阻塞」和「有空再说」。\n\n## 逐字记录类型(`ts type-equiv`)\n\n[核心数据结构](core-data-structures/core.md)文档会把与源码等价的声明及其原始 JSDoc 一并粘贴,让读者看到确切形状和源码契约。为防止粘贴内容在源码变化时漂移,请将其围栏为 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。对于不应把实现体写进目录的类,请使用 ` ```ts public-api ` 并设置 `\"projection\": \"public-api\"`;门禁检查的投影会保留公共字段、构造函数、访问器、方法以及类和成员的原始 JSDoc,同时省略实现体和私有或受保护成员。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码契约和确切形状。该门禁按文档、符号和投影,在主块与 manifest 条目之间强制 1:1 对应;只有当配对 `.zh.md` 块的完整受跟踪围栏序列与其无后缀兄弟文件按字节一致且顺序相同时,才会复用后者的条目。`doc-typecheck` 对可编译围栏应用同一派生规则,同时跳过两种源码等价围栏的编译,并将其排除在 opt-out 比例之外。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个主块时,请在同一个变更里更新 manifest。\n\n## 架构上下文\n\n在修改 `packages/` 目录下的任何内容之前,请先阅读 `docs/architecture.md`。这套代码围绕 Cordis 插件、事件溯源的会话、类型化的服务 seam 与显式扩展点构建。\n" + "content": "# 开发指南\n\n[English](development.md) | 中文\n\n本指南覆盖参与 DeepSeek Harness 开发所需的本地环境搭建、日常工作流与 CI 流程;设计动机与技术权衡请查阅相应 Agent Note。\n\n## 前置条件\n\n- Node.js 支持 22.19+ 与 24+。CI 覆盖 22.19、24 和 26;见 [Node 引擎下限 Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md)。\n- 启用了 Corepack 的 pnpm。仓库在 `package.json` 中固定使用 `pnpm@11.7.0`;如果 `pnpm --version` 无法通过 Corepack 解析,请先运行 `corepack enable`。\n- Git 2.26 或更高版本;钩子设置会启用 Git 的 worktree 专属配置扩展。\n- 可选:一个 DeepSeek API key,用于 TUI、headless 和 ACP(Agent Client Protocol)自动化 agent(智能体)演示以及真实 API 的 e2e 测试。\n\n## 首次搭建\n\n在仓库根目录安装依赖:\n\n```sh\npnpm install\n```\n\n安装过程同时会运行根目录的 `postinstall` 脚本,该脚本通过 `scripts/install-lefthook.mjs` 从仓库 dev 依赖安装 lefthook。当 `CI=true` 或 `GITHUB_ACTIONS=true` 时,该脚本会在探测 Git 前返回,因为自动化任务不会使用贡献者钩子。否则,包装脚本要求使用 Git 2.26 或更高版本,并会为当前 worktree 在其自身的 Git 目录下设置显式钩子目录;因此,关联 worktree 会使用各自的 lefthook 二进制文件和配置,而不会改写共用钩子。首次安装会启用 Git 的 worktree 专属配置扩展和仓库格式 1;见 [worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md)。\n\n如果依赖是从缓存恢复或 `postinstall` 被跳过而导致缺少钩子,请手动安装:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\n包装层会拒绝用户自有的 `core.hooksPath` 值。继承自系统、全局或共用仓库配置的路径必须设置 `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`。当 Git 使用另一个已注册 worktree 中由所有权标记佐证的钩子路径初始化新 worktree 时,包装层会将这个复制值替换为新 worktree 自有的路径;命令作用域和其他 worktree 作用域的路径必须显式集成或移除。\n\n启用 worktree 配置之前,请迁移格式 0 共用配置中直接设置的 `extensions.*`,并迁移直接设置的 `core.worktree` 或 `core.bare=true`,以及任何非空且尚未生效的 `config.worktree`。共用配置和每个 worktree 配置都必须是常规文件,而自有钩子目录只能包含不带别名的常规文件。\n\n检出目录移动后,请重新运行包装层,使其重新定位自有路径并重新生成钩子。对于陈旧或无效的安装程序锁,请先确认没有安装程序正在运行,再移除报告的锁并重试。若安装和钩子路径回滚都失败,请在重试前检查报告的 worktree 配置。完整安全契约由 [worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) 统一定义。\n\n新克隆后请先运行一次类型检查:\n\n```sh\npnpm run typecheck\n```\n\n首次类型检查会执行全仓 `tsc -b tsconfig.json` 图:发射每个 package/vendor 的 `lib/types`,并通过下述两个 no-emit 聚合检查示例、测试和脚本。\n\n## TypeScript 项目布局\n\n仓库的 TypeScript 配置只有三种角色;每个 tsconfig 文件恰好扮演其中一种。\n\n| 文件 | 角色 | 是否构成 program? |\n|---|---|---|\n| `tsconfig.json` | solution 根:`extends` base、`files: []`、引用两个聚合。全仓 `tsc -b tsconfig.json` 图、tsserver 发现入口,并经继承的 `paths` 充当 tsx 运行 `examples/` 与 `scripts/` 时的解析配置(它们最近的 tsconfig 就是此文件)。 | 否 |\n| `tsconfig.host.json` | host 聚合:host 侧各包(经 references)、示例、测试、脚本、website。排除 `packages/client`。 | 是 |\n| `tsconfig.client.json` | client 聚合:`packages/client/*` 各包及其测试、`apps/web`。 | 是 |\n| `tsconfig.base.json` | 共享 compilerOptions 与源码 `paths` 映射。同时是各 vitest 配置让 vite-tsconfig-paths 指向的解析门面:它没有 `include`,因此其 `paths` 适用于任何 importer。 | 否 |\n| `tsconfig.base.client.json` | 浏览器编译形状(`jsx`、DOM lib、`types: []`),由 client 聚合和每个 `packages/client/*` 包 extends。 | 否 |\n\nhost 与 client 保持两个聚合 program,是因为两侧在相同键下以不同服务对 cordis `Context` 接口做声明合并;单一 program 同时看到两份合并会报冲突。这种冲突只存在于 `ts.Program` 内部——模块解析永远不会触发它——所以 solution 可以同时引用两个聚合,一个 paths 门面也可以横跨两侧。由此推出两条纪律:\n\n- `tsconfig.base.json` 永不添加 `include` 或 `files`:它们会泄漏进每个 extends 它的包项目,并收窄门面的全匹配范围。\n- 构造全仓 `ts.Program` 的脚本显式种子 `tsconfig.host.json` 或 `tsconfig.client.json`——永不种子根 solution,因为把两个聚合展平进一个 program 会撞上 `Context` 合并冲突。基于 program 的生成器与门禁(`scripts/ts-project.ts` 的消费者、doc-typecheck standalone 模式)按决策仅覆盖 host 侧;client 侧只在出现真实需求时再获得基于 program 的工具。\n\n静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。决策记录:[solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md);tsc-first 发射管线见 [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md)。\n\n如果相关的本地检查需要使用构建后的包产物,请先构建一次:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` 包含 `publint`(用构建出的 `lib/*.js` 文件校验 package 入口点)和 `verify-node-next-types`(用一个临时的 NodeNext 消费方校验构建出的声明文件)。新 worktree 在 `pnpm run build` 运行之前没有打包的 JS 和声明文件;普通提交和推送无需构建,除非所选检查会使用这些产物。\n\n## 环境变量\n\n真实的 DeepSeek 适配器和需要密钥的 agent 演示从环境变量或仓库根目录一个被 gitignore 的 `.env` 文件读取凭证:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` 可选,默认为公开 API。请勿提交真实凭证。未设置 `DEEPSEEK_API_KEY` 时,真实 API 的 e2e 套件会自动跳过。\n\n## Git 钩子\n\nlefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点:\n\n- `pre-commit` 应用仅用于格式化的 ESLint 修复,使用 Oxlint 验证暂存文件并应用其原生修复,在暂存文件属于 `THIRD_PARTY_NOTICES.md` 的输入时重新生成该文件,然后检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫;\n- `pre-push` 只运行仓库增量类型检查(对根 solution 执行 `tsc -b`,覆盖 host 与 client 两个聚合)。\n\nvendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`。\n\n这些钩子有意不运行测试、快照、文档检查、构建或 `hygiene`。贡献者只运行一次[与改动行为相关的检查](../AGENTS.md#run-relevant-checks-locally);CI 负责全量覆盖率门禁、构建产物冒烟测试,以及 Node 22.19、24 和 26 兼容性矩阵。\n\n贡献者可以选择运行 `pnpm run check:all`,执行全面的本地门禁集。该命令独立于两个 Git 钩子,也不是对 agent 的指令。\n\n## CI 门禁\n\nkeyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若干宽粒度 lane,并在受支持的 Node 版本上运行一组较小的兼容性检查。产物消费方在各自 lane 内等待一次 build。单独的真实 API 工作流按其配置的 worker 上限运行 `pnpm run test:e2e`。当前门禁和 job 清单以 [scripts/run-gates.ts](../scripts/run-gates.ts) 和工作流文件为准。\n\n## 日常命令\n\n在仓库根目录使用:\n\n```sh\npnpm run test # unit tests\npnpm run test:coverage # unit tests with per-file coverage gates\npnpm run test:e2e # real-API tests; self-skips without DEEPSEEK_API_KEY\npnpm run check:all # comprehensive opt-in gate set; not wired to Git hooks\npnpm run typecheck # tsc -b over the root solution: emits package/vendor lib/types, checks both aggregates\npnpm run lint # oxlint .\npnpm run lint:fix # formatting-only ESLint, then oxlint . --fix\npnpm run doc-typecheck # compile checked TypeScript snippets in Markdown docs\npnpm run gen-cordis-catalog # regenerate docs/cordis-catalog/events.md + services.md from source\npnpm run verify-cordis-catalog # fail if either cordis catalog is stale\npnpm run verify-export-jsdoc # fail if a module-level package export lacks complete JSDoc\npnpm run gen-doc-graphs # regenerate generated relationship docs from source and curated graph definitions\npnpm run verify-doc-graphs # fail if generated relationship docs are stale\npnpm run verify-md-wrap # fail on hard-wrapped prose paragraphs in docs/README markdown\npnpm run verify-mermaid # fail if a ```mermaid diagram has invalid Mermaid syntax\npnpm run verify-type-equiv # fail if a ```ts type-equiv doc block drifts from its source type\npnpm run verify-doc-budgets # fail if a budgeted standing doc exceeds its word ceiling\npnpm run gen-translation-brief # print the minimal-update briefing for out-of-sync translation pairs (--apply splices code-only edits)\npnpm run doc-sync # all Markdown/doc gates, scheduled concurrently; the doc-sync leaf list in scripts/run-gates.ts is the full list\npnpm run gen-module-graph # regenerate docs/module-graph.md from package peerDeps\npnpm run verify-module-graph # fail if docs/module-graph.md is stale\npnpm run build # emit lib/types intermediates, then bundle lib/index.* runtime files\npnpm run verify-node-next-types # fail if built declarations are not NodeNext-consumable\npnpm run hygiene # knip, publint, workspace constraints, and NodeNext declaration check\n```\n\n修改 package 的公开行为时,请在同一个变更中更新相关 README 或 JSDoc。`pnpm run doc-sync` 能检测到被检查的 TypeScript 片段、生成文档的新鲜度、Markdown 换行/链接漂移、type-equiv、翻译配对、Mermaid 语法和文档预算,但更广泛的行文/API 同步仍需评审把关。\n\n## 演示\n\n单次运行的 Headless coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\n全屏交互式 coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:tui\n```\n\n自指的 cordis 演示可以检查并修改其实时插件运行时,并需要相同的凭证(默认 `web`,也可用 `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nACP 自动化服务器通过 JSON-RPC stdio 提供全新 agent 会话,同样需要 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n## TODO 标记\n\n请使用以下三种注释标签之一标记代码中的已知问题,按紧急程度排序:\n\n- `FIXME`:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 `FIXME`;\n- `TODO`:应当尽快修复的问题,等资源到位即可处理;\n- `XXX`:也许某天会修复的问题,优先级最低,不作承诺。\n\n请选择与紧急程度匹配的标签,让浏览代码的人一眼分清「发布阻塞」和「有空再说」。\n\n## 逐字记录类型(`ts type-equiv`)\n\n[核心数据结构](core-data-structures/core.md)文档会把与源码等价的声明及其原始 JSDoc 一并粘贴,让读者看到确切形状和源码契约。为防止粘贴内容在源码变化时漂移,请将其围栏为 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。对于不应把实现体写进目录的类,请使用 ` ```ts public-api ` 并设置 `\"projection\": \"public-api\"`;门禁检查的投影会保留公共字段、构造函数、访问器、方法以及类和成员的原始 JSDoc,同时省略实现体和私有或受保护成员。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码契约和确切形状。该门禁按文档、符号和投影,在主块与 manifest 条目之间强制 1:1 对应;只有当配对 `.zh.md` 块的完整受跟踪围栏序列与其无后缀兄弟文件按字节一致且顺序相同时,才会复用后者的条目。`doc-typecheck` 对可编译围栏应用同一派生规则,同时跳过两种源码等价围栏的编译,并将其排除在 opt-out 比例之外。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个主块时,请在同一个变更里更新 manifest。\n\n## 架构上下文\n\n在修改 `packages/` 目录下的任何内容之前,请先阅读 `docs/architecture.md`。这套代码围绕 Cordis 插件、事件溯源的会话、类型化的服务 seam 与显式扩展点构建。\n" }, { "role": "user", diff --git a/scripts/verify-cordis-config.ts b/scripts/verify-cordis-config.ts index 582da9dc65..bdb020a6a0 100644 --- a/scripts/verify-cordis-config.ts +++ b/scripts/verify-cordis-config.ts @@ -31,7 +31,10 @@ interface PluginReference { const root = resolve(import.meta.dirname, '..') // These example files are overlays consumed by the built dsh app, so their bare // specifiers resolve from apps/cli rather than the examples workspace. -const appOverlayFiles = new Set(['examples/web-cordis/cordis.yml']) +const appOverlayFiles = new Set([ + 'examples/web-cordis/cordis.yml', + ...globSync('examples/mcp-memory/*.cordis.yml', { cwd: root }), +]) const metadataFields = ['id', 'name', 'group', 'disabled', 'inject', 'intercept', 'isolate'] as const /** The adaptive directory-picker chooser package (mounts a backend row at boot). */ diff --git a/tsconfig.host.json b/tsconfig.host.json index d1b5d8caa8..b574305195 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -11,6 +11,7 @@ "apps/web/tests/scaffold.ts", "apps/web/tests/support.ts", "apps/web/tests/scaffold-hermetic.e2e.ts", + "apps/web/tests/core-web-profile.snapshot.ts", "apps/web/tests/live-interactions.e2e.ts", "apps/web/tests/question-composer.e2e.ts", "apps/web/tests/approval-composer.e2e.ts",