diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml index 5376a626e6..2357aa30f2 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md -2026-07-19-gui-web-client-architecture.md: 1a91d88818c374a1637b546fb3ddf6647af68570 -2026-07-19-gui-web-client-architecture.zh.md: 5c0bacde9836d45812895f5d9c89a0e8974ed7a1 +2026-07-19-gui-web-client-architecture.md: cba029ea74e89b277f9079ebb3765deeeb105b47 +2026-07-19-gui-web-client-architecture.zh.md: f700cc04fe1479d43957456c14b3abb4014d99c5 diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md index 1a91d88818..cba029ea74 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md @@ -44,7 +44,7 @@ Implementation homes: registry core and the props-share types in `packages/clien A service is a plugin's only API surface toward other plugins (UI components and injection faces are not APIs; a plugin nobody calls mounts no service — ui-trajectory is the minimal-plugin exemplar: no ctx service, only view-slot registrations). The roster: `ctx.connection` (api client + stream handles), `ctx.slots` (registry wrapper emitting `slots/changed`, render entry, renderer install seam), `ctx.sessions` (list store, current-session state, scope tree), `ctx.loader`, `ctx.theme`, `ctx.i18n`, `ctx.layout` (cross-plugin view navigation), `ctx.conversation` (send/cancel/startSession). Viewing state that used to live in service stores (panel widths, selection, drafts) now lives in entry-declared stores per the [slot system standard](2026-07-22-slot-type-chain-implementation.md). -There is no registration model besides slots — the former view and tool rings both dissolved into it. Conversation views are entries of the `'conversation.view'` list slot ui-conversation declares, tab metadata rides the registration options (`id`/`order`/`label`), and per-view chrome lives inside the view components themselves. A tool row is a keyed child slot each view declares for itself — today `'conversation.chat.toolview'` (keyed/session), declared by the chat entry's `children` table; the key space is runtime-open (SlotMap declares slots, never keys), which is what the tool ring's open tool-name set required. The render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`; the owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openDetails`), and `ToolRowProps` composes it with the session standard kit for registrant components. Registrants are plain plugins with zero dedicated machinery: `ctx.slots.inject('conversation.chat.toolview', () => ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row))`; the declaration is the load and reload dependency, independently from `ConversationService` ([decision](2026-08-05-slot-declaration-injection.md)). Interaction drafts and other row state ride the ordinary store seat. Trajectory/waterfall get same-shaped slots (names fixed by the slot-naming discipline `..`, one shared owner type) that land with their own row render sites — RendersCheck rejects a declaration nobody renders, so the two slots cannot be declared early. +There is no registration model besides slots — the former view and tool rings both dissolved into it. Conversation views are entries of the `'conversation.view'` list slot ui-conversation declares, tab metadata rides the registration options (`id`/`order`/`label`), and per-view chrome lives inside the view components themselves. Tool presentation crosses one explicit package boundary: ui-conversation places each ordered root call into the single `'conversation.chat.tool'` seat without interpreting Tool names or Code Dispatch topology; ui-tool selects `codeDispatches[rootCallId]` from the Runtime snapshot, renders that root/child shape, and declares the keyed/session `'tool.call.toolview'` child slot. The key space stays runtime-open (SlotMap declares slots, never keys), and both roots and children dispatch by `entryKey: toolName` with `GenericToolCard` as the fallback. Business packages register atomic views through `ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({ name: 'tool.call.toolview', key: '', inject? }, Row))`; the declaration is the load and reload dependency ([decision](2026-08-05-slot-declaration-injection.md)). ui-conversation separately delegates the selected call's details body through `'conversation.details.tool'`, so ui-tool's card models remain the single presentation owner without making conversation import Tool components. **Scope addressing** mirrors the host's agent-scope idiom: services are root singletons whose methods take no sessionId — they read the caller's scope mark (`scopeOf(ctx)`). Inside a session scope, `ctx.conversation.send('hi', 'queue')` targets that session; cross-session calls re-target by switching ctx (`ctx.sessions.scope(id)!.conversation.send(...)`); calling a scoped method from root ctx throws. Client session scopes are minted like host agent scopes (a no-op plugin fiber + a scope-key extend), built lazily on first viewing and torn down only when the session is removed and unwatched — host-session death alone does not tear a scope (it freezes into a read-only viewport). @@ -86,22 +86,24 @@ The glue package is the whole ctx↔React boundary; components stay framework-fr ## Directory shape -Twelve `packages/client/*` packages (ui-slots, ui-primitives, web-react, connection, runtime, ui-layout, ui-sidebar, ui-conversation, ui-trajectory, ui-theme, i18n, web) plus `apps/web` — the vite application, a thin `main` over the shell's boot export. Plugin packages keep their browser half under `src/client/`; **every build artifact lands in `lib/`** — the node half as `lib/index.js`/`lib/invariant.js`, the browser bundle as `lib/client.js` (the shared tsdown client preset emits both; there is no `dist/` directory, and `exports["./client"]` points at `./lib/client.js`). Dependency direction: `ui-slots ← web-react ← runtime ← ui-* (peers) ← web`, with ui-primitives/ui-theme/i18n as zero-dependency side paths. +Client packages live under `packages/client/*`, with `apps/web` as the thin Vite application over the shell's boot export. Plugin packages keep their browser half under `src/client/`; **every build artifact lands in `lib/`** — the node half as `lib/index.js`/`lib/invariant.js`, the browser bundle as `lib/client.js` (the shared tsdown client preset emits both; there is no `dist/` directory, and `exports["./client"]` points at `./lib/client.js`). `ui-slots`, web-react, and runtime form the infrastructure direction; feature plugins cooperate through services and slots rather than importing presentation implementations. A multi-domain plugin package additionally splits its client half by future package boundaries — ui-conversation is the exemplar: ``` src/client/ - contract/ the only shared face between domains (types + composed props shares) - service.ts cross-domain orchestration (imports contract only) - skeleton/ domain: shell components (ConversationRoot/InputBar/EmptyState/DetailsPanel) - chat/ domain: the chat view - toolviews/ domain: sample tool-row registrants (third-party posture) - apply.ts the ONLY file allowed to import across domains (assembly point) - index.ts thin re-export shell (contract + apply + components) + contract/ shared slot and cross-domain types + service.ts cross-domain orchestration + skeleton/ conversation shell and details host + chat/ ordered conversation view + input/ composer state machine + queue/ queued-message presentation + settings/ conversation settings rows + apply.ts cross-domain assembly point + index.ts public contract surface ``` -Domain implementation files never import a sibling domain — shared surfaces route through `contract/` (e.g. the toolviews samples take `ToolRowProps` from the contract, never chat internals). `scripts/verify-client-domain-graph.ts` enforces the layering (contract=0, domains=1, apply/index=2; imports may only point at levels ≤ own; sibling-domain edges fail). A future package split promotes each domain directory to a package and mechanically rewrites import paths. +Domain implementation files never import a sibling domain; shared surfaces route through `contract/`. `scripts/verify-client-domain-graph.ts` enforces the layering (contract=0, domains=1, apply/index=2; imports may only point at levels ≤ own; sibling-domain edges fail). Tool presentation is already a separate `ui-tool` package and reaches chat and details only through the slots ui-conversation declares. ## How to develop @@ -122,5 +124,5 @@ Token streams no longer shake the render tree: a frame storm costs unsubscribed | One statically-linked SPA bundle | Plugins must be host-composable at runtime (config-driven); a monolith re-couples every UI feature to one build | | window globals / import maps for shared deps | The DI require table keeps sharing explicit, fail-loud, and swappable; globals leak identity and version silently | | Business data in zustand slices | The event window/accumulator is a behavioral state machine, not a flat slice; the object layer keeps snapshot granularity and batching controllable | -| String-keyed global component registry for tool rows | Per-view keyed child slots plus in-component session branching carry the same need with the one registration model; a parallel registry does not come back ([toolview dissolution](2026-07-23-toolview-dissolution.md)) | +| Parallel string-keyed component registry for Tool rows | ui-tool's keyed child slot carries the runtime-open Tool-name set through the one slot registration model ([toolview dissolution](2026-07-23-toolview-dissolution.md)) | | Progressive/Suspense boot in P-I | One-flip boot is strictly simpler; the loader's per-plugin status face is kept so progressive lighting can land later without re-architecture | diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md index 5c0bacde98..f700cc04fe 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md @@ -44,7 +44,7 @@ slot 体系有自己的 RFC——[slot 体系标准](2026-07-22-slot-type-chain- 服务是插件对其他插件的唯一 API 面(UI 组件与注入面都不是 API;无人调用的插件不挂服务——ui-trajectory 即最小插件样板:无 ctx 服务,只做视图坑注册)。名册:`ctx.connection`(api client + 流句柄)、`ctx.slots`(注册表包装层,发 `slots/changed`,渲染入口,渲染器安装缝)、`ctx.sessions`(列表 store、当前会话状态、scope 树)、`ctx.loader`、`ctx.theme`、`ctx.i18n`、`ctx.layout`(跨插件视图导航)、`ctx.conversation`(send/cancel/startSession)。过去住在服务 store 里的观看态(面板宽、选中、草稿)现按 [slot 体系标准](2026-07-22-slot-type-chain-implementation.md) 住 entry 声明的 store。 -slot 之外不存在第二种注册模型——原视图环与工具环都已溶解进来。会话视图即 ui-conversation 声明的 `'conversation.view'` list 坑的 entry,tab 元数据随注册 options(`id`/`order`/`label`)走,per-view chrome 住视图组件自身。工具行是各视图自己声明的 keyed 子槽——今天是 `'conversation.chat.toolview'`(keyed/session),由 chat 条目的 `children` 表声明;key 空间运行时开放(SlotMap 声明槽、从不声明 key),这正是工具环「tool 名开放集」的原需求。渲染点逐行以 `entryKey: toolName` 分发、以 `GenericToolCard` 作调用点 `fallback`;owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openDetails`),`ToolRowProps` 把它与 session 标配 kit 预组合供注册方组件取用。注册方就是普通插件、零专用设施:`ctx.slots.inject('conversation.chat.toolview', () => ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row))`;声明本身就是加载与重载依赖,不依赖 `ConversationService`([决策](2026-08-05-slot-declaration-injection.md))。交互草稿等行内状态走普通 store 席位。trajectory/waterfall 得同形槽(槽名按槽名纪律 `<域>.<条目>.<孔位>` 已定死,共用一张 owner 类型),随各自的行渲染点落地——RendersCheck 拒绝无人渲染的声明,两槽无法提前声明。 +slot 之外不存在第二种注册模型——原视图环与工具环都已溶解进来。会话视图即 ui-conversation 声明的 `'conversation.view'` list 坑的 entry,tab 元数据随注册 options(`id`/`order`/`label`)走,per-view chrome 住视图组件自身。Tool 展示跨越一条显式包边界:ui-conversation 把每个已排序 root call 放进 single `'conversation.chat.tool'` seat,不解释 Tool 名称或 Code Dispatch 拓扑;ui-tool 从 Runtime snapshot 选择 `codeDispatches[rootCallId]`、渲染 root/child 形状,并声明 keyed/session 的 `'tool.call.toolview'` 子 slot。key 空间仍在运行时开放(SlotMap 声明 slot、从不声明 key),root 与 child 都按 `entryKey: toolName` 分发,以 `GenericToolCard` 兜底。业务包通过 `ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({ name: 'tool.call.toolview', key: '', inject? }, Row))` 注册原子视图;声明本身就是加载与重载依赖([决策](2026-08-05-slot-declaration-injection.md))。ui-conversation 还通过 `'conversation.details.tool'` 委托选中调用的详情正文,使 ui-tool 的 card model 保持为唯一展示所有者,同时避免 conversation 导入 Tool 组件。 **scope 寻址**与 host 侧 agent scope 惯例同构:服务是 root 单例,方法不收 sessionId——它们读调用方 ctx 上的 scope 标(`scopeOf(ctx)`)。在会话 scope 内,`ctx.conversation.send('hi', 'queue')` 自动打到该会话;跨会话调用换 ctx 定向(`ctx.sessions.scope(id)!.conversation.send(...)`);从 root ctx 直接调 scoped 方法即 throw。client 会话 scope 的铸造方式与 host agent scope 相同(no-op 插件 fiber + scope 键 extend),首次观看时惰性建,只有会话被移除且无人观看才拆——仅 host 会话死亡不拆 scope(冻结为只读视窗)。 @@ -86,22 +86,24 @@ Notifier 微任务合批 ──► ConversationSnapshot 缓存 ──uSES── ## 目录形态 -十二个 `packages/client/*` 包(ui-slots、ui-primitives、web-react、connection、runtime、ui-layout、ui-sidebar、ui-conversation、ui-trajectory、ui-theme、i18n、web)加 `apps/web`——vite 应用,壳 boot 导出之上的薄 `main`。插件包的浏览器半边在 `src/client/` 下;**一切构建产物落 `lib/`**——node 半边为 `lib/index.js`/`lib/invariant.js`,浏览器 bundle 为 `lib/client.js`(共享 tsdown client 预设两者皆出;无 `dist/` 目录,`exports["./client"]` 指向 `./lib/client.js`)。依赖方向:`ui-slots ← web-react ← runtime ← ui-*(并列)← web`,ui-primitives/ui-theme/i18n 为零依赖旁路。 +Client 包位于 `packages/client/*`,`apps/web` 是壳 boot 导出之上的薄 Vite 应用。插件包的浏览器半边在 `src/client/` 下;**一切构建产物落 `lib/`**——node 半边为 `lib/index.js`/`lib/invariant.js`,浏览器 bundle 为 `lib/client.js`(共享 tsdown client 预设两者皆出;无 `dist/` 目录,`exports["./client"]` 指向 `./lib/client.js`)。`ui-slots`、web-react 与 runtime 构成基础设施方向;功能插件通过 service 与 slot 协作,不导入展示实现。 多域插件包的 client 半边还按未来包边界再拆——ui-conversation 即样板: ``` src/client/ - contract/ the only shared face between domains (types + composed props shares) - service.ts cross-domain orchestration (imports contract only) - skeleton/ domain: shell components (ConversationRoot/InputBar/EmptyState/DetailsPanel) - chat/ domain: the chat view - toolviews/ domain: sample tool-row registrants (third-party posture) - apply.ts the ONLY file allowed to import across domains (assembly point) - index.ts thin re-export shell (contract + apply + components) + contract/ shared slot and cross-domain types + service.ts cross-domain orchestration + skeleton/ conversation shell and details host + chat/ ordered conversation view + input/ composer state machine + queue/ queued-message presentation + settings/ conversation settings rows + apply.ts cross-domain assembly point + index.ts public contract surface ``` -域实现文件永不 import 兄弟域——共享面一律走 `contract/`(如 toolviews 样例从契约取 `ToolRowProps`,永不碰 chat 内部)。`scripts/verify-client-domain-graph.ts` 把守分层(contract=0、域=1、apply/index=2;import 只准指向 ≤ 自己的层级;兄弟域边即失败)。将来拆包=每个域目录升格为包+机械改写 import 路径。 +各领域实现文件不 import 兄弟领域;共享面统一经过 `contract/`。`scripts/verify-client-domain-graph.ts` 把守分层(contract=0、domain=1、apply/index=2;import 只准指向不高于自身的层级;兄弟领域依赖会失败)。Tool 展示已经拆为独立 `ui-tool` 包,只通过 ui-conversation 声明的 slot 到达 chat 与 details。 ## 怎么开发 @@ -122,5 +124,5 @@ token 流不再震荡渲染树:帧风暴对未订阅会话只花一个脏位 | 静态链接的单 SPA bundle | 插件必须由 host 在运行时按配置组合;单体把每个 UI 功能重新耦回一次构建 | | window 全局变量 / import map 供共享依赖 | DI require 表让共享显式、大声失败、可替换;全局变量静默泄漏身份与版本 | | 业务数据进 zustand 切片 | 事件窗口/累积器是行为状态机,不是扁平切片;对象层保住快照粒度与合批的可控性 | -| 工具行走字符串键的全局组件注册表 | per-view keyed 子槽 + 组件内会话分支以唯一注册模型承载同一需求;平行 registry 不复活([toolview 溶解](2026-07-23-toolview-dissolution.md)) | +| Tool 行使用平行的字符串键组件注册表 | ui-tool 的 keyed 子 slot 通过唯一的 slot 注册模型承载运行时开放的 Tool 名称集合([toolview 溶解](2026-07-23-toolview-dissolution.md)) | | P-I 就做渐进/Suspense 启动 | 一次成型严格更简单;loader 的按插件状态面已保留,渐进点亮日后可落地而无需重构 | diff --git a/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.i18n.yaml index 90d13fcfea..09f0c27732 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.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-23-toolview-dissolution.md -2026-07-23-toolview-dissolution.md: 97d8beb4de43d9bc6348d942e5460d0321592b32 -2026-07-23-toolview-dissolution.zh.md: db93c6252d5d42d1fd85ce81ad430d95f4324cf2 +2026-07-23-toolview-dissolution.md: be1bd9d161714194855988d76a632c667fae8c84 +2026-07-23-toolview-dissolution.zh.md: afe5e03e8345fca2a0f097d86873974f6d417ff2 diff --git a/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.md b/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.md index 97d8beb4de..be1bd9d161 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.md +++ b/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.md @@ -4,7 +4,7 @@ Status: implemented English | [中文](2026-07-23-toolview-dissolution.zh.md) -> Scope: why the standalone tool ring (ToolViewRegistry/ctx.toolviews/outlet) was retired and what replaced it. The [web client architecture note](2026-07-19-gui-web-client-architecture.md) carries the shipped-state narrative this decision produced; the [slot system standard](2026-07-22-slot-type-chain-implementation.md) owns the registration model everything now runs on. +> Scope: why the standalone tool ring (ToolViewRegistry/ctx.toolviews/outlet) was retired and what replaced it. The [web client architecture note](2026-07-19-gui-web-client-architecture.md) carries the shipped-state narrative this decision produced; the [slot system standard](2026-07-22-slot-type-chain-implementation.md) owns the registration model everything now runs on. The later [Client Tool presentation ownership](2026-08-08-client-tool-presentation-ownership.md) decision supersedes only this note's per-view placement: Tool-name dispatch remains a keyed slot rather than a parallel registry. ## Problem @@ -14,24 +14,22 @@ After the view ring dissolved into the slot system, the client kept exactly one The tool ring is gone as independent infrastructure: a tool row is a **keyed child slot each view declares for itself**, and the client has exactly one registration model. The justification above was hollow — a keyed slot's *key space* is already runtime-open (SlotMap declares slots, never keys; the ask-user composer's `key: 'question'` was the precedent), so the open tool-name set fits `entryKey` dispatch natively. -Shipped shape (current-state narrative also in the [architecture note](2026-07-19-gui-web-client-architecture.md)): the chat entry's `children` table declares `'conversation.chat.toolview'` (keyed/session); the render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback` (the default card is domain property; the fallback option is ordinary renderSlot grammar). The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openDetails` — details being a session-level facility, not chat-private), and `ToolRowProps` pre-composes it with the session standard kit for registrant components. A registrant is a plain plugin using `ctx.slots.inject('conversation.chat.toolview', () => ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row))`; the declaration itself governs activation and replacement, without a false `ConversationService` edge ([decision](2026-08-05-slot-declaration-injection.md)). The bash sample is the third-party-posture exemplar and paints the same ToolRow chrome as Think (`Bash · {description}`). Trajectory/waterfall toolview slots share this exact shape (names fixed by the slot-naming discipline `..`, one shared owner type) and land with their own row render sites — RendersCheck rejects a declaration nobody renders, so the type system, not convention, blocks early empty declarations. - -Registry-era responsibilities all have successor homes: inject caching and row error isolation ride the framework renderer (entry×scope cache, per-entry `SlotErrorBoundary`); subscribe/getVersion ride the slot core's per-key version machinery; the future "store seat" is the ordinary store seat keyed slots already have (interaction-draft durability is its first named consumer); miss fallback is the call-site `fallback` option. +This decision originally placed `'conversation.chat.toolview'` under the chat entry and made the chat render site dispatch each row. The follow-up [Tool presentation ownership](2026-08-08-client-tool-presentation-ownership.md) moves that placement into a whole-Tool seat and gives `ui-tool` one keyed `'tool.call.toolview'` child slot. That follow-up changes the presentation owner, not this decision's core constraint: Tool registration continues to use ordinary keyed-slot machinery, with framework-owned activation, replacement, caching, error isolation, versioning, and fallback behavior. ## Accepted semantic changes -Four behavioral deltas were accepted deliberately, not overlooked. Cross-view appearance is per-view registration — a row must adapt to each view's layout anyway, so one registration per view is the correct coupling, and reuse is the same component in two register calls. Same-key double registration is a loud throw where the registry let later-wins silently override — a discipline correction, not a loss. Session-dimension dispatch, when a row needs it, belongs inside the component (the standard kit already carries `useSessions`), not in registry predicates — there is no shipped session-variant exemplar today. Registry-level shape override by third parties (a scoped registration shadowing a global one) has no equivalent; a real future need routes through key-naming conventions or a small in-component resolver, never a revived parallel registry. +Four behavioral deltas were accepted deliberately, not overlooked. Cross-view appearance was initially per-view registration; the follow-up note records why root/subcall composition later justified one Tool-wide presentation owner. Same-key double registration is a loud throw where the registry let later-wins silently override — a discipline correction, not a loss. Session-dimension dispatch, when a row needs it, belongs inside the component (the standard kit already carries `useSessions`), not in registry predicates — there is no shipped session-variant exemplar today. Registry-level shape override by third parties (a scoped registration shadowing a global one) has no equivalent; a real future need routes through key-naming conventions or a small in-component resolver, never a revived parallel registry. ## Alternatives considered -**Keep the standalone registry (the original shape).** Rejected: each of its multi-dimensional dispatch axes has a more correct home — the view dimension belongs to each view's own declared child slot (declaring is claiming, so specialization ownership lands right), and the session dimension belongs inside the component, which already holds the standard kit. What remained after both moves was a second copy of slot machinery with no distinguishing capability. +**Keep the standalone registry (the original shape).** Rejected: each of its multi-dimensional dispatch axes has a more correct home — presentation ownership belongs to an explicitly declared child slot, and the session dimension belongs inside the component, which already holds the standard kit. What remains is a second copy of slot machinery with no distinguishing capability. -**Promote `renderToolView` into the standard kit and move the registry into the runtime package.** Rejected: "tool row" is a conversation-domain concept; hoisting it into runtime would leak a domain vocabulary into the framework layer and still leave two registration models. +**Promote `renderToolView` into the standard kit and move the registry into the runtime package.** Rejected: Tool presentation is Client UI vocabulary; hoisting it into runtime would leak presentation into the data object layer and still leave two registration models. **Derive slot declarations from subscription refCounts** (declare the slot implicitly when the first registrant subscribes). Rejected for implicit coupling and debounce complexity; noted as a possible revisit only if a genuinely multi-viewer surface appears. -**A thin `registerToolView` facade over slots.register.** Deferred, not rejected: after dissolution the facade would carry only compile-time sugar (slot-name literal narrowing, tool→key vocabulary, props pre-composition) with zero runtime. Per "enforce at the operation boundary" (a facade is not an enforcement point) and "don't split preemptively" (today's registrant population is one bash sample), it stays unbuilt; the type sugar ships as the exported `ToolRowProps` alias. Regret clause: if registrants grow to three-to-five or a bulk-registration pattern appears, the facade is ten lines added without disturbing direct registration. +**A thin `registerToolView` facade over slots.register.** Deferred, not rejected: after dissolution the facade would carry only compile-time sugar (slot-name literal narrowing, tool→key vocabulary, props pre-composition) with zero runtime. Per "enforce at the operation boundary" (a facade is not an enforcement point), it stays unbuilt; the useful type composition ships as the exported Tool view props alias. A later facade can be added without disturbing direct registration if repeated registration ceremony justifies it. ## Consequences -The client has one registration model; auditing who renders tool rows = reading register calls, the same audit as every other slot. Registrants get the framework's error isolation, inject caching, and store seat for free — no capability ships twice. The costs are the accepted semantic changes above (chiefly: per-view registration for cross-view rows, and no third-party registry-level override). Independent registrants name the typed slot in `ctx.slots.inject`, so the dependency is explicit and follows declaration replacement without a service-order convention. +The client has one registration model; auditing who renders Tool calls means reading slot register calls, the same audit as every other slot. Registrants get the framework's error isolation, inject caching, and store seat for free — no capability ships twice. The costs are the accepted semantic changes above, chiefly loud duplicate-key failure and no third-party registry-level override. Independent registrants name the typed slot in `ctx.slots.inject`, so the dependency is explicit and follows declaration replacement without a service-order convention. diff --git a/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.zh.md b/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.zh.md index db93c6252d..afe5e03e83 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-23-toolview-dissolution.zh.md @@ -4,7 +4,7 @@ Status: implemented [English](2026-07-23-toolview-dissolution.md) | 中文 -> 范围:独立工具环(ToolViewRegistry/ctx.toolviews/outlet)为何退役、被什么取代。本决策产出的落地态叙述归 [Web 客户端架构注](2026-07-19-gui-web-client-architecture.md);一切现在所运行其上的注册模型归 [slot 体系标准](2026-07-22-slot-type-chain-implementation.md) 所有。 +> 范围:独立工具环(ToolViewRegistry/ctx.toolviews/outlet)为何退役、被什么取代。本决策产出的落地态叙述归 [Web 客户端架构注](2026-07-19-gui-web-client-architecture.md);一切现在所运行其上的注册模型归 [slot 体系标准](2026-07-22-slot-type-chain-implementation.md)所有。后续的 [Client Tool 展示所有权](2026-08-08-client-tool-presentation-ownership.md)决策仅取代本篇的 per-view 放置方式:Tool 名称分发仍使用 keyed slot,而非平行注册表。 ## Problem @@ -14,24 +14,22 @@ Status: implemented 工具环作为独立基础设施已消失:工具行是**各视图为自己声明的 keyed 子槽**,client 全域只剩一种注册模型。上述理由是空的——keyed slot 的 *key 空间*本就运行时开放(SlotMap 声明槽、从不声明 key;ask-user composer 的 `key: 'question'` 即先例),开放的 tool 名集合天然适配 `entryKey` 分发。 -落地形态(现状叙述同见[架构注](2026-07-19-gui-web-client-architecture.md)):chat 条目的 `children` 表声明 `'conversation.chat.toolview'`(keyed/session);渲染点逐行以 `entryKey: toolName` 分发、以 `GenericToolCard` 作调用点 `fallback`(默认卡片是域产权;fallback 选项就是普通 renderSlot 文法)。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openDetails`——details 是会话级设施,非 chat 私货),`ToolRowProps` 把它与 session 标配 kit 预组合供注册方组件取用。注册方是使用 `ctx.slots.inject('conversation.chat.toolview', () => ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row))` 的普通插件;声明本身控制激活与替换,不再引入虚假的 `ConversationService` 依赖([决策](2026-08-05-slot-declaration-injection.md))。bash 样例即第三方姿态的样板,并与 Think 绘制同一套 ToolRow chrome(`Bash · {description}`)。trajectory/waterfall 的 toolview 槽共用这套形状(槽名按槽名纪律 `<域>.<条目>.<孔位>` 定死,共用一张 owner 类型),随各自的行渲染点落地——RendersCheck 拒绝无人渲染的声明,挡住提前空声明的是类型系统而非约定。 - -registry 时代的职责各有后继居所:inject 缓存与行错误隔离乘框架渲染器(entry×scope 缓存、per-entry `SlotErrorBoundary`);subscribe/getVersion 乘 slot core 的 per-key 版本机;将来的「store 席位」就是 keyed slot 本就拥有的普通 store 席位(交互草稿耐久性是其首个具名消费者);miss 兜底即调用点 `fallback` 选项。 +本决策最初把 `'conversation.chat.toolview'` 放在 chat 条目下,由 chat 渲染点逐行分发。后续的 [Tool 展示所有权](2026-08-08-client-tool-presentation-ownership.md)引入整体 Tool 席位,并让 `ui-tool` 拥有唯一的 keyed `'tool.call.toolview'` 子 slot。后续决策改变的是展示所有者,而非本决策的核心约束:Tool 注册继续使用普通 keyed-slot 机制,激活、替换、缓存、错误隔离、版本与 fallback 行为仍归框架所有。 ## 接受的语义变化 -四项行为增量是刻意接受而非疏漏。跨视图出场=逐视图注册——行本须适配各视图版式,一视图一注册是正确耦合,复用即同一组件写两次 register。同 key 重复注册从注册表的 later-wins 静默覆盖变为 loud throw——纪律修正而非损失。会话维分发若行需要,归组件内部(标配 kit 已带 `useSessions`),不走注册表谓词——今天没有已落地的会话变体样例。第三方在 registry 级覆盖形态(scoped 注册压过 global)不复存在;真出现的未来需求走 key 命名空间约定或组件内小 resolver,永不复活平行注册表。 +四项行为增量是刻意接受而非疏漏。跨视图出场最初采用逐视图注册;后续 Note 记录了 root/subcall 编排为何足以支持一个 Tool 级展示所有者。同 key 重复注册从注册表的 later-wins 静默覆盖变为 loud throw——纪律修正而非损失。会话维分发若行需要,归组件内部(标配 kit 已带 `useSessions`),不走注册表谓词——今天没有已落地的会话变体样例。第三方在 registry 级覆盖形态(scoped 注册压过 global)不复存在;真出现的未来需求走 key 命名空间约定或组件内小 resolver,永不复活平行注册表。 ## Alternatives considered -**保留独立注册表(原形态)。** 拒绝:其多维分发的每一维都有更正确的家——视图维归各视图自己声明的子槽(declaring is claiming,特化面权属自然落对),会话维归已持有标配 kit 的组件内部。两步移完后剩下的只是一份没有任何独有能力的 slot 机器副本。 +**保留独立注册表(原形态)。** 拒绝:其多维分发的每一维都有更正确的家——展示所有权归显式声明的子 slot,会话维归已持有标配 kit 的组件内部。两步移完后剩下的只是一份没有任何独有能力的 slot 机器副本。 -**把 `renderToolView` 提进标配 kit、注册表迁入 runtime 包。** 拒绝:「工具行」是 conversation 域概念;上提进 runtime 会把域词汇泄漏进框架层,且依然留着两套注册模型。 +**把 `renderToolView` 提进标配 kit、注册表迁入 runtime 包。** 拒绝:Tool 展示是 Client UI 词汇;上提进 runtime 会把展示概念泄漏进数据对象层,且依然留着两套注册模型。 **以订阅 refCount 推导槽声明**(首个注册方订阅时隐式声明槽)。拒绝:隐式耦合加去抖复杂度;记为将来真出现多观看面时的备选。 -**slots.register 之上的薄 `registerToolView` 门面。** 缓建而非拒绝:溶解后该门面只剩编译期三糖(槽名字面量收窄、tool→key 词汇翻译、props 预组合),运行时为零。按「enforce at the operation boundary」(门面不是强制点)与「don't split preemptively」(今天注册方人口只有一个 bash 样例)保持不建;类型糖以导出的 `ToolRowProps` 别名兑现。后悔药条款:注册方长到三五家或出现批量注册模式时,门面十行可补,不扰直注。 +**slots.register 之上的薄 `registerToolView` 门面。** 缓建而非拒绝:溶解后该门面只剩编译期语法糖(slot 名字面量收窄、tool→key 词汇翻译、props 预组合),运行时为零。按「enforce at the operation boundary」(门面不是强制点)保持不建;有用的类型组合以导出的 Tool view props 别名兑现。若重复注册仪式今后足以证明其价值,可在不扰动直接注册的前提下补充门面。 ## Consequences -client 只有一种注册模型;审计谁渲染工具行 = 读 register 调用,与其他所有 slot 同一套审计。注册方免费获得框架的错误隔离、inject 缓存与 store 席位——没有能力要建两遍。代价即上文接受的语义变化(主要是:跨视图行要逐视图注册、第三方无 registry 级覆盖)。独立注册方在 `ctx.slots.inject` 中点名有类型约束的 slot,因此依赖关系既显式,又能跟随声明替换,无需服务顺序约定。 +client 只有一种注册模型;审计谁渲染 Tool 调用就是读 slot register 调用,与其他所有 slot 同一套审计。注册方免费获得框架的错误隔离、inject 缓存与 store 席位——没有能力要建两遍。代价即上文接受的语义变化,主要是重复 key 会 loud failure,且第三方无 registry 级覆盖。独立注册方在 `ctx.slots.inject` 中点名有类型约束的 slot,因此依赖关系既显式,又能跟随声明替换,无需服务顺序约定。 diff --git a/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.i18n.yaml new file mode 100644 index 0000000000..5097b9e8a9 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.md +2026-08-08-client-tool-presentation-ownership.md: e61c2030457cc9f0fda214e896b76afb37d0d2bc +2026-08-08-client-tool-presentation-ownership.zh.md: 5c56b8c17ef5ca6695f3b28f6b93218dade356c7 diff --git a/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.md b/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.md new file mode 100644 index 0000000000..e61c203045 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.md @@ -0,0 +1,105 @@ +# Agent Note: Client Tool presentation ownership + +Status: implemented + +English | [中文](2026-08-08-client-tool-presentation-ownership.zh.md) + +## Problem + +The Client Runtime already projects Tool calls into a stable lifecycle: it pairs call/result events by `callId`, preserves running and settled forms, and indexes Code Dispatch children by their root call. The chat view nevertheless owned the entire presentation stack. It placed root calls in ChatFlow, composed each root with its subcalls, dispatched every atomic call by Tool name, carried the generic fallback and card models, registered first-party Tool views, and reused those models in the details panel. + +That ownership made `ui-conversation` interpret business Tool names and made subcalls an orphaned concern if an atomic Tool view moved elsewhere. A business package such as `ui-skill` could register a row, but it still depended on conversation's Tool-specific composition contract. Adding Tool-specific Session projection would duplicate a data model the Runtime already owns, while moving only individual React components would leave the composition and model coupling in place. + +## Decision + +Tool is a first-class Client UI concept with one presentation owner, `@deepseek-ai/dsh-client-ui-tool`. Session Event, projection, fold, `ConversationSnapshot` construction and caching, historical paging, and Code Dispatch indexing remain unchanged. + +“First-class concept” describes UI ownership only; it adds no Runtime data kind. `ConversationNode` remains the transcript projection, `ChatFlowItem` remains the render unit produced when conversation sorts and groups nodes, `ToolCallBlock` remains the standard data for one call, and `ToolCallTree` only composes root/subcall presentation within Tool. Command continues to render through the separate `'conversation.chat.commandview'` seat and does not become Tool. + +`ui-conversation` owns ordered placement. `deriveChatFlow()` still decides where a settled Tool group appears, and `ChatView` still appends running calls, maintains scroll anchors and selection, and supplies host actions. For each root call it renders the single/session `'conversation.chat.tool'` seat with the root block, selected call id, session cwd, and open-file/inspect callbacks. It does not read Code Dispatch children, branch on Tool names, or import Tool-specific views and card models. + +`ui-tool` occupies that whole-Tool seat. Through its standard session slot props, `ToolCallTree` selects the Runtime-projected `codeDispatches[rootCallId]` array, renders the root followed by that one currently supported child level, and routes both forms through one keyed/session `'tool.call.toolview'` child slot using `entryKey: toolName`. An absent business registration renders `GenericToolCard`. This is deliberately one-level composition, not a claim that the Runtime supports an arbitrary recursive call graph. + +Business plugins register only atomic views against `'tool.call.toolview'`. Their owner payload is the standard Tool call block plus identity, cwd, and host actions; it carries no Session projector or conversation service. Skill remains an ordinary Tool and `ui-skill` registers the `skill` key through this seam. Existing first-party views live in `ui-tool` until a business package has a reason to own one independently. + +The details panel is a second Tool presentation site but not a call-tree owner. `ui-conversation` delegates its selected output body through the single/session `'conversation.details.tool'` seat; `ui-tool` renders the card-aware output and the seat fallback preserves raw result text when the plugin is absent. Card models therefore have one production owner without introducing a reverse implementation import. + +The Runtime remains the authority for Tool lifecycle and call topology. Code Dispatch stays a top-level official concept because it changes `codeDispatches` and parent/child identity; ordinary Tool business differences stay at the keyed presentation seam. This package boundary does not add a Tool projector/fold registry. + +## Runtime and render path + +This boundary starts at the Client's `ConversationSnapshot`; the full render path is: + +```text +ConversationSnapshot.nodes + -> deriveChatFlow() + -> settled tool-group positions ----+ + | +ConversationSnapshot.runningCalls | + -> ChatView flow tail ---------------+-> ToolSeat + -> conversation.chat.tool + -> ToolCallTree +ConversationSnapshot.codeDispatches[rootCallId] -+ + +-> root ToolCall + one-level child ToolCall + -> tool.call.toolview(entryKey = toolName) + |- registered atomic view + `- GenericToolCard fallback +``` + +The live Session's [`Session.buildSnapshot()`](../../../../packages/client/runtime/src/client/sessions/session.ts) caches arrays or maps such as `nodes`, `runningCalls`, and `codeDispatches` against independent revisions. Their references stay stable when the corresponding business state has not changed, allowing React selectors and memoization to skip unrelated updates. The historical projection's [`projectConversationHistory()`](../../../../packages/client/runtime/src/client/session-history/history-fold.ts) reconstructs the same running-call and Code Dispatch shapes from entries in its window. Tool UI consumes the snapshot shapes already unified by those paths; presentation packages do not repeat call/result pairing, historical replay, or cache indexing. + +[`ChatView`](../../../../packages/client/ui-conversation/src/client/chat/ChatView.tsx) reruns [`deriveChatFlow()`](../../../../packages/client/ui-conversation/src/client/chat/chat-flow.ts) only when the `nodes` reference changes. It groups consecutive settled Tool results into a `tool-group`, while running root calls append at the flow tail. Both paths ultimately enter the same `ToolSeat`, so settled and running forms share the whole-Tool seat. `ToolCallTree` selects only the current root's `codeDispatches[rootCallId]`; it does not introduce a business projector for presentation of other roots. + +## Code and responsibility boundaries + +| Owner | Primary code | Owns | Explicitly does not own | +|---|---|---|---| +| Client Runtime | [`Session`](../../../../packages/client/runtime/src/client/sessions/session.ts), [`history-fold.ts`](../../../../packages/client/runtime/src/client/session-history/history-fold.ts) | call/result pairing, running/settled lifecycle, Code Dispatch parent/child index, snapshot reference stability | Business views selected by Tool name | +| `ui-conversation` | [`chat-flow.ts`](../../../../packages/client/ui-conversation/src/client/chat/chat-flow.ts), [`ChatView.tsx`](../../../../packages/client/ui-conversation/src/client/chat/ChatView.tsx), [`slots.ts`](../../../../packages/client/ui-conversation/src/client/contract/slots.ts) | ChatFlow order, settled groups, running tail, scroll anchors, selection and host actions, whole-Tool seat declaration | subcall composition, `toolName` dispatch, Generic fallback, Tool card models | +| `ui-tool` | [`apply.ts`](../../../../packages/client/ui-tool/src/client/apply.ts), [`ToolCallTree.tsx`](../../../../packages/client/ui-tool/src/client/tool/ToolCallTree.tsx), [`slots.ts`](../../../../packages/client/ui-tool/src/client/contract/slots.ts) | root/subcall composition, atomic keyed dispatch, Generic fallback, Tool card models and built-in Tool views | ChatFlow ordering, Session Event fold | +| Business Tool plugins | [`ui-skill` registration example](../../../../packages/client/ui-skill/src/client/index.ts) | Atomic views for one or more wire Tool names | root/subcall placement and lifecycle pairing | +| Details path | [`DetailsPanel.tsx`](../../../../packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx), [`ToolDetails.tsx`](../../../../packages/client/ui-tool/src/client/tool/ToolDetails.tsx) | selected-call lookup, card-aware output, and raw fallback | chat call-tree composition | + +## Slot and owner contract + +A slot declaration also constrains render ownership. The conversation chat entry declares `'conversation.chat.tool'` through `children`, so only `ChatView` places the whole-Tool seat. When `ui-tool` registers that seat, its `children` declares `'tool.call.toolview'`, so only `ToolCallTree` renders the atomic Tool seat. Business plugins register keyed entries only; they neither participate in root/subcall composition nor establish a registry parallel to slots. + +The whole seat's `ToolTreeOwnerProps` carries the root `callId`, `toolName`, `ToolCallBlock`, `selectedCallId`, session `cwd`, `openFile(path)`, and `inspectCall(callId)`. `ToolCallTree` converts either a root or child into the same `ToolCallOwnerProps` and narrows inspect to a callback for that call. The atomic owner carries no `ReactNode`, Cordis `Context`, Session service, or projector; a business view consumes only one standard call block and host actions. + +The seat filler also preserves the conversation DOM contract on every root and child wrapper: `data-chat-anchor-key="call:"`, `data-chat-call-id`, and `data-selected="true"` on the selected call. `ChatView` consumes the anchor key to restore prepend/paging position; the Tool owner emits it because it alone composes child wrappers. + +Business plugins use one registration shape: + +```text +ctx.slots.inject('tool.call.toolview', () => + ctx.slots.register({ + name: 'tool.call.toolview', + key: '', + }, BusinessToolRow)) +``` + +`ui-tool`'s [`apply()`](../../../../packages/client/ui-tool/src/client/apply.ts) registers the whole-Tool renderer, details renderer, and existing built-in atomic views. An existing independent business package can move only its keyed registration, as `ui-skill` does, without changing `ui-conversation` or Session. + +## Details path + +[`DetailsPanel`](../../../../packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx) still locates the selected call in `nodes`, `runningCalls`, and `codeDispatches`, and it owns input arguments, empty states, and panel lifecycle. It passes only `{ block, cwd }` to `'conversation.details.tool'`; [`ToolDetails`](../../../../packages/client/ui-tool/src/client/tool/ToolDetails.tsx) reuses Tool card models to render the output. When `ui-tool` is absent, a settled call falls back to raw result text and a running call shows conversation's running fallback, so details never imports the Tool implementation in reverse. + +## Verification + +Test ownership follows production ownership. `ui-conversation` tests install a local whole-Tool seat probe and assert only ChatFlow placement, owner payload, and host contracts such as selection, open-file, and inspect; they do not import `ui-tool` production code or test helpers. `ui-tool` tests mount a real conversation host and verify root/subcall composition, keyed dispatch, generic fallback, concrete Tool UI, and plugin lifecycle. + +## Alternatives considered + +**Keep atomic Tool slots under every conversation view.** Rejected: each view would have to reproduce root/subcall composition, and a Tool registration would be isolated by view even though its business meaning is Tool-wide. A whole-Tool seat preserves view-owned placement while giving the call tree one owner. This supersedes the per-view placement selected by the earlier [toolview dissolution](2026-07-23-toolview-dissolution.md), while retaining its keyed-slot and no-parallel-registry decisions. + +**Move only the Tool React components and card models.** Rejected: `ChatView` would still own Tool-name dispatch and Code Dispatch composition, so the dependency would change file paths without changing responsibility. + +**Add business-specific Session projectors or folds.** Rejected: ordinary Tool views consume the standard call block already reconstructed by Runtime. A second registry would create two authorities for call identity and historical replay. Only a feature that changes logged topology or lifecycle earns a Runtime-level extension. + +**Make each atomic Tool view render its own subcalls recursively.** Rejected: the atomic registrant receives one Tool call and should not know whether it is a root or child. Root/child composition belongs to `ui-tool`, and the current wire/runtime shape only supports one Code Dispatch child level. + +**Import `ui-tool` components directly from `ui-conversation`.** Rejected: it would reverse the intended feature direction and make Tool presentation mandatory. Declared slots retain lifecycle ownership, fallback behavior, and independent plugin loading. + +## Consequences + +`ui-conversation` becomes independent of Tool-name business presentation while retaining ChatFlow, selection, and host interaction responsibilities. Root calls and subcalls cannot drift onto different dispatch paths, and business packages can own atomic Tool presentation without Session changes. The cost is one new Client package and two cross-package slot seams; `ui-tool` also deliberately depends on conversation's declared seats and locale namespace. The assembled Web bundle therefore mounts `ui-tool`; omitting it leaves chat Tool seats empty while the details seat keeps its raw-result fallback, without changing Session reconstruction. diff --git a/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.zh.md b/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.zh.md new file mode 100644 index 0000000000..5c56b8c17e --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.zh.md @@ -0,0 +1,105 @@ +# Agent Note: Client Tool 展示所有权 + +Status: implemented + +[English](2026-08-08-client-tool-presentation-ownership.md) | 中文 + +## Problem + +Client Runtime 已经把 Tool 调用投影成稳定的生命周期:它按 `callId` 配对 call/result 事件,保留 running 与 settled 两种形态,并按 root call 索引 Code Dispatch 子调用。但 chat view 仍拥有整套展示链路:它在 ChatFlow 中放置 root call,把每个 root 与 subcall 编排在一起,按 Tool 名称分发每个原子调用,携带通用 fallback 与 card model,注册第一方 Tool view,并在 details panel 中复用这些 model。 + +这种所有权迫使 `ui-conversation` 解释业务 Tool 名称;一旦原子 Tool view 被迁走,subcall 就会成为无主的遗留关注点。`ui-skill` 等业务包虽能注册一行视图,仍依赖 conversation 的 Tool 专属编排契约。增加 Tool 专属 Session projection 会重复 Runtime 已拥有的数据模型,而只移动单个 React 组件则会把编排与 model 耦合留在原地。 + +## Decision + +Tool 成为 Client UI 的一级概念,并由 `@deepseek-ai/dsh-client-ui-tool` 统一拥有展示。Session Event、projection、fold、`ConversationSnapshot` 构建与缓存、历史分页及 Code Dispatch 索引保持不变。 + +这里的“一级概念”只描述 UI 所有权,不增加 Runtime 数据种类。`ConversationNode` 仍是 transcript projection,`ChatFlowItem` 仍是 conversation 对节点进行排序与分组后得到的渲染单元,`ToolCallBlock` 仍是单次调用的标准数据,而 `ToolCallTree` 只负责 Tool 内部的 root/subcall 展示编排。Command 继续通过独立的 `'conversation.chat.commandview'` 席位渲染,不并入 Tool。 + +`ui-conversation` 拥有有序放置。`deriveChatFlow()` 仍决定 settled Tool group 在哪里出现,`ChatView` 仍追加 running call、维护滚动 anchor 与 selection,并提供宿主动作。对于每个 root call,它使用 root block、selected call id、session cwd 以及 open-file/inspect 回调渲染 single/session 的 `'conversation.chat.tool'` 席位。它不读取 Code Dispatch child、不按 Tool 名称分支,也不导入 Tool 专属 view 或 card model。 + +`ui-tool` 占据这个整体 Tool 席位。`ToolCallTree` 通过标准 session slot props 选择 Runtime 投影的 `codeDispatches[rootCallId]` 数组,先渲染 root,再渲染当前支持的一层 child;两种调用都通过同一个 keyed/session 的 `'tool.call.toolview'` 子 slot,以 `entryKey: toolName` 分发。业务未注册时渲染 `GenericToolCard`。这里刻意只编排一层,并不声称 Runtime 已支持任意递归调用图。 + +业务插件只对 `'tool.call.toolview'` 注册原子 view。其 owner payload 是标准 Tool call block 加 identity、cwd 与宿主动作,不携带 Session projector 或 conversation service。Skill 仍是普通 Tool,`ui-skill` 通过该 seam 注册 `skill` key。现有第一方 view 暂留在 `ui-tool`,直到某个业务包确有理由独立拥有它。 + +details panel 是第二个 Tool 展示点,但不是调用树所有者。`ui-conversation` 通过 single/session 的 `'conversation.details.tool'` 席位委托 selected output body;`ui-tool` 渲染能够识别 card 的输出,插件缺席时由席位 fallback 保留 raw result text。因此 card model 只有一个生产代码所有者,也不需要引入反向实现依赖。 + +Runtime 仍是 Tool 生命周期与调用拓扑的权威。Code Dispatch 会改变 `codeDispatches` 与 parent/child identity,因此继续作为官方顶级概念;普通 Tool 业务差异停留在 keyed 展示 seam。这个包边界不会增加 Tool projector/fold registry。 + +## Runtime 与渲染链路 + +这项边界从 Client 的 `ConversationSnapshot` 开始,完整渲染链路如下: + +```text +ConversationSnapshot.nodes + -> deriveChatFlow() + -> settled tool-group positions ----+ + | +ConversationSnapshot.runningCalls | + -> ChatView flow tail ---------------+-> ToolSeat + -> conversation.chat.tool + -> ToolCallTree +ConversationSnapshot.codeDispatches[rootCallId] -+ + +-> root ToolCall + one-level child ToolCall + -> tool.call.toolview(entryKey = toolName) + |- registered atomic view + `- GenericToolCard fallback +``` + +Live Session 的 [`Session.buildSnapshot()`](../../../../packages/client/runtime/src/client/sessions/session.ts) 按独立 revision 缓存 `nodes`、`runningCalls`、`codeDispatches` 等数组或 map;没有对应业务变化时,它们保持引用稳定,供 React selector 与 memo 跳过无关更新。历史 projection 的 [`projectConversationHistory()`](../../../../packages/client/runtime/src/client/session-history/history-fold.ts) 从窗口内 entry 重建相同的 running call 与 Code Dispatch 形态。Tool UI 直接消费这两个路径已经统一的 snapshot,不在展示包中重复 call/result 配对、历史 replay 或缓存索引。 + +[`ChatView`](../../../../packages/client/ui-conversation/src/client/chat/ChatView.tsx) 只在 `nodes` 引用变化时重新执行 [`deriveChatFlow()`](../../../../packages/client/ui-conversation/src/client/chat/chat-flow.ts),把连续 settled Tool result 合为 `tool-group`;running root call 则追加在 flow tail。两条路径最终都进入同一个 `ToolSeat`,因此 settled/running 形态共享整体 Tool 席位。`ToolCallTree` 只选择当前 root 的 `codeDispatches[rootCallId]`,不会因其他 root 的展示逻辑引入业务 projector。 + +## 代码与职责边界 + +| 所有者 | 主要代码 | 拥有的责任 | 明确不拥有 | +|---|---|---|---| +| Client Runtime | [`Session`](../../../../packages/client/runtime/src/client/sessions/session.ts)、[`history-fold.ts`](../../../../packages/client/runtime/src/client/session-history/history-fold.ts) | call/result 配对、running/settled 生命周期、Code Dispatch parent/child 索引、snapshot 引用稳定性 | Tool 名称对应的业务视图 | +| `ui-conversation` | [`chat-flow.ts`](../../../../packages/client/ui-conversation/src/client/chat/chat-flow.ts)、[`ChatView.tsx`](../../../../packages/client/ui-conversation/src/client/chat/ChatView.tsx)、[`slots.ts`](../../../../packages/client/ui-conversation/src/client/contract/slots.ts) | ChatFlow 顺序、settled group、running tail、scroll anchor、selection 与宿主动作、整体 Tool 席位声明 | subcall 组合、按 `toolName` 分发、Generic fallback、Tool card model | +| `ui-tool` | [`apply.ts`](../../../../packages/client/ui-tool/src/client/apply.ts)、[`ToolCallTree.tsx`](../../../../packages/client/ui-tool/src/client/tool/ToolCallTree.tsx)、[`slots.ts`](../../../../packages/client/ui-tool/src/client/contract/slots.ts) | root/subcall 组合、原子 keyed dispatch、Generic fallback、Tool card model 与内置 Tool view | ChatFlow 排序、Session Event fold | +| 业务 Tool 插件 | [`ui-skill` 注册例](../../../../packages/client/ui-skill/src/client/index.ts) | 一个或多个 wire Tool name 的原子 view | root/subcall 位置与生命周期配对 | +| details 路径 | [`DetailsPanel.tsx`](../../../../packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx)、[`ToolDetails.tsx`](../../../../packages/client/ui-tool/src/client/tool/ToolDetails.tsx) | selected call 定位、card-aware output 与 raw fallback | chat 调用树编排 | + +## Slot 与 owner 契约 + +slot 声明同时限定渲染所有权。conversation chat entry 通过 `children` 声明 `'conversation.chat.tool'`,因此只有 `ChatView` 放置整体 Tool 席位;`ui-tool` 注册该席位时再通过 `children` 声明 `'tool.call.toolview'`,因此只有 `ToolCallTree` 渲染原子 Tool 席位。业务插件只注册 keyed entry,不参与 root/subcall 编排,也不建立与 slot 平行的 registry。 + +整体席位的 `ToolTreeOwnerProps` 携带 root `callId`、`toolName`、`ToolCallBlock`、`selectedCallId`、session `cwd`、`openFile(path)` 与 `inspectCall(callId)`。`ToolCallTree` 把 root 或 child 转成相同的 `ToolCallOwnerProps`,并把 inspect 收窄成当前 call 的回调。原子 owner 不携带 `ReactNode`、Cordis `Context`、Session service 或 projector;业务 view 只消费一个标准调用块和宿主动作。 + +席位填充方还要在每个 root 和 child wrapper 上保留 conversation DOM 契约:`data-chat-anchor-key="call:"`、`data-chat-call-id`,以及 selected call 上的 `data-selected="true"`。`ChatView` 用 anchor key 恢复 prepend/paging 位置;child wrapper 由 Tool owner 独自编排,因此这些属性也由它输出。 + +业务插件遵循同一个注册形态: + +```text +ctx.slots.inject('tool.call.toolview', () => + ctx.slots.register({ + name: 'tool.call.toolview', + key: '', + }, BusinessToolRow)) +``` + +`ui-tool` 的 [`apply()`](../../../../packages/client/ui-tool/src/client/apply.ts) 注册整体 Tool renderer、details renderer 与现有内置原子 view;已有独立业务包可以像 `ui-skill` 一样只迁走自己的 keyed 注册,无需改动 `ui-conversation` 或 Session。 + +## Details 路径 + +[`DetailsPanel`](../../../../packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx) 仍从 `nodes`、`runningCalls` 与 `codeDispatches` 中定位选中的 call,并拥有 input 参数、空态和面板生命周期。它只把 `{ block, cwd }` 交给 `'conversation.details.tool'`;[`ToolDetails`](../../../../packages/client/ui-tool/src/client/tool/ToolDetails.tsx) 复用 Tool card model 渲染 output。`ui-tool` 缺席时,settled call 回退为 raw result text,running call 显示 conversation 的 running fallback,因此 details 不反向导入 Tool 实现。 + +## Verification + +测试归属跟随生产所有权。`ui-conversation` 的测试安装本地整体 Tool 席位替身,只验证 ChatFlow 位置、owner payload 与 selection、open-file、inspect 等宿主契约;它们不导入 `ui-tool` 的生产实现或测试 helper。`ui-tool` 的测试挂载真实 conversation 宿主,验证 root/subcall 编排、keyed dispatch、generic fallback、具体 Tool UI 与插件生命周期。 + +## Alternatives considered + +**在每个 conversation view 下保留原子 Tool slot。** 拒绝:每个 view 都必须重复 root/subcall 编排,而且 Tool 注册会按 view 隔离,即使它的业务语义本应是 Tool 级。整体 Tool 席位保留 view 对放置位置的所有权,同时让调用树只有一个所有者。它取代了早期 [toolview 溶解](2026-07-23-toolview-dissolution.md)所选择的 per-view 放置方式,但保留 keyed slot 与不设平行 registry 的决策。 + +**只移动 Tool React 组件与 card model。** 拒绝:`ChatView` 仍会拥有 Tool 名称分发与 Code Dispatch 编排,只是改变文件路径,没有改变责任。 + +**增加业务专属 Session projector 或 fold。** 拒绝:普通 Tool view 消费 Runtime 已重建的标准 call block。第二套 registry 会为 call identity 与历史 replay 建立两个权威。只有会改变日志拓扑或生命周期的能力才应获得 Runtime 级扩展。 + +**让每个原子 Tool view 递归渲染自己的 subcall。** 拒绝:原子注册方只接收一个 Tool call,不应知道自己是 root 还是 child。root/child 编排归 `ui-tool`,且当前 wire/runtime 形态只支持一层 Code Dispatch child。 + +**让 `ui-conversation` 直接导入 `ui-tool` 组件。** 拒绝:这会反转预期的 feature 依赖方向,并把 Tool 展示变成必选能力。声明式 slot 能保留生命周期所有权、fallback 行为与独立插件装载。 + +## Consequences + +`ui-conversation` 不再依赖 Tool 名称对应的业务展示,同时保留 ChatFlow、selection 与宿主交互责任。root call 与 subcall 不会漂移到不同分发路径,业务包无需修改 Session 即可拥有原子 Tool 展示。代价是新增一个 Client package 与两个跨包 slot seam;`ui-tool` 也明确依赖 conversation 声明的席位与 locale namespace。因此组装后的 Web bundle 会挂载 `ui-tool`;省略该插件时,chat Tool 席位为空,details 席位则保留 raw-result fallback,且 Session 重建不受影响。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.i18n.yaml index 3d9fef5b34..dfaaea4ab7 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.md -2026-08-05-context-meter-blind-to-compaction.md: ab39ae4e109f238960fd60de5e5b61075344f525 -2026-08-05-context-meter-blind-to-compaction.zh.md: c93aa509530e48acf906b18ba85bab4c7d355d69 +2026-08-05-context-meter-blind-to-compaction.md: 8f4845c3c9bf52c5c3a2d39dee2ff25bda30c7bd +2026-08-05-context-meter-blind-to-compaction.zh.md: 94ada0a9118db63876d5805ef5a8197d9c764102 diff --git a/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.md b/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.md index ab39ae4e10..8f4845c3c9 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.md +++ b/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.md @@ -43,4 +43,4 @@ The panel's composition rows still do not sum to the header, and now for one cle ## Testing -`packages/llm/token-meter/tests/token-usage-projection.spec.ts` covers the carry-forward across surface growth and a compaction (the sample holding still while the projection shrinks) and the zero clamp when heuristic error would drive the figure negative. `packages/client/ui-conversation/tests/context-meter.spec.tsx` pins the ring reading the projected figure, and `chat-stats-bash-sample.spec.tsx` pins `contextOccupancy`'s preference and its fallback. The end-to-end numbers above came from driving `BasicCompactService.compactNow` through a real `AgentLoop` with the projection registry mounted. +`packages/llm/token-meter/tests/token-usage-projection.spec.ts` covers the carry-forward across surface growth and a compaction (the sample holding still while the projection shrinks) and the zero clamp when heuristic error would drive the figure negative. `packages/client/ui-conversation/tests/context-meter.spec.tsx` pins the ring reading the projected figure, and `chat-stats.spec.tsx` pins `contextOccupancy`'s preference and its fallback. The end-to-end numbers above came from driving `BasicCompactService.compactNow` through a real `AgentLoop` with the projection registry mounted. diff --git a/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.zh.md b/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.zh.md index c93aa50953..94ada0a911 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-05-context-meter-blind-to-compaction.zh.md @@ -43,4 +43,4 @@ AFTER compact: ring=4% header=~4227/100000 rows=[system 18, tools 0, messag ## 测试 -`packages/llm/token-meter/tests/token-usage-projection.spec.ts` 覆盖了样本在表层增长与一次压缩上的推进(样本保持不动而投影值缩小),以及启发式误差会把数字压到负数时的零钳制。`packages/client/ui-conversation/tests/context-meter.spec.tsx` 钉住圆环读取投影值这一点,`chat-stats-bash-sample.spec.tsx` 钉住 `contextOccupancy` 的优先级与回退。上面那组端到端数字来自在挂载了投影注册表的真实 `AgentLoop` 上驱动 `BasicCompactService.compactNow`。 +`packages/llm/token-meter/tests/token-usage-projection.spec.ts` 覆盖了样本在表层增长与一次压缩上的推进(样本保持不动而投影值缩小),以及启发式误差会把数字压到负数时的零钳制。`packages/client/ui-conversation/tests/context-meter.spec.tsx` 钉住圆环读取投影值这一点,`chat-stats.spec.tsx` 钉住 `contextOccupancy` 的优先级与回退。上面那组端到端数字来自在挂载了投影注册表的真实 `AgentLoop` 上驱动 `BasicCompactService.compactNow`。 diff --git a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.i18n.yaml index cc872a60c3..f90cfe79db 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-23-web-todo-display.md -2026-07-23-web-todo-display.md: 9e6e4914cd24d1db9271baa3d3fb6fdc56a9ac65 -2026-07-23-web-todo-display.zh.md: 5a5ac554b37c255a7d8c1ce821ebeb1b3f9f091f +2026-07-23-web-todo-display.md: bb66ef8512badea090b3b22030eef3f43f3b1119 +2026-07-23-web-todo-display.zh.md: 7270874d1e96bb0a553d57ba24b3bc2bb38a7a71 diff --git a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.md b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.md index 9e6e4914cd..bb66ef8512 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.md +++ b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.md @@ -22,7 +22,7 @@ The panel mounts through the `conversation.input.dock` slot (a plain registrant ### TodoRow: the per-call row through the keyed toolview slot -The dedicated `todo_write` chat row is a plain registrant plugin (`todoToolview`, mounted from `apply`) that registers into the keyed `conversation.chat.toolview` slot through `ctx.slots.inject`, the same declaration-lifetime posture as the bash sample but a product registration. The summary derives from call args (`N/M done · first active item`, with a `+` count of the other active ones in `ToolRow`'s non-shrinking `summarySuffix` slot); unparseable args fall back to the generic row summary; clicking opens the details column with the raw args. No `ToolEventView` is added for todo — presentation is client-owned, and the durable list renders from the session event, not the tool card. +The dedicated `todo_write` chat row is a plain registrant plugin (`todoToolview`, mounted from `apply`) that registers into the keyed `tool.call.toolview` slot through `ctx.slots.inject`, the same declaration-lifetime posture as the bash sample but a product registration. The summary derives from call args (`N/M done · first active item`, with a `+` count of the other active ones in `ToolRow`'s non-shrinking `summarySuffix` slot); unparseable args fall back to the generic row summary; clicking opens the details column with the raw args. No `ToolEventView` is added for todo — presentation is client-owned, and the durable list renders from the session event, not the tool card. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.zh.md b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.zh.md index 5a5ac554b3..7270874d1e 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.zh.md +++ b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.zh.md @@ -22,7 +22,7 @@ Status: implemented ### TodoRow:经 keyed toolview slot 的逐调用行 -专用的 `todo_write` 对话行是一个普通注册者插件(`todoToolview`,由 `apply` 挂载),经 `ctx.slots.inject` 注册进 keyed 的 `conversation.chat.toolview` slot,遵循与 bash 样例相同的声明生命周期,但属产品级注册。摘要由调用 args 推导(`N/M done · first active item`,其余活跃项的 `+` 计数放在 `ToolRow` 的不收缩 `summarySuffix` 位里);无法解析的 args 回退到通用行摘要;点击会以原始 args 打开 details 列。todo 不新增任何 `ToolEventView`——呈现归客户端所有,常驻列表从会话事件渲染,而非工具卡。 +专用的 `todo_write` 对话行是一个普通注册者插件(`todoToolview`,由 `apply` 挂载),经 `ctx.slots.inject` 注册进 keyed 的 `tool.call.toolview` slot,遵循与 bash 样例相同的声明生命周期,但属产品级注册。摘要由调用 args 推导(`N/M done · first active item`,其余活跃项的 `+` 计数放在 `ToolRow` 的不收缩 `summarySuffix` 位里);无法解析的 args 回退到通用行摘要;点击会以原始 args 打开 details 列。todo 不新增任何 `ToolEventView`——呈现归客户端所有,常驻列表从会话事件渲染,而非工具卡。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/feature/2026-07-26-code-mode-chat-subcall-rows.i18n.yaml b/.agents/notes/implemented/feature/2026-07-26-code-mode-chat-subcall-rows.i18n.yaml index 9086e891db..d550ad1fb6 100644 --- a/.agents/notes/implemented/feature/2026-07-26-code-mode-chat-subcall-rows.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-26-code-mode-chat-subcall-rows.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-26-code-mode-chat-subcall-rows.md -2026-07-26-code-mode-chat-subcall-rows.md: 7d666f0a9e4b8bdb9bd6f5d0d0984fee0c4b21e2 -2026-07-26-code-mode-chat-subcall-rows.zh.md: b6b7de6c34673a0a0fa801681d642067a324d4cd +2026-07-26-code-mode-chat-subcall-rows.md: dc09e8fda9dbfb156b6218dec67584ee7bfead75 +2026-07-26-code-mode-chat-subcall-rows.zh.md: d445ddac4704fb940550d27ef7400e79d859393b diff --git a/.agents/notes/implemented/feature/2026-07-26-code-mode-chat-subcall-rows.md b/.agents/notes/implemented/feature/2026-07-26-code-mode-chat-subcall-rows.md index 7d666f0a9e..dc09e8fda9 100644 --- a/.agents/notes/implemented/feature/2026-07-26-code-mode-chat-subcall-rows.md +++ b/.agents/notes/implemented/feature/2026-07-26-code-mode-chat-subcall-rows.md @@ -15,7 +15,7 @@ With Code Mode enabled, the chat view showed one opaque `run_code` row: raw prog **Sub-calls are `ToolResultNode`s indexed off the surface flow, rendered through the same keyed slot as native rows, nested always-visible under their parent.** - **Data layer**: `Session.applyEventSideEffects` folds each in-window `tool/code-dispatch` into `ConversationSnapshot.codeDispatches: ReadonlyMap`, where `CodeSubCall` IS `ToolResultNode` (the sub-call id as `callId`, the logged args JSON-stringified into `call.argsRaw`, the full logged `content`/`isError`). Live mux frames and history replay build the identical index (`rebuildDerivedFromWindow` clears and re-derives; copy-on-write per-parent arrays keep snapshot references memo-stable). Sub-calls never join `nodes` — the surface flow remains exactly the model-visible turn structure. The event is narrowed structurally at the wire-consumer boundary (dsh-tools' host types cannot enter the client program — the host/client `Context` merges collide), the same posture as every cross-wire payload. -- **Render layer**: `ChatView`'s `CallRow` renders the parent, then — for parents present in the index — a `[data-subcalls]` nest of `SubCallRow`s, each dispatching through the SAME `'conversation.chat.toolview'` keyed hole with `entryKey = sub-tool name` and the same `GenericToolCard` fallback. Identity with native rows holds by construction: a keyed registration (e.g. the bash sample) takes over sub-rows exactly as it takes over top-level rows, with zero registration changes. Running parents (`runningCalls`) nest their so-far dispatches the same way, so sub-rows stream in live during the run (PR1 logs each dispatch as it completes). +- **Render layer**: `ChatView` passes each parent and its indexed children through the whole-Tool `'conversation.chat.tool'` seat. ui-tool's `ToolCallTree` renders the parent followed by a `[data-subcalls]` nest, and every atomic call dispatches through the same `'tool.call.toolview'` keyed slot with `entryKey = Tool name` and the same `GenericToolCard` fallback. A keyed registration therefore takes over child and top-level calls without registration changes. Running parents (`runningCalls`) receive their accumulated dispatches through the same owner payload, so child rows stream in during the run. - **`run_code` presentation**: a new `code` row variant (classifier `run_code → code`, `Code` title, `IconCodeOutline16`) summarizes with the model-authored `description` and expands to the program itself (monospace on the markdown code-block fill) rather than the args JSON envelope. - **Details panel**: `materialFor` falls through nodes → runningCalls → the dispatch index, so a selected sub-callId resolves to full args and complete output through the identical rendering path as a native settled call. diff --git a/.agents/notes/implemented/feature/2026-07-26-code-mode-chat-subcall-rows.zh.md b/.agents/notes/implemented/feature/2026-07-26-code-mode-chat-subcall-rows.zh.md index b6b7de6c34..d445ddac47 100644 --- a/.agents/notes/implemented/feature/2026-07-26-code-mode-chat-subcall-rows.zh.md +++ b/.agents/notes/implemented/feature/2026-07-26-code-mode-chat-subcall-rows.zh.md @@ -15,7 +15,7 @@ Status: implemented **子调用在界面流之外单独索引为 `ToolResultNode`,经由与原生行相同的 keyed slot 渲染,以始终可见的方式嵌套在父行之下。** - **数据层**:`Session.applyEventSideEffects` 把窗口内的每条 `tool/code-dispatch` 折入 `ConversationSnapshot.codeDispatches: ReadonlyMap`,其中 `CodeSubCall` 本身就是 `ToolResultNode`(子调用 id 充当 `callId`,已记录的参数经 JSON 字符串化写入 `call.argsRaw`,完整记录的 `content`/`isError` 原样携带)。实时多路复用帧与历史回放构建出同一份索引(`rebuildDerivedFromWindow` 先清空再重新推导;逐父级的写时复制(copy-on-write)数组保持快照引用稳定,便于 memo 化)。子调用永不进入 `nodes`——surface 流始终精确等于模型可见的轮次结构。该事件在 wire 消费方边界作结构性收窄(dsh-tools 的宿主类型无法进入客户端程序——宿主端/客户端两侧的 `Context` 声明合并会冲突),姿态与所有跨 wire 载荷一致。 -- **渲染层**:`ChatView` 的 `CallRow` 先渲染父行,随后对索引中出现的父级渲染一组 `[data-subcalls]` 嵌套的 `SubCallRow`,每一行都经由同一个 `'conversation.chat.toolview'` keyed slot、以 `entryKey = sub-tool name` 分发,并共用同一个 `GenericToolCard` 后备组件。与原生行的同一性由构造保证:一个 keyed 注册(例如 bash 样例)接管子行与接管顶层行的方式完全相同,注册本身零改动。运行中的父调用(`runningCalls`)也以同样的方式嵌套目前已产生的分发,因此子行在运行期间实时流入(PR1 在每次分发完成时即记录该分发)。 +- **渲染层**:`ChatView` 通过整体 Tool seat `'conversation.chat.tool'` 传递每个 parent 及其已索引的 child。ui-tool 的 `ToolCallTree` 先渲染 parent,再渲染一组 `[data-subcalls]` 嵌套;每个原子调用都通过同一个 `'tool.call.toolview'` keyed slot,以 Tool 名称作为 `entryKey`,并共用 `GenericToolCard` fallback。一个 keyed 注册因此无需变化即可同时接管 child 与顶层调用。运行中的 parent(`runningCalls`)通过同一 owner 载荷接收已累积的 dispatch,使 child 行在运行期间实时流入。 - **`run_code` 的呈现**:新增一种 `code` 行变体(分类器映射 `run_code → code`、标题 `Code`、图标 `IconCodeOutline16`),以模型撰写的 `description` 作摘要,展开后显示程序本身(在 markdown 代码块的填充底色上以等宽字体呈现),而非参数的 JSON 封装。 - **详情面板**:`materialFor` 按 nodes → runningCalls → 分发索引的顺序逐级回落,因此被选中的子调用 callId 会经由与已完结的原生调用完全相同的渲染路径,解析出完整参数与完整输出。 diff --git a/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.i18n.yaml index 5e079bc180..53dba67c61 100644 --- a/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-28-web-terminal-card.md -2026-07-28-web-terminal-card.md: 0e5f3e2157ebfc4e71aead26c15b6ee91958a5d5 -2026-07-28-web-terminal-card.zh.md: 1285d3fbb46ebd32ff163feac632cd487e8a04f1 +2026-07-28-web-terminal-card.md: 026b32fed2c79533abfadc58314e2844ff556414 +2026-07-28-web-terminal-card.zh.md: 4c74d880c5795cf85f32391b27e6ffd2c5074870 diff --git a/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md index 0e5f3e2157..026b32fed2 100644 --- a/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md +++ b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md @@ -8,11 +8,11 @@ English | [中文](2026-07-28-web-terminal-card.zh.md) The bash tool declares `card: 'terminal'` for both its call and its result ([render-intent union](../architecture/2026-07-02-tool-render-intent-union.md)): the call view carries the command, an optional model-authored description, and the working directory; the result view carries the output, exit code, and terminating signal. That view already reaches the browser — host, connection, and runtime deliver it onto `ConversationSnapshot` as `callView`/`resultView` — and the former TUI rendered it as a `$`-prompt card with an exit line and a head/tail height cap. -The Web client ignored it. `packages/client/ui-conversation/src/client/contract/tool-call-model.ts` derived every row from raw tool args, and `skeleton/DetailsPanel.tsx` flattened every tool's content blocks into one `
` with `white-space: pre-wrap; word-break: break-word`. Two defects followed from soft-wrapping and from having no height bound: multi-column output (`ls`, a table, box drawing) folded into a paragraph and lost the column alignment that is the whole point of that output, and a long single-column listing stretched the details panel to the length of the listing.
+The Web client ignored it. `packages/client/ui-tool/src/client/tool/models/tool-call-model.ts` derived every row from raw tool args, and `skeleton/DetailsPanel.tsx` flattened every tool's content blocks into one `
` with `white-space: pre-wrap; word-break: break-word`. Two defects followed from soft-wrapping and from having no height bound: multi-column output (`ls`, a table, box drawing) folded into a paragraph and lost the column alignment that is the whole point of that output, and a long single-column listing stretched the details panel to the length of the listing.
 
 ## Decision
 
-`TerminalBlock` is a `ui-primitives` component that renders a shell command as a terminal surface, and both Web render sites for a bash call consume the terminal render intent through it: the chat tool row's expanded body and the details panel's Output section. `ui-conversation/src/client/contract/terminal-card-model.ts` is the single place that turns the snapshot's `callView`/`resultView` pair into the component's props, so the two sites cannot disagree about a command, its cwd, or its exit status. It returns null — the generic path — whenever neither side declares `card: 'terminal'`, including a `card` value this client version does not know, and whenever a settled call's result view is generic, which is how the bash tool's execution errors and background starts keep their existing rendering. Two duties the render-intent contract assigns to the UI bridge land here rather than in the tool: a settled result's `title` REPLACES the pending one, and the working directory resolves against the session workspace — an absolute view cwd is used as-is, a relative one joins under the workspace, and an omitted one IS the workspace, which is the common case for a bash call with no `workdir`. A pure presenter cannot see the session cwd, which is why the resolution belongs at this seam; each render site supplies the cwd off the session list row. Only a PRESENT call view can mean "omitted, so use the workspace": when the paging window drops the call head there is no cwd anywhere — the result view carries none — and the original call may have used an explicit workdir, so the prompt draws a bare `$` rather than naming a directory it cannot know. The resolved path also normalizes its `.`/`..` segments, because the bash executor resolves the workdir before running: a `..` against `/w/app` runs in `/w`, so the prompt label has to read `w` rather than `..`. A UNC path's `server` and `share` are part of its root rather than poppable segments, since Windows cannot climb above a share. The call view's `description` rides the same derivation, since the contract renders it above the card and it must outrank the row's args-derived summary. All three render sites draw it: both chat-row shapes and the details panel. An expanded row draws it itself, because the collapsed summary is hidden while a row is open — without that the description would only ever be visible collapsed, which is the opposite of what "above the card" means.
+`TerminalBlock` is a `ui-primitives` component that renders a shell command as a terminal surface, and both Web render sites for a bash call consume the terminal render intent through it: the chat tool row's expanded body and the details panel's Output section. `packages/client/ui-tool/src/client/tool/models/terminal-card-model.ts` is the single place that turns the snapshot's `callView`/`resultView` pair into the component's props, so the two sites cannot disagree about a command, its cwd, or its exit status. It returns null — the generic path — whenever neither side declares `card: 'terminal'`, including a `card` value this client version does not know, and whenever a settled call's result view is generic, which is how the bash tool's execution errors and background starts keep their existing rendering. Two duties the render-intent contract assigns to the UI bridge land here rather than in the tool: a settled result's `title` REPLACES the pending one, and the working directory resolves against the session workspace — an absolute view cwd is used as-is, a relative one joins under the workspace, and an omitted one IS the workspace, which is the common case for a bash call with no `workdir`. A pure presenter cannot see the session cwd, which is why the resolution belongs at this seam; each render site supplies the cwd off the session list row. Only a PRESENT call view can mean "omitted, so use the workspace": when the paging window drops the call head there is no cwd anywhere — the result view carries none — and the original call may have used an explicit workdir, so the prompt draws a bare `$` rather than naming a directory it cannot know. The resolved path also normalizes its `.`/`..` segments, because the bash executor resolves the workdir before running: a `..` against `/w/app` runs in `/w`, so the prompt label has to read `w` rather than `..`. A UNC path's `server` and `share` are part of its root rather than poppable segments, since Windows cannot climb above a share. The call view's `description` rides the same derivation, since the contract renders it above the card and it must outrank the row's args-derived summary. All three render sites draw it: both chat-row shapes and the details panel. An expanded row draws it itself, because the collapsed summary is hidden while a row is open — without that the description would only ever be visible collapsed, which is the opposite of what "above the card" means.
 
 The component's contract:
 
@@ -27,7 +27,7 @@ Geometry, radius, and fonts mirror `CodeBlock`, so a terminal card and a fenced
 
 ### Inline output in the chat row reverses a stated convention
 
-`chat/ToolRow.tsx` and `contract/tool-call-model.ts` asserted "no inline output ever — full results live in the details panel". Showing the terminal block in the row reverses that, on the owner's explicit decision.
+`packages/client/ui-tool/src/client/tool/components/ToolRow.tsx` and `packages/client/ui-tool/src/client/tool/models/tool-call-model.ts` asserted "no inline output ever — full results live in the details panel". Showing the terminal block in the row reverses that, on the owner's explicit decision.
 
 The reason the reversal holds: for a shell command the output *is* the result the user is reading, so routing it exclusively to a panel makes the common case a two-step interaction. A bounded, height-capped, non-wrapping terminal block in the row is what makes a bash-heavy transcript readable in one pass. The old rule's actual concern was a row whose height was unbounded by the length of the output, and the height cap plus expand control is what keeps that from returning.
 
@@ -57,7 +57,7 @@ Inline rendering is licensed for the terminal intent alone. A future intent that
 
 `packages/client/ui-primitives/tests/ansi.spec.ts` pins the parse layer: token mapping for the basic colors, literal rgb for the values with no token, the background-run pair, every decoration and the `textDecoration` collision between two of them, the sanitizing of OSC strings and non-CSI escapes and inert controls, the cursor replay (redraws leaving a longer frame's tail standing, a trailing backspace erasing nothing, erase-in-line in all three parameter forms, tab stops, wide characters, SGR threading across lines, and a cursor/erase sequence never entering a cell style), and CRLF preservation. Each replay case was checked against a real terminal first. `packages/client/ui-primitives/tests/terminal-block.spec.tsx` pins the component: cwd shortening, the running/empty/settled arms, signal outranking exit code, the trailing-newline terminator rule, the head/tail cap with its `aria-expanded` toggle, the run-state dot across all three reachable states plus its position ahead of the prompt label, the one-row-per-command-line prompt and its single dot on the first row, and the copy control asserting raw output on both the accepted and refused clipboard paths, plus `writeClipboard` directly.
 
-`packages/client/ui-conversation/tests/terminal-card.spec.tsx` pins the wiring at every render site: `terminalCardModel`'s derivation and each of its null arms, the result title replacing the pending one, the cwd resolving against the session workspace across all four of its cases, the panel resetting the card's expand state when the selection changes, the chat row's expand-gated body against the panel's full-height one, `BashRow`'s resident card and its agreement with its own summary row's state dot, and the panel's Output section including the run_code sub-dispatch and the out-of-window head. That file is written against no gate pressure — `packages/client/ui-conversation/src/*` sits on the coverage `exclude` list in `vitest.config.ts`, so a coverage run over this package measures none of these files.
+`packages/client/ui-tool/tests/terminal-card.spec.tsx` pins the wiring at every render site: `terminalCardModel`'s derivation and each of its null arms, the result title replacing the pending one, the cwd resolving against the session workspace across all four of its cases, the panel resetting the card's expand state when the selection changes, the chat row's expand-gated body against the panel's full-height one, `BashRow`'s resident card and its agreement with its own summary row's state dot, and the panel's Output section including the run_code sub-dispatch and the out-of-window head. That file is written against no gate pressure — `packages/client/ui-tool/src/*` sits on the coverage `exclude` list in `vitest.config.ts`, so a coverage run over this package measures none of these files.
 
 `apps/web/tests/terminal-card.snapshot.ts` pins the assembled application over the built client bundles: the same render intent at both conversation render sites and in both chat-row shapes, because a bash call reaches a resident card only through the keyed `BashRow` registration and every other terminal-declaring tool name lands on the render-site fallback row, whose body is expand-gated. Fixture turn 65 was named `bash` and turn 60 left as `fx-bash` so one fixture covers both shapes, and turn 60's command was made two lines so the built-bundle snapshot pins the per-line prompt and its single dot (`dotsPerPromptRow: [1, 0]`). That terminal turn is ordered BEFORE the todo turn on purpose: the standing plan retires at the next `turn/start`, so appending it after would have emptied the dock's plan strip and taken the todo surfaces' own coverage with it; that turn also carries what turn 60's two prompt rows cannot — SGR runs resolved to `--dsw-*` tokens, output past the chat cap, a nested cwd, and a non-zero exit authored beside the sample. The sample's body deliberately carries NO `[exit code: N]` line: the real bash presenter consumes that marker out of the body precisely because the card shows the exit as its own pill, so leaving it in would pin a frame showing the exit twice — one the product path cannot produce.
 
diff --git a/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md
index 1285d3fbb4..4c74d880c5 100644
--- a/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md
+++ b/.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md
@@ -8,11 +8,11 @@ Status: implemented
 
 bash 工具的调用与结果都声明 `card: 'terminal'`([渲染意图联合类型](../architecture/2026-07-02-tool-render-intent-union.md)):调用视图携带命令、一段可选的模型撰写描述以及工作目录,结果视图携带输出、退出码与终止信号。该视图早已抵达浏览器——host、connection 与 runtime 把它投递到 `ConversationSnapshot` 的 `callView`/`resultView` 上——原 TUI 曾把它渲染为带 `$` 提示符的卡片,附退出行与首尾高度上限。
 
-Web client 却对它视而不见。`packages/client/ui-conversation/src/client/contract/tool-call-model.ts` 仅从原始工具参数推导每一行,`skeleton/DetailsPanel.tsx` 则把所有工具的内容块压平进一个 `
`,样式为 `white-space: pre-wrap; word-break: break-word`。软换行加上没有高度约束,带来两个缺陷:多列输出(`ls`、表格、制表符绘图)被折成一段文字,丢掉了这类输出赖以存在的列对齐;而单列的长列表会把详情面板拉长到与列表等长。
+Web client 却对它视而不见。`packages/client/ui-tool/src/client/tool/models/tool-call-model.ts` 仅从原始工具参数推导每一行,`skeleton/DetailsPanel.tsx` 则把所有工具的内容块压平进一个 `
`,样式为 `white-space: pre-wrap; word-break: break-word`。软换行加上没有高度约束,带来两个缺陷:多列输出(`ls`、表格、制表符绘图)被折成一段文字,丢掉了这类输出赖以存在的列对齐;而单列的长列表会把详情面板拉长到与列表等长。
 
 ## Decision
 
-`TerminalBlock` 是 `ui-primitives` 中把 shell 命令渲染为终端表面的组件,bash 调用在 Web 侧的两个渲染点都经由它消费 terminal 渲染意图:聊天工具行展开后的正文,以及详情面板的 Output 区。`ui-conversation/src/client/contract/terminal-card-model.ts` 是把快照上的 `callView`/`resultView` 这一对转换为该组件 props 的唯一位置,因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧。当两侧都不声明 `card: 'terminal'` 时它返回 null,即走 generic 路径——包括本 client 版本不认识的 `card` 取值;当一个已落定调用的结果视图是 generic 时同样返回 null,这正是 bash 工具的执行错误与后台启动得以保持既有渲染的方式。渲染意图契约交给 UI 桥接层的两项职责也落在这里,而不在工具侧:已落定结果的 `title` **替换**待定标题;工作目录针对会话 workspace 解析——视图给出的绝对路径原样使用,相对路径在 workspace 之下拼接,省略则**就是** workspace,而这正是不带 `workdir` 的 bash 调用的常见情形。纯 presenter 看不到会话 cwd,因此该解析属于这道接缝;两个渲染点各自从会话列表行取出 cwd 传入。只有**存在**的调用视图才能表示「省略了 cwd,因此取 workspace」:当分页窗口丢掉调用头时,任何地方都不再有 cwd——结果视图并不携带它——而原调用完全可能使用过一个显式 workdir,因此提示行绘制一个裸 `$`,而不是命名一个它无法知晓的目录。解析后的路径还会归一化其 `.`/`..` 段,因为 bash 执行器在运行前就已解析 workdir:相对 `/w/app` 的 `..` 实际运行在 `/w`,因此提示标签必须读作 `w` 而不是 `..`。UNC 路径的 `server` 与 `share` 属于其根,而非可弹出的路径段,因为 Windows 无法越过一个共享向上。调用视图的 `description` 走同一处推导,因为契约把它渲染在卡片上方,且它必须优先于该行由参数推导出的摘要。三个渲染点都会绘制它:两种聊天行形态与详情面板。展开后的行自行绘制它,因为一行处于展开态时其折叠摘要是隐藏的——否则该描述将只在折叠时可见,这与「位于卡片上方」的含义正好相反。
+`TerminalBlock` 是 `ui-primitives` 中把 shell 命令渲染为终端表面的组件,bash 调用在 Web 侧的两个渲染点都经由它消费 terminal 渲染意图:聊天工具行展开后的正文,以及详情面板的 Output 区。`packages/client/ui-tool/src/client/tool/models/terminal-card-model.ts` 是把快照上的 `callView`/`resultView` 这一对转换为该组件 props 的唯一位置,因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧。当两侧都不声明 `card: 'terminal'` 时它返回 null,即走 generic 路径——包括本 client 版本不认识的 `card` 取值;当一个已落定调用的结果视图是 generic 时同样返回 null,这正是 bash 工具的执行错误与后台启动得以保持既有渲染的方式。渲染意图契约交给 UI 桥接层的两项职责也落在这里,而不在工具侧:已落定结果的 `title` **替换**待定标题;工作目录针对会话 workspace 解析——视图给出的绝对路径原样使用,相对路径在 workspace 之下拼接,省略则**就是** workspace,而这正是不带 `workdir` 的 bash 调用的常见情形。纯 presenter 看不到会话 cwd,因此该解析属于这道接缝;两个渲染点各自从会话列表行取出 cwd 传入。只有**存在**的调用视图才能表示「省略了 cwd,因此取 workspace」:当分页窗口丢掉调用头时,任何地方都不再有 cwd——结果视图并不携带它——而原调用完全可能使用过一个显式 workdir,因此提示行绘制一个裸 `$`,而不是命名一个它无法知晓的目录。解析后的路径还会归一化其 `.`/`..` 段,因为 bash 执行器在运行前就已解析 workdir:相对 `/w/app` 的 `..` 实际运行在 `/w`,因此提示标签必须读作 `w` 而不是 `..`。UNC 路径的 `server` 与 `share` 属于其根,而非可弹出的路径段,因为 Windows 无法越过一个共享向上。调用视图的 `description` 走同一处推导,因为契约把它渲染在卡片上方,且它必须优先于该行由参数推导出的摘要。三个渲染点都会绘制它:两种聊天行形态与详情面板。展开后的行自行绘制它,因为一行处于展开态时其折叠摘要是隐藏的——否则该描述将只在折叠时可见,这与「位于卡片上方」的含义正好相反。
 
 该组件的契约:
 
@@ -27,7 +27,7 @@ Web client 却对它视而不见。`packages/client/ui-conversation/src/client/c
 
 ### 聊天行内嵌输出推翻了一条既有约定
 
-`chat/ToolRow.tsx` 与 `contract/tool-call-model.ts` 都断言过「绝不内嵌输出——完整结果在详情面板」。在行内显示终端块推翻了这一点,依据是 owner 的明确决定。
+`packages/client/ui-tool/src/client/tool/components/ToolRow.tsx` 与 `packages/client/ui-tool/src/client/tool/models/tool-call-model.ts` 都断言过「绝不内嵌输出——完整结果在详情面板」。在行内显示终端块推翻了这一点,依据是 owner 的明确决定。
 
 这次推翻成立的理由:对 shell 命令而言,输出**就是**用户要读的结果,把它专门收进面板会让最常见的情形变成两步交互。行内一个有界、限高、不换行的终端块,正是让 bash 密集的 transcript 一遍读完的条件。旧规则真正担心的是行高不受输出长度约束,而高度上限加展开控件正是防止其复现的机制。
 
@@ -57,7 +57,7 @@ Web client 却对它视而不见。`packages/client/ui-conversation/src/client/c
 
 `packages/client/ui-primitives/tests/ansi.spec.ts` 固定解析层:基本色的 token 映射、无对应 token 取值的字面 rgb、带背景分段的前后景配对、每一项装饰以及其中两项之间的 `textDecoration` 冲突、OSC 串与非 CSI 转义及无显示意义控制符的剥除、光标重放(较短重绘让上一帧尾巴留存、末尾退格不擦除任何东西、行内擦除的全部三种参数形式、制表位、宽字符、SGR 跨行延续,以及光标/擦除序列绝不进入单元格样式),以及 CRLF 的保留。每一条重放用例都先对照真实终端核实过。`packages/client/ui-primitives/tests/terminal-block.spec.tsx` 固定组件:cwd 缩短、运行中/空/已落定三条分支、信号优先于退出码、末尾终止符规则、首尾高度上限及其 `aria-expanded` 开关、运行状态点全部三种可达状态及其位于提示符标签之前的位置、每条命令行一行的提示区及其位于第一行的单枚状态点,以及复制控件在剪贴板接受与拒绝两条路径上都断言原始输出,另有对 `writeClipboard` 的直接固定。
 
-`packages/client/ui-conversation/tests/terminal-card.spec.tsx` 固定每个渲染点上的接线:`terminalCardModel` 的推导及其每一处 null 分支、结果标题替换待定标题、cwd 针对会话 workspace 解析的全部四种情形、切换选中调用时面板重置卡片展开态、对话行受展开控制的输出体与面板的全高输出体的对比、`BashRow` 的常驻卡片及其与自身摘要行状态点的一致性,以及面板 Output 区段(含 run_code 子派发与超出窗口的调用头)。该文件在没有门禁压力的情况下写成——`packages/client/ui-conversation/src/*` 位于 `vitest.config.ts` 的覆盖率 `exclude` 列表中,因此覆盖率运行不会统计其中任何文件。
+`packages/client/ui-tool/tests/terminal-card.spec.tsx` 固定每个渲染点上的接线:`terminalCardModel` 的推导及其每一处 null 分支、结果标题替换待定标题、cwd 针对会话 workspace 解析的全部四种情形、切换选中调用时面板重置卡片展开态、对话行受展开控制的输出体与面板的全高输出体的对比、`BashRow` 的常驻卡片及其与自身摘要行状态点的一致性,以及面板 Output 区段(含 run_code 子派发与超出窗口的调用头)。该文件在没有门禁压力的情况下写成——`packages/client/ui-tool/src/*` 位于 `vitest.config.ts` 的覆盖率 `exclude` 列表中,因此覆盖率运行不会统计其中任何文件。
 
 `apps/web/tests/terminal-card.snapshot.ts` 在构建后的客户端产物上固定组装完整的应用:同一渲染意图在两个对话渲染点、以及两种对话行形态下的表现——因为 bash 调用只有经由带键的 `BashRow` 注册才得到常驻卡片,而其他任何声明 terminal 的工具名都落到渲染点兜底行上,其输出体受展开控制。fixture 第 65 轮改名为 `bash`、第 60 轮保留 `fx-bash`,于是一份 fixture 覆盖两种形态,并把第 60 轮的命令改为两行,使构建产物快照钉住逐行提示区及其单枚状态点(`dotsPerPromptRow: [1, 0]`)。该终端轮有意排在 todo 轮**之前**:站立计划会在下一次 `turn/start` 时退役,若追加在其后就会让 dock 的计划条变空,并连带毁掉 todo 表面自身的覆盖;该轮还承载第 60 轮两个提示行无法覆盖的部分——解析到 `--dsw-*` token 的 SGR 分段、超出对话上限的输出、嵌套 cwd,以及在样本旁另行标注的非零退出码。样本正文有意**不含** `[exit code: N]` 行:真实的 bash presenter 正是因为卡片以徽章单独呈现退出状态,才把该标记从正文中消费掉;若保留它,钉住的将是一帧把退出状态显示两次的画面——而产品路径产不出这一帧。
 
diff --git a/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.i18n.yaml b/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.i18n.yaml
index 161e7b7fe7..35c6e070a6 100644
--- a/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.i18n.yaml
@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.md
-2026-07-29-ask-question-web-presentation.md: 90eeb3cdcc1a851b7d5e184c0f31cbccd82cbf55
-2026-07-29-ask-question-web-presentation.zh.md: d1d18c030fd6cd9fc7832f82c19b49e4e8e04d30
+2026-07-29-ask-question-web-presentation.md: 11471bba08b723620b83af0e14e64a759d11c520
+2026-07-29-ask-question-web-presentation.zh.md: 50f0dffa1a1f9cbd29b676cc1f21ba0298f6bf23
diff --git a/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.md b/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.md
index 90eeb3cdcc..11471bba08 100644
--- a/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.md
+++ b/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.md
@@ -12,7 +12,7 @@ Separately, the composer visuals had drifted from the current design: an expand-
 
 ## Decision
 
-A pending question owns exactly two surfaces: the composer takeover collects the answers, and a dedicated `ask_user_question` toolview row in the transcript names the interaction outcome. The row registers into the keyed `conversation.chat.toolview` hole exactly like `todo_write` and composes the shared `ToolRow` (chrome, running sweep, leading expansion). Its summary is the interaction verdict rather than args: `waiting` while running, `N/M answered` from the result JSON once settled (a skipped answer — empty `selected`, no `custom` — stays out of the count), `cancelled` for `ASK_CANCELLED`, and `interrupted` with the shared amber stopped semantics for `ASK_ABORTED`. Malformed or truncated results fall back to the generic summary. `PendingCard` narrows to `PendingWait<'approval'>` and `ChatView` filters the pending list to approval waits, so the placeholder card now exists only for the approval takeover still on the roadmap.
+A pending question owns exactly two surfaces: the composer takeover collects the answers, and a dedicated `ask_user_question` toolview row in the transcript names the interaction outcome. The row registers into the keyed `tool.call.toolview` hole exactly like `todo_write` and composes the shared `ToolRow` (chrome, running sweep, leading expansion). Its summary is the interaction verdict rather than args: `waiting` while running, `N/M answered` from the result JSON once settled (a skipped answer — empty `selected`, no `custom` — stays out of the count), `cancelled` for `ASK_CANCELLED`, and `interrupted` with the shared amber stopped semantics for `ASK_ABORTED`. Malformed or truncated results fall back to the generic summary. `PendingCard` narrows to `PendingWait<'approval'>` and `ChatView` filters the pending list to approval waits, so the placeholder card now exists only for the approval takeover still on the roadmap.
 
 The composer redesign moves paging into the footer next to the actions, renders multi-select options with explicit checkboxes, keeps single-select numbered rows, and replaces the expand-to-open custom entry with an always-visible custom input row (textarea for optionless questions). The `parseQuestionTitle` multi-select suffix convention is deleted; `multi_select` is already structured metadata, so the title renders verbatim.
 
diff --git a/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.zh.md b/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.zh.md
index d1d18c030f..50f0dffa1a 100644
--- a/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.zh.md
+++ b/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.zh.md
@@ -12,7 +12,7 @@ Web GUI 已经可以通过 `QuestionComposer` 的输入区接管收集回答,
 
 ## 决定
 
-一个待回答的问题恰好拥有两个界面:输入区接管收集回答,会话记录中一个专门的 `ask_user_question` toolview 行陈述交互结果。该行与 `todo_write` 完全一样注册进带 key 的 `conversation.chat.toolview` 槽位,并复用共享的 `ToolRow`(外观、运行扫光、前导展开)。其摘要是交互裁决而非参数:运行中显示 `waiting`,结算后从结果 JSON 得出 `N/M answered`(被跳过的回答 —— `selected` 为空且无 `custom` —— 不计入),`ASK_CANCELLED` 显示 `cancelled`,`ASK_ABORTED` 显示 `interrupted` 并沿用共享的琥珀色 stopped 语义。畸形或截断的结果回退到通用摘要。`PendingCard` 收窄为 `PendingWait<'approval'>`,`ChatView` 将待处理列表过滤为仅审批等待,占位卡片从此只服务于仍在路线图上的审批接管。
+一个待回答的问题恰好拥有两个界面:输入区接管收集回答,会话记录中一个专门的 `ask_user_question` toolview 行陈述交互结果。该行与 `todo_write` 完全一样注册进带 key 的 `tool.call.toolview` 槽位,并复用共享的 `ToolRow`(外观、运行扫光、前导展开)。其摘要是交互裁决而非参数:运行中显示 `waiting`,结算后从结果 JSON 得出 `N/M answered`(被跳过的回答 —— `selected` 为空且无 `custom` —— 不计入),`ASK_CANCELLED` 显示 `cancelled`,`ASK_ABORTED` 显示 `interrupted` 并沿用共享的琥珀色 stopped 语义。畸形或截断的结果回退到通用摘要。`PendingCard` 收窄为 `PendingWait<'approval'>`,`ChatView` 将待处理列表过滤为仅审批等待,占位卡片从此只服务于仍在路线图上的审批接管。
 
 输入区重设计将分页移到底部操作区旁,多选选项渲染显式复选框,单选保留编号行,并用始终可见的自定义输入行取代展开式自定义入口(无选项问题用多行文本框)。删除 `parseQuestionTitle` 的多选后缀约定;`multi_select` 已是结构化元数据,标题原样渲染。
 
diff --git a/.agents/notes/implemented/feature/2026-07-30-web-diff-card.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.i18n.yaml
index 388a8de9cd..d78733bb33 100644
--- a/.agents/notes/implemented/feature/2026-07-30-web-diff-card.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.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-30-web-diff-card.md
-2026-07-30-web-diff-card.md: eb43e09d6173ca2270df97cecaab6da36c70a679
-2026-07-30-web-diff-card.zh.md: 669cd49abc8eba8637705cd7c9f331cc51607755
+2026-07-30-web-diff-card.md: b078da8d0e688d705683599467bd98b5c6a0be48
+2026-07-30-web-diff-card.zh.md: a9c710df99c1060bbf50b591b2f62120dc7dcbef
diff --git a/.agents/notes/implemented/feature/2026-07-30-web-diff-card.md b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.md
index eb43e09d61..b078da8d0e 100644
--- a/.agents/notes/implemented/feature/2026-07-30-web-diff-card.md
+++ b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.md
@@ -14,7 +14,7 @@ This is the [terminal card](2026-07-28-web-terminal-card.md) done for the `diff`
 
 ## Decision
 
-`DiffBlock` is a `ui-primitives` component that renders a file mutation as an inline diff surface, and both Web render sites for a write/edit call consume the diff render intent through it: the chat tool row's body and the details panel's Output section. `ui-conversation/src/client/contract/diff-card-model.ts` is the single place that turns the snapshot's `callView`/`resultView` pair into the component's props, so the two sites cannot disagree about a change. It returns null — the generic path — whenever neither side declares `card: 'diff'`, including a `card` value this client version does not know, and whenever a settled call's result view is generic, which is how write/edit keep their execution errors on the generic path. The result side is authoritative once the call settles: the applied hunks replace the call-time diff derived from the arguments alone. A paging window that drops the call head still renders, because the result view carries the whole change.
+`DiffBlock` is a `ui-primitives` component that renders a file mutation as an inline diff surface, and both Web render sites for a write/edit call consume the diff render intent through it: the chat tool row's body and the details panel's Output section. `ui-tool/src/client/tool/models/diff-card-model.ts` is the single place that turns the snapshot's `callView`/`resultView` pair into the component's props, so the two sites cannot disagree about a change. It returns null — the generic path — whenever neither side declares `card: 'diff'`, including a `card` value this client version does not know, and whenever a settled call's result view is generic, which is how write/edit keep their execution errors on the generic path. The result side is authoritative once the call settles: the applied hunks replace the call-time diff derived from the arguments alone. A paging window that drops the call head still renders, because the result view carries the whole change.
 
 The component shares the TUI's single-column framing, line-terminator rule, and distinct-path file count. Line classification differs: Web renders the complete old and new sides, while the TUI derives neutral context and exact changed rows when its bounded comparison completes and labels its whole-side fallback approximate.
 
@@ -46,7 +46,7 @@ The multi-file arm of `DiffBlock` (one card, several path headers) has no produc
 
 `packages/client/ui-primitives/tests/diff-block.spec.tsx` pins the component: the create arm (added-only, no removed side), the edit arm (removed above added), the same-file `⋯` gap versus a new file's own header, the empty-diffs null render, the footer counts and their singular/plural, the head/tail cap with its `aria-expanded` toggle, and the copy control asserting the prefixed diff text on both the accepted and refused clipboard paths. Per-file 100%.
 
-`packages/client/ui-conversation/tests/diff-card.spec.tsx` pins the wiring at every render site: `diffCardModel`'s derivation and each of its null arms, the result hunks replacing the call-time diff, a window-truncated call still rendering from the result, the chat row's diff body, `FileMutationRow`'s resident card and its path link opening cwd-resolved through the host, its registration under both `write` and `edit`, and the panel's Output section.
+`packages/client/ui-tool/tests/diff-card.spec.tsx` pins the wiring at every render site: `diffCardModel`'s derivation and each of its null arms, the result hunks replacing the call-time diff, a window-truncated call still rendering from the result, the chat row's diff body, `FileMutationRow`'s resident card and its path link opening cwd-resolved through the host, its registration under both `write` and `edit`, and the panel's Output section.
 
 The fixture (`packages/client/connection/src/client/fixture.ts`) carries three diff turns so a `?fixture` server and the per-package wiring suite exercise all three arms at both render sites: a single-hunk edit (turn 62, keyed `FileMutationRow`), a create/write (turn 63), and a multi-hunk edit (turn 67, the `⋯` gap between two scattered hunks in one file). The built-boot snapshot (`apps/web/tests/built-boot.snapshot.ts`) is a boot-assembly smoke that asserts only that the graph mounts and reaches chat content (`data-sample="bash-global"`); by its own contract it carries no diff-behavior assertions, which the wiring suite owns.
 
diff --git a/.agents/notes/implemented/feature/2026-07-30-web-diff-card.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.zh.md
index 669cd49abc..a9c710df99 100644
--- a/.agents/notes/implemented/feature/2026-07-30-web-diff-card.zh.md
+++ b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.zh.md
@@ -14,7 +14,7 @@ Web 客户端忽略了它。write/edit 调用落到 `GenericToolCard`,其行
 
 ## Decision
 
-`DiffBlock` 是一个 `ui-primitives` 组件,把文件改动渲染为内联 diff 表面,write/edit 调用的两个 Web 渲染点都通过它消费 diff 渲染意图:chat 工具行的行体和详情面板的 Output 区。`ui-conversation/src/client/contract/diff-card-model.ts` 是唯一把快照的 `callView`/`resultView` 对转成组件 props 的地方,因此两个渲染点不会对一次改动产生分歧。当两侧都未声明 `card: 'diff'` 时它返回 null —— 走通用路径 —— 包括本客户端版本不认识的 `card` 值,以及已结算调用的 result view 是 generic 的情况(write/edit 的执行错误正是这样留在通用路径上的)。调用结算后 result 侧是权威:已应用的 hunk 替换仅从参数推导的 call 时 diff。分页窗口丢弃了 call 头也仍能渲染,因为 result view 携带完整改动。
+`DiffBlock` 是一个 `ui-primitives` 组件,把文件改动渲染为内联 diff 表面,write/edit 调用的两个 Web 渲染点都通过它消费 diff 渲染意图:chat 工具行的行体和详情面板的 Output 区。`ui-tool/src/client/tool/models/diff-card-model.ts` 是唯一把快照的 `callView`/`resultView` 对转成组件 props 的地方,因此两个渲染点不会对一次改动产生分歧。当两侧都未声明 `card: 'diff'` 时它返回 null —— 走通用路径 —— 包括本客户端版本不认识的 `card` 值,以及已结算调用的 result view 是 generic 的情况(write/edit 的执行错误正是这样留在通用路径上的)。调用结算后 result 侧是权威:已应用的 hunk 替换仅从参数推导的 call 时 diff。分页窗口丢弃了 call 头也仍能渲染,因为 result view 携带完整改动。
 
 该组件与 TUI 共用单栏框架、行终止符规则和去重路径计数。两者的行分类不同:Web 渲染完整的变更前后两侧,而 TUI 会在有界比较完成时派生中性上下文和精确变更行,并把整侧回退标记为近似结果。
 
@@ -46,7 +46,7 @@ chat 行把 diff 常驻渲染在路径链接摘要之下,上限 `CHAT_DIFF_MAX
 
 `packages/client/ui-primitives/tests/diff-block.spec.tsx` 钉住组件:新建支路(只有新增、无删除侧)、编辑支路(删除在新增之上)、同文件 `⋯` gap 对比新文件自己的头、空 diffs 的 null 渲染、页脚计数及其单复数、头尾上限及其 `aria-expanded` 切换、以及复制控件在接受与拒绝两条剪贴板路径上断言带前缀的 diff 文本。Per-file 100%。
 
-`packages/client/ui-conversation/tests/diff-card.spec.tsx` 钉住每个渲染点的接线:`diffCardModel` 的派生及其每个 null 支路、result hunk 替换 call 时 diff、窗口截断的 call 仍从 result 渲染、chat 行的 diff 体、`FileMutationRow` 的常驻卡片及其路径链接经 host 以 cwd 解析打开、其在 `write` 与 `edit` 下的注册、以及面板的 Output 区。
+`packages/client/ui-tool/tests/diff-card.spec.tsx` 钉住每个渲染点的接线:`diffCardModel` 的派生及其每个 null 支路、result hunk 替换 call 时 diff、窗口截断的 call 仍从 result 渲染、chat 行的 diff 体、`FileMutationRow` 的常驻卡片及其路径链接经 host 以 cwd 解析打开、其在 `write` 与 `edit` 下的注册、以及面板的 Output 区。
 
 fixture(`packages/client/connection/src/client/fixture.ts`)携带三个 diff turn,使 `?fixture` 服务与 per-package 接线测试套件在两个渲染点演练全部三个支路:单 hunk 编辑(turn 62,keyed `FileMutationRow`)、新建/写入(turn 63)、多 hunk 编辑(turn 67,一个文件内两处分散 hunk 之间的 `⋯` gap)。built-boot snapshot(`apps/web/tests/built-boot.snapshot.ts`)是启动装配 smoke,只断言图挂载并抵达 chat 内容(`data-sample="bash-global"`);按其自身契约它不带 diff 行为断言,那由接线套件负责。
 
diff --git a/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.i18n.yaml
index 388eb85ef8..3415ace63b 100644
--- a/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.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-30-web-read-card-frontend.md
-2026-07-30-web-read-card-frontend.md: 06559d70c655b86a6aa70c8a9e948f4f21a1f522
-2026-07-30-web-read-card-frontend.zh.md: 92fb724658d4c23b915f61efc3b482fdf1ce7c7b
+2026-07-30-web-read-card-frontend.md: e659645066b706f35709fead4c11023eb4fe9554
+2026-07-30-web-read-card-frontend.zh.md: 4fc02e23b45399c01b5c99d388f5ae1726b026c0
diff --git a/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.md b/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.md
index 06559d70c6..e659645066 100644
--- a/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.md
+++ b/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.md
@@ -10,7 +10,7 @@ The [read backend](2026-07-30-web-read-card.md) added a fourth render-intent car
 
 ## Decision
 
-`ReadBlock` is a `ui-primitives` component that renders a read result as a line-numbered, optionally syntax-highlighted file view, and both Web render sites for a read consume the read render intent through it: the chat tool row (resident under the summary line) and the details panel's Output section. `ui-conversation/src/client/contract/read-card-model.ts` is the single place that turns the snapshot's `resultView` into the component's props, so the two sites cannot disagree.
+`ReadBlock` is a `ui-primitives` component that renders a read result as a line-numbered, optionally syntax-highlighted file view, and both Web render sites for a read consume the read render intent through it: the chat tool row (resident under the summary line) and the details panel's Output section. `ui-tool/src/client/tool/models/read-card-model.ts` is the single place that turns the snapshot's `resultView` into the component's props, so the two sites cannot disagree.
 
 **A new `ReadBlock` primitive, not an extension of `CodeBlock`.** `CodeBlock` already does shiki highlighting with a language banner and a copy control, but a read view needs a per-line gutter carrying each line's own file number, which `CodeBlock` renders as a single `
` tree with no per-line structure. Extending `CodeBlock` with an optional gutter would push a read-specific concern (windowed line numbers, a "showing N of M" note, a height cap) onto every markdown fence and every `run_code` body that shares that component. Instead `ReadBlock` reuses the part that is genuinely shared: the shiki grammar singleton in `markdown/highlight.ts`. A new `highlightLines(code, lang)` there tokenizes into shiki's own per-line token arrays (`codeToTokens`) rather than the single-`
` HTML `highlightToHtml` produces, so the block can place one gutter number per line and still color the content through the same `--shiki-*` custom properties on the same grammar allowlist. The height cap and its head/tail expand arithmetic are copied from `TerminalBlock` (`ceil(max/2)` head plus the remaining tail), so a long read and a long command output collapse at the same place. The copy control writes the window's raw text (the lines joined by newlines), never the gutter numbers or the banner.
 
@@ -42,7 +42,7 @@ A read row in the Web chat now carries the file content resident, a deliberate d
 
 `packages/client/ui-primitives/tests/read-block.spec.tsx` pins the primitive and the token path: `highlightLines`' per-line css-variables runs, its trailing-terminator-line drop and the genuinely-blank-final-line case, its `undefined` for an unknown/absent language, and its lazy path (a lazy grammar returns plain on first touch, then highlights after the import registers and the subscriber fires); and `ReadBlock`'s gutter-numbered rows keeping the file's own numbers, the highlighted-vs-plain content arms, the banner (label, language, the count note only when the read is a window), the head/tail height cap with its `aria-expanded` toggle, the copy control writing the window's raw text on both the accepted and refused clipboard paths, and the empty-window arm hiding the copy control. `code-block.spec.tsx` covers `highlightToHtml` including its lazy path over every read-card grammar (each dynamic import thunk touched once). Both `ReadBlock.tsx` and `highlight.ts` (and `CodeBlock.tsx`) hold per-file 100% coverage across the two specs.
 
-`packages/client/ui-conversation/tests/read-card.spec.tsx` pins the wiring at every render site: `readCardModel`'s derivation and each null arm (running read, no view, generic view, unknown card), the result title replacing the relativized path, the path relativization against the workspace, the copy-not-alias of the frozen line array; the resident card in `GenericToolCard`'s fallback and in the keyed `ReadRow` (plus its path link opening the host, its running/error/stopped states, and its `read`-key registration); and the panel's Output section rendering the read card at full height while keeping the JSON Input section, with the running-read placeholder and non-read flattened-pre arms. That file sits on the coverage `exclude` list (`ui-conversation/src/*`), so it is written against no gate pressure.
+`packages/client/ui-tool/tests/read-card.spec.tsx` pins the wiring at every render site: `readCardModel`'s derivation and each null arm (running read, no view, generic view, unknown card), the result title replacing the relativized path, the path relativization against the workspace, the copy-not-alias of the frozen line array; the resident card in `GenericToolCard`'s fallback and in the keyed `ReadRow` (plus its path link opening the host, its running/error/stopped states, and its `read`-key registration); and the panel's Output section rendering the read card at full height while keeping the JSON Input section, with the running-read placeholder and non-read flattened-pre arms. That file sits on the coverage `exclude` list (`ui-tool/src/*`), so it is written against no gate pressure.
 
 The fixture (`packages/client/connection/src/client/fixture.ts`) gains turn 66, a `read` call whose result view is a windowed read (lines starting at file line 41, `totalLines` 180, a `ts` hint), so the built-boot snapshot and a live `?fixture` server show the read card with its gutter numbers, highlighting, and count note. It is named `read` to exercise the keyed `ReadRow`. The turn 64 `run_code` sample's nested read sub-dispatches do not exercise the render-site fallback read card: `session.ts` folds them with `resultView: null`, so they cover only the fallback row's generic row shape, not a read card inside it; the fallback-row read card is pinned by `read-card.spec.tsx`'s `web_fetch` case. Turn 66 is ordered before the todo turn (now 67) for the same reason the terminal sample is: the standing plan retires at the next `turn/start`.
 
diff --git a/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.zh.md
index 92fb724658..4fc02e23b4 100644
--- a/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.zh.md
+++ b/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.zh.md
@@ -10,7 +10,7 @@ Status: implemented
 
 ## Decision
 
-`ReadBlock` 是一个 `ui-primitives` 组件,把一次读取结果渲染成带行号、可选语法高亮的文件视图,读取的两个 Web 渲染点都通过它消费读取渲染意图:聊天工具行(常驻在摘要行之下)与详情面板的 Output 区段。`ui-conversation/src/client/contract/read-card-model.ts` 是把快照的 `resultView` 转成组件 props 的唯一位置,因此两个渲染点不会产生分歧。
+`ReadBlock` 是一个 `ui-primitives` 组件,把一次读取结果渲染成带行号、可选语法高亮的文件视图,读取的两个 Web 渲染点都通过它消费读取渲染意图:聊天工具行(常驻在摘要行之下)与详情面板的 Output 区段。`ui-tool/src/client/tool/models/read-card-model.ts` 是把快照的 `resultView` 转成组件 props 的唯一位置,因此两个渲染点不会产生分歧。
 
 **新建一个 `ReadBlock` primitive,而不是扩展 `CodeBlock`。** `CodeBlock` 已经带语言横幅和复制控件做 shiki 高亮,但读取视图需要一个每行带该行自身文件行号的行号栏,而 `CodeBlock` 把内容渲染为单个 `
` 树、没有逐行结构。给 `CodeBlock` 加一个可选行号栏会把读取专属的关切(窗口行号、"显示 N / M"提示、高度上限)强加给共享该组件的每个 markdown 代码围栏和每个 `run_code` 程序体。`ReadBlock` 转而复用真正共享的部分:`markdown/highlight.ts` 里的 shiki 语法单例。那里新增的 `highlightLines(code, lang)` 把代码切成 shiki 自己的逐行 token 数组(`codeToTokens`),而不是 `highlightToHtml` 产出的单 `
` HTML,于是该 block 能每行放一个行号、同时用同一套 `--shiki-*` 自定义属性、同一份语法白名单给内容上色。高度上限及其头/尾展开算法照抄自 `TerminalBlock`(`ceil(max/2)` 行头部加剩余的尾部),因此长读取和长命令输出在同一处折叠。复制控件写入窗口的原始文本(各行以换行拼接),绝不含行号栏或横幅。
 
@@ -42,7 +42,7 @@ Web 聊天里的读取行现在常驻承载文件内容,是相对纯摘要行
 
 `packages/client/ui-primitives/tests/read-block.spec.tsx` 固定 primitive 与 token 路径:`highlightLines` 的逐行 css-variables 运行、它对尾部终止行的丢弃与真正空白末行的情形、它对未知/缺省语言返回 `undefined`、以及它的 lazy 路径(lazy 语法首次触碰返回纯文本,import 注册且订阅者触发后再高亮);还有 `ReadBlock` 的带行号行保留文件自身编号、高亮与纯文本两条内容分支、横幅(标签、语言、仅当读取是窗口时的计数提示)、头/尾高度上限及其 `aria-expanded` 切换、复制控件在接受与拒绝两条剪贴板路径上写入窗口原始文本、以及空窗口分支隐藏复制控件。`code-block.spec.tsx` 覆盖 `highlightToHtml`,含它对每种读取卡片语法的 lazy 路径(每个动态 import thunk 各触碰一次)。`ReadBlock.tsx`、`highlight.ts`(及 `CodeBlock.tsx`)在这两个 spec 上均保持每文件 100% 覆盖。
 
-`packages/client/ui-conversation/tests/read-card.spec.tsx` 固定每个渲染点的接线:`readCardModel` 的派生与每条 null 分支(运行中读取、无视图、通用视图、未知卡片)、结果标题替换化简后的路径、路径相对工作区的化简、冻结行数组的复制而非别名;`GenericToolCard` 回退中与 keyed `ReadRow` 中的常驻卡片(外加其路径链接打开宿主、其 running/error/stopped 状态、以及其 `read` 键注册);还有面板 Output 区段以全高渲染读取卡片同时保留 JSON Input 区段,含运行中读取占位与非读取摊平 pre 两条分支。该文件位于覆盖 `exclude` 列表(`ui-conversation/src/*`),因此不承受门槛压力。
+`packages/client/ui-tool/tests/read-card.spec.tsx` 固定每个渲染点的接线:`readCardModel` 的派生与每条 null 分支(运行中读取、无视图、通用视图、未知卡片)、结果标题替换化简后的路径、路径相对工作区的化简、冻结行数组的复制而非别名;`GenericToolCard` 回退中与 keyed `ReadRow` 中的常驻卡片(外加其路径链接打开宿主、其 running/error/stopped 状态、以及其 `read` 键注册);还有面板 Output 区段以全高渲染读取卡片同时保留 JSON Input 区段,含运行中读取占位与非读取摊平 pre 两条分支。该文件位于覆盖 `exclude` 列表(`ui-tool/src/*`),因此不承受门槛压力。
 
 fixture(`packages/client/connection/src/client/fixture.ts`)增加 turn 66,一次 `read` 调用,其结果视图是窗口读取(行号从文件行 41 起、`totalLines` 180、`ts` 提示),使内置启动快照和实时 `?fixture` 服务器展示带行号、高亮和计数提示的读取卡片。它命名为 `read` 以驱动 keyed `ReadRow`。turn 64 的 `run_code` 样例中的嵌套读取子派发并不驱动渲染点回退读取卡片:`session.ts` 把它们折叠为 `resultView: null`,因此它们只覆盖回退行的通用行形状,而非回退行内的读取卡片;回退行读取卡片由 `read-card.spec.tsx` 的 `web_fetch` 用例钉住。turn 66 排在 todo turn(现为 67)之前,与终端样例同因:常驻计划在下一次 `turn/start` 退场。
 
diff --git a/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.i18n.yaml
index aab320ac99..6f3a5d895b 100644
--- a/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.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-30-web-result-card-frontend.md
-2026-07-30-web-result-card-frontend.md: 7457f30f71e811960ecadeb49caedef276682505
-2026-07-30-web-result-card-frontend.zh.md: 5fa53c4ecbeda40bd18a3c4e59ec75bd4358662d
+2026-07-30-web-result-card-frontend.md: c7e63220d824cf0bd3ac3536c528707d0db379bc
+2026-07-30-web-result-card-frontend.zh.md: 4cec11371e8d0848f2f03ded0b093d80cc25a7a5
diff --git a/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md b/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md
index 7457f30f71..c7e63220d8 100644
--- a/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md
+++ b/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md
@@ -10,7 +10,7 @@ The `web_search` and `web_fetch` tools declare a `card: 'web'` result view ([web
 
 ## Decision
 
-`WebBlock` is a `ui-primitives` component that renders a completed web retrieval, and every Web render site for a web call consumes the `web` render intent through it: the keyed chat tool rows (`web_search`/`web_fetch`), the `GenericToolCard` render-site fallback, and the details panel's Output section. `ui-conversation/src/client/contract/web-card-model.ts` is the single place that turns the snapshot's `resultView` into the component's props, mirroring `terminal-card-model.ts`, so no two sites disagree about what a web call shows. It returns null — the generic path — for a running call (the web card is result-only, since the tools keep a generic pending view), for a settled call whose result view is not a web card including a `card` value this client version does not know (which arrives over the wire and so cannot be trusted to be a compiled variant), for a generic result view (a web tool's error path returns the generic card, whose text the generic path preserves), and for a web card whose `kind` this client version does not know (a newer host's value off the wire, which reading as a fetch would draw as an empty URL and `HTTP undefined`).
+`WebBlock` is a `ui-primitives` component that renders a completed web retrieval, and every Web render site for a web call consumes the `web` render intent through it: the keyed chat tool rows (`web_search`/`web_fetch`), the `GenericToolCard` render-site fallback, and the details panel's Output section. `ui-tool/src/client/tool/models/web-card-model.ts` is the single place that turns the snapshot's `resultView` into the component's props, mirroring `terminal-card-model.ts`, so no two sites disagree about what a web call shows. It returns null — the generic path — for a running call (the web card is result-only, since the tools keep a generic pending view), for a settled call whose result view is not a web card including a `card` value this client version does not know (which arrives over the wire and so cannot be trusted to be a compiled variant), for a generic result view (a web tool's error path returns the generic card, whose text the generic path preserves), and for a web card whose `kind` this client version does not know (a newer host's value off the wire, which reading as a fetch would draw as an empty URL and `HTTP undefined`).
 
 One component draws both kinds, discriminated by `kind`. A `search` shows the answer as markdown above a citation list; each source is a safe external link labelled by its title, or its hostname when the provider gave none, with the snippet and publication date below it, and a `来源列表已截断` indicator when the tool capped the list. A `fetch` shows a compact summary: the linked final URL, its HTTP status, and a `内容已截断` indicator. One component rather than two because both are web retrieval rendered as one card family, which is exactly the reason the contract carries them under one `card` tag with a `kind` discriminant.
 
@@ -38,7 +38,7 @@ A separate later PR unifies the whole-row collapse/expand interaction and will f
 
 `packages/client/ui-primitives/tests/web-block.spec.tsx` pins the component per-file to the 100% gate: both kinds; the title-or-hostname-or-raw-URL label fallback; the safe-link attributes on both kinds (an http(s) URL becoming an external anchor with `target`/`rel`, a `javascript:`/`file:`/unparseable URL rendering as a plain span with no href); the snippet and date shown or omitted on present/empty/absent; the truncation indicator gated on the flag; and the full source list rendering inside one scroll container with no expand control and `
  • ` numbering every source contiguously from 1. -`packages/client/ui-conversation/tests/web-card.spec.tsx` mirrors `terminal-card.spec.tsx` at every wiring seam: `webCardModel`'s derivation projecting every source field, its truncation and absent-answer arms, the fetch derivation, and each null arm (running, null result view, generic result view, unknown card tag, unknown web `kind`); the keyed `WebRow`'s resident card for both kinds, its summary-row-alone running and failed arms; the `GenericToolCard` fallback growing the resident card for a web-declaring tool and keeping the plain row for a non-web call; the details panel's Output section for both kinds — including a `web_fetch`'s body flattened below its URL/status card — and its flattened fallback for a non-web result; and the keyed registration under both `web_search` and `web_fetch` with one component. That file sits on the coverage `exclude` list (`ui-conversation/src/*`), so a coverage run measures none of it. +`packages/client/ui-tool/tests/web-card.spec.tsx` mirrors `terminal-card.spec.tsx` at every wiring seam: `webCardModel`'s derivation projecting every source field, its truncation and absent-answer arms, the fetch derivation, and each null arm (running, null result view, generic result view, unknown card tag, unknown web `kind`); the keyed `WebRow`'s resident card for both kinds, its summary-row-alone running and failed arms; the `GenericToolCard` fallback growing the resident card for a web-declaring tool and keeping the plain row for a non-web call; the details panel's Output section for both kinds — including a `web_fetch`'s body flattened below its URL/status card — and its flattened fallback for a non-web result; and the keyed registration under both `web_search` and `web_fetch` with one component. That file sits on the coverage `exclude` list (`ui-tool/src/*`), so a coverage run measures none of it. The fixture (`packages/client/connection/src/client/fixture.ts`) adds turns 66 (`web_search`) and 67 (`web_fetch`), authored inline because the client-side fixture cannot import the web tool: turn 66's result view carries an answer and three sources exercising the citation list (a titled source with a snippet and date, a source with no title so its hostname labels the link, and a source with a date but no snippet) with the capped indicator on; turn 67's carries the fetched URL and a 200 status. Both keep a generic pending call view and add the `web` card only at result time, matching the contract's result-only web shape, and are named after the real tools so they hit the keyed `WebRow`. They are ordered before the todo turn (renumbered to 68) for the same reason the terminal turn is: the standing plan retires at the next `turn/start`, so a turn appended after it would empty the dock's plan strip. This drives the built-boot snapshot and a live `?fixture` server. diff --git a/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.zh.md index 5fa53c4ecb..4cec11371e 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.zh.md @@ -10,7 +10,7 @@ Status: implemented ## Decision -`WebBlock` 是一个 `ui-primitives` 组件,渲染一次已完成的 web 检索,web 调用的每个 Web 渲染点都通过它消费 `web` 渲染意图:键控的 chat 工具行(`web_search`/`web_fetch`)、`GenericToolCard` 渲染点兜底,以及详情面板的 Output 区。`ui-conversation/src/client/contract/web-card-model.ts` 是唯一把快照的 `resultView` 转成组件 props 的地方,镜像 `terminal-card-model.ts`,因此没有两个渲染点会对一次 web 调用的显示产生分歧。它返回 null —— 走通用路径 —— 对运行中的调用(web 卡片是 result-only 的,因为工具保留 generic pending 视图)、对 result view 不是 web 卡片的已结算调用(包括本客户端版本不认识的 `card` 值,它经 wire 抵达因而不能被信任为已编译的变体)、对 generic result view(web 工具的错误路径返回 generic 卡片,其文本由通用路径保留)、以及对本客户端版本不认识 `kind` 的 web 卡片(更新的 host 经 wire 发来的值,读作 fetch 会画出空 URL 和 `HTTP undefined`)。 +`WebBlock` 是一个 `ui-primitives` 组件,渲染一次已完成的 web 检索,web 调用的每个 Web 渲染点都通过它消费 `web` 渲染意图:键控的 chat 工具行(`web_search`/`web_fetch`)、`GenericToolCard` 渲染点兜底,以及详情面板的 Output 区。`ui-tool/src/client/tool/models/web-card-model.ts` 是唯一把快照的 `resultView` 转成组件 props 的地方,镜像 `terminal-card-model.ts`,因此没有两个渲染点会对一次 web 调用的显示产生分歧。它返回 null —— 走通用路径 —— 对运行中的调用(web 卡片是 result-only 的,因为工具保留 generic pending 视图)、对 result view 不是 web 卡片的已结算调用(包括本客户端版本不认识的 `card` 值,它经 wire 抵达因而不能被信任为已编译的变体)、对 generic result view(web 工具的错误路径返回 generic 卡片,其文本由通用路径保留)、以及对本客户端版本不认识 `kind` 的 web 卡片(更新的 host 经 wire 发来的值,读作 fetch 会画出空 URL 和 `HTTP undefined`)。 一个组件绘制两种 kind,由 `kind` 判别。`search` 把 answer 作为 markdown 显示在引用列表上方;每个 source 是一个安全外链,以其标题为标签,provider 未给标题时以其主机名为标签,下方是 snippet 与发布日期,工具截断列表时显示 `来源列表已截断` 提示。`fetch` 显示一个紧凑摘要:带链接的最终 URL、其 HTTP 状态、以及 `内容已截断` 提示。用一个组件而非两个,因为两者都是渲染为同一卡片族的 web 检索 —— 这正是契约把它们放在一个 `card` 标签下、用 `kind` 判别的原因。 @@ -38,7 +38,7 @@ Status: implemented `packages/client/ui-primitives/tests/web-block.spec.tsx` 把组件钉到 per-file 100% 门槛:两种 kind;标题-或-主机名-或-原始 URL 的标签回退;两种 kind 上的安全链接属性(http(s) URL 成为带 `target`/`rel` 的外链,`javascript:`/`file:`/无法解析的 URL 渲染为无 href 的纯 span);snippet 与日期在存在/为空/缺失时的显示或省略;由标志位控制的截断提示;以及完整 source 列表渲染在单个滚动容器内、无展开控件、`
  • ` 从 1 起为每条 source 连续编号。 -`packages/client/ui-conversation/tests/web-card.spec.tsx` 在每个接线接缝镜像 `terminal-card.spec.tsx`:`webCardModel` 的派生投影每个 source 字段、其截断与缺失 answer 的支路、fetch 派生、以及每个 null 支路(运行中、null result view、generic result view、未知 card 标签、未知 web `kind`);键控 `WebRow` 对两种 kind 的常驻卡片、其仅摘要行的运行中与失败支路;`GenericToolCard` 兜底为 web 声明工具长出常驻卡片、并为非 web 调用保持纯行;详情面板 Output 区对两种 kind —— 含 `web_fetch` 正文摊平在其 URL/状态卡片下方 —— 及其对非 web 结果的摊平回退;以及在 `web_search` 与 `web_fetch` 两键下用一个组件的键控注册。该文件位于覆盖率 `exclude` 列表(`ui-conversation/src/*`),因此覆盖率运行不度量它。 +`packages/client/ui-tool/tests/web-card.spec.tsx` 在每个接线接缝镜像 `terminal-card.spec.tsx`:`webCardModel` 的派生投影每个 source 字段、其截断与缺失 answer 的支路、fetch 派生、以及每个 null 支路(运行中、null result view、generic result view、未知 card 标签、未知 web `kind`);键控 `WebRow` 对两种 kind 的常驻卡片、其仅摘要行的运行中与失败支路;`GenericToolCard` 兜底为 web 声明工具长出常驻卡片、并为非 web 调用保持纯行;详情面板 Output 区对两种 kind —— 含 `web_fetch` 正文摊平在其 URL/状态卡片下方 —— 及其对非 web 结果的摊平回退;以及在 `web_search` 与 `web_fetch` 两键下用一个组件的键控注册。该文件位于覆盖率 `exclude` 列表(`ui-tool/src/*`),因此覆盖率运行不度量它。 fixture(`packages/client/connection/src/client/fixture.ts`)添加 turn 66(`web_search`)与 67(`web_fetch`),内联撰写,因为客户端 fixture 无法 import web 工具:turn 66 的 result view 携带一个 answer 与三个 source,演练引用列表(一个带 snippet 与日期的有标题 source、一个无标题因而以主机名标注链接的 source、一个有日期无 snippet 的 source)并开启截断提示;turn 67 携带抓取的 URL 与一个 200 状态。两者都保留 generic pending call view,仅在 result 时添加 `web` 卡片,匹配契约的 result-only web 形状,且以真实工具命名,使其命中键控 `WebRow`。它们被排在 todo turn(重编号为 68)之前,理由与终端 turn 相同:待定计划在下一个 `turn/start` 退休,所以排在其后的 turn 会清空 dock 的 plan strip。这驱动 built-boot snapshot 与一个实时 `?fixture` 服务。 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-search-card.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-search-card.i18n.yaml index 2d2b338b6f..0cfb6b01bd 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-search-card.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-web-search-card.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-30-web-search-card.md -2026-07-30-web-search-card.md: 4c7ae6c8c658f4f10f0667b12853cb2e70df15b1 -2026-07-30-web-search-card.zh.md: b129d411b9b402b18d6b4ec94dad544effd3bb8c +2026-07-30-web-search-card.md: e0be857df69dfd46b6e936c775c6bae1476c26dc +2026-07-30-web-search-card.zh.md: 1704b8f04c519511d5afa32ae6683c1e48ce7992 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-search-card.md b/.agents/notes/implemented/feature/2026-07-30-web-search-card.md index 4c7ae6c8c6..e0be857df6 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-search-card.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-search-card.md @@ -12,7 +12,7 @@ This is the follow-up the search render card note names: that PR was the backend ## Decision -`SearchBlock` is a `ui-primitives` component that renders a completed search as either shape, and the Web render sites for a `grep`/`glob` call consume the search render intent through it. `ui-conversation/src/client/contract/search-card-model.ts` is the single place that turns the snapshot's `resultView` into the component's props, so no render site re-derives the shape. It returns null — the generic path — whenever the result view is not a search card, including a still-running call (a search card is result-time only, so there is nothing before `execute`), a generic result a `grep`/`glob` failure or a nested `run_code` dispatch produces, a terminal result view, a `card` value this client version does not know, a `card: 'search'` view whose `shape` this version does not compile, and — because `shape` and the grouped/flat contents ride the same untrusted wire frame the host schema only string-checks — a known `shape` whose `files`/`paths` is missing or malformed (which would otherwise crash `SearchBlock` at `.reduce`/`.map`). The result-view discriminant is `shape` (not `kind`, which the backend reserves for the call view's icon-picking tag); `SearchBlock`'s own prop stays `kind`, mapped from `shape` in this derivation. +`SearchBlock` is a `ui-primitives` component that renders a completed search as either shape, and the Web render sites for a `grep`/`glob` call consume the search render intent through it. `ui-tool/src/client/tool/models/search-card-model.ts` is the single place that turns the snapshot's `resultView` into the component's props, so no render site re-derives the shape. It returns null — the generic path — whenever the result view is not a search card, including a still-running call (a search card is result-time only, so there is nothing before `execute`), a generic result a `grep`/`glob` failure or a nested `run_code` dispatch produces, a terminal result view, a `card` value this client version does not know, a `card: 'search'` view whose `shape` this version does not compile, and — because `shape` and the grouped/flat contents ride the same untrusted wire frame the host schema only string-checks — a known `shape` whose `files`/`paths` is missing or malformed (which would otherwise crash `SearchBlock` at `.reduce`/`.map`). The result-view discriminant is `shape` (not `kind`, which the backend reserves for the call view's icon-picking tag); `SearchBlock`'s own prop stays `kind`, mapped from `shape` in this derivation. The asymmetry with the terminal card is deliberate and inherited from the backend contract: `terminalCardModel` reads both `callView` and `resultView` because a command, cwd, and description exist at call time; `searchCardModel` reads only `resultView` because a search's matches or paths exist only after execution. A running search row therefore shows its summary alone, with no card. @@ -34,7 +34,7 @@ Geometry, radius, and fonts mirror `CodeBlock` and `TerminalBlock`, so a search Three sites consume the derivation, mirroring the terminal card's placement exactly: -- **The keyed `SearchRow`** (`toolviews/search-row.tsx`) registers ONE component under both `grep` and `glob` in the `conversation.chat.toolview` keyed hole, and renders the card RESIDENT under the summary row, capped at `CHAT_SEARCH_MAX_LINES` (8) — the same posture `BashRow` takes for its terminal card. Both tool names get the same row because the derived `kind` decides the shape, so a second component would duplicate it. A capped result's recovery footer sits below the card. Because the keyed row owns this render slot, a settled call with no search card — an errored search (grep/glob emit no result view on error), a successful nested `run_code` sub-dispatch (the backend computes no `presentationMeta`, so `resultView` is null), or a legacy generic result — would otherwise show only its summary with its content lost; the row surfaces that model-facing text as a fallback body, keyed on `search === null && settled` rather than on the error state alone. (This resident posture matches the current terminal/diff cards; a separate later PR unifies the whole-row collapse/expand interaction and flips all resident cards at once — out of scope here.) +- **The keyed `SearchRow`** (`toolviews/search-row.tsx`) registers ONE component under both `grep` and `glob` in the `tool.call.toolview` keyed hole, and renders the card RESIDENT under the summary row, capped at `CHAT_SEARCH_MAX_LINES` (8) — the same posture `BashRow` takes for its terminal card. Both tool names get the same row because the derived `kind` decides the shape, so a second component would duplicate it. A capped result's recovery footer sits below the card. Because the keyed row owns this render slot, a settled call with no search card — an errored search (grep/glob emit no result view on error), a successful nested `run_code` sub-dispatch (the backend computes no `presentationMeta`, so `resultView` is null), or a legacy generic result — would otherwise show only its summary with its content lost; the row surfaces that model-facing text as a fallback body, keyed on `search === null && settled` rather than on the error state alone. (This resident posture matches the current terminal/diff cards; a separate later PR unifies the whole-row collapse/expand interaction and flips all resident cards at once — out of scope here.) - **The generic fallback** (`chat/GenericToolCard` → `chat/ToolRow`) threads the derived model as an expand-gated body, the same arm `terminal` uses: a `grep`/`glob` result with no keyed row (none in the shipped app, since both are registered) still renders its card, with the recovery footer, behind the row's expand toggle. - **The details panel** (`skeleton/DetailsPanel`) renders the card at the primitive's own full height in the Output section, with the recovery footer below it, keeping the JSON Input section. @@ -56,7 +56,7 @@ Three sites consume the derivation, mirroring the terminal card's placement exac `packages/client/ui-primitives/tests/search-block.spec.tsx` pins the component at per-file 100%: both kinds, the folded pre-cap total in the summary, the empty arm, per-file collapse/re-expand without touching neighbours, a file header counting as one capped row alongside its matches, the tail slice restoring its owning file header when the cut falls mid-file, the head/tail cap and its expand control across both shapes and the no-tail and default-cap edges, and the copy control writing the whole structured result on the accepted and refused clipboard paths. -`packages/client/ui-conversation/tests/search-card.spec.tsx` pins the wiring at every render site: `searchCardModel`'s derivation for both kinds, the truncation signal, the replacement title, the recovery text surfaced only when capped, each null arm (running, no views, generic, terminal, unknown card, an uncompiled `kind`, and a known kind with a missing/malformed shape); the chat row's expand-gated matches and paths bodies through `GenericToolCard` (with the recovery footer) against the non-search args-JSON body; `SearchRow`'s resident card for both kinds, its recovery footer, its fallback body for both an errored search and a settled cardless result, its agreement with the summary row's run state, the replacement-title precedence, and the keyed registration under both `grep` and `glob` with one component; and the details panel's Output section for both kinds (with the recovery footer) against the non-search flattened form. `packages/client/ui-conversation/src/*` sits on the coverage exclude list, so this file is written against no gate pressure. `packages/client/connection/src/client/fixture.ts` gains a `grep` turn emitting `kind: 'matches'` (three files, twelve rows over the row cap, `truncated` with a spill-recovery footer, so it exercises the head/tail cap and the recovery footer in the assembled snapshot) and a `glob` turn emitting `kind: 'paths'`, both driving the built-boot snapshot and the live `?fixture` server. `apps/web/tests/search-card.snapshot.ts` is the assembled-output check the repo contract asks for: it boots the real built `client.js` bundles through the keyless fixture transport, opens the fixture session, and pins the grep card's assembled shape — kind, truncation summary, the head/tail slice, and its expand control — under `apps/web/tests/snapshots/search-card/`, so a broken SearchRow registration or a dropped card fails a golden the built-boot smoke (boot-only by contract) cannot. +`packages/client/ui-tool/tests/search-card.spec.tsx` pins the wiring at every render site: `searchCardModel`'s derivation for both kinds, the truncation signal, the replacement title, the recovery text surfaced only when capped, each null arm (running, no views, generic, terminal, unknown card, an uncompiled `kind`, and a known kind with a missing/malformed shape); the chat row's expand-gated matches and paths bodies through `GenericToolCard` (with the recovery footer) against the non-search args-JSON body; `SearchRow`'s resident card for both kinds, its recovery footer, its fallback body for both an errored search and a settled cardless result, its agreement with the summary row's run state, the replacement-title precedence, and the keyed registration under both `grep` and `glob` with one component; and the details panel's Output section for both kinds (with the recovery footer) against the non-search flattened form. `packages/client/ui-tool/src/*` sits on the coverage exclude list, so this file is written against no gate pressure. `packages/client/connection/src/client/fixture.ts` gains a `grep` turn emitting `kind: 'matches'` (three files, twelve rows over the row cap, `truncated` with a spill-recovery footer, so it exercises the head/tail cap and the recovery footer in the assembled snapshot) and a `glob` turn emitting `kind: 'paths'`, both driving the built-boot snapshot and the live `?fixture` server. `apps/web/tests/search-card.snapshot.ts` is the assembled-output check the repo contract asks for: it boots the real built `client.js` bundles through the keyless fixture transport, opens the fixture session, and pins the grep card's assembled shape — kind, truncation summary, the head/tail slice, and its expand control — under `apps/web/tests/snapshots/search-card/`, so a broken SearchRow registration or a dropped card fails a golden the built-boot smoke (boot-only by contract) cannot. ## Related diff --git a/.agents/notes/implemented/feature/2026-07-30-web-search-card.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-search-card.zh.md index b129d411b9..1704b8f04c 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-search-card.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-search-card.zh.md @@ -12,7 +12,7 @@ Status: implemented ## Decision -`SearchBlock` 是一个 `ui-primitives` 组件,把一次已完成的搜索渲染成两种形态之一,`grep`/`glob` 调用的 Web 渲染点都通过它消费搜索 render intent。`ui-conversation/src/client/contract/search-card-model.ts` 是把 snapshot 的 `resultView` 转成组件 props 的唯一位置,因此没有渲染点重新推导形态。当结果视图不是搜索卡片时它返回 null(走 generic 路径),包括仍在运行的调用(搜索卡片仅在结果阶段存在,`execute` 前无内容)、`grep`/`glob` 失败或嵌套 `run_code` dispatch 产生的 generic 结果、terminal 结果视图、本客户端版本不认识的 `card` 值、`shape` 是本版本无法编译的 `card: 'search'` 视图,以及 —— 因为 `shape` 和分组/扁平内容与 host schema 只做字符串校验的那同一个不可信 wire 帧同行 —— 一个 `shape` 已知但 `files`/`paths` 缺失或格式错误的视图(否则会让 `SearchBlock` 在 `.reduce`/`.map` 处崩溃)。结果视图的判别键是 `shape`(不是 `kind` —— 后端把 `kind` 留给 call view 的选图标签);`SearchBlock` 自身的 prop 仍是 `kind`,由本推导从 `shape` 映射得到。 +`SearchBlock` 是一个 `ui-primitives` 组件,把一次已完成的搜索渲染成两种形态之一,`grep`/`glob` 调用的 Web 渲染点都通过它消费搜索 render intent。`ui-tool/src/client/tool/models/search-card-model.ts` 是把 snapshot 的 `resultView` 转成组件 props 的唯一位置,因此没有渲染点重新推导形态。当结果视图不是搜索卡片时它返回 null(走 generic 路径),包括仍在运行的调用(搜索卡片仅在结果阶段存在,`execute` 前无内容)、`grep`/`glob` 失败或嵌套 `run_code` dispatch 产生的 generic 结果、terminal 结果视图、本客户端版本不认识的 `card` 值、`shape` 是本版本无法编译的 `card: 'search'` 视图,以及 —— 因为 `shape` 和分组/扁平内容与 host schema 只做字符串校验的那同一个不可信 wire 帧同行 —— 一个 `shape` 已知但 `files`/`paths` 缺失或格式错误的视图(否则会让 `SearchBlock` 在 `.reduce`/`.map` 处崩溃)。结果视图的判别键是 `shape`(不是 `kind` —— 后端把 `kind` 留给 call view 的选图标签);`SearchBlock` 自身的 prop 仍是 `kind`,由本推导从 `shape` 映射得到。 与终端卡片的不对称是刻意的,继承自后端契约:`terminalCardModel` 同时读 `callView` 和 `resultView`,因为命令、cwd、description 在调用时就存在;`searchCardModel` 只读 `resultView`,因为搜索的匹配或路径只在执行后存在。因此运行中的搜索行只显示摘要,没有卡片。 @@ -34,7 +34,7 @@ Status: implemented 三个渲染点消费该推导,与终端卡片的落位完全一致: -- **keyed `SearchRow`**(`toolviews/search-row.tsx`)把一个组件同时注册到 `conversation.chat.toolview` keyed hole 的 `grep` 与 `glob` 键下,并把卡片作为常驻(resident)渲染在摘要行下方,上限为 `CHAT_SEARCH_MAX_LINES`(8)—— 与 `BashRow` 对其终端卡片采取的姿态相同。两个工具名共用同一行,因为推导出的 `kind` 决定形态,第二个组件只会重复它。被截断结果的恢复脚注画在卡片下方。因为 keyed 行占据了这个渲染槽,一个没有搜索卡片的已结算调用 —— 出错的搜索(grep/glob 出错时不产出结果视图)、成功的嵌套 `run_code` 子派发(后端不为其计算 `presentationMeta`,故 `resultView` 为 null)、或旧日志的 generic 结果 —— 否则只会显示摘要而丢失内容;该行把这段面向模型的文本作为 fallback body 暴露出来,判据是 `search === null && 已结算`,而非仅凭错误状态。(该常驻姿态与当前的 terminal/diff 卡片一致;一个单独的后续 PR 会统一整行折叠/展开交互并一次性翻转所有常驻卡片 —— 不在本 PR 范围内。) +- **keyed `SearchRow`**(`toolviews/search-row.tsx`)把一个组件同时注册到 `tool.call.toolview` keyed hole 的 `grep` 与 `glob` 键下,并把卡片作为常驻(resident)渲染在摘要行下方,上限为 `CHAT_SEARCH_MAX_LINES`(8)—— 与 `BashRow` 对其终端卡片采取的姿态相同。两个工具名共用同一行,因为推导出的 `kind` 决定形态,第二个组件只会重复它。被截断结果的恢复脚注画在卡片下方。因为 keyed 行占据了这个渲染槽,一个没有搜索卡片的已结算调用 —— 出错的搜索(grep/glob 出错时不产出结果视图)、成功的嵌套 `run_code` 子派发(后端不为其计算 `presentationMeta`,故 `resultView` 为 null)、或旧日志的 generic 结果 —— 否则只会显示摘要而丢失内容;该行把这段面向模型的文本作为 fallback body 暴露出来,判据是 `search === null && 已结算`,而非仅凭错误状态。(该常驻姿态与当前的 terminal/diff 卡片一致;一个单独的后续 PR 会统一整行折叠/展开交互并一次性翻转所有常驻卡片 —— 不在本 PR 范围内。) - **generic fallback**(`chat/GenericToolCard` → `chat/ToolRow`)把推导出的 model 作为展开门控的 body 传入,与 `terminal` 用的是同一分支:没有 keyed 行的 `grep`/`glob` 结果(发布应用里没有,因为两者都注册了)仍在行的展开开关后渲染其卡片,并带恢复脚注。 - **details panel**(`skeleton/DetailsPanel`)在 Output 段以 primitive 自身的完整高度渲染卡片,恢复脚注画在其下方,保留 JSON Input 段。 @@ -56,7 +56,7 @@ Status: implemented `packages/client/ui-primitives/tests/search-block.spec.tsx` 以 per-file 100% 覆盖固定组件:两种 kind、折入摘要的截断前总数、空结果分支、逐文件折叠/再展开且不影响邻居、一个文件头与其匹配一起计为一个被截断行、切口落在文件中间时尾部切片恢复其所属文件头、跨两种形态的头/尾上限及其展开控件(含无尾与默认上限的边界),以及复制控件在接受与拒绝的剪贴板路径上写入整个结构化结果。 -`packages/client/ui-conversation/tests/search-card.spec.tsx` 固定每个渲染点的接线:`searchCardModel` 对两种 kind 的推导、截断信号、替换标题、仅在截断时暴露的恢复文本,以及每个 null 分支(运行中、无视图、generic、terminal、未知卡片、本版本无法编译的 `kind`、以及一个形态缺失/错误的已知 kind);通过 `GenericToolCard` 的展开门控 matches 与 paths body(含恢复脚注),对照非搜索的 args-JSON body;`SearchRow` 对两种 kind 的常驻卡片、它的恢复脚注、它对出错搜索与已结算无卡片结果两者的 fallback body、它与摘要行运行状态的一致、替换标题优先级,以及一个组件在 `grep` 与 `glob` 两个键下的 keyed 注册;以及 details panel 的 Output 段对两种 kind(含恢复脚注),对照非搜索的压平形态。`packages/client/ui-conversation/src/*` 在覆盖排除清单上,因此该文件不受 gate 压力。`packages/client/connection/src/client/fixture.ts` 新增一个发出 `kind: 'matches'` 的 `grep` turn(三个文件、十二行超过行内上限、`truncated` 且带溢出恢复脚注,因此在组装快照里同时演练头/尾上限与恢复脚注)与一个发出 `kind: 'paths'` 的 `glob` turn,两者都驱动 built-boot snapshot 与实时 `?fixture` 服务。`apps/web/tests/search-card.snapshot.ts` 是仓库契约要求的组装输出检查:它通过 keyless fixture 传输启动真实构建的 `client.js` bundle,打开 fixture 会话,并把 grep 卡片的组装形态——kind、截断摘要、头/尾切片及其展开控件——固定在 `apps/web/tests/snapshots/search-card/` 下,因此一个损坏的 SearchRow 注册或被丢弃的卡片会让一个 golden 失败,而 built-boot smoke(按契约只测启动)无法捕获它。 +`packages/client/ui-tool/tests/search-card.spec.tsx` 固定每个渲染点的接线:`searchCardModel` 对两种 kind 的推导、截断信号、替换标题、仅在截断时暴露的恢复文本,以及每个 null 分支(运行中、无视图、generic、terminal、未知卡片、本版本无法编译的 `kind`、以及一个形态缺失/错误的已知 kind);通过 `GenericToolCard` 的展开门控 matches 与 paths body(含恢复脚注),对照非搜索的 args-JSON body;`SearchRow` 对两种 kind 的常驻卡片、它的恢复脚注、它对出错搜索与已结算无卡片结果两者的 fallback body、它与摘要行运行状态的一致、替换标题优先级,以及一个组件在 `grep` 与 `glob` 两个键下的 keyed 注册;以及 details panel 的 Output 段对两种 kind(含恢复脚注),对照非搜索的压平形态。`packages/client/ui-tool/src/*` 在覆盖排除清单上,因此该文件不受 gate 压力。`packages/client/connection/src/client/fixture.ts` 新增一个发出 `kind: 'matches'` 的 `grep` turn(三个文件、十二行超过行内上限、`truncated` 且带溢出恢复脚注,因此在组装快照里同时演练头/尾上限与恢复脚注)与一个发出 `kind: 'paths'` 的 `glob` turn,两者都驱动 built-boot snapshot 与实时 `?fixture` 服务。`apps/web/tests/search-card.snapshot.ts` 是仓库契约要求的组装输出检查:它通过 keyless fixture 传输启动真实构建的 `client.js` bundle,打开 fixture 会话,并把 grep 卡片的组装形态——kind、截断摘要、头/尾切片及其展开控件——固定在 `apps/web/tests/snapshots/search-card/` 下,因此一个损坏的 SearchRow 注册或被丢弃的卡片会让一个 golden 失败,而 built-boot smoke(按契约只测启动)无法捕获它。 ## Related diff --git a/.agents/notes/implemented/feature/2026-07-30-web-tool-row-unified-expand-and-inspect.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-tool-row-unified-expand-and-inspect.i18n.yaml index a2710c73f9..255dd08832 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-tool-row-unified-expand-and-inspect.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-web-tool-row-unified-expand-and-inspect.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-30-web-tool-row-unified-expand-and-inspect.md -2026-07-30-web-tool-row-unified-expand-and-inspect.md: 98f1595564f0bd0d22f1ca4318b4c7fe15c6900d -2026-07-30-web-tool-row-unified-expand-and-inspect.zh.md: 8f00349a975f777cc4d556ade3a9abe9676b8848 +2026-07-30-web-tool-row-unified-expand-and-inspect.md: 4fcf1e9a567b73cd04f1bbed170c391aaedd9e73 +2026-07-30-web-tool-row-unified-expand-and-inspect.zh.md: 92d70f5aa6bd09e6f9e54015dafc38d8d3690a9e diff --git a/.agents/notes/implemented/feature/2026-07-30-web-tool-row-unified-expand-and-inspect.md b/.agents/notes/implemented/feature/2026-07-30-web-tool-row-unified-expand-and-inspect.md index 98f1595564..4fcf1e9a56 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-tool-row-unified-expand-and-inspect.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-tool-row-unified-expand-and-inspect.md @@ -16,7 +16,7 @@ The chat view's tool rows had drifted into per-surface interaction dialects: Too - The expanded card (figma 1249:35657) is a column of IN/OUT sections: each section is its own scrollport (max-height 150px) with a sticky gutter label, and the l2 divider spans the full card width. Think prose and the run_code CodeBlock keep their non-card bodies; context injection reuses the row with a label-less `plainBody` card. - `terminalFailed` reads a settled terminal card's exit status so BashRow and GenericToolCard surface a failing command as the row's red state dot — the only failure signal the collapsed row has, since the call itself settles `isError:false`. - TerminalBlock's banner joins the same reading model: it shares the card surface (no banner token), an l2 hairline separates it from the body, the command column caps at 150px and scrolls with sticky copy/status controls top-aligned to the first prompt row. -- Inspect: `ToolRowOwnerProps.inspect` (absent for rows without a call identity) renders a pill in real flow under the expanded body's bottom-left, revealed by hovering anywhere on the tool call. Clicking writes `{ callId }` to the chat store's one-shot `inspect` field and switches to the trajectory view; TrajectoryTable finds the record, opens its summary, and acknowledges by clearing the field. +- Inspect: `ToolCallOwnerProps.inspect` (absent for rows without a call identity) renders a pill in real flow under the expanded body's bottom-left, revealed by hovering anywhere on the tool call. Clicking writes `{ callId }` to the chat store's one-shot `inspect` field and switches to the trajectory view; TrajectoryTable finds the record, opens its summary, and acknowledges by clearing the field. - Scroll preservation: on every non-bottom scroll, the chat view saves `{ anchorKey, anchorTop, scrollTop }` into an apply-scope per-session map exposed as `chatScroll`; a remount first uses `scrollTop` to reach the approximate window, then corrects by the stable node/call anchor's rectangle delta so width reflow keeps the same reading row in place. Every pinned path, including Back to bottom, clears the entry synchronously before a tab or session switch. The map remains deliberately unpersisted — a fresh page load keeps the open-jump-to-bottom default. ## Alternatives considered @@ -31,4 +31,4 @@ The chat view's tool rows had drifted into per-surface interaction dialects: Too ## Consequences -Any registered toolview gets input AND output inspection in place, with the details panel and trajectory remaining the deep-dive surfaces. The unified interaction is contract-visible (`ToolRowProps.output/errorSummary/inspect`), so third-party rows opt in by passing model fields through. The bash sample intentionally re-replicates the new CSS (registrant posture), so future interaction changes still touch it by hand. `--dsw-font-markdown-code-block-small` (12/18) is a hand-added token pending a design-platform export. The web-cordis `distIndex` fix (plain concatenation, not URL.pathname) unblocks preview boots from a cwd with spaces. +Built-in ui-tool views get input and output inspection in place, with the details panel and trajectory remaining the deep-dive surfaces. The shared `ToolRow` interaction is internal to ui-tool; an external atomic view receives `ToolCallViewProps` and may expose the supplied `inspect` callback through its own chrome. The bash view keeps its separate CSS, so future interaction changes still touch it explicitly. `--dsw-font-markdown-code-block-small` (12/18) is a hand-added token pending a design-platform export. The web-cordis `distIndex` fix (plain concatenation, not URL.pathname) unblocks preview boots from a cwd with spaces. diff --git a/.agents/notes/implemented/feature/2026-07-30-web-tool-row-unified-expand-and-inspect.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-tool-row-unified-expand-and-inspect.zh.md index 8f00349a97..92d70f5aa6 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-tool-row-unified-expand-and-inspect.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-tool-row-unified-expand-and-inspect.zh.md @@ -16,7 +16,7 @@ - 展开卡片(figma 1249:35657)是 IN/OUT 分区列:每个分区是独立滚动区(max-height 150px),侧栏标签 sticky 固定,l2 分割线横贯整卡宽度。Think 的推理文本和 run_code 的 CodeBlock 保持非卡片体;上下文注入复用此行并以无标签的 `plainBody` 卡片展开。 - `terminalFailed` 读取已结算 terminal 卡片的退出状态,让 BashRow 和 GenericToolCard 把失败命令显示为行的红色状态点——这是折叠行唯一的失败信号,因为调用本身结算为 `isError:false`。 - TerminalBlock 的横幅并入同一阅读模型:与卡片共用同一表面(不再用 banner token),与正文之间是 l2 细线,命令列上限 150px 内部滚动,复制/状态控件 sticky 且顶对齐第一行提示符。 -- Inspect:`ToolRowOwnerProps.inspect`(无调用身份的行不提供)在展开体左下角以真实布局位置渲染胶囊,hover 整个 tool call 任意位置显示。点击将 `{ callId }` 写入 chat store 的一次性 `inspect` 字段并切换到 trajectory 视图;TrajectoryTable 找到记录、打开其摘要,并通过清空字段确认。 +- Inspect:`ToolCallOwnerProps.inspect`(无调用身份的行不提供)在展开体左下角以真实布局位置渲染胶囊,hover 整个 tool call 任意位置显示。点击将 `{ callId }` 写入 chat store 的一次性 `inspect` 字段并切换到 trajectory 视图;TrajectoryTable 找到记录、打开其摘要,并通过清空字段确认。 - 滚动保留:每次非贴底滚动时,聊天视图把 `{ anchorKey, anchorTop, scrollTop }` 保存到 apply 作用域的按会话 Map,并经注入 props 的 `chatScroll` 暴露;重挂载时先用 `scrollTop` 到达近似窗口,再按稳定 node/call 锚点的矩形差值校正,因此宽度重排后仍把同一阅读行保持在原位。包括「回到底部」在内的每条贴底路径都会在切换 tab 或会话前同步清除该项。Map 仍刻意不持久化——新页面加载保持打开即贴底的默认行为。 ## 曾考虑的替代方案 @@ -31,4 +31,4 @@ ## 后果 -任何已注册 toolview 都能就地查看输入与输出,详情面板和 trajectory 仍是深查表面。统一交互契约可见(`ToolRowProps.output/errorSummary/inspect`),第三方行透传模型字段即可接入。bash 示例有意重新复刻新 CSS(注册方姿态),未来交互变更仍需手动同步它。`--dsw-font-markdown-code-block-small`(12/18)是手工补充的 token,待设计平台导出后替换。web-cordis 的 `distIndex` 修复(纯拼接而非 URL.pathname)解除了含空格 cwd 下预览无法启动的问题。 +ui-tool 内置视图都能就地检查输入与输出,详情面板和 trajectory 仍是深查界面。共享 `ToolRow` 交互是 ui-tool 内部实现;外部原子视图接收 `ToolCallViewProps`,可以通过自己的 chrome 暴露其中的 `inspect` 回调。bash 视图保留独立 CSS,因此未来交互变化仍需显式同步。`--dsw-font-markdown-code-block-small`(12/18)是手工补充的 token,待设计平台导出后替换。web-cordis 的 `distIndex` 修复(纯拼接而非 URL.pathname)解除了含空格 cwd 下预览无法启动的问题。 diff --git a/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.i18n.yaml b/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.i18n.yaml index f4f0ef3891..c12de68dc8 100644 --- a/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md -2026-08-02-web-thinking-tail-scroll.md: c45840731153627b4ce460ee140257ba33d2c007 -2026-08-02-web-thinking-tail-scroll.zh.md: b8d0444d62294e123bec1d26cb4c07538bbf966f +2026-08-02-web-thinking-tail-scroll.md: 18e94b2e0075bf7099b9b48177de2e274896942a +2026-08-02-web-thinking-tail-scroll.zh.md: 9b1428af7d3ab3c25509696619de75adc1cd7b7f diff --git a/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md b/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md index c458407311..18e94b2e00 100644 --- a/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md +++ b/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md @@ -28,4 +28,4 @@ The collapsed row now communicates provider cadence through content motion as we ## Testing -`packages/client/ui-conversation/tests/chat-tool-row.spec.tsx` pins the latest-line selection, the calculated right-edge scroll position, and the settlement reset to the first line and `scrollLeft = 0`. The keyless assembled Chromium scenario in `apps/web/tests/lifecycle-chrome.e2e.ts` replays real recorded reasoning chunks at observable pacing, narrows the viewport until the summary overflows, and asserts that the live collapsed Think row reaches its actual browser scroll extent. Its settled replay golden remains unchanged, proving the historical summary contract stays stable. +`packages/client/ui-conversation/tests/reasoning-row.spec.tsx` pins the latest-line selection, the calculated right-edge scroll position, and the settlement reset to the first line and `scrollLeft = 0`. The keyless assembled Chromium scenario in `apps/web/tests/lifecycle-chrome.e2e.ts` replays real recorded reasoning chunks at observable pacing, narrows the viewport until the summary overflows, and asserts that the live collapsed Think row reaches its actual browser scroll extent. Its settled replay golden remains unchanged, proving the historical summary contract stays stable. diff --git a/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.zh.md b/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.zh.md index b8d0444d62..9b1428af7d 100644 --- a/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.zh.md +++ b/.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.zh.md @@ -28,4 +28,4 @@ Web Think 行在结算与流式 block 中都把 reasoning 首行渲染成折叠 ## 测试 -`packages/client/ui-conversation/tests/chat-tool-row.spec.tsx` 固定最新行选择、算出的右端滚动位置,以及结算后恢复首行和 `scrollLeft = 0`。`apps/web/tests/lifecycle-chrome.e2e.ts` 中的 keyless 完整 Chromium 场景以可观察节奏回放真实录制的 reasoning chunks,把视口收窄到摘要溢出,并断言实时折叠 Think 行到达真实浏览器的滚动边界。其结算态 replay golden 保持不变,证明历史摘要契约仍然稳定。 +`packages/client/ui-conversation/tests/reasoning-row.spec.tsx` 固定最新行选择、算出的右端滚动位置,以及结算后恢复首行和 `scrollLeft = 0`。`apps/web/tests/lifecycle-chrome.e2e.ts` 中的 keyless 完整 Chromium 场景以可观察节奏回放真实录制的 reasoning chunks,把视口收窄到摘要溢出,并断言实时折叠 Think 行到达真实浏览器的滚动边界。其结算态 replay golden 保持不变,证明历史摘要契约仍然稳定。 diff --git a/.agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.i18n.yaml b/.agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.i18n.yaml index 80f7ed4ab4..2ba0af23c4 100644 --- a/.agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.md -2026-08-03-web-search-source-scroll.md: c11bb2317b6ae6cad8017a4b76cb0b9ccebd6fc0 -2026-08-03-web-search-source-scroll.zh.md: add012216589b33cf244d8a14053a5b6d60631a6 +2026-08-03-web-search-source-scroll.md: 3215302b2f6c7a4e6d6cd7440a0c7f5048b72a80 +2026-08-03-web-search-source-scroll.zh.md: cf75a5e34632872d09a13e1667ecac5f01428e18 diff --git a/.agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.md b/.agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.md index c11bb2317b..3215302b2f 100644 --- a/.agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.md +++ b/.agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.md @@ -36,7 +36,7 @@ Every source the tool returned is always in the DOM, so no source the view carri ## Testing -`packages/client/ui-primitives/tests/web-block.spec.tsx` drops the collapse cases (head/tail slice, expand-on-click, collapsed-tail numbering, expander-out-of-numbering, head-alone, default cap) and adds: a 30-source card renders all 30 `
  • ` with no `[aria-expanded]` and no ` ) : ( {summaryText} @@ -285,38 +260,36 @@ export function ToolRow({ ) : webBody !== null ? - : isThink - ?
    {body}
    - : ( - <> - {variant === 'code' && body !== null && ( -
    - -
    - )} - {(cardBody !== null || outputText !== null) && ( -
    - {cardBody !== null && ( -
    - IN - {cardBody} -
    - )} - {cardBody !== null && outputText !== null && ( - - )} - {outputText !== null && ( -
    - OUT - - {outputText} - -
    - )} -
    - )} - - )} + : ( + <> + {variant === 'code' && body !== null && ( +
    + +
    + )} + {(cardBody !== null || outputText !== null) && ( +
    + {cardBody !== null && ( +
    + IN + {cardBody} +
    + )} + {cardBody !== null && outputText !== null && ( + + )} + {outputText !== null && ( +
    + OUT + + {outputText} + +
    + )} +
    + )} + + )} {inspect !== undefined && ( )) const view = b.runtime.renderRoot() @@ -191,7 +193,7 @@ describe('keyed toolview hole through the real machinery', () => { }) describe('registrant declaration injection', () => { - it('runs the plugin before ui-conversation and waits on the actual toolview declaration', async () => { + it('runs a registrant before ui-tool and waits on the actual toolview declaration', async () => { const runtime = await SlotTestRuntime.create() runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() }) const locale = new LocaleService(runtime.ctx) @@ -204,8 +206,8 @@ describe('registrant declaration injection', () => { let applyRuns = 0 const registrantApply = (registrantCtx: typeof runtime.ctx): void => { applyRuns += 1 - registrantCtx.slots.inject('conversation.chat.toolview', () => registrantCtx.slots.register( - { name: 'conversation.chat.toolview', key: 'late' }, () => null)) + registrantCtx.slots.inject('tool.call.toolview', () => registrantCtx.slots.register( + { name: 'tool.call.toolview', key: 'late' }, () => null)) } const late = runtime.ctx.plugin({ name: 'late-registrant', @@ -215,11 +217,12 @@ describe('registrant declaration injection', () => { await Promise.resolve() await late.await() expect(applyRuns).toBe(1) - expect(runtime.slots.entries('conversation.chat.toolview')).toHaveLength(0) + expect(runtime.slots.entries('tool.call.toolview')).toHaveLength(0) // Mounting the package declares the slot and activates the waiting entry. - await runtime.mount({ inject: [...inject], apply }) - expect(runtime.slots.entries('conversation.chat.toolview').map(e => e.options.key)) + await runtime.mount({ inject: [...injectConversation], apply: applyConversation }) + await runtime.mount({ inject: [...injectTool], apply: applyTool }) + expect(runtime.slots.entries('tool.call.toolview').map(e => e.options.key)) .toEqual(expect.arrayContaining(['bash', 'late'])) await runtime.dispose() }) diff --git a/packages/client/ui-tool/tests/toolview-type-chain.spec.tsx b/packages/client/ui-tool/tests/toolview-type-chain.spec.tsx new file mode 100644 index 0000000000..b1aa7b9464 --- /dev/null +++ b/packages/client/ui-tool/tests/toolview-type-chain.spec.tsx @@ -0,0 +1,34 @@ +// The Tool-owned keyed-slot type chain: registration shape and composed +// atomic-view props. Generic slot-system duals live in ui-slots tests. +import { describe, expect, it } from 'vitest' +import type { ReactNode } from 'react' +import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' +import type { ToolCallViewProps } from '../src/client/contract/slots.ts' + +describe('toolview type negatives (compile-time; body never runs)', () => { + it('holds the negative samples as expect-error sites', () => { + const negatives = (slots: SlotsService) => { + // Keyed registration requires the key shape field. + // @ts-expect-error missing `key` on a keyed-slot registration + slots.register({ name: 'tool.call.toolview' }, (_p: ToolCallViewProps) => null) + slots.register( + // @ts-expect-error `id`/`order` belong to list slots, not the keyed hole + { name: 'tool.call.toolview', key: 'k', order: 1 }, + (_p: ToolCallViewProps) => null) + const overreaching = (props: ToolCallViewProps): ReactNode => { + // @ts-expect-error loadOlder belongs to the conversation host, not an atomic Tool view + void props.loadOlder + return null + } + void overreaching + const drifted = (props: ToolCallViewProps): ReactNode => { + // @ts-expect-error the Tool call union has no pre-parsed args member + void props.block.argsParsed + return null + } + void drifted + return null as ReactNode + } + expect(negatives).toBeTypeOf('function') + }) +}) diff --git a/packages/client/ui-conversation/tests/web-card.spec.tsx b/packages/client/ui-tool/tests/web-card.spec.tsx similarity index 94% rename from packages/client/ui-conversation/tests/web-card.spec.tsx rename to packages/client/ui-tool/tests/web-card.spec.tsx index 220661ff88..b8a5a4e06c 100644 --- a/packages/client/ui-conversation/tests/web-card.spec.tsx +++ b/packages/client/ui-tool/tests/web-card.spec.tsx @@ -16,15 +16,17 @@ import type { } from '@deepseek-ai/dsh-client-runtime/client' import type { ToolResultView } from '@deepseek-ai/dsh-client-connection/client' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' -import type { SelectionTarget, ToolRowOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client' -import { webCardModel } from '../src/client/contract/web-card-model.ts' -import { createChatStore } from '../src/client/stores.ts' -import { GenericToolCard } from '../src/client/chat/GenericToolCard.tsx' -import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx' -import { WebRow, webToolview } from '../src/client/toolviews/web-row.tsx' +import type { SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { ToolCallOwnerProps } from '@deepseek-ai/dsh-client-ui-tool/client' +import { webCardModel } from '../src/client/tool/models/web-card-model.ts' +import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/client/stores.ts' +import { GenericToolCard } from '../src/client/tool/toolviews/GenericToolCard.tsx' +import { DetailsPanel } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/DetailsPanel.tsx' +import { WebRow, webToolview } from '../src/client/tool/toolviews/web-row.tsx' +import { renderToolDetails, SessionProviderStub } from './tool-details-render.tsx' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' -import { zh } from '../src/client/locales.ts' +import { zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts' afterEach(cleanup) @@ -121,7 +123,7 @@ describe('webCardModel', () => { }) describe('chat row web body', () => { - const ownerProps = (block: RunningToolCall | ToolResultNode, toolName: string): ToolRowOwnerProps => ({ + const ownerProps = (block: RunningToolCall | ToolResultNode, toolName: string): ToolCallOwnerProps => ({ callId: block.callId, toolName, block, openFile: vi.fn(), }) // WebRow reads only toolName/block off the full runtime share plus the locale @@ -214,6 +216,8 @@ describe('DetailsPanel web Output section', () => { }) return render( snapshot, subscribe: () => () => {} })} useSessions={bindSnapshotSelector(sessions)} diff --git a/packages/client/ui-tool/tsconfig.json b/packages/client/ui-tool/tsconfig.json new file mode 100644 index 0000000000..17516295fb --- /dev/null +++ b/packages/client/ui-tool/tsconfig.json @@ -0,0 +1,33 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../runtime" + }, + { + "path": "../locale" + }, + { + "path": "../ui-conversation" + }, + { + "path": "../ui-primitives" + }, + { + "path": "../ui-slots" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/client/ui-tool/tsdown.config.ts b/packages/client/ui-tool/tsdown.config.ts new file mode 100644 index 0000000000..1c66514f9a --- /dev/null +++ b/packages/client/ui-tool/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-client-ui-tool', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d604545dfd..ccbc67c764 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1284,6 +1284,9 @@ importers: '@deepseek-ai/dsh-client-ui-theme': specifier: workspace:^ version: link:../../client/ui-theme + '@deepseek-ai/dsh-client-ui-tool': + specifier: workspace:^ + version: link:../../client/ui-tool '@deepseek-ai/dsh-client-ui-trajectory': specifier: workspace:^ version: link:../../client/ui-trajectory @@ -2201,9 +2204,6 @@ importers: '@deepseek-ai/dsh-client-test-runtime': specifier: workspace:^ version: link:../test-runtime - '@deepseek-ai/dsh-client-ui-conversation': - specifier: workspace:^ - version: link:../ui-conversation '@deepseek-ai/dsh-client-ui-primitives': specifier: workspace:^ version: link:../ui-primitives @@ -2213,6 +2213,9 @@ importers: '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots + '@deepseek-ai/dsh-client-ui-tool': + specifier: workspace:^ + version: link:../ui-tool '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -2355,6 +2358,55 @@ importers: specifier: ^18.2.0 version: 18.3.1 + packages/client/ui-tool: + dependencies: + clsx: + specifier: ^2.0.0 + version: 2.1.1 + devDependencies: + '@deepseek-ai/dsh-client-connection': + specifier: workspace:^ + version: link:../connection + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale + '@deepseek-ai/dsh-client-runtime': + specifier: workspace:^ + version: link:../runtime + '@deepseek-ai/dsh-client-test-runtime': + specifier: workspace:^ + version: link:../test-runtime + '@deepseek-ai/dsh-client-ui-conversation': + specifier: workspace:^ + version: link:../ui-conversation + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@deepseek-ai/dsh-client-web-react': + specifier: workspace:^ + version: link:../web-react + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@testing-library/react': + specifier: ^16.1.0 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + cordis: + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + react: + specifier: ^18.2.0 + version: 18.3.1 + react-dom: + specifier: ^18.2.0 + version: 18.3.1(react@18.3.1) + packages/client/ui-trajectory: dependencies: '@tanstack/react-virtual': diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index ddbd7b66f8..3ca0317cf4 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -63,6 +63,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/client/ui-layout': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, 'packages/client/ui-sidebar': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, 'packages/client/ui-conversation': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, + 'packages/client/ui-tool': { kind: 'none', reason: 'Browser-side Tool presentation layer; renders logged calls without changing model context.' }, 'packages/client/ui-deliverables': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, 'packages/client/ui-slash': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, 'packages/client/ui-command': { kind: 'indirect', reason: 'The dispatch paths trigger the host command.execute RPC; each command handler\'s host package owns any model-visible effect.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index 75719f1ecc..de9b63459c 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -162,6 +162,7 @@ "@deepseek-ai/dsh-client-ui-layout": ["./packages/client/ui-layout/src"], "@deepseek-ai/dsh-client-ui-sidebar": ["./packages/client/ui-sidebar/src"], "@deepseek-ai/dsh-client-ui-conversation": ["./packages/client/ui-conversation/src"], + "@deepseek-ai/dsh-client-ui-tool": ["./packages/client/ui-tool/src"], "@deepseek-ai/dsh-client-ui-deliverables": ["./packages/client/ui-deliverables/src"], "@deepseek-ai/dsh-client-ui-slash": ["./packages/client/ui-slash/src"], "@deepseek-ai/dsh-client-ui-command": ["./packages/client/ui-command/src"], diff --git a/tsconfig.client.json b/tsconfig.client.json index 2a2b16e2e7..9ce72753c1 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -59,6 +59,7 @@ { "path": "./packages/client/ui-layout" }, { "path": "./packages/client/ui-sidebar" }, { "path": "./packages/client/ui-conversation" }, + { "path": "./packages/client/ui-tool" }, { "path": "./packages/client/ui-deliverables" }, { "path": "./packages/client/ui-workspace" }, { "path": "./packages/client/ui-slash" }, diff --git a/vitest.config.ts b/vitest.config.ts index f5a86ac7a9..6eb1bda354 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -162,6 +162,8 @@ export default defineConfig({ 'packages/client/web-react/src/*', 'packages/client/runtime/src/*', 'packages/client/ui-conversation/src/*', + 'packages/client/ui-primitives/src/DisclosureRow.tsx', + 'packages/client/ui-tool/src/*', 'packages/client/ui-slots/src/*', 'packages/client/ui-layout/src/*', 'packages/client/web/src/*',