Merge remote-tracking branch 'github/master' into xtr/trajectory-inspection-ui

# Conflicts:
#	packages/client/ui-primitives/src/Menu.tsx
This commit is contained in:
_Kerman
2026-07-28 15:41:03 +08:00
128 changed files with 1763 additions and 654 deletions

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # 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; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md
2026-07-22-slot-type-chain-implementation.md: 617524475f3da8af5d281efcfe8f79d500f31be8 2026-07-22-slot-type-chain-implementation.md: e88361701fc05c1ab30174dde147ae9558265ce6
2026-07-22-slot-type-chain-implementation.zh.md: 52edea30acea5989b3438cbcf4688df5a897f099 2026-07-22-slot-type-chain-implementation.zh.md: 90473861c199f326f4b3635580c885517f0d612b

View File

@@ -45,7 +45,7 @@ Parity rule: **the declaring entry holds the exclusive right to render its child
| runtime | `PropsRuntime<K>` | SlotMap entry for K | `OwnerOf<K>` (render-site params) + session-scope standard `useSession`/`sessionId` + global `useSessions`/`useWorkspaces` | | runtime | `PropsRuntime<K>` | SlotMap entry for K | `OwnerOf<K>` (render-site params) + session-scope standard `useSession`/`sessionId` + global `useSessions`/`useWorkspaces` |
| child render | `PropsRenderSlots<S>` | register's `children` keys | `renderSlot(key, owner)`, key statically narrowed to S; chain keys add `renderSlotChain` | | child render | `PropsRenderSlots<S>` | register's `children` keys | `renderSlot(key, owner)`, key statically narrowed to S; chain keys add `renderSlotChain` |
| store | `PropsStore<H>` | store factory return type | `useStore` selector hook + `actions.*` (draft-param stripped) | | store | `PropsStore<H>` | store factory return type | `useStore` selector hook + `actions.*` (draft-param stripped) |
| business | `I` | inject return type | plain data + callbacks (hooks banned) | | business | `I` | inject return type | plain data + callbacks; a reserved `hooks` compartment of bare observables arrives bound as `use<Name>` selector hooks (`InjectFace<I>`) |
`sessionId` is framework-supplied wherever `scope: 'session'` is declared — owner params do not carry it. The register call site is the double-lock choke point: a component whose renderSlot keys exceed the `children` declaration, or that misses a declared face, or whose store/inject shapes drift, is a compile error on that line. Delegation is ordinary props passing (hand the `renderSlot` function down, optionally behind a narrower signature) — there is no whitelist face object and no minting API. `sessionId` is framework-supplied wherever `scope: 'session'` is declared — owner params do not carry it. The register call site is the double-lock choke point: a component whose renderSlot keys exceed the `children` declaration, or that misses a declared face, or whose store/inject shapes drift, is a compile error on that line. Delegation is ordinary props passing (hand the `renderSlot` function down, optionally behind a narrower signature) — there is no whitelist face object and no minting API.
@@ -80,11 +80,11 @@ Store scope is **derived from the mounting entry's scope** (session slot → one
### inject: the registrant's business face, on its own ctx ### inject: the registrant's business face, on its own ctx
An inject factory takes what its declarations earn it — `sessionId` for session slots, bound `actions` when a store is declared, nothing otherwise — and reads services through the **apply closure's own ctx**, so its capability boundary is the plugin's declared `inject` topology (the cordis property proxy applies natively; there is no assembly handle carrying a wider ctx). Its return value is plain data and callbacks only: the narrowed read/write face of the plugin's own services, cross-service orchestration (e.g. `send` = `actions.clearDraft()` + `ctx.conversation.send(...)`), and per-(entry×session) assembly side effects. No hooks, no ReactNode producers, no whole-service objects — narrowing is the value: what a component can do is exactly the factory's return shape. An inject factory takes what its declarations earn it — `sessionId` for session slots, bound `actions` when a store is declared, nothing otherwise — and reads services through the **apply closure's own ctx**, so its capability boundary is the plugin's declared `inject` topology (the cordis property proxy applies natively; there is no assembly handle carrying a wider ctx). Its return value is plain data and callbacks, plus at most the reserved `hooks` compartment: a map of bare observable sources (getSnapshot+subscribe) the renderer binds into `use<Name>` selector hooks before the face reaches the component — the registrant-private twin of the provide channel's hooks compartment, for reactive facts too niche for the global standard kit (composer notices/lexicon, the settings nav rows). Components never receive the raw sources, so business code still contains no subscription machinery. Everything else stays plain: the narrowed read/write face of the plugin's own services, cross-service orchestration (e.g. `send` = `actions.clearDraft()` + `ctx.conversation.send(...)`), and per-(entry×session) assembly side effects. No hand-made hooks, no ReactNode producers, no whole-service objects — narrowing is the value: what a component can do is exactly the factory's return shape.
### Data-boundary discipline ### Data-boundary discipline
Hooks are framework-made only: `useSession`, `useSessions`, `useWorkspaces`, `useStore`, `renderSlot` are the five seats, implemented once with framework-guaranteed correctness; business code passes plain data and callbacks between parent and child (a component's own behavioral hooks that subscribe to nothing external remain fine). Live data has exactly three channels: what the parent knows travels as owner props at the renderSlot site; what only the component knows is local state; what must be shared across entries or survive remounts is a declared store. Derivation is a pure function over framework-hook data (`useMemo`), never a subscription of its own. Hooks are framework-made only: `useSession`, `useSessions`, `useWorkspaces`, `useStore`, `renderSlot` plus the hooks bound from provide contributions and inject `hooks` compartments — every one synthesized by the renderer's single binding machinery; business code passes plain data and callbacks between parent and child (a component's own behavioral hooks that subscribe to nothing external remain fine). Live data has exactly three channels: what the parent knows travels as owner props at the renderSlot site; what only the component knows is local state; what must be shared across entries or survive remounts is a declared store. Derivation is a pure function over framework-hook data (`useMemo`), never a subscription of its own.
### Tree context and the renderer seam ### Tree context and the renderer seam
@@ -111,7 +111,7 @@ Render authority is enforceable rather than conventional: who renders what is a
| Whitelist face objects (`ScopedSlots` + narrowing helpers) | With the whitelist already in the component's props type, the face is derivable by machinery; a mintable face object is a third authority surface with runtime-only checks | | Whitelist face objects (`ScopedSlots` + narrowing helpers) | With the whitelist already in the component's props type, the face is derivable by machinery; a mintable face object is a third authority surface with runtime-only checks |
| Assembly handles carrying root ctx into inject | Bypasses declared inject topology — every factory could reach every service, so package.json dependency declarations stop meaning anything | | Assembly handles carrying root ctx into inject | Bypasses declared inject topology — every factory could reach every service, so package.json dependency declarations stop meaning anything |
| `children` as a key array | kind/scope are runtime dispatch data; SlotMap is erased, so an array forces a second spec-registration API — a definition API reborn | | `children` as a key array | kind/scope are runtime dispatch data; SlotMap is erased, so an array forces a second spec-registration API — a definition API reborn |
| Business-defined hooks via inject | Every plugin becomes its own subscription machine; the framework store seat carries the same data with one audited machine | | Business hand-made hooks / raw observables in component props | Every plugin becomes its own subscription machine; the inject `hooks` compartment carries the same facts through the one audited binding machinery |
| Module-level store handles | A module-scope handle is a singleton across plugin reloads and test cases; the factory form scopes identity to apply/test invocation | | Module-level store handles | A module-scope handle is a singleton across plugin reloads and test cases; the factory form scopes identity to apply/test invocation |
| Components receiving the store instance | `update`/`set` in render code makes the mutation surface unauditable; declared actions keep "what can change" a register-site fact | | Components receiving the store instance | `update`/`set` in render code makes the mutation surface unauditable; declared actions keep "what can change" a register-site fact |
| `FC` at the register position / inferring `I` from the component | FC statics generate covariant noise that rejects valid components; component-side inference absorbs props drift silently (see rulings above) | | `FC` at the register position / inferring `I` from the component | FC statics generate covariant noise that rejects valid components; component-side inference absorbs props drift silently (see rulings above) |

View File

@@ -45,7 +45,7 @@ ctx.slots.register({
| 运行时 | `PropsRuntime<K>` | K 对应的 SlotMap entry | `OwnerOf<K>`(渲染现场传参)+ session scope 标配 `useSession`/`sessionId` + 全局 `useSessions`/`useWorkspaces` | | 运行时 | `PropsRuntime<K>` | K 对应的 SlotMap entry | `OwnerOf<K>`(渲染现场传参)+ session scope 标配 `useSession`/`sessionId` + 全局 `useSessions`/`useWorkspaces` |
| 子坑渲染 | `PropsRenderSlots<S>` | register 的 `children` 键集 | `renderSlot(key, owner)`,键参静态收窄到 S;chain 键另有 `renderSlotChain` | | 子坑渲染 | `PropsRenderSlots<S>` | register 的 `children` 键集 | `renderSlot(key, owner)`,键参静态收窄到 S;chain 键另有 `renderSlotChain` |
| store | `PropsStore<H>` | store 工厂的返回类型 | `useStore` selector hook + `actions.*`(剥去 draft 形参) | | store | `PropsStore<H>` | store 工厂的返回类型 | `useStore` selector hook + `actions.*`(剥去 draft 形参) |
| 业务 | `I` | inject 的返回类型 | 普通数据+回调(禁 hook) | | 业务 | `I` | inject 的返回类型 | 普通数据+回调;保留键 `hooks` 格的裸 observable 经绑定以 `use<Name>` 选择器 hook 到达(`InjectFace<I>`) |
凡声明 `scope: 'session'` 之处,`sessionId` 一律由框架供给——owner 传参不携带它。register 调用点是双向锁的收口:组件的 renderSlot 键集超出 `children` 声明、漏接某个已声明的面、store/inject 形状漂移,任何一条都在那一行上报编译错误。转授就是普通的 props 传递(把 `renderSlot` 函数递下去,可按需包一层更窄的签名)——不存在白名单面对象,也不存在铸面 API。 凡声明 `scope: 'session'` 之处,`sessionId` 一律由框架供给——owner 传参不携带它。register 调用点是双向锁的收口:组件的 renderSlot 键集超出 `children` 声明、漏接某个已声明的面、store/inject 形状漂移,任何一条都在那一行上报编译错误。转授就是普通的 props 传递(把 `renderSlot` 函数递下去,可按需包一层更窄的签名)——不存在白名单面对象,也不存在铸面 API。
@@ -80,11 +80,11 @@ store 的 scope **从挂载 entry 的 scope 推导**(session 坑→每个会
### inject:注册方的业务面,立足自己的 ctx ### inject:注册方的业务面,立足自己的 ctx
inject 工厂只收其声明挣来的形参——session 坑得 `sessionId`,声明了 store 的得绑定好的 `actions`,否则无参——取服务一律经 **apply 闭包自己的 ctx**,其能力边界因此就是本插件声明的 `inject` 拓扑(cordis property proxy 原生生效;不存在携带更宽 ctx 的装配句柄)。返回值只含普通数据与回调:本插件自有服务的收窄读写面、跨服务编排(如 `send` = `actions.clearDraft()` + `ctx.conversation.send(...)`)、以及 per-(entry×session) 的装配副作用。禁 hook、禁 ReactNode 生产者、禁递整个服务对象——收窄本身就是价值:组件能做什么,恰由工厂返回值的形状圈定。 inject 工厂只收其声明挣来的形参——session 坑得 `sessionId`,声明了 store 的得绑定好的 `actions`,否则无参——取服务一律经 **apply 闭包自己的 ctx**,其能力边界因此就是本插件声明的 `inject` 拓扑(cordis property proxy 原生生效;不存在携带更宽 ctx 的装配句柄)。返回值是普通数据与回调,至多外加保留键 `hooks` 格:一张裸 observable source(getSnapshot+subscribe)表,渲染器在业务面抵达组件前把每个 source 绑成 `use<Name>` 选择器 hook——即 provide 通道 hooks 格的注册方私有孪生,供太小众、不该进全局标准件的响应式事实(composer 的 notices/lexicon、settings 导航行)取用。组件永远收不到裸 source,业务代码因此仍零订阅机械。其余保持普通:本插件自有服务的收窄读写面、跨服务编排(如 `send` = `actions.clearDraft()` + `ctx.conversation.send(...)`)、以及 per-(entry×session) 的装配副作用。禁手造 hook、禁 ReactNode 生产者、禁递整个服务对象——收窄本身就是价值:组件能做什么,恰由工厂返回值的形状圈定。
### 数据界线纪律 ### 数据界线纪律
hook 只许框架造:`useSession`、`useSessions`、`useWorkspaces`、`useStore`、`renderSlot` 是仅有的五席,各实现一次、正确性由框架担保;业务代码在父子组件之间只传普通数据与回调(组件自用、不订阅任何外部数据源的行为 hook 不在此限)。活数据恰有三条通道:父知道的,作为 owner props 在 renderSlot 现场传入;只有组件自己知道的,是本地 state;需要跨 entry 共享或跨重挂载存活的,是声明的 store。派生是对框架 hook 数据做纯函数(`useMemo`),绝不自成一路订阅。 hook 只许框架造:`useSession`、`useSessions`、`useWorkspaces`、`useStore`、`renderSlot` 五席,加上 provide 贡献与 inject `hooks` 格绑出的 hook——全部出自渲染器同一台绑定机械;业务代码在父子组件之间只传普通数据与回调(组件自用、不订阅任何外部数据源的行为 hook 不在此限)。活数据恰有三条通道:父知道的,作为 owner props 在 renderSlot 现场传入;只有组件自己知道的,是本地 state;需要跨 entry 共享或跨重挂载存活的,是声明的 store。派生是对框架 hook 数据做纯函数(`useMemo`),绝不自成一路订阅。
### 树上语境与渲染器安装缝 ### 树上语境与渲染器安装缝
@@ -111,7 +111,7 @@ register 签名里的两条硬化裁定之所以存在,是因为显然的替
| 白名单面对象(`ScopedSlots` + 收窄辅助件) | 白名单已在组件的 props 类型里,面可由机械推导;可铸造的面对象是第三个权威面,且只有运行时校验 | | 白名单面对象(`ScopedSlots` + 收窄辅助件) | 白名单已在组件的 props 类型里,面可由机械推导;可铸造的面对象是第三个权威面,且只有运行时校验 |
| 装配句柄把 root ctx 带进 inject | 绕开声明的 inject 拓扑——每个工厂都摸得到每个服务,package.json 的依赖声明就此失去意义 | | 装配句柄把 root ctx 带进 inject | 绕开声明的 inject 拓扑——每个工厂都摸得到每个服务,package.json 的依赖声明就此失去意义 |
| `children` 用键数组形 | kind/scope 是运行时分派数据;SlotMap 已被擦除,数组形必然逼出第二个 spec 注册 API——定义 API 复活 | | `children` 用键数组形 | kind/scope 是运行时分派数据;SlotMap 已被擦除,数组形必然逼出第二个 spec 注册 API——定义 API 复活 |
| 业务经 inject 自定义 hook | 每个插件都变成自己的订阅机械;框架 store 席位用一台受审计的机械承载同样的数据 | | 业务手造 hook / 组件 props 里递裸 observable | 每个插件都变成自己的订阅机械;inject `hooks` 格让同样的事实走那一台受审计的绑定机械 |
| 模块级 store 句柄 | 模块级句柄是跨插件重载与跨测试用例的单例;工厂形把身份圈定在单次 apply/测试调用内 | | 模块级 store 句柄 | 模块级句柄是跨插件重载与跨测试用例的单例;工厂形把身份圈定在单次 apply/测试调用内 |
| 组件直收 store 实例 | 渲染代码里能用 `update`/`set`,变更面就无从审计;声明的 actions 让「什么能变」保持为 register 现场的事实 | | 组件直收 store 实例 | 渲染代码里能用 `update`/`set`,变更面就无从审计;声明的 actions 让「什么能变」保持为 register 现场的事实 |
| 注册位用 `FC` / 从组件推断 `I` | FC 静态位产生协变噪音、拒绝合法组件;组件侧推断静默吸收 props 漂移(见上文裁定) | | 注册位用 `FC` / 从组件推断 `I` | FC 静态位产生协变噪音、拒绝合法组件;组件侧推断静默吸收 props 漂移(见上文裁定) |

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md
2026-07-25-web-client-session-scope-and-provide-channel.md: 09afe6d9e879ae7529d309c3b5e656be849fa543 2026-07-25-web-client-session-scope-and-provide-channel.md: 4496e3786ed4adb6e60dfd5cfad72e989657f649
2026-07-25-web-client-session-scope-and-provide-channel.zh.md: 4d45d74c2e7c34601a5229fc0fc0780a23ec6fd5 2026-07-25-web-client-session-scope-and-provide-channel.zh.md: 768dada95aacdb358115d496f45e7fc0eece0151

View File

@@ -90,7 +90,7 @@ The sole provisioning path by which session slot components fetch their own sess
Slot scope is the closed set `root | session-maybe | session`: Slot scope is the closed set `root | session-maybe | session`:
- `root` receives only the global standard kit, with no session identity or provisioning. - `root` receives only the global standard kit, with no session identity or provisioning.
- `session-maybe` follows the current session, but the component instance does not change key when the id appears, disappears, or changes; with no session, `sessionId`, the results of `useSession`/`useInput`, and `inputActions` may all be absent. The unkeyed root `SessionMaybeProvider` drives these updates, while `SessionMaybeProvideInfo` uses the static key map to retain the complete hook/prop shape even with no session. - `session-maybe` follows the current session, but the component instance does not change key when the id appears, disappears, or changes; with no session, `sessionId`, the results of `useSession`/`useInput`, and `inputActions` may all be absent. The unkeyed root `SessionMaybeProvider` drives these updates by subscribing to the runtime's atomic `currentProvide` projection — selection moves and provider-roster changes publish through the same source, so a roster change under a stable current id republishes the mounted bundle instead of stranding entries on an obsolete hook/prop schema — while `SessionMaybeProvideInfo` uses the static key map to retain the complete hook/prop shape even with no session.
- `session` guarantees that `sessionId`, every hook source, and every prop exist; each strict entry's error boundary is keyed by `sessionId`, so switching sessions recreates that entry and its session store. - `session` guarantees that `sessionId`, every hook source, and every prop exist; each strict entry's error boundary is keyed by `sessionId`, so switching sessions recreates that entry and its session store.
`conversation` is the resident `session-maybe` shell: `ConversationRoot`, HeroShell, the Workspace picker, the composer stack, and the overlay chain's fallback frame retain their React instances across the no-session → blank-session switch; `conversation.session` carries only the strict-session header/view, while the composer and every input slot also stay strict `session`. With no session, the composer stack places the presentation-only `DisabledInputBar` directly; once a session appears, the input body is swapped for the strictly bound InputBar; the textarea may be rebuilt, while the Hero and the layout skeleton are not. The blank → engaging/active transition stays inside the same strict-session subtree, and the InputBar is never rebuilt on a phase flip. `conversation` is the resident `session-maybe` shell: `ConversationRoot`, HeroShell, the Workspace picker, the composer stack, and the overlay chain's fallback frame retain their React instances across the no-session → blank-session switch; `conversation.session` carries only the strict-session header/view, while the composer and every input slot also stay strict `session`. With no session, the composer stack places the presentation-only `DisabledInputBar` directly; once a session appears, the input body is swapped for the strictly bound InputBar; the textarea may be rebuilt, while the Hero and the layout skeleton are not. The blank → engaging/active transition stays inside the same strict-session subtree, and the InputBar is never rebuilt on a phase flip.

View File

@@ -90,7 +90,7 @@ session slot 组件「自己拿 session 数据」的唯一供数路径。插件
slot scope 是闭集 `root | session-maybe | session`: slot scope 是闭集 `root | session-maybe | session`:
- `root` 只拿全局标准件,不接收 session 身份或供数。 - `root` 只拿全局标准件,不接收 session 身份或供数。
- `session-maybe` 跟随 current session,但组件实例不因 id 有无或切换而换 key;无 session 时 `sessionId`、`useSession`/`useInput` 的选择结果及 `inputActions` 均可缺省。根部无 key 的 `SessionMaybeProvider` 驱动这条更新,`SessionMaybeProvideInfo` 靠静态键表在无 session 时仍保留完整 hook/prop 形状。 - `session-maybe` 跟随 current session,但组件实例不因 id 有无或切换而换 key;无 session 时 `sessionId`、`useSession`/`useInput` 的选择结果及 `inputActions` 均可缺省。根部无 key 的 `SessionMaybeProvider` 通过订阅 runtime 的原子 `currentProvide` 投影驱动这条更新——选择移动与 provider 名册变化经同一 source 发布,current id 不变时的名册变化也会重发已挂载 bundle,而不是把 entry 困在过期的 hook/prop 形状上——`SessionMaybeProvideInfo` 靠静态键表在无 session 时仍保留完整 hook/prop 形状。
- `session` 保证 `sessionId`、所有 hook source 与 props 均存在;每个严格 entry 的错误边界以 `sessionId` 为 key,切换 session 会重建该 entry 及其 session store。 - `session` 保证 `sessionId`、所有 hook source 与 props 均存在;每个严格 entry 的错误边界以 `sessionId` 为 key,切换 session 会重建该 entry 及其 session store。
`conversation` 是 `session-maybe` 的常驻外壳:`ConversationRoot`、HeroShell、Workspace picker、composer stack 与 overlay chain 的 fallback 外框在无 session → blank session 的切换中保持 React 实例;`conversation.session` 只承载严格 session 的 header/view,composer 与各输入 slot 也保持严格 `session`。无 session 时 composer stack 直接放纯展示的 `DisabledInputBar`,session 出现后把输入体换成严格绑定的 InputBar;textarea 允许重建,Hero 与布局骨架不重建。blank → engaging/active 仍在同一严格 session subtree 内,InputBar 不因 phase 翻转而重建。 `conversation` 是 `session-maybe` 的常驻外壳:`ConversationRoot`、HeroShell、Workspace picker、composer stack 与 overlay chain 的 fallback 外框在无 session → blank session 的切换中保持 React 实例;`conversation.session` 只承载严格 session 的 header/view,composer 与各输入 slot 也保持严格 `session`。无 session 时 composer stack 直接放纯展示的 `DisabledInputBar`,session 出现后把输入体换成严格绑定的 InputBar;textarea 允许重建,Hero 与布局骨架不重建。blank → engaging/active 仍在同一严格 session subtree 内,InputBar 不因 phase 翻转而重建。

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # 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; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.md
2026-07-25-web-command-surfaces-and-assembly.md: 5188e8c17b31157b1c03203a8d7ba2d8e6a1496b 2026-07-25-web-command-surfaces-and-assembly.md: 4c4a400abab940baebc1699fc15b709419865f0c
2026-07-25-web-command-surfaces-and-assembly.zh.md: 0134cc10cf4f49b7719d6a0dacb239389776d6ed 2026-07-25-web-command-surfaces-and-assembly.zh.md: c0acd1ecc5998a0ec488a1f13ed99ae4a93a240b

View File

@@ -28,8 +28,8 @@ The pipeline was ready but command knowledge had no landing spot: host-side `ctx
### Reference sources (seeing only projections plus their own apply closures, on the root ctx) ### Reference sources (seeing only projections plus their own apply closures, on the root ctx)
- **ui-skill**: `skill.list({sessionId})` addresses by session (the host resolves the project root from the session header); the directory cache is single-flight keyed by sessionId, prewarmed at birth by the `warm` hook and fully cleared by `connection/reset`. A pick produces a text outcome (the literal `/name ` text, Decision 21); `lexicon` supplies the roster from CatalogFetch's settled snapshot (`undefined` while not warm). No match hook (references never enter command adjudication). Skill references ride ordinary prompts as literal text (outside the command plane; tool-skill unchanged, with the session-prefix directory providing the cooperative association). - **ui-skill**: `skill.list({sessionId})` addresses by session (the host resolves the project root from the session header); the directory cache is single-flight keyed by sessionId, prewarmed at birth by the `warm` hook and fully cleared by `connection/reset`. A pick produces a text outcome (the literal `/name ` text, Decision 21); `lexicon` supplies the roster from CatalogFetch's settled snapshot (`undefined` while not warm), and `subscribeLexicon` notifies per-session listeners on settle and on invalidation. No match hook (references never enter command adjudication). Skill references ride ordinary prompts as literal text (outside the command plane; tool-skill unchanged, with the session-prefix directory providing the cooperative association).
- **ui-subagent**: candidates are zero-RPC (the sessions.list snapshot filtered by parentId/running); a pick produces a text outcome (the literal `@name ` text); `lexicon` derives from the same snapshot (the model-side representation awaits its business workstream). - **ui-subagent**: candidates are zero-RPC (the sessions.list snapshot filtered by parentId/running); a pick produces a text outcome (the literal `@name ` text); `lexicon` derives from the same snapshot and `subscribeLexicon` forwards the list store's change feed (the model-side representation awaits its business workstream).
### Fixture command routing and assembly ### Fixture command routing and assembly

View File

@@ -28,8 +28,8 @@ Status: implemented
### 引用源(只见投影 + 自家 apply 闭包的 root ctx) ### 引用源(只见投影 + 自家 apply 闭包的 root ctx)
- **ui-skill**:`skill.list({sessionId})` 按会话寻址(host 从会话 header 解析项目根);目录缓存按 sessionId 键控 single-flight,`warm` 钩子出生预热、`connection/reset` 全清。pick 产出 text outcome(`/name ` 原文,决策 21);`lexicon` 从 CatalogFetch 的 settled 快照给名录(未热 `undefined`)。无 match 钩子(引用不进命令裁决)。skill 引用以原文随普通 prompt 走(命令平面之外;tool-skill 不变,session-prefix 目录提供协作关联)。 - **ui-skill**:`skill.list({sessionId})` 按会话寻址(host 从会话 header 解析项目根);目录缓存按 sessionId 键控 single-flight,`warm` 钩子出生预热、`connection/reset` 全清。pick 产出 text outcome(`/name ` 原文,决策 21);`lexicon` 从 CatalogFetch 的 settled 快照给名录(未热 `undefined`),`subscribeLexicon` 在 settle 与失效时按会话通知监听者。无 match 钩子(引用不进命令裁决)。skill 引用以原文随普通 prompt 走(命令平面之外;tool-skill 不变,session-prefix 目录提供协作关联)。
- **ui-subagent**:候选零 RPC(sessions.list 快照按 parentId/running 过滤);pick 产出 text outcome(`@name ` 原文);`lexicon` 同快照派生(模型侧表示待业务立项)。 - **ui-subagent**:候选零 RPC(sessions.list 快照按 parentId/running 过滤);pick 产出 text outcome(`@name ` 原文);`lexicon` 同快照派生,`subscribeLexicon` 转发 list store 的变更通道(模型侧表示待业务立项)。
### fixture 命令路由与装配 ### fixture 命令路由与装配

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # 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; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md
2026-07-25-web-input-machine-and-slash-pipeline.md: acbd132a5fdb97a4098064aae689dfca604ad4b7 2026-07-25-web-input-machine-and-slash-pipeline.md: 8cf3be7b3b7579d0c37898a58fb0ab4990fd71bf
2026-07-25-web-input-machine-and-slash-pipeline.zh.md: 158650a41b47f98037a1b3e610d9294694c55a8c 2026-07-25-web-input-machine-and-slash-pipeline.zh.md: b1488893558d8b2bf9c104faca968435d46a9640

View File

@@ -81,10 +81,10 @@ A trigger/menu/pick pipeline with zero knowledge of "commands":
skill/@subagent references skip the placeholder + occurrence identity chain — a pick inserts the literal `/name ` `@name ` text straight into the draft, with the chip visual purely derived: skill/@subagent references skip the placeholder + occurrence identity chain — a pick inserts the literal `/name ` `@name ` text straight into the draft, with the chip visual purely derived:
- PickOutcome gains a `{text}` arm; the new scoped bail event `slash/input-insert-text` `{text, span}` (the same contract as the other three: draftRev CAS, returning true ⟺ an actual rewrite); facade.insertText goes through setDraft concatenation — zero machine changes. - PickOutcome gains a `{text}` arm; the new scoped bail event `slash/input-insert-text` `{text, span}` (the same contract as the other three: draftRev CAS, returning true ⟺ an actual rewrite); facade.insertText goes through setDraft concatenation — zero machine changes.
- Sources get an optional `lexicon?(session)` hook: a synchronous hot-snapshot name roster, with `undefined` = data not warm — zero decoration, never triggering a fetch (the render path stays synchronous and side-effect-free); the controller aggregates it into the `lexicon()` public surface. - Sources get an optional `lexicon?(session)` hook: a synchronous hot-snapshot name roster, with `undefined` = data not warm — zero decoration, never triggering a fetch (the render path stays synchronous and side-effect-free); the paired optional `subscribeLexicon?(session, listener)` hook is the invalidation channel for rolls that change after warm (catalog settles, children spawn/exit). The controller aggregates the rolls into its `lexicon` snapshot store (re-polling on each source notification); sources registered after scope birth are warmed and folded in via the service's live-controller broadcast.
- `decorations.scanTextRefs`: a word-boundary scan of the draft (`/name`, `@name` at line start / after whitespace; `x/name` never hits) against the roster; a hit gets the `.textRef` mark (a pure range highlight on the backdrop, same as hlToken); an edit breaking the match shape simply disappears on the next scan. - `decorations.scanTextRefs`: a word-boundary scan of the draft (`/name`, `@name` at line start / after whitespace; `x/name` never hits) against the roster; a hit gets the `.textRef` mark (a pure range highlight on the backdrop, same as hlToken); an edit breaking the match shape simply disappears on the next scan.
- Sending is the literal text (no more `<skill>` serialization); on the bubble side MessageItem decorates both shapes (the legacy `<skill>` tag + plain-text tokens). - Sending is the literal text (no more `<skill>` serialization); on the bubble side MessageItem decorates both shapes (the legacy `<skill>` tag + plain-text tokens).
- The old occurrence/paste/serialize chain stays on disk in full, undeleted (additive; deletion is a separate future cut). Known limitation kept as-is: with the lexicon not warm at paste / cold start there is no decoration — it lights up only after typing `/` opens the menu once. - The old occurrence/paste/serialize chain stays on disk in full, undeleted (additive; deletion is a separate future cut). Decoration reactivity: InputBar subscribes to the shell's lexicon source (uSES), so a roll that settles after the scope-birth prewarm lights existing draft tokens up without any menu interaction or unrelated re-render.
### Per-session provide contributions and the private keyboard surface ### Per-session provide contributions and the private keyboard surface

View File

@@ -81,10 +81,10 @@ occurrence 表与 chip 三投影:
skill/@subagent 引用不走占位符 + occurrence 身份链——pick 直接把 `/name ` `@name ` 原文插进 draft,chip 视觉纯派生: skill/@subagent 引用不走占位符 + occurrence 身份链——pick 直接把 `/name ` `@name ` 原文插进 draft,chip 视觉纯派生:
- PickOutcome 增 `{text}` arm;新 scoped bail 事件 `slash/input-insert-text` `{text, span}`(与另三个同契约:draftRev CAS、返回 true ⟺ 实际改写);facade.insertText 走 setDraft 拼接,机器零改动。 - PickOutcome 增 `{text}` arm;新 scoped bail 事件 `slash/input-insert-text` `{text, span}`(与另三个同契约:draftRev CAS、返回 true ⟺ 实际改写);facade.insertText 走 setDraft 拼接,机器零改动。
- source 可选 `lexicon?(session)` 钩子:同步热快照名录,`undefined` = 数据未热——零装饰、永不触发 fetch(渲染路径保持同步无副作用);controller 聚合为 `lexicon()` 公面。 - source 可选 `lexicon?(session)` 钩子:同步热快照名录,`undefined` = 数据未热——零装饰、永不触发 fetch(渲染路径保持同步无副作用);配对的可选 `subscribeLexicon?(session, listener)` 钩子是名录在 warm 之后仍会变化(目录 settle、子代生灭)时的失效通道。controller 把各名录聚合进自己的 `lexicon` snapshot store(每次 source 通知重拉);scope 出生后才注册的 source 由 service 广播给活 controller,补 warm 并并入名录。
- `decorations.scanTextRefs`:词边界扫描 draft(行首/空白后的 `/name`、`@name`,`x/name` 永不命中)对照名录,命中即 `.textRef` mark(backdrop 纯 range 高亮,同 hlToken);编辑破坏匹配形状下次扫描自然消失。 - `decorations.scanTextRefs`:词边界扫描 draft(行首/空白后的 `/name`、`@name`,`x/name` 永不命中)对照名录,命中即 `.textRef` mark(backdrop 纯 range 高亮,同 hlToken);编辑破坏匹配形状下次扫描自然消失。
- 发送即原文(不再 `<skill>` 序列化);气泡侧 MessageItem 双形状装饰(legacy `<skill>` 标签 + 纯文本 token)。 - 发送即原文(不再 `<skill>` 序列化);气泡侧 MessageItem 双形状装饰(legacy `<skill>` 标签 + 纯文本 token)。
- 旧 occurrence/paste/serialize 链全部保留在盘未删(additive;删除另成将来一刀)。已知局限维持现状:粘贴/冷启动时 lexicon 未热不装饰,输 `/` 开一次菜单后才亮。 - 旧 occurrence/paste/serialize 链全部保留在盘未删(additive;删除另成将来一刀)。装饰响应性:InputBar 以 uSES 订阅 shell 的 lexicon source,scope 出生预热后才 settle 的名录会直接点亮已有 draft token,无需菜单交互或无关重渲染。
### per-session 供数贡献与键盘私面 ### per-session 供数贡献与键盘私面

View File

@@ -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-28-web-conversation-polish-sweep.md
2026-07-28-web-conversation-polish-sweep.md: cae52217d66017509c025a5d8d37b1e1e8173c6a
2026-07-28-web-conversation-polish-sweep.zh.md: d19a5f75937e9ae9f553b2c941594db118fa434e

View File

@@ -0,0 +1,37 @@
# Agent Note: Web conversation UI polish sweep
Status: implemented
English | [中文](2026-07-28-web-conversation-polish-sweep.zh.md)
## Problem
A design review of the web GUI's conversation surfaces found a batch of presentation defects: portal menus painted one frame at the wrong position before repositioning (visible open jump), the chat column split one tool run into several groups whenever a step message carried only tool-call heads, tool row summaries printed workspace-absolute paths that consumed most of the row, the running-row sweep was implemented as an alpha mask that dimmed the whole row, the hero workspace chip resurrected a deleted workspace's folder name from the session cwd, and the header showed a turns counter nobody asked for next to a 13px title.
## Decision
The sweep lands as presentation-layer changes only; nothing enters the session log.
- **Portal menus pre-render hidden and measure before paint.** The menu list mounts with `visibility: hidden` at (0,0), measures in `useLayoutEffect`, and becomes visible already at its final position. Menus keep 12px viewport clearance with internal scroll; workspace create actions pin in a non-scrolling footer.
- **The chat flow skips assistant nodes that render nothing.** A finalized assistant node whose blocks are only tool-call heads and blank text/reasoning is dropped from the flow derivation, so consecutive tool results merge into one group. Interrupted nodes always render (they carry the 已停止 marker).
- **Tool row summaries relativize workspace-rooted paths.** The session cwd threads through the toolview slot contract (`ToolRowOwnerProps.cwd`) and `toolRowModel` strips it from summaries that start with it; paths outside the workspace stay verbatim. Display-only — args and the log are untouched.
- **The running sweep is a glare-band overlay.** A fixed-width `::after` gradient band animates across the row (the deepsuite ShimmerText pattern), replacing the previous `mask-image` approach, in both ToolRow and the Bash toolview.
- **The hero workspace chip is a selector, not an echo.** With no live selection (cold start, or the workspace was deleted after the list settled) it shows a "Choose workspace" placeholder; the cwd-derived name only bridges the initial list load, and stale pending picks clear when their workspace leaves a ready list.
- **One 16px vertical rhythm.** The chat column gap and in-group tool-row gap are both 16px, replacing the 10px in-group gap plus a negative cross-group margin.
- **Header title reads 14/20 with no turns counter**; StateDot ongoing and the turn tail use a stepped pixel-chase loading language; `body` gets grayscale antialiasing (`-webkit-font-smoothing` and the Firefox macOS equivalent).
## Alternatives considered
- **Position menus synchronously from anchor rects before mount.** Rejected: the list's own size is unknown until it lays out, so clamping to the viewport still needs a post-layout measure; measuring a hidden mounted node is the pattern React and Floating UI document.
- **Filter empty assistant messages host-side.** Rejected: the node is real model output that Trajectory and replay must keep; only the chat presentation should skip it, and the web layer is pure presentation by contract.
- **Relativize paths in each tool's presenter.** Rejected: the redundancy is shared by every path-summarizing tool; one display-only pass in `toolRowModel` covers them all and non-chat consumers keep absolute paths.
- **Keep the mask-based sweep.** Rejected: the mask dims the entire row content including state dots, and its exit transition fought the hover icon crossfade; an overlay band composites above the content without touching its alpha.
- **Keep showing the deleted workspace's name in the chip.** Rejected: the chip is the selector for the *next* session; echoing a cwd whose workspace the user just deleted misrepresents the current pick.
## Consequences
Chat renders fewer flow items than the snapshot has nodes: anyone counting rendered blocks against nodes must account for skipped render-nothing assistants (the chat-view spec pins this). The path relativization is a prefix check against the session cwd, so a workspace rename mid-session shows absolute paths until the summary re-derives — accepted as display-only staleness. The uniform 16px rhythm retires the tighter 10px tool-run look; a future denser layout would reintroduce a second constant deliberately. The menu pre-render adds one hidden layout pass per open, negligible at menu sizes.
## Testing
`chat-view.spec.tsx` pins the render-nothing grouping (including the interrupted exception); `chat-tool-row.spec.tsx` pins cwd relativization inside/outside the workspace and with an empty cwd; `atoms.spec.tsx` and `workspace-picker.spec.tsx` cover the menu and chip states; the full ui-conversation, ui-primitives, and ui-workspace suites pass.

View File

@@ -0,0 +1,37 @@
# Agent Note: Web conversation UI polish sweep
Status: implemented
[English](2026-07-28-web-conversation-polish-sweep.md) | 中文
## Problem
一次针对 web GUI 对话界面的设计评审发现了一批视觉呈现缺陷:portal 菜单在重新定位前会先在错误位置绘制一帧(打开时可见跳动);只要某条步骤消息只携带工具调用头,聊天列就会把一次工具运行拆成好几组;工具行摘要打印以工作区为根的绝对路径,占掉行内大部分空间;运行中行的扫光效果用 alpha 遮罩实现,把整行都压暗;hero 区的工作区 chip 会从会话 cwd 里复现已删除工作区的文件夹名;标题栏还在 13px 的标题旁显示一个没人需要的轮次计数。
## Decision
本次修复全部为纯展示侧改动落地;不会有任何内容进入会话日志。
- **Portal 菜单先隐藏预渲染,绘制前完成测量。**菜单列表以 `visibility: hidden` 挂载在 (0,0),在 `useLayoutEffect` 中测量,显示时已处于最终位置。菜单与视口保持 12px 间距并支持内部滚动;工作区创建操作固定在不滚动的页脚区。
- **聊天流跳过不渲染任何内容的助手节点。**已定稿的助手节点若其块仅含工具调用头和空白的文本/推理(reasoning)内容,就会从流推导中剔除,于是连续的工具结果合并为一组。被中断的节点始终渲染(它们携带「已停止」标记)。
- **工具行摘要把以工作区为根的路径转为相对路径。**会话 cwd 经由 toolview 插槽契约(`ToolRowOwnerProps.cwd`)逐层传递,`toolRowModel` 从以其开头的摘要中剥去该前缀;工作区之外的路径保持原样。这只影响显示:工具参数与日志均不受影响。
- **运行中的扫光效果改为高光带叠加层。**一条固定宽度的 `::after` 渐变光带横向扫过整行(即 deepsuite 的 ShimmerText 模式),取代先前的 `mask-image` 方案,ToolRow 与 Bash toolview 两处均已替换。
- **hero 区的工作区 chip 是选择器,而非回显。**没有有效选中项时(冷启动,或列表稳定后工作区被删除),它显示「Choose workspace」占位文案;由 cwd 推导的名称只用于衔接列表的首次加载,待定选择对应的工作区从已就绪的列表中消失时,该陈旧选择会被清除。
- **统一为 16px 的纵向节奏。**聊天列间距与分组内工具行间距统一为 16px,取代原先「分组内 10px 间距加跨分组负外边距」的做法。
- **标题栏标题改为 14/20,去掉轮次计数**;StateDot 的进行中状态与轮次尾部采用逐格推进的像素追逐式加载视觉语言;`body` 启用灰度抗锯齿(`-webkit-font-smoothing` 及 Firefox 在 macOS 上的等价设置)。
## Alternatives considered
- **挂载前根据锚点矩形同步定位菜单。**不予采纳:列表自身尺寸在布局完成前无从得知,向视口内收拢仍然需要布局后测量;对已挂载的隐藏节点做测量正是 React 与 Floating UI 文档记载的模式。
- **在宿主侧过滤空的助手消息。**不予采纳:该节点是真实的模型输出,Trajectory 与回放都必须保留它;只有聊天展示应当跳过它,且按契约 web 层只负责呈现。
- **在每个工具各自的展示器中做路径相对化。**不予采纳:这种冗余是所有输出路径摘要的工具共有的;在 `toolRowModel` 里做一次仅影响显示的处理即可覆盖全部工具,非聊天消费方仍拿到绝对路径。
- **保留基于遮罩的扫光。**不予采纳:遮罩会把包括状态圆点在内的整行内容压暗,其退出过渡还与悬停图标的交叉淡入淡出相互冲突;叠加光带在内容之上合成,完全不触碰内容的 alpha。
- **让 chip 继续显示已删除工作区的名称。**不予采纳:chip 是为*下一个*会话服务的选择器;用户刚删掉某个工作区,还回显它的 cwd,就是在错误呈现当前的选择。
## Consequences
聊天渲染出的流条目数少于快照中的节点数:凡是拿渲染出的块与节点数对账的人,都必须把被跳过的「不渲染任何内容」的助手节点计算在内(chat-view 规格测试固定了这一点)。路径相对化只是针对会话 cwd 的前缀检查,因此会话中途重命名工作区后,摘要在重新推导前会显示绝对路径,这被接受为仅影响显示的陈旧状态。统一的 16px 节奏淘汰了原先更紧凑的 10px 工具运行外观;将来若要更紧凑的布局,应当有意识地重新引入第二个常量。菜单预渲染让每次打开多一次隐藏布局计算,在菜单的尺寸量级下开销可忽略。
## Testing
`chat-view.spec.tsx` 固定了「不渲染任何内容」节点的分组行为(含被中断节点这一例外);`chat-tool-row.spec.tsx` 固定了工作区内、工作区外以及 cwd 为空时的 cwd 相对化行为;`atoms.spec.tsx` 与 `workspace-picker.spec.tsx` 覆盖菜单与 chip 的各种状态;ui-conversation、ui-primitives 与 ui-workspace 的全量测试套件通过。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md
2026-07-16-persistent-pty-sessions.md: 55ab262b89526911e16a3fec5dd624fc42fb2222 2026-07-16-persistent-pty-sessions.md: 691ccd4341837d63bb48d27c2a3fb007657fe7ba
2026-07-16-persistent-pty-sessions.zh.md: 525d199298bc071c36444d689368317968186c9f 2026-07-16-persistent-pty-sessions.zh.md: 86d57f79ee74429542730ebcad60bc9f3c9f15ca

View File

@@ -74,7 +74,7 @@ With `run_in_background: true`, `dsh-tool-pty` registers the in-flight send on `
The local backend first recognizes a private OSC prompt marker emitted by its controlled bash startup, then requires printable prompt text after that marker before declaring prompt readiness and runs three bounded fallback tiers. Carrying that state across data callbacks covers macOS delivery where the OSC marker and `PS1` arrive separately; the marker alone can no longer publish an empty MOTD. The marker is removed before output reaches the model and avoids a fixed silence delay for ordinary shell commands on both platforms. Unpublished startup does not accept zero-output silence as readiness; timeout rejects the spawn. If caller cancellation wins during startup, the backend closes the private session and propagates the exact `AbortSignal.reason`; a foreground PGID that is not observable yet cannot replace cancellation with a lookup error. All timings are validated config fields: `pollIntervalMs`, `exactProbeAfterMs`, `idleSilenceMs`, `handoffGraceMs`, and `timeoutMs`. The local backend first recognizes a private OSC prompt marker emitted by its controlled bash startup, then requires printable prompt text after that marker before declaring prompt readiness and runs three bounded fallback tiers. Carrying that state across data callbacks covers macOS delivery where the OSC marker and `PS1` arrive separately; the marker alone can no longer publish an empty MOTD. The marker is removed before output reaches the model and avoids a fixed silence delay for ordinary shell commands on both platforms. Unpublished startup does not accept zero-output silence as readiness; timeout rejects the spawn. If caller cancellation wins during startup, the backend closes the private session and propagates the exact `AbortSignal.reason`; a foreground PGID that is not observable yet cannot replace cancellation with a lookup error. All timings are validated config fields: `pollIntervalMs`, `exactProbeAfterMs`, `idleSilenceMs`, `handoffGraceMs`, and `timeoutMs`.
On Linux, the inspector reads the shell's terminal foreground PGID from `/proc/<shellPid>/stat`, enumerates every process and thread in that process group, and probes their current syscalls. A positive Tier 1 result requires an observed stdin wait: direct `read(0)`, a permitted read of a `select`/`pselect6` or `poll`/`ppoll` argument containing fd 0, or an epoll interest list containing fd 0. Unreadable process memory and unrecognized syscalls are misses, never positive guesses. Architecture tables contain only syscall numbers defined by the corresponding Linux UAPI; unsupported architectures skip Tier 1. On Linux, the inspector reads the shell's terminal foreground PGID from `/proc/<shellPid>/stat`, enumerates every process and thread in that process group, and probes their current syscalls. A positive Tier 1 result requires an observed stdin wait: direct `read(0)`, a permitted read of a `select`/`pselect6` or `poll`/`ppoll` argument containing fd 0, or an epoll interest list containing fd 0. A wait already present before terminal input is not post-write readiness: the same PGID must be observed outside that wait before re-entering it, while a changed foreground PGID is new evidence. Unreadable process memory and unrecognized syscalls are misses, never positive guesses. Architecture tables contain only syscall numbers defined by the corresponding Linux UAPI; unsupported architectures skip Tier 1.
On macOS there is no exact syscall tier. Output silence returns `inferred_idle` for any foreground process group, including Python and `gdb`; `ps`-derived terminal PGID is used for signaling, not as proof that only the shell can be idle. Pure process-inspector logic is injectable and unit-tested on Linux, while a macOS CI job exercises the real PTY and process-table path. On macOS there is no exact syscall tier. Output silence returns `inferred_idle` for any foreground process group, including Python and `gdb`; `ps`-derived terminal PGID is used for signaling, not as proof that only the shell can be idle. Pure process-inspector logic is injectable and unit-tested on Linux, while a macOS CI job exercises the real PTY and process-table path.
@@ -155,7 +155,7 @@ The package ships concise tool guidance explaining persistent state, owner isola
## Verification ## Verification
- Per-file coverage pins owner fencing, concurrent reservations, unpublished-spawn cancellation and awaited teardown, sandbox-mode change rejection, retriable lifecycle cleanup, readiness tiers, the configured handoff grace holding the idle fallback past one poll and its rejection below `pollIntervalMs`, sanitizer carry state, complete UTF-8 bounds, task integration, schemas, and exact render intents. - Per-file coverage pins owner fencing, concurrent reservations, unpublished-spawn cancellation and awaited teardown, sandbox-mode change rejection, retriable lifecycle cleanup, readiness tiers, rejection of pre-write stdin waits, the configured handoff grace holding the idle fallback past one poll and its rejection below `pollIntervalMs`, sanitizer carry state, complete UTF-8 bounds, task integration, schemas, and exact render intents.
- Linux process fixtures cover non-leader and non-main-thread stdin waits, zombie quiescence, unreadable process state, supported syscall tables, unsupported architectures, and false-positive rejection; macOS inspector logic is injected into the same unit suite. - Linux process fixtures cover non-leader and non-main-thread stdin waits, zombie quiescence, unreadable process state, supported syscall tables, unsupported architectures, and false-positive rejection; macOS inspector logic is injected into the same unit suite.
- Real `node-pty` tests exercise shell state, shared sandbox policy, environment scrubbing, raw-mode foreground `SIGINT` after deliberately delayed child readiness under scenario-owned timing bounds, a TERM-ignoring descendant, and immediate post-disposal quiescence on supported hosts. - Real `node-pty` tests exercise shell state, shared sandbox policy, environment scrubbing, raw-mode foreground `SIGINT` after deliberately delayed child readiness under scenario-owned timing bounds, a TERM-ignoring descendant, and immediate post-disposal quiescence on supported hosts.
- A Loader-driven `cordis.yml` test mounts the real three-package composition. ACP and headless snapshots pin the six schemas, bounded results, and errors through opt-in overlays; TUI snapshots pin terminal and generic card presentation. - A Loader-driven `cordis.yml` test mounts the real three-package composition. ACP and headless snapshots pin the six schemas, bounded results, and errors through opt-in overlays; TUI snapshots pin terminal and generic card presentation.
@@ -168,7 +168,7 @@ The package ships concise tool guidance explaining persistent state, owner isola
**Idle below Linux Tier 1 is heuristic.** Output silence cannot distinguish a prompt from sleep or network I/O. The typed result preserves uncertainty, and bounded timeout plus task waiting and signaling keep control with the model. **Idle below Linux Tier 1 is heuristic.** Output silence cannot distinguish a prompt from sleep or network I/O. The typed result preserves uncertainty, and bounded timeout plus task waiting and signaling keep control with the model.
**The exact-versus-inferred boundary is a latency trade, not a solvable race.** Attribution depends on whether the kernel publishes the foreground handoff before or after the silence bound elapses, so any fixed grace is a scheduling bet. `handoffGraceMs` puts that bet in deployment configuration: raising it buys exact `stdin_read` attribution on a slow or loaded host at the cost of interactive return latency after a prompt marker, and lowering it does the reverse. Tests that must not depend on the winner assert the observable behavior — the next send runs — rather than the attribution. **The exact-versus-inferred boundary is a latency trade, not a solvable race.** Attribution depends on whether the kernel publishes the foreground handoff before or after the silence bound elapses, so any fixed grace is a scheduling bet. `handoffGraceMs` puts that bet in deployment configuration: raising it buys exact `stdin_read` attribution on a slow or loaded host at the cost of interactive return latency after a prompt marker, and lowering it does the reverse. Tests that must not depend on the winner assert child-produced output from the next send, using a token absent from echoed input, rather than the attribution.
**Persistent state can drift from the model's belief.** The model may forget its cwd or active REPL. Session summaries and retained output help recovery, but no prompt can make state persistence deterministic. **Persistent state can drift from the model's belief.** The model may forget its cwd or active REPL. Session summaries and retained output help recovery, but no prompt can make state persistence deterministic.

View File

@@ -74,7 +74,7 @@ UI 渲染契约精确且不携带位置信息。`terminal_send` 只为前台发
本地后端先识别受控 bash 启动时发出的私有 OSC prompt marker,并且只有在该 marker 后出现可打印的 prompt 文本时才据此声明 prompt 就绪;除此之外,它还运行 3 个有界 fallback 层级。在 data callback 之间保留这项状态,可以适配 macOS 分开交付 OSC marker 与 `PS1` 的情况;单独的 marker 不会发布空 MOTD。marker 在输出到达模型前被移除,使两个平台上的普通 shell 命令都无需固定等待静默阈值。尚未发布的 startup 不会把零输出静默视为就绪;timeout 会拒绝 spawn。若调用方取消在 startup 期间胜出,后端会关闭私有会话并原样抛出 `AbortSignal.reason`;尚不可观察的前台 PGID 不会再用查找错误覆盖取消原因。所有时间参数都是经校验的配置字段:`pollIntervalMs`、`exactProbeAfterMs`、`idleSilenceMs`、`handoffGraceMs` 和 `timeoutMs`。 本地后端先识别受控 bash 启动时发出的私有 OSC prompt marker,并且只有在该 marker 后出现可打印的 prompt 文本时才据此声明 prompt 就绪;除此之外,它还运行 3 个有界 fallback 层级。在 data callback 之间保留这项状态,可以适配 macOS 分开交付 OSC marker 与 `PS1` 的情况;单独的 marker 不会发布空 MOTD。marker 在输出到达模型前被移除,使两个平台上的普通 shell 命令都无需固定等待静默阈值。尚未发布的 startup 不会把零输出静默视为就绪;timeout 会拒绝 spawn。若调用方取消在 startup 期间胜出,后端会关闭私有会话并原样抛出 `AbortSignal.reason`;尚不可观察的前台 PGID 不会再用查找错误覆盖取消原因。所有时间参数都是经校验的配置字段:`pollIntervalMs`、`exactProbeAfterMs`、`idleSilenceMs`、`handoffGraceMs` 和 `timeoutMs`。
在 Linux 上,检查器从 `/proc/<shellPid>/stat` 读取 shell 的终端前台 PGID,枚举该进程组中的每个进程与线程,并检查它们当前的 syscall。Tier 1 只有观察到 stdin 等待才返回正结果:直接 `read(0)`、获准读取且含 fd 0 的 `select`/`pselect6` 或 `poll`/`ppoll` 参数,或者含 fd 0 的 epoll interest list。无法读取的进程内存和未识别的 syscall 都是 miss,绝不作为正向猜测。架构表只包含对应 Linux UAPI 定义的 syscall number;不支持的架构跳过 Tier 1。 在 Linux 上,检查器从 `/proc/<shellPid>/stat` 读取 shell 的终端前台 PGID,枚举该进程组中的每个进程与线程,并检查它们当前的 syscall。Tier 1 只有观察到 stdin 等待才返回正结果:直接 `read(0)`、获准读取且含 fd 0 的 `select`/`pselect6` 或 `poll`/`ppoll` 参数,或者含 fd 0 的 epoll interest list。终端输入前就已存在的等待并不代表写入后就绪:必须先观察到同一 PGID 脱离该等待,之后再次进入等待才能使该次 send 完成;前台 PGID 发生变化则构成新的证据。无法读取的进程内存和未识别的 syscall 都是 miss,绝不作为正向猜测。架构表只包含对应 Linux UAPI 定义的 syscall number;不支持的架构跳过 Tier 1。
macOS 没有精确 syscall 层。任何前台进程组输出静默都会返回 `inferred_idle`,包括 Python 和 `gdb`;从 `ps` 推导的终端 PGID 只用于发送信号,不作为「只有 shell 才能 idle」的证明。纯进程检查逻辑可注入并在 Linux 上完成 unit 覆盖率,同时由 macOS CI job 驱动真实 PTY 和进程表路径。 macOS 没有精确 syscall 层。任何前台进程组输出静默都会返回 `inferred_idle`,包括 Python 和 `gdb`;从 `ps` 推导的终端 PGID 只用于发送信号,不作为「只有 shell 才能 idle」的证明。纯进程检查逻辑可注入并在 Linux 上完成 unit 覆盖率,同时由 macOS CI job 驱动真实 PTY 和进程表路径。
@@ -155,7 +155,7 @@ plugins:
## 验证 ## 验证
- 每文件覆盖率固定 owner 隔离、并发预留、未发布 spawn 的取消与等待式 teardown、沙箱模式变更拒绝、可重试的生命周期清理、就绪层级、配置化交接宽限把 idle fallback 顶过一次轮询以及低于 `pollIntervalMs` 时的拒绝、sanitizer carry state、完整 UTF-8 结果上限、task 集成、schema 和精确 render intent。 - 每文件覆盖率固定 owner 隔离、并发预留、未发布 spawn 的取消与等待式 teardown、沙箱模式变更拒绝、可重试的生命周期清理、就绪层级、对写入前 stdin 等待的拒绝、配置化交接宽限把 idle fallback 顶过一次轮询以及低于 `pollIntervalMs` 时的拒绝、sanitizer carry state、完整 UTF-8 结果上限、task 集成、schema 和精确 render intent。
- Linux 进程 fixture 覆盖非 leader 与非主线程的 stdin 等待、僵尸进程静止性、不可读进程状态、受支持的 syscall 表、不支持的架构和误报拒绝;同一单元测试套件通过注入覆盖 macOS 检查器逻辑。 - Linux 进程 fixture 覆盖非 leader 与非主线程的 stdin 等待、僵尸进程静止性、不可读进程状态、受支持的 syscall 表、不支持的架构和误报拒绝;同一单元测试套件通过注入覆盖 macOS 检查器逻辑。
- 真实 `node-pty` 测试在受支持宿主上覆盖 shell 状态、共享沙箱策略、环境清洗、在由场景掌控的时间界限内先有意延迟子进程就绪,再对 raw mode 前台进程发送 `SIGINT`、忽略 `SIGTERM` 的子进程,以及 dispose 返回后立即完全停稳。 - 真实 `node-pty` 测试在受支持宿主上覆盖 shell 状态、共享沙箱策略、环境清洗、在由场景掌控的时间界限内先有意延迟子进程就绪,再对 raw mode 前台进程发送 `SIGINT`、忽略 `SIGTERM` 的子进程,以及 dispose 返回后立即完全停稳。
- Loader 驱动的 `cordis.yml` 测试挂载真实三包组合。ACP 与 headless 快照通过 opt-in overlay 固定 6 个 schema、有界结果和错误;TUI 快照固定 terminal 与 generic 卡片展示。 - Loader 驱动的 `cordis.yml` 测试挂载真实三包组合。ACP 与 headless 快照通过 opt-in overlay 固定 6 个 schema、有界结果和错误;TUI 快照固定 terminal 与 generic 卡片展示。
@@ -168,7 +168,7 @@ plugins:
**Linux Tier 1 之外的 idle 都是启发式结果。**输出静默无法区分 prompt、sleep 和网络 I/O。类型化结果保留不确定性,有界 timeout、task 等待与信号让模型仍能掌握控制权。 **Linux Tier 1 之外的 idle 都是启发式结果。**输出静默无法区分 prompt、sleep 和网络 I/O。类型化结果保留不确定性,有界 timeout、task 等待与信号让模型仍能掌握控制权。
**精确归因与推断归因的边界是延迟取舍,不是可消除的竞态。**归因取决于内核在静默上限到达之前还是之后发布前台交接,因此任何固定宽限都是一次调度上的赌注。`handoffGraceMs` 把这个赌注交给部署配置:调大它可以在慢速或高负载主机上换到精确的 `stdin_read` 归因,代价是见过 prompt marker 之后的交互返回延迟;调小则相反。不应依赖胜负结果的测试断言可观察行为——下一次 send 能正常执行——而不是断言归因路径。 **精确归因与推断归因的边界是延迟取舍,不是可消除的竞态。**归因取决于内核在静默上限到达之前还是之后发布前台交接,因此任何固定宽限都是一次调度上的赌注。`handoffGraceMs` 把这个赌注交给部署配置:调大它可以在慢速或高负载主机上换到精确的 `stdin_read` 归因,代价是见过 prompt marker 之后的交互返回延迟;调小则相反。不应依赖胜负结果的测试使用不会出现在输入回显中的 token,断言下一次 send 中由子进程产生的输出,而不是断言归因路径。
**持久状态可能偏离模型认知。**模型可能忘记 cwd 或活跃 REPL。会话摘要和保留输出有助恢复,但任何 prompt 都无法让状态持久化变成确定行为。 **持久状态可能偏离模型认知。**模型可能忘记 cwd 或活跃 REPL。会话摘要和保留输出有助恢复,但任何 prompt 都无法让状态持久化变成确定行为。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md
2026-07-21-serial-cross-platform-ci-reference.md: 9ecd7906d2f28461a8a6f3f2fe485580214b39f3 2026-07-21-serial-cross-platform-ci-reference.md: 71b364f72a094f7899a0929eec29f3a16ccc8a93
2026-07-21-serial-cross-platform-ci-reference.zh.md: ed927012c5b0ba9d43444a129d10d966f5e7d923 2026-07-21-serial-cross-platform-ci-reference.zh.md: 0f324a5c235a2b495186caf0d6cc0490cc5876e3

View File

@@ -12,6 +12,8 @@ Encoding the one-minute non-Windows target and three-minute Windows target as jo
Reviewers also need a direct answer to a simpler question: what happens when the repository's complete primary Node CI aggregate runs without matrix selection, shard variables, or concurrent gates on each selected hosted operating system? Reviewers also need a direct answer to a simpler question: what happens when the repository's complete primary Node CI aggregate runs without matrix selection, shard variables, or concurrent gates on each selected hosted operating system?
Real-kernel sandbox proofs require specific hosted operating systems and architectures but do not provide a pull-request merge verdict. Repeating that four-job matrix for every pull request consumes Linux, arm64 Linux, and macOS capacity without satisfying branch protection or contributing to the required aggregate in another workflow.
## Decision ## Decision
[CI](../../../../.github/workflows/ci.yml) gives pull-request and master-push events complementary responsibilities. Pull requests run consolidated Linux and Windows jobs plus the Node compatibility and Python contracts on standard GitHub-hosted capacity. A push to `master` skips those jobs and runs four explicit references: `serial / linux`, `serial / macos`, and `serial / windows` on standard hosted runners, plus `serial / linux (self-hosted standby)` on the in-house `vm-backup` pool — the hot-standby drill that continuously re-proves the failover target described in the [failover runbook](2026-07-26-ci-failover-runbook.md). They intentionally duplicate their short checkout, runtime setup, and immutable install sequences instead of hiding the operating systems behind a matrix or reusable workflow. `workflow_dispatch` is reserved for runner benchmarks. [CI](../../../../.github/workflows/ci.yml) gives pull-request and master-push events complementary responsibilities. Pull requests run consolidated Linux and Windows jobs plus the Node compatibility and Python contracts on standard GitHub-hosted capacity. A push to `master` skips those jobs and runs four explicit references: `serial / linux`, `serial / macos`, and `serial / windows` on standard hosted runners, plus `serial / linux (self-hosted standby)` on the in-house `vm-backup` pool — the hot-standby drill that continuously re-proves the failover target described in the [failover runbook](2026-07-26-ci-failover-runbook.md). They intentionally duplicate their short checkout, runtime setup, and immutable install sequences instead of hiding the operating systems behind a matrix or reusable workflow. `workflow_dispatch` is reserved for runner benchmarks.
@@ -22,7 +24,9 @@ Platform ownership remains explicit inside that complete aggregate. `pty-local`
The macOS reference runs the ordinary Vitest project in forked processes. Node 24 on macOS arm64 has aborted in its CJS lexer from a worker thread; the process boundary contains that external runtime failure without removing any test from the aggregate, while Linux and Windows retain the lower-overhead thread pool. Repository-owned races are fixed at their observation boundaries: dev bundle polling stages each candidate table, graph, and watch-baseline map before publishing a rescan, and a missing bundle remains dirty until a successful content hash. PTY readiness retains a prompt candidate while polling checks foreground ownership; the ordinary silence bound covers inherited markers from interactive children. Real PTY fixtures assemble synchronization tokens at runtime so the interactive shell's input echo cannot satisfy a child-readiness wait. The live-link package-manager e2e preserves the workflow-prepared Corepack home and pnpm metadata/store caches while isolating the other managers' mutable caches, so it does not discard reusable package-manager state before the install. The macOS reference runs the ordinary Vitest project in forked processes. Node 24 on macOS arm64 has aborted in its CJS lexer from a worker thread; the process boundary contains that external runtime failure without removing any test from the aggregate, while Linux and Windows retain the lower-overhead thread pool. Repository-owned races are fixed at their observation boundaries: dev bundle polling stages each candidate table, graph, and watch-baseline map before publishing a rescan, and a missing bundle remains dirty until a successful content hash. PTY readiness retains a prompt candidate while polling checks foreground ownership; the ordinary silence bound covers inherited markers from interactive children. Real PTY fixtures assemble synchronization tokens at runtime so the interactive shell's input echo cannot satisfy a child-readiness wait. The live-link package-manager e2e preserves the workflow-prepared Corepack home and pnpm metadata/store caches while isolating the other managers' mutable caches, so it does not discard reusable package-manager state before the install.
Master reference jobs are diagnostic and do not participate in the pull request's required `all checks passed` result. A pull request runs only its required jobs; a master push runs only the serial references. Performance is evaluated from completed hosted-job timestamps and reported as a measurement; it is not encoded as a `timeout-minutes` value. The standalone [Sandbox](../../../../.github/workflows/sandbox.yml) workflow belongs to the reference side of the same split. Its bwrap, Landlock x64/arm64, and Seatbelt real-kernel matrix runs only after a push to `master`. Those four jobs are diagnostic: they are not branch-protection requirements and do not feed `all checks passed` across workflow files. Pull-request CI still checks sandbox source through its ordinary unit and coverage inventory; the host-kernel and packed-install proofs report after merge.
Master reference jobs are diagnostic and do not participate in the pull request's required `all checks passed` result. The CI and Sandbox workflows keep their cross-platform references on master pushes. Performance is evaluated from completed hosted-job timestamps and reported as a measurement; it is not encoded as a `timeout-minutes` value.
The portable reference uses GitHub's standard `ubuntu-latest`, `macos-latest`, and `windows-2025` labels; `serial / windows` is the one remaining native-Windows job, the complete-kernel oracle behind the Wine-hosted pull-request lane ([Wine lane decision](2026-07-27-wine-windows-gates-experiment.md)). Required pull-request jobs use portable standard capacity under the [required-CI decision](2026-07-23-portable-required-pull-request-ci.md). Higher-core hosted runners remain manual benchmarks because a correctness path must remain runnable without repository-external runner configuration. The portable reference uses GitHub's standard `ubuntu-latest`, `macos-latest`, and `windows-2025` labels; `serial / windows` is the one remaining native-Windows job, the complete-kernel oracle behind the Wine-hosted pull-request lane ([Wine lane decision](2026-07-27-wine-windows-gates-experiment.md)). Required pull-request jobs use portable standard capacity under the [required-CI decision](2026-07-23-portable-required-pull-request-ci.md). Higher-core hosted runners remain manual benchmarks because a correctness path must remain runnable without repository-external runner configuration.
@@ -31,6 +35,7 @@ The portable reference uses GitHub's standard `ubuntu-latest`, `macos-latest`, a
- **Set each timeout equal to its latency target** - rejected because scheduling variance would cancel correct work and suppress the evidence needed to diagnose a regression. - **Set each timeout equal to its latency target** - rejected because scheduling variance would cancel correct work and suppress the evidence needed to diagnose a regression.
- **Trust only the concurrent primary inventory** - rejected because scheduling and validation share implementation assumptions; a serial aggregate is an independent completeness check. - **Trust only the concurrent primary inventory** - rejected because scheduling and validation share implementation assumptions; a serial aggregate is an independent completeness check.
- **Run the serial references on every pull request** - rejected because they duplicate complete cross-platform aggregates and add macOS work to every change; the required jobs already execute the blocking Linux and Windows contracts. - **Run the serial references on every pull request** - rejected because they duplicate complete cross-platform aggregates and add macOS work to every change; the required jobs already execute the blocking Linux and Windows contracts.
- **Run the real-kernel Sandbox matrix on every pull request** - rejected because its four statuses do not participate in branch protection, while repeated installs, Landlock builds, and macOS unit parity consume runner capacity without changing the merge verdict. The master run retains the platform and installed-launcher signal.
- **Use one operating-system matrix** - rejected because three named jobs make the reference surface visible without another selection mechanism. - **Use one operating-system matrix** - rejected because three named jobs make the reference surface visible without another selection mechanism.
- **Run the serial reference on larger runners** - rejected because both required CI and its independent reference must remain runnable when organization-owned pools cannot allocate jobs. - **Run the serial reference on larger runners** - rejected because both required CI and its independent reference must remain runnable when organization-owned pools cannot allocate jobs.
@@ -40,6 +45,8 @@ The workflow contains duplicated setup steps and a master reference run can take
The reference may expose platform failures that the optimized blocking set does not yet claim to support, especially on Windows. Such a failure is evidence about current cross-platform behavior rather than a reason to weaken or silently skip the aggregate. The reference may expose platform failures that the optimized blocking set does not yet claim to support, especially on Windows. Such a failure is evidence about current cross-platform behavior rather than a reason to weaken or silently skip the aggregate.
A sandbox regression visible only to a real host kernel or the packed Landlock install can merge before the master run reports it. That post-merge detection window is accepted in exchange for removing four non-blocking jobs from every pull request; the default branch retains the complete signal.
The explicit `pty-local` ownership boundary means Windows does not claim coverage for a backend it cannot load, and forked macOS unit workers cost more process startup time. In return, every supported surface has an honest platform oracle, a native runtime abort cannot erase the rest of the unit result, and timing-sensitive observers start from state established before callers can mutate it. The explicit `pty-local` ownership boundary means Windows does not claim coverage for a backend it cannot load, and forked macOS unit workers cost more process startup time. In return, every supported surface has an honest platform oracle, a native runtime abort cannot erase the rest of the unit result, and timing-sensitive observers start from state established before callers can mutate it.
Removing strict duration timeouts means a latency regression is observed rather than automatically cancelled. Hosted measurements must therefore accompany performance changes, while the completed logs retain the information needed to optimize the slow lane. Removing strict duration timeouts means a latency regression is observed rather than automatically cancelled. Hosted measurements must therefore accompany performance changes, while the completed logs retain the information needed to optimize the slow lane.

View File

@@ -12,6 +12,8 @@ Status: implemented
评审人还需要直接回答一个更简单的问题:在每个选定的托管操作系统上,如果仓库完整的主 Node CI 聚合流程不使用矩阵选择、分片变量或并发门禁,运行结果会怎样? 评审人还需要直接回答一个更简单的问题:在每个选定的托管操作系统上,如果仓库完整的主 Node CI 聚合流程不使用矩阵选择、分片变量或并发门禁,运行结果会怎样?
真实内核沙箱验证需要特定的托管操作系统和架构,但不会产生拉取请求的合并裁决。在每个拉取请求上重复运行这个包含四个作业的矩阵会消耗 Linux、arm64 Linux 和 macOS 容量,却既不能满足分支保护,也无法参与另一工作流中的必需聚合结果。
## 决策 ## 决策
[CI](../../../../.github/workflows/ci.yml) 为拉取请求事件与 master 推送事件赋予互补的职责。拉取请求在 GitHub 标准托管容量上运行合并后的 Linux 和 Windows 作业,以及 Node 兼容性与 Python 契约。向 `master` 推送时会跳过这些作业,改为运行四个显式参考作业:在标准托管运行器上的 `serial / linux`、`serial / macos` 和 `serial / windows`,以及在公司自有 `vm-backup` 池上的 `serial / linux (self-hosted standby)`——后者是热备演练,持续验证[故障切换手册](2026-07-26-ci-failover-runbook.md)所描述的切换目标。这些作业有意分别重复简短的代码检出、运行时设置和依赖锁定的安装步骤,不用矩阵或可复用工作流把操作系统差异隐藏起来。`workflow_dispatch` 仅用于运行器基准测试。 [CI](../../../../.github/workflows/ci.yml) 为拉取请求事件与 master 推送事件赋予互补的职责。拉取请求在 GitHub 标准托管容量上运行合并后的 Linux 和 Windows 作业,以及 Node 兼容性与 Python 契约。向 `master` 推送时会跳过这些作业,改为运行四个显式参考作业:在标准托管运行器上的 `serial / linux`、`serial / macos` 和 `serial / windows`,以及在公司自有 `vm-backup` 池上的 `serial / linux (self-hosted standby)`——后者是热备演练,持续验证[故障切换手册](2026-07-26-ci-failover-runbook.md)所描述的切换目标。这些作业有意分别重复简短的代码检出、运行时设置和依赖锁定的安装步骤,不用矩阵或可复用工作流把操作系统差异隐藏起来。`workflow_dispatch` 仅用于运行器基准测试。
@@ -22,7 +24,9 @@ Status: implemented
macOS 参考流程使用 fork 进程运行常规 Vitest 项目。macOS arm64 上的 Node 24 曾在工作线程中执行 CJS 词法分析器时异常终止;进程边界能够隔离这一外部运行时故障,且无需从聚合流程中删除任何测试,而 Linux 与 Windows 仍使用开销更低的线程池。仓库自身引入的竞态均在相应的观测边界修复:开发构建产物的轮询逻辑每次发布重新扫描结果前,都会先暂存候选表、候选图和候选监视基线映射;构建产物缺失后会一直保持脏状态,直到成功计算内容哈希。PTY 就绪检测会在轮询检查前台进程组归属期间保留提示符候选项;常规静默时限也适用于交互式子进程继承提示符标记的情况。真实 PTY fixture 会在运行时拼接同步标记,使就绪等待逻辑不会把交互式 shell 的输入回显误判为子进程已就绪。实时链接场景下的包管理器 e2e 会保留由工作流预先准备的 Corepack 主目录、pnpm 元数据缓存和 store 缓存,同时隔离其他包管理器的可变缓存,因此不会在安装前丢弃可复用的包管理器状态。 macOS 参考流程使用 fork 进程运行常规 Vitest 项目。macOS arm64 上的 Node 24 曾在工作线程中执行 CJS 词法分析器时异常终止;进程边界能够隔离这一外部运行时故障,且无需从聚合流程中删除任何测试,而 Linux 与 Windows 仍使用开销更低的线程池。仓库自身引入的竞态均在相应的观测边界修复:开发构建产物的轮询逻辑每次发布重新扫描结果前,都会先暂存候选表、候选图和候选监视基线映射;构建产物缺失后会一直保持脏状态,直到成功计算内容哈希。PTY 就绪检测会在轮询检查前台进程组归属期间保留提示符候选项;常规静默时限也适用于交互式子进程继承提示符标记的情况。真实 PTY fixture 会在运行时拼接同步标记,使就绪等待逻辑不会把交互式 shell 的输入回显误判为子进程已就绪。实时链接场景下的包管理器 e2e 会保留由工作流预先准备的 Corepack 主目录、pnpm 元数据缓存和 store 缓存,同时隔离其他包管理器的可变缓存,因此不会在安装前丢弃可复用的包管理器状态。
master 分支的参考作业仅用于诊断,不参与拉取请求所要求的 `all checks passed` 结果。拉取请求只运行其必需作业;向 master 推送时只运行串行参考作业。系统根据已完成托管作业的时间戳评估性能,并将其报告为测量结果,而不是写成 `timeout-minutes` 值。 独立的 [Sandbox](../../../../.github/workflows/sandbox.yml) 工作流属于同一职责划分中的参考侧。其 bwrap、Landlock x64/arm64 与 Seatbelt 真实内核矩阵只在向 `master` 推送后运行。这四个作业仅用于诊断:它们既不是分支保护的必需项,也不会跨工作流计入 `all checks passed`。拉取请求 CI 仍通过常规的单元测试与覆盖率清单检查沙箱源码;宿主内核与 packed-install 验证在合并后报告结果。
master 分支的参考作业仅用于诊断,不参与拉取请求所要求的 `all checks passed` 结果。CI 与 Sandbox 工作流把跨平台参考流程保留在 master 推送上。系统根据已完成托管作业的时间戳评估性能,并将其报告为测量结果,而不是写成 `timeout-minutes` 值。
可移植的参考流程使用 GitHub 标准的 `ubuntu-latest`、`macos-latest` 和 `windows-2025` 标签;`serial / windows` 是仅存的原生 Windows 作业,是 Wine 托管拉取请求通道背后的完整内核标尺([Wine 通道决策](2026-07-27-wine-windows-gates-experiment.md))。依据[必需 CI 决策](2026-07-23-portable-required-pull-request-ci.md),拉取请求必需作业使用可移植的标准容量。更高核心数的托管运行器仍仅用于手动基准测试,因为正确性路径必须无需仓库外部的运行器配置即可运行。 可移植的参考流程使用 GitHub 标准的 `ubuntu-latest`、`macos-latest` 和 `windows-2025` 标签;`serial / windows` 是仅存的原生 Windows 作业,是 Wine 托管拉取请求通道背后的完整内核标尺([Wine 通道决策](2026-07-27-wine-windows-gates-experiment.md))。依据[必需 CI 决策](2026-07-23-portable-required-pull-request-ci.md),拉取请求必需作业使用可移植的标准容量。更高核心数的托管运行器仍仅用于手动基准测试,因为正确性路径必须无需仓库外部的运行器配置即可运行。
@@ -31,6 +35,7 @@ master 分支的参考作业仅用于诊断,不参与拉取请求所要求的
- **将每个超时值设为相应延迟目标**:不予采纳,因为调度波动会中止原本正确的执行,并使诊断回归所需的证据无法产生。 - **将每个超时值设为相应延迟目标**:不予采纳,因为调度波动会中止原本正确的执行,并使诊断回归所需的证据无法产生。
- **仅信任并发执行的主门禁清单**:不予采纳,因为调度逻辑与校验逻辑共享实现假设;串行聚合流程是一项独立的完整性检查。 - **仅信任并发执行的主门禁清单**:不予采纳,因为调度逻辑与校验逻辑共享实现假设;串行聚合流程是一项独立的完整性检查。
- **在每个拉取请求上运行串行参考作业**:不予采纳,因为这些作业会重复完整的跨平台聚合流程,并为每项改动增加 macOS 工作;必需作业已经执行阻塞性的 Linux 和 Windows 契约。 - **在每个拉取请求上运行串行参考作业**:不予采纳,因为这些作业会重复完整的跨平台聚合流程,并为每项改动增加 macOS 工作;必需作业已经执行阻塞性的 Linux 和 Windows 契约。
- **在每个拉取请求上运行真实内核 Sandbox 矩阵**:不予采纳,因为它的四个状态不参与分支保护,而重复安装、Landlock 构建以及为保持平台一致而运行的 macOS 单元测试会消耗运行器容量,却不会改变合并裁决。master 上的运行保留平台与已安装 launcher 的信号。
- **使用一个操作系统矩阵**:不予采纳,因为三个具名作业无需另一套选择机制,就能让参考流程的构成清晰可见。 - **使用一个操作系统矩阵**:不予采纳,因为三个具名作业无需另一套选择机制,就能让参考流程的构成清晰可见。
- **在大型运行器上运行串行参考流程**:不予采纳,因为当组织自有运行器池无法分配作业时,必需 CI 及其独立参考流程都必须仍可运行。 - **在大型运行器上运行串行参考流程**:不予采纳,因为当组织自有运行器池无法分配作业时,必需 CI 及其独立参考流程都必须仍可运行。
@@ -40,6 +45,8 @@ master 分支的参考作业仅用于诊断,不参与拉取请求所要求的
参考流程可能暴露某些平台上的故障,而优化后的阻塞门禁集合尚未声明支持这些平台,Windows 尤其如此。这类失败反映了当前的跨平台行为,不应成为削弱或静默跳过该聚合流程的理由。 参考流程可能暴露某些平台上的故障,而优化后的阻塞门禁集合尚未声明支持这些平台,Windows 尤其如此。这类失败反映了当前的跨平台行为,不应成为削弱或静默跳过该聚合流程的理由。
仅在真实宿主内核或打包后的 Landlock 安装中可见的沙箱回归,可能在 master 上的运行报告前已经合并。我们接受这个合并后检测窗口,以换取从每个拉取请求中移除四个非阻塞作业;默认分支仍保留完整信号。
明确的 `pty-local` 归属边界意味着 Windows 不会声称覆盖一个无法加载的后端,而 macOS 采用 fork 的单元测试工作进程会增加进程启动开销。这些代价换来的是:支持范围内的每项功能都有能够如实反映对应平台行为的判据,原生运行时异常终止不会抹掉其余单元测试结果,各项对时序敏感的观测逻辑也都会以调用方有机会修改状态前已建立的状态作为起点。 明确的 `pty-local` 归属边界意味着 Windows 不会声称覆盖一个无法加载的后端,而 macOS 采用 fork 的单元测试工作进程会增加进程启动开销。这些代价换来的是:支持范围内的每项功能都有能够如实反映对应平台行为的判据,原生运行时异常终止不会抹掉其余单元测试结果,各项对时序敏感的观测逻辑也都会以调用方有机会修改状态前已建立的状态作为起点。
移除严格的时长超时后,系统会观测到延迟回归,而不是在发生回归时自动取消运行。因此,性能改动必须附带托管环境测量结果,已完成的日志则保留优化最慢通道所需的信息。 移除严格的时长超时后,系统会观测到延迟回归,而不是在发生回归时自动取消运行。因此,性能改动必须附带托管环境测量结果,已完成的日志则保留优化最慢通道所需的信息。

View File

@@ -1,15 +1,16 @@
# Sandbox CI: the keyless real-kernel confinement proofs. A separate workflow # Sandbox CI: the keyless real-kernel confinement proofs. This master-only
# from ci.yml because the axis is different — these jobs fan out over # reference stays outside the pull-request verdict; rationale lives in
# OS×runner (kernel capabilities), not node versions. The Landlock launcher # .agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md.
# arrives from the registry with `pnpm install` (the npm package family # A separate workflow from ci.yml because the axis is different — these jobs
# fan out over OS×runner (kernel capabilities), not node versions. The Landlock
# launcher arrives from the registry with `pnpm install` (the npm package family
# `node-addon-landlock-run`, built and released from its own repository), so # `node-addon-landlock-run`, built and released from its own repository), so
# these legs exercise the true consumer path — nothing is compiled here. # these legs exercise the true consumer path — nothing is compiled here.
name: Sandbox name: Sandbox
on: on:
push: push:
branches: [main, master] branches: [master]
pull_request:
concurrency: concurrency:
group: ${{ github.workflow }}-${{ github.ref }} group: ${{ github.workflow }}-${{ github.ref }}

View File

@@ -165,7 +165,7 @@ it('locks the composer in the New Session view state until a Workspace is chosen
sidebar: visibleText(tree), sidebar: visibleText(tree),
}).toMatchInlineSnapshot(` }).toMatchInlineSnapshot(`
{ {
"chip": "New Workspace", "chip": "Choose workspace",
"composerDisabled": true, "composerDisabled": true,
"headline": "Let's start building", "headline": "Let's start building",
"sendDisabled": true, "sendDisabled": true,
@@ -232,7 +232,10 @@ it('New Session reuses the Workspace blank session and converts the single visib
// New Session resolves through the recent Workspace and reuses its blank // New Session resolves through the recent Workspace and reuses its blank
// session in place: no locked interlude, no second entity. // session in place: no locked interlude, no second entity.
fireEvent.click(screen.getByRole('button', { name: 'New session' })) const newSessionButton = screen.getAllByRole('button', { name: 'New session' })
.find(button => visibleText(button) === 'New Session')
if (newSessionButton === undefined) throw new Error('New Session button missing')
fireEvent.click(newSessionButton)
const composer = await findHeroComposer() const composer = await findHeroComposer()
await waitFor(() => { expect(within(tree).getByText('1 session')).toBeDefined() }, { timeout: 10_000 }) await waitFor(() => { expect(within(tree).getByText('1 session')).toBeDefined() }, { timeout: 10_000 })

View File

@@ -658,7 +658,7 @@ Applies one command claim to the scoped Input. Dispatched with the session's sco
'slash/input-begin-command'(request: BeginCommandRequest): true | undefined 'slash/input-begin-command'(request: BeginCommandRequest): true | undefined
``` ```
Source: [`packages/client/ui-slash/src/types.ts:220`](../../packages/client/ui-slash/src/types.ts) Source: [`packages/client/ui-slash/src/types.ts:230`](../../packages/client/ui-slash/src/types.ts)
### `slash/input-consume-token` — bail ### `slash/input-consume-token` — bail
@@ -674,7 +674,7 @@ Consumes one command token after business success (popup settle / menu-pick exec
'slash/input-consume-token'(request: ConsumeTokenRequest): true | undefined 'slash/input-consume-token'(request: ConsumeTokenRequest): true | undefined
``` ```
Source: [`packages/client/ui-slash/src/types.ts:234`](../../packages/client/ui-slash/src/types.ts) Source: [`packages/client/ui-slash/src/types.ts:244`](../../packages/client/ui-slash/src/types.ts)
### `slash/input-insert-reference` — bail ### `slash/input-insert-reference` — bail
@@ -690,7 +690,7 @@ Inserts one reference into the scoped Input (same carrier routing and applied-tr
'slash/input-insert-reference'(request: InsertReferenceRequest): true | undefined 'slash/input-insert-reference'(request: InsertReferenceRequest): true | undefined
``` ```
Source: [`packages/client/ui-slash/src/types.ts:227`](../../packages/client/ui-slash/src/types.ts) Source: [`packages/client/ui-slash/src/types.ts:237`](../../packages/client/ui-slash/src/types.ts)
### `slash/input-insert-text` — bail ### `slash/input-insert-text` — bail
@@ -707,7 +707,7 @@ Replaces the trigger token span with literal text — the plain-text reference p
'slash/input-insert-text'(request: InsertTextRequest): true | undefined 'slash/input-insert-text'(request: InsertTextRequest): true | undefined
``` ```
Source: [`packages/client/ui-slash/src/types.ts:242`](../../packages/client/ui-slash/src/types.ts) Source: [`packages/client/ui-slash/src/types.ts:252`](../../packages/client/ui-slash/src/types.ts)
## `subagent/*` ## `subagent/*`

View File

@@ -35,10 +35,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:80`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:80`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) |
| `session/event` | `emit` | [`packages/core/session/src/index.ts:92`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | | `session/event` | `emit` | [`packages/core/session/src/index.ts:92`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) |
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:102`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:102`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry) |
| `slash/input-begin-command` | `bail` | [`packages/client/ui-slash/src/types.ts:220`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | | `slash/input-begin-command` | `bail` | [`packages/client/ui-slash/src/types.ts:230`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` |
| `slash/input-consume-token` | `bail` | [`packages/client/ui-slash/src/types.ts:234`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | | `slash/input-consume-token` | `bail` | [`packages/client/ui-slash/src/types.ts:244`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` |
| `slash/input-insert-reference` | `bail` | [`packages/client/ui-slash/src/types.ts:227`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | | `slash/input-insert-reference` | `bail` | [`packages/client/ui-slash/src/types.ts:237`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` |
| `slash/input-insert-text` | `bail` | [`packages/client/ui-slash/src/types.ts:242`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | | `slash/input-insert-text` | `bail` | [`packages/client/ui-slash/src/types.ts:252`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` |
| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:140`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc), [`subagent`](../packages/subagent/subagent) | | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:140`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc), [`subagent`](../packages/subagent/subagent) |
| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:114`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:114`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:120`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | | `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:120`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |

View File

@@ -11,10 +11,21 @@ The [slot system standard](../../.agents/notes/implemented/architecture/2026-07-
1. **One API**: a plugin composes UI only through `ctx.slots.register({ name, children?, store?, inject? }, Component)`. There is no separate slot-definition call, no whitelist face object, no face-minting helper. The shell alone renders `'root'`. 1. **One API**: a plugin composes UI only through `ctx.slots.register({ name, children?, store?, inject? }, Component)`. There is no separate slot-definition call, no whitelist face object, no face-minting helper. The shell alone renders `'root'`.
2. **children = declaration + authorization**: the slots your component renders are exactly the keys of your register call's `children` object (spec values: `kind`/`scope`). Rendering a slot you didn't declare, or declaring one someone else declared, fails at load — do not work around it; the conflict is the design speaking. Slot names mirror the composition path: `<domain>.<entry>.<hole>` (e.g. `'conversation.chat.toolview'`). 2. **children = declaration + authorization**: the slots your component renders are exactly the keys of your register call's `children` object (spec values: `kind`/`scope`). Rendering a slot you didn't declare, or declaring one someone else declared, fails at load — do not work around it; the conflict is the design speaking. Slot names mirror the composition path: `<domain>.<entry>.<hole>` (e.g. `'conversation.chat.toolview'`).
3. **Component props are the four shares, all derived**: `PropsRuntime<K>` (SlotMap: owner params + `useSession`/`sessionId` on session scope + global `useSessions`/`useWorkspaces`) & `PropsRenderSlots<S>` (children keys) & `PropsStore<H>` (store factory) & the inject face. Never hand-write a member a share already derives; never re-type a share locally. 3. **Component props are the four shares, all derived**: `PropsRuntime<K>` (SlotMap: owner params + `useSession`/`sessionId` on session scope + global `useSessions`/`useWorkspaces`) & `PropsRenderSlots<S>` (children keys) & `PropsStore<H>` (store factory) & the inject face. Never hand-write a member a share already derives; never re-type a share locally.
4. **Hooks are framework-made only**: `useSession`, `useSessions`, `useWorkspaces`, `useStore`, `renderSlot` are the five seats. Business code never creates a hook or selector as a prop value — pass plain data and callbacks. (Component-internal behavioral hooks that subscribe to nothing external are fine.) 4. **Hooks are framework-made only**: `useSession`, `useSessions`, `useWorkspaces`, `useStore`, `renderSlot` are the five standing seats, plus the `use<Name>` hooks the renderer binds from provide contributions and inject `hooks` compartments. Business code never creates a hook or selector as a prop value — pass plain data and callbacks. (Component-internal behavioral hooks that subscribe to nothing external are fine.)
5. **Live data has exactly three channels**: parent knows it → owner props at the renderSlot site; only the component knows it → local state; shared across entries or survives remounts → a store declared at register. Derived data is a pure function over framework-hook data (`useMemo`), never its own subscription. 5. **Live data has exactly three channels**: parent knows it → owner props at the renderSlot site; only the component knows it → local state; shared across entries or survives remounts → a store declared at register. Derived data is a pure function over framework-hook data (`useMemo`), never its own subscription.
6. **Stores: read `props.useStore`, write `props.actions.*`** — the declared actions are the complete mutation surface. Write the store as an exported `createXXXStore()` factory (module-level handles are forbidden — de-facto singletons); share by passing one handle to several registers inside `apply`. Production code never calls the factory or `.create()` outside `apply`; tests do (that is the sanctioned zero-machinery path). 6. **Stores: read `props.useStore`, write `props.actions.*`** — the declared actions are the complete mutation surface. Write the store as an exported `createXXXStore()` factory (module-level handles are forbidden — de-facto singletons); share by passing one handle to several registers inside `apply`. Production code never calls the factory or `.create()` outside `apply`; tests do (that is the sanctioned zero-machinery path).
7. **inject returns plain data and callbacks** from the apply closure's own ctx — no hooks, no ReactNode producers, no whole-service objects. Its capability boundary is the plugin's declared `inject` topology; there is no wider ctx to reach for. 7. **inject returns plain data and callbacks** from the apply closure's own ctx — no hand-made hooks, no ReactNode producers, no whole-service objects. A registrant-private reactive fact rides the reserved `hooks` compartment (bare observables the renderer binds to `use<Name>`; components never see the sources). Its capability boundary is the plugin's declared `inject` topology; there is no wider ctx to reach for.
## Reactive read and contract-currency discipline
How live data reaches render code, and what may cross a business boundary:
1. **Everything a render reads that can change outside React arrives through a framework hook** (rule 4 above). Event-handler code may read live snapshots (e.g. `keyboard.snapshot`); render code subscribes.
2. **Business components contain no subscription machinery** — no `useSyncExternalStore`, no manual subscribe wiring, no mirroring an external snapshot into local state or a second store. Give each reactive fact its owning channel instead: registrant-private → the inject `hooks` compartment; cross-entry or remount-surviving → a declared store; per-session standard → `sessions.provide`.
3. **Data-access ladder** — resolve needs in this order: framework hooks (standing seats + provide/inject-bound `use<Name>`) → a declared store (`useStore`/`actions`) → inject callbacks → anything else is a new framework seam and needs main-thread arbitration.
4. **Contract currency is JSON-able data and callbacks.** Everything crossing a business boundary (owner props, inject faces, store state, provide contributions) is plain serializable data or a callback over such data; the inject `hooks` compartment is the one sanctioned carrier of bare observables, and components never see those either. ReactNode is not a currency: route render content through a slot; no new ReactNode-valued owner props or inject members (the composer's existing `accessory`/`overlay`/`leftItems`/`rightItems` seats are grandfathered and get migrated to slots progressively).
5. **An observable source keeps two identities stable**: the source object itself (hook binding is cached per source), and its snapshot between changes (`getSnapshot` returns the same reference until the fact moves).
6. **Whoever rebuilds a published value republishes it through the same source in the same step**, and a registration path that can run after consumers exist notifies the live consumers as part of registering.
## Export discipline (client plugin packages) ## Export discipline (client plugin packages)

View File

@@ -42,6 +42,10 @@
cursor: pointer; cursor: pointer;
} }
.selector:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.chevron { .chevron {
flex: none; flex: none;
} }

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/runtime/README.md # pnpm run verify-translation-pairing --write packages/client/runtime/README.md
README.md: 81261945cb2fd8b15f7c2f15cb1ae0b8e9928499 README.md: 16c1124ec812b9f030ce8266a16cdb8f5db0e6cc
README.zh.md: cbbf6eded4a5375223791275f26f3bc7b6553200 README.zh.md: a3d2a2dfdd1662afee65ec45e26b1ef1029f44b5

View File

@@ -39,5 +39,5 @@ Changing the target can change or invalidate provider-side cache reuse; this pac
## Known Limitations and Deferred Work ## Known Limitations and Deferred Work
- **`loader.unload` is a stub (throws not-implemented)** — the full chain (fiber dispose → registration cascade → style removal) lands with the HMR project. - **`loader.unload` is a stub (throws not-implemented)** — the full chain (fiber dispose → registration cascade → style removal) lands with the HMR project.
- **Scope teardown is stage-driven, single-occupant today** — the staged session follows `list.current` exactly (staging is the open signal: the event window opens ⟺ the session is on stage); a removed-while-staged session's scope survives frozen until the stage moves on, not until true observer count reaches zero. Resolution (`provideInfo()`/`binding()`/`scope()`) is pure addressing, render-safe. The staged state can widen to a multi-pane list when concurrent panes land. - **Scope teardown is stage-driven, single-occupant today** — the staged session follows `list.current` exactly (staging is the open signal: the event window opens ⟺ the session is on stage); a removed-while-staged session's scope survives frozen until the stage moves on, not until true observer count reaches zero. Resolution (`binding()`/`scope()`) is pure addressing, render-safe; the render layer reads the current bundle through the `currentProvideInfo` observable. The staged state can widen to a multi-pane list when concurrent panes land.
- **Value imports of this package from plugin bundles must use the `/client` subpath** — the bare package name is not in the loader externals table and inlines a second module instance, whose private scope-tag Symbol never matches (the empty-state P0 postmortem). - **Value imports of this package from plugin bundles must use the `/client` subpath** — the bare package name is not in the loader externals table and inlines a second module instance, whose private scope-tag Symbol never matches (the empty-state P0 postmortem).

View File

@@ -39,5 +39,5 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
## 已知限制与暂缓事项 ## 已知限制与暂缓事项
- **`loader.unload` 是 stub(抛出 not-implemented)**:完整链路(fiber 释放 → 注册级联 → 样式移除)随 HMR 项目落地。 - **`loader.unload` 是 stub(抛出 not-implemented)**:完整链路(fiber 释放 → 注册级联 → 样式移除)随 HMR 项目落地。
- **scope 拆卸由阶段驱动,目前只能有一个占用者**:已 staged 的 Session 精确跟随 `list.current`(staging 就是打开信号:事件窗口打开 ⟺ Session 位于 stage);在 staged 状态下被移除的 Session,其 scope 会冻结保留,直到 stage 转向其他 Session,而非直到真实观察者数量降为零。解析(`provideInfo()`/`binding()`/`scope()`)只是纯寻址,可安全用于渲染。并发 pane 落地时,staged 状态可以扩展为多 pane 列表。 - **scope 拆卸由阶段驱动,目前只能有一个占用者**:已 staged 的 Session 精确跟随 `list.current`(staging 就是打开信号:事件窗口打开 ⟺ Session 位于 stage);在 staged 状态下被移除的 Session,其 scope 会冻结保留,直到 stage 转向其他 Session,而非直到真实观察者数量降为零。解析(`binding()`/`scope()`)只是纯寻址,可安全用于渲染;渲染层经 `currentProvideInfo` observable 读取当前 bundle。并发 pane 落地时,staged 状态可以扩展为多 pane 列表。
- **插件组合包从该包执行值导入时必须使用 `/client` 子路径**:裸包名不在 loader external 表中,会内联第二个模块实例;其私有 scope-tag Symbol 永远无法匹配(空状态 P0 事故复盘)。 - **插件组合包从该包执行值导入时必须使用 `/client` 子路径**:裸包名不在 loader external 表中,会内联第二个模块实例;其私有 scope-tag Symbol 永远无法匹配(空状态 P0 事故复盘)。

View File

@@ -151,6 +151,13 @@ export class SessionsService {
readonly list: SnapshotStore<SessionListState> readonly list: SnapshotStore<SessionListState>
/** The object-layer instance cluster and frame dispatch entry. */ /** The object-layer instance cluster and frame dispatch entry. */
private readonly manager: SessionManager private readonly manager: SessionManager
/**
* Atomic current-session provide projection: selection changes and
* provider-roster changes publish through this one source (the renderer
* host's `sessions.provide` feed), so a roster change under a stable
* current id republishes the bundle instead of stranding mounted entries.
*/
readonly currentProvideInfo: HostObservable<SessionMaybeProvideInfo>
/** /**
* Persisted selection cell (the durable half of `list.current`). Private on * Persisted selection cell (the durable half of `list.current`). Private on
@@ -167,6 +174,10 @@ export class SessionsService {
private readonly providers: SessionProvideDescriptor[] = [] private readonly providers: SessionProvideDescriptor[] = []
/** Static no-session projection, rebuilt only when the provider roster changes. */ /** Static no-session projection, rebuilt only when the provider roster changes. */
private maybeInfo: SessionMaybeProvideInfo private maybeInfo: SessionMaybeProvideInfo
/** Latest published {@link SessionsService.currentProvideInfo} bundle (identity comparison dedupes republish). */
private currentProvideInfoSnapshot: SessionMaybeProvideInfo
/** currentProvideInfo subscribers (plain cell: bundles hold live Session sources, so no store freeze may touch them). */
private readonly currentProvideInfoListeners = new Set<() => void>()
/** /**
* The staged session id — follows `list.current` exactly, holding its last * The staged session id — follows `list.current` exactly, holding its last
* defined value across masked gaps (a transiently absent selection blanks * defined value across masked gaps (a transiently absent selection blanks
@@ -198,7 +209,11 @@ export class SessionsService {
// dedicated code path. Safe to run synchronously inside the store notify: // dedicated code path. Safe to run synchronously inside the store notify:
// the follower writes no list state — session.open()'s synchronous prefix // the follower writes no list state — session.open()'s synchronous prefix
// touches only session-side state and its own microtask-batched notifier. // touches only session-side state and its own microtask-batched notifier.
this.list.subscribe(() => { this.followCurrent() }) // The current-provide projection follows the same current writes.
this.list.subscribe(() => {
this.followCurrent()
this.updateCurrentProvideInfo()
})
// The runtime's own contribution comes first: useSession rides the same // The runtime's own contribution comes first: useSession rides the same
// provide channel every plugin uses (no renderer special case). // provide channel every plugin uses (no renderer special case).
this.providers.push({ this.providers.push({
@@ -206,6 +221,14 @@ export class SessionsService {
resolve: binding => ({ hooks: { session: binding.session } }), resolve: binding => ({ hooks: { session: binding.session } }),
}) })
this.maybeInfo = this.materializeMaybeProvideInfo() this.maybeInfo = this.materializeMaybeProvideInfo()
this.currentProvideInfoSnapshot = this.maybeInfo
this.currentProvideInfo = {
getSnapshot: () => this.currentProvideInfoSnapshot,
subscribe: (fn) => {
this.currentProvideInfoListeners.add(fn)
return () => { this.currentProvideInfoListeners.delete(fn) }
},
}
rootCtx.reflect.provide('sessions', this, undefined) rootCtx.reflect.provide('sessions', this, undefined)
} }
@@ -238,6 +261,30 @@ export class SessionsService {
for (const record of this.scopes.values()) { for (const record of this.scopes.values()) {
record.provideInfo = this.materializeProvideInfo(record.binding) record.provideInfo = this.materializeProvideInfo(record.binding)
} }
this.updateCurrentProvideInfo()
}
/**
* Re-derive the current selection's provide bundle and publish it when it
* changed. Bundles are identity-stable per (scope, roster)
* materialization, so an identity compare is exact; synchronous notify —
* both call sites (list.subscribe, provide()) already sit behind their own
* batching or registration edges.
*/
private updateCurrentProvideInfo(): void {
const next = this.maybeProvideInfo(this.list.getSnapshot().current)
if (next === this.currentProvideInfoSnapshot) return
this.currentProvideInfoSnapshot = next
for (const fn of [...this.currentProvideInfoListeners]) {
try {
fn()
} catch (error) {
// Contain subscriber failures: this notify runs inside the list
// notification, where a throwing render-side subscriber would starve
// later listeners and abort the projection pass that scheduled it.
console.error('sessions.currentProvideInfo subscriber failed:', error)
}
}
} }
/** Build the static no-session kit and reject duplicate declared names. */ /** Build the static no-session kit and reject duplicate declared names. */
@@ -404,25 +451,21 @@ export class SessionsService {
} }
/** /**
* Resolve the render-layer standard-props bundle (SessionProvider's feed * Resolve one session's render-layer standard-props bundle (ctx never
* through the renderer host; ctx never enters the render layer). Pure * enters the render layer; the renderer subscribes to
* resolution — render-safe: SessionProvider calls this during render, so no * {@link SessionsService.currentProvideInfo}). Pure resolution — render-safe:
* staging, no window side effects (StrictMode double-invokes and concurrent * no staging, no window side effects (StrictMode double-invokes and
* discarded passes must stay free). * concurrent discarded passes must stay free).
* @param id - session id.
* @returns the provide info, or undefined for a session neither listed nor already scoped.
*/ */
provideInfo(id: string): SessionProvideInfo | undefined { private provideInfo(id: string): SessionProvideInfo | undefined {
return this.resolve(id as SessionId)?.provideInfo return this.resolve(id as SessionId)?.provideInfo
} }
/** /**
* Resolve the current-session-optional standard kit. Unknown or absent ids * Resolve the current-session-optional standard kit. Unknown or absent ids
* return the static no-session projection rather than removing hook props. * return the static no-session projection rather than removing hook props.
* @param id - current session id, when selected.
* @returns a definite or no-session provide bundle.
*/ */
maybeProvideInfo(id: string | undefined): SessionMaybeProvideInfo { private maybeProvideInfo(id: string | undefined): SessionMaybeProvideInfo {
return (id === undefined ? undefined : this.provideInfo(id)) ?? this.maybeInfo return (id === undefined ? undefined : this.provideInfo(id)) ?? this.maybeInfo
} }

View File

@@ -246,13 +246,6 @@ export class SlotsService extends Service {
if (workspaces === undefined) { if (workspaces === undefined) {
throw new Error("renderSlot('root') before the workspaces service mounted — boot order puts runtime apply first") throw new Error("renderSlot('root') before the workspaces service mounted — boot order puts runtime apply first")
} }
// Identity-stable view: current rides the list snapshot (arbitrated), but
// the provider consumes it as its own observable; one cached object keeps
// the renderer's per-source hook cache stable.
const current = {
getSnapshot: () => sessions.list.getSnapshot().current as string | undefined,
subscribe: (fn: () => void) => sessions.list.subscribe(fn),
}
this._host = { this._host = {
subscribe: (key, fn) => this._core.subscribe(key, fn), subscribe: (key, fn) => this._core.subscribe(key, fn),
getVersion: key => this._core.getVersion(key), getVersion: key => this._core.getVersion(key),
@@ -263,9 +256,7 @@ export class SlotsService extends Service {
entry.store === undefined ? undefined : this.resolveStore(entry.store as unknown as EngineStoreHandle, scopeKey), entry.store === undefined ? undefined : this.resolveStore(entry.store as unknown as EngineStoreHandle, scopeKey),
sessions: { sessions: {
list: sessions.list, list: sessions.list,
current, provideInfo: sessions.currentProvideInfo,
provideInfo: id => sessions.provideInfo(id),
maybeProvideInfo: id => sessions.maybeProvideInfo(id),
}, },
workspaces: { list: workspaces.list }, workspaces: { list: workspaces.list },
} }

View File

@@ -79,7 +79,8 @@ describe('scope tree', () => {
expect(scopeOf(scoped as Context)).toBe('s1') expect(scopeOf(scoped as Context)).toBe('s1')
expect(scopeOf(b.ctx)).toBeUndefined() expect(scopeOf(b.ctx)).toBeUndefined()
const binding = b.svc.binding(sid('s1')) const binding = b.svc.binding(sid('s1'))
expect(binding?.session).toBe(b.svc.provideInfo('s1')?.hooks['session']) b.svc.open(sid('s1'))
expect(binding?.session).toBe(b.svc.currentProvideInfo.getSnapshot().hooks['session'])
expect(b.svc.binding(sid('s1'))).toBe(binding) expect(b.svc.binding(sid('s1'))).toBe(binding)
expect(binding?.ctx).toBe(scoped) expect(binding?.ctx).toBe(scoped)
}) })
@@ -183,24 +184,82 @@ describe('current selection (migrated from ui-layout, arbitrated into the list s
}) })
describe('cell (render-layer session kit)', () => { describe('cell (render-layer session kit)', () => {
it('resolves an identity-stable {sessionId, session} cell; unknown ids yield undefined', async () => { it('resolves an identity-stable {sessionId, session} cell through the current projection', async () => {
const b = bench() const b = bench()
await feedList(b, [{ id: 's1' }]) await feedList(b, [{ id: 's1' }])
const info = b.svc.provideInfo('s1') b.svc.open(sid('s1'))
expect(info).toBeDefined() const info = b.svc.currentProvideInfo.getSnapshot()
expect(info?.sessionId).toBe('s1') expect(info.sessionId).toBe('s1')
// The bundle carries bare observables; hook binding happens in React. // The bundle carries bare observables; hook binding happens in React.
expect(info?.hooks['session']).toBe(b.svc.binding(sid('s1'))?.session) expect(info.hooks['session']).toBe(b.svc.binding(sid('s1'))?.session)
expect(b.svc.provideInfo('s1')).toBe(info) // Re-staging the same id republishes nothing: identity holds.
expect(b.svc.provideInfo('ghost')).toBeUndefined() b.svc.open(sid('s1'))
expect(b.svc.currentProvideInfo.getSnapshot()).toBe(info)
}) })
it('provideInfo()/binding() are pure resolution: no staging, no deferred sweep', async () => { it('currentProvideInfo follows selection: absent projection ↔ definite bundle, notified on each move', async () => {
const b = bench()
await feedList(b, [{ id: 's1' }, { id: 's2' }])
const absent = b.svc.currentProvideInfo.getSnapshot()
expect(absent.sessionId).toBeUndefined()
expect(Object.hasOwn(absent.hooks, 'session')).toBe(true)
const notified = vi.fn()
b.svc.currentProvideInfo.subscribe(notified)
b.svc.open(sid('s1'))
const s1Bundle = b.svc.currentProvideInfo.getSnapshot()
expect(s1Bundle.sessionId).toBe('s1')
expect(s1Bundle.hooks['session']).toBe(b.svc.binding(sid('s1'))?.session)
expect(notified).toHaveBeenCalledTimes(1)
b.svc.open(sid('s2'))
const s2Bundle = b.svc.currentProvideInfo.getSnapshot()
expect(s2Bundle.sessionId).toBe('s2')
expect(s2Bundle).not.toBe(s1Bundle)
expect(notified).toHaveBeenCalledTimes(2)
b.svc.clear()
await Promise.resolve() // clearSelection projects through the manager notifier
expect(b.svc.currentProvideInfo.getSnapshot().sessionId).toBeUndefined()
})
it('a provider roster change under a stable current id republishes the bundle', async () => {
const b = bench()
await feedList(b, [{ id: 's1' }])
b.svc.open(sid('s1'))
const before = b.svc.currentProvideInfo.getSnapshot()
const notified = vi.fn()
b.svc.currentProvideInfo.subscribe(notified)
const source = { getSnapshot: () => 'live', subscribe: () => () => {} }
const dispose = b.svc.provide({
hooks: ['extra'],
props: ['marker'],
resolve: () => ({ hooks: { extra: source }, props: { marker: 7 } }),
})
const added = b.svc.currentProvideInfo.getSnapshot()
expect(added).not.toBe(before)
expect(added).toMatchObject({ sessionId: 's1', props: { marker: 7 } })
expect(added.hooks['extra']).toBe(source)
expect(notified).toHaveBeenCalledTimes(1)
dispose()
const removed = b.svc.currentProvideInfo.getSnapshot()
expect(removed).not.toBe(added)
expect(Object.hasOwn(removed.hooks, 'extra')).toBe(false)
expect(notified).toHaveBeenCalledTimes(2)
})
it('an unsubscribed currentProvideInfo listener stops receiving notifications', async () => {
const b = bench()
await feedList(b, [{ id: 's1' }])
const notified = vi.fn()
const off = b.svc.currentProvideInfo.subscribe(notified)
off()
b.svc.open(sid('s1'))
expect(notified).not.toHaveBeenCalled()
})
it('binding() is pure resolution: no staging, no deferred sweep', async () => {
const b = bench() const b = bench()
await feedList(b, [{ id: 's1' }, { id: 's2' }]) await feedList(b, [{ id: 's1' }, { id: 's2' }])
b.svc.open(sid('s1')) // staged b.svc.open(sid('s1')) // staged
b.svc.provideInfo('s2') // resolution only — must NOT move the stage b.svc.binding(sid('s2')) // resolution only — must NOT move the stage
b.svc.binding(sid('s2'))
await feedList(b, [{ id: 's2' }]) // s1 removed: still staged → deferred, scope survives await feedList(b, [{ id: 's2' }]) // s1 removed: still staged → deferred, scope survives
expect(b.svc.scope(sid('s1'))).toBeDefined() expect(b.svc.scope(sid('s1'))).toBeDefined()
}) })
@@ -211,7 +270,6 @@ describe('cell (render-layer session kit)', () => {
const historyCalls = () => b.api.calls.filter(c => c.method === 'session.history') const historyCalls = () => b.api.calls.filter(c => c.method === 'session.history')
// Resolution is addressing, not staging: no window pull. // Resolution is addressing, not staging: no window pull.
b.svc.scope(sid('s1')) b.svc.scope(sid('s1'))
b.svc.provideInfo('s1')
b.svc.binding(sid('s1')) b.svc.binding(sid('s1'))
expect(historyCalls()).toHaveLength(0) expect(historyCalls()).toHaveLength(0)
b.svc.open(sid('s1')) b.svc.open(sid('s1'))

View File

@@ -97,18 +97,13 @@ function fakeWorkspaces() {
return { list: { getSnapshot: () => state, subscribe: () => () => undefined } } return { list: { getSnapshot: () => state, subscribe: () => () => undefined } }
} }
/** Minimal sessions face for the host seam (list observable + provide bundle). */ /** Minimal sessions face for the host seam (list observable + current provide projection). */
function fakeSessions() { function fakeSessions() {
const state = { ids: [], byId: {}, current: undefined as string | undefined } const state = { ids: [], byId: {}, current: undefined as string | undefined }
const absentInfo = { sessionId: undefined, hooks: { session: undefined }, props: {} }
return { return {
list: { getSnapshot: () => state, subscribe: () => () => undefined }, list: { getSnapshot: () => state, subscribe: () => () => undefined },
provideInfo: (id: string) => (id === 'known' currentProvideInfo: { getSnapshot: () => absentInfo, subscribe: () => () => undefined },
? {
sessionId: id,
hooks: { session: { getSnapshot: () => undefined, subscribe: () => () => undefined } },
props: {},
}
: undefined),
} }
} }
@@ -232,13 +227,11 @@ describe('host face', () => {
expect(host.entriesOf('t.host')).toHaveLength(0) expect(host.entriesOf('t.host')).toHaveLength(0)
}) })
it('exposes sessions list/current/provideInfo (current riding the list snapshot)', async () => { it('exposes the session list and the atomic current provide projection', async () => {
const bench = await boot() const bench = await boot()
const host = captureHost(bench) const host = captureHost(bench)
expect(host.sessions.list.getSnapshot()).toMatchObject({ ids: [] }) expect(host.sessions.list.getSnapshot()).toMatchObject({ ids: [] })
expect(host.sessions.current.getSnapshot()).toBeUndefined() expect(host.sessions.provideInfo.getSnapshot()).toMatchObject({ sessionId: undefined })
expect(host.sessions.provideInfo('known')).toMatchObject({ sessionId: 'known' })
expect(host.sessions.provideInfo('ghost')).toBeUndefined()
}) })
it('exposes the independent Workspace list source', async () => { it('exposes the independent Workspace list source', async () => {

View File

@@ -135,13 +135,15 @@ export function apply(ctx: Context): void {
'conversation.input.model': { kind: 'single', scope: 'session' }, 'conversation.input.model': { kind: 'single', scope: 'session' },
}, },
inject: (sessionId: SessionId): ComposerBarInjected => { inject: (sessionId: SessionId): ComposerBarInjected => {
const shell = inputHub.shell(sessionId)
return { return {
keyboard: inputHub.keyboard(sessionId), keyboard: shell,
stop: () => { stop: () => {
scopedConversation(sessions, sessionId).cancel().catch(() => { scopedConversation(sessions, sessionId).cancel().catch(() => {
// Stop failure surfaces via snapshot.promptError; nothing to restore. // Stop failure surfaces via snapshot.promptError; nothing to restore.
}) })
}, },
hooks: { notices: shell.notices, lexicon: shell.lexicon },
} }
}, },
}, InputBar) }, InputBar)

View File

@@ -9,18 +9,6 @@
color: var(--dsw-alias-label-primary); color: var(--dsw-alias-label-primary);
} }
.pulse {
display: inline-block;
width: 8px;
height: 14px;
background: var(--dsw-alias-state-business-primary);
animation: pulse 1s infinite ease-in-out;
}
@keyframes pulse {
50% { opacity: 0.2; }
}
/* Interrupted-turn terminal marker: quiet inline tag, no animation. */ /* Interrupted-turn terminal marker: quiet inline tag, no animation. */
.stopped { .stopped {
align-self: flex-start; align-self: flex-start;

View File

@@ -2,8 +2,8 @@
// reasoning as the figma Think summary row (expand = indented gray text), // reasoning as the figma Think summary row (expand = indented gray text),
// other-block JSON fallback. Tool-call heads are NOT rendered here: the chat // other-block JSON fallback. Tool-call heads are NOT rendered here: the chat
// view groups them into tool rows through its keyed toolview slot (figma // view groups them into tool rows through its keyed toolview slot (figma
// step-summary flow). Shared by finalized nodes and the streaming partial // step-summary flow). Shared by finalized nodes and the streaming partial;
// (pulse marker). // the turn-level loading dots live in the chat view's tail, not here.
import { memo } from 'react' import { memo } from 'react'
import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client' import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client'
@@ -14,7 +14,7 @@ import css from './AssistantMarkdown.module.css'
export interface AssistantMarkdownProps { export interface AssistantMarkdownProps {
blocks: readonly AssistantBlock[] blocks: readonly AssistantBlock[]
streaming: boolean streaming: boolean
/** Frozen partial of an aborted turn: rendered with a 已停止 marker, no pulse. */ /** Frozen partial of an aborted turn: rendered with a 已停止 marker. */
interrupted?: boolean | undefined interrupted?: boolean | undefined
} }
@@ -28,7 +28,7 @@ function ThinkRow({ text, running }: { text: string; running: boolean }) {
return ( return (
<ToolRow <ToolRow
variant="think" variant="think"
icon={<IconThinkOutline14 />} icon={<IconThinkOutline14 size={14} />}
title="Think" title="Think"
summary={firstLine(text)} summary={firstLine(text)}
body={text} body={text}
@@ -58,7 +58,6 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ blocks, strea
default: return <JsonBlock key={i} label="未知内容块" payload={block.block} /> default: return <JsonBlock key={i} label="未知内容块" payload={block.block} />
} }
})} })}
{streaming && <span className={css.pulse} />}
{interrupted && <span className={css.stopped}>已停止</span>} {interrupted && <span className={css.stopped}>已停止</span>}
</div> </div>
) )

View File

@@ -1,5 +1,6 @@
/* Chat flow: block gap 16 between narration/bubbles/tool groups (figma); /* Chat flow: one 16px rhythm everywhere — between blocks (prose <-> tool
tool rows inside a group gap 10. Input padding cap rides the skeleton. */ runs) via the column gap and between consecutive tool rows via the group
gap. Input padding cap rides the skeleton. */
.root { .root {
position: relative; position: relative;
@@ -30,7 +31,7 @@
.toolGroup { .toolGroup {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 10px; gap: 16px;
} }
.callRow { .callRow {
@@ -51,6 +52,35 @@
border-left: 1px solid var(--dsw-alias-border-l2); border-left: 1px solid var(--dsw-alias-border-l2);
} }
/* Turn loader: one row of four 2.5px pixels (StateDot blue) chasing left to
right with a stepped trail — flat keyframe holds, no tweening. Phase
offsets come from per-rect animation-delay (index * -250ms) set inline
by the component. */
.turnDots {
align-self: flex-start;
flex: none;
display: flex;
align-items: center;
/* One message line box: the dots center inside the text line height. */
height: 26px;
/* Same pin as StateDot: ongoing blue has no alias token (business-primary
is the 500 step, not this 450). */
color: var(--dsw-static-deepseek-450);
}
.turnDotCell {
fill: currentColor;
opacity: 0.15;
animation: dsh-turn-dots-chase 1s infinite;
}
@keyframes dsh-turn-dots-chase {
0%, 24.9% { opacity: 1; }
25%, 49.9% { opacity: 0.6; }
50%, 74.9% { opacity: 0.35; }
75%, 100% { opacity: 0.15; }
}
.hint { .hint {
color: var(--dsw-alias-label-tertiary); color: var(--dsw-alias-label-tertiary);
font-size: 12px; font-size: 12px;

View File

@@ -49,19 +49,20 @@ type UseConversation = SnapshotSelectorHook<ConversationSnapshot>
* top-level call (same registrations, same fallback), nested by the parent. * top-level call (same registrations, same fallback), nested by the parent.
* A started-but-unsettled sub-call arrives as the RunningToolCall shape and * A started-but-unsettled sub-call arrives as the RunningToolCall shape and
* renders the running state exactly as a native in-flight row. */ * renders the running state exactly as a native in-flight row. */
const SubCallRow = memo(function SubCallRow({ renderSlot, node, onOpenDetails, selected }: { const SubCallRow = memo(function SubCallRow({ renderSlot, node, onOpenDetails, selected, cwd }: {
renderSlot: RenderToolRow renderSlot: RenderToolRow
node: CodeSubCall node: CodeSubCall
onOpenDetails: OpenDetails onOpenDetails: OpenDetails
selected: boolean selected: boolean
cwd: string | undefined
}) { }) {
const settled = 'kind' in node const settled = 'kind' in node
const toolName = settled ? node.call?.name ?? '' : node.name const toolName = settled ? node.call?.name ?? '' : node.name
const seq = settled ? node.seq : node.time const seq = settled ? node.seq : node.time
const owner = useMemo(() => ({ const owner = useMemo(() => ({
callId: node.callId, toolName, block: node, callId: node.callId, toolName, block: node, cwd,
openDetails: () => { onOpenDetails({ turnSeq: seq, callId: node.callId, toolName }) }, openDetails: () => { onOpenDetails({ turnSeq: seq, callId: node.callId, toolName }) },
}), [node, toolName, seq, onOpenDetails]) }), [node, toolName, seq, cwd, onOpenDetails])
return ( return (
<div className={css.callRow} data-selected={selected || undefined}> <div className={css.callRow} data-selected={selected || undefined}>
{renderSlot('conversation.chat.toolview', owner, { {renderSlot('conversation.chat.toolview', owner, {
@@ -77,7 +78,9 @@ const SubCallRow = memo(function SubCallRow({ renderSlot, node, onOpenDetails, s
* GenericToolCard at this render site. A `run_code` call additionally * GenericToolCard at this render site. A `run_code` call additionally
* renders its logged sub-dispatches as always-visible indented rows — * renders its logged sub-dispatches as always-visible indented rows —
* each one the same keyed-slot dispatch as a native top-level call. */ * each one the same keyed-slot dispatch as a native top-level call. */
const CallRow = memo(function CallRow({ renderSlot, callId, toolName, block, seq, onOpenDetails, selected, subCalls, selectedCallId }: { const CallRow = memo(function CallRow({
renderSlot, callId, toolName, block, seq, onOpenDetails, selected, subCalls, selectedCallId, cwd,
}: {
renderSlot: RenderToolRow renderSlot: RenderToolRow
callId: string callId: string
toolName: string toolName: string
@@ -91,11 +94,13 @@ const CallRow = memo(function CallRow({ renderSlot, callId, toolName, block, seq
subCalls?: readonly CodeSubCall[] | undefined subCalls?: readonly CodeSubCall[] | undefined
/** The store's selected callId, matched against sub-rows (undefined when no sub-row here is selected). */ /** The store's selected callId, matched against sub-rows (undefined when no sub-row here is selected). */
selectedCallId?: string | undefined selectedCallId?: string | undefined
/** Session workspace root for path-relative summaries. */
cwd: string | undefined
}) { }) {
const owner = useMemo(() => ({ const owner = useMemo(() => ({
callId, toolName, block, callId, toolName, block, cwd,
openDetails: () => { onOpenDetails({ turnSeq: seq, callId, toolName }) }, openDetails: () => { onOpenDetails({ turnSeq: seq, callId, toolName }) },
}), [callId, toolName, block, seq, onOpenDetails]) }), [callId, toolName, block, seq, cwd, onOpenDetails])
return ( return (
<div className={css.callRow} data-selected={selected || undefined}> <div className={css.callRow} data-selected={selected || undefined}>
{renderSlot('conversation.chat.toolview', owner, { {renderSlot('conversation.chat.toolview', owner, {
@@ -111,6 +116,7 @@ const CallRow = memo(function CallRow({ renderSlot, callId, toolName, block, seq
node={node} node={node}
onOpenDetails={onOpenDetails} onOpenDetails={onOpenDetails}
selected={node.callId === selectedCallId} selected={node.callId === selectedCallId}
cwd={cwd}
/> />
))} ))}
</div> </div>
@@ -119,8 +125,8 @@ const CallRow = memo(function CallRow({ renderSlot, callId, toolName, block, seq
) )
}) })
/** Consecutive tool results as one step-run group (figma VERTICAL gap10). */ /** Consecutive tool results as one step-run group (uniform 16px rhythm). */
const ToolGroup = memo(function ToolGroup({ renderSlot, results, onOpenDetails, selectedCallId, codeDispatches }: { const ToolGroup = memo(function ToolGroup({ renderSlot, results, onOpenDetails, selectedCallId, codeDispatches, cwd }: {
renderSlot: RenderToolRow renderSlot: RenderToolRow
results: readonly ToolResultNode[] results: readonly ToolResultNode[]
onOpenDetails: OpenDetails onOpenDetails: OpenDetails
@@ -128,6 +134,8 @@ const ToolGroup = memo(function ToolGroup({ renderSlot, results, onOpenDetails,
selectedCallId: string | undefined selectedCallId: string | undefined
/** Sub-dispatch index off the snapshot (map reference is chunk-storm stable). */ /** Sub-dispatch index off the snapshot (map reference is chunk-storm stable). */
codeDispatches: ReadonlyMap<string, readonly CodeSubCall[]> codeDispatches: ReadonlyMap<string, readonly CodeSubCall[]>
/** Session workspace root for path-relative summaries. */
cwd: string | undefined
}) { }) {
return ( return (
<div className={css.toolGroup}> <div className={css.toolGroup}>
@@ -143,12 +151,47 @@ const ToolGroup = memo(function ToolGroup({ renderSlot, results, onOpenDetails,
selected={node.callId === selectedCallId} selected={node.callId === selectedCallId}
subCalls={codeDispatches.get(node.callId)} subCalls={codeDispatches.get(node.callId)}
selectedCallId={selectedCallId} selectedCallId={selectedCallId}
cwd={cwd}
/> />
))} ))}
</div> </div>
) )
}) })
/** Turn loader: one row of four 2.5px pixels (half a notch above the StateDot
* 2px cell, same blue) chasing left to right with a stepped trail — flat
* keyframe holds, no tweening, no rotation. Phase offsets come from
* per-rect animation-delay. */
const LOADER_CELLS = [0, 5, 10, 15] as const
function TurnDots() {
return (
/* The wrapper is a 26px line box (message line height) so the loader
occupies one text line and centers the dots inside it. */
<div className={css.turnDots} aria-hidden="true">
<svg
width="17.5"
height="2.5"
viewBox="0 0 17.5 2.5"
shapeRendering="crispEdges"
>
{LOADER_CELLS.map((x, index) => (
<rect
key={x}
className={css.turnDotCell}
x={x}
y="0"
width="2.5"
height="2.5"
/* Negative delay phases the chase so every cell animates from mount. */
style={{ animationDelay: `${(index - LOADER_CELLS.length) * 250}ms` }}
/>
))}
</svg>
</div>
)
}
/** The streaming partial, isolated so chunk batches re-render only this tail. /** The streaming partial, isolated so chunk batches re-render only this tail.
* onGrow lets the scroll owner follow content the parent never re-renders for. */ * onGrow lets the scroll owner follow content the parent never re-renders for. */
function StreamingTail({ useSession, onGrow }: { function StreamingTail({ useSession, onGrow }: {
@@ -167,8 +210,11 @@ function StreamingTail({ useSession, onGrow }: {
* The chat view slot entry: pure component over the composed props (tool rows * The chat view slot entry: pure component over the composed props (tool rows
* render through the declared keyed hole's renderSlot share). * render through the declared keyed hole's renderSlot share).
*/ */
export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOlder }: ChatViewSlotProps) { export function ChatView({ useSession, useSessions, useStore, renderSlot, sessionId, openDetails, loadOlder }: ChatViewSlotProps) {
const nodes = useSession(s => s.nodes) const nodes = useSession(s => s.nodes)
// Workspace root off the session list row: path summaries display relative to it.
const cwd = useSessions(s => s.byId[sessionId]?.cwd)
const running = useSession(s => s.running)
const runningCalls = useSession(s => s.runningCalls) const runningCalls = useSession(s => s.runningCalls)
const codeDispatches = useSession(s => s.codeDispatches) const codeDispatches = useSession(s => s.codeDispatches)
const pending = useSession(s => s.pending) const pending = useSession(s => s.pending)
@@ -268,6 +314,7 @@ export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOl
onOpenDetails={openDetails} onOpenDetails={openDetails}
selectedCallId={inGroup ? selectedCallId : undefined} selectedCallId={inGroup ? selectedCallId : undefined}
codeDispatches={codeDispatches} codeDispatches={codeDispatches}
cwd={cwd}
/> />
) )
} }
@@ -309,11 +356,15 @@ export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOl
selected={call.callId === selectedCallId} selected={call.callId === selectedCallId}
subCalls={codeDispatches.get(call.callId)} subCalls={codeDispatches.get(call.callId)}
selectedCallId={selectedCallId} selectedCallId={selectedCallId}
cwd={cwd}
/> />
))} ))}
</div> </div>
)} )}
{pending.map(item => <PendingCard key={item.key} item={item} />)} {pending.map(item => <PendingCard key={item.key} item={item} />)}
{/* Turn-level loading signal: rides the whole running turn (first-token
wait, tool execution, streaming) so it never flickers per step. */}
{running && <TurnDots />}
</div> </div>
</div> </div>
<StatsLine useSession={useSession} /> <StatsLine useSession={useSession} />

View File

@@ -13,20 +13,20 @@ import { toolRowModel, type ToolRowVariant } from '../contract/tool-call-model.t
import { ToolRow } from './ToolRow.tsx' import { ToolRow } from './ToolRow.tsx'
import { IconSparkle16 } from './IconSparkle16.tsx' import { IconSparkle16 } from './IconSparkle16.tsx'
/** Variant leading icons (figma table). */ /** Variant leading icons (figma table); all glyphs render at 14 inside the 16px leading box. */
const VARIANT_ICONS: Record<ToolRowVariant, ReactNode> = { const VARIANT_ICONS: Record<ToolRowVariant, ReactNode> = {
think: <IconThinkOutline14 />, think: <IconThinkOutline14 size={14} />,
search: <IconSearchOutline16 />, search: <IconSearchOutline16 size={14} />,
read: <IconBrowseOutline16 />, read: <IconBrowseOutline16 size={14} />,
bash: <IconApiOutline14 size={16} />, bash: <IconApiOutline14 size={14} />,
write: <IconEditOutline16 />, write: <IconEditOutline16 size={14} />,
edit: <IconEditOutline16 />, edit: <IconEditOutline16 size={14} />,
code: <IconCodeOutline16 />, code: <IconCodeOutline16 size={14} />,
others: <IconSparkle16 />, others: <IconSparkle16 size={14} />,
} }
export function GenericToolCard({ toolName, block, openDetails }: ToolRowOwnerProps) { export function GenericToolCard({ toolName, block, cwd, openDetails }: ToolRowOwnerProps) {
const model = toolRowModel(toolName, block) const model = toolRowModel(toolName, block, cwd)
return ( return (
<ToolRow <ToolRow
variant={model.variant} variant={model.variant}

View File

@@ -7,22 +7,48 @@
} }
.row { .row {
position: relative; /* sweep-glare overlay anchor */
overflow: hidden;
display: flex; display: flex;
align-items: center; align-items: center;
height: 24px; height: 24px;
min-width: 0; min-width: 0;
} }
/* Running sweep (deepsuite ShimmerText pattern): a fixed-width glare band —
theme background at 60% — glides over the row content from off-left to
off-right, washing glyphs and icon toward the background as it passes.
ease-out with a 10% end hold gives each pass a beat before the next. */
.root[data-state='running'] .row::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-tool-row-sweep 2.6s ease-out infinite;
pointer-events: none;
}
@keyframes dsh-tool-row-sweep {
0% { left: -300px; }
90%, 100% { left: 100%; }
}
/* Clickable rows keep only the cursor affordance — no hover fill. */
.row[data-clickable] { .row[data-clickable] {
cursor: pointer; cursor: pointer;
border-radius: 6px; border-radius: 6px;
} }
.row[data-clickable]:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.leading { .leading {
position: relative; /* .chevronHover overlay anchor */
flex: none; flex: none;
width: 16px; width: 16px;
height: 16px; height: 16px;
@@ -65,11 +91,36 @@ button.leading {
color: var(--dsw-alias-label-secondary); color: var(--dsw-alias-label-secondary);
} }
/* Hover preview on expandable rows: the idle tool icon crossfades (100ms)
into a down chevron before the row is opened. The chevron overlays the
icon cell absolutely so both can stay mounted for the opacity transition. */
.iconIdle {
display: inline-flex;
opacity: 1;
transition: opacity 100ms ease;
}
.chevronHover {
position: absolute;
inset: 0;
margin: auto;
opacity: 0;
transition: opacity 100ms ease;
}
.row:hover .iconIdle {
opacity: 0;
}
.row:hover .chevronHover {
opacity: 1;
}
.title { .title {
flex: none; flex: none;
font-size: 14px; font-size: 14px;
line-height: 24px; line-height: 24px;
color: var(--dsw-alias-label-primary-dimmed); color: var(--dsw-alias-label-secondary);
} }
.sep { .sep {

View File

@@ -1,8 +1,10 @@
// ToolRow: the single-line tool summary row (figma component set 122:9479) — // ToolRow: the single-line tool summary row (figma component set 122:9479) —
// 16px leading slot (state dot / tool icon, chevron when expanded) + title + // 16px leading slot (state dot / tool icon, chevron on hover or expanded) + title +
// separator dot + FILL-truncated summary. Expanded body is indented gray text; // separator dot + FILL-truncated summary. Expanded body is indented gray text;
// no inline output (full results live in the details panel). Expand state is // no inline output (full results live in the details panel). Expand state is
// component-local view state; row click hands the selection off to the owner. // component-local view state; row click hands the selection off to the owner.
// TODO(ux): converge every chat-tab tool row on in-place expansion for its
// expandable content, retiring the details-panel handoff where feasible.
import { useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react' import { useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
import clsx from 'clsx' import clsx from 'clsx'
@@ -28,11 +30,11 @@ export interface ToolRowProps {
onOpenDetails?: (() => void) | undefined onOpenDetails?: (() => void) | undefined
} }
/** Leading-slot state substitution: the tool icon yields to the state semantic /** Leading-slot state substitution: the tool icon yields to the terminal state
* (running = blue ring, error = red, interrupted = amber halo; ok = icon). */ * semantic (error = red, interrupted = amber halo). Running keeps the icon —
* the row sweep (CSS on data-state) carries the in-flight signal. */
function leadingFor(state: ToolRowState, icon: ReactNode): ReactNode { function leadingFor(state: ToolRowState, icon: ReactNode): ReactNode {
switch (state) { switch (state) {
case 'running': return <StateDot state="ongoing" />
case 'error': return <StateDot state="error" /> case 'error': return <StateDot state="error" />
case 'stopped': return <StateDot state="warning" /> case 'stopped': return <StateDot state="warning" />
default: return icon default: return icon
@@ -66,6 +68,19 @@ export function ToolRow({
event.preventDefault() event.preventDefault()
toggleExpand() toggleExpand()
} }
// Expandable rows preview the toggle on hover: the tool icon yields to a
// down chevron (CSS swap on .row:hover); state dots still take precedence.
const collapsedIcon = expandable
? (
<>
<span className={css.iconIdle}>{icon}</span>
<IconChevronDownOutline14 className={clsx(css.chevron, css.chevronHover)} />
</>
)
: icon
const leading = open
? <IconChevronDownOutline14 className={css.chevron} />
: leadingFor(state, collapsedIcon)
return ( return (
<div className={css.root} data-variant={variant} data-tool={toolName} data-state={state}> <div className={css.root} data-variant={variant} data-tool={toolName} data-state={state}>
<div <div
@@ -84,11 +99,11 @@ export function ToolRow({
aria-expanded={open} aria-expanded={open}
onClick={toggleFromLeading} onClick={toggleFromLeading}
> >
{open ? <IconChevronDownOutline14 className={clsx(css.chevron)} /> : leadingFor(state, icon)} {leading}
</button> </button>
) : ( ) : (
<span className={css.leading}> <span className={css.leading}>
{open ? <IconChevronDownOutline14 className={clsx(css.chevron)} /> : leadingFor(state, icon)} {leading}
</span> </span>
)} )}
<span className={css.title}>{title}</span> <span className={css.title}>{title}</span>

View File

@@ -12,6 +12,16 @@ export type ChatFlowItem =
| { kind: 'node'; key: string; node: ConversationNode } | { kind: 'node'; key: string; node: ConversationNode }
| { kind: 'tool-group'; key: string; results: readonly ToolResultNode[] } | { kind: 'tool-group'; key: string; results: readonly ToolResultNode[] }
/** An assistant node that renders nothing: only tool-call heads (rows render
* via the grouping pass) and blank text/reasoning. Skipped by the flow so it
* neither costs column gaps nor splits a tool-row run. Interrupted nodes
* always render (the 已停止 marker). */
function rendersNothing(node: ConversationNode): boolean {
return node.kind === 'assistant' && node.interrupted !== true
&& node.blocks.every(b => b.kind === 'tool-call'
|| ((b.kind === 'text' || b.kind === 'reasoning') && b.text.trim() === ''))
}
/** /**
* Group finalized nodes into the step-summary flow. * Group finalized nodes into the step-summary flow.
* @param nodes - snapshot nodes (surface order). * @param nodes - snapshot nodes (surface order).
@@ -21,6 +31,7 @@ export function deriveChatFlow(nodes: readonly ConversationNode[]): ChatFlowItem
const items: ChatFlowItem[] = [] const items: ChatFlowItem[] = []
let group: ToolResultNode[] | null = null let group: ToolResultNode[] | null = null
for (const node of nodes) { for (const node of nodes) {
if (rendersNothing(node)) continue
if (node.kind === 'tool-result') { if (node.kind === 'tool-result') {
if (group === null) { if (group === null) {
group = [node] group = [node]

View File

@@ -1,11 +1,11 @@
/** Conversation slot declarations and their composed component props. */ /** Conversation slot declarations and their composed component props. */
import type { ReactNode, RefObject } from 'react' import type { ReactNode, RefObject } from 'react'
import type { import type {
MaybeSnapshotSelectorHook, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook, InjectFace, MaybeSnapshotSelectorHook, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook,
} from '@deepseek-ai/dsh-client-ui-slots' } from '@deepseek-ai/dsh-client-ui-slots'
import type { ConversationSnapshot, PendingInteraction, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client' import type { ConversationSnapshot, ObservableSnapshot, PendingInteraction, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
import type {} from '@deepseek-ai/dsh-client-ui-layout/client' import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
import type { ComposerKeyboard, InputActions, InputState } from '../input/contract.ts' import type { ComposerKeyboard, InputActions, InputNotice, InputState } from '../input/contract.ts'
import type { createChatStore } from '../stores.ts' import type { createChatStore } from '../stores.ts'
import type { CallId, SelectionTarget, ViewTab } from './views.ts' import type { CallId, SelectionTarget, ViewTab } from './views.ts'
@@ -145,6 +145,8 @@ export interface ToolRowOwnerProps {
toolName: string toolName: string
/** Frozen call slice: the running call or the settled result node. */ /** Frozen call slice: the running call or the settled result node. */
block: ToolCallBlock block: ToolCallBlock
/** Session workspace root; path summaries display relative to it. */
cwd?: string | undefined
/** Open the details panel for this call (session-level facility, supplied by the view). */ /** Open the details panel for this call (session-level facility, supplied by the view). */
openDetails: () => void openDetails: () => void
} }
@@ -222,6 +224,13 @@ export interface ComposerBarInjected {
keyboard: ComposerKeyboard keyboard: ComposerKeyboard
/** Cancel the in-flight turn. */ /** Cancel the in-flight turn. */
stop: () => void stop: () => void
/** Registrant hooks compartment: the renderer binds these to useNotices/useLexicon. */
hooks: {
/** Latest surfaced notice (null after none; seq keys re-render of repeats). */
notices: ObservableSnapshot<InputNotice | null>
/** Hot plain-text reference lexicon for the decoration scan (decision 21). */
lexicon: ObservableSnapshot<ReadonlyMap<'/' | '@', readonly string[]>>
}
} }
/** /**
@@ -233,11 +242,11 @@ export interface InputControlOwnerProps {
locked: boolean locked: boolean
} }
/** Full composer-bar component props: standard kit & owner share & control-seat render share & injected share. */ /** Full composer-bar component props: standard kit & owner share & control-seat render share & injected share (hooks compartment bound). */
export type ComposerBarProps = export type ComposerBarProps =
PropsRuntime<'conversation.composer.bar'> PropsRuntime<'conversation.composer.bar'>
& PropsRenderSlots<'conversation.input.plan' | 'conversation.input.model'> & PropsRenderSlots<'conversation.input.plan' | 'conversation.input.model'>
& ComposerBarInjected & InjectFace<ComposerBarInjected>
/** /**
* Composer chain currency: what ConversationRoot dispatches at its * Composer chain currency: what ConversationRoot dispatches at its
@@ -302,6 +311,8 @@ export type DetailsSlotProps = PropsRuntime<'details'> & PropsStore<ChatStore> &
export interface EmptyWorkspaceOwnerProps { export interface EmptyWorkspaceOwnerProps {
open: boolean open: boolean
anchorRef?: RefObject<HTMLElement> anchorRef?: RefObject<HTMLElement>
/** Currently active workspace (renders a trailing check in the picker list). */
selectedId?: WorkspaceId | undefined
onPick: (workspaceId: WorkspaceId) => void onPick: (workspaceId: WorkspaceId) => void
onClose: () => void onClose: () => void
} }

View File

@@ -101,6 +101,14 @@ const SUMMARY_KEYS: Record<ToolRowVariant, readonly string[]> = {
others: [], others: [],
} }
/** Strip the workspace root from workspace-rooted absolute paths (display only). */
function relativizeToCwd(text: string, cwd: string | undefined): string {
if (cwd === undefined || cwd === '') return text
const root = cwd.replace(/[/\\]+$/, '')
if (text.startsWith(`${root}/`) || text.startsWith(`${root}\\`)) return text.slice(root.length + 1)
return text
}
function deriveSummary(variant: ToolRowVariant, argsRaw: string): string { function deriveSummary(variant: ToolRowVariant, argsRaw: string): string {
const parsed = parseArgs(argsRaw) const parsed = parseArgs(argsRaw)
if (typeof parsed !== 'object' || parsed === null) return firstLine(argsRaw) if (typeof parsed !== 'object' || parsed === null) return firstLine(argsRaw)
@@ -130,16 +138,17 @@ function deriveBody(variant: ToolRowVariant, argsRaw: string): string | null {
* Derive the full row model from a frozen call slice. * Derive the full row model from a frozen call slice.
* @param toolName - wire tool name (dispatch-supplied; survives windowless results). * @param toolName - wire tool name (dispatch-supplied; survives windowless results).
* @param block - RunningToolCall or ToolResultNode off the snapshot caches. * @param block - RunningToolCall or ToolResultNode off the snapshot caches.
* @param cwd - session workspace root; workspace-rooted path summaries display relative to it.
* @returns the row model. * @returns the row model.
*/ */
export function toolRowModel(toolName: string, block: ToolCallBlock): ToolRowModel { export function toolRowModel(toolName: string, block: ToolCallBlock, cwd?: string): ToolRowModel {
const variant = classifyTool(toolName) const variant = classifyTool(toolName)
const done = 'kind' in block const done = 'kind' in block
const argsRaw = (done ? block.call?.argsRaw : block.argsRaw) ?? '' const argsRaw = (done ? block.call?.argsRaw : block.argsRaw) ?? ''
const state: ToolRowState = !done ? 'running' const state: ToolRowState = !done ? 'running'
: block.error?.code === 'interrupted' ? 'stopped' : block.error?.code === 'interrupted' ? 'stopped'
: block.isError ? 'error' : 'ok' : block.isError ? 'error' : 'ok'
const base = argsRaw === '' ? block.callId : deriveSummary(variant, argsRaw) const base = argsRaw === '' ? block.callId : relativizeToCwd(deriveSummary(variant, argsRaw), cwd)
const toolTitle = TOOL_TITLES[toolName] const toolTitle = TOOL_TITLES[toolName]
// Others keeps the static "Tool call" title (figma literal); the real tool // Others keeps the static "Tool call" title (figma literal); the real tool
// name rides the mutable summary slot unless the tool owns a specific title. // name rides the mutable summary slot unless the tool owns a specific title.

View File

@@ -77,8 +77,6 @@ export interface InputNotice {
* satisfies it structurally. * satisfies it structurally.
*/ */
export interface ComposerKeyboard { export interface ComposerKeyboard {
/** Latest surfaced notice store (null after none). */
readonly notices: SnapshotStore<InputNotice | null>
/** Live machine state for event-handler reads (render reads go through useInput). */ /** Live machine state for event-handler reads (render reads go through useInput). */
readonly snapshot: InputState readonly snapshot: InputState
/** Draft write with the DOM-observed edit shape (narrows occurrence math). */ /** Draft write with the DOM-observed edit shape (narrows occurrence math). */
@@ -99,8 +97,6 @@ export interface ComposerKeyboard {
space(): boolean space(): boolean
/** Dismiss the popupSelect shell (any interaction outside the box). */ /** Dismiss the popupSelect shell (any interaction outside the box). */
dismissPopup(): void dismissPopup(): void
/** Hot plain-text reference lexicons for the decoration scan (decision 21; empty Map without a pipeline). */
lexicon(): ReadonlyMap<'/' | '@', readonly string[]>
} }
/** One queued-message row projected from the session/queued frames (T9 supplies the store). */ /** One queued-message row projected from the session/queued frames (T9 supplies the store). */

View File

@@ -206,11 +206,14 @@ export class SessionInputShell implements SessionInput {
} }
/** /**
* Hot plain-text reference lexicons for the decoration scan (decision 21). * Hot plain-text reference lexicon source for the decoration scan
* @returns the controller's per-trigger aggregation; empty Map without a pipeline. * (decision 21): delegates to the controller's aggregated store. Stable
* identity per shell; without a pipeline the snapshot is the empty Map and
* subscribers never fire.
*/ */
lexicon(): ReadonlyMap<'/' | '@', readonly string[]> { readonly lexicon: ObservableSnapshot<ReadonlyMap<'/' | '@', readonly string[]>> = {
return this.deps.slash?.()?.lexicon() ?? EMPTY_LEXICON getSnapshot: () => this.deps.slash?.()?.lexicon.getSnapshot() ?? EMPTY_LEXICON,
subscribe: fn => this.deps.slash?.()?.lexicon.subscribe(fn) ?? (() => {}),
} }
/** /**

View File

@@ -54,8 +54,8 @@
border: none; border: none;
border-radius: 12px; border-radius: 12px;
background: transparent; background: transparent;
font-size: 13px; font-size: 14px;
line-height: 16px; line-height: 20px;
color: var(--dsw-alias-label-tertiary); color: var(--dsw-alias-label-tertiary);
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
@@ -72,13 +72,6 @@
cursor: default; cursor: default;
} }
.meta {
margin-left: 4px;
font-size: 12px;
line-height: 18px;
color: var(--dsw-alias-label-tertiary);
}
/* figma Tab_Group 34:11441: 35px strip, gap 36, pad-left 8, tabs bottom-aligned. */ /* figma Tab_Group 34:11441: 35px strip, gap 36, pad-left 8, tabs bottom-aligned. */
.tabs { .tabs {
display: flex; display: flex;
@@ -87,7 +80,7 @@
padding-left: 8px; padding-left: 8px;
} }
/* figma .Tab 34:11442: 13/16 wt510 text, gap 8 to the 3px bar (no bottom rounding). */ /* figma .Tab 34:11442: 13/16 text (figma wt510, rendered 500), gap 8 to the 3px bar (no bottom rounding). */
.tab { .tab {
position: relative; position: relative;
padding: 0 0 11px; padding: 0 0 11px;
@@ -95,7 +88,7 @@
background: transparent; background: transparent;
font-size: 13px; font-size: 13px;
line-height: 16px; line-height: 16px;
font-weight: 510; font-weight: 500;
color: var(--dsw-alias-label-tertiary); color: var(--dsw-alias-label-tertiary);
cursor: pointer; cursor: pointer;
} }
@@ -139,11 +132,45 @@
NOT absolute+transform: a transform would make this box the containing NOT absolute+transform: a transform would make this box the containing
block for position:fixed descendants (pickers/modals), shrinking them. */ block for position:fixed descendants (pickers/modals), shrinking them. */
.composerHero { .composerHero {
position: relative; /* .heroGlow positioning context */
align-self: center; align-self: center;
/* figma 75:8208: 12 between hero chrome / workspace row / card. */
gap: 12px;
/* Foot inside the centered box floats the stack a bit above true center. */
padding-bottom: 32px;
width: min(776px, calc(100% - 48px)); width: min(776px, calc(100% - 48px));
z-index: 1; z-index: 1;
} }
/* Blue backdrop ellipse (figma 313:14109), centered on the input card: the
card's resting center sits ~92px above the stack bottom (32 foot pad +
half of the ~120px two-row card); width tracks the card (glow asset 1051
vs design card 776) so blur scales in userSpace with it. z-index -1 keeps
it behind the in-flow hero content inside this stacking context. */
.heroGlow {
position: absolute;
left: 50%;
bottom: 92px;
z-index: -1;
width: calc(100% * 1051 / 776);
aspect-ratio: 1051 / 468;
transform: translate(-50%, 50%);
pointer-events: none;
}
.heroWorkspaceRow {
display: flex;
align-items: center;
min-width: 0;
padding-left: 8px;
}
.root[data-phase='hero'] { .root[data-phase='hero'] {
justify-content: center; justify-content: center;
} }
/* Settling (session replaying, hero/docked unknown): keep the composer
mounted but invisible so no wrong layout flashes before the phase lands. */
.root[data-phase='settling'] .composerStack {
visibility: hidden;
}

View File

@@ -6,7 +6,7 @@ import { useEffect, useRef, useState } from 'react'
import clsx from 'clsx' import clsx from 'clsx'
import type { WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client' import type { WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
import type { ConversationSlotProps, InputZone } from '../contract/slots.ts' import type { ConversationSlotProps, InputZone } from '../contract/slots.ts'
import { HeroShell, WorkspaceChip, workspaceLabel } from './EmptyHero.tsx' import { HeroGlow, HeroShell, WorkspaceChip, workspaceLabel } from './EmptyHero.tsx'
import { DisabledInputBar } from './DisabledInputBar.tsx' import { DisabledInputBar } from './DisabledInputBar.tsx'
import css from './ConversationRoot.module.css' import css from './ConversationRoot.module.css'
@@ -36,33 +36,53 @@ export function ConversationRoot({
workspace => workspace.workspaceId === pendingWorkspaceId, workspace => workspace.workspaceId === pendingWorkspaceId,
) )
// Clear the pending pick once the session lands in it, or when the picked
// workspace disappears from a ready list (deleted from the sidebar).
useEffect(() => { useEffect(() => {
if (pendingWorkspaceId !== undefined if (pendingWorkspaceId === undefined) return
&& sessionWorkspace?.workspaceId === pendingWorkspaceId) { if (sessionWorkspace?.workspaceId === pendingWorkspaceId
|| (workspaces.phase === 'ready' && pendingWorkspace === undefined)) {
setPendingWorkspaceId(undefined) setPendingWorkspaceId(undefined)
} }
}, [pendingWorkspaceId, sessionWorkspace?.workspaceId]) }, [pendingWorkspaceId, sessionWorkspace?.workspaceId, workspaces.phase, pendingWorkspace])
const hero = sessionId === undefined || (composerPhase === 'blank' && (openState === 'open' || openState === 'loading')) // While a session is still replaying (loading + blank) the hero/docked
// choice is unknowable — render the composer hidden instead of flashing
// the centered hero and snapping to the docked bar (or vice versa).
const settling = sessionId !== undefined && composerPhase === 'blank' && openState === 'loading'
const hero = sessionId === undefined || (composerPhase === 'blank' && openState === 'open')
const zone: InputZone | undefined = const zone: InputZone | undefined =
session === undefined || inputState === undefined ? undefined : { session, input: inputState } session === undefined || inputState === undefined ? undefined : { session, input: inputState }
// Flow optimization — worth a close PR review for code/boundary issues.
// The chip is a selector; label resolution walks the flow top-down:
// 1. a just-picked workspace (pending) → its title;
// 2. cold start, no session yet → placeholder ("Choose workspace");
// 3. the blank session's workspace is in the list → its title;
// 4. list still loading → cwd folder name bridges so the title does not
// flash on refresh (empty cwd → placeholder);
// 5. list ready but no owning workspace (deleted from the sidebar) →
// placeholder, never the deleted folder's name via cwd.
const chipTitle = pendingWorkspace?.title
?? (sessionId === undefined
? undefined
: sessionWorkspace?.title
?? (workspaces.phase === 'ready' || cwd === undefined || cwd === ''
? undefined
: workspaceLabel(cwd)))
const heroWorkspaceRow = ( const heroWorkspaceRow = (
<> <div className={css.heroWorkspaceRow}>
<WorkspaceChip <WorkspaceChip
buttonRef={pickerAnchor} buttonRef={pickerAnchor}
label={ label={chipTitle}
pendingWorkspace?.title
?? (sessionId === undefined
? workspaceLabel('')
: sessionWorkspace?.title ?? workspaceLabel(cwd ?? ''))
}
menuOpen={pickerOpen} menuOpen={pickerOpen}
onClick={() => { setPickerOpen(open => !open) }} onClick={() => { setPickerOpen(open => !open) }}
/> />
{renderSlot('conversation.hero.workspace', { {renderSlot('conversation.hero.workspace', {
open: pickerOpen, open: pickerOpen,
anchorRef: pickerAnchor, anchorRef: pickerAnchor,
selectedId: pendingWorkspaceId ?? sessionWorkspace?.workspaceId,
onPick: (workspaceId) => { onPick: (workspaceId) => {
setPickerOpen(false) setPickerOpen(false)
setPendingWorkspaceId(workspaceId) setPendingWorkspaceId(workspaceId)
@@ -72,10 +92,13 @@ export function ConversationRoot({
}, },
onClose: () => { setPickerOpen(false) }, onClose: () => { setPickerOpen(false) },
})} })}
</> </div>
) )
const inputBar = sessionId === undefined // The placeholder chip ("Choose workspace") and the inert input travel
// together: a blank session whose workspace vanished (deleted from the
// sidebar) reverts to the same disabled bar as the initial no-session state.
const inputBar = sessionId === undefined || (hero && chipTitle === undefined)
? <DisabledInputBar /> ? <DisabledInputBar />
: renderSlot('conversation.composer.bar', { : renderSlot('conversation.composer.bar', {
variant: hero ? 'hero' : 'composer', variant: hero ? 'hero' : 'composer',
@@ -87,6 +110,7 @@ export function ConversationRoot({
const composerBar = ( const composerBar = (
<div className={clsx(css.composerStack, hero && css.composerHero)}> <div className={clsx(css.composerStack, hero && css.composerHero)}>
{hero && <HeroGlow className={css.heroGlow} />}
{hero && <HeroShell />} {hero && <HeroShell />}
{hero && heroWorkspaceRow} {hero && heroWorkspaceRow}
{!hero && zone !== undefined && renderSlot('conversation.input.dock', zone)} {!hero && zone !== undefined && renderSlot('conversation.input.dock', zone)}
@@ -101,7 +125,7 @@ export function ConversationRoot({
) )
return ( return (
<div className={css.root} data-phase={hero ? 'hero' : 'active'}> <div className={css.root} data-phase={settling ? 'settling' : hero ? 'hero' : 'active'}>
{/* Mounted for every real session, hero included: ConversationSession {/* Mounted for every real session, hero included: ConversationSession
renders no chrome while blank but owns the draft-persistence mirror renders no chrome while blank but owns the draft-persistence mirror
bind — unmounting it in the hero would lose pre-first-send text on bind — unmounting it in the hero would lose pre-first-send text on

View File

@@ -31,7 +31,6 @@ export function ConversationSession({
const activeId = useStore(s => s.view) ?? 'chat' const activeId = useStore(s => s.view) ?? 'chat'
const active = tabs.find(view => view.id === activeId) ?? tabs[0] const active = tabs.find(view => view.id === activeId) ?? tabs[0]
const ancestry = useSessions(s => deriveAncestry(s, sessionId), shallowEqual) const ancestry = useSessions(s => deriveAncestry(s, sessionId), shallowEqual)
const turns = useSession(s => countTurns(s))
const composerPhase = useSession(s => s.composerPhase) const composerPhase = useSession(s => s.composerPhase)
const blank = useSession(s => s.blank) const blank = useSession(s => s.blank)
const inputState = useInput(s => s) const inputState = useInput(s => s)
@@ -69,7 +68,6 @@ export function ConversationSession({
) )
})} })}
{ancestry.length === 0 && <span className={css.crumbCurrent}>{sessionId}</span>} {ancestry.length === 0 && <span className={css.crumbCurrent}>{sessionId}</span>}
<span className={css.meta}>· {turns} turns</span>
</nav> </nav>
</div> </div>
{tabs.length > 1 && ( {tabs.length > 1 && (
@@ -96,9 +94,3 @@ export function ConversationSession({
</> </>
) )
} }
function countTurns(snapshot: { nodes: readonly { kind: string }[] }): number {
let count = 0
for (const node of snapshot.nodes) if (node.kind === 'user') count += 1
return count
}

View File

@@ -29,7 +29,7 @@ export function DisabledInputBar() {
<div className={css.trailing}> <div className={css.trailing}>
<button type="button" className={css.primary} aria-label="Send message" disabled> <button type="button" className={css.primary} aria-label="Send message" disabled>
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden> <svg viewBox="0 0 16 16" width="16" height="16" aria-hidden>
<path d="M8 13V3.8M8 3.8L3.8 8M8 3.8L12.2 8" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none" /> <path d="M8.3125 0.980183C8.66767 1.0531 8.97902 1.20418 9.2627 1.43233C9.48724 1.61297 9.73029 1.85793 9.97949 2.10714L14.707 6.83468L13.293 8.24874L9 3.95577V15.0417H7V3.95577L2.70703 8.24874L1.29297 6.83468L6.02051 2.10714C6.26971 1.85793 6.51277 1.61297 6.7373 1.43233C6.97662 1.23986 7.28445 1.04402 7.6875 0.980183C7.8973 0.947006 8.1031 0.95516 8.3125 0.980183Z" fill="currentColor" />
</svg> </svg>
</button> </button>
</div> </div>

View File

@@ -7,20 +7,18 @@
import { useId } from 'react' import { useId } from 'react'
import type { ReactNode, RefObject } from 'react' import type { ReactNode, RefObject } from 'react'
import { import {
FishLogo, IconChevronDownOutline14, IconFolderOpen16, FishLogo, IconChevronDownOutline14, IconFolderClose16, IconFolderOpen16,
} from '@deepseek-ai/dsh-client-ui-primitives' } from '@deepseek-ai/dsh-client-ui-primitives'
import { workspaceTitleOf } from '@deepseek-ai/dsh-client-runtime/client' import { workspaceTitleOf } from '@deepseek-ai/dsh-client-runtime/client'
import css from './HeroShell.module.css' import css from './HeroShell.module.css'
/** /**
* Basename label for the workspace chip / menu rows (the shared derivation); * Basename label for the workspace chip (the shared derivation);
* empty → the design's "New Workspace" placeholder copy; separator-only * separator-only paths echo the raw cwd.
* paths echo the raw cwd. * @param cwd - workspace directory path (non-empty).
* @param cwd - workspace directory path ('' for none).
* @returns chip label. * @returns chip label.
*/ */
export function workspaceLabel(cwd: string): string { export function workspaceLabel(cwd: string): string {
if (cwd === '') return 'New Workspace'
const base = workspaceTitleOf(cwd) const base = workspaceTitleOf(cwd)
return base !== '' ? base : cwd return base !== '' ? base : cwd
} }
@@ -28,15 +26,17 @@ export function workspaceLabel(cwd: string): string {
/** /**
* The workspace chip (folder + label + chevron), always interactive: before * The workspace chip (folder + label + chevron), always interactive: before
* the first message the workspace stays switchable — picking another one * the first message the workspace stays switchable — picking another one
* moves the New Session flow to that workspace's blank session. * moves the New Session flow to that workspace's blank session. Without a
* @param props.label - chip label (see {@link workspaceLabel}). * label the chip renders its placeholder state: closed folder + the
* "Choose workspace" call to action.
* @param props.label - chip label (see {@link workspaceLabel}); omitted → placeholder.
* @param props.menuOpen - menu expansion echo. * @param props.menuOpen - menu expansion echo.
* @param props.onClick - menu toggle. * @param props.onClick - menu toggle.
* @returns the chip button element. * @returns the chip button element.
*/ */
export function WorkspaceChip({ buttonRef, label, menuOpen = false, onClick }: { export function WorkspaceChip({ buttonRef, label, menuOpen = false, onClick }: {
buttonRef?: RefObject<HTMLButtonElement> buttonRef?: RefObject<HTMLButtonElement>
label: string label?: string | undefined
menuOpen?: boolean menuOpen?: boolean
onClick?: () => void onClick?: () => void
}) { }) {
@@ -50,13 +50,49 @@ export function WorkspaceChip({ buttonRef, label, menuOpen = false, onClick }: {
aria-expanded={menuOpen} aria-expanded={menuOpen}
onClick={onClick} onClick={onClick}
> >
<IconFolderOpen16 className={css.folder} size={16} /> {label === undefined
<span className={css.workspaceLabel}>{label}</span> ? <IconFolderClose16 className={css.folder} size={16} />
: <IconFolderOpen16 className={css.folder} size={16} />}
<span className={css.workspaceLabel}>{label ?? 'Choose workspace'}</span>
<IconChevronDownOutline14 className={css.chevron} size={12} /> <IconChevronDownOutline14 className={css.chevron} size={12} />
</button> </button>
) )
} }
/**
* The soft blue backdrop ellipse (figma 313:14109). Rendered by the hero
* owner (ConversationRoot), not HeroShell, so it can center on the input
* card; the owner's className supplies all positioning.
* @param props.className - positioning class from the owner.
* @returns the blurred-ellipse svg element.
*/
export function HeroGlow({ className }: { className?: string | undefined }) {
// Stable filter id so multiple hero mounts do not collide in the DOM.
const glowFilterId = `empty-glow-${useId().replace(/:/g, '')}`
return (
<svg className={className} viewBox="0 0 1051 468" fill="none" aria-hidden="true">
<defs>
<filter
id={glowFilterId}
x="0"
y="0"
width="1051"
height="468"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB"
>
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
<feGaussianBlur stdDeviation="50" result="effect1_foregroundBlur" />
</filter>
</defs>
<g filter={`url(#${glowFilterId})`}>
<ellipse cx="525.5" cy="234" rx="425.5" ry="134" fill="#6187D8" fillOpacity="0.08" />
</g>
</svg>
)
}
/** Hero chrome props. The workspace row rides the InputBar accessory hole, not here. */ /** Hero chrome props. The workspace row rides the InputBar accessory hole, not here. */
export interface HeroShellProps { export interface HeroShellProps {
/** Overlay content after the stack (modals). */ /** Overlay content after the stack (modals). */
@@ -64,13 +100,12 @@ export interface HeroShellProps {
} }
/** /**
* Render the hero chrome (headline + glow; no composer, no workspace row). * Render the hero chrome (headline only; no glow, no composer, no workspace
* row — the glow is the owner's {@link HeroGlow}).
* @param props - see {@link HeroShellProps}. * @param props - see {@link HeroShellProps}.
* @returns the centered hero element tree. * @returns the centered hero element tree.
*/ */
export function HeroShell({ children }: HeroShellProps) { export function HeroShell({ children }: HeroShellProps) {
// Stable filter id so multiple hero mounts do not collide in the DOM.
const glowFilterId = `empty-glow-${useId().replace(/:/g, '')}`
return ( return (
<div className={css.root}> <div className={css.root}>
<div className={css.stack}> <div className={css.stack}>
@@ -80,29 +115,6 @@ export function HeroShell({ children }: HeroShellProps) {
Let&apos;s start building Let&apos;s start building
</div> </div>
<div className={css.body}> <div className={css.body}>
{/* figma 313:14109: soft ellipse behind workspace + composer; width
tracks the card (glow asset 1051 vs design card 776) so blur
scales in userSpace with it. */}
<svg className={css.glow} viewBox="0 0 1051 468" fill="none" aria-hidden="true">
<defs>
<filter
id={glowFilterId}
x="0"
y="0"
width="1051"
height="468"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB"
>
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
<feGaussianBlur stdDeviation="50" result="effect1_foregroundBlur" />
</filter>
</defs>
<g filter={`url(#${glowFilterId})`}>
<ellipse cx="525.5" cy="234" rx="425.5" ry="134" fill="#6187D8" fillOpacity="0.1" />
</g>
</svg>
{/* The resident composer (rendered by ConversationRoot at its stable {/* The resident composer (rendered by ConversationRoot at its stable
tree position; the workspace row rides its accessory hole) is tree position; the workspace row rides its accessory hole) is
CSS-positioned into this gap during the hero phase — see CSS-positioned into this gap during the hero phase — see

View File

@@ -8,8 +8,7 @@
justify-content: center; justify-content: center;
height: 100%; height: 100%;
min-width: 0; min-width: 0;
padding: 24px; padding: 0 24px;
margin-bottom: -70px;
} }
/* Cap matches InputBar card width (800). Glow may paint past the sides. */ /* Cap matches InputBar card width (800). Glow may paint past the sides. */
@@ -24,17 +23,15 @@
overflow: visible; overflow: visible;
} }
/* figma 34:10411: fish + title, gap 10, centered; 26/32 wt600; title block /* figma 34:10411: fish + title, gap 10, centered; 26/32 wt500. */
keeps 36px below the headline before the flex gap. */
.headline { .headline {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 10px; gap: 10px;
padding-bottom: 36px;
font-size: 26px; font-size: 26px;
line-height: 32px; line-height: 32px;
font-weight: 600; font-weight: 500;
color: var(--dsw-alias-label-primary); color: var(--dsw-alias-label-primary);
} }
@@ -44,8 +41,9 @@
color: var(--dsw-alias-state-business-primary); color: var(--dsw-alias-state-business-primary);
} }
/* Workspace row sits 12px above the input card (figma y80 → y112). Glow is /* Workspace row sits 12px above the input card (figma y80 → y112). The blue
centered on this block so it stays under the picker + InputBar together. */ glow lives with the owner (ConversationRoot .heroGlow) so it can center on
the input card. */
.body { .body {
position: relative; position: relative;
display: flex; display: flex;
@@ -55,19 +53,7 @@
overflow: visible; overflow: visible;
} }
/* Design input 776 → glow SVG 1051×468 (ellipse 851×268 + blur pad). */ .body > * {
.glow {
position: absolute;
left: 50%;
top: 50%;
z-index: 0;
width: calc(100% * 1051 / 776);
aspect-ratio: 1051 / 468;
transform: translate(-50%, -50%);
pointer-events: none;
}
.body > :not(.glow) {
position: relative; position: relative;
z-index: 1; z-index: 1;
} }
@@ -88,7 +74,7 @@
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 4px; gap: 4px;
max-width: fit-content; max-width: min(100%, 360px);
min-height: 28px; min-height: 28px;
padding: 0 8px; padding: 0 8px;
border: none; border: none;

View File

@@ -171,6 +171,10 @@
.input, .input,
.mirror, .mirror,
.backdrop { .backdrop {
/* Textareas default to content-box (unlike buttons/inputs): without this the
width:100% textarea gains its padding OUTSIDE the card and text runs past
the right padding — and wraps 28px later than the mirror/backdrop layers. */
box-sizing: border-box;
/* figma .InputText 34:10434: pl 16 / pr 12 / pt 4. Backdrop MUST share these /* figma .InputText 34:10434: pl 16 / pr 12 / pt 4. Backdrop MUST share these
metrics or the highlight ranges drift off the glyphs. */ metrics or the highlight ranges drift off the glyphs. */
padding: 4px 12px 0 16px; padding: 4px 12px 0 16px;
@@ -306,11 +310,14 @@
border: none; border: none;
border-radius: 999px; border-radius: 999px;
background: var(--dsw-alias-button-info-fill); background: var(--dsw-alias-button-info-fill);
color: var(--dsw-alias-label-primary-foreground); /* Static white, not the foreground token: the arrow stays white on the blue
fill in both themes (design 34:10465). */
color: #fff;
cursor: pointer; cursor: pointer;
transition: background-color 100ms ease;
} }
.primary:hover { .primary:hover:not(:disabled) {
background: var(--dsw-alias-button-info-hover); background: var(--dsw-alias-button-info-hover);
} }
@@ -319,14 +326,6 @@
cursor: default; cursor: default;
} }
/* Stop state: same slot, dimmed brand fill — the running-state send-key
replacement is a design gap filled by us (figma gives no stop form). */
.stopping,
.stopping:hover {
background: var(--dsw-alias-button-primary-dimmed);
color: var(--dsw-alias-label-primary);
}
.retry { .retry {
margin-left: 8px; margin-left: 8px;
padding: 1px 8px; padding: 1px 8px;

View File

@@ -1,11 +1,12 @@
/** The default composer body: the 'conversation.composer.bar' slot entry /** The default composer body: the 'conversation.composer.bar' slot entry
* (decision 20). Machine state arrives through the standard provide channel * (decision 20). Machine state arrives through the standard provide channel
* (useInput + inputActions); the keyboard/DOM command face and stop arrive * (useInput + inputActions); the keyboard/DOM command face and stop arrive
* through this entry's own inject; layout-phase inputs (variant, placeholder, * through this entry's own inject, whose hooks compartment binds
* useNotices/useLexicon; layout-phase inputs (variant, placeholder,
* region-slot content) ride the owner props. Session facts * region-slot content) ride the owner props. Session facts
* (running/removed/promptError) are self-selected via useSession. */ * (running/removed/promptError) are self-selected via useSession. */
import { useEffect, useRef, useState, useSyncExternalStore } from 'react' import { useEffect, useRef, useState } from 'react'
import type { ChangeEvent, KeyboardEvent, MouseEvent, ReactNode } from 'react' import type { ChangeEvent, KeyboardEvent, MouseEvent, ReactNode } from 'react'
import clsx from 'clsx' import clsx from 'clsx'
import { IconPlusOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' import { IconPlusOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
@@ -27,15 +28,12 @@ const READONLY_OPTIONS: readonly { id: string; label: string }[] = [
] ]
export function InputBar({ export function InputBar({
useSession, useInput, inputActions, keyboard, stop, renderSlot, useSession, useInput, inputActions, keyboard, stop, renderSlot, useNotices, useLexicon,
variant, placeholder, accessory, overlay, leftItems, rightItems, onAdd, addLabel = 'Add attachment', variant, placeholder, accessory, overlay, leftItems, rightItems, onAdd, addLabel = 'Add attachment',
}: InputBarProps) { }: InputBarProps) {
const input = useInput(s => s) const input = useInput(s => s)
const noticeStore = keyboard.notices const notice = useNotices(s => s)
const notice = useSyncExternalStore( const lexicon = useLexicon(s => s)
(fn: () => void) => noticeStore.subscribe(fn),
() => noticeStore.getSnapshot(),
)
const promptError = useSession(s => s.promptError) const promptError = useSession(s => s.promptError)
const running = useSession(s => s.running) const running = useSession(s => s.running)
const disabled = useSession(s => s.removed) const disabled = useSession(s => s.removed)
@@ -244,7 +242,7 @@ export function InputBar({
// claim token highlights through behind the textarea glyphs; each U+FFFC // claim token highlights through behind the textarea glyphs; each U+FFFC
// placeholder renders as a chip (the textarea's own glyph is invisible, the // placeholder renders as a chip (the textarea's own glyph is invisible, the
// backdrop chip supplies the visual); the claim hint is ghost text. // backdrop chip supplies the visual); the claim hint is ghost text.
const deco = deriveDecorations(input, keyboard.lexicon()) const deco = deriveDecorations(input, lexicon)
const backdrop: ReactNode[] = [] const backdrop: ReactNode[] = []
{ {
// Segment boundaries: the token range end, every chip offset, and every // Segment boundaries: the token range end, every chip offset, and every
@@ -374,7 +372,7 @@ export function InputBar({
{machineBusy && <span className={css.pending} data-input-pending aria-label="处理中" />} {machineBusy && <span className={css.pending} data-input-pending aria-label="处理中" />}
<button <button
type="button" type="button"
className={clsx(css.primary, running && css.stopping)} className={css.primary}
aria-label={primaryLabel} aria-label={primaryLabel}
title={primaryLabel} title={primaryLabel}
disabled={!running && (empty || disabled || machineBusy)} disabled={!running && (empty || disabled || machineBusy)}
@@ -383,11 +381,11 @@ export function InputBar({
> >
{running ? ( {running ? (
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden> <svg viewBox="0 0 16 16" width="16" height="16" aria-hidden>
<rect x="4" y="4" width="8" height="8" rx="1.5" fill="currentColor" /> <rect x="3" y="3" width="10" height="10" rx="3" fill="currentColor" />
</svg> </svg>
) : ( ) : (
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden> <svg viewBox="0 0 16 16" width="16" height="16" aria-hidden>
<path d="M8 13V3.8M8 3.8L3.8 8M8 3.8L12.2 8" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none" /> <path d="M8.3125 0.980183C8.66767 1.0531 8.97902 1.20418 9.2627 1.43233C9.48724 1.61297 9.73029 1.85793 9.97949 2.10714L14.707 6.83468L13.293 8.24874L9 3.95577V15.0417H7V3.95577L2.70703 8.24874L1.29297 6.83468L6.02051 2.10714C6.26971 1.85793 6.51277 1.61297 6.7373 1.43233C6.97662 1.23986 7.28445 1.04402 7.6875 0.980183C7.8973 0.947006 8.1031 0.95516 8.3125 0.980183Z" fill="currentColor" />
</svg> </svg>
)} )}
</button> </button>

View File

@@ -92,15 +92,15 @@
color: var(--dsw-alias-state-success-primary); color: var(--dsw-alias-state-success-primary);
} }
.glyphPending {
color: var(--dsw-alias-label-caption);
}
.glyphProgress { .glyphProgress {
color: var(--dsw-alias-state-business-primary); color: var(--dsw-alias-state-business-primary);
animation: todo-progress-spin 1s linear infinite; animation: todo-progress-spin 1s linear infinite;
} }
.glyphPending {
color: var(--dsw-alias-label-caption);
}
@keyframes todo-progress-spin { @keyframes todo-progress-spin {
to { to {
transform: rotate(360deg); transform: rotate(360deg);

View File

@@ -1,6 +1,8 @@
/* Bash toolview: same geometry/tokens as ToolRow (figma Bash · description). */ /* Bash toolview: same geometry/tokens as ToolRow (figma Bash · description). */
.root { .root {
position: relative; /* sweep-glare overlay anchor */
overflow: hidden;
display: flex; display: flex;
align-items: center; align-items: center;
height: 24px; height: 24px;
@@ -9,8 +11,27 @@
border-radius: 6px; border-radius: 6px;
} }
.root:hover { /* Running sweep glare — same deepsuite ShimmerText pattern as ToolRow. */
background: var(--dsw-alias-interactive-bg-hover); .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-bash-row-sweep 2.6s ease-out infinite;
pointer-events: none;
}
@keyframes dsh-bash-row-sweep {
0% { left: -300px; }
90%, 100% { left: 100%; }
} }
.leading { .leading {
@@ -39,7 +60,7 @@
flex: none; flex: none;
font-size: 14px; font-size: 14px;
line-height: 24px; line-height: 24px;
color: var(--dsw-alias-label-primary-dimmed); color: var(--dsw-alias-label-secondary);
} }
.sep { .sep {

View File

@@ -12,10 +12,10 @@ import css from './bash-sample.module.css'
function leadingFor(state: ToolRowState) { function leadingFor(state: ToolRowState) {
switch (state) { switch (state) {
case 'running': return <StateDot state="ongoing" />
case 'error': return <StateDot state="error" /> case 'error': return <StateDot state="error" />
case 'stopped': return <StateDot state="warning" /> case 'stopped': return <StateDot state="warning" />
default: return <IconApiOutline14 size={16} /> // Running keeps the icon — the row sweep carries the in-flight signal.
default: return <IconApiOutline14 size={14} />
} }
} }

View File

@@ -10,10 +10,6 @@
border-radius: 6px; border-radius: 6px;
} }
.row:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.leading { .leading {
flex: none; flex: none;
width: 16px; width: 16px;
@@ -29,6 +25,7 @@
flex: none; flex: none;
font-size: 14px; font-size: 14px;
line-height: 24px; line-height: 24px;
font-weight: 500; /* figma wt510, rendered 500 */
color: var(--dsw-alias-label-primary-dimmed); color: var(--dsw-alias-label-primary-dimmed);
} }

View File

@@ -87,12 +87,13 @@ async function bench() {
} }
} }
const providers: TestProvider[] = [] const providers: TestProvider[] = []
const absentInfo = { sessionId: undefined, hooks: {}, props: {} }
const sessionsFake = { const sessionsFake = {
list: listStore, list: listStore,
binding: (id: SessionId) => ({ sessionId: id, session: sessionFake, ctx: mint(id) }), binding: (id: SessionId) => ({ sessionId: id, session: sessionFake, ctx: mint(id) }),
scope: (id: SessionId) => mint(id), scope: (id: SessionId) => mint(id),
provideInfo: () => undefined, provideInfo: () => undefined,
maybeProvideInfo: () => ({ hooks: {}, props: {} }), currentProvideInfo: { getSnapshot: () => absentInfo, subscribe: () => () => {} },
provide: (descriptor: TestProvider) => { providers.push(descriptor); return () => {} }, provide: (descriptor: TestProvider) => { providers.push(descriptor); return () => {} },
scopeOf, scopeOf,
sessionOf: (actx: Context) => (scopeOf(actx) === undefined ? undefined : sessionFake), sessionOf: (actx: Context) => (scopeOf(actx) === undefined ? undefined : sessionFake),

View File

@@ -32,12 +32,13 @@ async function bench() {
current: undefined, current: undefined,
phase: 'ready', phase: 'ready',
}) })
const absentInfo = { sessionId: undefined, hooks: {}, props: {} }
const sessionsFake = { const sessionsFake = {
list: listStore, list: listStore,
binding: vi.fn(), binding: vi.fn(),
scope: () => undefined, scope: () => undefined,
provideInfo: () => undefined, provideInfo: () => undefined,
maybeProvideInfo: () => ({ hooks: {}, props: {} }), currentProvideInfo: { getSnapshot: () => absentInfo, subscribe: () => () => {} },
provide: vi.fn(() => () => {}), provide: vi.fn(() => () => {}),
create: vi.fn(), create: vi.fn(),
open: vi.fn(), open: vi.fn(),

View File

@@ -87,6 +87,9 @@ async function bench(snapshot: ConversationSnapshot) {
// Provide-channel contributions land in this bundle the way the runtime // Provide-channel contributions land in this bundle the way the runtime
// materializes them; the renderer host serves it through provideInfo. // materializes them; the renderer host serves it through provideInfo.
const provided: { hooks: Record<string, unknown>; props: Record<string, unknown> } = { hooks: {}, props: {} } const provided: { hooks: Record<string, unknown>; props: Record<string, unknown> } = { hooks: {}, props: {} }
// Identity-stable currentProvideInfo snapshot (uSES getSnapshot contract),
// materialized on first render after the provide contributions landed.
let infoCell: { sessionId: SessionId; hooks: Record<string, unknown>; props: Record<string, unknown> } | undefined
const sessionsFake = { const sessionsFake = {
list, list,
binding: (id: SessionId) => (id === SID binding: (id: SessionId) => (id === SID
@@ -103,9 +106,10 @@ async function bench(snapshot: ConversationSnapshot) {
provideInfo: (id: string) => (id === SID provideInfo: (id: string) => (id === SID
? { sessionId: SID, hooks: { session, ...provided.hooks }, props: provided.props } ? { sessionId: SID, hooks: { session, ...provided.hooks }, props: provided.props }
: undefined), : undefined),
maybeProvideInfo: (id: string | undefined) => (id === SID currentProvideInfo: {
? { sessionId: SID, hooks: { session, ...provided.hooks }, props: provided.props } getSnapshot: () => infoCell ??= { sessionId: SID, hooks: { session, ...provided.hooks }, props: provided.props },
: { hooks: provided.hooks, props: provided.props }), subscribe: () => () => {},
},
create: vi.fn(), create: vi.fn(),
open: vi.fn(), open: vi.fn(),
} }
@@ -251,7 +255,7 @@ describe('run_code sub-calls through the real chat machinery', () => {
const b = await bench(snapshotWith([], dispatches, [runningCode(parent)])) const b = await bench(snapshotWith([], dispatches, [runningCode(parent)]))
const view = mountApp(b.slots) const view = mountApp(b.slots)
// The nested row derives 'running' from the RunningToolCall shape — the // The nested row derives 'running' from the RunningToolCall shape — the
// same StateDot ring a native in-flight row wears. // same data-state chrome (row sweep) a native in-flight row wears.
const nested = view.container.querySelector('[data-subcalls] [data-variant][data-state="running"]') const nested = view.container.querySelector('[data-subcalls] [data-variant][data-state="running"]')
expect(nested).not.toBeNull() expect(nested).not.toBeNull()
}) })

View File

@@ -64,6 +64,16 @@ describe('tool-call-model', () => {
expect(toolRowModel('', running({ argsRaw: '' })).summary).toBe('c1') expect(toolRowModel('', running({ argsRaw: '' })).summary).toBe('c1')
}) })
it('displays workspace-rooted paths relative to the session cwd', () => {
const cwd = '/Users/u/ws/'
expect(toolRowModel('edit', running({ name: 'edit', argsRaw: '{"file_path":"/Users/u/ws/src/x.ts"}' }), cwd).summary).toBe('src/x.ts')
expect(toolRowModel('read', running({ name: 'read', argsRaw: '{"path":"/Users/u/ws/a.md"}' }), cwd).summary).toBe('a.md')
// Paths outside the workspace (and non-path summaries) stay verbatim.
expect(toolRowModel('read', running({ name: 'read', argsRaw: '{"path":"/etc/hosts"}' }), cwd).summary).toBe('/etc/hosts')
expect(toolRowModel('bash', running({ argsRaw: '{"command":"pwd"}' }), cwd).summary).toBe('pwd')
expect(toolRowModel('read', running({ name: 'read', argsRaw: '{"path":"/Users/u/ws/a.md"}' }), '').summary).toBe('/Users/u/ws/a.md')
})
it('body pretty-prints JSON args, keeps raw non-JSON, null when empty', () => { it('body pretty-prints JSON args, keeps raw non-JSON, null when empty', () => {
expect(toolRowModel('bash', running({ argsRaw: '{"a":1}' })).body).toBe('{\n "a": 1\n}') expect(toolRowModel('bash', running({ argsRaw: '{"a":1}' })).body).toBe('{\n "a": 1\n}')
expect(toolRowModel('bash', running({ argsRaw: 'raw' })).body).toBe('raw') expect(toolRowModel('bash', running({ argsRaw: 'raw' })).body).toBe('raw')
@@ -125,12 +135,12 @@ describe('ToolRow', () => {
expect(view.getByText('List files')).toBeTruthy() expect(view.getByText('List files')).toBeTruthy()
}) })
it('running and error states replace the icon with a StateDot', () => { it('running keeps the icon (row sweep carries the signal); error swaps in a StateDot', () => {
const runningView = render(<ToolRow {...rowProps} state="running" />) const runningView = render(<ToolRow {...rowProps} state="running" />)
expect(runningView.queryByTestId('tool-icon')).toBeNull() expect(runningView.queryByTestId('tool-icon')).not.toBeNull()
expect(runningView.container.querySelector('[data-state="running"]')).not.toBeNull() expect(runningView.container.querySelector('[data-state="running"]')).not.toBeNull()
const errorView = render(<ToolRow {...rowProps} state="error" />) const errorView = render(<ToolRow {...rowProps} state="error" />)
expect(errorView.queryByTestId('tool-icon')).toBeNull() expect(errorView.container.querySelector('[data-testid="tool-icon"]')).toBeNull()
}) })
it('non-expandable rows render a passive leading slot', () => { it('non-expandable rows render a passive leading slot', () => {

View File

@@ -24,6 +24,9 @@ import type { ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/clien
const SID = 's1' as SessionId const SID = 's1' as SessionId
/** Identity-stable no-session bundle (uSES getSnapshot contract). */
const ABSENT_INFO = { sessionId: undefined, hooks: {}, props: {} }
afterEach(cleanup) afterEach(cleanup)
// The chat store persists under its declared key; clear between cases. // The chat store persists under its declared key; clear between cases.
beforeEach(() => { beforeEach(() => {
@@ -89,30 +92,28 @@ async function bench(nodes: ToolResultNode[]) {
subscribe: (fn: () => void) => session.subscribe(fn), subscribe: (fn: () => void) => session.subscribe(fn),
}, },
}) })
const provideInfo = (id: string) => {
if (id !== SID) return undefined
if (info === undefined) {
const hooks: Record<string, unknown> = { session }
const props: Record<string, unknown> = {}
for (const provider of providers) {
const c = provider(bindingOf(SID))
Object.assign(hooks, c.hooks ?? {})
Object.assign(props, c.props ?? {})
}
info = { sessionId: SID, hooks, props }
}
return info
}
ctx.provide('sessions', { ctx.provide('sessions', {
list, list,
binding: bindingOf, binding: bindingOf,
scope: () => actxFake, scope: () => actxFake,
provideInfo: (id: string) => { provideInfo,
if (id !== SID) return undefined currentProvideInfo: {
if (info === undefined) { getSnapshot: () => provideInfo(SID),
const hooks: Record<string, unknown> = { session } subscribe: () => () => {},
const props: Record<string, unknown> = {}
for (const provider of providers) {
const c = provider(bindingOf(SID))
Object.assign(hooks, c.hooks ?? {})
Object.assign(props, c.props ?? {})
}
info = { sessionId: SID, hooks, props }
}
return info
},
maybeProvideInfo(id: string | undefined) {
// `this` inside an object-literal method is any under strict lint; the
// fake resolves through its own provideInfo above.
/* eslint-disable-next-line @typescript-eslint/no-unsafe-return,
@typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */
return (id === undefined ? undefined : this.provideInfo(id)) ?? { hooks: {}, props: {} }
}, },
provide: (d: { resolve: (typeof providers)[number] }) => { providers.push(d.resolve); return () => {} }, provide: (d: { resolve: (typeof providers)[number] }) => { providers.push(d.resolve); return () => {} },
scopeOf: () => SID, scopeOf: () => SID,
@@ -254,7 +255,10 @@ describe('registrant load-order seam', () => {
binding: () => undefined, binding: () => undefined,
scope: () => undefined, scope: () => undefined,
provideInfo: () => undefined, provideInfo: () => undefined,
maybeProvideInfo: () => ({ hooks: {}, props: {} }), currentProvideInfo: {
getSnapshot: () => ABSENT_INFO,
subscribe: () => () => {},
},
provide: () => () => {}, provide: () => () => {},
create: vi.fn(), create: vi.fn(),
open: vi.fn(), open: vi.fn(),

View File

@@ -131,6 +131,22 @@ describe('chat-flow derivation', () => {
expect(flowKeys(items)).toBe('n1|n2|g3|n5|g6') expect(flowKeys(items)).toBe('n1|n2|g3|n5|g6')
expect(flowKeys(deriveChatFlow([...nodes, toolResult(7, 'd')]))).toBe('n1|n2|g3|n5|g6') expect(flowKeys(deriveChatFlow([...nodes, toolResult(7, 'd')]))).toBe('n1|n2|g3|n5|g6')
}) })
it('skips render-nothing assistant nodes so tool runs stay one group', () => {
// A tool-call-only step message (and blank text/reasoning) renders nothing:
// it must not split the run into two groups with an empty line between.
const headsOnly: AssistantMessageNode = {
kind: 'assistant', seq: 4, time: 4_000, turn: 1, step: 2,
blocks: [{ kind: 'tool-call', callId: 'b', name: 'read', argsRaw: '{}' }, { kind: 'text', text: ' \n' }, { kind: 'reasoning', text: '' }],
}
const items = deriveChatFlow([toolResult(3, 'a'), headsOnly, toolResult(5, 'b')])
expect(flowKeys(items)).toBe('g3')
const group = items[0]!
expect(group.kind === 'tool-group' && group.results.map(r => r.callId)).toEqual(['a', 'b'])
// Interrupted and visible-content nodes still render (已停止 marker / prose).
expect(flowKeys(deriveChatFlow([toolResult(3, 'a'), { ...headsOnly, interrupted: true }, toolResult(5, 'b')]))).toBe('g3|n4|g5')
expect(flowKeys(deriveChatFlow([toolResult(3, 'a'), assistant(4, 'found'), toolResult(5, 'b')]))).toBe('g3|n4|g5')
})
}) })
describe('ChatView', () => { describe('ChatView', () => {

View File

@@ -90,7 +90,7 @@ describe('tails', () => {
expect(view.container.querySelector('[data-state="ok"]')).not.toBeNull() expect(view.container.querySelector('[data-state="ok"]')).not.toBeNull()
}) })
it('BashRow shows StateDot chrome for running/error/stopped (root session arm)', () => { it('BashRow carries data-state for running (row sweep) and StateDots for error/stopped (root session arm)', () => {
const sid = 'root-1' as SessionId const sid = 'root-1' as SessionId
const list = createSnapshotStore<SessionListState>({ const list = createSnapshotStore<SessionListState>({
ids: [sid], ids: [sid],

View File

@@ -56,7 +56,11 @@ function bench(over?: BenchOptions) {
// Lexicon-only stub: adjudication untouched (undefined slash methods are // Lexicon-only stub: adjudication untouched (undefined slash methods are
// never reached — these benches drive plain-draft flows only). // never reached — these benches drive plain-draft flows only).
...(lex !== undefined ...(lex !== undefined
? { slash: (() => ({ lexicon: () => lex })) as unknown as NonNullable<ShellDeps['slash']> } ? {
slash: (() => ({
lexicon: { getSnapshot: () => lex, subscribe: () => () => {} },
})) as unknown as NonNullable<ShellDeps['slash']>,
}
: {}), : {}),
}) })
if (over?.draft !== undefined && over.draft !== '') shell.setDraft(over.draft) if (over?.draft !== undefined && over.draft !== '') shell.setDraft(over.draft)
@@ -87,6 +91,8 @@ function bench(over?: BenchOptions) {
useInput: bindSnapshotSelector(shell.state), useInput: bindSnapshotSelector(shell.state),
inputActions: shell.actions, inputActions: shell.actions,
keyboard: shell, keyboard: shell,
useNotices: bindSnapshotSelector(shell.notices),
useLexicon: bindSnapshotSelector(shell.lexicon),
stop, stop,
renderSlot, renderSlot,
variant: over?.variant ?? 'composer', variant: over?.variant ?? 'composer',

View File

@@ -42,6 +42,8 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled
useInput: bindSnapshotSelector(shell.state), useInput: bindSnapshotSelector(shell.state),
inputActions: shell.actions, inputActions: shell.actions,
keyboard: shell, keyboard: shell,
useNotices: bindSnapshotSelector(shell.notices),
useLexicon: bindSnapshotSelector(shell.lexicon),
renderSlot: (() => null) as InputBarProps['renderSlot'], renderSlot: (() => null) as InputBarProps['renderSlot'],
stop: vi.fn(), stop: vi.fn(),
variant: 'composer', variant: 'composer',

View File

@@ -128,6 +128,8 @@ async function scopedBench(register?: (slash: SlashService) => void) {
useInput: bindSnapshotSelector(shell.state), useInput: bindSnapshotSelector(shell.state),
inputActions: shell.actions, inputActions: shell.actions,
keyboard: shell, keyboard: shell,
useNotices: bindSnapshotSelector(shell.notices),
useLexicon: bindSnapshotSelector(shell.lexicon),
renderSlot: (() => null) as InputBarProps['renderSlot'], renderSlot: (() => null) as InputBarProps['renderSlot'],
stop: vi.fn(), stop: vi.fn(),
variant: 'composer', variant: 'composer',
@@ -234,6 +236,35 @@ describe('scenario H: backspace breaks the token', () => {
}) })
}) })
describe('scenario: reference decoration lights up when the lexicon settles', () => {
it('a typed /name token gains the text-ref mark without further input once the roll goes hot', async () => {
let roll: readonly string[] | undefined
let notify: (() => void) | undefined
const b = await scopedBench((slash) => {
slash.registerSource({
trigger: '/', name: 'skill',
candidates: () => Promise.resolve([]),
onPick: () => undefined,
lexicon: () => roll,
subscribeLexicon: (_session: ClientSessionContext, listener: () => void) => {
notify = listener
return () => { notify = undefined }
},
} as never)
})
// Typed before the catalog settled: a plain token, no decoration.
b.type('/deploy now')
expect(b.view.container.querySelector('[data-decoration="text-ref"]')).toBeNull()
// The catalog settles (ui-skill's settle path fires the same notification).
act(() => {
roll = ['deploy']
notify?.()
})
const mark = b.view.container.querySelector('[data-decoration="text-ref"]')
expect(mark?.textContent).toBe('/deploy')
})
})
describe('scenario I: unknown /xyz + enter', () => { describe('scenario I: unknown /xyz + enter', () => {
it('adjudication misses in one hop and the whole line rides the default sink', async () => { it('adjudication misses in one hop and the whole line rides the default sink', async () => {
const b = await bench() const b = await bench()

View File

@@ -11,6 +11,9 @@ import { createChatStore } from '../src/client/stores.ts'
const sid = (s: string): SessionId => s as SessionId const sid = (s: string): SessionId => s as SessionId
/** Identity-stable no-session bundle (uSES getSnapshot contract). */
const ABSENT_INFO = { sessionId: undefined, hooks: {}, props: {} }
interface Bench { interface Bench {
slots: SlotsService slots: SlotsService
chat: ReturnType<typeof createChatStore> chat: ReturnType<typeof createChatStore>
@@ -23,7 +26,10 @@ function bench(): Bench {
ids: [], byId: {}, current: undefined, phase: 'ready', ids: [], byId: {}, current: undefined, phase: 'ready',
}), }),
provideInfo: () => undefined, provideInfo: () => undefined,
maybeProvideInfo: () => ({ hooks: {}, props: {} }), currentProvideInfo: {
getSnapshot: () => ABSENT_INFO,
subscribe: () => () => {},
},
provide: () => () => {}, provide: () => () => {},
}) })
ctx.provide('workspaces', { ctx.provide('workspaces', {

View File

@@ -122,6 +122,8 @@ function mount(
useInput={useInput} useInput={useInput}
inputActions={inputActions} inputActions={inputActions}
keyboard={wiring} keyboard={wiring}
useNotices={bindSnapshotSelector(wiring.notices)}
useLexicon={bindSnapshotSelector(wiring.lexicon)}
stop={stop} stop={stop}
renderSlot={(() => null) as InputBarProps['renderSlot']} renderSlot={(() => null) as InputBarProps['renderSlot']}
{...bar} {...bar}

View File

@@ -72,9 +72,11 @@
max-height: min(360px, calc(100vh - 96px)); max-height: min(360px, calc(100vh - 96px));
overflow: hidden; overflow: hidden;
padding: 4px; padding: 4px;
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin); /* Surface tokens match the Menu primitive card (ui-primitives
* Menu.module.css) so every dropdown reads as the same material. */
border: 1px solid var(--dsw-alias-border-inverted);
border-radius: 12px; border-radius: 12px;
background: var(--dsw-specific-input-major); background: var(--dsw-specific-menu);
box-shadow: var(--dsw-shadow-lv3); box-shadow: var(--dsw-shadow-lv3);
color: var(--dsw-alias-label-primary); color: var(--dsw-alias-label-primary);
} }
@@ -132,7 +134,7 @@
top: 0; top: 0;
z-index: 1; z-index: 1;
padding: 5px 8px 3px; padding: 5px 8px 3px;
background: var(--dsw-specific-input-major); background: var(--dsw-specific-menu);
color: var(--dsw-alias-label-tertiary); color: var(--dsw-alias-label-tertiary);
font-size: 12px; font-size: 12px;
line-height: 18px; line-height: 18px;
@@ -156,11 +158,16 @@
} }
.option:hover:not(:disabled), .option:hover:not(:disabled),
.option:focus-visible, .option:focus-visible {
.selected {
background: var(--dsw-alias-interactive-bg-hover); background: var(--dsw-alias-interactive-bg-hover);
} }
/* Selection marker is the trailing check, not a fill — matches the Menu
* primitive's selected treatment. */
.selected {
background: transparent;
}
.option:disabled { .option:disabled {
color: var(--dsw-alias-label-dimmed); color: var(--dsw-alias-label-dimmed);
cursor: default; cursor: default;
@@ -201,7 +208,7 @@
display: grid; display: grid;
place-items: center; place-items: center;
flex: 0 0 18px; flex: 0 0 18px;
color: var(--dsw-alias-state-business-primary); color: var(--dsw-alias-label-primary);
} }
/* Two-level root cells (figma 496:26454 .Menu_cell): 40px row, 10px side /* Two-level root cells (figma 496:26454 .Menu_cell): 40px row, 10px side

View File

@@ -18,7 +18,7 @@
.button:disabled { .button:disabled {
cursor: not-allowed; cursor: not-allowed;
color: var(--dsw-alias-label-dimmed); opacity: 0.4;
} }
.md { .md {
@@ -44,10 +44,6 @@
background: var(--dsw-alias-button-primary-hover); background: var(--dsw-alias-button-primary-hover);
} }
.primary:disabled {
background: var(--dsw-alias-button-primary-dimmed);
}
.ghost:hover:not(:disabled) { .ghost:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover); background: var(--dsw-alias-interactive-bg-hover);
} }
@@ -66,10 +62,6 @@
background: var(--dsw-alias-interactive-bg-hover); background: var(--dsw-alias-interactive-bg-hover);
} }
.outline:disabled {
border-color: var(--dsw-alias-border-l1);
}
.toolbar { .toolbar {
background: var(--dsw-alias-button-tool-bar-fill); background: var(--dsw-alias-button-tool-bar-fill);
} }

View File

@@ -26,6 +26,7 @@
left: 0; left: 0;
z-index: 100; z-index: 100;
min-width: 218px; min-width: 218px;
max-width: 360px;
} }
/* Portal mode: fixed in the viewport, coordinates supplied inline from the /* Portal mode: fixed in the viewport, coordinates supplied inline from the
@@ -50,6 +51,36 @@
right: 0; right: 0;
} }
/* Viewport fit: the card stops 12px short of the viewport's top/bottom edges
* (24 = 2 × the portal MARGIN in Menu.tsx) and taller content scrolls inside
* .viewport, so a pinned .footer stays visible. Menus with submenu rows skip
* this class — the overflow clip would crop the side card, so they rely on
* staying short. */
.scrollable {
max-height: calc(100vh - 24px);
}
.viewport {
display: flex;
flex-direction: column;
min-height: 0;
}
.scrollable .viewport {
overflow-y: auto;
}
/* Pinned rows below the scroll region; l2 hairline (l1 is near-invisible on
* the menu surface) mirrors the .separator spacing. */
.footer {
flex: none;
display: flex;
flex-direction: column;
margin-top: 4px;
padding-top: 4px;
border-top: 1px solid var(--dsw-alias-border-l2);
}
.itemWrap { .itemWrap {
position: relative; position: relative;
} }
@@ -109,7 +140,7 @@
} }
.item:disabled { .item:disabled {
color: var(--dsw-alias-label-dimmed); opacity: 0.4;
cursor: not-allowed; cursor: not-allowed;
} }

View File

@@ -5,6 +5,8 @@
// The owner controls `open`; outside-click closing uses one document listener // The owner controls `open`; outside-click closing uses one document listener
// active only while open. Submenus open on hover/focus inside the same root. // active only while open. Submenus open on hover/focus inside the same root.
// Entries also cover non-interactive `label` headings and `danger` rows. // Entries also cover non-interactive `label` headings and `danger` rows.
// Lists keep 12px clearance to the viewport's top/bottom edges and scroll
// internally past that; submenu-bearing menus are exempt (see .scrollable).
import { useEffect, useLayoutEffect, useRef, useState } from 'react' import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import type { CSSProperties, ReactNode } from 'react' import type { CSSProperties, ReactNode } from 'react'
@@ -50,6 +52,9 @@ function isLabel(entry: MenuEntry): entry is MenuLabel {
return 'type' in entry && entry.type === 'label' return 'type' in entry && entry.type === 'label'
} }
/** Unplaced portal list: hidden but laid out at a fixed origin so offsetWidth/offsetHeight are real. */
const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 }
/** /**
* Render an anchored dropdown menu. * Render an anchored dropdown menu.
* @param props.open - whether the list is showing (owner-controlled). * @param props.open - whether the list is showing (owner-controlled).
@@ -73,17 +78,20 @@ function isLabel(entry: MenuEntry): entry is MenuLabel {
* the trigger (render-prop anchors, effect-positioned proxies — measuring the * the trigger (render-prop anchors, effect-positioned proxies — measuring the
* wrapper there races the host's layout effects). Called on open and on every * wrapper there races the host's layout effects). Called on open and on every
* scroll/resize; return null to skip placement for that frame. * scroll/resize; return null to skip placement for that frame.
* @param props.footer - rows pinned below the scrolling items area, separated
* by a hairline; they stay visible while the items above scroll.
* @returns anchor wrapper with the conditional list. * @returns anchor wrapper with the conditional list.
*/ */
export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, compact = false, getAnchorRect, className }: { export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, compact = false, getAnchorRect, footer, className }: {
open: boolean open: boolean
anchor: ReactNode anchor: ReactNode
items: readonly MenuEntry[] items: readonly MenuEntry[]
selectedId?: string footer?: readonly MenuEntry[]
selectedId?: string | undefined
onSelect: (id: string) => void onSelect: (id: string) => void
onClose: () => void onClose: () => void
align?: 'start' | 'end' align?: 'start' | 'end'
side?: 'bottom' | 'top' side?: 'bottom' | 'top' | 'right'
portal?: boolean portal?: boolean
closeOnPointerLeave?: boolean closeOnPointerLeave?: boolean
compact?: boolean compact?: boolean
@@ -111,11 +119,34 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
r = rootRef.current?.getBoundingClientRect() ?? null r = rootRef.current?.getBoundingClientRect() ?? null
} }
if (r === null) return if (r === null) return
setFixedPos({ const MARGIN = 12
...(align === 'start' ? { left: r.left } : { right: window.innerWidth - r.right }), const vw = window.innerWidth
...(side === 'bottom' ? { top: r.bottom + 4 } : { bottom: window.innerHeight - r.top + 4 }), const vh = window.innerHeight
}) const listEl = listRef.current
const lw = listEl?.offsetWidth ?? 0
const lh = listEl?.offsetHeight ?? 0
let x: number
let y: number
if (side === 'right') {
x = r.right + 4
y = r.top
} else if (align === 'start') {
x = r.left
y = side === 'bottom' ? r.bottom + 4 : r.top - lh - 4
} else {
x = r.right - lw
y = side === 'bottom' ? r.bottom + 4 : r.top - lh - 4
}
if (lw > 0) x = Math.min(Math.max(x, MARGIN), vw - lw - MARGIN)
if (lh > 0) y = Math.min(Math.max(y, MARGIN), vh - lh - MARGIN)
setFixedPos({ left: x, top: y })
} }
// First run measures the hidden pre-render (same commit as `open`), so
// end/top alignment and clamping use real dimensions before anything
// paints — no visible jump from a zero-size first guess.
place() place()
window.addEventListener('scroll', place, true) window.addEventListener('scroll', place, true)
window.addEventListener('resize', place) window.addEventListener('resize', place)
@@ -148,11 +179,77 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
} }
}, [open, onClose]) }, [open, onClose])
const list = open && (!portal || fixedPos !== null) && ( // 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)
const renderEntry = (entry: MenuEntry) => {
if (isSeparator(entry)) {
return <div key={entry.id} className={css.separator} role="separator" />
}
if (isLabel(entry)) {
return <div key={entry.id} className={css.label} role="presentation">{entry.text}</div>
}
const hasSub = entry.submenu !== undefined && entry.submenu.length > 0
const subOpen = hasSub && openSubmenuId === entry.id
return (
<div
key={entry.id}
className={css.itemWrap}
onMouseEnter={() => { setOpenSubmenuId(hasSub ? entry.id : null) }}
onMouseLeave={() => { setOpenSubmenuId(null) }}
>
<button
type="button"
role="menuitem"
className={clsx(css.item, entry.id === selectedId && css.selected, entry.danger === true && css.danger)}
disabled={entry.disabled}
aria-haspopup={hasSub ? 'menu' : undefined}
aria-expanded={hasSub ? subOpen : undefined}
onFocus={() => { setOpenSubmenuId(hasSub ? entry.id : null) }}
onClick={() => {
if (hasSub) {
setOpenSubmenuId(entry.id)
return
}
onSelect(entry.id)
}}
>
{entry.icon !== undefined && <span className={css.itemIcon}>{entry.icon}</span>}
<span className={css.itemLabel}>{entry.label}</span>
{/* Selection marker is a trailing check (figma .Menu_cell), not a fill. */}
{entry.id === selectedId && <IconCheckOutline16 className={css.check} />}
</button>
{subOpen && entry.submenu !== undefined && (
<div className={clsx(css.submenu, compact && css.compactList)} role="menu">
{entry.submenu.map(sub => (
<button
key={sub.id}
type="button"
role="menuitem"
className={css.item}
disabled={sub.disabled}
onClick={() => { onSelect(sub.id) }}
>
{sub.icon !== undefined && <span className={css.itemIcon}>{sub.icon}</span>}
<span className={css.itemLabel}>{sub.label}</span>
</button>
))}
</div>
)}
</div>
)
}
// Portal lists render hidden until placed: the placement effect measures
// this pre-render in the same commit, so the first painted frame is
// already at the final position (with getAnchorRect returning null the
// list simply stays hidden).
const list = open && (
<div <div
ref={listRef} ref={listRef}
className={clsx(css.list, compact && css.compactList, portal && css.portal, side === 'top' && !portal && css.sideTop, align === 'end' && !portal && css.alignEnd)} className={clsx(css.list, compact && css.compactList, scrollable && css.scrollable, portal && css.portal, side === 'top' && !portal && css.sideTop, align === 'end' && !portal && css.alignEnd)}
style={fixedPos ?? undefined} style={portal ? fixedPos ?? MEASURE_STYLE : undefined}
role="menu" role="menu"
onPointerLeave={closeOnPointerLeave ? () => { onClose() } : undefined} onPointerLeave={closeOnPointerLeave ? () => { onClose() } : undefined}
// React portals bubble synthetic events through the REACT tree: without // React portals bubble synthetic events through the REACT tree: without
@@ -160,63 +257,14 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
// (open/toggle) after onSelect. // (open/toggle) after onSelect.
onClick={(e) => { e.stopPropagation() }} onClick={(e) => { e.stopPropagation() }}
> >
{items.map((entry) => { <div className={css.viewport} role="presentation">
if (isSeparator(entry)) { {items.map(renderEntry)}
return <div key={entry.id} className={css.separator} role="separator" /> </div>
} {footer !== undefined && footer.length > 0 && (
if (isLabel(entry)) { <div className={css.footer} role="presentation">
return <div key={entry.id} className={css.label} role="presentation">{entry.text}</div> {footer.map(renderEntry)}
} </div>
const hasSub = entry.submenu !== undefined && entry.submenu.length > 0 )}
const subOpen = hasSub && openSubmenuId === entry.id
return (
<div
key={entry.id}
className={css.itemWrap}
onMouseEnter={() => { setOpenSubmenuId(hasSub ? entry.id : null) }}
onMouseLeave={() => { setOpenSubmenuId(null) }}
>
<button
type="button"
role="menuitem"
className={clsx(css.item, entry.id === selectedId && css.selected, entry.danger === true && css.danger)}
disabled={entry.disabled}
aria-haspopup={hasSub ? 'menu' : undefined}
aria-expanded={hasSub ? subOpen : undefined}
onFocus={() => { setOpenSubmenuId(hasSub ? entry.id : null) }}
onClick={() => {
if (hasSub) {
setOpenSubmenuId(entry.id)
return
}
onSelect(entry.id)
}}
>
{entry.icon !== undefined && <span className={css.itemIcon}>{entry.icon}</span>}
<span className={css.itemLabel}>{entry.label}</span>
{/* Selection marker is a trailing check (figma .Menu_cell), not a fill. */}
{entry.id === selectedId && <IconCheckOutline16 className={css.check} />}
</button>
{subOpen && entry.submenu !== undefined && (
<div className={clsx(css.submenu, compact && css.compactList)} role="menu">
{entry.submenu.map(sub => (
<button
key={sub.id}
type="button"
role="menuitem"
className={css.item}
disabled={sub.disabled}
onClick={() => { onSelect(sub.id) }}
>
{sub.icon !== undefined && <span className={css.itemIcon}>{sub.icon}</span>}
<span className={css.itemLabel}>{sub.label}</span>
</button>
))}
</div>
)}
</div>
)
})}
</div> </div>
) )

View File

@@ -54,7 +54,7 @@
margin: 0; margin: 0;
font-size: 16px; font-size: 16px;
line-height: 24px; line-height: 24px;
font-weight: 510; font-weight: 500; /* figma wt510, rendered 500 */
color: var(--dsw-alias-label-primary); color: var(--dsw-alias-label-primary);
} }

View File

@@ -1,7 +1,7 @@
/* Ongoing blue has no alias token (state-business-primary is the 500 step, /* Ongoing blue has no alias token (state-business-primary is the 500 step,
* not this 450) — component-level var pinned to the static scale instead. */ * not this 450) — component-level var pinned to the static scale instead. */
.dot, .dot,
.ring { .matrix {
--dsh-state-ongoing: var(--dsw-static-deepseek-450); --dsh-state-ongoing: var(--dsw-static-deepseek-450);
} }
@@ -42,24 +42,24 @@
color: var(--dsw-alias-state-error-primary); color: var(--dsw-alias-state-error-primary);
} }
.ring { /* Pixel chase: each outer cell holds a discrete brightness step (flat keyframe
* holds, no tweening — the retro feel), peaking when the chase hits it and
* decaying over the next three cells. Phase offsets come from per-rect
* animation-delay (index * -125ms) set inline by the component. */
.matrix {
flex: none; flex: none;
color: var(--dsh-state-ongoing); color: var(--dsh-state-ongoing);
animation: dsh-state-dot-spin 1s linear infinite;
} }
.stopFrom { .cell {
stop-color: currentColor; fill: currentColor;
stop-opacity: 1; opacity: 0.15;
animation: dsh-state-dot-chase 1s infinite;
} }
.stopTo { @keyframes dsh-state-dot-chase {
stop-color: currentColor; 0%, 12.4% { opacity: 1; }
stop-opacity: 0; 12.5%, 24.9% { opacity: 0.6; }
} 25%, 37.4% { opacity: 0.35; }
37.5%, 100% { opacity: 0.15; }
@keyframes dsh-state-dot-spin {
to {
transform: rotate(360deg);
}
} }

View File

@@ -1,15 +1,19 @@
// StateDot: session state indicator (figma nodes 14:3303/3305/3312, 122:9182). // StateDot: session state indicator (figma nodes 14:3303/3305/3312, 122:9182).
// done/warning/error: 10x10 halo (same color, 10% opacity) around a 6x6 solid // done/warning/error: 10x10 halo (same color, 10% opacity) around a 6x6 solid
// core. ongoing: 10x10 ring, 1px inside stroke, color fading out along a // core. ongoing: a pixel-art chase — the 8 outer cells of a 3x3 matrix light
// linear gradient, spinning. Colors resolve through --dsw-* tokens only. // up clockwise with a stepped trail. Colors resolve through --dsw-* tokens only.
import { useId } from 'react'
import clsx from 'clsx' import clsx from 'clsx'
import css from './StateDot.module.css' import css from './StateDot.module.css'
/** Four-color session state semantic (green done / amber approval-waiting / blue running ring / red error). */ /** Four-color session state semantic (green done / amber approval-waiting / blue running ring / red error). */
export type StateDotState = 'done' | 'warning' | 'ongoing' | 'error' export type StateDotState = 'done' | 'warning' | 'ongoing' | 'error'
/** Outer 3x3 matrix cells (2px pixels on a 10px grid), clockwise from top-left. */
const MATRIX_CELLS: readonly (readonly [number, number])[] = [
[0, 0], [4, 0], [8, 0], [8, 4], [8, 8], [4, 8], [0, 8], [0, 4],
]
/** /**
* Render a state dot. * Render a state dot.
* @param props.state - which of the four states to show. * @param props.state - which of the four states to show.
@@ -22,25 +26,29 @@ export function StateDot({ state, size = 10, className }: {
size?: number size?: number
className?: string className?: string
}) { }) {
const gradientId = useId()
if (state === 'ongoing') { if (state === 'ongoing') {
return ( return (
<svg <svg
className={clsx(css.ring, className)} className={clsx(css.matrix, className)}
data-state="ongoing" data-state="ongoing"
width={size} width={size}
height={size} height={size}
viewBox="0 0 10 10" viewBox="0 0 10 10"
shapeRendering="crispEdges"
aria-hidden="true" aria-hidden="true"
> >
<defs> {MATRIX_CELLS.map(([x, y], index) => (
{/* Gradient handles from the figma node: (0.1,0) -> (0.85,1). */} <rect
<linearGradient id={gradientId} x1="1" y1="0" x2="8.5" y2="10" gradientUnits="userSpaceOnUse"> key={`${x}-${y}`}
<stop className={css.stopFrom} offset="0" /> className={css.cell}
<stop className={css.stopTo} offset="1" /> x={x}
</linearGradient> y={y}
</defs> width="2"
<circle cx="5" cy="5" r="4.5" fill="none" strokeWidth="1" stroke={`url(#${gradientId})`} /> height="2"
/* Negative delay phases the chase so every cell animates from mount. */
style={{ animationDelay: `${(index - MATRIX_CELLS.length) * 125}ms` }}
/>
))}
</svg> </svg>
) )
} }

View File

@@ -72,7 +72,7 @@ export function Tooltip({ label, side = 'right', disabled = false, children }: {
{cloneElement(children, { {cloneElement(children, {
ref: mergedRef, ref: mergedRef,
onMouseEnter: (e) => { children.props.onMouseEnter?.(e); triggers.current.hover = true; show() }, onMouseEnter: (e) => { children.props.onMouseEnter?.(e); triggers.current.hover = true; show() },
onMouseLeave: (e) => { children.props.onMouseLeave?.(e); triggers.current.hover = false; hide() }, onMouseLeave: (e) => { children.props.onMouseLeave?.(e); triggers.current.hover = false; setPos(null) },
onFocus: (e) => { children.props.onFocus?.(e); triggers.current.focus = true; show() }, onFocus: (e) => { children.props.onFocus?.(e); triggers.current.focus = true; show() },
onBlur: (e) => { children.props.onBlur?.(e); triggers.current.focus = false; hide() }, onBlur: (e) => { children.props.onBlur?.(e); triggers.current.focus = false; hide() },
})} })}

View File

@@ -273,14 +273,47 @@ describe('Menu', () => {
expect(onClose).toHaveBeenCalledTimes(1) expect(onClose).toHaveBeenCalledTimes(1)
}) })
it('portal mode positions from the opposite edges for align=end / side=top', () => { it('portal mode resolves align=end / side=top to clamped left/top coordinates', () => {
render( render(
<Menu portal open align="end" side="top" anchor={<span>trigger</span>} items={items} onSelect={() => {}} onClose={() => {}} />) <Menu portal open align="end" side="top" anchor={<span>trigger</span>} items={items} onSelect={() => {}} onClose={() => {}} />)
const menu = screen.getByRole('menu') const menu = screen.getByRole('menu')
expect(menu.style.right).not.toBe('') expect(menu.style.left).not.toBe('')
expect(menu.style.bottom).not.toBe('') expect(menu.style.top).not.toBe('')
expect(menu.style.left).toBe('') expect(menu.style.right).toBe('')
expect(menu.style.top).toBe('') expect(menu.style.bottom).toBe('')
})
it('renders footer rows in a pinned section below the items; they still select', () => {
const onSelect = vi.fn()
render(
<Menu
open
anchor={<span>trigger</span>}
items={items}
footer={[{ id: 'new', label: 'Create new' }]}
onSelect={onSelect}
onClose={() => {}}
/>)
const footerItem = screen.getByRole('menuitem', { name: 'Create new' })
expect((footerItem.closest('div[class*="footer"]'))).not.toBeNull()
expect(screen.getByRole('menuitem', { name: 'Alpha' }).closest('div[class*="footer"]')).toBeNull()
fireEvent.click(footerItem)
expect(onSelect).toHaveBeenCalledWith('new')
})
it('caps the list height for internal scrolling unless a submenu row is present', () => {
const { rerender } = render(
<Menu open anchor={<span>trigger</span>} items={items} onSelect={() => {}} onClose={() => {}} />)
expect(screen.getByRole('menu').className).toMatch(/scrollable/)
rerender(
<Menu
open
anchor={<span>trigger</span>}
items={[{ id: 'p', label: 'Parent', submenu: [{ id: 's', label: 'Sub' }] }]}
onSelect={() => {}}
onClose={() => {}}
/>)
expect(screen.getByRole('menu').className).not.toMatch(/scrollable/)
}) })
}) })

View File

@@ -14,16 +14,17 @@ describe('StateDot', () => {
expect(dot.getAttribute('aria-hidden')).toBe('true') expect(dot.getAttribute('aria-hidden')).toBe('true')
}) })
it('solid states are spans; ongoing is an svg gradient ring', () => { it('solid states are spans; ongoing is an svg pixel matrix', () => {
const { container, rerender } = render(<StateDot state="done" />) const { container, rerender } = render(<StateDot state="done" />)
expect(container.firstElementChild?.tagName).toBe('SPAN') expect(container.firstElementChild?.tagName).toBe('SPAN')
rerender(<StateDot state="ongoing" />) rerender(<StateDot state="ongoing" />)
const ring = container.firstElementChild as SVGSVGElement const matrix = container.firstElementChild as SVGSVGElement
expect(ring.tagName).toBe('svg') expect(matrix.tagName).toBe('svg')
const circle = ring.querySelector('circle') const cells = matrix.querySelectorAll('rect')
expect(circle?.getAttribute('stroke-width')).toBe('1') expect(cells).toHaveLength(8)
expect(circle?.getAttribute('stroke')).toMatch(/^url\(#/) // Chase phase: every cell carries its own negative animation delay.
expect(ring.querySelector('linearGradient')).not.toBeNull() const delays = [...cells].map(cell => (cell).style.animationDelay)
expect(new Set(delays).size).toBe(8)
}) })
it('sizes via the size prop in both shapes', () => { it('sizes via the size prop in both shapes', () => {

View File

@@ -81,23 +81,20 @@ describe('Tooltip', () => {
expect(screen.getByRole('tooltip')).toBeTruthy() expect(screen.getByRole('tooltip')).toBeTruthy()
}) })
it('keeps the bubble while either hover or focus is still active', () => { it('mouse leave hides the bubble immediately, even while the anchor stays focused', () => {
render( render(
<Tooltip label="Sticky"> <Tooltip label="Sticky">
<button type="button">anchor</button> <button type="button">anchor</button>
</Tooltip>, </Tooltip>,
) )
const anchor = screen.getByText('anchor') const anchor = screen.getByText('anchor')
// Focused AND hovered: leaving with the mouse must not drop the bubble. // Focused AND hovered: leaving with the mouse drops the bubble at once.
fireEvent.focus(anchor) fireEvent.focus(anchor)
fireEvent.mouseEnter(anchor) fireEvent.mouseEnter(anchor)
fireEvent.mouseLeave(anchor) fireEvent.mouseLeave(anchor)
expect(screen.getByRole('tooltip')).toBeTruthy()
fireEvent.blur(anchor)
expect(screen.queryByRole('tooltip')).toBeNull() expect(screen.queryByRole('tooltip')).toBeNull()
// Symmetric: blurring while still hovered keeps it, mouseleave ends it. // Re-entering shows it again; blurring while still hovered keeps it.
fireEvent.mouseEnter(anchor) fireEvent.mouseEnter(anchor)
fireEvent.focus(anchor)
fireEvent.blur(anchor) fireEvent.blur(anchor)
expect(screen.getByRole('tooltip')).toBeTruthy() expect(screen.getByRole('tooltip')).toBeTruthy()
fireEvent.mouseLeave(anchor) fireEvent.mouseLeave(anchor)

View File

@@ -72,6 +72,10 @@
cursor: pointer; cursor: pointer;
} }
.selector:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
}
.selector:disabled { .selector:disabled {
cursor: default; cursor: default;
} }
@@ -80,18 +84,21 @@
flex: none; flex: none;
} }
/* Tool Call mode cubes share an 8px gap. */ /* Tool Call mode cubes share an 8px gap and wrap to one per row when the
panel is too narrow. */
.cubeRow { .cubeRow {
display: flex; display: flex;
align-items: stretch; align-items: stretch;
gap: 8px; gap: 8px;
flex-wrap: wrap;
} }
/* Tool Call mode cube (figma '.Selector Cube' 418w r16; horizontal inset = /* Tool Call mode cube (figma '.Selector Cube' 418w r16, flexed to fit the
* outer pad 4 + inner .Menu_cell pad 10, vertical = inner pad 8). */ * 800 panel; horizontal inset = outer pad 4 + inner .Menu_cell pad 10,
* vertical = inner pad 8). */
.modeCube { .modeCube {
box-sizing: border-box; box-sizing: border-box;
width: 418px; flex: 1 1 276px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
@@ -101,6 +108,11 @@
border-radius: 16px; border-radius: 16px;
background: transparent; background: transparent;
text-align: left; text-align: left;
cursor: pointer;
}
.modeCube:hover:not(.selected) {
background: var(--dsw-alias-interactive-bg-hover);
} }
/* Selected cube: #F5F6F7 fill + #ADB2B8 border (static token — the bluish-400 /* Selected cube: #F5F6F7 fill + #ADB2B8 border (static token — the bluish-400

View File

@@ -44,7 +44,8 @@
white-space: nowrap; white-space: nowrap;
} }
/* Full-viewport layer (figma Mask 501:29946 #000@24%, no blur). */ /* Full-viewport layer (figma Mask 501:29946 #000@24%): mask tokens match the
Modal primitive (--dsw-alias-bg-mask-1 + --dsw-mask-blur). */
.overlay { .overlay {
position: fixed; position: fixed;
inset: 0; inset: 0;
@@ -58,21 +59,22 @@
position: absolute; position: absolute;
inset: 0; inset: 0;
background: var(--dsw-alias-bg-mask-1); background: var(--dsw-alias-bg-mask-1);
backdrop-filter: var(--dsw-mask-blur);
} }
/* Panel (figma Settings 501:29947): 1080x700, r24, white, lv3 shadow /* Panel (figma Settings 501:29947): r24, white, lv3 shadow (figma effects
(figma effects match --dsw-shadow-lv3 exactly). */ match --dsw-shadow-lv3 exactly); figma's 1080x700 is shrunk to 800x600. */
.panel { .panel {
position: relative; position: relative;
z-index: 1; z-index: 1;
display: flex; display: flex;
width: 1080px; width: 800px;
height: 700px; height: 600px;
max-width: calc(100vw - 48px); max-width: calc(100vw - 48px);
max-height: calc(100vh - 48px); max-height: calc(100vh - 48px);
border-radius: 24px; border-radius: 24px;
overflow: hidden; overflow: hidden;
background: var(--dsw-alias-bg-layer-1); background: var(--dsw-alias-bg-layer-2);
box-shadow: var(--dsw-shadow-lv3); box-shadow: var(--dsw-shadow-lv3);
} }

View File

@@ -10,7 +10,7 @@
import { useCallback, useEffect, useId, useRef, useState } from 'react' import { useCallback, useEffect, useId, useRef, useState } from 'react'
import clsx from 'clsx' import clsx from 'clsx'
import { IconCloseOutline16, IconDataOutline16, IconSettingsOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' import { IconCloseOutline16, IconDataOutline16, IconSettingsOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { SettingsRootComponentProps } from './contract/slots.ts' import type { SettingsRootComponentProps, SettingsSectionRow } from './contract/slots.ts'
import css from './SettingsRoot.module.css' import css from './SettingsRoot.module.css'
/** Nav glyph by section id; unknown ids fall back to the settings gear. */ /** Nav glyph by section id; unknown ids fall back to the settings gear. */
@@ -20,7 +20,7 @@ function navIcon(id: string) {
} }
type PanelProps = { type PanelProps = {
rows: ReturnType<SettingsRootComponentProps['sections']> rows: readonly SettingsSectionRow[]
renderSlot: SettingsRootComponentProps['renderSlot'] renderSlot: SettingsRootComponentProps['renderSlot']
onClose: () => void onClose: () => void
} }
@@ -92,20 +92,14 @@ function SettingsPanel({ rows, renderSlot, onClose }: PanelProps) {
* @returns the settings shell element tree. * @returns the settings shell element tree.
*/ */
export function SettingsRoot(props: SettingsRootComponentProps) { export function SettingsRoot(props: SettingsRootComponentProps) {
const { wide, subscribeSections, sectionsVersion, sections, renderSlot } = props const { wide, useSections, renderSlot } = props
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const close = useCallback(() => { setOpen(false) }, []) const close = useCallback(() => { setOpen(false) }, [])
// The ledger tick keeps the nav rows fresh: registrants re-register with // The ledger tick keeps the nav rows fresh: registrants re-register with
// freshly localized text on locale change, and the trigger/header/close // freshly localized text on locale change, and the trigger/header/close
// seats re-render through their own outlets' subscriptions. // seats re-render through their own outlets' subscriptions.
// State = ledger version: same-version notifications dedupe to no render. const rows = useSections(s => s)
const [, setSectionsRev] = useState(() => sectionsVersion())
useEffect(
() => subscribeSections(() => { setSectionsRev(sectionsVersion()) }),
[subscribeSections, sectionsVersion],
)
const rows = sections()
return ( return (
<> <>

View File

@@ -7,7 +7,7 @@
* setting never means editing the shell; copy that belongs to no single * setting never means editing the shell; copy that belongs to no single
* feature (chrome, the General section) is owned by ui-settings-general. * feature (chrome, the General section) is owned by ui-settings-general.
*/ */
import type { PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type { HostObservable, InjectFace, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
// Type-only: pulls ui-sidebar's SlotMap merge (the 'sidebar.settings' entry) // Type-only: pulls ui-sidebar's SlotMap merge (the 'sidebar.settings' entry)
// into every program that sees this contract. // into every program that sees this contract.
import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client' import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client'
@@ -72,26 +72,32 @@ export interface SettingsSectionOwnerProps {
children?: never children?: never
} }
/** One nav row projected from a settings.section registration's options. */
export interface SettingsSectionRow {
id: string
order: number
label: string
}
/** /**
* Registrant-private injected share of the settings shell (assembled in * Registrant-private injected share of the settings shell (assembled in
* apply): ledger projections only — the shell reads no locale state. * apply): the ledger's nav-row projection as a hooks-compartment source —
* the shell reads no locale state and subscribes through the bound hook.
*/ */
export type SettingsRootInjected = { export type SettingsRootInjected = {
/** Read the settings.section ledger version (nav invalidation). */ hooks: {
sectionsVersion: () => number /** settings.section ledger projected into ordered nav rows. */
/** Subscribe to settings.section ledger changes. */ sections: HostObservable<readonly SettingsSectionRow[]>
subscribeSections: (listener: () => void) => () => void }
/** Project the settings.section ledger into nav rows (id/order/label). */
sections: () => readonly { id: string; order: number; label: string }[]
} }
/** /**
* Full component props of the settings shell root: the sidebar owner share * Full component props of the settings shell root: the sidebar owner share
* (wide/rail state) plus the declared render shares and the injected face. * (wide/rail state) plus the declared render shares and the injected face
* No store is registered — modal open state and active section id are * (hooks compartment bound to useSections). No store is registered — modal
* component-local viewing state. * open state and active section id are component-local viewing state.
*/ */
export type SettingsRootComponentProps = export type SettingsRootComponentProps =
PropsRuntime<'sidebar.settings'> PropsRuntime<'sidebar.settings'>
& PropsRenderSlots<'settings.trigger' | 'settings.header' | 'settings.close' | 'settings.section'> & PropsRenderSlots<'settings.trigger' | 'settings.header' | 'settings.close' | 'settings.section'>
& SettingsRootInjected & InjectFace<SettingsRootInjected>

View File

@@ -10,12 +10,12 @@
*/ */
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots' import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots'
import type { SettingsRootInjected } from './contract/slots.ts' import type { SettingsRootInjected, SettingsSectionRow } from './contract/slots.ts'
import { SettingsRoot } from './SettingsRoot.tsx' import { SettingsRoot } from './SettingsRoot.tsx'
export type { export type {
SettingsHeaderOwnerProps, SettingsRootComponentProps, SettingsRootInjected, SettingsHeaderOwnerProps, SettingsRootComponentProps, SettingsRootInjected,
SettingsSectionOwnerProps, SettingsTriggerOwnerProps, SettingsSectionOwnerProps, SettingsSectionRow, SettingsTriggerOwnerProps,
} from './contract/slots.ts' } from './contract/slots.ts'
/** /**
@@ -32,17 +32,31 @@ export const inject = ['slots']
* @param ctx - client root context. * @param ctx - client root context.
*/ */
export function apply(ctx: ClientContext): void { export function apply(ctx: ClientContext): void {
// Ledger → nav-row projection as an observable source (uSES contract:
// getSnapshot returns the cached rows until the ledger version moves).
let rowsVersion = -1
let rows: readonly SettingsSectionRow[] = []
const injected = (): SettingsRootInjected => ({ const injected = (): SettingsRootInjected => ({
sectionsVersion: () => ctx.slots.getVersion('settings.section'), hooks: {
subscribeSections: listener => ctx.slots.subscribe('settings.section', listener), sections: {
sections: () => ctx.slots.entries('settings.section') getSnapshot: () => {
.map(e => ({ const version = ctx.slots.getVersion('settings.section')
/* v8 ignore next -- list-slot registration requires id (SlotCore rejects an entry without one) */ if (version !== rowsVersion) {
id: e.options.id ?? '', rowsVersion = version
order: e.options.order ?? 0, rows = ctx.slots.entries('settings.section')
label: e.options.label ?? '', .map(e => ({
})) /* v8 ignore next -- list-slot registration requires id (SlotCore rejects an entry without one) */
.sort((a, b) => a.order - b.order), id: e.options.id ?? '',
order: e.options.order ?? 0,
label: e.options.label ?? '',
}))
.sort((a, b) => a.order - b.order)
}
return rows
},
subscribe: listener => ctx.slots.subscribe('settings.section', listener),
},
},
}) })
ctx.effect(() => { ctx.effect(() => {
const deferred = deferRegistration(ctx.slots, 'sidebar.settings', SettingsRoot, () => const deferred = deferRegistration(ctx.slots, 'sidebar.settings', SettingsRoot, () =>

View File

@@ -60,22 +60,25 @@ describe('ui-settings apply', () => {
const b = await bench() const b = await bench()
declare(b.slots) declare(b.slots)
await b.ctx.plugin({ inject: [...inject], apply }).await() await b.ctx.plugin({ inject: [...inject], apply }).await()
const injected = injectedOf(b.slots) const { sections } = injectedOf(b.slots).hooks
// The shell ships no sections of its own — registrants fill the ledger. // The shell ships no sections of its own — registrants fill the ledger.
expect(injected.sections()).toEqual([]) expect(sections.getSnapshot()).toEqual([])
b.slots.register({ name: 'settings.section', id: 'z', order: 20, label: 'Z' } as never, () => null) b.slots.register({ name: 'settings.section', id: 'z', order: 20, label: 'Z' } as never, () => null)
// No order and no label: both projection defaults apply. // No order and no label: both projection defaults apply.
b.slots.register({ name: 'settings.section', id: 'a' } as never, () => null) b.slots.register({ name: 'settings.section', id: 'a' } as never, () => null)
expect(injected.sections()).toEqual([ const rows = sections.getSnapshot()
expect(rows).toEqual([
{ id: 'a', order: 0, label: '' }, { id: 'a', order: 0, label: '' },
{ id: 'z', order: 20, label: 'Z' }, { id: 'z', order: 20, label: 'Z' },
]) ])
expect(injected.sectionsVersion()).toBe(b.slots.getVersion('settings.section')) // Snapshot identity is stable until the ledger moves (uSES contract).
expect(sections.getSnapshot()).toBe(rows)
const listener = vi.fn() const listener = vi.fn()
const off = injected.subscribeSections(listener) const off = sections.subscribe(listener)
b.slots.register({ name: 'settings.section', id: 'b', order: 1, label: 'B' } as never, () => null) b.slots.register({ name: 'settings.section', id: 'b', order: 1, label: 'B' } as never, () => null)
await Promise.resolve() await Promise.resolve()
expect(listener).toHaveBeenCalled() expect(listener).toHaveBeenCalled()
expect(sections.getSnapshot()).not.toBe(rows)
off() off()
}) })

View File

@@ -1,5 +1,6 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest' import { afterEach, describe, expect, it, vi } from 'vitest'
import { useEffect, useState } from 'react'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import type { SettingsRootComponentProps } from '../src/client/contract/slots.ts' import type { SettingsRootComponentProps } from '../src/client/contract/slots.ts'
import { SettingsRoot } from '../src/client/SettingsRoot.tsx' import { SettingsRoot } from '../src/client/SettingsRoot.tsx'
@@ -22,9 +23,9 @@ function mount({
{ id: 'models', order: 10, label: 'Models' }, { id: 'models', order: 10, label: 'Models' },
], ],
}: { wide?: boolean; rows?: Row[] } = {}) { }: { wide?: boolean; rows?: Row[] } = {}) {
// Mutable row store standing in for the ledger; bump() plays a change. // Mutable row source standing in for the bound useSections hook; bump()
// plays a ledger change through the same observable contract.
let current = rows let current = rows
let version = 0
const listeners = new Set<() => void>() const listeners = new Set<() => void>()
const renderSlot = vi.fn( const renderSlot = vi.fn(
((key: string, _owner: unknown, opts?: { only?: string }) => { ((key: string, _owner: unknown, opts?: { only?: string }) => {
@@ -38,19 +39,21 @@ function mount({
useSessions: unusedHook, useSessions: unusedHook,
useWorkspaces: unusedHook, useWorkspaces: unusedHook,
wide, wide,
sectionsVersion: () => version, useSections: (select) => {
subscribeSections: (listener) => { const [, force] = useState(0)
listeners.add(listener) useEffect(() => {
return () => { listeners.delete(listener) } const listener = () => { force(n => n + 1) }
listeners.add(listener)
return () => { listeners.delete(listener) }
}, [])
return select(current)
}, },
sections: () => current,
renderSlot, renderSlot,
} }
const view = render(<SettingsRoot {...props} />) const view = render(<SettingsRoot {...props} />)
const bump = (next: Row[]) => { const bump = (next: Row[]) => {
act(() => { act(() => {
current = next current = next
version += 1
for (const fn of [...listeners]) fn() for (const fn of [...listeners]) fn()
}) })
} }

View File

@@ -79,13 +79,19 @@
/* Brand group (figma I133:7632): the full wordmark rides the text ink /* Brand group (figma I133:7632): the full wordmark rides the text ink
(figma-flows ruling: main-screen instance is black; blue is brand (figma-flows ruling: main-screen instance is black; blue is brand
emphasis only). */ emphasis only). A button only in behavior (New Session shortcut): the
pointer cursor is the sole affordance — no hover chrome on the mark. */
.brand { .brand {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
overflow: hidden; overflow: hidden;
padding: 0;
border: none;
background: transparent;
color: inherit;
cursor: pointer;
} }
.iconButton { .iconButton {

View File

@@ -61,10 +61,17 @@ export function SidebarRoot({
style={wide ? { width: collapsed ? lastWideWidth.current : width } : undefined} style={wide ? { width: collapsed ? lastWideWidth.current : width } : undefined}
> >
<div className={css.logoRow}> <div className={css.logoRow}>
{/* Expanded, the wordmark doubles as a New Session shortcut; the
collapsed rail's logo is the expand toggle below instead. */}
{wide && ( {wide && (
<span className={clsx(css.brand, css.wide)}> <button
type="button"
className={clsx(css.brand, css.wide)}
aria-label="New session"
onClick={() => { startSession() }}
>
<BrandWordmark /> <BrandWordmark />
</span> </button>
)} )}
{/* Rail resting state is the whale mark; hovering swaps in the panel {/* Rail resting state is the whale mark; hovering swaps in the panel
icon (the expand affordance, figma sidebar-hover flow). */} icon (the expand affordance, figma sidebar-hover flow). */}

View File

@@ -54,10 +54,13 @@ function mountShell({ collapsed = false, width = 300 }: { collapsed?: boolean; w
} }
describe('SidebarRoot shell', () => { describe('SidebarRoot shell', () => {
it('routes New Session and the column toggle', () => { it('routes New Session (capsule + wordmark) and the column toggle', () => {
const b = mountShell() const b = mountShell()
fireEvent.click(screen.getByRole('button', { name: 'New session' })) // Expanded, both the wordmark and the capsule start a session.
expect(b.startSession).toHaveBeenCalledWith() const starters = screen.getAllByRole('button', { name: 'New session' })
expect(starters).toHaveLength(2)
for (const button of starters) fireEvent.click(button)
expect(b.startSession).toHaveBeenCalledTimes(2)
fireEvent.click(screen.getByRole('button', { name: 'Collapse sidebar' })) fireEvent.click(screen.getByRole('button', { name: 'Collapse sidebar' }))
expect(b.toggleSidebar).toHaveBeenCalledOnce() expect(b.toggleSidebar).toHaveBeenCalledOnce()
}) })

View File

@@ -44,6 +44,21 @@ export function apply(ctx: ClientContext): void {
// Session-keyed catalog cache; single-flight per key. Plugin-closure state: // Session-keyed catalog cache; single-flight per key. Plugin-closure state:
// the fiber effect below is its teardown boundary. // the fiber effect below is its teardown boundary.
const fetches = new Map<SessionId, CatalogFetch>() const fetches = new Map<SessionId, CatalogFetch>()
// Per-session lexicon invalidation listeners (subscribeLexicon consumers).
const lexiconListeners = new Map<SessionId, Set<() => void>>()
const notifyLexicon = (sessionId: SessionId): void => {
for (const listener of [...(lexiconListeners.get(sessionId) ?? [])]) {
try {
listener()
} catch (error) {
// Contain listener failures: settlement notifies from an ignored
// promise chain (a throw would surface as an unhandled rejection)
// and one faulty consumer must not starve the others.
console.error('[ui-skill] lexicon listener failed:', error)
}
}
}
const fetchCatalog = (sessionId: SessionId): Promise<readonly SkillEntry[]> => { const fetchCatalog = (sessionId: SessionId): Promise<readonly SkillEntry[]> => {
const existing = fetches.get(sessionId) const existing = fetches.get(sessionId)
@@ -58,7 +73,10 @@ export function apply(ctx: ClientContext): void {
fetches.set(sessionId, entry) fetches.set(sessionId, entry)
promise.then( promise.then(
// Settled snapshot backs the synchronous lexicon reads. // Settled snapshot backs the synchronous lexicon reads.
(skills) => { entry.settled = skills }, (skills) => {
entry.settled = skills
notifyLexicon(sessionId)
},
// A failed fetch must not poison the key: the next consumer retries. // A failed fetch must not poison the key: the next consumer retries.
() => { () => {
if (fetches.get(sessionId) === entry) fetches.delete(sessionId) if (fetches.get(sessionId) === entry) fetches.delete(sessionId)
@@ -72,6 +90,7 @@ export function apply(ctx: ClientContext): void {
if (entry === undefined) return if (entry === undefined) return
fetches.delete(key) fetches.delete(key)
entry.abort.abort() entry.abort.abort()
notifyLexicon(key)
} }
const clearAll = (): void => { const clearAll = (): void => {
@@ -97,6 +116,16 @@ export function apply(ctx: ClientContext): void {
lexicon(session) { lexicon(session) {
return fetches.get(session.sessionId)?.settled?.map(skill => skill.name) return fetches.get(session.sessionId)?.settled?.map(skill => skill.name)
}, },
subscribeLexicon(session, listener) {
const key = session.sessionId
const listeners = lexiconListeners.get(key) ?? new Set()
listeners.add(listener)
lexiconListeners.set(key, listeners)
return () => {
listeners.delete(listener)
if (listeners.size === 0) lexiconListeners.delete(key)
}
},
onPick({ candidate }) { onPick({ candidate }) {
// Decision 21: plain-text reference — the literal lands in the draft // Decision 21: plain-text reference — the literal lands in the draft
// and ships to the model verbatim (trailing space closes the token). // and ships to the model verbatim (trailing space closes the token).

View File

@@ -208,6 +208,33 @@ describe('lexicon', () => {
// Another session's key is independent — cold until its own fetch. // Another session's key is independent — cold until its own fetch.
expect(source.lexicon!(proj('s2'))).toBeUndefined() expect(source.lexicon!(proj('s2'))).toBeUndefined()
}) })
it('subscribeLexicon notifies on catalog settle and on invalidation, per session', async () => {
const { list } = countingList()
const { ctx, source } = await bench(list)
const s1 = vi.fn()
const s2 = vi.fn()
source.subscribeLexicon!(proj('s1'), s1)
source.subscribeLexicon!(proj('s2'), s2)
await source.candidates(proj('s1'), req(''))
expect(s1).toHaveBeenCalledTimes(1)
expect(s2).not.toHaveBeenCalled()
// Reset invalidates every cached session: each key notifies its own listeners.
await source.candidates(proj('s2'), req(''))
ctx.emit('connection/reset')
expect(s1).toHaveBeenCalledTimes(2)
expect(s2).toHaveBeenCalledTimes(2)
})
it('an unsubscribed lexicon listener stops receiving notifications', async () => {
const { list } = countingList()
const { source } = await bench(list)
const listener = vi.fn()
const off = source.subscribeLexicon!(proj('s1'), listener)
off()
await source.candidates(proj('s1'), req(''))
expect(listener).not.toHaveBeenCalled()
})
}) })
describe('pick and codec', () => { describe('pick and codec', () => {

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # 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; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write # pnpm run verify-translation-pairing --write packages/client/ui-slash/README.md
README.md: d2978695d71686059bfbcbb4fc3ef896d92add4a README.md: 4e363c2682bf91862ec40f3f2174831451fb9b0d
README.zh.md: 6aeb078a922aaa93d50ed16b4dbe54329737d018 README.zh.md: 76d39673cb853d1889ee84cb9f3595708eae2db3

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md) English | [中文](README.zh.md)
Input trigger pipeline plugin: `/` and `@` detection under the caret (word-boundary + guard-tier rules), the grouped candidate menu, and pick routing to registered sources. `ctx.slash` owns the source roster and resolves one `SlashController` per session scope (`sessionOf`); the conversation wiring layer drives `track`/`arbitrate`/`onSpace`/`adjudicate` on the controller. Sources receive a `ClientSessionContext` projection per call — sessions are always agent-backed, so the projection is the session identity alone and the roster is warmed once at scope birth. The pipeline is command-agnostic: space/enter adjudication polls the optional `matchSpace`/`matchEnter` hooks in registration order and the first non-undefined answer wins. Input trigger pipeline plugin: `/` and `@` detection under the caret (word-boundary + guard-tier rules), the grouped candidate menu, and pick routing to registered sources. `ctx.slash` owns the source roster and resolves one `SlashController` per session scope (`sessionOf`); the conversation wiring layer drives `track`/`arbitrate`/`onSpace`/`adjudicate` on the controller. Sources receive a `ClientSessionContext` projection per call — sessions are always agent-backed, so the projection is the session identity alone. A source is warmed in every session controller it can reach: the roster present at scope birth warms during controller construction, and a source registered later is warmed into every live controller by the registration itself. Sources whose `lexicon` roll changes after warm implement `subscribeLexicon(session, listener)`; the controller re-polls on each notification and publishes the aggregation through its `lexicon` snapshot store. The pipeline is command-agnostic: space/enter adjudication polls the optional `matchSpace`/`matchEnter` hooks in registration order and the first non-undefined answer wins.
Layering: `src/core/` (T2) is the pure core — `detectTrigger`, `menuReduce`/`seedGroups`/`MENU_CLOSED`, `exactMatch`, zero React/DOM/cordis; `src/client/service.ts` is the shell wiring the core to the menu snapshot store, the per-hit candidate fetch (generation-gated, `AbortSignal`-superseded, failed sources drop silently with a console record), and the three pick paths. `src/types.ts` and the two `contract.ts` files are the frozen cross-package contract (design v4 §5.1); changes require main-thread arbitration. Layering: `src/core/` (T2) is the pure core — `detectTrigger`, `menuReduce`/`seedGroups`/`MENU_CLOSED`, `exactMatch`, zero React/DOM/cordis; `src/client/service.ts` is the shell wiring the core to the menu snapshot store, the per-hit candidate fetch (generation-gated, `AbortSignal`-superseded, failed sources drop silently with a console record), and the three pick paths. `src/types.ts` and the two `contract.ts` files are the frozen cross-package contract (design v4 §5.1); changes require main-thread arbitration.

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文 [English](README.md) | 中文
输入触发管线插件:光标处的 `/` 与 `@` 检测(词边界 + guard tier 规则)、分组候选菜单,以及把 pick 路由到已注册 source。`ctx.slash` 拥有 source roster,并按会话 scope(`sessionOf`)各解析一个 `SlashController`;会话领域的接线层在 controller 上驱动 `track`/`arbitrate`/`onSpace`/`adjudicate`。source 每次调用收到一个 `ClientSessionContext` 投影——会话恒为 agent-backed,因此投影只含会话身份,roster 在 scope 出生时预热一次。管线对命令零知识:空格/回车裁决按注册序轮询可选的 `matchSpace`/`matchEnter` 钩子,第一个非 undefined 的应答胜出。 输入触发管线插件:光标处的 `/` 与 `@` 检测(词边界 + guard tier 规则)、分组候选菜单,以及把 pick 路由到已注册 source。`ctx.slash` 拥有 source roster,并按会话 scope(`sessionOf`)各解析一个 `SlashController`;会话领域的接线层在 controller 上驱动 `track`/`arbitrate`/`onSpace`/`adjudicate`。source 每次调用收到一个 `ClientSessionContext` 投影——会话恒为 agent-backed,因此投影只含会话身份。source 在它能触达的每个会话 controller 中都会被预热:scope 出生时在场的 roster 随 controller 构造预热,晚于此注册的 source 由注册动作本身预热进每个活 controller。`lexicon` 名录在预热后仍会变化的 source 实现 `subscribeLexicon(session, listener)`;controller 每收到通知就重拉,并把聚合结果经其 `lexicon` snapshot store 发布。管线对命令零知识:空格/回车裁决按注册序轮询可选的 `matchSpace`/`matchEnter` 钩子,第一个非 undefined 的应答胜出。
分层:`src/core/`(T2)是纯内核——`detectTrigger`、`menuReduce`/`seedGroups`/`MENU_CLOSED`、`exactMatch`,零 React/DOM/cordis;`src/client/service.ts` 是壳层,把内核接到菜单快照 store、逐 hit 候选拉取(以 generation 把关、后继请求经 `AbortSignal` 取代旧请求、失败的 source 静默丢弃并留一条 console 记录)和三条 pick 路径上。`src/types.ts` 与两个 `contract.ts` 文件是冻结的跨包契约(设计 v4 §5.1);变更需经主线程仲裁。 分层:`src/core/`(T2)是纯内核——`detectTrigger`、`menuReduce`/`seedGroups`/`MENU_CLOSED`、`exactMatch`,零 React/DOM/cordis;`src/client/service.ts` 是壳层,把内核接到菜单快照 store、逐 hit 候选拉取(以 generation 把关、后继请求经 `AbortSignal` 取代旧请求、失败的 source 静默丢弃并留一条 console 记录)和三条 pick 路径上。`src/types.ts` 与两个 `contract.ts` 文件是冻结的跨包契约(设计 v4 §5.1);变更需经主线程仲裁。

View File

@@ -40,18 +40,35 @@ export interface SlashControllerDeps {
export class SlashController { export class SlashController {
/** Menu state store (per-session; survives session switches, dies with the scope). */ /** Menu state store (per-session; survives session switches, dies with the scope). */
readonly menu: SnapshotStore<MenuState> = createSnapshotStore<MenuState>(MENU_CLOSED) readonly menu: SnapshotStore<MenuState> = createSnapshotStore<MenuState>(MENU_CLOSED)
/**
* Aggregated hot reference lexicon, grouped by trigger (decision 21):
* sources implementing the lexicon hook are polled with the session
* projection; undefined answers (roll not hot yet) are skipped; multiple
* sources on one trigger concatenate in registration order. A snapshot
* store because rolls change asynchronously (catalog settles, children
* spawn/exit) — render-side consumers subscribe instead of re-reading a
* mutable answer.
*/
readonly lexicon: SnapshotStore<ReadonlyMap<TriggerChar, readonly string[]>> =
createSnapshotStore<ReadonlyMap<TriggerChar, readonly string[]>>(new Map())
/** The authoritative hit: single truth for span CAS material (menu snapshot never carries it alone). */ /** The authoritative hit: single truth for span CAS material (menu snapshot never carries it alone). */
private hit: TriggerHit | null = null private hit: TriggerHit | null = null
private fetch: AbortController | null = null private fetch: AbortController | null = null
private disposed = false private disposed = false
/** Per-source lexicon unsubscribers (sources without the hook never enter). */
private readonly lexiconOffs = new Map<SlashSource, () => void>()
constructor(private readonly deps: SlashControllerDeps) { constructor(private readonly deps: SlashControllerDeps) {
// Scope-birth prewarm: sessions are always agent-backed, so the one-time // Scope-birth prewarm: sessions are always agent-backed, so the one-time
// roster warm here replaces the projection-transition watch — there are // roster warm here replaces the projection-transition watch — there are
// no capability steps to react to. // no capability steps to react to.
const projection = this.project() const projection = this.project()
for (const src of deps.roster.all()) src.warm?.(projection) for (const src of deps.roster.all()) {
src.warm?.(projection)
this.watchLexicon(src, projection)
}
this.refreshLexicon()
} }
/** /**
@@ -220,6 +237,23 @@ export class SlashController {
if (state.open && state.hit !== null && state.hit.trigger === source.trigger) { if (state.open && state.hit !== null && state.hit.trigger === source.trigger) {
this.reduce({ type: 'source-failed', generation: state.generation, source: source.name }) this.reduce({ type: 'source-failed', generation: state.generation, source: source.name })
} }
this.lexiconOffs.get(source)?.()
this.lexiconOffs.delete(source)
this.refreshLexicon()
}
/**
* Admit a source registered after this controller's birth (root registry
* change notification): warm it and fold its roll into the live lexicon —
* the constructor-time prewarm covers only the roster present at scope
* birth.
* @param source - the newly registered source.
*/
sourceAdded(source: SlashSource): void {
const projection = this.project()
source.warm?.(projection)
this.watchLexicon(source, projection)
this.refreshLexicon()
} }
/** Scope teardown: close and abort (the service deletes the map entry). */ /** Scope teardown: close and abort (the service deletes the map entry). */
@@ -228,6 +262,8 @@ export class SlashController {
this.stopFetch() this.stopFetch()
this.reduce({ type: 'close' }) this.reduce({ type: 'close' })
this.hit = null this.hit = null
for (const off of this.lexiconOffs.values()) off()
this.lexiconOffs.clear()
} }
/** The session projection handed to sources (agent-backed identity; constant per scope). */ /** The session projection handed to sources (agent-backed identity; constant per scope). */
@@ -248,25 +284,33 @@ export class SlashController {
return actx.bail(actx, 'slash/input-insert-reference', { reference: outcome.insert, span }) === true return actx.bail(actx, 'slash/input-insert-reference', { reference: outcome.insert, span }) === true
} }
/** /** Re-poll every lexicon-bearing source and publish the aggregated rolls (see the store doc). */
* Aggregate the sources' plain-text reference lexicons (decision 21), private refreshLexicon(): void {
* grouped by trigger: sources implementing the hook are polled with the
* session projection (onSpace's poll pattern); undefined answers (roll not
* hot yet) are skipped; multiple sources on one trigger concatenate in
* registration order.
* @returns trigger → decorated-name roll for the decoration scan.
*/
lexicon(): ReadonlyMap<TriggerChar, readonly string[]> {
const projection = this.project() const projection = this.project()
const rolls = new Map<TriggerChar, readonly string[]>() const rolls = new Map<TriggerChar, readonly string[]>()
for (const src of this.deps.roster.all()) { for (const src of this.deps.roster.all()) {
if (src.lexicon === undefined) continue if (src.lexicon === undefined) continue
const names = src.lexicon(projection) let names: readonly string[] | undefined
try {
names = src.lexicon(projection)
} catch (error) {
// A faulty source drops silently with a console record (the
// candidate-fetch failure policy); the refresh runs inside
// notification callbacks, where a throw would starve other consumers.
console.error(`[ui-slash] source "${src.name}" lexicon failed:`, error)
continue
}
if (names === undefined) continue if (names === undefined) continue
const prev = rolls.get(src.trigger) const prev = rolls.get(src.trigger)
rolls.set(src.trigger, prev === undefined ? names : [...prev, ...names]) rolls.set(src.trigger, prev === undefined ? names : [...prev, ...names])
} }
return rolls this.lexicon.set(rolls)
}
/** Wire one source's lexicon invalidation channel into refresh (hookless or roll-less sources never notify). */
private watchLexicon(source: SlashSource, projection: ClientSessionContext): void {
if (source.lexicon === undefined || source.subscribeLexicon === undefined) return
this.lexiconOffs.set(source, source.subscribeLexicon(projection, () => { this.refreshLexicon() }))
} }
/** Launch the candidate fetch for one hit generation, superseding the previous one. */ /** Launch the candidate fetch for one hit generation, superseding the previous one. */

View File

@@ -38,7 +38,8 @@ export class SlashService extends Service implements SlashServiceContract {
} }
/** /**
* Register one trigger source. * Register one trigger source. Live session controllers are notified so a
* source arriving after scope birth still warms and joins the lexicon.
* @param src - the source; (trigger, name) must be unique — duplicates throw. * @param src - the source; (trigger, name) must be unique — duplicates throw.
* @returns the disposer (callers wrap registration in ctx.effect). Disposal * @returns the disposer (callers wrap registration in ctx.effect). Disposal
* while a controller shows the source's menu group drops that group. * while a controller shows the source's menu group drops that group.
@@ -49,6 +50,16 @@ export class SlashService extends Service implements SlashServiceContract {
throw new Error(`slash source "${src.trigger}${src.name}" is already registered`) throw new Error(`slash source "${src.trigger}${src.name}" is already registered`)
} }
live.sources.push(src) live.sources.push(src)
for (const controller of live.controllers.values()) {
try {
controller.sourceAdded(src)
} catch (error) {
// Contain faulty source callbacks (warm/subscribeLexicon): the
// registration must stand with a usable disposer and the remaining
// controllers must still be notified.
console.error(`[ui-slash] source "${src.trigger}${src.name}" late-registration setup failed:`, error)
}
}
return () => { return () => {
const at = live.sources.indexOf(src) const at = live.sources.indexOf(src)
if (at < 0) return if (at < 0) return

View File

@@ -165,6 +165,16 @@ export interface SlashSource {
* (the render path must stay synchronous and side-effect free). * (the render path must stay synchronous and side-effect free).
*/ */
lexicon?(session: ClientSessionContext): readonly string[] | undefined lexicon?(session: ClientSessionContext): readonly string[] | undefined
/**
* Subscribe to changes of this source's {@link SlashSource.lexicon} answer
* for one session (backing data settled, invalidated, or refreshed). The
* controller re-polls lexicon on each notification; a source whose roll
* never changes after warm omits the hook.
* @param session - stable session projection.
* @param listener - invalidation callback.
* @returns unsubscribe.
*/
subscribeLexicon?(session: ClientSessionContext, listener: () => void): () => void
/** Reference codec; required for sources producing insert outcomes. */ /** Reference codec; required for sources producing insert outcomes. */
readonly codec?: ReferenceCodec readonly codec?: ReferenceCodec
} }

Some files were not shown because too many files have changed in this diff Show More