diff --git a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.i18n.yaml index eacbd89847..1604914ac0 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-22-slot-type-chain-implementation.md: 617524475f3da8af5d281efcfe8f79d500f31be8 -2026-07-22-slot-type-chain-implementation.zh.md: 52edea30acea5989b3438cbcf4688df5a897f099 +# 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: e88361701fc05c1ab30174dde147ae9558265ce6 +2026-07-22-slot-type-chain-implementation.zh.md: 90473861c199f326f4b3635580c885517f0d612b diff --git a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md index 617524475f..e88361701f 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md +++ b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md @@ -45,7 +45,7 @@ Parity rule: **the declaring entry holds the exclusive right to render its child | runtime | `PropsRuntime` | SlotMap entry for K | `OwnerOf` (render-site params) + session-scope standard `useSession`/`sessionId` + global `useSessions`/`useWorkspaces` | | child render | `PropsRenderSlots` | register's `children` keys | `renderSlot(key, owner)`, key statically narrowed to S; chain keys add `renderSlotChain` | | store | `PropsStore` | 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` selector hooks (`InjectFace`) | `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 -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` 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 -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 @@ -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 | | 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 | -| 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 | | 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) | diff --git a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.zh.md b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.zh.md index 52edea30ac..90473861c1 100644 --- a/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.zh.md @@ -45,7 +45,7 @@ ctx.slots.register({ | 运行时 | `PropsRuntime` | K 对应的 SlotMap entry | `OwnerOf`(渲染现场传参)+ session scope 标配 `useSession`/`sessionId` + 全局 `useSessions`/`useWorkspaces` | | 子坑渲染 | `PropsRenderSlots` | register 的 `children` 键集 | `renderSlot(key, owner)`,键参静态收窄到 S;chain 键另有 `renderSlotChain` | | store | `PropsStore` | store 工厂的返回类型 | `useStore` selector hook + `actions.*`(剥去 draft 形参) | -| 业务 | `I` | inject 的返回类型 | 普通数据+回调(禁 hook) | +| 业务 | `I` | inject 的返回类型 | 普通数据+回调;保留键 `hooks` 格的裸 observable 经绑定以 `use` 选择器 hook 到达(`InjectFace`) | 凡声明 `scope: 'session'` 之处,`sessionId` 一律由框架供给——owner 传参不携带它。register 调用点是双向锁的收口:组件的 renderSlot 键集超出 `children` 声明、漏接某个已声明的面、store/inject 形状漂移,任何一条都在那一行上报编译错误。转授就是普通的 props 传递(把 `renderSlot` 函数递下去,可按需包一层更窄的签名)——不存在白名单面对象,也不存在铸面 API。 @@ -80,11 +80,11 @@ store 的 scope **从挂载 entry 的 scope 推导**(session 坑→每个会 ### 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` 选择器 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 类型里,面可由机械推导;可铸造的面对象是第三个权威面,且只有运行时校验 | | 装配句柄把 root ctx 带进 inject | 绕开声明的 inject 拓扑——每个工厂都摸得到每个服务,package.json 的依赖声明就此失去意义 | | `children` 用键数组形 | kind/scope 是运行时分派数据;SlotMap 已被擦除,数组形必然逼出第二个 spec 注册 API——定义 API 复活 | -| 业务经 inject 自定义 hook | 每个插件都变成自己的订阅机械;框架 store 席位用一台受审计的机械承载同样的数据 | +| 业务手造 hook / 组件 props 里递裸 observable | 每个插件都变成自己的订阅机械;inject `hooks` 格让同样的事实走那一台受审计的绑定机械 | | 模块级 store 句柄 | 模块级句柄是跨插件重载与跨测试用例的单例;工厂形把身份圈定在单次 apply/测试调用内 | | 组件直收 store 实例 | 渲染代码里能用 `update`/`set`,变更面就无从审计;声明的 actions 让「什么能变」保持为 register 现场的事实 | | 注册位用 `FC` / 从组件推断 `I` | FC 静态位产生协变噪音、拒绝合法组件;组件侧推断静默吸收 props 漂移(见上文裁定) | diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml index f3a525fe70..4783b79a6a 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md -2026-07-25-web-client-session-scope-and-provide-channel.md: 09afe6d9e879ae7529d309c3b5e656be849fa543 -2026-07-25-web-client-session-scope-and-provide-channel.zh.md: 4d45d74c2e7c34601a5229fc0fc0780a23ec6fd5 +2026-07-25-web-client-session-scope-and-provide-channel.md: 4496e3786ed4adb6e60dfd5cfad72e989657f649 +2026-07-25-web-client-session-scope-and-provide-channel.zh.md: 768dada95aacdb358115d496f45e7fc0eece0151 diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md index 09afe6d9e8..4496e3786e 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md @@ -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`: - `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. `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. diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md index 4d45d74c2e..768dada95a 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md @@ -90,7 +90,7 @@ session slot 组件「自己拿 session 数据」的唯一供数路径。插件 slot scope 是闭集 `root | session-maybe | 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。 `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 翻转而重建。 diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.i18n.yaml index d636aab9ff..29f56666b2 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-25-web-command-surfaces-and-assembly.md: 5188e8c17b31157b1c03203a8d7ba2d8e6a1496b -2026-07-25-web-command-surfaces-and-assembly.zh.md: 0134cc10cf4f49b7719d6a0dacb239389776d6ed +# 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: 4c4a400abab940baebc1699fc15b709419865f0c +2026-07-25-web-command-surfaces-and-assembly.zh.md: c0acd1ecc5998a0ec488a1f13ed99ae4a93a240b diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.md b/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.md index 5188e8c17b..4c4a400aba 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.md @@ -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) -- **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-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-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 and `subscribeLexicon` forwards the list store's change feed (the model-side representation awaits its business workstream). ### Fixture command routing and assembly diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.zh.md b/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.zh.md index 0134cc10cf..c0acd1ecc5 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.zh.md @@ -28,8 +28,8 @@ Status: implemented ### 引用源(只见投影 + 自家 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-subagent**:候选零 RPC(sessions.list 快照按 parentId/running 过滤);pick 产出 text outcome(`@name ` 原文);`lexicon` 同快照派生(模型侧表示待业务立项)。 +- **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` 同快照派生,`subscribeLexicon` 转发 list store 的变更通道(模型侧表示待业务立项)。 ### fixture 命令路由与装配 diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml index 0249baff80..121879629c 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-25-web-input-machine-and-slash-pipeline.md: acbd132a5fdb97a4098064aae689dfca604ad4b7 -2026-07-25-web-input-machine-and-slash-pipeline.zh.md: 158650a41b47f98037a1b3e610d9294694c55a8c +# 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: 8cf3be7b3b7579d0c37898a58fb0ab4990fd71bf +2026-07-25-web-input-machine-and-slash-pipeline.zh.md: b1488893558d8b2bf9c104faca968435d46a9640 diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md index acbd132a5f..8cf3be7b3b 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md @@ -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: - 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. - Sending is the literal text (no more `` serialization); on the bubble side MessageItem decorates both shapes (the legacy `` 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 diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md index 158650a41b..b148889355 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md @@ -81,10 +81,10 @@ occurrence 表与 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 拼接,机器零改动。 -- 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);编辑破坏匹配形状下次扫描自然消失。 - 发送即原文(不再 `` 序列化);气泡侧 MessageItem 双形状装饰(legacy `` 标签 + 纯文本 token)。 -- 旧 occurrence/paste/serialize 链全部保留在盘未删(additive;删除另成将来一刀)。已知局限维持现状:粘贴/冷启动时 lexicon 未热不装饰,输 `/` 开一次菜单后才亮。 +- 旧 occurrence/paste/serialize 链全部保留在盘未删(additive;删除另成将来一刀)。装饰响应性:InputBar 以 uSES 订阅 shell 的 lexicon source,scope 出生预热后才 settle 的名录会直接点亮已有 draft token,无需菜单交互或无关重渲染。 ### per-session 供数贡献与键盘私面 diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-web-conversation-polish-sweep.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-28-web-conversation-polish-sweep.i18n.yaml new file mode 100644 index 0000000000..3df46dedf2 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-28-web-conversation-polish-sweep.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-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 diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-web-conversation-polish-sweep.md b/.agents/notes/implemented/bug-fix/2026-07-28-web-conversation-polish-sweep.md new file mode 100644 index 0000000000..cae52217d6 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-28-web-conversation-polish-sweep.md @@ -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. diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-web-conversation-polish-sweep.zh.md b/.agents/notes/implemented/bug-fix/2026-07-28-web-conversation-polish-sweep.zh.md new file mode 100644 index 0000000000..d19a5f7593 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-28-web-conversation-polish-sweep.zh.md @@ -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 的全量测试套件通过。 diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml index fd047fa625..be4fc8f19f 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md -2026-07-16-persistent-pty-sessions.md: 55ab262b89526911e16a3fec5dd624fc42fb2222 -2026-07-16-persistent-pty-sessions.zh.md: 525d199298bc071c36444d689368317968186c9f +2026-07-16-persistent-pty-sessions.md: 691ccd4341837d63bb48d27c2a3fb007657fe7ba +2026-07-16-persistent-pty-sessions.zh.md: 86d57f79ee74429542730ebcad60bc9f3c9f15ca diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md index 55ab262b89..691ccd4341 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md @@ -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`. -On Linux, the inspector reads the shell's terminal foreground PGID from `/proc//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//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. @@ -155,7 +155,7 @@ The package ships concise tool guidance explaining persistent state, owner isola ## 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. - 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. @@ -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. -**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. diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md index 525d199298..86d57f79ee 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md @@ -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`。 -在 Linux 上,检查器从 `/proc//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//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 和进程表路径。 @@ -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 检查器逻辑。 - 真实 `node-pty` 测试在受支持宿主上覆盖 shell 状态、共享沙箱策略、环境清洗、在由场景掌控的时间界限内先有意延迟子进程就绪,再对 raw mode 前台进程发送 `SIGINT`、忽略 `SIGTERM` 的子进程,以及 dispose 返回后立即完全停稳。 - 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 等待与信号让模型仍能掌握控制权。 -**精确归因与推断归因的边界是延迟取舍,不是可消除的竞态。**归因取决于内核在静默上限到达之前还是之后发布前台交接,因此任何固定宽限都是一次调度上的赌注。`handoffGraceMs` 把这个赌注交给部署配置:调大它可以在慢速或高负载主机上换到精确的 `stdin_read` 归因,代价是见过 prompt marker 之后的交互返回延迟;调小则相反。不应依赖胜负结果的测试断言可观察行为——下一次 send 能正常执行——而不是断言归因路径。 +**精确归因与推断归因的边界是延迟取舍,不是可消除的竞态。**归因取决于内核在静默上限到达之前还是之后发布前台交接,因此任何固定宽限都是一次调度上的赌注。`handoffGraceMs` 把这个赌注交给部署配置:调大它可以在慢速或高负载主机上换到精确的 `stdin_read` 归因,代价是见过 prompt marker 之后的交互返回延迟;调小则相反。不应依赖胜负结果的测试使用不会出现在输入回显中的 token,断言下一次 send 中由子进程产生的输出,而不是断言归因路径。 **持久状态可能偏离模型认知。**模型可能忘记 cwd 或活跃 REPL。会话摘要和保留输出有助恢复,但任何 prompt 都无法让状态持久化变成确定行为。 diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml index 51073f506e..333d19da04 100644 --- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-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.zh.md: ed927012c5b0ba9d43444a129d10d966f5e7d923 +2026-07-21-serial-cross-platform-ci-reference.md: 71b364f72a094f7899a0929eec29f3a16ccc8a93 +2026-07-21-serial-cross-platform-ci-reference.zh.md: 0f324a5c235a2b495186caf0d6cc0490cc5876e3 diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md index 9ecd7906d2..71b364f72a 100644 --- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md +++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md @@ -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? +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 [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. -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. @@ -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. - **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 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. - **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. +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. 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. diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md index ed927012c5..0f324a5c23 100644 --- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md +++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md @@ -12,6 +12,8 @@ Status: implemented 评审人还需要直接回答一个更简单的问题:在每个选定的托管操作系统上,如果仓库完整的主 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` 仅用于运行器基准测试。 @@ -22,7 +24,9 @@ Status: implemented 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),拉取请求必需作业使用可移植的标准容量。更高核心数的托管运行器仍仅用于手动基准测试,因为正确性路径必须无需仓库外部的运行器配置即可运行。 @@ -31,6 +35,7 @@ master 分支的参考作业仅用于诊断,不参与拉取请求所要求的 - **将每个超时值设为相应延迟目标**:不予采纳,因为调度波动会中止原本正确的执行,并使诊断回归所需的证据无法产生。 - **仅信任并发执行的主门禁清单**:不予采纳,因为调度逻辑与校验逻辑共享实现假设;串行聚合流程是一项独立的完整性检查。 - **在每个拉取请求上运行串行参考作业**:不予采纳,因为这些作业会重复完整的跨平台聚合流程,并为每项改动增加 macOS 工作;必需作业已经执行阻塞性的 Linux 和 Windows 契约。 +- **在每个拉取请求上运行真实内核 Sandbox 矩阵**:不予采纳,因为它的四个状态不参与分支保护,而重复安装、Landlock 构建以及为保持平台一致而运行的 macOS 单元测试会消耗运行器容量,却不会改变合并裁决。master 上的运行保留平台与已安装 launcher 的信号。 - **使用一个操作系统矩阵**:不予采纳,因为三个具名作业无需另一套选择机制,就能让参考流程的构成清晰可见。 - **在大型运行器上运行串行参考流程**:不予采纳,因为当组织自有运行器池无法分配作业时,必需 CI 及其独立参考流程都必须仍可运行。 @@ -40,6 +45,8 @@ master 分支的参考作业仅用于诊断,不参与拉取请求所要求的 参考流程可能暴露某些平台上的故障,而优化后的阻塞门禁集合尚未声明支持这些平台,Windows 尤其如此。这类失败反映了当前的跨平台行为,不应成为削弱或静默跳过该聚合流程的理由。 +仅在真实宿主内核或打包后的 Landlock 安装中可见的沙箱回归,可能在 master 上的运行报告前已经合并。我们接受这个合并后检测窗口,以换取从每个拉取请求中移除四个非阻塞作业;默认分支仍保留完整信号。 + 明确的 `pty-local` 归属边界意味着 Windows 不会声称覆盖一个无法加载的后端,而 macOS 采用 fork 的单元测试工作进程会增加进程启动开销。这些代价换来的是:支持范围内的每项功能都有能够如实反映对应平台行为的判据,原生运行时异常终止不会抹掉其余单元测试结果,各项对时序敏感的观测逻辑也都会以调用方有机会修改状态前已建立的状态作为起点。 移除严格的时长超时后,系统会观测到延迟回归,而不是在发生回归时自动取消运行。因此,性能改动必须附带托管环境测量结果,已完成的日志则保留优化最慢通道所需的信息。 diff --git a/.github/workflows/sandbox.yml b/.github/workflows/sandbox.yml index 0d14914bfd..36f58cc75b 100644 --- a/.github/workflows/sandbox.yml +++ b/.github/workflows/sandbox.yml @@ -1,15 +1,16 @@ -# Sandbox CI: the keyless real-kernel confinement proofs. 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 +# Sandbox CI: the keyless real-kernel confinement proofs. This master-only +# reference stays outside the pull-request verdict; rationale lives in +# .agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md. +# 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 # these legs exercise the true consumer path — nothing is compiled here. name: Sandbox on: push: - branches: [main, master] - pull_request: + branches: [master] concurrency: group: ${{ github.workflow }}-${{ github.ref }} diff --git a/apps/web/tests/workspace-flow.snapshot.ts b/apps/web/tests/workspace-flow.snapshot.ts index e1fe183ef1..b33df1b157 100644 --- a/apps/web/tests/workspace-flow.snapshot.ts +++ b/apps/web/tests/workspace-flow.snapshot.ts @@ -165,7 +165,7 @@ it('locks the composer in the New Session view state until a Workspace is chosen sidebar: visibleText(tree), }).toMatchInlineSnapshot(` { - "chip": "New Workspace", + "chip": "Choose workspace", "composerDisabled": true, "headline": "Let's start building", "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 // 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() await waitFor(() => { expect(within(tree).getByText('1 session')).toBeDefined() }, { timeout: 10_000 }) diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 86a857cd17..ca0e9b82fc 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -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 ``` -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 @@ -674,7 +674,7 @@ Consumes one command token after business success (popup settle / menu-pick exec '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 @@ -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 ``` -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 @@ -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 ``` -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/*` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 18ca96e1b3..3a72d5c0f9 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -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/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) | -| `slash/input-begin-command` | `bail` | [`packages/client/ui-slash/src/types.ts:220`](../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-insert-reference` | `bail` | [`packages/client/ui-slash/src/types.ts:227`](../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-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:244`](../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: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/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) | diff --git a/packages/client/AGENTS.md b/packages/client/AGENTS.md index 3728641560..a7d80b3232 100644 --- a/packages/client/AGENTS.md +++ b/packages/client/AGENTS.md @@ -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'`. 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: `..` (e.g. `'conversation.chat.toolview'`). 3. **Component props are the four shares, all derived**: `PropsRuntime` (SlotMap: owner params + `useSession`/`sessionId` on session scope + global `useSessions`/`useWorkspaces`) & `PropsRenderSlots` (children keys) & `PropsStore` (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` 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. 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`; 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`) → 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) diff --git a/packages/client/locale/src/client/LanguageRow.module.css b/packages/client/locale/src/client/LanguageRow.module.css index f17a67d279..57d975d9b7 100644 --- a/packages/client/locale/src/client/LanguageRow.module.css +++ b/packages/client/locale/src/client/LanguageRow.module.css @@ -42,6 +42,10 @@ cursor: pointer; } +.selector:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + .chevron { flex: none; } diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index 3fa9c934f3..32d4fdf417 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/runtime/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/runtime/README.md -README.md: 81261945cb2fd8b15f7c2f15cb1ae0b8e9928499 -README.zh.md: cbbf6eded4a5375223791275f26f3bc7b6553200 +README.md: 16c1124ec812b9f030ce8266a16cdb8f5db0e6cc +README.zh.md: a3d2a2dfdd1662afee65ec45e26b1ef1029f44b5 diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index 81261945cb..16c1124ec8 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -39,5 +39,5 @@ Changing the target can change or invalidate provider-side cache reuse; this pac ## 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. -- **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). diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index cbbf6eded4..a3d2a2dfdd 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -39,5 +39,5 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 ## 已知限制与暂缓事项 - **`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 事故复盘)。 diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts index ec8ddc2354..9754c87345 100644 --- a/packages/client/runtime/src/client/sessions/service.ts +++ b/packages/client/runtime/src/client/sessions/service.ts @@ -151,6 +151,13 @@ export class SessionsService { readonly list: SnapshotStore /** The object-layer instance cluster and frame dispatch entry. */ 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 /** * Persisted selection cell (the durable half of `list.current`). Private on @@ -167,6 +174,10 @@ export class SessionsService { private readonly providers: SessionProvideDescriptor[] = [] /** Static no-session projection, rebuilt only when the provider roster changes. */ 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 * 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: // the follower writes no list state — session.open()'s synchronous prefix // 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 // provide channel every plugin uses (no renderer special case). this.providers.push({ @@ -206,6 +221,14 @@ export class SessionsService { resolve: binding => ({ hooks: { session: binding.session } }), }) 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) } @@ -238,6 +261,30 @@ export class SessionsService { for (const record of this.scopes.values()) { 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. */ @@ -404,25 +451,21 @@ export class SessionsService { } /** - * Resolve the render-layer standard-props bundle (SessionProvider's feed - * through the renderer host; ctx never enters the render layer). Pure - * resolution — render-safe: SessionProvider calls this during render, so no - * staging, no window side effects (StrictMode double-invokes and concurrent - * discarded passes must stay free). - * @param id - session id. - * @returns the provide info, or undefined for a session neither listed nor already scoped. + * Resolve one session's render-layer standard-props bundle (ctx never + * enters the render layer; the renderer subscribes to + * {@link SessionsService.currentProvideInfo}). Pure resolution — render-safe: + * no staging, no window side effects (StrictMode double-invokes and + * concurrent discarded passes must stay free). */ - provideInfo(id: string): SessionProvideInfo | undefined { + private provideInfo(id: string): SessionProvideInfo | undefined { return this.resolve(id as SessionId)?.provideInfo } /** * Resolve the current-session-optional standard kit. Unknown or absent ids * 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 } diff --git a/packages/client/runtime/src/client/slots.ts b/packages/client/runtime/src/client/slots.ts index 74af31502a..d18377e052 100644 --- a/packages/client/runtime/src/client/slots.ts +++ b/packages/client/runtime/src/client/slots.ts @@ -246,13 +246,6 @@ export class SlotsService extends Service { if (workspaces === undefined) { 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 = { subscribe: (key, fn) => this._core.subscribe(key, fn), 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), sessions: { list: sessions.list, - current, - provideInfo: id => sessions.provideInfo(id), - maybeProvideInfo: id => sessions.maybeProvideInfo(id), + provideInfo: sessions.currentProvideInfo, }, workspaces: { list: workspaces.list }, } diff --git a/packages/client/runtime/tests/sessions-service.spec.ts b/packages/client/runtime/tests/sessions-service.spec.ts index 44ab4ffb4f..45539d3b99 100644 --- a/packages/client/runtime/tests/sessions-service.spec.ts +++ b/packages/client/runtime/tests/sessions-service.spec.ts @@ -79,7 +79,8 @@ describe('scope tree', () => { expect(scopeOf(scoped as Context)).toBe('s1') expect(scopeOf(b.ctx)).toBeUndefined() 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(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)', () => { - 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() await feedList(b, [{ id: 's1' }]) - const info = b.svc.provideInfo('s1') - expect(info).toBeDefined() - expect(info?.sessionId).toBe('s1') + b.svc.open(sid('s1')) + const info = b.svc.currentProvideInfo.getSnapshot() + expect(info.sessionId).toBe('s1') // The bundle carries bare observables; hook binding happens in React. - expect(info?.hooks['session']).toBe(b.svc.binding(sid('s1'))?.session) - expect(b.svc.provideInfo('s1')).toBe(info) - expect(b.svc.provideInfo('ghost')).toBeUndefined() + expect(info.hooks['session']).toBe(b.svc.binding(sid('s1'))?.session) + // Re-staging the same id republishes nothing: identity holds. + 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() await feedList(b, [{ id: 's1' }, { id: 's2' }]) b.svc.open(sid('s1')) // staged - b.svc.provideInfo('s2') // resolution only — must NOT move the stage - b.svc.binding(sid('s2')) + b.svc.binding(sid('s2')) // resolution only — must NOT move the stage await feedList(b, [{ id: 's2' }]) // s1 removed: still staged → deferred, scope survives 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') // Resolution is addressing, not staging: no window pull. b.svc.scope(sid('s1')) - b.svc.provideInfo('s1') b.svc.binding(sid('s1')) expect(historyCalls()).toHaveLength(0) b.svc.open(sid('s1')) diff --git a/packages/client/runtime/tests/slots-service.spec.ts b/packages/client/runtime/tests/slots-service.spec.ts index 97bcb50f0a..07e03b6e9d 100644 --- a/packages/client/runtime/tests/slots-service.spec.ts +++ b/packages/client/runtime/tests/slots-service.spec.ts @@ -97,18 +97,13 @@ function fakeWorkspaces() { 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() { const state = { ids: [], byId: {}, current: undefined as string | undefined } + const absentInfo = { sessionId: undefined, hooks: { session: undefined }, props: {} } return { list: { getSnapshot: () => state, subscribe: () => () => undefined }, - provideInfo: (id: string) => (id === 'known' - ? { - sessionId: id, - hooks: { session: { getSnapshot: () => undefined, subscribe: () => () => undefined } }, - props: {}, - } - : undefined), + currentProvideInfo: { getSnapshot: () => absentInfo, subscribe: () => () => undefined }, } } @@ -232,13 +227,11 @@ describe('host face', () => { 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 host = captureHost(bench) expect(host.sessions.list.getSnapshot()).toMatchObject({ ids: [] }) - expect(host.sessions.current.getSnapshot()).toBeUndefined() - expect(host.sessions.provideInfo('known')).toMatchObject({ sessionId: 'known' }) - expect(host.sessions.provideInfo('ghost')).toBeUndefined() + expect(host.sessions.provideInfo.getSnapshot()).toMatchObject({ sessionId: undefined }) }) it('exposes the independent Workspace list source', async () => { diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index dfbe60532f..808224626f 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -135,13 +135,15 @@ export function apply(ctx: Context): void { 'conversation.input.model': { kind: 'single', scope: 'session' }, }, inject: (sessionId: SessionId): ComposerBarInjected => { + const shell = inputHub.shell(sessionId) return { - keyboard: inputHub.keyboard(sessionId), + keyboard: shell, stop: () => { scopedConversation(sessions, sessionId).cancel().catch(() => { // Stop failure surfaces via snapshot.promptError; nothing to restore. }) }, + hooks: { notices: shell.notices, lexicon: shell.lexicon }, } }, }, InputBar) diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.module.css b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.module.css index b4570af921..9177d721c3 100644 --- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.module.css +++ b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.module.css @@ -9,18 +9,6 @@ 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. */ .stopped { align-self: flex-start; diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx index 6daf78719e..e3573b14f5 100644 --- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx @@ -2,8 +2,8 @@ // reasoning as the figma Think summary row (expand = indented gray text), // 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 -// step-summary flow). Shared by finalized nodes and the streaming partial -// (pulse marker). +// step-summary flow). Shared by finalized nodes and the streaming partial; +// the turn-level loading dots live in the chat view's tail, not here. import { memo } from 'react' import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client' @@ -14,7 +14,7 @@ import css from './AssistantMarkdown.module.css' export interface AssistantMarkdownProps { blocks: readonly AssistantBlock[] 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 } @@ -28,7 +28,7 @@ function ThinkRow({ text, running }: { text: string; running: boolean }) { return ( } + icon={} title="Think" summary={firstLine(text)} body={text} @@ -58,7 +58,6 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ blocks, strea default: return } })} - {streaming && } {interrupted && 已停止} ) diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.module.css b/packages/client/ui-conversation/src/client/chat/ChatView.module.css index 6d75f9a519..fae96e5f6c 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.module.css +++ b/packages/client/ui-conversation/src/client/chat/ChatView.module.css @@ -1,5 +1,6 @@ -/* Chat flow: block gap 16 between narration/bubbles/tool groups (figma); - tool rows inside a group gap 10. Input padding cap rides the skeleton. */ +/* Chat flow: one 16px rhythm everywhere — between blocks (prose <-> tool + runs) via the column gap and between consecutive tool rows via the group + gap. Input padding cap rides the skeleton. */ .root { position: relative; @@ -30,7 +31,7 @@ .toolGroup { display: flex; flex-direction: column; - gap: 10px; + gap: 16px; } .callRow { @@ -51,6 +52,35 @@ 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 { color: var(--dsw-alias-label-tertiary); font-size: 12px; diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index f61c6da6ef..deb7f09f6c 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -49,19 +49,20 @@ type UseConversation = SnapshotSelectorHook * top-level call (same registrations, same fallback), nested by the parent. * A started-but-unsettled sub-call arrives as the RunningToolCall shape and * 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 node: CodeSubCall onOpenDetails: OpenDetails selected: boolean + cwd: string | undefined }) { const settled = 'kind' in node const toolName = settled ? node.call?.name ?? '' : node.name const seq = settled ? node.seq : node.time const owner = useMemo(() => ({ - callId: node.callId, toolName, block: node, + callId: node.callId, toolName, block: node, cwd, openDetails: () => { onOpenDetails({ turnSeq: seq, callId: node.callId, toolName }) }, - }), [node, toolName, seq, onOpenDetails]) + }), [node, toolName, seq, cwd, onOpenDetails]) return (
{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 * renders its logged sub-dispatches as always-visible indented rows — * 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 callId: string toolName: string @@ -91,11 +94,13 @@ const CallRow = memo(function CallRow({ renderSlot, callId, toolName, block, seq subCalls?: readonly CodeSubCall[] | undefined /** The store's selected callId, matched against sub-rows (undefined when no sub-row here is selected). */ selectedCallId?: string | undefined + /** Session workspace root for path-relative summaries. */ + cwd: string | undefined }) { const owner = useMemo(() => ({ - callId, toolName, block, + callId, toolName, block, cwd, openDetails: () => { onOpenDetails({ turnSeq: seq, callId, toolName }) }, - }), [callId, toolName, block, seq, onOpenDetails]) + }), [callId, toolName, block, seq, cwd, onOpenDetails]) return (
{renderSlot('conversation.chat.toolview', owner, { @@ -111,6 +116,7 @@ const CallRow = memo(function CallRow({ renderSlot, callId, toolName, block, seq node={node} onOpenDetails={onOpenDetails} selected={node.callId === selectedCallId} + cwd={cwd} /> ))}
@@ -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). */ -const ToolGroup = memo(function ToolGroup({ renderSlot, results, onOpenDetails, selectedCallId, codeDispatches }: { +/** Consecutive tool results as one step-run group (uniform 16px rhythm). */ +const ToolGroup = memo(function ToolGroup({ renderSlot, results, onOpenDetails, selectedCallId, codeDispatches, cwd }: { renderSlot: RenderToolRow results: readonly ToolResultNode[] onOpenDetails: OpenDetails @@ -128,6 +134,8 @@ const ToolGroup = memo(function ToolGroup({ renderSlot, results, onOpenDetails, selectedCallId: string | undefined /** Sub-dispatch index off the snapshot (map reference is chunk-storm stable). */ codeDispatches: ReadonlyMap + /** Session workspace root for path-relative summaries. */ + cwd: string | undefined }) { return (
@@ -143,12 +151,47 @@ const ToolGroup = memo(function ToolGroup({ renderSlot, results, onOpenDetails, selected={node.callId === selectedCallId} subCalls={codeDispatches.get(node.callId)} selectedCallId={selectedCallId} + cwd={cwd} /> ))}
) }) +/** 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. */ + + ) +} + /** 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. */ 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 * 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) + // 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 codeDispatches = useSession(s => s.codeDispatches) const pending = useSession(s => s.pending) @@ -268,6 +314,7 @@ export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOl onOpenDetails={openDetails} selectedCallId={inGroup ? selectedCallId : undefined} codeDispatches={codeDispatches} + cwd={cwd} /> ) } @@ -309,11 +356,15 @@ export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOl selected={call.callId === selectedCallId} subCalls={codeDispatches.get(call.callId)} selectedCallId={selectedCallId} + cwd={cwd} /> ))}
)} {pending.map(item => )} + {/* Turn-level loading signal: rides the whole running turn (first-token + wait, tool execution, streaming) so it never flickers per step. */} + {running && } diff --git a/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx b/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx index 5cb2126f34..e5b5fb541b 100644 --- a/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx +++ b/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx @@ -13,20 +13,20 @@ import { toolRowModel, type ToolRowVariant } from '../contract/tool-call-model.t import { ToolRow } from './ToolRow.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 = { - think: , - search: , - read: , - bash: , - write: , - edit: , - code: , - others: , + think: , + search: , + read: , + bash: , + write: , + edit: , + code: , + others: , } -export function GenericToolCard({ toolName, block, openDetails }: ToolRowOwnerProps) { - const model = toolRowModel(toolName, block) +export function GenericToolCard({ toolName, block, cwd, openDetails }: ToolRowOwnerProps) { + const model = toolRowModel(toolName, block, cwd) return ( void) | undefined } -/** Leading-slot state substitution: the tool icon yields to the state semantic - * (running = blue ring, error = red, interrupted = amber halo; ok = icon). */ +/** Leading-slot state substitution: the tool icon yields to the terminal state + * 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 { switch (state) { - case 'running': return case 'error': return case 'stopped': return default: return icon @@ -66,6 +68,19 @@ export function ToolRow({ event.preventDefault() 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 + ? ( + <> + {icon} + + + ) + : icon + const leading = open + ? + : leadingFor(state, collapsedIcon) return (
- {open ? : leadingFor(state, icon)} + {leading} ) : ( - {open ? : leadingFor(state, icon)} + {leading} )} {title} diff --git a/packages/client/ui-conversation/src/client/chat/chat-flow.ts b/packages/client/ui-conversation/src/client/chat/chat-flow.ts index 8f47e334c5..f1ce061af8 100644 --- a/packages/client/ui-conversation/src/client/chat/chat-flow.ts +++ b/packages/client/ui-conversation/src/client/chat/chat-flow.ts @@ -12,6 +12,16 @@ export type ChatFlowItem = | { kind: 'node'; key: string; node: ConversationNode } | { 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. * @param nodes - snapshot nodes (surface order). @@ -21,6 +31,7 @@ export function deriveChatFlow(nodes: readonly ConversationNode[]): ChatFlowItem const items: ChatFlowItem[] = [] let group: ToolResultNode[] | null = null for (const node of nodes) { + if (rendersNothing(node)) continue if (node.kind === 'tool-result') { if (group === null) { group = [node] diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 58261aa988..f16d6ebda6 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -1,11 +1,11 @@ /** Conversation slot declarations and their composed component props. */ import type { ReactNode, RefObject } from 'react' import type { - MaybeSnapshotSelectorHook, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook, + InjectFace, MaybeSnapshotSelectorHook, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook, } 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 { 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 { CallId, SelectionTarget, ViewTab } from './views.ts' @@ -145,6 +145,8 @@ export interface ToolRowOwnerProps { toolName: string /** Frozen call slice: the running call or the settled result node. */ 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). */ openDetails: () => void } @@ -222,6 +224,13 @@ export interface ComposerBarInjected { keyboard: ComposerKeyboard /** Cancel the in-flight turn. */ 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 + /** Hot plain-text reference lexicon for the decoration scan (decision 21). */ + lexicon: ObservableSnapshot> + } } /** @@ -233,11 +242,11 @@ export interface InputControlOwnerProps { 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 = PropsRuntime<'conversation.composer.bar'> & PropsRenderSlots<'conversation.input.plan' | 'conversation.input.model'> - & ComposerBarInjected + & InjectFace /** * Composer chain currency: what ConversationRoot dispatches at its @@ -302,6 +311,8 @@ export type DetailsSlotProps = PropsRuntime<'details'> & PropsStore & export interface EmptyWorkspaceOwnerProps { open: boolean anchorRef?: RefObject + /** Currently active workspace (renders a trailing check in the picker list). */ + selectedId?: WorkspaceId | undefined onPick: (workspaceId: WorkspaceId) => void onClose: () => void } diff --git a/packages/client/ui-conversation/src/client/contract/tool-call-model.ts b/packages/client/ui-conversation/src/client/contract/tool-call-model.ts index 5b725df00b..c7db85b0aa 100644 --- a/packages/client/ui-conversation/src/client/contract/tool-call-model.ts +++ b/packages/client/ui-conversation/src/client/contract/tool-call-model.ts @@ -101,6 +101,14 @@ const SUMMARY_KEYS: Record = { 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 { const parsed = parseArgs(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. * @param toolName - wire tool name (dispatch-supplied; survives windowless results). * @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. */ -export function toolRowModel(toolName: string, block: ToolCallBlock): ToolRowModel { +export function toolRowModel(toolName: string, block: ToolCallBlock, cwd?: string): ToolRowModel { const variant = classifyTool(toolName) const done = 'kind' in block const argsRaw = (done ? block.call?.argsRaw : block.argsRaw) ?? '' const state: ToolRowState = !done ? 'running' : block.error?.code === 'interrupted' ? 'stopped' : 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] // 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. diff --git a/packages/client/ui-conversation/src/client/input/contract.ts b/packages/client/ui-conversation/src/client/input/contract.ts index 8a4d2905db..4454662cd6 100644 --- a/packages/client/ui-conversation/src/client/input/contract.ts +++ b/packages/client/ui-conversation/src/client/input/contract.ts @@ -77,8 +77,6 @@ export interface InputNotice { * satisfies it structurally. */ export interface ComposerKeyboard { - /** Latest surfaced notice store (null after none). */ - readonly notices: SnapshotStore /** Live machine state for event-handler reads (render reads go through useInput). */ readonly snapshot: InputState /** Draft write with the DOM-observed edit shape (narrows occurrence math). */ @@ -99,8 +97,6 @@ export interface ComposerKeyboard { space(): boolean /** Dismiss the popupSelect shell (any interaction outside the box). */ 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). */ diff --git a/packages/client/ui-conversation/src/client/input/facade.ts b/packages/client/ui-conversation/src/client/input/facade.ts index f3f6dd7451..d6b2fde80a 100644 --- a/packages/client/ui-conversation/src/client/input/facade.ts +++ b/packages/client/ui-conversation/src/client/input/facade.ts @@ -206,11 +206,14 @@ export class SessionInputShell implements SessionInput { } /** - * Hot plain-text reference lexicons for the decoration scan (decision 21). - * @returns the controller's per-trigger aggregation; empty Map without a pipeline. + * Hot plain-text reference lexicon source for the decoration scan + * (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[]> { - return this.deps.slash?.()?.lexicon() ?? EMPTY_LEXICON + readonly lexicon: ObservableSnapshot> = { + getSnapshot: () => this.deps.slash?.()?.lexicon.getSnapshot() ?? EMPTY_LEXICON, + subscribe: fn => this.deps.slash?.()?.lexicon.subscribe(fn) ?? (() => {}), } /** diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css index be68ea1394..de974da86c 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css @@ -54,8 +54,8 @@ border: none; border-radius: 12px; background: transparent; - font-size: 13px; - line-height: 16px; + font-size: 14px; + line-height: 20px; color: var(--dsw-alias-label-tertiary); text-overflow: ellipsis; white-space: nowrap; @@ -72,13 +72,6 @@ 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. */ .tabs { display: flex; @@ -87,7 +80,7 @@ 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 { position: relative; padding: 0 0 11px; @@ -95,7 +88,7 @@ background: transparent; font-size: 13px; line-height: 16px; - font-weight: 510; + font-weight: 500; color: var(--dsw-alias-label-tertiary); cursor: pointer; } @@ -139,11 +132,45 @@ NOT absolute+transform: a transform would make this box the containing block for position:fixed descendants (pickers/modals), shrinking them. */ .composerHero { + position: relative; /* .heroGlow positioning context */ 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)); 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'] { 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; +} diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx index a9ddc2e4f8..84e62d0ef6 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx @@ -6,7 +6,7 @@ import { useEffect, useRef, useState } from 'react' import clsx from 'clsx' import type { WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client' 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 css from './ConversationRoot.module.css' @@ -36,33 +36,53 @@ export function ConversationRoot({ 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(() => { - if (pendingWorkspaceId !== undefined - && sessionWorkspace?.workspaceId === pendingWorkspaceId) { + if (pendingWorkspaceId === undefined) return + if (sessionWorkspace?.workspaceId === pendingWorkspaceId + || (workspaces.phase === 'ready' && pendingWorkspace === 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 = 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 = ( - <> +
{ setPickerOpen(open => !open) }} /> {renderSlot('conversation.hero.workspace', { open: pickerOpen, anchorRef: pickerAnchor, + selectedId: pendingWorkspaceId ?? sessionWorkspace?.workspaceId, onPick: (workspaceId) => { setPickerOpen(false) setPendingWorkspaceId(workspaceId) @@ -72,10 +92,13 @@ export function ConversationRoot({ }, onClose: () => { setPickerOpen(false) }, })} - +
) - 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) ? : renderSlot('conversation.composer.bar', { variant: hero ? 'hero' : 'composer', @@ -87,6 +110,7 @@ export function ConversationRoot({ const composerBar = (
+ {hero && } {hero && } {hero && heroWorkspaceRow} {!hero && zone !== undefined && renderSlot('conversation.input.dock', zone)} @@ -101,7 +125,7 @@ export function ConversationRoot({ ) return ( -
+
{/* Mounted for every real session, hero included: ConversationSession renders no chrome while blank but owns the draft-persistence mirror bind — unmounting it in the hero would lose pre-first-send text on diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx index e1770d14a6..470a2edc3d 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx @@ -31,7 +31,6 @@ export function ConversationSession({ const activeId = useStore(s => s.view) ?? 'chat' const active = tabs.find(view => view.id === activeId) ?? tabs[0] const ancestry = useSessions(s => deriveAncestry(s, sessionId), shallowEqual) - const turns = useSession(s => countTurns(s)) const composerPhase = useSession(s => s.composerPhase) const blank = useSession(s => s.blank) const inputState = useInput(s => s) @@ -69,7 +68,6 @@ export function ConversationSession({ ) })} {ancestry.length === 0 && {sessionId}} - · {turns} turns
{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 -} diff --git a/packages/client/ui-conversation/src/client/skeleton/DisabledInputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/DisabledInputBar.tsx index 118baf5ac9..bfaa0b8d02 100644 --- a/packages/client/ui-conversation/src/client/skeleton/DisabledInputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/DisabledInputBar.tsx @@ -29,7 +29,7 @@ export function DisabledInputBar() {
diff --git a/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx b/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx index c6122ecbcc..7fbed0ee25 100644 --- a/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx @@ -7,20 +7,18 @@ import { useId } from 'react' import type { ReactNode, RefObject } from 'react' import { - FishLogo, IconChevronDownOutline14, IconFolderOpen16, + FishLogo, IconChevronDownOutline14, IconFolderClose16, IconFolderOpen16, } from '@deepseek-ai/dsh-client-ui-primitives' import { workspaceTitleOf } from '@deepseek-ai/dsh-client-runtime/client' import css from './HeroShell.module.css' /** - * Basename label for the workspace chip / menu rows (the shared derivation); - * empty → the design's "New Workspace" placeholder copy; separator-only - * paths echo the raw cwd. - * @param cwd - workspace directory path ('' for none). + * Basename label for the workspace chip (the shared derivation); + * separator-only paths echo the raw cwd. + * @param cwd - workspace directory path (non-empty). * @returns chip label. */ export function workspaceLabel(cwd: string): string { - if (cwd === '') return 'New Workspace' const base = workspaceTitleOf(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 first message the workspace stays switchable — picking another one - * moves the New Session flow to that workspace's blank session. - * @param props.label - chip label (see {@link workspaceLabel}). + * moves the New Session flow to that workspace's blank session. Without a + * 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.onClick - menu toggle. * @returns the chip button element. */ export function WorkspaceChip({ buttonRef, label, menuOpen = false, onClick }: { buttonRef?: RefObject - label: string + label?: string | undefined menuOpen?: boolean onClick?: () => void }) { @@ -50,13 +50,49 @@ export function WorkspaceChip({ buttonRef, label, menuOpen = false, onClick }: { aria-expanded={menuOpen} onClick={onClick} > - - {label} + {label === undefined + ? + : } + {label ?? 'Choose workspace'} ) } +/** + * 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 ( + + ) +} + /** Hero chrome props. The workspace row rides the InputBar accessory hole, not here. */ export interface HeroShellProps { /** 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}. * @returns the centered hero element tree. */ 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 (
@@ -80,29 +115,6 @@ export function HeroShell({ children }: HeroShellProps) { Let's start building
- {/* 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. */} - {/* The resident composer (rendered by ConversationRoot at its stable tree position; the workspace row rides its accessory hole) is CSS-positioned into this gap during the hero phase — see diff --git a/packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css b/packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css index 3bba50c67c..71b90bfdfa 100644 --- a/packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css @@ -8,8 +8,7 @@ justify-content: center; height: 100%; min-width: 0; - padding: 24px; - margin-bottom: -70px; + padding: 0 24px; } /* Cap matches InputBar card width (800). Glow may paint past the sides. */ @@ -24,17 +23,15 @@ overflow: visible; } -/* figma 34:10411: fish + title, gap 10, centered; 26/32 wt600; title block - keeps 36px below the headline before the flex gap. */ +/* figma 34:10411: fish + title, gap 10, centered; 26/32 wt500. */ .headline { display: flex; align-items: center; justify-content: center; gap: 10px; - padding-bottom: 36px; font-size: 26px; line-height: 32px; - font-weight: 600; + font-weight: 500; color: var(--dsw-alias-label-primary); } @@ -44,8 +41,9 @@ color: var(--dsw-alias-state-business-primary); } -/* Workspace row sits 12px above the input card (figma y80 → y112). Glow is - centered on this block so it stays under the picker + InputBar together. */ +/* Workspace row sits 12px above the input card (figma y80 → y112). The blue + glow lives with the owner (ConversationRoot .heroGlow) so it can center on + the input card. */ .body { position: relative; display: flex; @@ -55,19 +53,7 @@ overflow: visible; } -/* Design input 776 → glow SVG 1051×468 (ellipse 851×268 + blur pad). */ -.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) { +.body > * { position: relative; z-index: 1; } @@ -88,7 +74,7 @@ display: inline-flex; align-items: center; gap: 4px; - max-width: fit-content; + max-width: min(100%, 360px); min-height: 28px; padding: 0 8px; border: none; diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css index 7b2711da39..0cb3920f6e 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css @@ -171,6 +171,10 @@ .input, .mirror, .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 metrics or the highlight ranges drift off the glyphs. */ padding: 4px 12px 0 16px; @@ -306,11 +310,14 @@ border: none; border-radius: 999px; 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; + transition: background-color 100ms ease; } -.primary:hover { +.primary:hover:not(:disabled) { background: var(--dsw-alias-button-info-hover); } @@ -319,14 +326,6 @@ 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 { margin-left: 8px; padding: 1px 8px; diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index f2313d798b..26a65def6a 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -1,11 +1,12 @@ /** The default composer body: the 'conversation.composer.bar' slot entry * (decision 20). Machine state arrives through the standard provide channel * (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 * (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 clsx from 'clsx' import { IconPlusOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' @@ -27,15 +28,12 @@ const READONLY_OPTIONS: readonly { id: string; label: string }[] = [ ] 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', }: InputBarProps) { const input = useInput(s => s) - const noticeStore = keyboard.notices - const notice = useSyncExternalStore( - (fn: () => void) => noticeStore.subscribe(fn), - () => noticeStore.getSnapshot(), - ) + const notice = useNotices(s => s) + const lexicon = useLexicon(s => s) const promptError = useSession(s => s.promptError) const running = useSession(s => s.running) const disabled = useSession(s => s.removed) @@ -244,7 +242,7 @@ export function InputBar({ // claim token highlights through behind the textarea glyphs; each U+FFFC // placeholder renders as a chip (the textarea's own glyph is invisible, the // 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[] = [] { // Segment boundaries: the token range end, every chip offset, and every @@ -374,7 +372,7 @@ export function InputBar({ {machineBusy && } diff --git a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css index 8086cbfea7..c6b50a2c77 100644 --- a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css @@ -92,15 +92,15 @@ color: var(--dsw-alias-state-success-primary); } +.glyphPending { + color: var(--dsw-alias-label-caption); +} + .glyphProgress { color: var(--dsw-alias-state-business-primary); animation: todo-progress-spin 1s linear infinite; } -.glyphPending { - color: var(--dsw-alias-label-caption); -} - @keyframes todo-progress-spin { to { transform: rotate(360deg); diff --git a/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css b/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css index 9c42e69b59..a79faf30f1 100644 --- a/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css +++ b/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css @@ -1,6 +1,8 @@ /* Bash toolview: same geometry/tokens as ToolRow (figma Bash · description). */ .root { + position: relative; /* sweep-glare overlay anchor */ + overflow: hidden; display: flex; align-items: center; height: 24px; @@ -9,8 +11,27 @@ border-radius: 6px; } -.root:hover { - background: var(--dsw-alias-interactive-bg-hover); +/* Running sweep glare — same deepsuite ShimmerText pattern as ToolRow. */ +.root[data-state='running']::after { + content: ''; + position: absolute; + top: 0; + bottom: 0; + left: 0; + width: 300px; + background: linear-gradient( + 90deg, + transparent 0%, + color-mix(in srgb, var(--dsw-alias-bg-base) 60%, transparent) 55%, + transparent 100% + ); + animation: dsh-bash-row-sweep 2.6s ease-out infinite; + pointer-events: none; +} + +@keyframes dsh-bash-row-sweep { + 0% { left: -300px; } + 90%, 100% { left: 100%; } } .leading { @@ -39,7 +60,7 @@ flex: none; font-size: 14px; line-height: 24px; - color: var(--dsw-alias-label-primary-dimmed); + color: var(--dsw-alias-label-secondary); } .sep { diff --git a/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx b/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx index 616eee5943..fbb1104eca 100644 --- a/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx +++ b/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx @@ -12,10 +12,10 @@ import css from './bash-sample.module.css' function leadingFor(state: ToolRowState) { switch (state) { - case 'running': return case 'error': return case 'stopped': return - default: return + // Running keeps the icon — the row sweep carries the in-flight signal. + default: return } } diff --git a/packages/client/ui-conversation/src/client/toolviews/todo-row.module.css b/packages/client/ui-conversation/src/client/toolviews/todo-row.module.css index dd32d56b01..1a1b142b3a 100644 --- a/packages/client/ui-conversation/src/client/toolviews/todo-row.module.css +++ b/packages/client/ui-conversation/src/client/toolviews/todo-row.module.css @@ -10,10 +10,6 @@ border-radius: 6px; } -.row:hover { - background: var(--dsw-alias-interactive-bg-hover); -} - .leading { flex: none; width: 16px; @@ -29,6 +25,7 @@ flex: none; font-size: 14px; line-height: 24px; + font-weight: 500; /* figma wt510, rendered 500 */ color: var(--dsw-alias-label-primary-dimmed); } diff --git a/packages/client/ui-conversation/tests/apply-inject.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.spec.tsx index 504d0ec8c6..885cdd2523 100644 --- a/packages/client/ui-conversation/tests/apply-inject.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.spec.tsx @@ -87,12 +87,13 @@ async function bench() { } } const providers: TestProvider[] = [] + const absentInfo = { sessionId: undefined, hooks: {}, props: {} } const sessionsFake = { list: listStore, binding: (id: SessionId) => ({ sessionId: id, session: sessionFake, ctx: mint(id) }), scope: (id: SessionId) => mint(id), provideInfo: () => undefined, - maybeProvideInfo: () => ({ hooks: {}, props: {} }), + currentProvideInfo: { getSnapshot: () => absentInfo, subscribe: () => () => {} }, provide: (descriptor: TestProvider) => { providers.push(descriptor); return () => {} }, scopeOf, sessionOf: (actx: Context) => (scopeOf(actx) === undefined ? undefined : sessionFake), diff --git a/packages/client/ui-conversation/tests/chat-apply.spec.tsx b/packages/client/ui-conversation/tests/chat-apply.spec.tsx index 36a25c5b34..dab04e94c7 100644 --- a/packages/client/ui-conversation/tests/chat-apply.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-apply.spec.tsx @@ -32,12 +32,13 @@ async function bench() { current: undefined, phase: 'ready', }) + const absentInfo = { sessionId: undefined, hooks: {}, props: {} } const sessionsFake = { list: listStore, binding: vi.fn(), scope: () => undefined, provideInfo: () => undefined, - maybeProvideInfo: () => ({ hooks: {}, props: {} }), + currentProvideInfo: { getSnapshot: () => absentInfo, subscribe: () => () => {} }, provide: vi.fn(() => () => {}), create: vi.fn(), open: vi.fn(), diff --git a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx index aa9451b413..73236428fe 100644 --- a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx @@ -87,6 +87,9 @@ async function bench(snapshot: ConversationSnapshot) { // Provide-channel contributions land in this bundle the way the runtime // materializes them; the renderer host serves it through provideInfo. const provided: { hooks: Record; props: Record } = { hooks: {}, props: {} } + // Identity-stable currentProvideInfo snapshot (uSES getSnapshot contract), + // materialized on first render after the provide contributions landed. + let infoCell: { sessionId: SessionId; hooks: Record; props: Record } | undefined const sessionsFake = { list, binding: (id: SessionId) => (id === SID @@ -103,9 +106,10 @@ async function bench(snapshot: ConversationSnapshot) { provideInfo: (id: string) => (id === SID ? { sessionId: SID, hooks: { session, ...provided.hooks }, props: provided.props } : undefined), - maybeProvideInfo: (id: string | undefined) => (id === SID - ? { sessionId: SID, hooks: { session, ...provided.hooks }, props: provided.props } - : { hooks: provided.hooks, props: provided.props }), + currentProvideInfo: { + getSnapshot: () => infoCell ??= { sessionId: SID, hooks: { session, ...provided.hooks }, props: provided.props }, + subscribe: () => () => {}, + }, create: 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 view = mountApp(b.slots) // 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"]') expect(nested).not.toBeNull() }) diff --git a/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx b/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx index 13a74548b4..63f2da6586 100644 --- a/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx @@ -64,6 +64,16 @@ describe('tool-call-model', () => { 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', () => { expect(toolRowModel('bash', running({ argsRaw: '{"a":1}' })).body).toBe('{\n "a": 1\n}') expect(toolRowModel('bash', running({ argsRaw: 'raw' })).body).toBe('raw') @@ -125,12 +135,12 @@ describe('ToolRow', () => { 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() - expect(runningView.queryByTestId('tool-icon')).toBeNull() + expect(runningView.queryByTestId('tool-icon')).not.toBeNull() expect(runningView.container.querySelector('[data-state="running"]')).not.toBeNull() const errorView = render() - expect(errorView.queryByTestId('tool-icon')).toBeNull() + expect(errorView.container.querySelector('[data-testid="tool-icon"]')).toBeNull() }) it('non-expandable rows render a passive leading slot', () => { diff --git a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx index 87e188bbbd..ec713d4bb1 100644 --- a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx @@ -24,6 +24,9 @@ import type { ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/clien const SID = 's1' as SessionId +/** Identity-stable no-session bundle (uSES getSnapshot contract). */ +const ABSENT_INFO = { sessionId: undefined, hooks: {}, props: {} } + afterEach(cleanup) // The chat store persists under its declared key; clear between cases. beforeEach(() => { @@ -89,30 +92,28 @@ async function bench(nodes: ToolResultNode[]) { subscribe: (fn: () => void) => session.subscribe(fn), }, }) + const provideInfo = (id: string) => { + if (id !== SID) return undefined + if (info === undefined) { + const hooks: Record = { session } + const props: Record = {} + 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', { list, binding: bindingOf, scope: () => actxFake, - provideInfo: (id: string) => { - if (id !== SID) return undefined - if (info === undefined) { - const hooks: Record = { session } - const props: Record = {} - 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: {} } + provideInfo, + currentProvideInfo: { + getSnapshot: () => provideInfo(SID), + subscribe: () => () => {}, }, provide: (d: { resolve: (typeof providers)[number] }) => { providers.push(d.resolve); return () => {} }, scopeOf: () => SID, @@ -254,7 +255,10 @@ describe('registrant load-order seam', () => { binding: () => undefined, scope: () => undefined, provideInfo: () => undefined, - maybeProvideInfo: () => ({ hooks: {}, props: {} }), + currentProvideInfo: { + getSnapshot: () => ABSENT_INFO, + subscribe: () => () => {}, + }, provide: () => () => {}, create: vi.fn(), open: vi.fn(), diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index 20389c9e23..1754397eba 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -131,6 +131,22 @@ describe('chat-flow derivation', () => { expect(flowKeys(items)).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', () => { diff --git a/packages/client/ui-conversation/tests/coverage-tails.spec.tsx b/packages/client/ui-conversation/tests/coverage-tails.spec.tsx index 96f9cf1aa6..ccac62c74f 100644 --- a/packages/client/ui-conversation/tests/coverage-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/coverage-tails.spec.tsx @@ -90,7 +90,7 @@ describe('tails', () => { 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 list = createSnapshotStore({ ids: [sid], diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index c50a35110a..4999125c62 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -56,7 +56,11 @@ function bench(over?: BenchOptions) { // Lexicon-only stub: adjudication untouched (undefined slash methods are // never reached — these benches drive plain-draft flows only). ...(lex !== undefined - ? { slash: (() => ({ lexicon: () => lex })) as unknown as NonNullable } + ? { + slash: (() => ({ + lexicon: { getSnapshot: () => lex, subscribe: () => () => {} }, + })) as unknown as NonNullable, + } : {}), }) if (over?.draft !== undefined && over.draft !== '') shell.setDraft(over.draft) @@ -87,6 +91,8 @@ function bench(over?: BenchOptions) { useInput: bindSnapshotSelector(shell.state), inputActions: shell.actions, keyboard: shell, + useNotices: bindSnapshotSelector(shell.notices), + useLexicon: bindSnapshotSelector(shell.lexicon), stop, renderSlot, variant: over?.variant ?? 'composer', diff --git a/packages/client/ui-conversation/tests/input-matrix.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.spec.tsx index 284ef6c76a..f21f124b78 100644 --- a/packages/client/ui-conversation/tests/input-matrix.spec.tsx +++ b/packages/client/ui-conversation/tests/input-matrix.spec.tsx @@ -42,6 +42,8 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled useInput: bindSnapshotSelector(shell.state), inputActions: shell.actions, keyboard: shell, + useNotices: bindSnapshotSelector(shell.notices), + useLexicon: bindSnapshotSelector(shell.lexicon), renderSlot: (() => null) as InputBarProps['renderSlot'], stop: vi.fn(), variant: 'composer', diff --git a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx index 414f3c15b4..807650169b 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx +++ b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx @@ -128,6 +128,8 @@ async function scopedBench(register?: (slash: SlashService) => void) { useInput: bindSnapshotSelector(shell.state), inputActions: shell.actions, keyboard: shell, + useNotices: bindSnapshotSelector(shell.notices), + useLexicon: bindSnapshotSelector(shell.lexicon), renderSlot: (() => null) as InputBarProps['renderSlot'], stop: vi.fn(), 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', () => { it('adjudication misses in one hop and the whole line rides the default sink', async () => { const b = await bench() diff --git a/packages/client/ui-conversation/tests/selection-survival.spec.ts b/packages/client/ui-conversation/tests/selection-survival.spec.ts index ec50a3f317..533d5ce730 100644 --- a/packages/client/ui-conversation/tests/selection-survival.spec.ts +++ b/packages/client/ui-conversation/tests/selection-survival.spec.ts @@ -11,6 +11,9 @@ import { createChatStore } from '../src/client/stores.ts' 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 { slots: SlotsService chat: ReturnType @@ -23,7 +26,10 @@ function bench(): Bench { ids: [], byId: {}, current: undefined, phase: 'ready', }), provideInfo: () => undefined, - maybeProvideInfo: () => ({ hooks: {}, props: {} }), + currentProvideInfo: { + getSnapshot: () => ABSENT_INFO, + subscribe: () => () => {}, + }, provide: () => () => {}, }) ctx.provide('workspaces', { diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index cc6ac42995..4d3d10244f 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -122,6 +122,8 @@ function mount( useInput={useInput} inputActions={inputActions} keyboard={wiring} + useNotices={bindSnapshotSelector(wiring.notices)} + useLexicon={bindSnapshotSelector(wiring.lexicon)} stop={stop} renderSlot={(() => null) as InputBarProps['renderSlot']} {...bar} diff --git a/packages/client/ui-model/src/client/ModelSelect.module.css b/packages/client/ui-model/src/client/ModelSelect.module.css index cdf7f4be2e..d17a777edd 100644 --- a/packages/client/ui-model/src/client/ModelSelect.module.css +++ b/packages/client/ui-model/src/client/ModelSelect.module.css @@ -72,9 +72,11 @@ max-height: min(360px, calc(100vh - 96px)); overflow: hidden; 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; - background: var(--dsw-specific-input-major); + background: var(--dsw-specific-menu); box-shadow: var(--dsw-shadow-lv3); color: var(--dsw-alias-label-primary); } @@ -132,7 +134,7 @@ top: 0; z-index: 1; padding: 5px 8px 3px; - background: var(--dsw-specific-input-major); + background: var(--dsw-specific-menu); color: var(--dsw-alias-label-tertiary); font-size: 12px; line-height: 18px; @@ -156,11 +158,16 @@ } .option:hover:not(:disabled), -.option:focus-visible, -.selected { +.option:focus-visible { 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 { color: var(--dsw-alias-label-dimmed); cursor: default; @@ -201,7 +208,7 @@ display: grid; place-items: center; 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 diff --git a/packages/client/ui-primitives/src/Button.module.css b/packages/client/ui-primitives/src/Button.module.css index 3f415b5d15..b9384dd522 100644 --- a/packages/client/ui-primitives/src/Button.module.css +++ b/packages/client/ui-primitives/src/Button.module.css @@ -18,7 +18,7 @@ .button:disabled { cursor: not-allowed; - color: var(--dsw-alias-label-dimmed); + opacity: 0.4; } .md { @@ -44,10 +44,6 @@ background: var(--dsw-alias-button-primary-hover); } -.primary:disabled { - background: var(--dsw-alias-button-primary-dimmed); -} - .ghost:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover); } @@ -66,10 +62,6 @@ background: var(--dsw-alias-interactive-bg-hover); } -.outline:disabled { - border-color: var(--dsw-alias-border-l1); -} - .toolbar { background: var(--dsw-alias-button-tool-bar-fill); } diff --git a/packages/client/ui-primitives/src/Menu.module.css b/packages/client/ui-primitives/src/Menu.module.css index e6a59ea84a..6b16f9fd6e 100644 --- a/packages/client/ui-primitives/src/Menu.module.css +++ b/packages/client/ui-primitives/src/Menu.module.css @@ -26,6 +26,7 @@ left: 0; z-index: 100; min-width: 218px; + max-width: 360px; } /* Portal mode: fixed in the viewport, coordinates supplied inline from the @@ -50,6 +51,36 @@ 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 { position: relative; } @@ -109,7 +140,7 @@ } .item:disabled { - color: var(--dsw-alias-label-dimmed); + opacity: 0.4; cursor: not-allowed; } diff --git a/packages/client/ui-primitives/src/Menu.tsx b/packages/client/ui-primitives/src/Menu.tsx index 5e9a7bacf6..747750363d 100644 --- a/packages/client/ui-primitives/src/Menu.tsx +++ b/packages/client/ui-primitives/src/Menu.tsx @@ -5,6 +5,8 @@ // The owner controls `open`; outside-click closing uses one document listener // active only while open. Submenus open on hover/focus inside the same root. // 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 type { CSSProperties, ReactNode } from 'react' @@ -50,6 +52,9 @@ function isLabel(entry: MenuEntry): entry is MenuLabel { 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. * @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 * wrapper there races the host's layout effects). Called on open and on every * 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. */ -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 anchor: ReactNode items: readonly MenuEntry[] - selectedId?: string + footer?: readonly MenuEntry[] + selectedId?: string | undefined onSelect: (id: string) => void onClose: () => void align?: 'start' | 'end' - side?: 'bottom' | 'top' + side?: 'bottom' | 'top' | 'right' portal?: boolean closeOnPointerLeave?: boolean compact?: boolean @@ -111,11 +119,34 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align r = rootRef.current?.getBoundingClientRect() ?? null } if (r === null) return - setFixedPos({ - ...(align === 'start' ? { left: r.left } : { right: window.innerWidth - r.right }), - ...(side === 'bottom' ? { top: r.bottom + 4 } : { bottom: window.innerHeight - r.top + 4 }), - }) + const MARGIN = 12 + const vw = window.innerWidth + 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() window.addEventListener('scroll', place, true) window.addEventListener('resize', place) @@ -148,11 +179,77 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align } }, [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
+ } + if (isLabel(entry)) { + return
{entry.text}
+ } + const hasSub = entry.submenu !== undefined && entry.submenu.length > 0 + const subOpen = hasSub && openSubmenuId === entry.id + return ( +
{ setOpenSubmenuId(hasSub ? entry.id : null) }} + onMouseLeave={() => { setOpenSubmenuId(null) }} + > + + {subOpen && entry.submenu !== undefined && ( +
+ {entry.submenu.map(sub => ( + + ))} +
+ )} +
+ ) + } + + // 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 && (
{ onClose() } : undefined} // 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. onClick={(e) => { e.stopPropagation() }} > - {items.map((entry) => { - if (isSeparator(entry)) { - return
- } - if (isLabel(entry)) { - return
{entry.text}
- } - const hasSub = entry.submenu !== undefined && entry.submenu.length > 0 - const subOpen = hasSub && openSubmenuId === entry.id - return ( -
{ setOpenSubmenuId(hasSub ? entry.id : null) }} - onMouseLeave={() => { setOpenSubmenuId(null) }} - > - - {subOpen && entry.submenu !== undefined && ( -
- {entry.submenu.map(sub => ( - - ))} -
- )} -
- ) - })} +
+ {items.map(renderEntry)} +
+ {footer !== undefined && footer.length > 0 && ( +
+ {footer.map(renderEntry)} +
+ )}
) diff --git a/packages/client/ui-primitives/src/Modal.module.css b/packages/client/ui-primitives/src/Modal.module.css index 02c075803e..0c6d68e5fa 100644 --- a/packages/client/ui-primitives/src/Modal.module.css +++ b/packages/client/ui-primitives/src/Modal.module.css @@ -54,7 +54,7 @@ margin: 0; font-size: 16px; line-height: 24px; - font-weight: 510; + font-weight: 500; /* figma wt510, rendered 500 */ color: var(--dsw-alias-label-primary); } diff --git a/packages/client/ui-primitives/src/StateDot.module.css b/packages/client/ui-primitives/src/StateDot.module.css index e5a8ebdb50..e9442f46bb 100644 --- a/packages/client/ui-primitives/src/StateDot.module.css +++ b/packages/client/ui-primitives/src/StateDot.module.css @@ -1,7 +1,7 @@ /* 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. */ .dot, -.ring { +.matrix { --dsh-state-ongoing: var(--dsw-static-deepseek-450); } @@ -42,24 +42,24 @@ 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; color: var(--dsh-state-ongoing); - animation: dsh-state-dot-spin 1s linear infinite; } -.stopFrom { - stop-color: currentColor; - stop-opacity: 1; +.cell { + fill: currentColor; + opacity: 0.15; + animation: dsh-state-dot-chase 1s infinite; } -.stopTo { - stop-color: currentColor; - stop-opacity: 0; -} - -@keyframes dsh-state-dot-spin { - to { - transform: rotate(360deg); - } +@keyframes dsh-state-dot-chase { + 0%, 12.4% { opacity: 1; } + 12.5%, 24.9% { opacity: 0.6; } + 25%, 37.4% { opacity: 0.35; } + 37.5%, 100% { opacity: 0.15; } } diff --git a/packages/client/ui-primitives/src/StateDot.tsx b/packages/client/ui-primitives/src/StateDot.tsx index c4117673ba..643f9283f4 100644 --- a/packages/client/ui-primitives/src/StateDot.tsx +++ b/packages/client/ui-primitives/src/StateDot.tsx @@ -1,15 +1,19 @@ // 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 -// core. ongoing: 10x10 ring, 1px inside stroke, color fading out along a -// linear gradient, spinning. Colors resolve through --dsw-* tokens only. +// core. ongoing: a pixel-art chase — the 8 outer cells of a 3x3 matrix light +// up clockwise with a stepped trail. Colors resolve through --dsw-* tokens only. -import { useId } from 'react' import clsx from 'clsx' import css from './StateDot.module.css' /** Four-color session state semantic (green done / amber approval-waiting / blue running ring / red 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. * @param props.state - which of the four states to show. @@ -22,25 +26,29 @@ export function StateDot({ state, size = 10, className }: { size?: number className?: string }) { - const gradientId = useId() if (state === 'ongoing') { return ( ) } diff --git a/packages/client/ui-primitives/src/Tooltip.tsx b/packages/client/ui-primitives/src/Tooltip.tsx index 2c48854055..30e0c5fe0e 100644 --- a/packages/client/ui-primitives/src/Tooltip.tsx +++ b/packages/client/ui-primitives/src/Tooltip.tsx @@ -72,7 +72,7 @@ export function Tooltip({ label, side = 'right', disabled = false, children }: { {cloneElement(children, { ref: mergedRef, 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() }, onBlur: (e) => { children.props.onBlur?.(e); triggers.current.focus = false; hide() }, })} diff --git a/packages/client/ui-primitives/tests/atoms.spec.tsx b/packages/client/ui-primitives/tests/atoms.spec.tsx index 49d194cb04..51b14faa17 100644 --- a/packages/client/ui-primitives/tests/atoms.spec.tsx +++ b/packages/client/ui-primitives/tests/atoms.spec.tsx @@ -273,14 +273,47 @@ describe('Menu', () => { 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( trigger} items={items} onSelect={() => {}} onClose={() => {}} />) const menu = screen.getByRole('menu') - expect(menu.style.right).not.toBe('') - expect(menu.style.bottom).not.toBe('') - expect(menu.style.left).toBe('') - expect(menu.style.top).toBe('') + expect(menu.style.left).not.toBe('') + expect(menu.style.top).not.toBe('') + expect(menu.style.right).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( + trigger} + 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( + trigger} items={items} onSelect={() => {}} onClose={() => {}} />) + expect(screen.getByRole('menu').className).toMatch(/scrollable/) + rerender( + trigger} + items={[{ id: 'p', label: 'Parent', submenu: [{ id: 's', label: 'Sub' }] }]} + onSelect={() => {}} + onClose={() => {}} + />) + expect(screen.getByRole('menu').className).not.toMatch(/scrollable/) }) }) diff --git a/packages/client/ui-primitives/tests/state-dot.spec.tsx b/packages/client/ui-primitives/tests/state-dot.spec.tsx index a3759174ff..86e41c7787 100644 --- a/packages/client/ui-primitives/tests/state-dot.spec.tsx +++ b/packages/client/ui-primitives/tests/state-dot.spec.tsx @@ -14,16 +14,17 @@ describe('StateDot', () => { 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() expect(container.firstElementChild?.tagName).toBe('SPAN') rerender() - const ring = container.firstElementChild as SVGSVGElement - expect(ring.tagName).toBe('svg') - const circle = ring.querySelector('circle') - expect(circle?.getAttribute('stroke-width')).toBe('1') - expect(circle?.getAttribute('stroke')).toMatch(/^url\(#/) - expect(ring.querySelector('linearGradient')).not.toBeNull() + const matrix = container.firstElementChild as SVGSVGElement + expect(matrix.tagName).toBe('svg') + const cells = matrix.querySelectorAll('rect') + expect(cells).toHaveLength(8) + // Chase phase: every cell carries its own negative animation delay. + const delays = [...cells].map(cell => (cell).style.animationDelay) + expect(new Set(delays).size).toBe(8) }) it('sizes via the size prop in both shapes', () => { diff --git a/packages/client/ui-primitives/tests/tooltip.spec.tsx b/packages/client/ui-primitives/tests/tooltip.spec.tsx index 6741921c66..591a5eb67a 100644 --- a/packages/client/ui-primitives/tests/tooltip.spec.tsx +++ b/packages/client/ui-primitives/tests/tooltip.spec.tsx @@ -81,23 +81,20 @@ describe('Tooltip', () => { 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( , ) 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.mouseEnter(anchor) fireEvent.mouseLeave(anchor) - expect(screen.getByRole('tooltip')).toBeTruthy() - fireEvent.blur(anchor) 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.focus(anchor) fireEvent.blur(anchor) expect(screen.getByRole('tooltip')).toBeTruthy() fireEvent.mouseLeave(anchor) diff --git a/packages/client/ui-settings-general/src/client/GeneralSection.module.css b/packages/client/ui-settings-general/src/client/GeneralSection.module.css index 5e053a49c5..aced3b2962 100644 --- a/packages/client/ui-settings-general/src/client/GeneralSection.module.css +++ b/packages/client/ui-settings-general/src/client/GeneralSection.module.css @@ -72,6 +72,10 @@ cursor: pointer; } +.selector:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover); +} + .selector:disabled { cursor: default; } @@ -80,18 +84,21 @@ 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 { display: flex; align-items: stretch; gap: 8px; + flex-wrap: wrap; } -/* Tool Call mode cube (figma '.Selector Cube' 418w r16; horizontal inset = - * outer pad 4 + inner .Menu_cell pad 10, vertical = inner pad 8). */ +/* Tool Call mode cube (figma '.Selector Cube' 418w r16, flexed to fit the + * 800 panel; horizontal inset = outer pad 4 + inner .Menu_cell pad 10, + * vertical = inner pad 8). */ .modeCube { box-sizing: border-box; - width: 418px; + flex: 1 1 276px; display: flex; flex-direction: column; justify-content: center; @@ -101,6 +108,11 @@ border-radius: 16px; background: transparent; 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 diff --git a/packages/client/ui-settings/src/client/SettingsRoot.module.css b/packages/client/ui-settings/src/client/SettingsRoot.module.css index e2b2c878df..5f7ca9ec23 100644 --- a/packages/client/ui-settings/src/client/SettingsRoot.module.css +++ b/packages/client/ui-settings/src/client/SettingsRoot.module.css @@ -44,7 +44,8 @@ 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 { position: fixed; inset: 0; @@ -58,21 +59,22 @@ position: absolute; inset: 0; background: var(--dsw-alias-bg-mask-1); + backdrop-filter: var(--dsw-mask-blur); } -/* Panel (figma Settings 501:29947): 1080x700, r24, white, lv3 shadow - (figma effects match --dsw-shadow-lv3 exactly). */ +/* Panel (figma Settings 501:29947): r24, white, lv3 shadow (figma effects + match --dsw-shadow-lv3 exactly); figma's 1080x700 is shrunk to 800x600. */ .panel { position: relative; z-index: 1; display: flex; - width: 1080px; - height: 700px; + width: 800px; + height: 600px; max-width: calc(100vw - 48px); max-height: calc(100vh - 48px); border-radius: 24px; overflow: hidden; - background: var(--dsw-alias-bg-layer-1); + background: var(--dsw-alias-bg-layer-2); box-shadow: var(--dsw-shadow-lv3); } diff --git a/packages/client/ui-settings/src/client/SettingsRoot.tsx b/packages/client/ui-settings/src/client/SettingsRoot.tsx index 0d12135311..c3480e1d18 100644 --- a/packages/client/ui-settings/src/client/SettingsRoot.tsx +++ b/packages/client/ui-settings/src/client/SettingsRoot.tsx @@ -10,7 +10,7 @@ import { useCallback, useEffect, useId, useRef, useState } from 'react' import clsx from 'clsx' 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' /** Nav glyph by section id; unknown ids fall back to the settings gear. */ @@ -20,7 +20,7 @@ function navIcon(id: string) { } type PanelProps = { - rows: ReturnType + rows: readonly SettingsSectionRow[] renderSlot: SettingsRootComponentProps['renderSlot'] onClose: () => void } @@ -92,20 +92,14 @@ function SettingsPanel({ rows, renderSlot, onClose }: PanelProps) { * @returns the settings shell element tree. */ export function SettingsRoot(props: SettingsRootComponentProps) { - const { wide, subscribeSections, sectionsVersion, sections, renderSlot } = props + const { wide, useSections, renderSlot } = props const [open, setOpen] = useState(false) const close = useCallback(() => { setOpen(false) }, []) // The ledger tick keeps the nav rows fresh: registrants re-register with // freshly localized text on locale change, and the trigger/header/close // seats re-render through their own outlets' subscriptions. - // State = ledger version: same-version notifications dedupe to no render. - const [, setSectionsRev] = useState(() => sectionsVersion()) - useEffect( - () => subscribeSections(() => { setSectionsRev(sectionsVersion()) }), - [subscribeSections, sectionsVersion], - ) - const rows = sections() + const rows = useSections(s => s) return ( <> diff --git a/packages/client/ui-settings/src/client/contract/slots.ts b/packages/client/ui-settings/src/client/contract/slots.ts index 1a263108bc..c20a041858 100644 --- a/packages/client/ui-settings/src/client/contract/slots.ts +++ b/packages/client/ui-settings/src/client/contract/slots.ts @@ -7,7 +7,7 @@ * setting never means editing the shell; copy that belongs to no single * 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) // into every program that sees this contract. import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client' @@ -72,26 +72,32 @@ export interface SettingsSectionOwnerProps { 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 - * 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 = { - /** Read the settings.section ledger version (nav invalidation). */ - sectionsVersion: () => number - /** Subscribe to settings.section ledger changes. */ - subscribeSections: (listener: () => void) => () => void - /** Project the settings.section ledger into nav rows (id/order/label). */ - sections: () => readonly { id: string; order: number; label: string }[] + hooks: { + /** settings.section ledger projected into ordered nav rows. */ + sections: HostObservable + } } /** * Full component props of the settings shell root: the sidebar owner share - * (wide/rail state) plus the declared render shares and the injected face. - * No store is registered — modal open state and active section id are - * component-local viewing state. + * (wide/rail state) plus the declared render shares and the injected face + * (hooks compartment bound to useSections). No store is registered — modal + * open state and active section id are component-local viewing state. */ export type SettingsRootComponentProps = PropsRuntime<'sidebar.settings'> & PropsRenderSlots<'settings.trigger' | 'settings.header' | 'settings.close' | 'settings.section'> - & SettingsRootInjected + & InjectFace diff --git a/packages/client/ui-settings/src/client/index.ts b/packages/client/ui-settings/src/client/index.ts index 7cb3dfd6d4..f858be9c37 100644 --- a/packages/client/ui-settings/src/client/index.ts +++ b/packages/client/ui-settings/src/client/index.ts @@ -10,12 +10,12 @@ */ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' 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' export type { SettingsHeaderOwnerProps, SettingsRootComponentProps, SettingsRootInjected, - SettingsSectionOwnerProps, SettingsTriggerOwnerProps, + SettingsSectionOwnerProps, SettingsSectionRow, SettingsTriggerOwnerProps, } from './contract/slots.ts' /** @@ -32,17 +32,31 @@ export const inject = ['slots'] * @param ctx - client root context. */ 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 => ({ - sectionsVersion: () => ctx.slots.getVersion('settings.section'), - subscribeSections: listener => ctx.slots.subscribe('settings.section', listener), - sections: () => ctx.slots.entries('settings.section') - .map(e => ({ - /* v8 ignore next -- list-slot registration requires id (SlotCore rejects an entry without one) */ - id: e.options.id ?? '', - order: e.options.order ?? 0, - label: e.options.label ?? '', - })) - .sort((a, b) => a.order - b.order), + hooks: { + sections: { + getSnapshot: () => { + const version = ctx.slots.getVersion('settings.section') + if (version !== rowsVersion) { + rowsVersion = version + rows = ctx.slots.entries('settings.section') + .map(e => ({ + /* v8 ignore next -- list-slot registration requires id (SlotCore rejects an entry without one) */ + 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(() => { const deferred = deferRegistration(ctx.slots, 'sidebar.settings', SettingsRoot, () => diff --git a/packages/client/ui-settings/tests/apply.spec.ts b/packages/client/ui-settings/tests/apply.spec.ts index d7fdfbd546..caec65f3f5 100644 --- a/packages/client/ui-settings/tests/apply.spec.ts +++ b/packages/client/ui-settings/tests/apply.spec.ts @@ -60,22 +60,25 @@ describe('ui-settings apply', () => { const b = await bench() declare(b.slots) 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. - expect(injected.sections()).toEqual([]) + expect(sections.getSnapshot()).toEqual([]) b.slots.register({ name: 'settings.section', id: 'z', order: 20, label: 'Z' } as never, () => null) // No order and no label: both projection defaults apply. 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: '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 off = injected.subscribeSections(listener) + const off = sections.subscribe(listener) b.slots.register({ name: 'settings.section', id: 'b', order: 1, label: 'B' } as never, () => null) await Promise.resolve() expect(listener).toHaveBeenCalled() + expect(sections.getSnapshot()).not.toBe(rows) off() }) diff --git a/packages/client/ui-settings/tests/settings-root.spec.tsx b/packages/client/ui-settings/tests/settings-root.spec.tsx index 9584d500e5..dd340dc2ea 100644 --- a/packages/client/ui-settings/tests/settings-root.spec.tsx +++ b/packages/client/ui-settings/tests/settings-root.spec.tsx @@ -1,5 +1,6 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' +import { useEffect, useState } from 'react' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import type { SettingsRootComponentProps } from '../src/client/contract/slots.ts' import { SettingsRoot } from '../src/client/SettingsRoot.tsx' @@ -22,9 +23,9 @@ function mount({ { id: 'models', order: 10, label: 'Models' }, ], }: { 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 version = 0 const listeners = new Set<() => void>() const renderSlot = vi.fn( ((key: string, _owner: unknown, opts?: { only?: string }) => { @@ -38,19 +39,21 @@ function mount({ useSessions: unusedHook, useWorkspaces: unusedHook, wide, - sectionsVersion: () => version, - subscribeSections: (listener) => { - listeners.add(listener) - return () => { listeners.delete(listener) } + useSections: (select) => { + const [, force] = useState(0) + useEffect(() => { + const listener = () => { force(n => n + 1) } + listeners.add(listener) + return () => { listeners.delete(listener) } + }, []) + return select(current) }, - sections: () => current, renderSlot, } const view = render() const bump = (next: Row[]) => { act(() => { current = next - version += 1 for (const fn of [...listeners]) fn() }) } diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css index fe73435df8..ebb47467af 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css @@ -79,13 +79,19 @@ /* Brand group (figma I133:7632): the full wordmark rides the text ink (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 { flex: 1; min-width: 0; display: inline-flex; align-items: center; overflow: hidden; + padding: 0; + border: none; + background: transparent; + color: inherit; + cursor: pointer; } .iconButton { diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx index f5f810cbbe..30d705c780 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx @@ -61,10 +61,17 @@ export function SidebarRoot({ style={wide ? { width: collapsed ? lastWideWidth.current : width } : undefined} >
+ {/* Expanded, the wordmark doubles as a New Session shortcut; the + collapsed rail's logo is the expand toggle below instead. */} {wide && ( - + )} {/* Rail resting state is the whale mark; hovering swaps in the panel icon (the expand affordance, figma sidebar-hover flow). */} diff --git a/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx b/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx index 458ed0bda4..3c8086e4ce 100644 --- a/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx +++ b/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx @@ -54,10 +54,13 @@ function mountShell({ collapsed = false, width = 300 }: { collapsed?: boolean; w } describe('SidebarRoot shell', () => { - it('routes New Session and the column toggle', () => { + it('routes New Session (capsule + wordmark) and the column toggle', () => { const b = mountShell() - fireEvent.click(screen.getByRole('button', { name: 'New session' })) - expect(b.startSession).toHaveBeenCalledWith() + // Expanded, both the wordmark and the capsule start a session. + 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' })) expect(b.toggleSidebar).toHaveBeenCalledOnce() }) diff --git a/packages/client/ui-skill/src/client/index.ts b/packages/client/ui-skill/src/client/index.ts index 677843c844..d34c6ba96d 100644 --- a/packages/client/ui-skill/src/client/index.ts +++ b/packages/client/ui-skill/src/client/index.ts @@ -44,6 +44,21 @@ export function apply(ctx: ClientContext): void { // Session-keyed catalog cache; single-flight per key. Plugin-closure state: // the fiber effect below is its teardown boundary. const fetches = new Map() + // Per-session lexicon invalidation listeners (subscribeLexicon consumers). + const lexiconListeners = new Map 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 => { const existing = fetches.get(sessionId) @@ -58,7 +73,10 @@ export function apply(ctx: ClientContext): void { fetches.set(sessionId, entry) promise.then( // 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. () => { if (fetches.get(sessionId) === entry) fetches.delete(sessionId) @@ -72,6 +90,7 @@ export function apply(ctx: ClientContext): void { if (entry === undefined) return fetches.delete(key) entry.abort.abort() + notifyLexicon(key) } const clearAll = (): void => { @@ -97,6 +116,16 @@ export function apply(ctx: ClientContext): void { lexicon(session) { 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 }) { // Decision 21: plain-text reference — the literal lands in the draft // and ships to the model verbatim (trailing space closes the token). diff --git a/packages/client/ui-skill/tests/browser-plugin.spec.ts b/packages/client/ui-skill/tests/browser-plugin.spec.ts index 11f53e142c..0d8f2c57cd 100644 --- a/packages/client/ui-skill/tests/browser-plugin.spec.ts +++ b/packages/client/ui-skill/tests/browser-plugin.spec.ts @@ -208,6 +208,33 @@ describe('lexicon', () => { // Another session's key is independent — cold until its own fetch. 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', () => { diff --git a/packages/client/ui-slash/README.i18n.yaml b/packages/client/ui-slash/README.i18n.yaml index c09d7f4c28..1053e205b0 100644 --- a/packages/client/ui-slash/README.i18n.yaml +++ b/packages/client/ui-slash/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -README.md: d2978695d71686059bfbcbb4fc3ef896d92add4a -README.zh.md: 6aeb078a922aaa93d50ed16b4dbe54329737d018 +# pnpm run verify-translation-pairing --write packages/client/ui-slash/README.md +README.md: 4e363c2682bf91862ec40f3f2174831451fb9b0d +README.zh.md: 76d39673cb853d1889ee84cb9f3595708eae2db3 diff --git a/packages/client/ui-slash/README.md b/packages/client/ui-slash/README.md index d2978695d7..4e363c2682 100644 --- a/packages/client/ui-slash/README.md +++ b/packages/client/ui-slash/README.md @@ -2,7 +2,7 @@ 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. diff --git a/packages/client/ui-slash/README.zh.md b/packages/client/ui-slash/README.zh.md index 6aeb078a92..76d39673cb 100644 --- a/packages/client/ui-slash/README.zh.md +++ b/packages/client/ui-slash/README.zh.md @@ -2,7 +2,7 @@ [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);变更需经主线程仲裁。 diff --git a/packages/client/ui-slash/src/client/controller.ts b/packages/client/ui-slash/src/client/controller.ts index d3d3567e4d..ab0b26da54 100644 --- a/packages/client/ui-slash/src/client/controller.ts +++ b/packages/client/ui-slash/src/client/controller.ts @@ -40,18 +40,35 @@ export interface SlashControllerDeps { export class SlashController { /** Menu state store (per-session; survives session switches, dies with the scope). */ readonly menu: SnapshotStore = createSnapshotStore(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> = + createSnapshotStore>(new Map()) /** The authoritative hit: single truth for span CAS material (menu snapshot never carries it alone). */ private hit: TriggerHit | null = null private fetch: AbortController | null = null private disposed = false + /** Per-source lexicon unsubscribers (sources without the hook never enter). */ + private readonly lexiconOffs = new Map void>() constructor(private readonly deps: SlashControllerDeps) { // Scope-birth prewarm: sessions are always agent-backed, so the one-time // roster warm here replaces the projection-transition watch — there are // no capability steps to react to. 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) { 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). */ @@ -228,6 +262,8 @@ export class SlashController { this.stopFetch() this.reduce({ type: 'close' }) 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). */ @@ -248,25 +284,33 @@ export class SlashController { return actx.bail(actx, 'slash/input-insert-reference', { reference: outcome.insert, span }) === true } - /** - * Aggregate the sources' plain-text reference lexicons (decision 21), - * 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 { + /** Re-poll every lexicon-bearing source and publish the aggregated rolls (see the store doc). */ + private refreshLexicon(): void { const projection = this.project() const rolls = new Map() for (const src of this.deps.roster.all()) { 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 const prev = rolls.get(src.trigger) 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. */ diff --git a/packages/client/ui-slash/src/client/service.ts b/packages/client/ui-slash/src/client/service.ts index 094f325393..c47d44c3d4 100644 --- a/packages/client/ui-slash/src/client/service.ts +++ b/packages/client/ui-slash/src/client/service.ts @@ -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. * @returns the disposer (callers wrap registration in ctx.effect). Disposal * 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`) } 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 () => { const at = live.sources.indexOf(src) if (at < 0) return diff --git a/packages/client/ui-slash/src/types.ts b/packages/client/ui-slash/src/types.ts index 2fb26fa4b6..4b9bd64408 100644 --- a/packages/client/ui-slash/src/types.ts +++ b/packages/client/ui-slash/src/types.ts @@ -165,6 +165,16 @@ export interface SlashSource { * (the render path must stay synchronous and side-effect free). */ 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. */ readonly codec?: ReferenceCodec } diff --git a/packages/client/ui-slash/tests/service.spec.ts b/packages/client/ui-slash/tests/service.spec.ts index 379d7bbe8a..099e2bb4f5 100644 --- a/packages/client/ui-slash/tests/service.spec.ts +++ b/packages/client/ui-slash/tests/service.spec.ts @@ -126,6 +126,18 @@ describe('registerSource', () => { slash.registerSource(deferredSource('/', 'beta').source) }) + it('a source registered after controller birth warms in every live controller', async () => { + const { slash, mint } = await serviceBench() + const ca = slash.sessionOf(mint('a').actx) + const cb = slash.sessionOf(mint('b').actx) + const late = deferredSource('/', 'late', { lexicon: () => ['fresh'] }) + slash.registerSource(late.source) + expect(late.warm).toHaveBeenNthCalledWith(1, { sessionId: sid('a') }) + expect(late.warm).toHaveBeenNthCalledWith(2, { sessionId: sid('b') }) + expect(ca.lexicon.getSnapshot().get('/')).toEqual(['fresh']) + expect(cb.lexicon.getSnapshot().get('/')).toEqual(['fresh']) + }) + it('HMR shape: dispose of the registering fiber removes the source', async () => { const { root, slash, mint } = await serviceBench() const controller = slash.sessionOf(mint('a').actx) @@ -513,7 +525,7 @@ describe('lexicon', () => { skill, lexSource('@', 'subagent', ['worker-1']), ]) - const rolls = controller.lexicon() + const rolls = controller.lexicon.getSnapshot() expect([...rolls.keys()]).toEqual(['/', '@']) expect(rolls.get('/')).toEqual(['commit-helper', 'review']) expect(rolls.get('@')).toEqual(['worker-1']) @@ -522,7 +534,7 @@ describe('lexicon', () => { it('an undefined answer (roll not hot) is skipped without seeding the trigger', () => { const { controller } = controllerBench([lexSource('/', 'skill', undefined)]) - expect(controller.lexicon().size).toBe(0) + expect(controller.lexicon.getSnapshot().size).toBe(0) }) it('two sources on one trigger concatenate in registration order', () => { @@ -531,10 +543,63 @@ describe('lexicon', () => { lexSource('/', 'prompt', ['c']), lexSource('@', 'subagent', undefined), // not hot: '@' stays absent ]) - const rolls = controller.lexicon() + const rolls = controller.lexicon.getSnapshot() expect(rolls.get('/')).toEqual(['b', 'a', 'c']) expect(rolls.has('@')).toBe(false) }) + + it('a source lexicon notification republishes the aggregated store', () => { + let roll: readonly string[] | undefined = undefined + let notify: (() => void) | undefined + const source: SlashSource = { + trigger: '/', + name: 'skill', + candidates: () => Promise.resolve([]), + onPick: () => undefined, + lexicon: () => roll, + subscribeLexicon: (_session, listener) => { + notify = listener + return () => { notify = undefined } + }, + } + const { controller } = controllerBench([source]) + expect(controller.lexicon.getSnapshot().size).toBe(0) + const seen: number[] = [] + controller.lexicon.subscribe(() => { seen.push(controller.lexicon.getSnapshot().size) }) + roll = ['commit-helper'] + notify?.() + expect(controller.lexicon.getSnapshot().get('/')).toEqual(['commit-helper']) + expect(seen).toEqual([1]) + controller.dispose() + expect(notify).toBeUndefined() + }) + + it('a source registered after scope birth is warmed and folded into the live lexicon', () => { + const { controller, sources } = controllerBench([]) + expect(controller.lexicon.getSnapshot().size).toBe(0) + const warm = vi.fn() + const late: SlashSource = { + trigger: '/', + name: 'late', + candidates: () => Promise.resolve([]), + onPick: () => undefined, + warm, + lexicon: () => ['fresh'], + } + sources.push(late) + controller.sourceAdded(late) + expect(warm).toHaveBeenCalledWith({ sessionId: sid('a') }) + expect(controller.lexicon.getSnapshot().get('/')).toEqual(['fresh']) + }) + + it('a removed source leaves the aggregated lexicon', () => { + const src = lexSource('/', 'skill', ['gone']) + const { controller, sources } = controllerBench([src]) + expect(controller.lexicon.getSnapshot().get('/')).toEqual(['gone']) + sources.splice(sources.indexOf(src), 1) + controller.sourceRemoved(src) + expect(controller.lexicon.getSnapshot().size).toBe(0) + }) }) describe('arbitrate', () => { diff --git a/packages/client/ui-slots/src/index.ts b/packages/client/ui-slots/src/index.ts index 1f30f7027b..7b980571ef 100644 --- a/packages/client/ui-slots/src/index.ts +++ b/packages/client/ui-slots/src/index.ts @@ -14,6 +14,7 @@ * consumer merges keys in and the intersection is what keeps them string-typed. * The rule fires on the empty-map view, not on real redundancy. */ import type { ReactNode } from 'react' +import type { HostObservable } from './renderer.ts' import type { BoundActions, HandleOf, PropsStore, SnapshotSelectorHook, StoreDecl } from './store.ts' export * from './store.ts' @@ -214,11 +215,40 @@ export type PropsRenderSlots = { */ export type SlotComponent

= (props: P) => ReactNode +/** + * Registrant hooks compartment: bare observable sources (getSnapshot + + * subscribe pairs) supplied under the reserved `hooks` key of an inject + * face. The registrant-private twin of the `sessions.provide` hooks + * compartment: the renderer binds each source into a `use` selector + * hook, so the sources never reach the component and plugin-private reactive + * facts ride the same subscription machinery as the standard kit instead of + * hand-rolled component subscriptions. + */ +export type HooksSources = Record> + +/** + * Selector-hook share synthesized from a hooks compartment: each source + * `name` becomes a `use` selector hook over its snapshot type. + */ +export type PropsHooks = { + [N in keyof HS & string as `use${Capitalize}`]: + SnapshotSelectorHook ? T : never> +} + +/** + * The component-side view of an inject face: the reserved `hooks` + * compartment (when declared) arrives as bound `use` selector hooks; + * every other member passes through verbatim. + */ +export type InjectFace = + I extends { hooks: infer HS extends HooksSources } ? Omit & PropsHooks : I + /** * The four-share component props intersection: runtime share (SlotMap) + * child-render share (children declaration) + store share (declared handle) + - * the registrant's injected business face. Each share derives from its single - * source of truth; components reference this composition, never re-type it. + * the registrant's injected business face (its hooks compartment bound, see + * {@link InjectFace}). Each share derives from its single source of truth; + * components reference this composition, never re-type it. */ export type ComposedProps< K extends keyof SlotMap & string, @@ -226,7 +256,7 @@ export type ComposedProps< H, I extends object, M = never, -> = PropsRuntime & PropsRenderSlots & PropsStore & I & MatchedShare +> = PropsRuntime & PropsRenderSlots & PropsStore & InjectFace & MatchedShare /** * Inject factory parameter list, derived from the registration's declaration: diff --git a/packages/client/ui-slots/src/renderer.ts b/packages/client/ui-slots/src/renderer.ts index 5b7de0d6f1..4a437887d1 100644 --- a/packages/client/ui-slots/src/renderer.ts +++ b/packages/client/ui-slots/src/renderer.ts @@ -105,18 +105,14 @@ export interface SlotRendererHost { sessions: { /** Session list source backing the useSessions standard hook. */ list: HostObservable - /** Current-session source used by SessionProvider. */ - current: HostObservable - /** Resolve a definite session bundle, or undefined when the id is unknown. */ - provideInfo(id: string): SessionProvideInfo | undefined /** - * Resolve the current-session-optional standard props bundle. The result - * always carries the static provider roster, even when `id` is absent or - * cannot resolve to a live session. - * @param id - current session id, when selected. - * @returns the optional provide info. + * Atomic current-session provide projection used by SessionProvider: + * selection changes and provider-roster changes publish through this one + * source, so a stable current id cannot strand mounted entries on an + * obsolete hook/prop schema. Carries the static roster with sessionId + * undefined while no current session resolves. */ - maybeProvideInfo(id: string | undefined): SessionMaybeProvideInfo + provideInfo: HostObservable } /** Workspace-side standard-kit sources. */ workspaces: { diff --git a/packages/client/ui-subagent/src/client/index.ts b/packages/client/ui-subagent/src/client/index.ts index 3ad1543c68..4170f8e339 100644 --- a/packages/client/ui-subagent/src/client/index.ts +++ b/packages/client/ui-subagent/src/client/index.ts @@ -39,6 +39,10 @@ export function apply(ctx: ClientContext): void { // The list snapshot is always warm — the full running-children roster. return childLabels(session, '') }, + subscribeLexicon(_session, listener) { + // The roll derives from the list snapshot, so its change feed IS the list's. + return sessions.list.subscribe(listener) + }, onPick({ candidate }) { // Decision 21: plain-text reference — the literal lands in the draft // and ships to the model verbatim (trailing space closes the token). diff --git a/packages/client/ui-subagent/tests/browser-plugin.spec.ts b/packages/client/ui-subagent/tests/browser-plugin.spec.ts index fc74470406..d138295276 100644 --- a/packages/client/ui-subagent/tests/browser-plugin.spec.ts +++ b/packages/client/ui-subagent/tests/browser-plugin.spec.ts @@ -32,17 +32,31 @@ function sessionsWith(sessions: SessionSummary[]) { const byId: Record = {} for (const s of sessions) byId[s.id] = s const snapshot = { ids: sessions.map(s => s.id), byId, current: undefined } as unknown as SessionListState - return { list: { getSnapshot: () => snapshot } } + const subs = new Set<() => void>() + return { + list: { + getSnapshot: () => snapshot, + subscribe: (fn: () => void) => { subs.add(fn); return () => { subs.delete(fn) } }, + }, + notify: () => { for (const fn of [...subs]) fn() }, + listenerCount: () => subs.size, + } } -/** Boot the plugin over fake slash/sessions faces; returns the captured source. */ -async function bench(sessions: SessionSummary[]): Promise { +/** Boot the plugin over fake slash/sessions faces; returns the captured source and the list face. */ +async function fullBench(sessions: SessionSummary[]) { const ctx = new Context() let captured: SlashSource | undefined + const face = sessionsWith(sessions) ctx.provide('slash', { registerSource: (src: SlashSource) => { captured = src; return () => {} } }) - ctx.provide('sessions', sessionsWith(sessions)) + ctx.provide('sessions', face) await ctx.plugin({ inject: [...inject], apply }).await() - return captured! + return { source: captured!, face } +} + +/** Source-only bench for the behavior-contract suites. */ +async function bench(sessions: SessionSummary[]): Promise { + return (await fullBench(sessions)).source } const FAMILY: SessionSummary[] = [ @@ -113,6 +127,19 @@ describe('lexicon', () => { expect(source.lexicon!(proj('parent'))).toEqual(['worker-1', 'worker-2', 'scout']) expect(source.lexicon!(proj('childless'))).toEqual([]) }) + + it('subscribeLexicon forwards the session-list change feed and unsubscribes cleanly', async () => { + const { source, face } = await fullBench(FAMILY) + let notified = 0 + const off = source.subscribeLexicon!(proj('parent'), () => { notified += 1 }) + expect(face.listenerCount()).toBe(1) + face.notify() + expect(notified).toBe(1) + off() + expect(face.listenerCount()).toBe(0) + face.notify() + expect(notified).toBe(1) + }) }) describe('pick and codec', () => { diff --git a/packages/client/ui-theme/src/client/AppearanceRow.module.css b/packages/client/ui-theme/src/client/AppearanceRow.module.css index 9619caa823..6c5354b0ac 100644 --- a/packages/client/ui-theme/src/client/AppearanceRow.module.css +++ b/packages/client/ui-theme/src/client/AppearanceRow.module.css @@ -20,13 +20,15 @@ display: flex; align-items: stretch; gap: 8px; + flex-wrap: wrap; } /* Appearance cube (figma '.Selector Cube' 276x82 r16, pad 20/32, centered - * icon-over-label column, gap 4). */ + * icon-over-label column, gap 4); flexed down from the figma width so all + * three sit on one row in the 800 panel, wrapping when narrower. */ .themeCube { box-sizing: border-box; - width: 276px; + flex: 1 1 180px; display: flex; flex-direction: column; align-items: center; @@ -43,6 +45,10 @@ cursor: pointer; } +.themeCube:hover:not(.selected) { + background: var(--dsw-alias-interactive-bg-hover); +} + /* Selected cube: #F5F6F7 fill + #ADB2B8 border (static token — the bluish-400 * step has no alias-layer name). */ .selected { diff --git a/packages/client/ui-theme/src/styles/design-platform.css b/packages/client/ui-theme/src/styles/design-platform.css index e00ec415b7..3e8710822e 100644 --- a/packages/client/ui-theme/src/styles/design-platform.css +++ b/packages/client/ui-theme/src/styles/design-platform.css @@ -1,3 +1,6 @@ +/* Figma font-weight 510 (an SF Pro variable-font weight) always renders as + font-weight: 500 in this UI — non-variable webfonts snap intermediate + weights unpredictably across platforms. */ body { --dsw-static-amber-100: rgb(254, 245, 231); --dsw-static-amber-400: rgb(247, 173, 49); @@ -20,7 +23,7 @@ body { --dsw-static-deepseek-300: rgb(183, 200, 254); --dsw-static-deepseek-400: rgb(103, 158, 254); --dsw-static-deepseek-450: rgb(86, 134, 254); - --dsw-static-deepseek-500: rgb(57, 100, 254); + --dsw-static-deepseek-500: rgb(65, 118, 230); --dsw-static-deepseek-50: rgb(237, 243, 254); --dsw-static-deepseek-600: rgb(72, 104, 178); --dsw-static-deepseek-700-delete: rgb(47, 76, 143); @@ -95,7 +98,7 @@ body[data-ds-dark-theme] { --dsw-static-deepseek-300: rgb(183, 200, 254); --dsw-static-deepseek-400: rgb(103, 158, 254); --dsw-static-deepseek-450: rgb(86, 134, 254); - --dsw-static-deepseek-500: rgb(57, 100, 254); + --dsw-static-deepseek-500: rgb(65, 118, 230); --dsw-static-deepseek-50: rgb(237, 243, 254); --dsw-static-deepseek-600: rgb(72, 104, 178); --dsw-static-deepseek-700-delete: rgb(47, 76, 143); @@ -302,7 +305,7 @@ body[data-ds-dark-theme] { --dsw-alias-scrollbar-bg-l2: var(--dsw-static-neutral-600); --dsw-alias-scrollbar-hover-l1: var(--dsw-static-neutral-600); --dsw-alias-scrollbar-hover-l2: var(--dsw-static-neutral-550); - --dsw-alias-state-business-primary: var(--dsw-static-deepseek-500); + --dsw-alias-state-business-primary: var(--dsw-static-deepseek-400); --dsw-alias-state-business-tertiary: var(--dsw-static-deepseek-800); --dsw-alias-state-error-primary: var(--dsw-static-red-400); --dsw-alias-state-error-secondary: var(--dsw-static-red-400); diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css b/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css index c03d511c92..7dcb4f9b9d 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css @@ -129,6 +129,7 @@ reads the shell's class names): the two icon controls stack as 36x36 circles matching the shell's rail rhythm. */ .rail .sectionHeader { + gap: 0; padding-left: 0; margin-bottom: 12px; } diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx index ea1753a63e..dfca686a6e 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx @@ -265,6 +265,7 @@ export function WorkspaceBrowser({ // states; the menu anchors on this button). const [wsPickerOpen, setWsPickerOpen] = useState(false) const wsPlusRef = useRef(null) + const composingRef = useRef(false) // Rail search = expand + land in the search box: the flag arms before the // expand request; once the shell flips wide the input mounts and takes focus. @@ -358,7 +359,6 @@ export function WorkspaceBrowser({ className={css.iconButton} aria-label="Create workspace" onClick={() => { - if (!wide) expandSidebar() setWsPickerOpen(v => !v) }} > @@ -372,6 +372,8 @@ export function WorkspaceBrowser({ useWorkspaces={useWorkspaces} createWorkspace={createWorkspace} pickDirectory={pickDirectory} + createOnly + side="right" onPick={(workspaceId) => { setWsPickerOpen(false) startSession(workspaceId) @@ -459,9 +461,12 @@ export function WorkspaceBrowser({ aria-label="Workspace name" autoFocus disabled={renaming} + onFocus={(e) => { e.target.select() }} onChange={(e) => { setRenameDraft(e.target.value); setRenameError(null) }} + onCompositionStart={() => { composingRef.current = true }} + onCompositionEnd={() => { composingRef.current = false }} onKeyDown={(e) => { - if (e.key === 'Enter') { + if (e.key === 'Enter' && !composingRef.current) { e.preventDefault() confirmRename() } diff --git a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx index 4ec84c7d3d..f33e7dc526 100644 --- a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx +++ b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx @@ -5,7 +5,7 @@ * slot registration. */ import type { RefObject } from 'react' -import { useCallback, useState } from 'react' +import { useCallback, useRef, useState } from 'react' import { Button, IconFolderClose16, IconPlusOutline16, Menu, Modal, type MenuEntry, } from '@deepseek-ai/dsh-client-ui-primitives' @@ -37,6 +37,12 @@ export interface WorkspaceCreateFlowProps { onPick: (workspaceId: WorkspaceId) => void /** Close the popover (outside click / Escape / post-pick). */ onClose: () => void + /** Only show create actions (open folder / create new), hide existing workspaces. */ + createOnly?: boolean + /** Menu opening direction relative to the anchor. */ + side?: 'bottom' | 'top' | 'right' + /** Currently active workspace (trailing check in the picker list). */ + selectedId?: WorkspaceId | undefined } /** @@ -52,6 +58,9 @@ export function WorkspaceCreateFlow({ pickDirectory, onPick, onClose, + createOnly = false, + side = 'bottom', + selectedId, }: WorkspaceCreateFlowProps) { const workspaceSnapshot = useWorkspaces(state => state) const workspaces = workspaceSnapshot.items @@ -65,21 +74,26 @@ export function WorkspaceCreateFlow({ const [modalError, setModalError] = useState(null) const [pickingFolder, setPickingFolder] = useState(false) const [folderConflict, setFolderConflict] = useState(false) + const composingRef = useRef(false) const normalizedWorkspaceName = workspaceName.trim() const duplicateWorkspaceName = !creating && normalizedWorkspaceName !== '' && workspaces.some(workspace => workspace.title === normalizedWorkspaceName) - const items: MenuEntry[] = [ - ...workspaces.map(workspace => ({ + const createEntries: MenuEntry[] = [ + { id: OPEN_LOCAL_FOLDER, label: 'Open local folder…', icon: , disabled: pickingFolder }, + { id: CREATE_NEW, label: 'Create a new workspace', icon: , disabled: pickingFolder }, + ] + // With workspaces listed, the create actions pin below the scroll region + // (divider + always visible); otherwise they ARE the menu. + const pinCreate = !createOnly && workspaces.length > 0 + const items: MenuEntry[] = pinCreate + ? workspaces.map(workspace => ({ id: workspace.workspaceId, label: workspace.title, icon: , disabled: pickingFolder, - })), - ...(workspaces.length > 0 ? [{ type: 'separator' as const, id: 'sep-create' }] : []), - { id: OPEN_LOCAL_FOLDER, label: 'Open local folder…', icon: , disabled: pickingFolder }, - { id: CREATE_NEW, label: 'Create a new workspace', icon: , disabled: pickingFolder }, - ] + })) + : createEntries const closeModal = (): void => { if (creating) return @@ -114,7 +128,7 @@ export function WorkspaceCreateFlow({ } if (id === CREATE_NEW) { onClose() - setWorkspaceName('workspace') + setWorkspaceName('') setModalError(null) setModalKind('create') return @@ -149,8 +163,11 @@ export function WorkspaceCreateFlow({ open={open} anchor={null} items={items} + {...pinCreate ? { footer: createEntries } : {}} + selectedId={selectedId} onSelect={handleSelect} onClose={onClose} + side={side} portal getAnchorRect={getAnchorRect} /> @@ -194,12 +211,15 @@ export function WorkspaceCreateFlow({ { setWorkspaceName(event.target.value); setModalError(null) }} + onCompositionStart={() => { composingRef.current = true }} + onCompositionEnd={() => { composingRef.current = false }} onKeyDown={(event) => { - if (event.key === 'Enter') { + if (event.key === 'Enter' && !composingRef.current) { event.preventDefault() confirmCreate() } @@ -225,6 +245,7 @@ export function WorkspacePicker({ open, anchorRef, useWorkspaces, + selectedId, onPick, onClose, createWorkspace, @@ -237,6 +258,7 @@ export function WorkspacePicker({ useWorkspaces={useWorkspaces} createWorkspace={createWorkspace} pickDirectory={pickDirectory} + selectedId={selectedId} onPick={onPick} onClose={onClose} /> diff --git a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx index 7b8462f6db..e8405c6bcd 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx @@ -263,25 +263,21 @@ describe('WorkspaceBrowser', () => { } }) - it('rail create-workspace expands the shell and opens the picker; wide toggles in place', () => { + it('rail create-workspace toggles the create-only picker in place, without expanding', () => { const expandSidebar = vi.fn() - const b = mount({ wide: false, expandSidebar, useWorkspaces: hook(workspaceState([workspace('alpha', [])])) }) + mount({ wide: false, expandSidebar, useWorkspaces: hook(workspaceState([workspace('alpha', [])])) }) fireEvent.click(screen.getByRole('button', { name: 'Create workspace' })) - expect(expandSidebar).toHaveBeenCalledTimes(1) - rerender(b, { wide: true }) - // The picker menu is open (anchored on the +); picking starts a session. - fireEvent.click(screen.getByRole('menuitem', { name: 'alpha' })) - expect(b.props.startSession).toHaveBeenCalledWith(wid('alpha')) - expect(screen.queryByRole('menu')).toBeNull() - // Wide toggle: open and close without expand requests. - fireEvent.click(screen.getByRole('button', { name: 'Create workspace' })) - expect(screen.getByRole('menu')).toBeTruthy() + expect(expandSidebar).not.toHaveBeenCalled() + // createOnly: existing workspaces are not listed, only the create actions. + expect(screen.queryByRole('menuitem', { name: 'alpha' })).toBeNull() + expect(screen.getByRole('menuitem', { name: 'Open local folder…' })).toBeTruthy() + // Toggle: open and close in place. fireEvent.click(screen.getByRole('button', { name: 'Create workspace' })) expect(screen.queryByRole('menu')).toBeNull() - expect(expandSidebar).toHaveBeenCalledTimes(1) // Escape closes the picker through its own onClose. fireEvent.click(screen.getByRole('button', { name: 'Create workspace' })) + expect(screen.getByRole('menu')).toBeTruthy() fireEvent.keyDown(document, { key: 'Escape' }) expect(screen.queryByRole('menu')).toBeNull() }) diff --git a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx index 6cad175ff3..081ff0c044 100644 --- a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx @@ -205,6 +205,8 @@ describe('WorkspacePicker', () => { it('reports non-Error creation failures', async () => { const b = mount([], vi.fn(async () => { throw 'permission denied' })) chooseItem('Create a new workspace') + // The name field starts empty (no prefill); a name is required to submit. + fireEvent.change(screen.getByLabelText('New workspace name'), { target: { value: 'broken' } }) fireEvent.click(screen.getByRole('button', { name: 'Create workspace' })) await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('Workspace creation failed: permission denied') diff --git a/packages/client/web-react/src/scoped-slots.tsx b/packages/client/web-react/src/scoped-slots.tsx index 3ef01d7390..5cea31cd6c 100644 --- a/packages/client/web-react/src/scoped-slots.tsx +++ b/packages/client/web-react/src/scoped-slots.tsx @@ -5,8 +5,8 @@ import { Component, useSyncExternalStore, type FC, type ReactNode } from 'react' import { SlotOwnershipError, StaleAuthorizationError, - type ChainRenderOpts, type RenderOpts, type SessionMaybeProvideInfo, type SessionProvideInfo, - type SlotRenderer, type SlotRendererHost, type SlotScope, type StoredEntry, + type ChainRenderOpts, type HostObservable, type RenderOpts, type SessionMaybeProvideInfo, + type SessionProvideInfo, type SlotRenderer, type SlotRendererHost, type SlotScope, type StoredEntry, } from '@deepseek-ai/dsh-client-ui-slots' import { HostContext, SessionMaybeProvider, SessionProvider, SlotAssemblyError, maybeObservableHook, @@ -96,7 +96,26 @@ function runInject(entry: StoredEntry, info: SessionMaybeProvideInfo | undefined const args: unknown[] = [] if (info !== undefined) args.push(info.sessionId) if (actions !== undefined) args.push(actions) - return (inject as (...args: unknown[]) => InjectedProps)(...args) + return bindInjectHooks((inject as (...args: unknown[]) => InjectedProps)(...args)) +} + +/** + * Bind an inject face's reserved `hooks` compartment (bare observable + * sources, see HooksSources) into `use` selector hooks — the + * registrant-private twin of the provide-bundle binding in standardKit. + * Runs once per cached inject result; hook identity rides observableHook's + * per-source cache. + */ +function bindInjectHooks(face: InjectedProps): InjectedProps { + const sources = face['hooks'] + if (sources === undefined) return face + const { hooks: _hooks, ...rest } = face + const bound: InjectedProps = rest + for (const [name, source] of Object.entries(sources as Record>)) { + const hookName = `use${name[0]?.toUpperCase() ?? ''}${name.slice(1)}` + bound[hookName] = observableHook(source) + } + return bound } function cachedRootInject(entry: StoredEntry, actions: object | undefined): InjectedProps { diff --git a/packages/client/web-react/src/session-provider.tsx b/packages/client/web-react/src/session-provider.tsx index 79cb763a3e..a9679e460c 100644 --- a/packages/client/web-react/src/session-provider.tsx +++ b/packages/client/web-react/src/session-provider.tsx @@ -90,9 +90,9 @@ function useAbsentSnapshot(_selector: (snapshot: never) => S, _equal?: (a: S, */ export function SessionMaybeProvider({ children }: { children: ReactNode }) { const host = useHost() - const id = observableHook(host.sessions.current)(s => s) + const info = observableHook(host.sessions.provideInfo)(s => s) return ( - + {children} ) @@ -107,17 +107,17 @@ export interface SessionProviderProps { } /** - * Framework-wired session area: subscribes to the host's current-session - * source, resolves the session cell, and remounts the body under - * `key={sessionId}` so a session switch rebuilds the session subtree. This - * dependency-inverted layer uses plain string ids; `PropsRuntime` applies the - * branded type at the component boundary. + * Framework-wired session area: subscribes to the host's current provide + * source and remounts the body under `key={sessionId}` so a session switch + * rebuilds the session subtree. This dependency-inverted layer uses plain + * string ids; `PropsRuntime` applies the branded type at the component + * boundary. */ export function SessionProvider({ empty, children }: SessionProviderProps) { const host = useHost() - const id = observableHook(host.sessions.current)(s => s) - const info = id === undefined ? undefined : host.sessions.provideInfo(id) - if (id === undefined || info === undefined) return <>{empty?.() ?? null} + const info = observableHook(host.sessions.provideInfo)(s => s) + const id = info.sessionId + if (id === undefined) return <>{empty?.() ?? null} return ( {children(id)} diff --git a/packages/client/web-react/tests/scoped-slots-real-core.spec.tsx b/packages/client/web-react/tests/scoped-slots-real-core.spec.tsx index 381170527a..6649018b0b 100644 --- a/packages/client/web-react/tests/scoped-slots-real-core.spec.tsx +++ b/packages/client/web-react/tests/scoped-slots-real-core.spec.tsx @@ -26,6 +26,7 @@ type FrameSlots = PropsRenderSlots<'spec.single' | 'spec.list'> /** Passthrough host over the real core (store/session seats unused here). */ function hostOver(core: SlotCore): SlotRendererHost { + const absentInfo = { sessionId: undefined, hooks: {}, props: {} } return { subscribe: (key, fn) => core.subscribe(key, fn), getVersion: key => core.getVersion(key), @@ -35,9 +36,7 @@ function hostOver(core: SlotCore): SlotRendererHost { storeOf: () => undefined, sessions: { list: { getSnapshot: () => ({}), subscribe: () => () => {} }, - current: { getSnapshot: () => undefined, subscribe: () => () => {} }, - provideInfo: () => undefined, - maybeProvideInfo: () => ({ sessionId: undefined, hooks: {}, props: {} }), + provideInfo: { getSnapshot: () => absentInfo, subscribe: () => () => {} }, }, workspaces: { list: { getSnapshot: () => ({}), subscribe: () => () => {} }, diff --git a/packages/client/web-react/tests/scoped-slots.spec.tsx b/packages/client/web-react/tests/scoped-slots.spec.tsx index 51b12d2385..971a6ad060 100644 --- a/packages/client/web-react/tests/scoped-slots.spec.tsx +++ b/packages/client/web-react/tests/scoped-slots.spec.tsx @@ -12,6 +12,7 @@ import { describe, expect, it, vi } from 'vitest' import { act, fireEvent, render } from '@testing-library/react' import { useEffect, type ReactNode } from 'react' import type { ActionsDecl, SlotEntryDef, SlotSpec, StoreHandle, StoredEntry } from '@deepseek-ai/dsh-client-ui-slots' +import type { SessionMaybeProvideInfo } from '@deepseek-ai/dsh-client-ui-slots' import { createSlotRenderer, SessionProvider, SlotOwnershipError, StaleAuthorizationError, type RenderOpts, type SessionProvideInfo, @@ -85,7 +86,9 @@ function makeHost() { const storeCache = new Map>() const list = observable<{ ids: string[] }>({ ids: [] }) const workspaces = observable<{ ids: string[] }>({ ids: [] }) - const current = observable(undefined) + const absentInfo: SessionMaybeProvideInfo = { sessionId: undefined, hooks: {}, props: {} } + const provide = observable(absentInfo) + let currentId: string | undefined const infos = new Map() const bump = (key: string) => { @@ -123,10 +126,7 @@ function makeHost() { }, sessions: { list, - current, - provideInfo: id => infos.get(id), - maybeProvideInfo: id => (id === undefined ? undefined : infos.get(id)) - ?? { sessionId: undefined, hooks: {}, props: {} }, + provideInfo: provide, }, workspaces: { list: workspaces }, } @@ -134,7 +134,14 @@ function makeHost() { host, list, workspaces, - current, + // Same driver surface as the old current cell: set(id) publishes the + // resolved bundle (or the absent projection) through the provide source. + current: { + set: (id: string | undefined) => { + currentId = id + provide.set((id === undefined ? undefined : infos.get(id)) ?? absentInfo) + }, + }, declare: (key: string, spec: DeclaredSpec) => { specs.set(key, spec); bump(key) }, add: (key: string, partial: Omit & { options?: StoredEntry['options'] }) => { const entry = entryOf(partial) @@ -161,6 +168,7 @@ function makeHost() { props: {}, } infos.set(id, info) + if (currentId === id) provide.set(info) return info }, } @@ -740,6 +748,25 @@ describe('inject: execution point, parameter derivation, cache granularity', () expect(inject).toHaveBeenCalledWith() }) + it('binds the inject hooks compartment into use selector hooks (sources never reach the component)', () => { + const h = makeHost() + h.declare('k.single', SINGLE_ROOT) + const badge = observable('cold') + const seen: Record[] = [] + h.add('k.single', { + component: (props: { useBadge?: (sel: (s: string) => S) => S; hooks?: unknown; plain?: string }) => { + seen.push({ hooks: props.hooks, plain: props.plain, read: props.useBadge!(s => s) }) + return null + }, + inject: () => ({ plain: 'kept', hooks: { badge } }), + }) + mountRoot(h, { 'k.single': SINGLE_ROOT }, renderSlot => renderSlot('k.single', {})) + // The raw compartment is consumed by the binding; the plain member passes through. + expect(seen.at(-1)).toEqual({ hooks: undefined, plain: 'kept', read: 'cold' }) + act(() => { badge.set('hot') }) + expect(seen.at(-1)!['read']).toBe('hot') + }) + it('session inject receives sessionId and caches per (entry x session): switch-back reuses', () => { const h = makeHost() h.declare('k.session', SINGLE_SESSION) diff --git a/packages/client/web-react/tests/session-provider.spec.tsx b/packages/client/web-react/tests/session-provider.spec.tsx index 2e055bcee2..c1f1f648a2 100644 --- a/packages/client/web-react/tests/session-provider.spec.tsx +++ b/packages/client/web-react/tests/session-provider.spec.tsx @@ -9,7 +9,7 @@ import { useEffect, useRef } from 'react' import { describe, expect, it, vi } from 'vitest' import { act, render } from '@testing-library/react' -import type { StoredEntry } from '@deepseek-ai/dsh-client-ui-slots' +import type { SessionMaybeProvideInfo, StoredEntry } from '@deepseek-ai/dsh-client-ui-slots' import { createSlotRenderer, SessionProvider, type SessionProvideInfo, type SlotRendererHost, @@ -26,12 +26,14 @@ function observable(initial: T) { } /** - * Minimal host: SessionProvider only reads sessions.current/cell, but it must + * Minimal host: SessionProvider only reads sessions.provideInfo, but it must * render inside the renderer tree (HostContext), so the harness mounts a real * root entry whose body is the test's render-prop provider. */ function makeHost(bodies: { root: (rp: (key: string, owner: object) => React.ReactNode) => React.ReactNode }) { - const current = observable(undefined) + const absentInfo: SessionMaybeProvideInfo = { sessionId: undefined, hooks: { session: undefined }, props: {} } + const provide = observable(absentInfo) + let currentId: string | undefined const infos = new Map() const sessionEntries: StoredEntry[] = [] const rootEntry: StoredEntry = { @@ -49,16 +51,20 @@ function makeHost(bodies: { root: (rp: (key: string, owner: object) => React.Rea storeOf: () => undefined, sessions: { list: observable({ ids: [] }), - current, - provideInfo: id => infos.get(id), - maybeProvideInfo: id => (id === undefined ? undefined : infos.get(id)) - ?? { sessionId: undefined, hooks: { session: undefined }, props: {} }, + provideInfo: provide, }, workspaces: { list: observable({ items: [] }) }, } return { host, - current, + // Same driver surface as the old current cell: set(id) publishes the + // resolved bundle (or the absent projection) through the provide source. + current: { + set: (id: string | undefined) => { + currentId = id + provide.set((id === undefined ? undefined : infos.get(id)) ?? absentInfo) + }, + }, addSession: (id: string) => { // Bare source per bundle (identity-stable): the machinery binds useSession from it. const info: SessionProvideInfo = { @@ -67,8 +73,14 @@ function makeHost(bodies: { root: (rp: (key: string, owner: object) => React.Rea props: {}, } infos.set(id, info) + if (currentId === id) provide.set(info) return info }, + /** Swap one session's bundle in place (roster-change stand-in); republish when current. */ + replaceSession: (info: SessionProvideInfo) => { + infos.set(info.sessionId, info) + if (currentId === info.sessionId) provide.set(info) + }, registerSession: (entry: StoredEntry) => { sessionEntries.push(entry) }, } } @@ -149,6 +161,28 @@ describe('SessionProvider', () => { expect(seen.at(-1)!['sessionId']).toBe('s2') }) + it('republishes a mounted session entry when its provide bundle changes under the same id', () => { + const seen: unknown[] = [] + const h = makeHost({ + root: renderSlot => {() => renderSlot('k.session', {})}, + }) + const original = h.addSession('s1') + h.registerSession({ + component: (props: { feature?: string }) => { + seen.push(props.feature) + return null + }, + options: {}, + }) + render(<>{createSlotRenderer().renderRoot(h.host, {})}) + act(() => { h.current.set('s1') }) + expect(seen.at(-1)).toBeUndefined() + // A provider-roster change rematerializes the bundle; the provide source + // must carry it to already-mounted entries without a selection change. + act(() => { h.replaceSession({ ...original, props: { feature: 'now-live' } }) }) + expect(seen.at(-1)).toBe('now-live') + }) + it('fails loud when mounted outside the renderer tree (no host channel)', () => { const spy = vi.spyOn(console, 'error').mockImplementation(() => {}) expect(() => render( diff --git a/packages/client/web-react/tests/stale-authorization.spec.tsx b/packages/client/web-react/tests/stale-authorization.spec.tsx index 6c3e5d194b..df3bc51d2b 100644 --- a/packages/client/web-react/tests/stale-authorization.spec.tsx +++ b/packages/client/web-react/tests/stale-authorization.spec.tsx @@ -21,6 +21,7 @@ function makeHost() { const versions = new Map() const subs = new Map void>>() const live = new Set() + const absentInfo = { sessionId: undefined, hooks: {}, props: {} } const bump = (key: string) => { versions.set(key, (versions.get(key) ?? 0) + 1) for (const fn of [...(subs.get(key) ?? [])]) fn() @@ -39,9 +40,7 @@ function makeHost() { storeOf: () => undefined, sessions: { list: { getSnapshot: () => ({}), subscribe: () => () => {} }, - current: { getSnapshot: () => undefined, subscribe: () => () => {} }, - provideInfo: () => undefined, - maybeProvideInfo: () => ({ sessionId: undefined, hooks: {}, props: {} }), + provideInfo: { getSnapshot: () => absentInfo, subscribe: () => () => {} }, }, workspaces: { list: { getSnapshot: () => ({}), subscribe: () => () => {} }, diff --git a/packages/client/web/src/base.css b/packages/client/web/src/base.css index b8449634eb..03593fa829 100644 --- a/packages/client/web/src/base.css +++ b/packages/client/web/src/base.css @@ -15,6 +15,10 @@ body, body { font-family: var(--dsw-font-family); + /* Grayscale antialiasing over subpixel rendering: WebKit/Blink and the + Firefox macOS equivalent; other engines ignore both lines. */ + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; color: var(--dsw-alias-label-primary); background: var(--dsw-alias-bg-base); } diff --git a/packages/llm/llm-retry/tests/transport-recovery.spec.ts b/packages/llm/llm-retry/tests/transport-recovery.spec.ts index fcaa9c8d91..9c61f84848 100644 --- a/packages/llm/llm-retry/tests/transport-recovery.spec.ts +++ b/packages/llm/llm-retry/tests/transport-recovery.spec.ts @@ -204,7 +204,9 @@ describe('bounded retry through the real DeepSeek HTTP/SSE adapter', () => { apiKey: 'mock-key', successText: 'recovered after timeout', }) - context = await harness(server.baseURL, { streamIdleTimeoutMs: 30 }) + // This crosses the real HTTP idle timer, so leave scheduler slack between + // the stalled attempt and the mock server's immediate successful response. + context = await harness(server.baseURL, { streamIdleTimeoutMs: 1_000 }) const agent = context.agentLoop.create(SessionId('wire-stall'), { provider: 'deepseek', model: 'mock-model', @@ -216,7 +218,7 @@ describe('bounded retry through the real DeepSeek HTTP/SSE adapter', () => { expect(agent.session.events.filter(event => event.type === 'llm/retry').map(event => event.data.failure.code)) .toEqual(['TIMEOUT']) expect(finalAssistantText(agent)).toBe('recovered after timeout') - }) + }, 10_000) it('stops after the configured transport retry budget is exhausted', async () => { const server = await start(['connection_reset', 'connection_reset', 'connection_reset'], { diff --git a/packages/pty/pty-local/README.i18n.yaml b/packages/pty/pty-local/README.i18n.yaml index 279b7ab950..04e34c3f3c 100644 --- a/packages/pty/pty-local/README.i18n.yaml +++ b/packages/pty/pty-local/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -README.md: abc7d9bc81b6ec43bed6277de7f42ddf5728c7a8 -README.zh.md: 7a99a303ac3b6c83e2acd0f8154e9b313ef673d1 +# pnpm run verify-translation-pairing --write packages/pty/pty-local/README.md +README.md: 6f243a6edf3ab8bc228cfda3f6b3b774dabadcbb +README.zh.md: c417828a443d227b5bfcdc1c788cf4ab6751b2ee diff --git a/packages/pty/pty-local/README.md b/packages/pty/pty-local/README.md index abc7d9bc81..6f243a6edf 100644 --- a/packages/pty/pty-local/README.md +++ b/packages/pty/pty-local/README.md @@ -8,7 +8,7 @@ Local Linux/macOS `node-pty` backend for `ctx.pty`; loading it on another platfo The plugin injects `pty`, `sandbox`, and `sandboxPolicy`, then registers the configured backend type (`shell`). `danger-full-access` starts the shell directly; confined modes wrap the exact shell argv through `ctx.sandbox`. The effective session mode is resolved at spawn. A change to a different effective mode is rejected before its `sandbox/mode` event commits while that owner has an open PTY or a spawn in progress; the fence is attached to the exact owner and therefore outlives a local-provider reload that retains existing sessions. Wait for creation to settle and close the sessions before changing modes, so a terminal opened with wider access cannot survive a downgrade. -Linux readiness combines a foreground-verified private bash prompt marker, foreground-process-group syscall inspection, silence fallback, and absolute timeout. macOS uses the verified prompt marker plus silence/timeout because it has no `/proc` syscall surface. A marker is not ready until printable prompt text arrives, including when the OSC marker and `PS1` are split across data callbacks; when bash prints the marker before the kernel publishes its return to the foreground process group, polling retains the candidate for `handoffGraceMs` past the ordinary silence bound so a coincident handoff can win; that grace must cover at least one `pollIntervalMs` and is rejected at load otherwise. An interactive child that inherits `PROMPT_COMMAND` therefore cannot suppress inferred-idle readiness until the absolute timeout. Unrecognized or unreadable process state is never a positive exact-idle signal. During unpublished startup, a fallback requires observed output; zero-output silence cannot publish an empty session, and timeout rejects the spawn. Cancellation closes the unpublished shell and rejects with the caller's exact abort reason even when its foreground process group is not observable yet; if that close fails, `PtyBackendCleanupError` separately preserves the cleanup failure for registry disposal. Incomplete terminal-control sequences are bounded by `maxReadBytes` and discarded through their terminator after crossing that limit; a trailing carriage return is carried across callbacks so split CRLF becomes one newline. +Linux readiness combines a foreground-verified private bash prompt marker, foreground-process-group syscall inspection, silence fallback, and absolute timeout. macOS uses the verified prompt marker plus silence/timeout because it has no `/proc` syscall surface. A marker is not ready until printable prompt text arrives, including when the OSC marker and `PS1` are split across data callbacks; when bash prints the marker before the kernel publishes its return to the foreground process group, polling retains the candidate for `handoffGraceMs` past the ordinary silence bound so a coincident handoff can win; that grace must cover at least one `pollIntervalMs` and is rejected at load otherwise. An interactive child that inherits `PROMPT_COMMAND` therefore cannot suppress inferred-idle readiness until the absolute timeout. Unrecognized or unreadable process state is never a positive exact-idle signal. A foreground group's stdin wait that already existed before a send is likewise not post-write readiness: the same group must be observed outside that wait before a later wait can settle the send, while a changed foreground group is new evidence. During unpublished startup, a fallback requires observed output; zero-output silence cannot publish an empty session, and timeout rejects the spawn. Cancellation closes the unpublished shell and rejects with the caller's exact abort reason even when its foreground process group is not observable yet; if that close fails, `PtyBackendCleanupError` separately preserves the cleanup failure for registry disposal. Incomplete terminal-control sequences are bounded by `maxReadBytes` and discarded through their terminator after crossing that limit; a trailing carriage return is carried across callbacks so split CRLF becomes one newline. Send cancellation resolves the current foreground process group and delivers a real `SIGINT`; it never emulates interruption by writing `\x03`, so raw-mode programs remain cancellable. Close sends `SIGTERM` to descendants, waits, then sends `SIGKILL` to the union of captured survivors and newly scanned descendants so reparenting cannot hide a process from teardown. It verifies that every retained identity is gone or, on Linux, a non-executing zombie before stopping the shell; zombie entries are quiescent and are reaped as the shell exits. A survivor failure does not cache a permanently rejected close; a later close retries the teardown. diff --git a/packages/pty/pty-local/README.zh.md b/packages/pty/pty-local/README.zh.md index 7a99a303ac..c417828a44 100644 --- a/packages/pty/pty-local/README.zh.md +++ b/packages/pty/pty-local/README.zh.md @@ -8,7 +8,7 @@ 该插件注入 `pty`、`sandbox` 和 `sandboxPolicy`,然后注册所配置的后端类型(`shell`)。`danger-full-access` 会直接启动 shell;受限模式则通过 `ctx.sandbox` 包装确切的 shell argv。系统在 spawn 时解析会话的实际模式。当某个所有者存在开放的 PTY 或正在进行 spawn 时,如果配置变更会得到不同的实际模式,系统会在对应 `sandbox/mode` 事件提交前拒绝该变更。该限制绑定到确切所有者,因此即使本地提供方重新加载并保留现有会话,它仍然有效。更改模式前,请等待创建结算并关闭会话,避免以更宽权限打开的终端在权限降级后继续存在。 -Linux 的就绪检测结合以下机制:由前台状态验证的私有 bash 提示符标记、前台进程组 syscall 检查、静默回退和绝对超时。macOS 没有 `/proc` syscall 接口,因此使用经过验证的提示符标记以及静默/超时。当可打印的提示符文本尚未到达时,即使 OSC 标记和 `PS1` 被拆到多个数据回调中,系统也不会把标记视为就绪。如果 bash 在内核发布其重新取得前台进程组的状态前打印标记,轮询会在普通静默上限之后再保留该候选状态 `handoffGraceMs`,使恰好同时发生的前台交接有机会胜出;该宽限至少要覆盖一个 `pollIntervalMs`,否则加载时即被拒绝。因此,继承 `PROMPT_COMMAND` 的交互式子进程无法持续压制推断空闲就绪,最多只能延续到绝对超时。无法识别或读取的进程状态绝不会作为精确空闲的正向信号。尚未发布的启动过程中,回退路径要求已经观察到输出;零输出静默不能发布空会话,超时则拒绝 spawn。取消操作会关闭尚未发布的 shell,并以调用方提供的确切中止原因拒绝,即使当时还无法观察其前台进程组。如果关闭失败,`PtyBackendCleanupError` 会单独保留清理失败,供注册表释放资源时处理。未完成的终端控制序列受 `maxReadBytes` 限制;超过上限后,系统会丢弃内容直到其终止符。末尾的回车会跨回调保留,使拆分的 CRLF 合并为一个换行。 +Linux 的就绪检测结合以下机制:由前台状态验证的私有 bash 提示符标记、前台进程组 syscall 检查、静默回退和绝对超时。macOS 没有 `/proc` syscall 接口,因此使用经过验证的提示符标记以及静默/超时。当可打印的提示符文本尚未到达时,即使 OSC 标记和 `PS1` 被拆到多个数据回调中,系统也不会把标记视为就绪。如果 bash 在内核发布其重新取得前台进程组的状态前打印标记,轮询会在普通静默上限之后再保留该候选状态 `handoffGraceMs`,使恰好同时发生的前台交接有机会胜出;该宽限至少要覆盖一个 `pollIntervalMs`,否则加载时即被拒绝。因此,继承 `PROMPT_COMMAND` 的交互式子进程无法持续压制推断空闲就绪,最多只能延续到绝对超时。无法识别或读取的进程状态绝不会作为精确空闲的正向信号。同样,一次 send 之前就已存在的前台进程组 stdin 等待并不代表写入后就绪:必须先观察到同一进程组脱离该等待,之后再次进入等待才能使该次 send 完成;前台进程组发生变化则构成新的证据。尚未发布的启动过程中,回退路径要求已经观察到输出;零输出静默不能发布空会话,超时则拒绝 spawn。取消操作会关闭尚未发布的 shell,并以调用方提供的确切中止原因拒绝,即使当时还无法观察其前台进程组。如果关闭失败,`PtyBackendCleanupError` 会单独保留清理失败,供注册表释放资源时处理。未完成的终端控制序列受 `maxReadBytes` 限制;超过上限后,系统会丢弃内容直到其终止符。末尾的回车会跨回调保留,使拆分的 CRLF 合并为一个换行。 取消发送时,系统会解析当前前台进程组并发送真正的 `SIGINT`;它绝不会通过写入 `\x03` 模拟中断,因此原始模式程序仍可取消。关闭操作先向后代发送 `SIGTERM` 并等待,再向已捕获的存活进程与新扫描到的后代之并集发送 `SIGKILL`,防止进程通过重新设定父进程而逃避清理。系统确认每个保留的进程身份都已消失;在 Linux 上,非执行中的僵尸进程也视为完全停稳,并会随 shell 退出而回收。如果仍有进程存活,失败结果不会缓存成永久拒绝的关闭操作;后续关闭仍会重试清理。 diff --git a/packages/pty/pty-local/src/session.ts b/packages/pty/pty-local/src/session.ts index f6e6e7a20d..46f3be2ca8 100644 --- a/packages/pty/pty-local/src/session.ts +++ b/packages/pty/pty-local/src/session.ts @@ -79,14 +79,18 @@ class LocalSendOperation implements PtySendOperation { private readonly output: BoundedTextBuffer private readonly promise: PromiseWithResolvers private finished = false + private initialForegroundLeftWait: boolean constructor( maxBytes: number, readonly startedAt: number, + private readonly initialForegroundPgid: number | undefined, + initialForegroundWasWaiting: boolean, private readonly onCancel: () => void, ) { this.output = new BoundedTextBuffer(maxBytes) this.promise = Promise.withResolvers() + this.initialForegroundLeftWait = !initialForegroundWasWaiting } get done(): Promise { @@ -119,6 +123,15 @@ class LocalSendOperation implements PtySendOperation { return this.output.consume() } + acceptsStdinWait(pgid: number, waiting: boolean): boolean { + // The same group may still expose the wait that existed before terminal.write. + // Observe every poll so a departure before the exact-settlement threshold + // still makes a later return to that wait post-write evidence. + if (pgid !== this.initialForegroundPgid) return waiting + if (!waiting) this.initialForegroundLeftWait = true + return waiting && this.initialForegroundLeftWait + } + cancel(): boolean { if (this.finished) return false this.onCancel() @@ -200,9 +213,14 @@ export class LocalPtySession implements PtyBackendSession { if (this.active !== undefined) throw new Error('PTY session already has an active send') if (request.signal?.aborted === true) throw new Error('PTY send aborted before write') + const initialForegroundPgid = this.inspector.foregroundPgid(this.pid) + const initialForegroundWasWaiting = initialForegroundPgid !== undefined + && this.inspector.isStdinWaiting(initialForegroundPgid) const operation = new LocalSendOperation( this.config.maxReadBytes, Date.now(), + initialForegroundPgid, + initialForegroundWasWaiting, () => { this.interrupt(operation) }, ) this.active = operation @@ -321,12 +339,15 @@ export class LocalPtySession implements PtyBackendSession { } const elapsed = Date.now() - operation.startedAt const startupHasOutput = !this.initializing || this.scrollback.snapshot().text.length > 0 - if (startupHasOutput && elapsed >= this.config.exactProbeAfterMs) { + let acceptsStdinWait = false + if (startupHasOutput) { const pgid = this.inspector.foregroundPgid(this.pid) - if (pgid !== undefined && this.inspector.isStdinWaiting(pgid)) { - this.settleActive('stdin_read') - return - } + acceptsStdinWait = pgid !== undefined + && operation.acceptsStdinWait(pgid, this.inspector.isStdinWaiting(pgid)) + } + if (elapsed >= this.config.exactProbeAfterMs && acceptsStdinWait) { + this.settleActive('stdin_read') + return } // A prompt candidate can race bash's foreground handoff, but an interactive // child also inherits PROMPT_COMMAND. Silence therefore remains the bound diff --git a/packages/pty/pty-local/tests/local.spec.ts b/packages/pty/pty-local/tests/local.spec.ts index c57dc11c31..3e5a12173e 100644 --- a/packages/pty/pty-local/tests/local.spec.ts +++ b/packages/pty/pty-local/tests/local.spec.ts @@ -170,12 +170,15 @@ describe('pty-local real shell', () => { controller.abort() const result = await foreground.done expectReadyForNextSend(result.waitReason) - const after = await ctx.pty.startSend(agent, created.sessionId, { - text: 'echo AFTER_SIGINT', + const afterReady = 'AFTER_SIGINT' + const afterCommand = 'printf "AFTER_%s\\n" SIGINT' + expect(afterCommand).not.toContain(afterReady) + const after = ctx.pty.startSend(agent, created.sessionId, { + text: afterCommand, submit: true, - }).done - expect(after.viewport).toContain('AFTER_SIGINT') - expectReadyForNextSend(after.waitReason) + }) + await waitForOutput(after, afterReady, 15_000) + expectReadyForNextSend((await after.done).waitReason) await ctx.pty.kill(agent, created.sessionId) - }, 20_000) + }, 35_000) }) diff --git a/packages/pty/pty-local/tests/session.spec.ts b/packages/pty/pty-local/tests/session.spec.ts index e4dbacaa2a..53ce61eebc 100644 --- a/packages/pty/pty-local/tests/session.spec.ts +++ b/packages/pty/pty-local/tests/session.spec.ts @@ -115,12 +115,60 @@ describe('LocalPtySession readiness and output', () => { inspector.waiting = true const operation = session.startSend({ text: 'python3', submit: true }) expect(terminal.writes).toEqual(['python3', '\r']) + inspector.pgid = 789 terminal.emitData('Python\r\n>>> ') await vi.advanceTimersByTimeAsync(20) expect(await operation.done).toMatchObject({ waitReason: 'stdin_read', viewport: 'Python\n>>> ', sessionStatus: { kind: 'running' } }) expect(operation.cancel()).toBe(false) }) + it('does not reuse a pre-write stdin wait as post-write readiness', async () => { + vi.useFakeTimers() + const terminal = new FakeTerminal() + const inspector = new FakeInspector() + const session = new LocalPtySession(terminal.asPty(), inspector, config()) + await initialize(session, terminal) + + inspector.waiting = true + const operation = session.startSend({ text: 'echo ready', submit: true }) + let settled = false + void operation.done.then(() => { settled = true }) + await vi.advanceTimersByTimeAsync(20) + expect(settled).toBe(false) + + inspector.waiting = false + await vi.advanceTimersByTimeAsync(10) + expect(settled).toBe(false) + inspector.waiting = true + await vi.advanceTimersByTimeAsync(10) + expect((await operation.done).waitReason).toBe('stdin_read') + }) + + it('tracks a pre-write wait exit before exact probing begins', async () => { + vi.useFakeTimers() + const terminal = new FakeTerminal() + const inspector = new FakeInspector() + const session = new LocalPtySession(terminal.asPty(), inspector, config({ + exactProbeAfterMs: 50, + idleSilenceMs: 100, + timeoutMs: 200, + })) + await initialize(session, terminal) + + inspector.waiting = true + const operation = session.startSend({ text: 'fast command', submit: true }) + let settled = false + void operation.done.then(() => { settled = true }) + inspector.waiting = false + await vi.advanceTimersByTimeAsync(10) + inspector.waiting = true + await vi.advanceTimersByTimeAsync(30) + expect(settled).toBe(false) + await vi.advanceTimersByTimeAsync(10) + expect(settled).toBe(true) + expect((await operation.done).waitReason).toBe('stdin_read') + }) + it('distinguishes inferred idle, timeout, exit signal, and operation reads', async () => { vi.useFakeTimers() const terminal = new FakeTerminal() diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index f9d2bc0206..c226b7eab3 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -3040,12 +3040,14 @@ describe('pi-tui chat lifecycle and transcript', () => { expect(result.terminal.output).toContain('advertised by multiple providers') expect(result.terminal.output).toContain('already alpha/a1') + const firstSelectorOutput = result.terminal.output.length result.terminal.send('/model') result.terminal.send('\r') result.terminal.send('/model') result.terminal.send('\r') - await tick() - expect(result.terminal.output).toContain('Select model') + await vi.waitFor(() => { + expect(result.terminal.output.slice(firstSelectorOutput)).toContain('Select model') + }) result.terminal.send('\x1b') await tick() diff --git a/vitest.config.ts b/vitest.config.ts index b011a32d14..ad10dcfed3 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -105,6 +105,9 @@ export default defineConfig({ // Trajectory's compact Markdown projection retains deferred branch coverage. 'packages/client/ui-primitives/src/markdown/plain-text.ts', 'packages/client/ui-question/src/client/QuestionComposer.tsx', + 'packages/client/ui-primitives/src/Menu.tsx', + 'packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx', + 'packages/client/ui-workspace/src/client/WorkspacePicker.tsx', 'packages/client/web-react/src/*', 'packages/client/runtime/src/*', 'packages/client/ui-conversation/src/*',