Selaa lähdekoodia

cleanup(client): extract Tool presentation into ui-tool

imccyu 1 kuukausi sitten
vanhempi
sitoutus
7cc554ef16
94 muutettua tiedostoa jossa 1793 lisäystä ja 1271 poistoa
  1. 4 0
      packages/bundle/web-app/cordis.patch.yml
  2. 1 0
      packages/bundle/web-app/package.json
  3. 1 1
      packages/client/AGENTS.md
  4. 2 2
      packages/client/README.i18n.yaml
  5. 1 0
      packages/client/README.md
  6. 1 0
      packages/client/README.zh.md
  7. 11 11
      packages/client/connection/src/client/fixture.ts
  8. 1 1
      packages/client/runtime/src/client/index.ts
  9. 2 2
      packages/client/ui-conversation/README.i18n.yaml
  10. 3 15
      packages/client/ui-conversation/README.md
  11. 3 15
      packages/client/ui-conversation/README.zh.md
  12. 1 1
      packages/client/ui-conversation/package.json
  13. 8 42
      packages/client/ui-conversation/src/client/apply.ts
  14. 0 11
      packages/client/ui-conversation/src/client/chat/ChatView.module.css
  15. 22 113
      packages/client/ui-conversation/src/client/chat/ChatView.tsx
  16. 28 34
      packages/client/ui-conversation/src/client/contract/slots.ts
  17. 15 0
      packages/client/ui-conversation/src/client/contract/tool-path.ts
  18. 3 4
      packages/client/ui-conversation/src/client/index.ts
  19. 0 33
      packages/client/ui-conversation/src/client/skeleton/DetailsPanel.module.css
  20. 23 90
      packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx
  21. 1 1
      packages/client/ui-conversation/src/invariant.ts
  22. 7 127
      packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx
  23. 10 14
      packages/client/ui-conversation/tests/chat-apply.spec.tsx
  24. 4 49
      packages/client/ui-conversation/tests/chat-stats.spec.tsx
  25. 58 64
      packages/client/ui-conversation/tests/chat-view.spec.tsx
  26. 5 85
      packages/client/ui-conversation/tests/coverage-tails.spec.tsx
  27. 27 5
      packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx
  28. 3 154
      packages/client/ui-conversation/tests/todo-panel.spec.tsx
  29. 4 33
      packages/client/ui-conversation/tests/views-type-chain.spec.tsx
  30. 2 2
      packages/client/ui-skill/README.i18n.yaml
  31. 1 1
      packages/client/ui-skill/README.md
  32. 1 1
      packages/client/ui-skill/README.zh.md
  33. 3 3
      packages/client/ui-skill/package.json
  34. 6 6
      packages/client/ui-skill/src/client/SkillRow.tsx
  35. 2 2
      packages/client/ui-skill/src/client/index.ts
  36. 3 3
      packages/client/ui-skill/tests/browser-plugin.spec.ts
  37. 1 1
      packages/client/ui-skill/tsconfig.json
  38. 6 0
      packages/client/ui-tool/README.i18n.yaml
  39. 44 0
      packages/client/ui-tool/README.md
  40. 44 0
      packages/client/ui-tool/README.zh.md
  41. 73 0
      packages/client/ui-tool/package.json
  42. 43 0
      packages/client/ui-tool/src/client/apply.ts
  43. 39 0
      packages/client/ui-tool/src/client/contract/slots.ts
  44. 3 0
      packages/client/ui-tool/src/client/index.ts
  45. 2 0
      packages/client/ui-tool/src/client/locale.ts
  46. 12 0
      packages/client/ui-tool/src/client/tool/ToolCallTree.module.css
  47. 88 0
      packages/client/ui-tool/src/client/tool/ToolCallTree.tsx
  48. 46 0
      packages/client/ui-tool/src/client/tool/ToolDetails.module.css
  49. 66 0
      packages/client/ui-tool/src/client/tool/ToolDetails.tsx
  50. 69 0
      packages/client/ui-tool/src/client/tool/components/DisclosureRow.module.css
  51. 104 0
      packages/client/ui-tool/src/client/tool/components/DisclosureRow.tsx
  52. 0 18
      packages/client/ui-tool/src/client/tool/components/ToolRow.module.css
  53. 40 66
      packages/client/ui-tool/src/client/tool/components/ToolRow.tsx
  54. 0 0
      packages/client/ui-tool/src/client/tool/models/diff-card-model.ts
  55. 0 0
      packages/client/ui-tool/src/client/tool/models/read-card-model.ts
  56. 0 0
      packages/client/ui-tool/src/client/tool/models/search-card-model.ts
  57. 0 0
      packages/client/ui-tool/src/client/tool/models/terminal-card-model.ts
  58. 3 7
      packages/client/ui-tool/src/client/tool/models/tool-call-model.ts
  59. 0 0
      packages/client/ui-tool/src/client/tool/models/web-card-model.ts
  60. 13 15
      packages/client/ui-tool/src/client/tool/toolviews/GenericToolCard.tsx
  61. 9 9
      packages/client/ui-tool/src/client/tool/toolviews/ask-question-row.tsx
  62. 0 0
      packages/client/ui-tool/src/client/tool/toolviews/bash-sample.module.css
  63. 8 8
      packages/client/ui-tool/src/client/tool/toolviews/bash-sample.tsx
  64. 10 10
      packages/client/ui-tool/src/client/tool/toolviews/file-mutation-row.tsx
  65. 0 0
      packages/client/ui-tool/src/client/tool/toolviews/plan-summary.ts
  66. 10 10
      packages/client/ui-tool/src/client/tool/toolviews/read-row.tsx
  67. 11 11
      packages/client/ui-tool/src/client/tool/toolviews/search-row.tsx
  68. 10 10
      packages/client/ui-tool/src/client/tool/toolviews/todo-row.tsx
  69. 10 10
      packages/client/ui-tool/src/client/tool/toolviews/web-row.tsx
  70. 4 0
      packages/client/ui-tool/src/css-modules.d.ts
  71. 4 0
      packages/client/ui-tool/src/index.ts
  72. 30 0
      packages/client/ui-tool/src/invariant.ts
  73. 4 4
      packages/client/ui-tool/tests/ask-question-row.spec.tsx
  74. 149 0
      packages/client/ui-tool/tests/assembly-surfaces.spec.tsx
  75. 7 4
      packages/client/ui-tool/tests/chat-code-subcalls.spec.tsx
  76. 116 0
      packages/client/ui-tool/tests/coverage-tails.spec.tsx
  77. 9 6
      packages/client/ui-tool/tests/diff-card.spec.tsx
  78. 10 7
      packages/client/ui-tool/tests/read-card.spec.tsx
  79. 9 6
      packages/client/ui-tool/tests/search-card.spec.tsx
  80. 11 6
      packages/client/ui-tool/tests/terminal-card.spec.tsx
  81. 158 0
      packages/client/ui-tool/tests/todo-row.spec.tsx
  82. 63 0
      packages/client/ui-tool/tests/tool-call-tree.spec.tsx
  83. 22 0
      packages/client/ui-tool/tests/tool-details-render.tsx
  84. 1 1
      packages/client/ui-tool/tests/tool-row-styles.spec.ts
  85. 5 109
      packages/client/ui-tool/tests/tool-row.spec.tsx
  86. 20 17
      packages/client/ui-tool/tests/toolview-slot.spec.tsx
  87. 34 0
      packages/client/ui-tool/tests/toolview-type-chain.spec.tsx
  88. 12 8
      packages/client/ui-tool/tests/web-card.spec.tsx
  89. 33 0
      packages/client/ui-tool/tsconfig.json
  90. 3 0
      packages/client/ui-tool/tsdown.config.ts
  91. 55 3
      pnpm-lock.yaml
  92. 1 0
      tsconfig.base.json
  93. 1 0
      tsconfig.client.json
  94. 1 0
      vitest.config.ts

+ 4 - 0
packages/bundle/web-app/cordis.patch.yml

@@ -149,6 +149,10 @@
     - id: ui-conversation
       name: '@deepseek-ai/dsh-client-ui-conversation'
 
+    # Tool call tree, generic fallback, and keyed business Tool views.
+    - id: ui-tool
+      name: '@deepseek-ai/dsh-client-ui-tool'
+
     # Turn tail: the produced-files row under each closing assistant message.
     # Remove this entry to turn the surface off; the tail hole renders empty.
     - id: ui-deliverables

+ 1 - 0
packages/bundle/web-app/package.json

@@ -55,6 +55,7 @@
     "@deepseek-ai/dsh-client-ui-slash": "workspace:^",
     "@deepseek-ai/dsh-client-ui-subagent": "workspace:^",
     "@deepseek-ai/dsh-client-ui-theme": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-tool": "workspace:^",
     "@deepseek-ai/dsh-client-ui-trajectory": "workspace:^",
     "@deepseek-ai/dsh-client-ui-workspace": "workspace:^",
     "@deepseek-ai/dsh-code-runtime-worker": "workspace:^",

+ 1 - 1
packages/client/AGENTS.md

@@ -9,7 +9,7 @@ Packages here are named with the directory prefix: `@deepseek-ai/dsh-client-<nam
 The [slot system standard](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md) owns the full design; these are the rules you must not violate when writing or reviewing client code:
 
 1. **One API**: a plugin composes UI only through `ctx.slots.register({ name, children?, store?, inject? }, Component)`. There is no separate slot-definition call, no whitelist face object, no face-minting helper. The shell alone renders `'root'`.
-2. **children = declaration + authorization**: the slots your component renders are exactly the keys of your register call's `children` object (spec values: `kind`/`scope`). Rendering a slot you didn't declare, or declaring one someone else declared, fails at load — do not work around it; the conflict is the design speaking. Slot names mirror the composition path: `<domain>.<entry>.<hole>` (e.g. `'conversation.chat.toolview'`).
+2. **children = declaration + authorization**: the slots your component renders are exactly the keys of your register call's `children` object (spec values: `kind`/`scope`). Rendering a slot you didn't declare, or declaring one someone else declared, fails at load — do not work around it; the conflict is the design speaking. Slot names mirror the composition path: `<domain>.<entry>.<hole>` (e.g. `'tool.call.toolview'`).
 3. **Component props are the four shares, all derived**: `PropsRuntime<K>` (SlotMap: owner params + `useSession`/`sessionId` on session scope + global `useSessions`/`useWorkspaces`) & `PropsRenderSlots<S>` (children keys) & `PropsStore<H>` (store factory) & the inject face. Never hand-write a member a share already derives; never re-type a share locally.
 4. **Hooks are framework-made only**: `useSession`, `useSessions`, `useWorkspaces`, `useStore`, `renderSlot` are the five standing seats, plus the `use<Name>` hooks the renderer binds from provide contributions and inject `hooks` compartments. Business code never creates a hook or selector as a prop value — pass plain data and callbacks. (Component-internal behavioral hooks that subscribe to nothing external are fine.)
 5. **Live data has exactly three channels**: parent knows it → owner props at the renderSlot site; only the component knows it → local state; shared across entries or survives remounts → a store declared at register. Derived data is a pure function over framework-hook data (`useMemo`), never its own subscription.

+ 2 - 2
packages/client/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/README.md
-README.md: b950772d4cad6d873426f8aee6416fa56afca2ee
-README.zh.md: 8f1f7f46777b7037e8baa04c9ec16ef74ffd478d
+README.md: b6fa426fbe541e2b22d2bf5f19d4397361cf0899
+README.zh.md: 5a55bb8c2c31b5215fc73e75e1c4f3aca79add64

+ 1 - 0
packages/client/README.md

@@ -22,6 +22,7 @@ The browser side of the dsh web GUI: shell boot, browser-host communication, sha
 | [`ui-sidebar/`](ui-sidebar/README.md) | Presents workspace and session navigation. |
 | [`ui-workspace/`](ui-workspace/README.md) | Provides workspace selection and creation surfaces. |
 | [`ui-conversation/`](ui-conversation/README.md) | Presents the active conversation and its input surface. |
+| [`ui-tool/`](ui-tool/README.md) | Composes Tool call trees and keyed per-Tool views. |
 | [`ui-goal/`](ui-goal/README.md) | Presents and manages the current goal. |
 | [`ui-trajectory/`](ui-trajectory/README.md) | Presents alternate views of agent activity. |
 | [`ui-command/`](ui-command/README.md) | Provides session-aware command discovery and dispatch. |

+ 1 - 0
packages/client/README.zh.md

@@ -22,6 +22,7 @@ dsh web GUI 的浏览器侧:shell 启动、浏览器与宿主通信、共享 U
 | [`ui-sidebar/`](ui-sidebar/README.md) | 展示 Workspace 与会话导航。 |
 | [`ui-workspace/`](ui-workspace/README.md) | 提供 Workspace 选择与创建界面。 |
 | [`ui-conversation/`](ui-conversation/README.md) | 展示当前会话及其输入界面。 |
+| [`ui-tool/`](ui-tool/README.md) | 编排 Tool 调用树和按 Tool 键控的视图。 |
 | [`ui-goal/`](ui-goal/README.md) | 展示和管理当前目标。 |
 | [`ui-trajectory/`](ui-trajectory/README.md) | 提供 agent(智能体)活动的其他视图。 |
 | [`ui-command/`](ui-command/README.md) | 提供会话感知的命令发现与分发。 |

+ 11 - 11
packages/client/connection/src/client/fixture.ts

@@ -157,19 +157,19 @@ const SEARCH_MATCHES_FIXTURE: { path: string; matches: { lineNumber: number; lin
     ],
   },
   {
-    path: 'packages/client/ui-conversation/src/client/contract/search-card-model.ts',
+    path: 'packages/client/ui-tool/src/client/tool/models/search-card-model.ts',
     matches: [
-      { lineNumber: 24, line: 'export const CHAT_SEARCH_MAX_LINES = 8' },
-      { lineNumber: 60, line: 'export function searchCardModel(block: ToolCallBlock): SearchCardModel | null {' },
+      { lineNumber: 45, line: 'export const CHAT_SEARCH_MAX_LINES = 8' },
+      { lineNumber: 130, line: 'export function searchCardModel(block: ToolCallBlock): SearchCardModel | null {' },
     ],
   },
   {
-    path: 'packages/client/ui-conversation/src/client/toolviews/search-row.tsx',
+    path: 'packages/client/ui-tool/src/client/tool/toolviews/search-row.tsx',
     matches: [
-      { lineNumber: 33, line: 'export function SearchRow({ toolName, block, inspect, t }: SearchRowProps) {' },
-      { lineNumber: 35, line: '  const search = searchCardModel(block)' },
-      { lineNumber: 52, line: '      search={search}' },
-      { lineNumber: 78, line: "      yield ctx.slots.register({ name: 'conversation.chat.toolview', key: 'grep', locale: NS }, SearchRow)" },
+      { lineNumber: 34, line: 'export function SearchRow({ toolName, block, inspect, t }: SearchRowProps) {' },
+      { lineNumber: 36, line: '  const search = searchCardModel(block)' },
+      { lineNumber: 56, line: '      search={search}' },
+      { lineNumber: 78, line: "      yield ctx.slots.register({ name: 'tool.call.toolview', key: 'grep', locale: NS }, SearchRow)" },
     ],
   },
 ]
@@ -197,9 +197,9 @@ const SEARCH_MATCHES_TEXT = [
 const SEARCH_PATHS_FIXTURE = [
   'packages/client/ui-primitives/src/SearchBlock.tsx',
   'packages/client/ui-primitives/src/SearchBlock.module.css',
-  'packages/client/ui-conversation/src/client/contract/search-card-model.ts',
-  'packages/client/ui-conversation/src/client/toolviews/search-row.tsx',
-  'packages/client/ui-conversation/tests/search-card.spec.tsx',
+  'packages/client/ui-tool/src/client/tool/models/search-card-model.ts',
+  'packages/client/ui-tool/src/client/tool/toolviews/search-row.tsx',
+  'packages/client/ui-tool/tests/search-card.spec.tsx',
 ]
 
 /**

+ 1 - 1
packages/client/runtime/src/client/index.ts

@@ -85,7 +85,7 @@ declare module '@deepseek-ai/dsh-type-meta' {
   }
 }
 
-/** The conversation-snapshot selector hook (ConvViewProps/ToolRowProps take this). */
+/** The conversation-snapshot selector hook supplied to session-scoped UI entries. */
 export type UseConversationSession = SnapshotSelectorHook<ConversationSnapshot>
 
 /**

+ 2 - 2
packages/client/ui-conversation/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
-README.md: 6b541b840ed67ee6fd735a0643dde8c60f1ec22d
-README.zh.md: 01692c395cdb0f50e0fd41ab92f51d9e3ceecb4f
+README.md: 3aade8409e74c38cb86a66076cca50d51a1ccbf8
+README.zh.md: 19d08bbc072f1f242417b91ae4dd771f970244b1

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 3 - 15
packages/client/ui-conversation/README.md


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 3 - 15
packages/client/ui-conversation/README.zh.md


+ 1 - 1
packages/client/ui-conversation/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@deepseek-ai/dsh-client-ui-conversation",
-  "description": "Conversation domain: skeleton (header/tabs/composer), chat view, ctx.toolviews registry, minimal details panel",
+  "description": "Conversation domain: skeleton, ordered chat flow, composer, and details host",
   "version": "0.0.1",
   "private": true,
   "type": "module",

+ 8 - 42
packages/client/ui-conversation/src/client/apply.ts

@@ -11,7 +11,7 @@ import type {
   ConversationSessionHeaderInjected, ConversationSessionInjected, DetailsInjected,
 } from './contract/slots.ts'
 import type { InputNotice } from './input/contract.ts'
-import { resolveToolPath } from './contract/tool-call-model.ts'
+import { resolveToolPath } from './contract/tool-path.ts'
 import { createChatStore } from './stores.ts'
 import { ConversationService } from './service.ts'
 import type { IConversation } from './service.ts'
@@ -24,14 +24,7 @@ import { EnterBehaviorRow } from './settings/EnterBehaviorRow.tsx'
 import type { EnterBehaviorRowInjected } from './settings/EnterBehaviorRow.tsx'
 import { ChatView } from './chat/ChatView.tsx'
 import { StatsLine } from './chat/StatsLine.tsx'
-import { bashToolviewSample } from './toolviews/bash-sample.tsx'
-import { readToolview } from './toolviews/read-row.tsx'
-import { fileMutationToolview } from './toolviews/file-mutation-row.tsx'
-import { searchToolview } from './toolviews/search-row.tsx'
-import { webToolview } from './toolviews/web-row.tsx'
 import { ApprovalPanel } from './skeleton/ApprovalPanel.tsx'
-import { todoToolview } from './toolviews/todo-row.tsx'
-import { askQuestionToolview } from './toolviews/ask-question-row.tsx'
 import { todoDockEntry } from './skeleton/TodoPanel.tsx'
 import { queueDockEntry } from './queue/QueueDock.tsx'
 import { ConversationRoot } from './skeleton/ConversationRoot.tsx'
@@ -41,7 +34,7 @@ import { en, NS, zh, type ConversationKey } from './locales.ts'
 
 declare module '@deepseek-ai/dsh-client-ui-slots' {
   interface LocaleNamespaceMap {
-    /** The conversation surfaces' copy (skeleton, chat view, toolviews, docks). */
+    /** The conversation skeleton, chat flow, commands, details, and docks copy. */
     conversation: ConversationKey
   }
 }
@@ -304,10 +297,8 @@ export function apply(ctx: Context): void {
   slots.register({ name: 'conversation.composer', select: selectApproval, priority: 1, locale: NS }, ApprovalPanel)
 
   // The chat view: first entry of the ring this package just declared.
-  // Declaring the keyed toolview hole here is claiming it: ChatView is the
-  // only component authorized to render per-tool rows. Shares the chat
-  // store, so its selection writes land in the same per-session instance the
-  // details panel reads.
+  // ChatView owns ordered Tool placement but delegates each whole root call
+  // to ui-tool, which owns root/subcall composition and atomic dispatch.
   slots.register({
     name: 'conversation.view',
     id: 'chat',
@@ -315,7 +306,7 @@ export function apply(ctx: Context): void {
     label: () => t('view.chat'),
     locale: NS,
     children: {
-      'conversation.chat.toolview': { kind: 'keyed', scope: 'session' },
+      'conversation.chat.tool': { kind: 'single', scope: 'session' },
       'conversation.chat.commandview': { kind: 'keyed', scope: 'session' },
       'conversation.chat.turnTail': { kind: 'chain', scope: 'session' },
     },
@@ -368,34 +359,6 @@ export function apply(ctx: Context): void {
   // this service remains only where conversation actions are required.
   ctx.plugin(ConversationService, { input: inputHub, blocks: composerBlocks })
 
-  // The bash sample rides the same declaration seam, in third-party posture
-  // (ToolRow-matching Bash · {description} chrome).
-  ctx.plugin(bashToolviewSample)
-
-  // The read row rides the same seam (a product registration, not a sample):
-  // Read · {path} chrome with the file's read card resident below it.
-  ctx.plugin(readToolview)
-
-  // The write/edit rows ride the same seam: a file-mutation call declares the
-  // diff render intent, so these rows stack the applied diff card under their
-  // path-link summary (the terminal card's posture, applied to diffs).
-  ctx.plugin(fileMutationToolview)
-
-  // The grep/glob search row rides the same seam: one component registered
-  // under both tool names, since both declare the same search render intent.
-  ctx.plugin(searchToolview)
-
-  // The web rows ride the same seam: one WebRow registered under both
-  // web_search and web_fetch, rendering the completed retrieval's web card
-  // resident under the summary (a product registration, not a sample).
-  ctx.plugin(webToolview)
-
-  // The todo_write row rides the same seam (a product registration, not a sample).
-  ctx.plugin(todoToolview)
-
-  // The ask_user_question row: waiting/answered/cancelled interaction outcome.
-  ctx.plugin(askQuestionToolview)
-
   // The plan strip rides the input dock above the queue rows (same posture).
   ctx.plugin(todoDockEntry)
 
@@ -406,6 +369,9 @@ export function apply(ctx: Context): void {
   slots.register({
     name: 'details',
     locale: NS,
+    children: {
+      'conversation.details.tool': { kind: 'single', scope: 'session' },
+    },
     store: chatStore,
     inject: (): DetailsInjected => ({
       closeDetails: () => { layout.closeDetails() },

+ 0 - 11
packages/client/ui-conversation/src/client/chat/ChatView.module.css

@@ -64,17 +64,6 @@
 /* Selection still sets data-selected for details linkage; no outline —
    tool rows match Think chrome (no selected ring). */
 
-/* run_code sub-dispatch rows: indented under the parent row, left-edged so
-   the code turn reads as one unit; each nested row is itself a .callRow. */
-.subCalls {
-  display: flex;
-  flex-direction: column;
-  gap: 4px;
-  margin: 4px 0 2px 22px;
-  padding-left: 8px;
-  border-left: 1px solid var(--dsw-alias-border-l2);
-}
-
 /* Turn activity keeps the former loader's one-line footprint. A pale
    brand-blue band sweeps from left to right; reduced-motion keeps it static. */
 .turnStatus {

+ 22 - 113
packages/client/ui-conversation/src/client/chat/ChatView.tsx

@@ -2,10 +2,9 @@
 // assistant narration, tool summary rows grouped into step runs, pending
 // cards, paging, and bottom-follow. Session stats live on
 // 'conversation.composer.dock' (sticky with the composer). Pure component
-// registered directly; its registration declares the keyed
-// 'conversation.chat.toolview' hole, so tool rows render through the props
-// renderSlot share (entryKey = tool name, GenericToolCard as the render-site
-// fallback).
+// registered directly; its registration declares the whole-Tool
+// 'conversation.chat.tool' seat. ui-tool owns root/subcall composition and
+// keyed per-tool dispatch behind that boundary.
 //
 // Scroll: when nested under `[data-conversation-scroll]` (active conversation
 // column), that host is the scrollport and this view is flow content; when
@@ -25,7 +24,7 @@ import {
   memo, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode,
 } from 'react'
 import type {
-  CodeSubCall, CommandNode, ConversationNode, ConversationSnapshot, RunningToolCall, ToolResultNode,
+  CommandNode, ConversationNode, ConversationSnapshot, RunningToolCall, ToolResultNode,
 } from '@deepseek-ai/dsh-client-runtime/client'
 import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
 import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
@@ -34,7 +33,6 @@ import { assistantActionsSeqs, assistantBranchSeqs, deriveChatFlow, runningTurnS
 import { AssistantMarkdown } from './AssistantMarkdown.tsx'
 import { CompactionCommandCard } from './CompactionCommandCard.tsx'
 import { GenericCommandCard } from './GenericCommandCard.tsx'
-import { GenericToolCard } from './GenericToolCard.tsx'
 import { MessageItem, PendingSteeringBubble } from './MessageItem.tsx'
 import { formatRunDuration } from './message-chrome.ts'
 import { deriveTurnMetrics } from './turn-metrics.ts'
@@ -104,8 +102,8 @@ type OpenFile = (path: string) => void
 
 type InspectCall = (callId: string) => void
 
-/** The declared toolview hole's render share (stable framework binding, passed through memoized rows). */
-type RenderToolRow = ChatViewSlotProps['renderSlot']
+/** Declared child-slot render share (stable framework binding). */
+type RenderChatSlot = ChatViewSlotProps['renderSlot']
 
 type ChatScrollPosition = NonNullable<ReturnType<ChatViewSlotProps['chatScroll']['read']>>
 
@@ -136,129 +134,49 @@ function scrollPosition(list: HTMLElement, scrollport: HTMLElement): ChatScrollP
   }
 }
 
-/** One `run_code` sub-dispatch row: the identical keyed-slot dispatch as a
- *  top-level call (same registrations, same fallback), nested by the parent.
- *  A started-but-unsettled sub-call arrives as the RunningToolCall shape and
- *  renders the running state exactly as a native in-flight row. */
-const SubCallRow = memo(function SubCallRow({ renderSlot, node, openFile, selected, cwd, inspectCall, t }: {
-  renderSlot: RenderToolRow
-  node: CodeSubCall
-  openFile: OpenFile
-  selected: boolean
-  cwd: string | undefined
-  inspectCall: InspectCall
-  t: ChatViewSlotProps['t']
-}) {
-  const settled = 'kind' in node
-  const toolName = settled ? node.call?.name ?? '' : node.name
-  const owner = useMemo(() => ({
-    callId: node.callId, toolName, block: node, openFile, cwd,
-    inspect: () => { inspectCall(node.callId) },
-  }), [node, toolName, openFile, cwd, inspectCall])
-  return (
-    <div
-      className={css.callRow}
-      data-chat-anchor-key={`call:${node.callId}`}
-      data-chat-call-id={node.callId}
-      data-selected={selected || undefined}
-    >
-      {renderSlot('conversation.chat.toolview', owner, {
-        entryKey: toolName,
-        fallback: <GenericToolCard {...owner} t={t} />,
-      })}
-    </div>
-  )
-})
-
-/** One tool call row (result or running): dispatches through the keyed
- *  toolview slot with the owner payload; unregistered tools fall back to
- *  GenericToolCard at this render site. A `run_code` call additionally
- *  renders its logged sub-dispatches as always-visible indented rows —
- *  each one the same keyed-slot dispatch as a native top-level call. */
-const CallRow = memo(function CallRow({
-  renderSlot, callId, toolName, block, openFile, selected, subCalls, selectedCallId, cwd, inspectCall, t,
+/** One ordered root Tool call handed intact to the Tool presentation plugin. */
+const ToolSeat = memo(function ToolSeat({
+  renderSlot, callId, toolName, block, openFile, selectedCallId, cwd, inspectCall,
 }: {
-  renderSlot: RenderToolRow
+  renderSlot: RenderChatSlot
   callId: string
   toolName: string
   block: ToolResultNode | RunningToolCall
   openFile: OpenFile
-  selected: boolean
-  /** `run_code` sub-dispatches in dispatch order (reference-stable per
-   *  parent; running entries settle in place); undefined for ordinary calls. */
-  subCalls?: readonly CodeSubCall[] | undefined
-  /** The store's selected callId, matched against sub-rows (undefined when no sub-row here is selected). */
   selectedCallId?: string | undefined
-  /** Session workspace root for path-relative summaries. */
   cwd: string | undefined
   inspectCall: InspectCall
-  t: ChatViewSlotProps['t']
 }) {
   const owner = useMemo(() => ({
-    callId, toolName, block, openFile, cwd,
-    inspect: () => { inspectCall(callId) },
-  }), [callId, toolName, block, openFile, cwd, inspectCall])
-  return (
-    <div
-      className={css.callRow}
-      data-chat-anchor-key={`call:${callId}`}
-      data-chat-call-id={callId}
-      data-selected={selected || undefined}
-    >
-      {renderSlot('conversation.chat.toolview', owner, {
-        entryKey: toolName,
-        fallback: <GenericToolCard {...owner} t={t} />,
-      })}
-      {subCalls !== undefined && subCalls.length > 0 && (
-        <div className={css.subCalls} data-subcalls>
-          {subCalls.map(node => (
-            <SubCallRow
-              key={node.callId}
-              renderSlot={renderSlot}
-              node={node}
-              openFile={openFile}
-              selected={node.callId === selectedCallId}
-              cwd={cwd}
-              inspectCall={inspectCall}
-              t={t}
-            />
-          ))}
-        </div>
-      )}
-    </div>
-  )
+    callId, toolName, block, selectedCallId, cwd, openFile, inspectCall,
+  }), [callId, toolName, block, selectedCallId, cwd, openFile, inspectCall])
+  return renderSlot('conversation.chat.tool', owner)
 })
 
 /** Consecutive tool results as one step-run group (uniform 16px rhythm). */
-const ToolGroup = memo(function ToolGroup({ renderSlot, results, openFile, selectedCallId, codeDispatches, cwd, inspectCall, t }: {
-  renderSlot: RenderToolRow
+const ToolGroup = memo(function ToolGroup({ renderSlot, results, openFile, selectedCallId, cwd, inspectCall }: {
+  renderSlot: RenderChatSlot
   results: readonly ToolResultNode[]
   openFile: OpenFile
-  /** Only set when the selected call lives in THIS group, top-level or nested (memo economy). */
+  /** Tool ownership resolves whether the selection is this root or one of its children. */
   selectedCallId: string | undefined
-  /** Sub-dispatch index off the snapshot (map reference is chunk-storm stable). */
-  codeDispatches: ReadonlyMap<string, readonly CodeSubCall[]>
   /** Session workspace root for path-relative summaries. */
   cwd: string | undefined
   inspectCall: InspectCall
-  t: ChatViewSlotProps['t']
 }) {
   return (
     <div className={css.toolGroup}>
       {results.map(node => (
-        <CallRow
+        <ToolSeat
           key={node.callId}
           renderSlot={renderSlot}
           callId={node.callId}
           toolName={node.call?.name ?? ''}
           block={node}
           openFile={openFile}
-          selected={node.callId === selectedCallId}
-          subCalls={codeDispatches.get(node.callId)}
           selectedCallId={selectedCallId}
           cwd={cwd}
           inspectCall={inspectCall}
-          t={t}
         />
       ))}
     </div>
@@ -269,7 +187,7 @@ const ToolGroup = memo(function ToolGroup({ renderSlot, results, openFile, selec
  *  generic card as the render-site fallback (zero registration required). A
  *  run-less cross-window node has no name and always lands on the fallback. */
 const CommandRow = memo(function CommandRow({ renderSlot, node, compaction, t }: {
-  renderSlot: RenderToolRow
+  renderSlot: RenderChatSlot
   node: CommandNode
   compaction?: Extract<ConversationNode, { kind: 'compaction' }>
   t: ChatViewSlotProps['t']
@@ -336,8 +254,8 @@ function StreamingTail({ useSession, t }: {
 }
 
 /**
- * The chat view slot entry: pure component over the composed props (tool rows
- * render through the declared keyed hole's renderSlot share).
+ * The chat view slot entry: pure component over the composed props; each
+ * ordered root Tool call crosses the declared whole-Tool render seat.
  */
 export function ChatView({
   useSession, useSessions, useStore, renderSlot, renderSlotChain, sessionId, openFile, loadOlder, inspectCall, chatScroll, forkAt, t,
@@ -350,7 +268,6 @@ export function ChatView({
   const cwd = useSessions(s => s.byId[sessionId]?.cwd)
   const running = useSession(s => s.running)
   const runningCalls = useSession(s => s.runningCalls)
-  const codeDispatches = useSession(s => s.codeDispatches)
   const openState = useSession(s => s.openState)
   const openError = useSession(s => s.openError)
   const hasMore = useSession(s => s.hasMore)
@@ -569,19 +486,14 @@ export function ChatView({
 
   const renderItem = (item: ChatFlowItem): ReactNode => {
     if (item.kind === 'tool-group') {
-      const inGroup = selectedCallId !== undefined
-        && item.results.some(r => r.callId === selectedCallId
-          || codeDispatches.get(r.callId)?.some(sub => sub.callId === selectedCallId) === true)
       return (
         <ToolGroup
           renderSlot={renderSlot}
           results={item.results}
           openFile={openFile}
-          selectedCallId={inGroup ? selectedCallId : undefined}
-          codeDispatches={codeDispatches}
+          selectedCallId={selectedCallId}
           cwd={cwd}
           inspectCall={inspectCall}
-          t={t}
         />
       )
     }
@@ -676,19 +588,16 @@ export function ChatView({
           {runningCalls.length > 0 && (
             <div className={css.toolGroup}>
               {runningCalls.map(call => (
-                <CallRow
+                <ToolSeat
                   key={call.callId}
                   renderSlot={renderSlot}
                   callId={call.callId}
                   toolName={call.name}
                   block={call}
                   openFile={openFile}
-                  selected={call.callId === selectedCallId}
-                  subCalls={codeDispatches.get(call.callId)}
                   selectedCallId={selectedCallId}
                   cwd={cwd}
                   inspectCall={inspectCall}
-                  t={t}
                 />
               ))}
             </div>

+ 28 - 34
packages/client/ui-conversation/src/client/contract/slots.ts

@@ -31,13 +31,10 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
      */
     'conversation.view': { kind: 'list'; scope: 'session'; owner: ConvViewOwnerProps }
     /**
-     * The chat view's per-tool row hole: keyed dispatch on the wire tool name
-     * (the key space is runtime-open — SlotMap declares slots, never keys).
-     * Declared by the chat view entry (declaring is claiming); the render
-     * site dispatches via `entryKey: toolName` with GenericToolCard as the
-     * `fallback` for unregistered tools.
+     * One root Tool call at its ordered ChatFlow position. The chat view owns
+     * placement; ui-tool owns root/subcall composition and keyed dispatch.
      */
-    'conversation.chat.toolview': { kind: 'keyed'; scope: 'session'; owner: ToolRowOwnerProps }
+    'conversation.chat.tool': { kind: 'single'; scope: 'session'; owner: ToolTreeOwnerProps }
     /**
      * The chat view's per-command row hole: keyed dispatch on the command
      * name (`command/run.name`; a run-less cross-window node has none and
@@ -55,6 +52,8 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
      * to return null; an all-declined chain renders nothing.
      */
     'conversation.chat.turnTail': { kind: 'chain'; scope: 'session'; owner: TurnTailOwnerProps }
+    /** Selected Tool call output inside the details panel. */
+    'conversation.details.tool': { kind: 'single'; scope: 'session'; owner: DetailsToolOwnerProps }
     /**
      * The composer takeover chain: entries are selector-routed replacements
      * of the default InputBar. Declared by this package's 'conversation'
@@ -177,42 +176,36 @@ export interface TurnTailOwnerProps {
   openFile: (path: string) => void
 }
 
-/**
- * Owner share of a per-view toolview slot: the call material the rendering
- * view supplies per row. Uniform across views — the trajectory/waterfall
- * toolview slots (same kind/scope/owner, names fixed by the slot-naming
- * discipline) land with their own row render sites; today only the chat slot
- * is declared (RendersCheck rejects a declaration nobody renders).
- */
-export interface ToolRowOwnerProps {
-  /** Tool call identity (details linkage; stable across running → settled). */
+/** Owner currency of the chat view's whole-Tool rendering seat. */
+export interface ToolTreeOwnerProps {
+  /** Root Tool call identity, stable across running → settled. */
   callId: CallId
-  /** Wire tool name (also the keyed dispatch key at the render site). */
+  /** Root wire Tool name. */
   toolName: string
-  /** Frozen call slice: the running call or the settled result node. */
+  /** Frozen root call slice: running call or settled result node. */
   block: ToolCallBlock
+  /** Selected call id; the Tool owner resolves whether it is root or child. */
+  selectedCallId?: CallId | undefined
   /** Session workspace root; path summaries display relative to it. */
   cwd?: string | undefined
   /**
    * Open a tool-arg filesystem path with the host OS default application.
-   * The chat view resolves relative paths against the session cwd.
+   * The conversation owner resolves relative paths against the session cwd.
    */
   openFile: (path: string) => void
   /**
-   * Jump to this call's record in the trajectory view (the expanded row's
-   * hover Inspect affordance). Undefined when no trajectory jump is wired.
+   * Jump to any call in this tree in the trajectory view.
    */
-  inspect?: (() => void) | undefined
+  inspectCall: (callId: CallId) => void
 }
 
-/**
- * Full props of a registered tool-row component: the slot's runtime share
- * (owner payload + session standard kit + global seat). Registrants type
- * their component `FC<ToolRowProps & I>` with `I` inferred from their inject
- * factory. Declared against the chat slot; the three per-view toolview slots
- * share one declaration shape, so this alias serves them all.
- */
-export type ToolRowProps = PropsRuntime<'conversation.chat.toolview'>
+/** Owner currency of the details panel's Tool output renderer. */
+export interface DetailsToolOwnerProps {
+  /** Frozen selected call slice. */
+  block: ToolCallBlock
+  /** Session workspace root for card cwd and relative-path display. */
+  cwd?: string | undefined
+}
 
 /**
  * Owner share of the per-command row slot: the frozen {@link CommandNode}
@@ -229,7 +222,7 @@ export interface CommandRowOwnerProps {
   compaction?: CompactionSummaryNode
 }
 
-/** Full props of a registered command-row component (same shape rule as {@link ToolRowProps}). */
+/** Full props of a registered command-row component. */
 export type CommandRowProps = PropsRuntime<'conversation.chat.commandview'>
 
 /**
@@ -521,9 +514,9 @@ export interface ChatViewInjected {
   forkAt: (seq: number) => void
 }
 
-/** Full chat-view component props: runtime & the declared toolview/commandview holes' render share & store & injected & locale seat. */
+/** Full chat-view component props: runtime & its Tool/command/tail render shares & store & injected & locale seat. */
 export type ChatViewSlotProps =
-  PropsRuntime<'conversation.view'> & PropsRenderSlots<'conversation.chat.toolview' | 'conversation.chat.commandview' | 'conversation.chat.turnTail'>
+  PropsRuntime<'conversation.view'> & PropsRenderSlots<'conversation.chat.tool' | 'conversation.chat.commandview' | 'conversation.chat.turnTail'>
   & PropsStore<ChatStore> & ChatViewInjected & PropsLocale<'conversation'>
 
 /**
@@ -535,8 +528,9 @@ export interface DetailsInjected {
   closeDetails: () => void
 }
 
-/** Full details-slot component props: selection rides the shared store, call material useSession; copy the locale seat. */
-export type DetailsSlotProps = PropsRuntime<'details'> & PropsStore<ChatStore> & DetailsInjected & PropsLocale<'conversation'>
+/** Full details-slot props: selection store, Tool output seat, injected close callback, and locale. */
+export type DetailsSlotProps = PropsRuntime<'details'> & PropsRenderSlots<'conversation.details.tool'>
+  & PropsStore<ChatStore> & DetailsInjected & PropsLocale<'conversation'>
 
 /** Owner share common to the hero / New-Session Workspace pickers. */
 export interface EmptyWorkspaceOwnerProps {

+ 15 - 0
packages/client/ui-conversation/src/client/contract/tool-path.ts

@@ -0,0 +1,15 @@
+/** Resolve a Tool argument path against the session workspace. */
+
+/**
+ * Resolve a Tool argument path for the Host opener.
+ * @param cwd - session workspace root, when known.
+ * @param path - path carried by the Tool arguments.
+ * @returns an absolute-or-workspace-relative Host path.
+ */
+export function resolveToolPath(cwd: string | undefined, path: string): string {
+  if (path.startsWith('/') || /^[A-Za-z]:[/\\]/.test(path) || path.startsWith('\\\\')) return path
+  if (cwd === undefined || cwd === '') return path
+  const base = cwd.replace(/[/\\]+$/, '')
+  const rel = path.replace(/^[/\\]+/, '')
+  return `${base}/${rel}`
+}

+ 3 - 4
packages/client/ui-conversation/src/client/index.ts

@@ -10,14 +10,13 @@ export type { IConversation } from './service.ts'
 export type {
   CallId, ChatStoreState, SelectionTarget, ViewTab,
 } from './contract/views.ts'
-export type { ToolCallBlock } from './contract/tool-call-model.ts'
 export type { ConversationKey } from './locales.ts'
 export type {
   ChatStore, ChatViewInjected, ChatViewSlotProps, CommandRowOwnerProps, CommandRowProps, ComposerBarInjected,
   ComposerChainProps, ConversationInjected,
-  ConversationSessionHeaderInjected, ConversationSessionInjected, ConversationSlotProps,
-  ConvViewOwnerProps, ConvViewProps, DetailsInjected, DetailsSlotProps,
-  EmptyWorkspaceOwnerProps, ToolRowOwnerProps, ToolRowProps, TurnTailOwnerProps,
+  ConversationSessionHeaderInjected, ConversationSessionInjected, ConversationSlotProps, ConvViewOwnerProps,
+  ConvViewProps, DetailsInjected, DetailsSlotProps, DetailsToolOwnerProps, EmptyWorkspaceOwnerProps,
+  ToolTreeOwnerProps, TurnTailOwnerProps,
 } from './contract/slots.ts'
 // Export discipline: packages/client/AGENTS.md.
 

+ 0 - 33
packages/client/ui-conversation/src/client/skeleton/DetailsPanel.module.css

@@ -92,36 +92,3 @@
 .code[data-error] {
   color: var(--dsw-alias-state-error-primary);
 }
-
-/* Above the card, which is where the render-intent contract puts a terminal
-   call's description; the panel has no summary row to carry it. */
-.terminalDescription {
-  margin: 0 0 6px;
-  color: var(--dsw-alias-label-secondary);
-  font: var(--dsw-font-xs-13);
-}
-
-/* A card body (terminal, diff, or search) sits directly under its section
-   label, so it drops the primitive's standalone vertical margin; the section
-   owns the spacing. Card-neutral: no card-kind-specific value. */
-.cardBody {
-  margin: 0;
-}
-
-/* The recovery footer for a capped search: the result text (its `Full … stored
-   at …` locator) below the card in the muted tone, since the card holds only the
-   retained rows. */
-.searchRecovery {
-  margin: 6px 0 0;
-  white-space: pre-wrap;
-  overflow-wrap: anywhere;
-  color: var(--dsw-alias-label-tertiary);
-  font: var(--dsw-font-xs-13);
-}
-
-/* The read and web cards sit directly under their section label, same as the
-   terminal card: drop the primitive's standalone vertical margin. */
-.read,
-.web {
-  margin: 0;
-}

+ 23 - 90
packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx

@@ -7,16 +7,11 @@
 // share the store seat exists for) and derives the call material from the
 // session snapshot — no data of its own.
 
-import { CodeBlock, DiffBlock, ReadBlock, SearchBlock, TerminalBlock, WebBlock } from '@deepseek-ai/dsh-client-ui-primitives'
+import { Fragment } from 'react'
+import { CodeBlock } from '@deepseek-ai/dsh-client-ui-primitives'
 import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client'
-import type { ConversationSnapshot, RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
+import type { ConversationSnapshot, RunningToolCall, ToolCallBlock, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
 import type { DetailsSlotProps } from '../contract/slots.ts'
-import { readCardModel } from '../contract/read-card-model.ts'
-import { diffCardModel } from '../contract/diff-card-model.ts'
-import { searchCardModel } from '../contract/search-card-model.ts'
-import { terminalBlockLabels, terminalCardModel } from '../contract/terminal-card-model.ts'
-import { webCardModel } from '../contract/web-card-model.ts'
-import { resultText, type ToolCallBlock } from '../contract/tool-call-model.ts'
 import css from './DetailsPanel.module.css'
 
 /** Full props composed by reference from the contract (automatic shares & injected share). */
@@ -72,7 +67,15 @@ function pretty(raw: string): string {
   }
 }
 
-export function DetailsPanel({ useSession, useSessions, sessionId, useStore, closeDetails, t }: DetailsPanelProps) {
+/** Flatten a settled result for the no-ui-tool fallback. */
+function rawResultText(block: ToolCallBlock): string {
+  if (!('kind' in block)) return ''
+  const parts = block.content.map(item => item.type === 'text' ? item.text : JSON.stringify(item, null, 2))
+  if (parts.length === 0 && block.error !== undefined) parts.push(`${block.error.name}: ${block.error.code}`)
+  return parts.join('\n')
+}
+
+export function DetailsPanel({ useSession, useSessions, sessionId, useStore, renderSlot, closeDetails, t }: DetailsPanelProps) {
   const selection = useStore(s => s.selection)
   // Session workspace root: an omitted or relative terminal cwd resolves
   // against it, which the pure presenter cannot see.
@@ -118,7 +121,17 @@ export function DetailsPanel({ useSession, useSessions, sessionId, useStore, clo
                       state (the terminal card's expand and copy), which React
                       would otherwise carry into the next selection because the
                       panel does not unmount between calls. */}
-                  <OutputBody key={callId} material={material} cwd={sessionCwd} t={t} />
+                  <Fragment key={callId}>
+                    {renderSlot('conversation.details.tool', { block: material.block, cwd: sessionCwd }, {
+                      fallback: 'kind' in material.block
+                        ? (
+                          <pre className={css.code} data-error={material.block.isError || undefined}>
+                            {rawResultText(material.block)}
+                          </pre>
+                        )
+                        : <div className={css.empty}>{t('details.running')}</div>,
+                    })}
+                  </Fragment>
                 </section>
               </>
             )}
@@ -126,83 +139,3 @@ export function DetailsPanel({ useSession, useSessions, sessionId, useStore, clo
     </div>
   )
 }
-
-/**
- * The Output section's body for the selected call. A terminal-card call — a
- * shell command's call/result views — renders through the shared TerminalBlock
- * at the primitive's own full height allowance, so column-aligned output keeps
- * its alignment and scrolls sideways instead of folding. A read-card call
- * renders through the shared ReadBlock at that same full height, so the whole
- * returned window is line-numbered and highlighted. A diff-card call — a
- * write/edit's applied change — renders through the shared DiffBlock at the same
- * full height. A search-card call — a `grep`/`glob` result view — renders
- * through the shared SearchBlock at the same full height allowance, with a
- * capped search's recovery footer below it. A web-card call — a
- * `web_search`/`web_fetch` result — renders through WebBlock at its own full
- * source-list allowance. Every other call, and a running call with no card yet,
- * keeps the flattened text form.
- * @param props.material - the selected call's material from {@link materialFor}.
- * @param props.cwd - the session workspace root, resolving the terminal view's cwd.
- * @param props.t - the panel's locale seat, passed down as a plain prop.
- * @returns the Output section's body element.
- */
-function OutputBody({ material, cwd, t }: { material: CallMaterial; cwd: string | undefined; t: DetailsPanelProps['t'] }) {
-  const terminal = terminalCardModel(material.block, cwd)
-  if (terminal !== null) {
-    // The contract renders the presenter's description above the card, and the
-    // panel has no summary row to carry it, so it is drawn here.
-    return (
-      <>
-        {terminal.description !== undefined && (
-          <div className={css.terminalDescription}>{terminal.description}</div>
-        )}
-        <TerminalBlock {...terminal.card} labels={terminalBlockLabels(t)} className={css.cardBody} />
-      </>
-    )
-  }
-  const read = readCardModel(material.block, cwd)
-  // The panel takes the primitive's own default cap, not the row's tighter one:
-  // it is the single-call reading surface, so the whole window is available.
-  if (read !== null) return <ReadBlock {...read} className={css.read} />
-  const diff = diffCardModel(material.block)
-  if (diff !== null) return <DiffBlock {...diff.card} className={css.cardBody} />
-  const search = searchCardModel(material.block)
-  if (search !== null) {
-    return (
-      <>
-        <SearchBlock {...search.card} className={css.cardBody} />
-        {/* A capped search's recovery locator lives only in the result text;
-            show it below the card so the dropped rows stay reachable. */}
-        {search.recovery !== undefined && (
-          <div className={css.searchRecovery}>{search.recovery}</div>
-        )}
-      </>
-    )
-  }
-  const web = webCardModel(material.block)
-  // The card shows every source the tool returned (the same list the model saw),
-  // scrolling within its own capped height. Below the card the panel also renders
-  // the flattened result content — the model-visible text the card does not carry
-  // verbatim (a web_fetch card shows only the URL and status, so its fetched body
-  // lives only here; a search card's answer and sources are structured, so the
-  // flattened form repeats them as the raw text the model saw).
-  if (web !== null) {
-    const settled = 'kind' in material.block ? material.block : null
-    const body = settled === null ? '' : resultText(settled)
-    return (
-      <>
-        <WebBlock {...web} className={css.web} />
-        {body !== '' && <pre className={css.code}>{body}</pre>}
-      </>
-    )
-  }
-  // A settled call always carries the result node the flattened form needs;
-  // the running shape has no result to flatten.
-  if (!('kind' in material.block)) return <div className={css.empty}>{t('details.running')}</div>
-  const result = material.block
-  return (
-    <pre className={css.code} data-error={result.isError || undefined}>
-      {resultText(result)}
-    </pre>
-  )
-}

+ 1 - 1
packages/client/ui-conversation/src/invariant.ts

@@ -17,7 +17,7 @@ export const inject = ['invariants']
 /**
  * No runtime invariant: the conversation service emits no cordis events, and
  * both rings this package owns (the 'conversation.view' tab ring and the
- * 'conversation.chat.toolview' row hole) ride the slot system, whose ledger
+ * 'conversation.chat.tool' whole-call seat) ride the slot system, whose ledger
  * invariants live with the runtime slots package.
  */
 const install: InvariantInstaller = () => {}

+ 7 - 127
packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx

@@ -1,35 +1,14 @@
 // @vitest-environment jsdom
-/**
- * Assembly-level acceptance on SlotTestRuntime (real apply, real slot
- * machinery, real renderer; data fed as fixtures) for surfaces that were
- * previously pinned only by the assembled-app jsdom snapshots
- * (apps/web/tests/{todo-display,terminal-card,slash-flow}.snapshot.ts):
- *
- * - the todo_write turn reaches BOTH surfaces through the product
- *   registrations (keyed toolview row in the flow, plan strip in the input
- *   dock via the 'todos' projection) and the strip follows projection
- *   retirement;
- * - the bash keyed row carries its resident terminal card, and the fallback
- *   row reaches the same card through its expand control;
- * - the resident composer textarea survives the blank→active conversion as
- *   the SAME DOM node (focus/IME continuity rides React reconciliation:
- *   component identity + tree position, which this assembled tree pins).
- *
- * Component-level behavior (collapse interaction, card model arms, summary
- * derivations) lives in todo-panel.spec.tsx / terminal-card.spec.tsx; this
- * suite only proves the assembled wiring.
- */
+/** Conversation assembly acceptance independent of Tool presentation. */
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 import { cleanup, fireEvent, waitFor, within } from '@testing-library/react'
 import { useState } from 'react'
 import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
-import type { ISession, SessionId, TodoItem, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
+import type { ISession, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
 import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
 import { SlotTestRuntime, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
 import { apply, inject, type EmptyWorkspaceOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
 
-// The service reads its initial locale from the browser; these specs assert
-// the shipped Chinese copy, so they state the browser they assume.
 usePinnedBrowserLanguages('zh-CN')
 
 const SID = 's1' as SessionId
@@ -50,30 +29,6 @@ beforeEach(() => {
   vi.stubGlobal('ResizeObserver', ResizeObserverStub)
 })
 
-const TODOS: TodoItem[] = [
-  { content: '梳理需求', status: 'completed' },
-  { content: '实现 fixture 样本', status: 'in_progress' },
-  { content: '浏览器验收', status: 'pending' },
-]
-
-const todoResult = (seq: number): ToolResultNode => ({
-  kind: 'tool-result', seq, time: seq * 1_000, callId: `todo-${seq}`,
-  call: { name: 'todo_write', argsRaw: JSON.stringify({ todos: TODOS }) },
-  callTime: seq * 1_000 - 500,
-  content: [], isError: false, callView: null, resultView: null,
-})
-
-const bashResult = (seq: number, callId: string, over?: Partial<ToolResultNode>): ToolResultNode => ({
-  kind: 'tool-result', seq, time: seq * 1_000, callId,
-  call: { name: 'bash', argsRaw: '{"command":"ls -la","description":"List files"}' },
-  callTime: seq * 1_000 - 500,
-  content: [{ type: 'text', text: 'total 2\ndemo.txt\n' }], isError: false,
-  callView: { card: 'terminal', title: 'ls -la', description: 'List files' },
-  resultView: { card: 'terminal', output: 'total 2\ndemo.txt\n', exitCode: 0 },
-  ...over,
-})
-
-/** Test-owned AppFrame role: declares and renders the resident conversation area. */
 type AppRootProps = PropsRenderSlots<'conversation' | 'details'>
 function AppRoot({ renderSlot }: AppRootProps) {
   return <>{renderSlot('conversation', {})}</>
@@ -84,7 +39,6 @@ const LAYOUT_CHILDREN = {
   'details': { kind: 'single', scope: 'session' },
 } as const
 
-/** Stateful occupant proving the root-scoped Hero workspace outlet is not rebuilt. */
 function WorkspaceProbe({ open }: EmptyWorkspaceOwnerProps) {
   const [count, setCount] = useState(0)
   return (
@@ -94,7 +48,7 @@ function WorkspaceProbe({ open }: EmptyWorkspaceOwnerProps) {
   )
 }
 
-async function bench(nodes: ToolResultNode[], opts?: { blank?: boolean }) {
+async function bench(opts?: { blank?: boolean }) {
   const runtime = await SlotTestRuntime.create()
   runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
   const locale = new LocaleService(runtime.ctx)
@@ -104,7 +58,7 @@ async function bench(nodes: ToolResultNode[], opts?: { blank?: boolean }) {
     id: SID,
     summary: { title: 'S', displayTitle: 'S', cwd: '/proj' },
     snapshot: {
-      nodes,
+      nodes: [],
       ...(opts?.blank === true ? { blank: true, composerPhase: 'blank' as const } : {}),
     },
     session: {
@@ -117,69 +71,6 @@ async function bench(nodes: ToolResultNode[], opts?: { blank?: boolean }) {
   return runtime
 }
 
-describe('todo_write assembly (product registrations, no outlet twins)', () => {
-  it('reaches the keyed toolview row and the dock plan strip, and the strip follows projection retirement', async () => {
-    const runtime = await bench([todoResult(3)])
-    // The dock strip reads the host-computed 'todos' projection.
-    runtime.sessions.behavior(SID).projections.set('todos', TODOS)
-    const view = runtime.renderRoot()
-
-    // Keyed toolview registration took the row (summary derived from args).
-    const row = view.container.querySelector('[data-tool="todo_write"]')
-    expect(row).not.toBeNull()
-    expect(row!.textContent).toContain('1/3 已完成 · 实现 fixture 样本')
-
-    // The plan strip sits in the input dock, fed by the projection
-    // (default-collapsed: the header summary shows; rows appear on expand).
-    const panel = view.container.querySelector('[data-testid="todo-panel"]')
-    expect(panel).not.toBeNull()
-    expect(panel!.textContent).toContain('1 已完成\u2002·\u20021 进行中\u2002·\u20021 待处理')
-    fireEvent.click(panel!.querySelector('button')!)
-    expect([...panel!.querySelectorAll('li')].map(li => li.getAttribute('data-status')))
-      .toEqual(['completed', 'in_progress', 'pending'])
-
-    // Next turn retires the standing plan (host pushes null): the strip
-    // clears while the historical row stays in the flow.
-    await runtime.flush()
-    runtime.sessions.behavior(SID).projections.set('todos', null)
-    await waitFor(() => {
-      expect(view.container.querySelector('[data-testid="todo-panel"]')).toBeNull()
-    })
-    expect(view.container.querySelector('[data-tool="todo_write"]')).not.toBeNull()
-    await runtime.dispose()
-  })
-})
-
-describe('terminal card assembly', () => {
-  it('both the keyed bash row and the fallback row reach the terminal card through the whole-row expand', async () => {
-    const runtime = await bench([
-      bashResult(3, 'c-keyed'),
-      // An unregistered tool with terminal views: GenericToolCard fallback.
-      bashResult(4, 'c-fallback', { call: { name: 'fx-bash', argsRaw: '{"command":"ls -la"}' } }),
-    ])
-    const view = runtime.renderRoot()
-
-    // Keyed BashRow: collapsed by default, the whole summary row is the toggle.
-    const keyedRow = view.container.querySelector('[data-sample="bash"]')
-    const keyed = keyedRow?.parentElement
-    expect(keyed?.querySelector('[data-terminal]')).toBeNull()
-    fireEvent.click(keyedRow!)
-    await waitFor(() => {
-      expect(keyed!.querySelector('[data-terminal]')).not.toBeNull()
-    })
-
-    // Fallback row: same unified expand interaction.
-    const fallback = view.container.querySelector('[data-tool="fx-bash"]')
-    expect(fallback).not.toBeNull()
-    expect(fallback!.querySelector('[data-terminal]')).toBeNull()
-    fireEvent.click(fallback!.querySelector('[data-expandable]')!)
-    await waitFor(() => {
-      expect(fallback!.querySelector('[data-terminal]')).not.toBeNull()
-    })
-    await runtime.dispose()
-  })
-})
-
 describe('resident composer', () => {
   it('renders the locked view state while no session exists at all', async () => {
     const runtime = await SlotTestRuntime.create()
@@ -190,8 +81,6 @@ describe('resident composer', () => {
     await runtime.root.declare(LAYOUT_CHILDREN, AppRoot)
     await runtime.mount({ inject: [...inject], apply })
     const view = runtime.renderRoot()
-    // No session entity: the inert twin renders (disabled textarea), and the
-    // workspace picker chip is the only live control.
     const textarea = view.container.querySelector('textarea')
     expect(textarea).not.toBeNull()
     expect(textarea!.disabled).toBe(true)
@@ -242,12 +131,8 @@ describe('resident composer', () => {
     await runtime.dispose()
   })
 
-
   it('the textarea survives the blank→active conversion as the same DOM node', async () => {
-    const runtime = await bench([], { blank: true })
-    // The hero renders the LIVE composer only when the blank session's
-    // workspace resolves a chip title; an ownerless blank session shows the
-    // disabled twin instead (deleted-workspace semantics).
+    const runtime = await bench({ blank: true })
     await runtime.workspaces.update((draft) => {
       draft.items = [{ workspaceId: 'w1', title: 'Proj', path: '/proj', sessionIds: [SID] }] as never
     })
@@ -256,13 +141,11 @@ describe('resident composer', () => {
     expect(hero).not.toBeNull()
     expect(hero!.disabled).toBe(false)
 
-    // First acceptance: the session leaves blank and the composer docks.
     await runtime.sessions.updateSnapshot(SID, (draft) => {
       draft.blank = false
       draft.composerPhase = 'active'
     })
-    const docked = view.container.querySelector('textarea')
-    expect(docked).toBe(hero)
+    expect(view.container.querySelector('textarea')).toBe(hero)
     await runtime.dispose()
   })
 })
@@ -291,8 +174,6 @@ describe('prompt rejection through the assembled composer', () => {
     fireEvent.keyDown(composer, { key: 'Enter' })
     await waitFor(() => { expect(prompt).toHaveBeenCalledOnce() })
 
-    // The rejection lands in snapshot.promptError (the Session's own path);
-    // the fixture mirrors that hop — the assembled InputBar renders it.
     await runtime.sessions.updateSnapshot(SID, (draft) => {
       draft.promptError = {
         op: 'send',
@@ -301,7 +182,6 @@ describe('prompt rejection through the assembled composer', () => {
     })
     const alert = await view.findByRole('alert')
     expect(alert.textContent).toContain('prompt rejected before acceptance (agent-busy)')
-    // Failure restore: the machine returned the draft to the same textarea.
     await waitFor(() => {
       expect((view.container.querySelector('textarea'))!.value).toBe('do not lose this')
     })
@@ -311,7 +191,7 @@ describe('prompt rejection through the assembled composer', () => {
 
 describe('title projection across assembled surfaces', () => {
   it('one summary update re-labels the current-session crumb', async () => {
-    const runtime = await bench([])
+    const runtime = await bench()
     const view = runtime.renderRoot()
     const hierarchy = view.getByRole('navigation', { name: '会话层级' })
     expect(within(hierarchy).getByRole('button', { name: 'S' }).hasAttribute('disabled')).toBe(true)

+ 10 - 14
packages/client/ui-conversation/tests/chat-apply.spec.tsx

@@ -1,12 +1,9 @@
 // @vitest-environment jsdom
 // apply wiring: the conversation service provided, the chat view registered
-// as the first 'conversation.view' ring entry declaring the keyed toolview
-// hole, the slot registrations land against a root entry's children
-// declarations (the AppFrame role), the shared store handle rides all strict
-// session entries, and the bash sample + todo row mount through declaration
-// injection as keyed entries. Full-chain rendering belongs to the
-// machinery spec (chat-toolview-slot.spec.tsx) and the shell e2e; this spec
-// stops at the assembly surface.
+// as the first 'conversation.view' ring entry declaring the whole-Tool seat,
+// the slot registrations land against a root entry's children declarations
+// (the AppFrame role), and the shared store handle rides all strict session
+// entries. Tool composition belongs to ui-tool and its machinery spec.
 
 import { describe, expect, it, vi } from 'vitest'
 import { SlotTestRuntime, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
@@ -56,7 +53,7 @@ describe('apply wiring', () => {
     await b.runtime.dispose()
   })
 
-  it('registers the chat view as the first ring entry, declaring the keyed toolview hole', async () => {
+  it('registers the chat view as the first ring entry, declaring the whole-Tool seat', async () => {
     const b = await bench()
     const entries = b.slots.entries('conversation.view')
     expect(entries.map(e => e.options.id)).toEqual(['chat'])
@@ -65,7 +62,7 @@ describe('apply wiring', () => {
     expect(entries[0]?.options.order).toBe(0)
     // Declaring is claiming: the chat entry's registration put the hole on
     // the ledger with the contract's kind/scope.
-    expect(b.slots.spec('conversation.chat.toolview')).toEqual({ kind: 'keyed', scope: 'session' })
+    expect(b.slots.spec('conversation.chat.tool')).toEqual({ kind: 'single', scope: 'session' })
     await b.runtime.dispose()
   })
 
@@ -92,14 +89,13 @@ describe('apply wiring', () => {
     await b.runtime.dispose()
   })
 
-  it('mounts the tool rows as keyed entries through declaration injection', async () => {
+  it('leaves per-Tool rows to the ui-tool plugin', async () => {
     const b = await bench()
     // The actual toolview declaration activates every registrant. The
     // file-mutation registrant claims both write and edit for the diff card; the
     // one search row registers under both grep and glob; the web rows register
     // one component under both web tool names.
-    const entries = b.slots.entries('conversation.chat.toolview')
-    expect(entries.map(e => e.options.key)).toEqual(['bash', 'read', 'edit', 'write', 'grep', 'glob', 'web_search', 'web_fetch', 'todo_write', 'ask_user_question'])
+    expect(b.slots.entries('conversation.chat.tool')).toHaveLength(0)
     // Stats stick with the composer (not inside ChatView).
     expect(b.slots.entries('conversation.composer.dock').map(e => e.options.id)).toEqual(['stats'])
     await b.runtime.dispose()
@@ -112,8 +108,8 @@ describe('apply wiring', () => {
     // The declared ring collapses with its declaring entry, and the chat
     // entry's keyed hole (with the sample's registration) collapses with it.
     expect(b.slots.entries('conversation.view')).toHaveLength(0)
-    expect(b.slots.entries('conversation.chat.toolview')).toHaveLength(0)
-    expect(b.slots.spec('conversation.chat.toolview')).toBeUndefined()
+    expect(b.slots.entries('conversation.chat.tool')).toHaveLength(0)
+    expect(b.slots.spec('conversation.chat.tool')).toBeUndefined()
     expect(b.slots.entries('details')).toHaveLength(0)
     expect(b.slots.entries('settings.general.item')).toHaveLength(0)
     expect(b.runtime.ctx.get('conversation')).toBeUndefined()

+ 4 - 49
packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx → packages/client/ui-conversation/tests/chat-stats.spec.tsx

@@ -1,26 +1,21 @@
 // @vitest-environment jsdom
-// StatsLine (composer.dock entry): totals derivation + the RFC
-// hard acceptance — zero renders during streaming. Bash sample row: ToolRow
-// chrome (Bash · description) without a row click target.
+// StatsLine (composer.dock entry): totals derivation + the RFC hard
+// acceptance — zero renders during streaming.
 
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 import { act, cleanup, fireEvent, render } from '@testing-library/react'
 import type {
-  AssistantMessageNode, ConversationSnapshot, SessionId, SessionListState, ToolResultNode,
+  AssistantMessageNode, ConversationSnapshot, SessionId, ToolResultNode,
 } from '@deepseek-ai/dsh-client-runtime/client'
-import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
 import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
 import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
 import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts'
 import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
 import { StatsLine, contextOccupancy, deriveStats, formatDuration, formatTokens, type StatsLineProps } from '../src/client/chat/StatsLine.tsx'
-import { BashRow } from '../src/client/toolviews/bash-sample.tsx'
 import { en, zh } from '../src/client/locales.ts'
 
-type BashRowProps = Parameters<typeof BashRow>[0]
-
 // Mirrors the real lookup chain (conversation namespace, then common).
-const t: BashRowProps['t'] = makeTranslate(zh, commonZh)
+const t: StatsLineProps['t'] = makeTranslate(zh, commonZh)
 const tEn: StatsLineProps['t'] = makeTranslate(en, commonEn)
 
 /** jsdom has no ResizeObserver; StatsLine watches its row for ellipsis truncation through one. */
@@ -301,43 +296,3 @@ describe('StatsLine', () => {
     expect(renders).toBe(before)
   })
 })
-
-describe('bash sample row', () => {
-  const SID = 'root-1' as SessionId
-
-  const result = (callId: string): ToolResultNode => ({
-    kind: 'tool-result', seq: 3, time: 3_000, callId,
-    call: { name: 'bash', argsRaw: '{"command":"make build","description":"Build"}' },
-    callTime: 2_000,
-    content: [], isError: false, callView: null, resultView: null,
-  })
-
-  function listStore() {
-    return createSnapshotStore<SessionListState>({
-      ids: [SID],
-      byId: {
-        [SID]: { id: SID, title: 'r', displayTitle: 'r', running: false, blank: false, updatedAt: 0 },
-      },
-      current: undefined,
-      phase: 'ready',
-      subagentsByParent: {},
-      currentAddress: undefined,
-    })
-  }
-
-  const rowProps = (): BashRowProps => ({
-    callId: 'c1', toolName: 'bash', block: result('c1'),
-    openFile: vi.fn(),
-    sessionId: SID,
-    useSessions: bindSnapshotSelector(listStore()),
-    t,
-  } as unknown as BashRowProps)
-
-  it('summarizes as Bash · description without a row click target', () => {
-    const view = render(<BashRow {...rowProps()} />)
-    const row = view.container.querySelector('[data-sample="bash"]')!
-    expect(row.textContent).toContain('Bash')
-    expect(row.textContent).toContain('Build')
-    expect(row.getAttribute('data-clickable')).toBeNull()
-  })
-})

+ 58 - 64
packages/client/ui-conversation/tests/chat-view.spec.tsx

@@ -1,7 +1,7 @@
 // @vitest-environment jsdom
 // ChatView behavior: flow derivation, streaming isolation (Profiler counts),
-// toolview dispatch and selection handoff — driven through a scripted
-// ObservableSnapshot fake, no wire.
+// Tool seat ownership and selection handoff — driven through a scripted
+// ObservableSnapshot fake, no wire or Tool presentation plugin.
 
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 import { Profiler } from 'react'
@@ -14,7 +14,7 @@ import type {
 import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
 import { createSnapshotStore, PendingWait } from '@deepseek-ai/dsh-client-runtime/client'
 import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
-import type { ChatViewSlotProps, SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client'
+import type { ChatViewSlotProps, SelectionTarget, ToolTreeOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
 import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
 import { createChatStore } from '../src/client/stores.ts'
@@ -137,12 +137,25 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
   const forkAt = vi.fn()
   // Selection rides the REAL chat store (same construction path as
   // production; the view reads it through the PropsStore useStore share).
-  // renderSlot stub renders the render-site fallback (an empty keyed ledger:
-  // every tool lands on GenericToolCard); keyed dispatch to registered rows
-  // is the slot machinery's behavior, covered by its own specs.
   const chat = createChatStore().create()
-  const renderSlot = ((_key: string, _owner: object, opts?: { fallback?: React.ReactNode }) =>
-    opts?.fallback ?? null) as unknown as ChatViewSlotProps['renderSlot']
+  const t = makeTranslate(zh, commonZh)
+  const toolOwners: ToolTreeOwnerProps[] = []
+  const renderSlot = ((key: string, owner: object, opts?: { fallback?: React.ReactNode }) => {
+    if (key !== 'conversation.chat.tool') return opts?.fallback ?? null
+    const tool = owner as ToolTreeOwnerProps
+    toolOwners.push(tool)
+    // Tool providers own their subtree. The host double carries only the
+    // semantic anchor required by ChatView's prepend-position contract.
+    return (
+      <div
+        data-testid={`tool-seat-${tool.callId}`}
+        data-chat-anchor-key={`call:${tool.callId}`}
+        data-chat-call-id={tool.callId}
+      >
+        {tool.toolName || '(unnamed)'}:{tool.callId}
+      </div>
+    )
+  }) as unknown as ChatViewSlotProps['renderSlot']
   const renderSlotChain = ((_key: string, _owner: object, opts?: { fallback?: React.ReactNode }) =>
     opts?.fallback ?? null) as unknown as ChatViewSlotProps['renderSlotChain']
   // SessionProvider seat arrives with the session-scope child declaration;
@@ -168,10 +181,13 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
     chatScroll,
     forkAt,
     // Mirrors the real lookup chain (conversation namespace, then common).
-    t: makeTranslate(zh, commonZh),
+    t,
   }
   const setSelection = (next: SelectionTarget | null): void => { chat.actions.select(next) }
-  return { set, ChatView, props, openDetails, openFile, loadOlder, inspectCall, chatScroll, forkAt, setSelection }
+  return {
+    set, ChatView, props, openDetails, openFile, loadOlder, inspectCall,
+    chatScroll, forkAt, setSelection, toolOwners,
+  }
 }
 
 /** Simulate reader input (any device): a delivered position that deviates
@@ -374,14 +390,13 @@ describe('chat-flow derivation', () => {
 })
 
 describe('ChatView', () => {
-  it('a windowless tool result (call head truncated) renders with an empty tool name', () => {
+  it('hands a windowless tool result to the Tool seat with an empty tool name', () => {
     const h = makeHarness({
       nodes: [{ ...toolResult(3, 'w1'), call: null }],
     })
     const view = render(<h.ChatView {...h.props} />)
-    // classifyTool('') → others; the summary slot falls back to the callId.
-    expect(view.container.querySelector('[data-variant="others"]')).not.toBeNull()
-    expect(view.getByText('w1')).toBeTruthy()
+    expect(view.getByTestId('tool-seat-w1')).toBeTruthy()
+    expect(h.toolOwners[0]).toMatchObject({ callId: 'w1', toolName: '' })
   })
 
   it('prepend keeps the reader\'s latest pending-request scroll position anchored', () => {
@@ -423,8 +438,8 @@ describe('ChatView', () => {
     const view = render(<h.ChatView {...h.props} />)
     expect(view.getByText('do the thing')).toBeTruthy()
     expect(view.getByText('running tools')).toBeTruthy()
-    expect(view.getAllByText('Bash')).toHaveLength(2)
-    expect(view.getByText('run a')).toBeTruthy()
+    expect(view.getByTestId('tool-seat-a').textContent).toBe('bash:a')
+    expect(view.getByTestId('tool-seat-b').textContent).toBe('bash:b')
     expect([...view.container.querySelectorAll('[data-chat-flow-key]')].map(row => ({
       key: row.getAttribute('data-chat-flow-key'),
       kind: row.getAttribute('data-chat-flow-kind'),
@@ -590,14 +605,12 @@ describe('ChatView', () => {
     ])
   })
 
-  it('the expanded row Inspect pill hands the call id to inspectCall', () => {
+  it('hands the trajectory callback to the Tool seat', () => {
     const h = makeHarness({
       nodes: [toolResult(3, 'a')],
     })
-    const view = render(<h.ChatView {...h.props} />)
-    fireEvent.click(view.getByRole('button', { name: /Bash/ }))
-    fireEvent.click(view.getByText('Inspect'))
-    expect(h.inspectCall).toHaveBeenCalledWith('a')
+    render(<h.ChatView {...h.props} />)
+    expect(h.toolOwners[0]?.inspectCall).toBe(h.inspectCall)
   })
 
   it('shows assistant IconActions only on the last content message of each turn', () => {
@@ -822,7 +835,7 @@ describe('ChatView', () => {
     // not re-render, so the row's renderSlot call count freezes during chunks.
     let rowRenders = 0
     h.props.renderSlot = ((key: string, _owner: object) => {
-      if (key !== 'conversation.chat.toolview') return null
+      if (key !== 'conversation.chat.tool') return null
       rowRenders += 1
       return <div data-testid="counting-row" />
     })
@@ -838,44 +851,19 @@ describe('ChatView', () => {
     expect(rowRenders).toBe(afterMount)
   })
 
-  it('tool row expands to the args body via the whole-row toggle', () => {
+  it('updates the selected call id handed to the Tool seat', () => {
     const h = makeHarness({ nodes: [toolResult(3, 'a')] })
-    const view = render(<h.ChatView {...h.props} />)
-    expect(view.queryByText(/"command": "cmd-a"/)).toBeNull()
-    fireEvent.click(view.container.querySelector('[data-expandable]')!)
-    expect(view.getByText(/"command": "cmd-a"/)).toBeTruthy()
-  })
-
-  it('clicking a bash summary does not open details; selection still marks data-selected', () => {
-    const h = makeHarness({ nodes: [toolResult(3, 'a')] })
-    const view = render(<h.ChatView {...h.props} />)
-    fireEvent.click(view.getByText('run a'))
-    expect(h.openDetails).not.toHaveBeenCalled()
-    expect(h.openFile).not.toHaveBeenCalled()
-    expect(view.container.querySelector('[data-selected]')).toBeNull()
+    render(<h.ChatView {...h.props} />)
+    expect(h.toolOwners.at(-1)?.selectedCallId).toBeUndefined()
     act(() => { h.setSelection({ turnSeq: 3, callId: 'a', toolName: 'bash' }) })
-    expect(view.container.querySelector('[data-selected]')).not.toBeNull()
+    expect(h.toolOwners.at(-1)?.selectedCallId).toBe('a')
   })
 
-  it('clicking a file-tool path summary opens the host file, not details', () => {
-    const h = makeHarness({
-      nodes: [{
-        kind: 'tool-result', seq: 3, time: 3_000, callId: 'r1',
-        call: { name: 'read', argsRaw: '{"path":"src/a.ts"}' },
-        callTime: 2_500, content: [], isError: false, callView: null, resultView: null,
-      }],
-    })
-    const view = render(<h.ChatView {...h.props} />)
-    fireEvent.click(view.getByText('src/a.ts'))
-    expect(h.openFile).toHaveBeenCalledWith('src/a.ts')
-    expect(h.openDetails).not.toHaveBeenCalled()
-  })
-
-  it('running calls render as a live tool group with the running state', () => {
+  it('hands running calls to a live Tool group', () => {
     const h = makeHarness({ runningCalls: [runningCall('r1')], running: true })
     const view = render(<h.ChatView {...h.props} />)
-    expect(view.container.querySelector('[data-state="running"]')).not.toBeNull()
-    expect(view.getByText('cmd-r1')).toBeTruthy()
+    expect(view.getByTestId('tool-seat-r1')).toBeTruthy()
+    expect(h.toolOwners[0]?.block).toMatchObject({ callId: 'r1', argsRaw: '{"command":"cmd-r1"}' })
     expect(view.getByRole('status').textContent).toBe('Deep diving...')
   })
 
@@ -903,19 +891,25 @@ describe('ChatView', () => {
     expect(status.textContent).toMatch(/^Deep diving\.\.\.2分0\d秒$/)
   })
 
-  it('dispatches each tool row through the keyed slot with the tool name as entryKey', () => {
-    const h = makeHarness({ nodes: [toolResult(3, 'a')] })
-    const calls: { key: string; entryKey?: string }[] = []
-    h.props.renderSlot = ((key: string, _owner: object, opts?: { entryKey?: string; fallback?: React.ReactNode }) => {
-      calls.push({ key, ...(opts?.entryKey !== undefined ? { entryKey: opts.entryKey } : {}) })
+  it('hands each ordered root call to the whole-Tool slot', () => {
+    const block = toolResult(3, 'a')
+    const h = makeHarness({ nodes: [block] })
+    const calls: { key: string; owner: object; entryKey?: string }[] = []
+    h.props.renderSlot = ((key: string, owner: object, opts?: { entryKey?: string; fallback?: React.ReactNode }) => {
+      calls.push({ key, owner, ...(opts?.entryKey !== undefined ? { entryKey: opts.entryKey } : {}) })
       return opts?.fallback ?? null
     })
     render(<h.ChatView {...h.props} />)
-    // Keyed dispatch: slot name is the declared hole, entryKey the wire tool
-    // name, and the fallback (GenericToolCard) renders on an empty ledger.
-    // (Registered-row takeover and live unload are slot machinery behavior,
-    // owned by the slot system's own specs.)
-    expect(calls).toEqual([{ key: 'conversation.chat.toolview', entryKey: 'bash' }])
+    expect(calls).toHaveLength(1)
+    expect(calls[0]).toMatchObject({
+      key: 'conversation.chat.tool',
+      owner: { callId: 'a', toolName: 'bash', selectedCallId: undefined },
+    })
+    const owner = calls[0]?.owner as ToolTreeOwnerProps
+    expect(owner.block).toBe(block)
+    expect(owner.openFile).toBe(h.openFile)
+    expect(owner.inspectCall).toBe(h.inspectCall)
+    expect(calls[0]?.entryKey).toBeUndefined()
   })
 
   it('prepend preserves a semantic row; a trailing user node force-scrolls', () => {

+ 5 - 85
packages/client/ui-conversation/tests/coverage-tails.spec.tsx

@@ -1,26 +1,17 @@
 // @vitest-environment jsdom
-// Branch tails the acceptance specs do not reach: ToolRow stopped-state dot,
-// bash sample state dots, the node-half empty apply, and AssistantMarkdown
-// reasoning/unknown block arms.
+// Branch tails the acceptance specs do not reach: the node-half empty apply
+// and AssistantMarkdown reasoning/unknown block arms.
 
-import { afterEach, describe, expect, it, vi } from 'vitest'
+import { afterEach, describe, expect, it } from 'vitest'
 import { cleanup, render } from '@testing-library/react'
-import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
-import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
-import type { RunningToolCall, SessionId, SessionListState, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
 import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
 import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
 import { apply as nodeApply } from '../src/index.ts'
-import { GenericToolCard, type GenericToolCardProps } from '../src/client/chat/GenericToolCard.tsx'
-import { ToolRow } from '../src/client/chat/ToolRow.tsx'
-import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
-import { BashRow } from '../src/client/toolviews/bash-sample.tsx'
+import { AssistantMarkdown, type AssistantMarkdownProps } from '../src/client/chat/AssistantMarkdown.tsx'
 import { zh } from '../src/client/locales.ts'
 
-type BashRowProps = Parameters<typeof BashRow>[0]
-
 // Mirrors the real lookup chain (conversation namespace, then common).
-const t: GenericToolCardProps['t'] = makeTranslate(zh, commonZh)
+const t: AssistantMarkdownProps['t'] = makeTranslate(zh, commonZh)
 
 afterEach(cleanup)
 
@@ -29,14 +20,6 @@ describe('tails', () => {
     expect(() => { nodeApply() }).not.toThrow()
   })
 
-  it('ToolRow stopped state renders the warning dot in the leading slot', () => {
-    const view = render(
-      <ToolRow t={t} variant="bash" icon={<i data-testid="icon" />} title="Bash" summary="s" body={null} state="stopped" />,
-    )
-    expect(view.queryByTestId('icon')).toBeNull()
-    expect(view.container.querySelector('[data-state="stopped"]')).not.toBeNull()
-  })
-
   it('AssistantMarkdown renders reasoning as a Think row and unknown blocks as JSON fallback', () => {
     const view = render(
       <AssistantMarkdown
@@ -73,67 +56,4 @@ describe('tails', () => {
     expect(blank.container.firstChild).toBeNull()
   })
 
-  it('a settled others-variant row renders the sparkle icon in the leading slot', () => {
-    const settled: ToolResultNode = {
-      kind: 'tool-result', seq: 2, time: 2_000, callId: 'c5',
-      call: { name: 'todo_write', argsRaw: '{"note":"x"}' },
-      callTime: 1_000,
-      content: [], isError: false, callView: null, resultView: null,
-    }
-    const props: GenericToolCardProps = {
-      callId: 'c5', toolName: 'todo_write', block: settled, openFile: vi.fn(), t,
-    }
-    const view = render(<GenericToolCard {...props} />)
-    // Settled ok state keeps the variant icon (sparkle) instead of a StateDot.
-    expect(view.container.querySelector('[data-variant="others"] svg')).not.toBeNull()
-    expect(view.container.querySelector('[data-state="ok"]')).not.toBeNull()
-  })
-
-  it('BashRow carries data-state for running (row sweep) and StateDots for error/stopped', () => {
-    const sid = 'root-1' as SessionId
-    const list = createSnapshotStore<SessionListState>({
-      ids: [sid],
-      byId: { [sid]: { id: sid, title: 'r', displayTitle: 'r', running: false, blank: false, updatedAt: 0 } },
-      current: undefined,
-      phase: 'ready',
-      subagentsByParent: {},
-      currentAddress: undefined,
-    })
-    const props = (block: RunningToolCall | ToolResultNode) => ({
-      callId: 'c1', toolName: 'bash', block, openFile: vi.fn(),
-      sessionId: sid, useSessions: bindSnapshotSelector(list),
-      t,
-    } as unknown as BashRowProps)
-
-    const running: RunningToolCall = {
-      callId: 'c1', name: 'bash', argsRaw: '{"command":"ls","description":"List"}',
-      turn: 1, step: 1, time: 1_000, callView: null,
-    }
-    const errorResult: ToolResultNode = {
-      kind: 'tool-result', seq: 1, time: 1_000, callId: 'c1',
-      call: { name: 'bash', argsRaw: '{"command":"boom"}' },
-      callTime: 500,
-      content: [], isError: true, callView: null, resultView: null,
-    }
-    const stoppedResult: ToolResultNode = {
-      ...errorResult,
-      error: { name: 'E', code: 'interrupted' },
-    }
-
-    const runningView = render(<BashRow {...props(running)} />)
-    expect(runningView.container.querySelector('[data-state="running"]')).not.toBeNull()
-    expect(runningView.getByText('Bash')).toBeTruthy()
-    expect(runningView.getByText('List')).toBeTruthy()
-    runningView.unmount()
-
-    const errorView = render(<BashRow {...props(errorResult)} />)
-    expect(errorView.container.querySelector('[data-sample="bash"]')).not.toBeNull()
-    expect(errorView.container.querySelector('[data-state="error"]')).not.toBeNull()
-    expect(errorView.getByText('失败')).toBeTruthy()
-    errorView.unmount()
-
-    const stoppedView = render(<BashRow {...props(stoppedResult)} />)
-    expect(stoppedView.container.querySelector('[data-state="stopped"]')).not.toBeNull()
-    expect(stoppedView.getByText('已停止')).toBeTruthy()
-  })
 })

+ 27 - 5
packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx

@@ -6,7 +6,8 @@ import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
 import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
 import type { UseSession } from '@deepseek-ai/dsh-client-web-react'
 import type { ConversationSnapshot, SessionId, SessionListState, WorkspaceListState } from '@deepseek-ai/dsh-client-runtime/client'
-import type { SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client'
+import type { SessionProviderComponent } from '@deepseek-ai/dsh-client-ui-slots'
+import type { DetailsSlotProps, DetailsToolOwnerProps, SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
 import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
 import { createChatStore } from '../src/client/stores.ts'
@@ -33,6 +34,17 @@ afterEach(() => {
 
 const SID = 's1' as SessionId
 
+/** Minimal framework seat for direct DetailsPanel host tests. */
+const SessionProviderStub: SessionProviderComponent = ({ children }) => children(SID)
+
+/** Observe the owner currency without importing the Tool details renderer. */
+function renderToolDetailsProbe(owners?: DetailsToolOwnerProps[]): DetailsSlotProps['renderSlot'] {
+  return (_key, owner) => {
+    owners?.push(owner as DetailsToolOwnerProps)
+    return <div data-testid="tool-details-seat" />
+  }
+}
+
 function snapshotBase(): ConversationSnapshot {
   return {
     sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
@@ -95,6 +107,8 @@ describe('render branch tails', () => {
     })
     const view = render(
       <DetailsPanel
+        SessionProvider={SessionProviderStub}
+        renderSlot={renderToolDetailsProbe()}
         sessionId={SID}
         useSession={bindSnapshotSelector({ getSnapshot: () => snap, subscribe: () => () => {} })}
         useSessions={bindSnapshotSelector(emptyList)}
@@ -130,8 +144,11 @@ describe('render branch tails', () => {
       items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
       baselinesReady: true, recentWorkspaceId: undefined,
     })
+    const owners: DetailsToolOwnerProps[] = []
     const view = render(
       <DetailsPanel
+        SessionProvider={SessionProviderStub}
+        renderSlot={renderToolDetailsProbe(owners)}
         sessionId={SID}
         useSession={bindSnapshotSelector({ getSnapshot: () => snap, subscribe: () => () => {} })}
         useSessions={bindSnapshotSelector(emptyList)}
@@ -145,10 +162,15 @@ describe('render branch tails', () => {
         t={t}
       />,
     )
-    // Sub-call material: the sub-tool name titles the panel, args pretty-print,
-    // and the COMPLETE logged output renders (no truncation anywhere).
+    // Conversation resolves the selected sub-call and hands its complete
+    // frozen block to the Tool-owned details seat.
     expect(view.getByText('read')).toBeTruthy()
-    expect(view.getByText(/notes\/demo\.txt/)).toBeTruthy()
-    expect(view.getByText(longText)).toBeTruthy()
+    expect(view.getByTestId('tool-details-seat')).toBeTruthy()
+    expect(owners).toHaveLength(1)
+    expect(owners[0]?.block).toMatchObject({
+      callId: 'p1:code:1',
+      call: { name: 'read', argsRaw: '{"path":"notes/demo.txt"}' },
+      content: [{ type: 'text', text: longText }],
+    })
   })
 })

+ 3 - 154
packages/client/ui-conversation/tests/todo-panel.spec.tsx

@@ -1,30 +1,20 @@
 // @vitest-environment jsdom
 /**
  * Todo display acceptance: the TodoPanel plan strip (empty-hidden, status rows
- * including several `in_progress` at once, collapse), its TodoDock adapter
- * (selects the plan off the session snapshot and follows changes), the row's
- * plan summary (counts plus the two halves of the active summary — the named
- * task and the `+N` count that parallel work adds, kept apart so the row never
- * ellipsizes the count away), and the todo_write toolview row (progress summary
- * from args, generic fallback on malformed JSON, shared ToolRow state dots and
- * leading expansion).
+ * including several `in_progress` at once, collapse), and its TodoDock
+ * adapter (selects the plan off the session snapshot and follows changes).
  */
 import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
 import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
-import type { TodoItem, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
+import type { TodoItem } from '@deepseek-ai/dsh-client-runtime/client'
 import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
 import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
-// Export discipline: packages/client/AGENTS.md.
-import { TodoRow, todoToolview } from '../src/client/toolviews/todo-row.tsx'
 import type { TodoDockProps } from '../src/client/skeleton/TodoPanel.tsx'
 import { TodoDock, TodoPanel, todoDockEntry } from '../src/client/skeleton/TodoPanel.tsx'
-import { planSummary } from '../src/client/toolviews/plan-summary.ts'
 import { NS, zh } from '../src/client/locales.ts'
 
-type TodoRowProps = Parameters<typeof TodoRow>[0]
-
 // Mirrors the real lookup chain (conversation namespace, then common).
 const t: TodoDockProps['t'] = makeTranslate(zh, commonZh)
 
@@ -45,40 +35,6 @@ const PARALLEL: TodoItem[] = [
   { content: '补测试', status: 'pending' },
 ]
 
-describe('planSummary', () => {
-  it('counts done/total and names the single active item with no extra count', () => {
-    expect(planSummary(LIST)).toEqual({ done: 1, total: 3, activeContent: '写组件', activeExtra: 0 })
-  })
-
-  it('reports the extra active count separately when several items are in progress', () => {
-    // Parallel work marks several: naming one and hiding the rest would lose
-    // them, and the count stays unjoined so the row cannot ellipsize it.
-    expect(planSummary(PARALLEL)).toEqual({ done: 1, total: 5, activeContent: '写组件', activeExtra: 2 })
-  })
-
-  it('has no hint when nothing is in progress', () => {
-    expect(planSummary([{ content: '都完了', status: 'completed' }]))
-      .toEqual({ done: 1, total: 1, activeContent: null, activeExtra: 0 })
-  })
-
-  it('has no hint when the first active item carries no usable content (model JSON)', () => {
-    // Unvalidated args: a missing, mistyped, empty, or whitespace-only content
-    // yields no hint — and no orphan count, even with a second active item to
-    // count. Whitespace-only is the tool's own rejection rule (trimmed
-    // non-empty), and a rejected call keeps its args verbatim.
-    expect(planSummary([{ status: 'in_progress' }, { content: 'x', status: 'in_progress' }]))
-      .toMatchObject({ activeContent: null, activeExtra: 0 })
-    expect(planSummary([{ content: 42, status: 'in_progress' }]).activeContent).toBeNull()
-    expect(planSummary([{ content: '', status: 'in_progress' }]).activeContent).toBeNull()
-    expect(planSummary([{ content: '   ', status: 'in_progress' }, { content: 'x', status: 'in_progress' }]))
-      .toMatchObject({ activeContent: null, activeExtra: 0 })
-  })
-
-  it('is empty-safe', () => {
-    expect(planSummary([])).toEqual({ done: 0, total: 0, activeContent: null, activeExtra: 0 })
-  })
-})
-
 describe('TodoPanel', () => {
   it('renders nothing while the list is empty', () => {
     const { container } = render(<TodoPanel todos={[]} t={t} />)
@@ -178,110 +134,3 @@ describe('TodoDock', () => {
     expect(register).toHaveBeenCalledWith({ name: 'conversation.input.dock', id: 'todo', order: 0, locale: NS }, TodoDock)
   })
 })
-
-const resultNode = (argsRaw: string, over?: Partial<ToolResultNode>): ToolResultNode => ({
-  kind: 'tool-result', seq: 10, time: 2_000, callTime: 1_000, callId: 'c1',
-  call: { name: 'todo_write', argsRaw },
-  content: [], isError: false, callView: null, resultView: null, ...over,
-})
-
-function rowProps(block: unknown): TodoRowProps {
-  return {
-    callId: 'c1', toolName: 'todo_write', block,
-    openFile: vi.fn(),
-    sessionId: 's1',
-    useSessions: () => undefined,
-    t,
-  } as unknown as TodoRowProps
-}
-
-describe('TodoRow', () => {
-  const ARGS = JSON.stringify({ todos: LIST })
-
-  it('summarizes counts and the active item from the call args', () => {
-    render(<TodoRow {...rowProps(resultNode(ARGS))} />)
-    expect(screen.getByText('更新任务清单')).toBeTruthy()
-    expect(screen.getByText('1/3 已完成 · 写组件')).toBeTruthy()
-  })
-
-  it('reports the extra active count outside the ellipsized summary text', () => {
-    const { container } = render(<TodoRow {...rowProps(resultNode(JSON.stringify({ todos: PARALLEL })))} />)
-    const text = screen.getByText('1/5 已完成 · 写组件')
-    const extra = screen.getByText('+2')
-    // Separate spans: .summary truncates, the count must not travel inside it.
-    expect(text.contains(extra)).toBe(false)
-    expect(container.textContent).toContain('1/5 已完成 · 写组件+2')
-  })
-
-  it('omits the active clause when no item is in progress and reads running-call args', () => {
-    const args = JSON.stringify({ todos: [{ content: 'x', status: 'completed' }] })
-    render(<TodoRow {...rowProps({ callId: 'c1', name: 'todo_write', argsRaw: args, turn: 1, step: 1, time: 1_000, callView: null })} />)
-    expect(screen.getByText('1/1 已完成')).toBeTruthy()
-  })
-
-  it('keeps the counts when an active item has unusable content, instead of the generic summary', () => {
-    // planSummary yields activeContent null here, but the counts are known good,
-    // so the row drops only the active clause — `?? model.summary` never runs.
-    const args = JSON.stringify({ todos: [{ content: 'done', status: 'completed' }, { content: 42, status: 'in_progress' }] })
-    const { container } = render(<TodoRow {...rowProps(resultNode(args))} />)
-    expect(screen.getByText('1/2 已完成')).toBeTruthy()
-    expect(container.textContent).not.toContain('+')
-  })
-
-  it('keeps the non-ok execution states visible through the shared row states', () => {
-    // A running call (no result yet) carries the running state (row sweep).
-    const args = JSON.stringify({ todos: LIST })
-    const running = render(<TodoRow {...rowProps({ callId: 'c1', name: 'todo_write', argsRaw: args, turn: 1, step: 1, time: 1_000, callView: null })} />)
-    expect(running.container.querySelector('[data-state="running"]')).not.toBeNull()
-    expect(running.container.querySelector('[data-state="running"] svg')).not.toBeNull()
-    running.unmount()
-    // A cancelled call wrote no todo/write: the row must not read as a completed update.
-    const stopped = render(<TodoRow {...rowProps(resultNode(args, { isError: true, error: { name: 'Interrupted', code: 'interrupted' } }))} />)
-    expect(stopped.container.querySelector('[data-state="stopped"]')).not.toBeNull()
-  })
-
-  it('falls back to the generic summary on malformed args and marks the error state', () => {
-    const view = render(<TodoRow {...rowProps(resultNode('not json', { isError: true }))} />)
-    expect(view.container.querySelector('[data-state="error"]')).not.toBeNull()
-    // Generic others summary: "<tool> · <raw>".
-    expect(screen.getByText('todo_write · not json')).toBeTruthy()
-  })
-
-  it('falls back when parsed args carry no todos array', () => {
-    render(<TodoRow {...rowProps(resultNode('{"other":1}'))} />)
-    expect(screen.getByText('todo_write · {"other":1}')).toBeTruthy()
-  })
-
-  it('leading toggle expands the raw args body', () => {
-    render(<TodoRow {...rowProps(resultNode(ARGS))} />)
-    fireEvent.click(screen.getByRole('button', { expanded: false }))
-    expect(screen.getByRole('button', { expanded: true })).toBeTruthy()
-    // The expanded body is the pretty-printed args, not the tool output.
-    expect(screen.getByText(/搭骨架/)).toBeTruthy()
-  })
-
-  it.each([
-    { label: 'null root', argsRaw: 'null' },
-    { label: 'non-object root', argsRaw: '42' },
-    { label: 'null items', argsRaw: '{"todos":[null]}' },
-  ])('falls back to the generic summary on valid JSON with an invalid shape ($label)', ({ argsRaw }) => {
-    render(<TodoRow {...rowProps(resultNode(argsRaw))} />)
-    // No throw, and the generic others summary carries the raw args verbatim.
-    expect(screen.getByText(`todo_write · ${argsRaw}`)).toBeTruthy()
-  })
-
-  it('window-truncated result (call head lost) falls back to the callId summary', () => {
-    render(<TodoRow {...rowProps(resultNode('', { call: null }))} />)
-    expect(screen.getByText('todo_write · c1')).toBeTruthy()
-  })
-
-  it('todoToolview injects the toolview declaration directly', () => {
-    expect(todoToolview.name).toBe('todo-toolview')
-    expect(todoToolview.inject).toEqual(['slots'])
-    const register = vi.fn(() => () => undefined)
-    const inject = vi.fn((_name: string, callback: () => () => void) => callback())
-    todoToolview.apply({ slots: { inject, register } } as never)
-    expect(inject).toHaveBeenCalledWith('conversation.chat.toolview', expect.any(Function))
-    expect(register).toHaveBeenCalledWith({ name: 'conversation.chat.toolview', key: 'todo_write', locale: NS }, TodoRow)
-  })
-})

+ 4 - 33
packages/client/ui-conversation/tests/views-type-chain.spec.tsx

@@ -1,16 +1,11 @@
-// View-ring + toolview-hole type-chain samples, slot form: both are declared
-// slots, so the register→inject→render chain and its compile-time locks are
-// the slot system's (ui-slots/tests/type-chain.spec.tsx owns the generic
-// duals). This spec pins the package-specific surface: the SlotMap rows
-// (kind/scope/owner), list- and keyed-kind registration shapes, the ChatView
-// and tool-row composed-props contracts, and the runtime dual — a real
-// SlotsService ledger driving registration/order/disposal the way
-// ConversationRoot's tab projection consumes it.
+// View-ring type-chain samples. This spec pins the conversation-owned SlotMap
+// row, list-kind registration shape, composed view props, and the runtime
+// ledger projection consumed by ConversationRoot.
 import { Context } from 'cordis'
 import { describe, expect, it } from 'vitest'
 import type { ReactNode } from 'react'
 import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
-import type { ChatViewSlotProps, ConvViewProps, ToolRowProps } from '../src/client/contract/slots.ts'
+import type { ChatViewSlotProps, ConvViewProps } from '../src/client/contract/slots.ts'
 
 describe('view-ring type negatives (compile-time; body never runs)', () => {
   it('holds the negative samples as expect-error sites', () => {
@@ -54,30 +49,6 @@ describe('view-ring type negatives (compile-time; body never runs)', () => {
         return null
       }
       void chatProps
-      // 7. Keyed hole registration requires the key shape field.
-      // @ts-expect-error missing `key` on a keyed-slot registration
-      slots.register({ name: 'conversation.chat.toolview' }, (_p: ToolRowProps) => null)
-      // 8. A list-kind shape field is rejected on the keyed hole.
-      slots.register(
-        // @ts-expect-error `id`/`order` belong to list slots, not the keyed hole
-        { name: 'conversation.chat.toolview', key: 'k', order: 1 },
-        (_p: ToolRowProps) => null)
-      // 9. Tool-row components stay within their composed contract: the
-      //    owner share + standard kit supply no chat-view members.
-      const overreaching = (props: ToolRowProps): ReactNode => {
-        // @ts-expect-error loadOlder lives on ChatViewSlotProps, not the row contract
-        void props.loadOlder
-        return null
-      }
-      void overreaching
-      // 10. Owner-share drift is red at the row component seam: block is the
-      //     call union, not arbitrary payload.
-      const drifted = (props: ToolRowProps): ReactNode => {
-        // @ts-expect-error the block union has no `argsParsed` member
-        void props.block.argsParsed
-        return null
-      }
-      void drifted
       return null as ReactNode
     }
     expect(negatives).toBeTypeOf('function')

+ 2 - 2
packages/client/ui-skill/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-skill/README.md
-README.md: bdd772662acda1f8cf1b7d8a7c5532f9b37123dd
-README.zh.md: 959ff0ede6d545150fb22710c8af75859966caa9
+README.md: 44953fe36ad337d0dd70e4d8c0cc2372b8924c9b
+README.zh.md: 8c21ef35eded61324d139dd32b7c1e38f8709d55

+ 1 - 1
packages/client/ui-skill/README.md

@@ -12,7 +12,7 @@ The `/client` export surface is the plugin body (`apply`/`inject`) only; the sou
 
 ## Skill tool row
 
-The browser plugin also registers a keyed `skill` toolview in `conversation.chat.toolview`. A collapsed row renders the 14-pixel skill document-and-sparkle glyph, `Skill` title, separator, and requested skill name with the same neutral hierarchy as the Bash row; running calls carry the transcript shimmer, failures replace the name with the first error line, and interrupted calls use the warning state. A settled row expands as one whole-row disclosure into a bounded `Instructions` card containing the exact durable tool output, with the standard trajectory `Inspect` affordance when available. The row derives its name, lifecycle, and body only from a paired call/result slice in the current runtime window, never from the current catalog, so replay remains stable when installed skills or their descriptions change.
+The browser plugin also registers the `skill` wire name in `ui-tool`'s keyed `tool.call.toolview` slot. A collapsed row renders the 14-pixel skill document-and-sparkle glyph, `Skill` title, separator, and requested skill name with the same neutral hierarchy as the Bash row; running calls carry the transcript shimmer, failures replace the name with the first error line, and interrupted calls use the warning state. A settled row expands as one whole-row disclosure into a bounded `Instructions` card containing the exact durable tool output, with the standard trajectory `Inspect` affordance when available. The row derives its name, lifecycle, and body only from the frozen call/result slice supplied by `ui-tool`, never from the current catalog, so replay remains stable when installed skills or their descriptions change.
 
 ## Model Experience
 

+ 1 - 1
packages/client/ui-skill/README.zh.md

@@ -12,7 +12,7 @@ pick 会落下字面文本 `/name `,提示词发出的就是同一段字面文
 
 ## skill 工具行
 
-浏览器插件还会把一个 key 为 `skill` 的 toolview 注册进 `conversation.chat.toolview`。收起的行以与 Bash 行相同的中性色层级显示 14 像素的 skill 文档与闪光组合图标、`Skill` 标题、分隔符和请求加载的 skill 名称;运行中的调用带有 transcript(文本记录)的扫光效果,失败时用错误首行替换名称,中断调用则使用警告状态。已结算的行以整行作为展开入口,展开后显示一个尺寸受限的 `Instructions` 卡片,其中原样呈现持久化的工具输出;可用时还会提供标准执行轨迹的 `Inspect` 入口。该行的名称、生命周期和正文只派生自当前 runtime 窗口中已配对的调用/结果片段,绝不读取当前 skill 目录,因此即使已安装的 skill 或其描述发生变化,回放仍保持稳定。
+浏览器插件还会把 `skill` wire 名称注册进 `ui-tool` 的 keyed `tool.call.toolview` slot。收起的行以与 Bash 行相同的中性色层级显示 14 像素的 skill 文档与闪光组合图标、`Skill` 标题、分隔符和请求加载的 skill 名称;运行中的调用带有 transcript(文本记录)的扫光效果,失败时用错误首行替换名称,中断调用则使用警告状态。已结算的行以整行作为展开入口,展开后显示一个尺寸受限的 `Instructions` 卡片,其中原样呈现持久化的工具输出;可用时还会提供标准执行轨迹的 `Inspect` 入口。该行的名称、生命周期和正文只派生自 `ui-tool` 提供的冻结 call/result slice,绝不读取当前 skill 目录,因此即使已安装的 skill 或其描述发生变化,回放仍保持稳定。
 
 ## 模型体验
 

+ 3 - 3
packages/client/ui-skill/package.json

@@ -26,7 +26,7 @@
     "inject": [
       "@deepseek-ai/dsh-client-runtime",
       "@deepseek-ai/dsh-client-locale",
-      "@deepseek-ai/dsh-client-ui-conversation",
+      "@deepseek-ai/dsh-client-ui-tool",
       "@deepseek-ai/dsh-client-ui-slash"
     ],
     "platform": "web"
@@ -40,7 +40,7 @@
     "@deepseek-ai/dsh-client-connection": "^0.0.1",
     "@deepseek-ai/dsh-client-locale": "^0.0.1",
     "@deepseek-ai/dsh-client-runtime": "^0.0.1",
-    "@deepseek-ai/dsh-client-ui-conversation": "^0.0.1",
+    "@deepseek-ai/dsh-client-ui-tool": "^0.0.1",
     "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
     "@deepseek-ai/dsh-client-ui-slash": "^0.0.1",
     "@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
@@ -53,7 +53,7 @@
     "@deepseek-ai/dsh-client-locale": "workspace:^",
     "@deepseek-ai/dsh-client-runtime": "workspace:^",
     "@deepseek-ai/dsh-client-test-runtime": "workspace:^",
-    "@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-tool": "workspace:^",
     "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
     "@deepseek-ai/dsh-client-ui-slash": "workspace:^",
     "@deepseek-ai/dsh-client-ui-slots": "workspace:^",

+ 6 - 6
packages/client/ui-skill/src/client/SkillRow.tsx

@@ -6,7 +6,7 @@ import { useState, type KeyboardEvent, type ReactNode } from 'react'
 import {
   IconChevronDownOutline14, IconInspectOutline12, IconSkillOutline16, StateDot,
 } from '@deepseek-ai/dsh-client-ui-primitives'
-import type { ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
+import type { ToolCallViewProps } from '@deepseek-ai/dsh-client-ui-tool/client'
 import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
 import css from './SkillRow.module.css'
 
@@ -14,7 +14,7 @@ import css from './SkillRow.module.css'
 type SkillRowState = 'running' | 'ok' | 'error' | 'stopped'
 
 /** Full row props: the toolview runtime share plus this package's locale seat. */
-type SkillRowProps = ToolRowProps & PropsLocale<'skill'>
+type SkillRowProps = ToolCallViewProps & PropsLocale<'skill'>
 
 /** Compact, replay-stable view model for the dedicated row. */
 interface SkillRowModel {
@@ -45,9 +45,9 @@ function skillName(argsRaw: string, callId: string): string {
   return argsRaw === '' ? callId : firstLine(argsRaw)
 }
 
-/** Flatten durable result blocks under the generic tool-row text contract.
- *  Keep aligned with ui-conversation's contract/tool-call-model.ts `resultText`. */
-function resultText(block: ToolRowProps['block']): string | null {
+/** Flatten durable result blocks under the generic Tool-row text contract.
+ *  Keep aligned with ui-tool's models/tool-call-model.ts `resultText`. */
+function resultText(block: ToolCallViewProps['block']): string | null {
   if (!('kind' in block)) return null
   const parts: string[] = []
   for (const item of block.content) {
@@ -60,7 +60,7 @@ function resultText(block: ToolRowProps['block']): string | null {
 }
 
 /** Derive display state without consulting the live skill catalog. */
-function skillRowModel(block: ToolRowProps['block']): SkillRowModel {
+function skillRowModel(block: ToolCallViewProps['block']): SkillRowModel {
   const settled = 'kind' in block
   const argsRaw = (settled ? block.call?.argsRaw : block.argsRaw) ?? ''
   const state: SkillRowState = !settled

+ 2 - 2
packages/client/ui-skill/src/client/index.ts

@@ -58,8 +58,8 @@ export const inject = ['slash', 'connection', 'sessions', 'slots', 'locale']
  */
 export function apply(ctx: ClientContext): void {
   ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-skill: dictionaries')
-  ctx.slots.inject('conversation.chat.toolview', () => ctx.slots.register(
-    { name: 'conversation.chat.toolview', key: 'skill', locale: NS },
+  ctx.slots.inject('tool.call.toolview', () => ctx.slots.register(
+    { name: 'tool.call.toolview', key: 'skill', locale: NS },
     SkillRow,
   ))
 

+ 3 - 3
packages/client/ui-skill/tests/browser-plugin.spec.ts

@@ -41,7 +41,7 @@ function providePresentation(ctx: Context): PresentationCapture {
   const slots = new SlotsService(ctx)
   slots.register({
     name: 'root',
-    children: { 'conversation.chat.toolview': { kind: 'keyed', scope: 'session' } },
+    children: { 'tool.call.toolview': { kind: 'keyed', scope: 'session' } },
   } as never, () => null)
   const capture: PresentationCapture = {
     slots,
@@ -113,7 +113,7 @@ describe('apply', () => {
     ctx.provide('sessions', { subagentAddress: () => undefined })
     const presentation = providePresentation(ctx)
     await ctx.plugin({ inject: [...inject], apply }).await()
-    const entry = presentation.slots.entries('conversation.chat.toolview')[0]
+    const entry = presentation.slots.entries('tool.call.toolview')[0]
     expect(entry?.options).toMatchObject({ key: 'skill' })
     expect(entry?.locale).toBe('skill')
     expect(entry?.component).toBe(SkillToolRow)
@@ -158,7 +158,7 @@ describe('apply', () => {
     // …and fiber teardown releases it.
     await fiber.dispose()
     expect(() => slash.registerSource(rival)).not.toThrow()
-    expect(presentation.slots.entries('conversation.chat.toolview')).toHaveLength(0)
+    expect(presentation.slots.entries('tool.call.toolview')).toHaveLength(0)
     expect(presentation.localeDisposed).toBe(true)
   })
 })

+ 1 - 1
packages/client/ui-skill/tsconfig.json

@@ -21,7 +21,7 @@
       "path": "../runtime"
     },
     {
-      "path": "../ui-conversation"
+      "path": "../ui-tool"
     },
     {
       "path": "../ui-primitives"

+ 6 - 0
packages/client/ui-tool/README.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write packages/client/ui-tool/README.md
+README.md: 381253f4eddaa57b89318dd23da3a049505fdd15
+README.zh.md: ae539131198771bc1d0e280bbfaa76ec0ec60792

+ 44 - 0
packages/client/ui-tool/README.md

@@ -0,0 +1,44 @@
+# @deepseek-ai/dsh-client-ui-tool
+
+English | [中文](README.zh.md)
+
+Client Tool presentation plugin. `ui-conversation` supplies one ordered root call through `conversation.chat.tool`; this package renders that root and its Code Dispatch children, then dispatches every atomic call through the keyed `tool.call.toolview` slot. Unregistered Tool names use the generic card.
+
+Business UI packages register only their wire Tool names and atomic views. They do not pair Session events, rebuild the transcript, or own root/subcall topology. The Runtime remains authoritative for call/result pairing, lifecycle, and `codeDispatches`; the conversation view remains authoritative for ChatFlow placement.
+
+## Rendering contract
+
+`ToolCallTree` receives one root `ToolCallBlock`, selection state, the session `cwd`, and Host callbacks for opening files and inspecting calls. Through its standard session slot props it selects the Runtime-projected `codeDispatches[rootCallId]` array, then sends the root and every child through the same atomic dispatch path. The Runtime currently exposes only one Code Dispatch child level, so the renderer preserves that shape instead of inventing recursive data.
+
+The package also fills `conversation.details.tool` with `ToolDetails`. The row and details renderers share the same pure card models for `terminal`, `read`, `diff`, `search`, and `web` render intents. Unknown intent tags and malformed wire card data fall back to flattened Tool result text.
+
+Generic rows classify known Tool names into search, read, shell, write, edit, code, or generic variants. Running, successful, failed, and interrupted lifecycle states come only from the frozen call/result slice. File paths resolve against the session `cwd` only when the user invokes the Host open-file callback; presentation code does not read Session services.
+
+## Atomic Tool views
+
+An owning business package registers its wire Tool name into `tool.call.toolview`:
+
+```ts ignore-check
+ctx.slots.inject('tool.call.toolview', () =>
+  ctx.slots.register({
+    name: 'tool.call.toolview',
+    key: '<wire tool name>',
+  }, BusinessToolRow))
+```
+
+The owner payload is `ToolCallOwnerProps`: `callId`, `toolName`, the frozen `block`, optional `cwd`, and plain `openFile`/`inspect` callbacks. The registration receives the normal session slot runtime share. It does not receive React nodes, Runtime services, or root/subcall knowledge.
+
+This package currently owns the generic fallback and the built-in bash/pwsh, read, write/edit, grep/glob, web, todo, question, and Code Dispatch presentations. `ui-skill` demonstrates a business-owned registration for `skill`.
+
+## Model Experience
+
+None. This package renders already logged Tool calls and results and does not alter model requests, Tool execution, or session events.
+
+#### KV Cache effect
+
+None. The package is client-only presentation.
+
+## Known Limitations and Deferred Work
+
+- The Runtime currently exposes one level of Code Dispatch children. The renderer sends roots and children through the same atomic path, but it does not claim an arbitrary recursive wire topology.
+- Existing first-party Tool views are initially colocated here and can move to their owning business packages independently through the keyed slot.

+ 44 - 0
packages/client/ui-tool/README.zh.md

@@ -0,0 +1,44 @@
+# @deepseek-ai/dsh-client-ui-tool
+
+[English](README.md) | 中文
+
+Client Tool 展示插件。`ui-conversation` 通过 `conversation.chat.tool` 交付一个已经排好位置的 root call;本包渲染该 root 及其 Code Dispatch 子调用,并把每个原子调用通过 keyed slot `tool.call.toolview` 分发。没有注册的 Tool 名称使用通用卡片。
+
+业务 UI 包只注册 wire Tool 名称和原子视图,不配对 Session Event、不重建 transcript,也不拥有 root/subcall 拓扑。Runtime 继续负责 call/result 配对、生命周期和 `codeDispatches`;conversation view 继续负责 ChatFlow 位置。
+
+## 渲染契约
+
+`ToolCallTree` 接收一个 root `ToolCallBlock`、selection 状态、会话 `cwd`,以及用于打开文件和检查调用的 Host 回调。它通过标准 session slot props 选择 Runtime 投影的 `codeDispatches[rootCallId]` 数组,再让 root 与每个 child 经过同一条原子分发路径。Runtime 当前只暴露一层 Code Dispatch child,因此 renderer 保留该形状,不自行发明递归数据。
+
+本包还通过 `ToolDetails` 填充 `conversation.details.tool`。行 renderer 与详情 renderer 为 `terminal`、`read`、`diff`、`search` 和 `web` render intent 共用同一组纯 card model。本版本不认识的 intent 标签和格式错误的 wire card 数据都会回退为压平的 Tool result 文本。
+
+通用行把已知 Tool 名称归类为 search、read、shell、write、edit、code 或 generic 变体。运行中、成功、失败和中断状态只来自冻结的 call/result slice。只有用户调用 Host 打开文件回调时,文件路径才相对会话 `cwd` 解析;展示代码不读取 Session service。
+
+## 原子 Tool 视图
+
+业务所有方把自己的 wire Tool 名称注册进 `tool.call.toolview`:
+
+```ts ignore-check
+ctx.slots.inject('tool.call.toolview', () =>
+  ctx.slots.register({
+    name: 'tool.call.toolview',
+    key: '<wire tool name>',
+  }, BusinessToolRow))
+```
+
+owner 载荷为 `ToolCallOwnerProps`:`callId`、`toolName`、冻结的 `block`、可选 `cwd`,以及普通的 `openFile`/`inspect` 回调。注册项会收到正常的 Session slot runtime share,但不会收到 React node、Runtime service 或 root/subcall 知识。
+
+本包当前拥有 generic fallback,以及 bash/pwsh、read、write/edit、grep/glob、web、todo、question 和 Code Dispatch 的内置展示。`ui-skill` 展示了业务包如何拥有 `skill` 注册。
+
+## 模型体验
+
+无。本包只渲染已经记录的 Tool 调用和结果,不改变模型请求、Tool 执行或 Session Event。
+
+#### KV Cache 影响
+
+无。本包只负责 Client 展示。
+
+## 已知限制与后续工作
+
+- Runtime 当前只暴露一层 Code Dispatch 子调用。renderer 会让 root 和 child 经过同一个原子分发路径,但不宣称 wire 拓扑已经支持任意递归。
+- 现有第一方 Tool 视图初期仍集中在本包,之后可以通过 keyed slot 独立迁回各自业务包。

+ 73 - 0
packages/client/ui-tool/package.json

@@ -0,0 +1,73 @@
+{
+  "name": "@deepseek-ai/dsh-client-ui-tool",
+  "description": "Client Tool call-tree renderer and keyed per-tool presentation slot",
+  "version": "0.0.1",
+  "private": true,
+  "type": "module",
+  "main": "lib/index.js",
+  "types": "lib/types/index.d.ts",
+  "exports": {
+    ".": {
+      "types": "./lib/types/index.d.ts",
+      "default": "./lib/index.js"
+    },
+    "./invariant": {
+      "types": "./lib/types/invariant.d.ts",
+      "default": "./lib/invariant.js"
+    },
+    "./client": {
+      "types": "./lib/types/client/index.d.ts",
+      "default": "./lib/client.js"
+    },
+    "./src/*": "./src/*",
+    "./package.json": "./package.json"
+  },
+  "dshClient": {
+    "inject": [
+      "@deepseek-ai/dsh-client-runtime",
+      "@deepseek-ai/dsh-client-locale",
+      "@deepseek-ai/dsh-client-ui-conversation"
+    ],
+    "platform": "web"
+  },
+  "scripts": {
+    "bundle": "tsdown",
+    "watch": "tsdown --watch"
+  },
+  "license": "BSD-3-Clause",
+  "dependencies": {
+    "clsx": "^2.0.0"
+  },
+  "peerDependencies": {
+    "@deepseek-ai/dsh-client-locale": "^0.0.1",
+    "@deepseek-ai/dsh-client-runtime": "^0.0.1",
+    "@deepseek-ai/dsh-client-ui-conversation": "^0.0.1",
+    "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
+    "@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
+    "@deepseek-ai/dsh-invariants": "^0.0.1",
+    "cordis": "^4.0.0-rc.7",
+    "react": "^18.2.0"
+  },
+  "devDependencies": {
+    "@deepseek-ai/dsh-client-connection": "workspace:^",
+    "@deepseek-ai/dsh-client-locale": "workspace:^",
+    "@deepseek-ai/dsh-client-runtime": "workspace:^",
+    "@deepseek-ai/dsh-client-test-runtime": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
+    "@deepseek-ai/dsh-client-web-react": "workspace:^",
+    "@deepseek-ai/dsh-invariants": "workspace:^",
+    "@testing-library/react": "^16.1.0",
+    "@types/react": "~18.3.1",
+    "cordis": "^4.0.0-rc.7",
+    "react": "^18.2.0",
+    "react-dom": "^18.2.0"
+  },
+  "files": [
+    "lib/index.js",
+    "lib/invariant.js",
+    "lib/client.js",
+    "lib/types/**/*.d.ts"
+  ]
+}

+ 43 - 0
packages/client/ui-tool/src/client/apply.ts

@@ -0,0 +1,43 @@
+/** Register the Tool call tree, details renderer, and built-in atomic views. */
+import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
+import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
+import { ToolCallTree } from './tool/ToolCallTree.tsx'
+import { ToolDetails } from './tool/ToolDetails.tsx'
+import { CONVERSATION_NS as NS } from './locale.ts'
+import { askQuestionToolview } from './tool/toolviews/ask-question-row.tsx'
+import { bashToolviewSample } from './tool/toolviews/bash-sample.tsx'
+import { fileMutationToolview } from './tool/toolviews/file-mutation-row.tsx'
+import { readToolview } from './tool/toolviews/read-row.tsx'
+import { searchToolview } from './tool/toolviews/search-row.tsx'
+import { todoToolview } from './tool/toolviews/todo-row.tsx'
+import { webToolview } from './tool/toolviews/web-row.tsx'
+
+/** Required service: the slot registry that owns both Tool render seats. */
+export const inject = ['slots']
+
+/**
+ * Mount the whole-Tool renderers and built-in atomic Tool registrations.
+ * @param ctx - Client root context.
+ */
+export function apply(ctx: ClientContext): void {
+  ctx.slots.inject('conversation.chat.tool', () => ctx.slots.register({
+    name: 'conversation.chat.tool',
+    locale: NS,
+    children: {
+      'tool.call.toolview': { kind: 'keyed', scope: 'session' },
+    },
+  }, ToolCallTree))
+
+  ctx.slots.inject('conversation.details.tool', () => ctx.slots.register({
+    name: 'conversation.details.tool',
+    locale: NS,
+  }, ToolDetails))
+
+  ctx.plugin(bashToolviewSample)
+  ctx.plugin(readToolview)
+  ctx.plugin(fileMutationToolview)
+  ctx.plugin(searchToolview)
+  ctx.plugin(webToolview)
+  ctx.plugin(todoToolview)
+  ctx.plugin(askQuestionToolview)
+}

+ 39 - 0
packages/client/ui-tool/src/client/contract/slots.ts

@@ -0,0 +1,39 @@
+/** Tool UI slot declarations and their composed component props. */
+import type { PropsLocale, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
+import type { ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client'
+import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
+import type {} from '@deepseek-ai/dsh-client-locale/client'
+
+declare module '@deepseek-ai/dsh-client-ui-slots' {
+  interface SlotMap {
+    /** Keyed atomic Tool call view, dispatched by the wire Tool name. */
+    'tool.call.toolview': { kind: 'keyed'; scope: 'session'; owner: ToolCallOwnerProps }
+  }
+}
+
+/** Standard owner currency supplied to every atomic Tool view. */
+export interface ToolCallOwnerProps {
+  /** Tool call identity, stable across running and settled forms. */
+  callId: string
+  /** Wire Tool name and keyed dispatch value. */
+  toolName: string
+  /** Frozen running call or settled result node. */
+  block: ToolCallBlock
+  /** Session workspace root for relative summaries. */
+  cwd?: string | undefined
+  /** Open a Tool argument path through the Host. */
+  openFile: (path: string) => void
+  /** Inspect this call in the trajectory view when available. */
+  inspect?: (() => void) | undefined
+}
+
+/** Full props of a registered atomic Tool view. */
+export type ToolCallViewProps = PropsRuntime<'tool.call.toolview'>
+
+/** Full props of the Tool call-tree renderer registered into the chat flow. */
+export type ToolTreeProps = PropsRuntime<'conversation.chat.tool'>
+  & PropsRenderSlots<'tool.call.toolview'>
+  & PropsLocale<'conversation'>
+
+/** Full props of the selected Tool output renderer in the details panel. */
+export type ToolDetailsProps = PropsRuntime<'conversation.details.tool'> & PropsLocale<'conversation'>

+ 3 - 0
packages/client/ui-tool/src/client/index.ts

@@ -0,0 +1,3 @@
+/** Browser Tool plugin: whole-call composition and keyed atomic Tool views. */
+export { apply, inject } from './apply.ts'
+export type { ToolCallOwnerProps, ToolCallViewProps, ToolDetailsProps, ToolTreeProps } from './contract/slots.ts'

+ 2 - 0
packages/client/ui-tool/src/client/locale.ts

@@ -0,0 +1,2 @@
+/** Locale namespace supplied by the conversation owner to Tool renderers. */
+export const CONVERSATION_NS = 'conversation'

+ 12 - 0
packages/client/ui-tool/src/client/tool/ToolCallTree.module.css

@@ -0,0 +1,12 @@
+.callRow {
+  border-radius: 6px;
+}
+
+.subCalls {
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+  margin: 4px 0 2px 22px;
+  padding-left: 8px;
+  border-left: 1px solid var(--dsw-alias-border-l2);
+}

+ 88 - 0
packages/client/ui-tool/src/client/tool/ToolCallTree.tsx

@@ -0,0 +1,88 @@
+/** Root/subcall Tool composition with one keyed atomic dispatch path. */
+import { memo, useMemo } from 'react'
+import type { CodeSubCall, ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client'
+import type { ToolCallOwnerProps, ToolTreeProps } from '../contract/slots.ts'
+import { GenericToolCard } from './toolviews/GenericToolCard.tsx'
+import css from './ToolCallTree.module.css'
+
+/** Resolve a Code Dispatch child's wire Tool name from either lifecycle form. */
+function subCallName(node: CodeSubCall): string {
+  return 'kind' in node ? node.call?.name ?? '' : node.name
+}
+
+/** One atomic call dispatched through the Tool-owned keyed slot. */
+const ToolCall = memo(function ToolCall({
+  renderSlot, callId, toolName, block, openFile, selected, cwd, inspectCall, t,
+}: Pick<ToolTreeProps, 'renderSlot' | 'openFile' | 'cwd' | 'inspectCall' | 't'> & {
+  callId: string
+  toolName: string
+  block: ToolCallBlock
+  selected: boolean
+}) {
+  const owner: ToolCallOwnerProps = useMemo(() => ({
+    callId,
+    toolName,
+    block,
+    openFile,
+    cwd,
+    inspect: () => { inspectCall(callId) },
+  }), [callId, toolName, block, openFile, cwd, inspectCall])
+  return (
+    <div
+      className={css.callRow}
+      data-chat-anchor-key={`call:${callId}`}
+      data-chat-call-id={callId}
+      data-selected={selected || undefined}
+    >
+      {renderSlot('tool.call.toolview', owner, {
+        entryKey: toolName,
+        fallback: <GenericToolCard {...owner} t={t} />,
+      })}
+    </div>
+  )
+})
+
+/**
+ * Render one root Tool call and its currently supported one-level Code
+ * Dispatch children. Root and children use the same atomic keyed dispatch.
+ * @param props - whole-Tool owner data and the Tool-owned child-slot share.
+ * @returns the Tool call tree.
+ */
+export function ToolCallTree({
+  useSession, renderSlot, callId, toolName, block, selectedCallId, cwd, openFile, inspectCall, t,
+}: ToolTreeProps) {
+  const subCalls = useSession(snapshot => snapshot.codeDispatches.get(callId))
+  return (
+    <>
+      <ToolCall
+        renderSlot={renderSlot}
+        callId={callId}
+        toolName={toolName}
+        block={block}
+        openFile={openFile}
+        selected={callId === selectedCallId}
+        cwd={cwd}
+        inspectCall={inspectCall}
+        t={t}
+      />
+      {subCalls !== undefined && subCalls.length > 0 ? (
+        <div className={css.subCalls} data-subcalls>
+          {subCalls.map(node => (
+            <ToolCall
+              key={node.callId}
+              renderSlot={renderSlot}
+              callId={node.callId}
+              toolName={subCallName(node)}
+              block={node}
+              openFile={openFile}
+              selected={node.callId === selectedCallId}
+              cwd={cwd}
+              inspectCall={inspectCall}
+              t={t}
+            />
+          ))}
+        </div>
+      ) : null}
+    </>
+  )
+}

+ 46 - 0
packages/client/ui-tool/src/client/tool/ToolDetails.module.css

@@ -0,0 +1,46 @@
+.description {
+  margin: 0 0 6px;
+  color: var(--dsw-alias-label-secondary);
+  font: var(--dsw-font-xs-13);
+}
+
+.cardBody {
+  margin: 0;
+}
+
+.recovery {
+  margin: 6px 0 0;
+  white-space: pre-wrap;
+  overflow-wrap: anywhere;
+  color: var(--dsw-alias-label-tertiary);
+  font: var(--dsw-font-xs-13);
+}
+
+.code {
+  margin: 0;
+  padding: 16px;
+  border-radius: 12px;
+  background: var(--dsw-alias-markdown-code-block);
+  font-family: var(--ds-font-family-code);
+  font-size: 13px;
+  line-height: 22px;
+  color: var(--dsw-alias-label-primary);
+  white-space: pre-wrap;
+  word-break: break-word;
+}
+
+.code[data-error] {
+  color: var(--dsw-alias-state-error-primary);
+}
+
+.read,
+.web {
+  margin: 0;
+}
+
+.empty {
+  padding: 8px 0;
+  font-size: 13px;
+  line-height: 20px;
+  color: var(--dsw-alias-label-tertiary);
+}

+ 66 - 0
packages/client/ui-tool/src/client/tool/ToolDetails.tsx

@@ -0,0 +1,66 @@
+/** Card-aware output body for the selected Tool call in details. */
+import { DiffBlock, ReadBlock, SearchBlock, TerminalBlock, WebBlock } from '@deepseek-ai/dsh-client-ui-primitives'
+import type { ToolDetailsProps } from '../contract/slots.ts'
+import { diffCardModel } from './models/diff-card-model.ts'
+import { readCardModel } from './models/read-card-model.ts'
+import { searchCardModel } from './models/search-card-model.ts'
+import { terminalBlockLabels, terminalCardModel } from './models/terminal-card-model.ts'
+import { resultText } from './models/tool-call-model.ts'
+import { webCardModel } from './models/web-card-model.ts'
+import css from './ToolDetails.module.css'
+
+/** Pure details-body inputs; framework session seats stay at the slot boundary. */
+interface ToolDetailsContentProps {
+  block: ToolDetailsProps['block']
+  cwd?: ToolDetailsProps['cwd']
+  t: ToolDetailsProps['t']
+}
+
+/**
+ * Render the selected Tool call's structured output when its presentation
+ * intent is known, otherwise preserve the flattened result text.
+ * @param props - selected call slice, workspace root, and locale seat.
+ * @returns the details output body.
+ */
+export function ToolDetails({ block, cwd, t }: ToolDetailsContentProps) {
+  const terminal = terminalCardModel(block, cwd)
+  if (terminal !== null) {
+    return (
+      <>
+        {terminal.description !== undefined ? (
+          <div className={css.description}>{terminal.description}</div>
+        ) : null}
+        <TerminalBlock {...terminal.card} labels={terminalBlockLabels(t)} className={css.cardBody} />
+      </>
+    )
+  }
+  const read = readCardModel(block, cwd)
+  if (read !== null) return <ReadBlock {...read} className={css.read} />
+  const diff = diffCardModel(block)
+  if (diff !== null) return <DiffBlock {...diff.card} className={css.cardBody} />
+  const search = searchCardModel(block)
+  if (search !== null) {
+    return (
+      <>
+        <SearchBlock {...search.card} className={css.cardBody} />
+        {search.recovery !== undefined ? <div className={css.recovery}>{search.recovery}</div> : null}
+      </>
+    )
+  }
+  const web = webCardModel(block)
+  if (web !== null) {
+    const body = 'kind' in block ? resultText(block) : ''
+    return (
+      <>
+        <WebBlock {...web} className={css.web} />
+        {body !== '' ? <pre className={css.code}>{body}</pre> : null}
+      </>
+    )
+  }
+  if (!('kind' in block)) return <div className={css.empty}>{t('details.running')}</div>
+  return (
+    <pre className={css.code} data-error={block.isError || undefined}>
+      {resultText(block)}
+    </pre>
+  )
+}

+ 69 - 0
packages/client/ui-tool/src/client/tool/components/DisclosureRow.module.css

@@ -0,0 +1,69 @@
+/* Shared Tool calls disclosure header: [16px leading] gap 6 [title 14/24]. */
+
+.root {
+  display: flex;
+  flex-direction: column;
+  width: 100%;
+  min-width: 0;
+}
+
+.row {
+  position: relative;
+  overflow: hidden;
+  display: flex;
+  align-items: center;
+  height: 24px;
+  min-width: 0;
+}
+
+.row[data-expandable] {
+  cursor: pointer;
+}
+
+.leading {
+  position: relative;
+  flex: none;
+  width: 16px;
+  height: 16px;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  margin-right: 6px;
+  padding: 0;
+  border: none;
+  background: none;
+  color: var(--dsw-alias-label-tertiary);
+}
+
+button.leading {
+  cursor: pointer;
+}
+
+.iconIdle {
+  display: inline-flex;
+  opacity: 1;
+  transition: opacity 100ms ease;
+}
+
+.chevronHover {
+  position: absolute;
+  inset: 0;
+  margin: auto;
+  opacity: 0;
+  transition: opacity 100ms ease;
+}
+
+.row:hover .iconIdle {
+  opacity: 0;
+}
+
+.row:hover .chevronHover {
+  opacity: 1;
+}
+
+.title {
+  flex: none;
+  font-size: 14px;
+  line-height: 24px;
+  color: var(--dsw-alias-label-secondary);
+}

+ 104 - 0
packages/client/ui-tool/src/client/tool/components/DisclosureRow.tsx

@@ -0,0 +1,104 @@
+import { type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
+import clsx from 'clsx'
+import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
+import css from './DisclosureRow.module.css'
+
+/** Shared 24px disclosure chrome for conversation flow rows. */
+export interface DisclosureRowProps {
+  icon: ReactNode
+  title: string
+  open: boolean
+  expandable: boolean
+  onToggle: () => void
+  /** Makes the complete title row the disclosure target. */
+  expandOnRowClick?: boolean | undefined
+  /** Replaces the collapsed icon with a chevron while the row is hovered. */
+  previewChevron?: boolean | undefined
+  /** Keeps `collapsedContent` inline while open (ToolRow's summary stays readable next to the expanded card). */
+  keepContentWhenOpen?: boolean | undefined
+  collapsedContent?: ReactNode
+  children?: ReactNode
+  className?: string | undefined
+  rowClassName?: string | undefined
+  leadingClassName?: string | undefined
+  chevronClassName?: string | undefined
+  titleClassName?: string | undefined
+}
+
+/**
+ * Render one disclosure header and its controlled expanded content.
+ * @param props - Visual content, controlled state, and interaction policy.
+ * @returns The disclosure row.
+ */
+export function DisclosureRow({
+  icon,
+  title,
+  open,
+  expandable,
+  onToggle,
+  expandOnRowClick = false,
+  previewChevron = expandable,
+  keepContentWhenOpen = false,
+  collapsedContent,
+  children,
+  className,
+  rowClassName,
+  leadingClassName,
+  chevronClassName,
+  titleClassName,
+}: DisclosureRowProps) {
+  const rowExpands = expandable && expandOnRowClick
+  const toggleFromLeading = (event: MouseEvent<HTMLButtonElement>) => {
+    event.stopPropagation()
+    onToggle()
+  }
+  const toggleFromKeyboard = (event: KeyboardEvent<HTMLDivElement>) => {
+    if (!rowExpands || (event.key !== 'Enter' && event.key !== ' ')) return
+    event.preventDefault()
+    onToggle()
+  }
+  const collapsedLeading = previewChevron
+    ? (
+      <>
+        <span className={css.iconIdle}>{icon}</span>
+        <IconChevronDownOutline14 className={clsx(chevronClassName, css.chevronHover)} />
+      </>
+    )
+    : icon
+  const leading = open
+    ? <IconChevronDownOutline14 className={chevronClassName} />
+    : collapsedLeading
+
+  return (
+    <div className={clsx(css.root, className)} data-open={open || undefined}>
+      <div
+        className={clsx(css.row, rowClassName)}
+        data-disclosure-row
+        data-expandable={rowExpands || undefined}
+        role={rowExpands ? 'button' : undefined}
+        tabIndex={rowExpands ? 0 : undefined}
+        aria-expanded={rowExpands ? open : undefined}
+        onClick={rowExpands ? onToggle : undefined}
+        onKeyDown={rowExpands ? toggleFromKeyboard : undefined}
+      >
+        {expandable && !rowExpands ? (
+          <button
+            type="button"
+            className={clsx(css.leading, leadingClassName)}
+            aria-expanded={open}
+            onClick={toggleFromLeading}
+          >
+            {leading}
+          </button>
+        ) : (
+          <span className={clsx(css.leading, leadingClassName)}>
+            {leading}
+          </span>
+        )}
+        <span className={clsx(css.title, titleClassName)}>{title}</span>
+        {(keepContentWhenOpen || !open) && collapsedContent}
+      </div>
+      {open && children}
+    </div>
+  )
+}

+ 0 - 18
packages/client/ui-conversation/src/client/chat/ToolRow.module.css → packages/client/ui-tool/src/client/tool/components/ToolRow.module.css

@@ -84,11 +84,6 @@
   color: var(--dsw-alias-label-tertiary);
 }
 
-/* Live reasoning follows its one-line summary to the inline end. */
-.summary[data-follow-end] {
-  text-overflow: clip;
-}
-
 /* Trailing summary fragment kept out of .summary's ellipsis, for a count whose
    whole value is that it survives a narrow row (the todo row's parallel-active
    `+n`). Repeats .summary's type because it sits beside that text, and its
@@ -184,19 +179,6 @@
   overflow-y: auto;
 }
 
-/* Think expanded body: plain indented gray reasoning prose — no IN/OUT card
-   (the reasoning is not an input payload), pre-wrapped at the row's indent.
-   Uncapped: reasoning reads as message prose, so it flows with the page
-   instead of scrolling in a box. */
-.thinkBody {
-  padding: 4px 0 4px 22px;
-  font-size: 14px;
-  line-height: 24px;
-  white-space: pre-wrap;
-  word-break: break-word;
-  color: var(--dsw-alias-label-tertiary);
-}
-
 /* Expanded input/output card (figma 1249:35657): the code-block surface and
    radius from the TerminalBlock/CodeBlock family. The card itself is a plain
    column — the padding and the IN/OUT gutter-label grid live on each section

+ 40 - 66
packages/client/ui-conversation/src/client/chat/ToolRow.tsx → packages/client/ui-tool/src/client/tool/components/ToolRow.tsx

@@ -4,36 +4,32 @@
 // DisclosureRow chrome with the whole row as the expand toggle (click /
 // Enter / Space, icon→chevron hover preview). The collapsed row is always
 // one line; every row with body, output, or a card material (terminal, diff,
-// read, search, web) is expandable; the summary stays inline while open,
-// except Think, where the running collapsed row follows the latest line at its
-// scroll end and the summary yields while open to avoid repeating the body.
+// read, search, web) is expandable; the summary stays inline while open.
 // The expanded body — an IN/OUT gutter-labeled card (figma 1249:35657) for
 // text input/output, the run_code program through CodeBlock, or a card
 // primitive (TerminalBlock, DiffBlock, ReadBlock, SearchBlock, WebBlock) for a
 // call that declared that render intent — lives in a max-height scroll
 // container so a long payload scrolls internally instead of taking over the
-// message flow; Think's prose is the exception and flows uncapped like message
-// text. Every card kind starts collapsed, so a run of tool calls stays
+// message flow. Every card kind starts collapsed, so a run of tool calls stays
 // scannable; the details panel is the single-call full-height reading surface.
 // Expand state is component-local view state. File-tool summaries are path
 // links that open through the host (stopPropagation keeps the two gestures
 // independent); an error row's collapsed summary is the failure's first line in
 // the error color.
 
-import { useEffect, useRef, useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
+import { useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
 import clsx from 'clsx'
 import {
   CodeBlock, DiffBlock, IconInspectOutline12, ReadBlock, SearchBlock, StateDot, TerminalBlock, WebBlock,
 } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { WebBlockProps } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
-import { CHAT_DIFF_MAX_LINES, type DiffCardModel } from '../contract/diff-card-model.ts'
-import { CHAT_READ_MAX_LINES, type ReadCardModel } from '../contract/read-card-model.ts'
-import { CHAT_SEARCH_MAX_LINES, type SearchCardModel } from '../contract/search-card-model.ts'
-import { terminalBlockLabels, type TerminalCardModel } from '../contract/terminal-card-model.ts'
-import type { ToolRowState, ToolRowVariant } from '../contract/tool-call-model.ts'
+import { CHAT_DIFF_MAX_LINES, type DiffCardModel } from '../models/diff-card-model.ts'
+import { CHAT_READ_MAX_LINES, type ReadCardModel } from '../models/read-card-model.ts'
+import { CHAT_SEARCH_MAX_LINES, type SearchCardModel } from '../models/search-card-model.ts'
+import { terminalBlockLabels, type TerminalCardModel } from '../models/terminal-card-model.ts'
+import type { ToolRowState, ToolRowVariant } from '../models/tool-call-model.ts'
 import { DisclosureRow } from './DisclosureRow.tsx'
-import { useThrottledVisualUpdate } from './use-throttled-visual-update.ts'
 import css from './ToolRow.module.css'
 
 export interface ToolRowProps {
@@ -101,8 +97,7 @@ export interface ToolRowProps {
   onOpenFile?: ((path: string) => void) | undefined
   /**
    * Jump to this call in the trajectory view: a hover-revealed Inspect pill
-   * over the expanded body. Absent = no affordance (rows without a call
-   * identity, like Think).
+   * over the expanded body. Absent = no affordance.
    */
   inspect?: (() => void) | undefined
 }
@@ -153,7 +148,6 @@ export function ToolRow({
   inspect,
 }: ToolRowProps) {
   const [expanded, setExpanded] = useState(false)
-  const summaryRef = useRef<HTMLSpanElement>(null)
   const terminalBody = terminal ?? null
   const diffBody = diff ?? null
   const readBody = read ?? null
@@ -178,19 +172,6 @@ export function ToolRow({
   const suffix = failureLine === null ? summarySuffix ?? null : null
   // The failure line is error prose, not the path: no open-file affordance.
   const fileLink = filePath !== undefined && onOpenFile !== undefined && failureLine === null
-  const isThink = variant === 'think'
-  const followSummaryEnd = isThink && state === 'running' && !open
-  const scheduleSummaryScroll = useThrottledVisualUpdate(() => {
-    const summaryElement = summaryRef.current
-    if (summaryElement === null) return
-    summaryElement.scrollLeft = followSummaryEnd
-      ? summaryElement.scrollWidth - summaryElement.clientWidth
-      : 0
-  })
-  useEffect(() => {
-    if (!isThink) return
-    scheduleSummaryScroll()
-  }, [followSummaryEnd, isThink, scheduleSummaryScroll, summaryText])
   const toggleExpand = () => {
     setExpanded(v => !v)
   }
@@ -205,9 +186,6 @@ export function ToolRow({
   const fileLinkKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
     if (event.key === 'Enter' || event.key === ' ') event.stopPropagation()
   }
-  // Think reasoning is prose, not an input payload: expanded, it renders as
-  // plain indented text (no IN/OUT card) and the inline summary yields to avoid
-  // repeating the body.
   // The code variant's program renders through CodeBlock (shiki), so only its
   // output joins the IN/OUT card; every other variant's input does too.
   const cardBody = variant === 'code' ? null : body
@@ -227,7 +205,7 @@ export function ToolRow({
         open={open}
         expandable={expandable}
         expandOnRowClick
-        keepContentWhenOpen={!isThink}
+        keepContentWhenOpen
         onToggle={toggleExpand}
         collapsedContent={summaryText !== '' && (
           /* An empty summary drops the separator with it (a row that is only
@@ -245,9 +223,7 @@ export function ToolRow({
               </button>
             ) : (
               <span
-                ref={isThink ? summaryRef : undefined}
                 className={clsx(css.summary, failureLine !== null && css.errorSummary)}
-                data-follow-end={followSummaryEnd || undefined}
               >
                 {summaryText}
               </span>
@@ -285,38 +261,36 @@ export function ToolRow({
                   )
                   : webBody !== null
                     ? <WebBlock {...webBody} className={css.webBody} />
-                    : isThink
-                      ? <div className={css.thinkBody}>{body}</div>
-                      : (
-                        <>
-                          {variant === 'code' && body !== null && (
-                            <div className={css.bodyScroll}>
-                              <CodeBlock code={body} lang="typescript" copyLabel={t('copy')} copiedLabel={t('copied')} className={css.codeBody} />
-                            </div>
-                          )}
-                          {(cardBody !== null || outputText !== null) && (
-                            <div className={css.ioCard}>
-                              {cardBody !== null && (
-                                <div className={css.ioSection}>
-                                  <span className={css.ioLabel}>IN</span>
-                                  <span className={css.ioText}>{cardBody}</span>
-                                </div>
-                              )}
-                              {cardBody !== null && outputText !== null && (
-                                <span className={css.ioDivider} aria-hidden />
-                              )}
-                              {outputText !== null && (
-                                <div className={css.ioSection}>
-                                  <span className={css.ioLabel}>OUT</span>
-                                  <span className={css.ioText} data-error={state === 'error' || undefined}>
-                                    {outputText}
-                                  </span>
-                                </div>
-                              )}
-                            </div>
-                          )}
-                        </>
-                      )}
+                    : (
+                      <>
+                        {variant === 'code' && body !== null && (
+                          <div className={css.bodyScroll}>
+                            <CodeBlock code={body} lang="typescript" copyLabel={t('copy')} copiedLabel={t('copied')} className={css.codeBody} />
+                          </div>
+                        )}
+                        {(cardBody !== null || outputText !== null) && (
+                          <div className={css.ioCard}>
+                            {cardBody !== null && (
+                              <div className={css.ioSection}>
+                                <span className={css.ioLabel}>IN</span>
+                                <span className={css.ioText}>{cardBody}</span>
+                              </div>
+                            )}
+                            {cardBody !== null && outputText !== null && (
+                              <span className={css.ioDivider} aria-hidden />
+                            )}
+                            {outputText !== null && (
+                              <div className={css.ioSection}>
+                                <span className={css.ioLabel}>OUT</span>
+                                <span className={css.ioText} data-error={state === 'error' || undefined}>
+                                  {outputText}
+                                </span>
+                              </div>
+                            )}
+                          </div>
+                        )}
+                      </>
+                    )}
           {inspect !== undefined && (
             <button
               type="button"

+ 0 - 0
packages/client/ui-conversation/src/client/contract/diff-card-model.ts → packages/client/ui-tool/src/client/tool/models/diff-card-model.ts


+ 0 - 0
packages/client/ui-conversation/src/client/contract/read-card-model.ts → packages/client/ui-tool/src/client/tool/models/read-card-model.ts


+ 0 - 0
packages/client/ui-conversation/src/client/contract/search-card-model.ts → packages/client/ui-tool/src/client/tool/models/search-card-model.ts


+ 0 - 0
packages/client/ui-conversation/src/client/contract/terminal-card-model.ts → packages/client/ui-tool/src/client/tool/models/terminal-card-model.ts


+ 3 - 7
packages/client/ui-conversation/src/client/contract/tool-call-model.ts → packages/client/ui-tool/src/client/tool/models/tool-call-model.ts

@@ -13,18 +13,15 @@ import type { ToolCallBlock, ToolResultNode } from '@deepseek-ai/dsh-client-runt
 
 export type { ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client'
 
-/** The frozen slice the chat view hands to toolview components as `block`
- *  (both members are cache-stable references off ConversationSnapshot). */
-
-/** The eight row variants (think is fed by reasoning blocks, not tool calls). */
-export type ToolRowVariant = 'think' | 'search' | 'read' | 'bash' | 'write' | 'edit' | 'code' | 'others'
+/** Tool-call row variants selected by the generic atomic renderer. */
+export type ToolRowVariant = 'search' | 'read' | 'bash' | 'write' | 'edit' | 'code' | 'others'
 
 /** Row state semantic; colors self-supplied via StateDot (design gives none). */
 export type ToolRowState = 'running' | 'ok' | 'error' | 'stopped'
 
 /** Figma row titles per variant (design literals, not translatable copy). */
 export const VARIANT_TITLES: Record<ToolRowVariant, string> = {
-  think: 'Think', search: 'Search', read: 'Read', bash: 'Bash',
+  search: 'Search', read: 'Read', bash: 'Bash',
   write: 'Write', edit: 'Edit', code: 'Code', others: 'Tool call',
 }
 
@@ -130,7 +127,6 @@ const SUMMARY_KEYS: Record<ToolRowVariant, readonly string[]> = {
   bash: ['description', 'command'],
   read: ['path', 'file_path', 'url'],
   search: ['query', 'pattern', 'url'],
-  think: [],
   write: ['path', 'file_path'],
   edit: ['path', 'file_path'],
   code: ['description'],

+ 0 - 0
packages/client/ui-conversation/src/client/contract/web-card-model.ts → packages/client/ui-tool/src/client/tool/models/web-card-model.ts


+ 13 - 15
packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx → packages/client/ui-tool/src/client/tool/toolviews/GenericToolCard.tsx

@@ -1,26 +1,24 @@
-// GenericToolCard: the default tool row — classifies the tool into one of
-// the five figma row variants and renders the summary row. Supplied by the
-// chat view as the keyed toolview slot's render-site fallback (an
+// GenericToolCard: the default tool row — classifies the tool into a visual
+// variant and renders the summary row. Supplied by the Tool call tree as the
+// keyed atomic-view slot's render-site fallback (an
 // unregistered tool name lands here); registrants may also compose it as a
 // base, feeding the same owner payload through.
 
 import type { ReactNode } from 'react'
 import {
   IconApiOutline14, IconBrowseOutline16, IconCodeOutline16, IconEditOutline16, IconSearchOutline16, IconSparkle16,
-  IconThinkOutline14,
 } from '@deepseek-ai/dsh-client-ui-primitives'
-import type { ChatViewSlotProps, ToolRowOwnerProps } from '../contract/slots.ts'
-import { readCardModel } from '../contract/read-card-model.ts'
-import { diffCardModel } from '../contract/diff-card-model.ts'
-import { searchCardModel } from '../contract/search-card-model.ts'
-import { terminalCardModel, terminalFailed } from '../contract/terminal-card-model.ts'
-import { webCardModel } from '../contract/web-card-model.ts'
-import { toolRowModel, type ToolRowVariant } from '../contract/tool-call-model.ts'
-import { ToolRow } from './ToolRow.tsx'
+import type { ToolCallOwnerProps, ToolTreeProps } from '../../contract/slots.ts'
+import { readCardModel } from '../models/read-card-model.ts'
+import { diffCardModel } from '../models/diff-card-model.ts'
+import { searchCardModel } from '../models/search-card-model.ts'
+import { terminalCardModel, terminalFailed } from '../models/terminal-card-model.ts'
+import { webCardModel } from '../models/web-card-model.ts'
+import { toolRowModel, type ToolRowVariant } from '../models/tool-call-model.ts'
+import { ToolRow } from '../components/ToolRow.tsx'
 
 /** Variant leading icons (figma table); all glyphs render at 14 inside the 16px leading box. */
 const VARIANT_ICONS: Record<ToolRowVariant, ReactNode> = {
-  think: <IconThinkOutline14 size={14} />,
   search: <IconSearchOutline16 size={14} />,
   read: <IconBrowseOutline16 size={14} />,
   bash: <IconApiOutline14 size={14} />,
@@ -31,8 +29,8 @@ const VARIANT_ICONS: Record<ToolRowVariant, ReactNode> = {
 }
 
 /** Card props: the owner payload plus the render site's locale seat (plain prop). */
-export interface GenericToolCardProps extends ToolRowOwnerProps {
-  t: ChatViewSlotProps['t']
+export interface GenericToolCardProps extends ToolCallOwnerProps {
+  t: ToolTreeProps['t']
 }
 
 export function GenericToolCard({ toolName, block, cwd, openFile, inspect, t }: GenericToolCardProps) {

+ 9 - 9
packages/client/ui-conversation/src/client/toolviews/ask-question-row.tsx → packages/client/ui-tool/src/client/tool/toolviews/ask-question-row.tsx

@@ -1,6 +1,6 @@
 // ask_user_question toolview: question-flavored summary row replacing the
 // generic "Tool call" card, registered into the keyed
-// 'conversation.chat.toolview' hole like todo-row. The row composes ToolRow
+// 'tool.call.toolview' hole like todo-row. The row composes ToolRow
 // (chrome, running sweep, whole-row expand) and swaps in the interaction
 // outcome — `waiting` while pending, answered-count once settled, `cancelled`
 // when the user dismissed the whole set — because the questions themselves
@@ -9,10 +9,10 @@
 import { IconQuestionOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { Context } from 'cordis'
 import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
-import type { ToolRowProps } from '../contract/slots.ts'
-import { toolRowModel } from '../contract/tool-call-model.ts'
-import { ToolRow } from '../chat/ToolRow.tsx'
-import { NS } from '../locales.ts'
+import type { ToolCallViewProps } from '../../contract/slots.ts'
+import { toolRowModel } from '../models/tool-call-model.ts'
+import { ToolRow } from '../components/ToolRow.tsx'
+import { CONVERSATION_NS as NS } from '../../locale.ts'
 
 /** One parsed answer entry, shape-checked (result JSON crosses the wire). */
 interface AnswerEntry { selected?: unknown; custom?: unknown }
@@ -40,7 +40,7 @@ function answeredSummary(text: string, t: AskQuestionRowProps['t']): string | nu
 }
 
 /** Full row props: the toolview runtime share plus the standard locale seat. */
-type AskQuestionRowProps = ToolRowProps & PropsLocale<'conversation'>
+type AskQuestionRowProps = ToolCallViewProps & PropsLocale<'conversation'>
 
 /** One-line question-interaction row (the whole row toggles the call's
  *  Input/Output sections, ToolRow's unified expand). */
@@ -90,12 +90,12 @@ export const askQuestionToolview = {
   name: 'ask-question-toolview',
   inject: ['slots'],
   /**
-   * Register the ask-question row into the chat view's keyed toolview hole.
+   * Register the ask-question row into the Tool-owned keyed view slot.
    * @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
    */
   apply(ctx: Context): void {
-    ctx.slots.inject('conversation.chat.toolview', () => ctx.slots.register({
-      name: 'conversation.chat.toolview', key: 'ask_user_question', locale: NS,
+    ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({
+      name: 'tool.call.toolview', key: 'ask_user_question', locale: NS,
     }, AskQuestionRow))
   },
 }

+ 0 - 0
packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css → packages/client/ui-tool/src/client/tool/toolviews/bash-sample.module.css


+ 8 - 8
packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx → packages/client/ui-tool/src/client/tool/toolviews/bash-sample.tsx

@@ -20,14 +20,14 @@ import {
   IconApiOutline14, IconChevronDownOutline14, IconInspectOutline12, StateDot, TerminalBlock,
 } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
-import type { ToolRowProps } from '../contract/slots.ts'
-import { terminalBlockLabels, terminalCardModel, terminalFailed } from '../contract/terminal-card-model.ts'
-import { toolRowModel, type ToolRowState } from '../contract/tool-call-model.ts'
-import { NS } from '../locales.ts'
+import type { ToolCallViewProps } from '../../contract/slots.ts'
+import { terminalBlockLabels, terminalCardModel, terminalFailed } from '../models/terminal-card-model.ts'
+import { toolRowModel, type ToolRowState } from '../models/tool-call-model.ts'
+import { CONVERSATION_NS as NS } from '../../locale.ts'
 import css from './bash-sample.module.css'
 
 /** Bash row props: the toolview runtime share plus the standard locale seat. */
-type BashRowProps = ToolRowProps & PropsLocale<'conversation'>
+type BashRowProps = ToolCallViewProps & PropsLocale<'conversation'>
 
 function leadingFor(state: ToolRowState) {
   switch (state) {
@@ -171,11 +171,11 @@ export const bashToolviewSample = {
   name: 'bash-toolview-sample',
   inject: ['slots'],
   /**
-   * Register the bash row into the chat view's keyed toolview hole.
+   * Register the bash row into the Tool-owned keyed view slot.
    * @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
    */
   apply(ctx: Context): void {
-    ctx.slots.inject('conversation.chat.toolview', () =>
-      ctx.slots.register({ name: 'conversation.chat.toolview', key: 'bash', locale: NS }, BashRow))
+    ctx.slots.inject('tool.call.toolview', () =>
+      ctx.slots.register({ name: 'tool.call.toolview', key: 'bash', locale: NS }, BashRow))
   },
 }

+ 10 - 10
packages/client/ui-conversation/src/client/toolviews/file-mutation-row.tsx → packages/client/ui-tool/src/client/tool/toolviews/file-mutation-row.tsx

@@ -11,14 +11,14 @@
 import type { Context } from 'cordis'
 import { IconEditOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
-import type { ToolRowProps } from '../contract/slots.ts'
-import { diffCardModel } from '../contract/diff-card-model.ts'
-import { toolRowModel } from '../contract/tool-call-model.ts'
-import { ToolRow } from '../chat/ToolRow.tsx'
-import { NS } from '../locales.ts'
+import type { ToolCallViewProps } from '../../contract/slots.ts'
+import { diffCardModel } from '../models/diff-card-model.ts'
+import { toolRowModel } from '../models/tool-call-model.ts'
+import { ToolRow } from '../components/ToolRow.tsx'
+import { CONVERSATION_NS as NS } from '../../locale.ts'
 
 /** Full row props: the toolview runtime share plus the standard locale seat. */
-type FileMutationRowProps = ToolRowProps & PropsLocale<'conversation'>
+type FileMutationRowProps = ToolCallViewProps & PropsLocale<'conversation'>
 
 /**
  * File-mutation row: icon + {Edit,Write} · {path} in the shared ToolRow chrome,
@@ -60,14 +60,14 @@ export const fileMutationToolview = {
   name: 'file-mutation-toolview',
   inject: ['slots'],
   /**
-   * Register the file-mutation row into the chat view's keyed toolview hole
+   * Register the file-mutation row into the Tool-owned keyed view slot
    * under both mutation tool names.
    * @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
    */
   apply(ctx: Context): void {
-    ctx.slots.inject('conversation.chat.toolview', function* () {
-      yield ctx.slots.register({ name: 'conversation.chat.toolview', key: 'edit', locale: NS }, FileMutationRow)
-      yield ctx.slots.register({ name: 'conversation.chat.toolview', key: 'write', locale: NS }, FileMutationRow)
+    ctx.slots.inject('tool.call.toolview', function* () {
+      yield ctx.slots.register({ name: 'tool.call.toolview', key: 'edit', locale: NS }, FileMutationRow)
+      yield ctx.slots.register({ name: 'tool.call.toolview', key: 'write', locale: NS }, FileMutationRow)
     })
   },
 }

+ 0 - 0
packages/client/ui-conversation/src/client/toolviews/plan-summary.ts → packages/client/ui-tool/src/client/tool/toolviews/plan-summary.ts


+ 10 - 10
packages/client/ui-conversation/src/client/toolviews/read-row.tsx → packages/client/ui-tool/src/client/tool/toolviews/read-row.tsx

@@ -10,14 +10,14 @@
 import type { Context } from 'cordis'
 import { IconBrowseOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
-import type { ToolRowProps } from '../contract/slots.ts'
-import { readCardModel } from '../contract/read-card-model.ts'
-import { toolRowModel } from '../contract/tool-call-model.ts'
-import { ToolRow } from '../chat/ToolRow.tsx'
-import { NS } from '../locales.ts'
+import type { ToolCallViewProps } from '../../contract/slots.ts'
+import { readCardModel } from '../models/read-card-model.ts'
+import { toolRowModel } from '../models/tool-call-model.ts'
+import { ToolRow } from '../components/ToolRow.tsx'
+import { CONVERSATION_NS as NS } from '../../locale.ts'
 
 /** Full row props: the toolview runtime share plus the standard locale seat. */
-type ReadRowProps = ToolRowProps & PropsLocale<'conversation'>
+type ReadRowProps = ToolCallViewProps & PropsLocale<'conversation'>
 
 /**
  * Read row: icon + Read · {path} in the shared ToolRow chrome, with the file's
@@ -48,18 +48,18 @@ export function ReadRow({ toolName, block, cwd, openFile, inspect, t }: ReadRowP
 }
 
 /**
- * The read row as a plain registrant plugin following the chat toolview
+ * The read row as a plain registrant plugin following the atomic Tool-view
  * declaration across independent activation and reload lifetimes.
  */
 export const readToolview = {
   name: 'read-toolview',
   inject: ['slots'],
   /**
-   * Register the read row into the chat view's keyed toolview hole.
+   * Register the read row into the Tool-owned keyed view slot.
    * @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
    */
   apply(ctx: Context): void {
-    ctx.slots.inject('conversation.chat.toolview', () =>
-      ctx.slots.register({ name: 'conversation.chat.toolview', key: 'read', locale: NS }, ReadRow))
+    ctx.slots.inject('tool.call.toolview', () =>
+      ctx.slots.register({ name: 'tool.call.toolview', key: 'read', locale: NS }, ReadRow))
   },
 }

+ 11 - 11
packages/client/ui-conversation/src/client/toolviews/search-row.tsx → packages/client/ui-tool/src/client/tool/toolviews/search-row.tsx

@@ -14,14 +14,14 @@
 import type { Context } from 'cordis'
 import { IconSearchOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
-import type { ToolRowProps } from '../contract/slots.ts'
-import { searchCardModel } from '../contract/search-card-model.ts'
-import { toolRowModel } from '../contract/tool-call-model.ts'
-import { ToolRow } from '../chat/ToolRow.tsx'
-import { NS } from '../locales.ts'
+import type { ToolCallViewProps } from '../../contract/slots.ts'
+import { searchCardModel } from '../models/search-card-model.ts'
+import { toolRowModel } from '../models/tool-call-model.ts'
+import { ToolRow } from '../components/ToolRow.tsx'
+import { CONVERSATION_NS as NS } from '../../locale.ts'
 
 /** Full row props: the toolview runtime share plus the standard locale seat. */
-type SearchRowProps = ToolRowProps & PropsLocale<'conversation'>
+type SearchRowProps = ToolCallViewProps & PropsLocale<'conversation'>
 
 /**
  * Search row: icon + Search · {summary} in the shared ToolRow chrome, with the
@@ -61,7 +61,7 @@ export function SearchRow({ toolName, block, inspect, t }: SearchRowProps) {
 }
 
 /**
- * The search toolview follows the chat toolview declaration across activation
+ * The search view follows the atomic Tool-view declaration across activation
  * and reload. One component registers under both keys because `grep` and
  * `glob` are the same visual object discriminated by the result view's `kind`.
  */
@@ -69,14 +69,14 @@ export const searchToolview = {
   name: 'search-toolview',
   inject: ['slots'],
   /**
-   * Register the search row into the chat view's keyed toolview hole under both
+   * Register the search row into the Tool-owned keyed view slot under both
    * the `grep` and `glob` tool names.
    * @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
    */
   apply(ctx: Context): void {
-    ctx.slots.inject('conversation.chat.toolview', function* () {
-      yield ctx.slots.register({ name: 'conversation.chat.toolview', key: 'grep', locale: NS }, SearchRow)
-      yield ctx.slots.register({ name: 'conversation.chat.toolview', key: 'glob', locale: NS }, SearchRow)
+    ctx.slots.inject('tool.call.toolview', function* () {
+      yield ctx.slots.register({ name: 'tool.call.toolview', key: 'grep', locale: NS }, SearchRow)
+      yield ctx.slots.register({ name: 'tool.call.toolview', key: 'glob', locale: NS }, SearchRow)
     })
   },
 }

+ 10 - 10
packages/client/ui-conversation/src/client/toolviews/todo-row.tsx → packages/client/ui-tool/src/client/tool/toolviews/todo-row.tsx

@@ -1,5 +1,5 @@
 // todo_write toolview: plan-flavored summary row replacing the generic
-// "Tool call" card, registered into the keyed 'conversation.chat.toolview'
+// "Tool call" card, registered into the keyed 'tool.call.toolview'
 // hole like the bash sample (a product registration, not a sample). The row
 // composes ToolRow (chrome, running sweep, whole-row expand) and swaps in a
 // summary of the written list (counts + active items) from the call args, with
@@ -10,14 +10,14 @@
 import { IconChecklistOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { Context } from 'cordis'
 import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
-import type { ToolRowProps } from '../contract/slots.ts'
-import { toolRowModel } from '../contract/tool-call-model.ts'
-import { ToolRow } from '../chat/ToolRow.tsx'
-import { NS } from '../locales.ts'
+import type { ToolCallViewProps } from '../../contract/slots.ts'
+import { toolRowModel } from '../models/tool-call-model.ts'
+import { ToolRow } from '../components/ToolRow.tsx'
+import { CONVERSATION_NS as NS } from '../../locale.ts'
 import { planSummary, type PlanItemLike } from './plan-summary.ts'
 
 /** Todo row props: the toolview runtime share plus the standard locale seat. */
-type TodoRowProps = ToolRowProps & PropsLocale<'conversation'>
+type TodoRowProps = ToolCallViewProps & PropsLocale<'conversation'>
 
 function isItem(value: unknown): value is PlanItemLike {
   return typeof value === 'object' && value !== null
@@ -81,18 +81,18 @@ export function TodoRow({ toolName, block, inspect, t }: TodoRowProps) {
 }
 
 /**
- * The todo row as a plain registrant plugin following the chat toolview
+ * The todo row as a plain registrant plugin following the atomic Tool-view
  * declaration across independent activation and reload lifetimes.
  */
 export const todoToolview = {
   name: 'todo-toolview',
   inject: ['slots'],
   /**
-   * Register the todo row into the chat view's keyed toolview hole.
+   * Register the todo row into the Tool-owned keyed view slot.
    * @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
    */
   apply(ctx: Context): void {
-    ctx.slots.inject('conversation.chat.toolview', () =>
-      ctx.slots.register({ name: 'conversation.chat.toolview', key: 'todo_write', locale: NS }, TodoRow))
+    ctx.slots.inject('tool.call.toolview', () =>
+      ctx.slots.register({ name: 'tool.call.toolview', key: 'todo_write', locale: NS }, TodoRow))
   },
 }

+ 10 - 10
packages/client/ui-conversation/src/client/toolviews/web-row.tsx → packages/client/ui-tool/src/client/tool/toolviews/web-row.tsx

@@ -12,14 +12,14 @@
 import type { Context } from 'cordis'
 import { IconBrowseOutline16, IconSearchOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
-import type { ToolRowProps } from '../contract/slots.ts'
-import { webCardModel } from '../contract/web-card-model.ts'
-import { toolRowModel } from '../contract/tool-call-model.ts'
-import { ToolRow } from '../chat/ToolRow.tsx'
-import { NS } from '../locales.ts'
+import type { ToolCallViewProps } from '../../contract/slots.ts'
+import { webCardModel } from '../models/web-card-model.ts'
+import { toolRowModel } from '../models/tool-call-model.ts'
+import { ToolRow } from '../components/ToolRow.tsx'
+import { CONVERSATION_NS as NS } from '../../locale.ts'
 
 /** Full row props: the toolview runtime share plus the standard locale seat. */
-type WebRowProps = ToolRowProps & PropsLocale<'conversation'>
+type WebRowProps = ToolCallViewProps & PropsLocale<'conversation'>
 
 /** web_fetch reads one URL; web_search queries. Titles are figma literals. */
 const WEB_TITLES: Record<string, string> = {
@@ -55,7 +55,7 @@ export function WebRow({ toolName, block, inspect, t }: WebRowProps) {
 }
 
 /**
- * The web rows follow the chat toolview declaration across activation and
+ * The web rows follow the atomic Tool-view declaration across activation and
  * reload. One WebRow component registers under both web tool names.
  */
 export const webToolview = {
@@ -66,9 +66,9 @@ export const webToolview = {
    * @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
    */
   apply(ctx: Context): void {
-    ctx.slots.inject('conversation.chat.toolview', function* () {
-      yield ctx.slots.register({ name: 'conversation.chat.toolview', key: 'web_search', locale: NS }, WebRow)
-      yield ctx.slots.register({ name: 'conversation.chat.toolview', key: 'web_fetch', locale: NS }, WebRow)
+    ctx.slots.inject('tool.call.toolview', function* () {
+      yield ctx.slots.register({ name: 'tool.call.toolview', key: 'web_search', locale: NS }, WebRow)
+      yield ctx.slots.register({ name: 'tool.call.toolview', key: 'web_fetch', locale: NS }, WebRow)
     })
   },
 }

+ 4 - 0
packages/client/ui-tool/src/css-modules.d.ts

@@ -0,0 +1,4 @@
+declare module '*.module.css' {
+  const classes: Record<string, string>
+  export default classes
+}

+ 4 - 0
packages/client/ui-tool/src/index.ts

@@ -0,0 +1,4 @@
+/** Host loader entry for the browser-only Tool UI plugin. */
+
+/** Provides no host-side behavior. */
+export function apply(): void {}

+ 30 - 0
packages/client/ui-tool/src/invariant.ts

@@ -0,0 +1,30 @@
+/**
+ * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-tool`.
+ * @module @deepseek-ai/dsh-client-ui-tool/invariant
+ */
+
+/* jscpd:ignore-start */
+import type { Context } from 'cordis'
+import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
+
+const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-tool'
+
+/** Cordis companion plugin name. */
+export const name = 'client-ui-tool-invariant'
+/** Service required before the companion can reserve package ownership. */
+export const inject = ['invariants']
+
+/**
+ * No runtime invariant: Tool composition is browser-only and contributes no
+ * events or cross-plugin mutable state; slot ownership is checked by ui-slots.
+ */
+const install: InvariantInstaller = () => {}
+
+/**
+ * Register this package's invariant companion.
+ * @param ctx - Cordis context carrying the invariant service.
+ * @returns the installed registration's disposer after setup succeeds.
+ */
+export const apply = (ctx: Context): Promise<() => void> =>
+  Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
+/* jscpd:ignore-end */

+ 4 - 4
packages/client/ui-conversation/tests/ask-question-row.spec.tsx → packages/client/ui-tool/tests/ask-question-row.spec.tsx

@@ -13,8 +13,8 @@ import type { ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
 import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
 import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
 // Export discipline: packages/client/AGENTS.md.
-import { AskQuestionRow, askQuestionToolview } from '../src/client/toolviews/ask-question-row.tsx'
-import { zh } from '../src/client/locales.ts'
+import { AskQuestionRow, askQuestionToolview } from '../src/client/tool/toolviews/ask-question-row.tsx'
+import { zh } from '../../ui-conversation/src/client/locales.ts'
 
 afterEach(cleanup)
 
@@ -130,9 +130,9 @@ describe('AskQuestionRow', () => {
     const register = vi.fn(() => () => undefined)
     const inject = vi.fn((_name: string, callback: () => () => void) => callback())
     askQuestionToolview.apply({ slots: { inject, register } } as never)
-    expect(inject).toHaveBeenCalledWith('conversation.chat.toolview', expect.any(Function))
+    expect(inject).toHaveBeenCalledWith('tool.call.toolview', expect.any(Function))
     expect(register).toHaveBeenCalledWith(
-      { name: 'conversation.chat.toolview', key: 'ask_user_question', locale: 'conversation' },
+      { name: 'tool.call.toolview', key: 'ask_user_question', locale: 'conversation' },
       AskQuestionRow,
     )
   })

+ 149 - 0
packages/client/ui-tool/tests/assembly-surfaces.spec.tsx

@@ -0,0 +1,149 @@
+// @vitest-environment jsdom
+/** Tool assembly acceptance through the real ui-conversation host. */
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { cleanup, fireEvent, waitFor } from '@testing-library/react'
+import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
+import type { ISession, SessionId, TodoItem, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
+import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
+import { SlotTestRuntime, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
+import { apply as applyConversation, inject as injectConversation } from '@deepseek-ai/dsh-client-ui-conversation/client'
+import { apply as applyTool, inject as injectTool } from '../src/client/apply.ts'
+
+// The service reads its initial locale from the browser; these specs assert
+// the shipped Chinese copy, so they state the browser they assume.
+usePinnedBrowserLanguages('zh-CN')
+
+const SID = 's1' as SessionId
+
+/** jsdom has no ResizeObserver; the composer seat publishes its height through one. */
+class ResizeObserverStub {
+  observe(): void {}
+  unobserve(): void {}
+  disconnect(): void {}
+}
+
+afterEach(() => {
+  cleanup()
+  vi.unstubAllGlobals()
+})
+beforeEach(() => {
+  localStorage.clear()
+  vi.stubGlobal('ResizeObserver', ResizeObserverStub)
+})
+const TODOS: TodoItem[] = [
+  { content: '梳理需求', status: 'completed' },
+  { content: '实现 fixture 样本', status: 'in_progress' },
+  { content: '浏览器验收', status: 'pending' },
+]
+
+const todoResult = (seq: number): ToolResultNode => ({
+  kind: 'tool-result', seq, time: seq * 1_000, callId: `todo-${seq}`,
+  call: { name: 'todo_write', argsRaw: JSON.stringify({ todos: TODOS }) },
+  callTime: seq * 1_000 - 500,
+  content: [], isError: false, callView: null, resultView: null,
+})
+
+const bashResult = (seq: number, callId: string, over?: Partial<ToolResultNode>): ToolResultNode => ({
+  kind: 'tool-result', seq, time: seq * 1_000, callId,
+  call: { name: 'bash', argsRaw: '{"command":"ls -la","description":"List files"}' },
+  callTime: seq * 1_000 - 500,
+  content: [{ type: 'text', text: 'total 2\ndemo.txt\n' }], isError: false,
+  callView: { card: 'terminal', title: 'ls -la', description: 'List files' },
+  resultView: { card: 'terminal', output: 'total 2\ndemo.txt\n', exitCode: 0 },
+  ...over,
+})
+
+/** Test-owned AppFrame role: declares and renders the resident conversation area. */
+type AppRootProps = PropsRenderSlots<'conversation' | 'details'>
+function AppRoot({ renderSlot }: AppRootProps) {
+  return <>{renderSlot('conversation', {})}</>
+}
+
+const LAYOUT_CHILDREN = {
+  'conversation': { kind: 'single', scope: 'session-maybe' },
+  'details': { kind: 'single', scope: 'session' },
+} as const
+
+async function bench(nodes: ToolResultNode[]) {
+  const runtime = await SlotTestRuntime.create()
+  runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
+  const locale = new LocaleService(runtime.ctx)
+  runtime.provide('locale', locale)
+  runtime.slots.installLocale(locale)
+  await runtime.sessions.add({
+    id: SID,
+    summary: { title: 'S', displayTitle: 'S', cwd: '/proj' },
+    snapshot: { nodes },
+    session: {
+      loadOlder: vi.fn<ISession['loadOlder']>(),
+      prompt: vi.fn<ISession['prompt']>(async () => ({ ok: true, value: { accepted: true } })),
+    },
+  })
+  await runtime.root.declare(LAYOUT_CHILDREN, AppRoot)
+  await runtime.mount({ inject: [...injectConversation], apply: applyConversation })
+  await runtime.mount({ inject: [...injectTool], apply: applyTool })
+  return runtime
+}
+
+describe('todo_write assembly (product registrations, no outlet twins)', () => {
+  it('reaches the keyed toolview row and the dock plan strip, and the strip follows projection retirement', async () => {
+    const runtime = await bench([todoResult(3)])
+    // The dock strip reads the host-computed 'todos' projection.
+    runtime.sessions.behavior(SID).projections.set('todos', TODOS)
+    const view = runtime.renderRoot()
+
+    // Keyed toolview registration took the row (summary derived from args).
+    const row = view.container.querySelector('[data-tool="todo_write"]')
+    expect(row).not.toBeNull()
+    expect(row!.textContent).toContain('1/3 已完成 · 实现 fixture 样本')
+
+    // The plan strip sits in the input dock, fed by the projection
+    // (default-collapsed: the header summary shows; rows appear on expand).
+    const panel = view.container.querySelector('[data-testid="todo-panel"]')
+    expect(panel).not.toBeNull()
+    expect(panel!.textContent).toContain('1 已完成\u2002·\u20021 进行中\u2002·\u20021 待处理')
+    fireEvent.click(panel!.querySelector('button')!)
+    expect([...panel!.querySelectorAll('li')].map(li => li.getAttribute('data-status')))
+      .toEqual(['completed', 'in_progress', 'pending'])
+
+    // Next turn retires the standing plan (host pushes null): the strip
+    // clears while the historical row stays in the flow.
+    await runtime.flush()
+    runtime.sessions.behavior(SID).projections.set('todos', null)
+    await waitFor(() => {
+      expect(view.container.querySelector('[data-testid="todo-panel"]')).toBeNull()
+    })
+    expect(view.container.querySelector('[data-tool="todo_write"]')).not.toBeNull()
+    await runtime.dispose()
+  })
+})
+
+describe('terminal card assembly', () => {
+  it('both the keyed bash row and the fallback row reach the terminal card through the whole-row expand', async () => {
+    const runtime = await bench([
+      bashResult(3, 'c-keyed'),
+      // An unregistered tool with terminal views: GenericToolCard fallback.
+      bashResult(4, 'c-fallback', { call: { name: 'fx-bash', argsRaw: '{"command":"ls -la"}' } }),
+    ])
+    const view = runtime.renderRoot()
+
+    // Keyed BashRow: collapsed by default, the whole summary row is the toggle.
+    const keyedRow = view.container.querySelector('[data-sample="bash"]')
+    const keyed = keyedRow?.parentElement
+    expect(keyed?.querySelector('[data-terminal]')).toBeNull()
+    fireEvent.click(keyedRow!)
+    await waitFor(() => {
+      expect(keyed!.querySelector('[data-terminal]')).not.toBeNull()
+    })
+
+    // Fallback row: same unified expand interaction.
+    const fallback = view.container.querySelector('[data-tool="fx-bash"]')
+    expect(fallback).not.toBeNull()
+    expect(fallback!.querySelector('[data-terminal]')).toBeNull()
+    fireEvent.click(fallback!.querySelector('[data-expandable]')!)
+    await waitFor(() => {
+      expect(fallback!.querySelector('[data-terminal]')).not.toBeNull()
+    })
+    await runtime.dispose()
+  })
+})

+ 7 - 4
packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx → packages/client/ui-tool/tests/chat-code-subcalls.spec.tsx

@@ -19,7 +19,8 @@ import type {
 import { createSlotRenderer } from '@deepseek-ai/dsh-client-web-react'
 import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
 import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
-import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
+import { apply as applyConversation, inject as injectConversation } from '@deepseek-ai/dsh-client-ui-conversation/client'
+import { apply as applyTool, inject as injectTool } from '../src/client/apply.ts'
 
 const SID = 's1' as SessionId
 
@@ -80,7 +81,7 @@ function AppRoot({ renderSlot }: AppRootProps) {
   return <>{renderSlot('conversation', {})}</>
 }
 
-/** Same real-stack bench as the toolview-slot spec: SlotsService + renderer + this package's apply; fakes only at service seams. */
+/** Same real-stack bench as the toolview-slot spec: SlotsService + renderer + both owning package applies; fakes only at service seams. */
 async function bench(snapshot: ConversationSnapshot) {
   const ctx = new Context()
   const slotsFiber = ctx.plugin(SlotsService)
@@ -150,9 +151,11 @@ async function bench(snapshot: ConversationSnapshot) {
     },
   }, AppRoot)
 
-  const fiber = ctx.plugin({ inject: [...inject], apply })
+  const fiber = ctx.plugin({ inject: [...injectConversation], apply: applyConversation })
   await fiber.await()
-  return { ctx, slots, fiber, session, layout, workspaces }
+  const toolFiber = ctx.plugin({ inject: [...injectTool], apply: applyTool })
+  await toolFiber.await()
+  return { ctx, slots, fiber, toolFiber, session, layout, workspaces }
 }
 
 function mountApp(slots: SlotsService) {

+ 116 - 0
packages/client/ui-tool/tests/coverage-tails.spec.tsx

@@ -0,0 +1,116 @@
+// @vitest-environment jsdom
+// Tool presentation branch tails not reached by the main acceptance specs.
+
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { cleanup, render } from '@testing-library/react'
+import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
+import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
+import type { RunningToolCall, SessionId, SessionListState, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
+import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
+import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
+import { GenericToolCard, type GenericToolCardProps } from '../src/client/tool/toolviews/GenericToolCard.tsx'
+import { ToolRow } from '../src/client/tool/components/ToolRow.tsx'
+import { BashRow } from '../src/client/tool/toolviews/bash-sample.tsx'
+import { zh } from '../../ui-conversation/src/client/locales.ts'
+
+type BashRowProps = Parameters<typeof BashRow>[0]
+
+// Mirrors the real lookup chain (conversation namespace, then common).
+const t: GenericToolCardProps['t'] = makeTranslate(zh, commonZh)
+
+afterEach(cleanup)
+
+const SID = 'root-1' as SessionId
+
+function listStore() {
+  return createSnapshotStore<SessionListState>({
+    ids: [SID],
+    byId: {
+      [SID]: { id: SID, title: 'r', displayTitle: 'r', running: false, blank: false, updatedAt: 0 },
+    },
+    current: undefined,
+    phase: 'ready',
+    subagentsByParent: {},
+    currentAddress: undefined,
+  })
+}
+
+function bashProps(block: RunningToolCall | ToolResultNode): BashRowProps {
+  return {
+    callId: 'c1', toolName: 'bash', block, openFile: vi.fn(),
+    sessionId: SID, useSessions: bindSnapshotSelector(listStore()),
+    t,
+  } as unknown as BashRowProps
+}
+
+describe('Tool presentation tails', () => {
+  it('ToolRow stopped state renders the warning dot in the leading slot', () => {
+    const view = render(
+      <ToolRow t={t} variant="bash" icon={<i data-testid="icon" />} title="Bash" summary="s" body={null} state="stopped" />,
+    )
+    expect(view.queryByTestId('icon')).toBeNull()
+    expect(view.container.querySelector('[data-state="stopped"]')).not.toBeNull()
+  })
+
+  it('a settled others-variant row renders the sparkle icon in the leading slot', () => {
+    const settled: ToolResultNode = {
+      kind: 'tool-result', seq: 2, time: 2_000, callId: 'c5',
+      call: { name: 'todo_write', argsRaw: '{"note":"x"}' },
+      callTime: 1_000,
+      content: [], isError: false, callView: null, resultView: null,
+    }
+    const props: GenericToolCardProps = {
+      callId: 'c5', toolName: 'todo_write', block: settled, openFile: vi.fn(), t,
+    }
+    const view = render(<GenericToolCard {...props} />)
+    expect(view.container.querySelector('[data-variant="others"] svg')).not.toBeNull()
+    expect(view.container.querySelector('[data-state="ok"]')).not.toBeNull()
+  })
+
+  it('BashRow summarizes the description without a row click target', () => {
+    const settled: ToolResultNode = {
+      kind: 'tool-result', seq: 3, time: 3_000, callId: 'c1',
+      call: { name: 'bash', argsRaw: '{"command":"make build","description":"Build"}' },
+      callTime: 2_000,
+      content: [], isError: false, callView: null, resultView: null,
+    }
+    const view = render(<BashRow {...bashProps(settled)} />)
+    const row = view.container.querySelector('[data-sample="bash"]')!
+    expect(row.textContent).toContain('Bash')
+    expect(row.textContent).toContain('Build')
+    expect(row.getAttribute('data-clickable')).toBeNull()
+  })
+
+  it('BashRow carries data-state for running and StateDots for error/stopped', () => {
+    const running: RunningToolCall = {
+      callId: 'c1', name: 'bash', argsRaw: '{"command":"ls","description":"List"}',
+      turn: 1, step: 1, time: 1_000, callView: null,
+    }
+    const errorResult: ToolResultNode = {
+      kind: 'tool-result', seq: 1, time: 1_000, callId: 'c1',
+      call: { name: 'bash', argsRaw: '{"command":"boom"}' },
+      callTime: 500,
+      content: [], isError: true, callView: null, resultView: null,
+    }
+    const stoppedResult: ToolResultNode = {
+      ...errorResult,
+      error: { name: 'E', code: 'interrupted' },
+    }
+
+    const runningView = render(<BashRow {...bashProps(running)} />)
+    expect(runningView.container.querySelector('[data-state="running"]')).not.toBeNull()
+    expect(runningView.getByText('Bash')).toBeTruthy()
+    expect(runningView.getByText('List')).toBeTruthy()
+    runningView.unmount()
+
+    const errorView = render(<BashRow {...bashProps(errorResult)} />)
+    expect(errorView.container.querySelector('[data-sample="bash"]')).not.toBeNull()
+    expect(errorView.container.querySelector('[data-state="error"]')).not.toBeNull()
+    expect(errorView.getByText('失败')).toBeTruthy()
+    errorView.unmount()
+
+    const stoppedView = render(<BashRow {...bashProps(stoppedResult)} />)
+    expect(stoppedView.container.querySelector('[data-state="stopped"]')).not.toBeNull()
+    expect(stoppedView.getByText('已停止')).toBeTruthy()
+  })
+})

+ 9 - 6
packages/client/ui-conversation/tests/diff-card.spec.tsx → packages/client/ui-tool/tests/diff-card.spec.tsx

@@ -15,12 +15,13 @@ import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-client-conne
 import type { SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
 import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
-import { CHAT_DIFF_MAX_LINES, diffCardModel } from '../src/client/contract/diff-card-model.ts'
-import { createChatStore } from '../src/client/stores.ts'
-import { GenericToolCard, type GenericToolCardProps } from '../src/client/chat/GenericToolCard.tsx'
-import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx'
-import { FileMutationRow, fileMutationToolview } from '../src/client/toolviews/file-mutation-row.tsx'
-import { zh } from '../src/client/locales.ts'
+import { CHAT_DIFF_MAX_LINES, diffCardModel } from '../src/client/tool/models/diff-card-model.ts'
+import { createChatStore } from '../../ui-conversation/src/client/stores.ts'
+import { GenericToolCard, type GenericToolCardProps } from '../src/client/tool/toolviews/GenericToolCard.tsx'
+import { DetailsPanel } from '../../ui-conversation/src/client/skeleton/DetailsPanel.tsx'
+import { FileMutationRow, fileMutationToolview } from '../src/client/tool/toolviews/file-mutation-row.tsx'
+import { renderToolDetails, SessionProviderStub } from './tool-details-render.tsx'
+import { zh } from '../../ui-conversation/src/client/locales.ts'
 
 afterEach(cleanup)
 
@@ -323,6 +324,8 @@ describe('DetailsPanel diff Output section', () => {
     })
     return render(
       <DetailsPanel
+        SessionProvider={SessionProviderStub}
+        renderSlot={renderToolDetails(t)}
         sessionId={SID}
         useSession={bindSnapshotSelector({ getSnapshot: () => snapshot, subscribe: () => () => {} })}
         useSessions={bindSnapshotSelector(sessions)}

+ 10 - 7
packages/client/ui-conversation/tests/read-card.spec.tsx → packages/client/ui-tool/tests/read-card.spec.tsx

@@ -18,12 +18,13 @@ import type {
 } from '@deepseek-ai/dsh-client-runtime/client'
 import type { ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
 import type { SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client'
-import { CHAT_READ_MAX_LINES, readCardModel } from '../src/client/contract/read-card-model.ts'
-import { createChatStore } from '../src/client/stores.ts'
-import { GenericToolCard, type GenericToolCardProps } from '../src/client/chat/GenericToolCard.tsx'
-import { zh } from '../src/client/locales.ts'
-import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx'
-import { ReadRow, readToolview } from '../src/client/toolviews/read-row.tsx'
+import { CHAT_READ_MAX_LINES, readCardModel } from '../src/client/tool/models/read-card-model.ts'
+import { createChatStore } from '../../ui-conversation/src/client/stores.ts'
+import { GenericToolCard, type GenericToolCardProps } from '../src/client/tool/toolviews/GenericToolCard.tsx'
+import { zh } from '../../ui-conversation/src/client/locales.ts'
+import { DetailsPanel } from '../../ui-conversation/src/client/skeleton/DetailsPanel.tsx'
+import { ReadRow, readToolview } from '../src/client/tool/toolviews/read-row.tsx'
+import { renderToolDetails, SessionProviderStub } from './tool-details-render.tsx'
 
 afterEach(cleanup)
 
@@ -243,7 +244,7 @@ describe('ReadRow keyed toolview', () => {
     } } as unknown as Context
     readToolview.apply(ctx)
     // The row composes ToolRow, so it declares its locale namespace at the seat.
-    expect(registered).toEqual([{ name: 'conversation.chat.toolview', key: 'read', locale: 'conversation' }])
+    expect(registered).toEqual([{ name: 'tool.call.toolview', key: 'read', locale: 'conversation' }])
     expect(readToolview.inject).toEqual(['slots'])
   })
 })
@@ -269,6 +270,8 @@ describe('DetailsPanel Output section (read)', () => {
     })
     return render(
       <DetailsPanel
+        SessionProvider={SessionProviderStub}
+        renderSlot={renderToolDetails(t)}
         sessionId={SID}
         t={t}
         useSession={bindSnapshotSelector({ getSnapshot: () => snapshot, subscribe: () => () => {} })}

+ 9 - 6
packages/client/ui-conversation/tests/search-card.spec.tsx → packages/client/ui-tool/tests/search-card.spec.tsx

@@ -17,12 +17,13 @@ import type { ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
 import type { SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
 import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
-import { CHAT_SEARCH_MAX_LINES, searchCardModel } from '../src/client/contract/search-card-model.ts'
-import { zh } from '../src/client/locales.ts'
-import { createChatStore } from '../src/client/stores.ts'
-import { GenericToolCard, type GenericToolCardProps } from '../src/client/chat/GenericToolCard.tsx'
-import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx'
-import { SearchRow, searchToolview } from '../src/client/toolviews/search-row.tsx'
+import { CHAT_SEARCH_MAX_LINES, searchCardModel } from '../src/client/tool/models/search-card-model.ts'
+import { zh } from '../../ui-conversation/src/client/locales.ts'
+import { createChatStore } from '../../ui-conversation/src/client/stores.ts'
+import { GenericToolCard, type GenericToolCardProps } from '../src/client/tool/toolviews/GenericToolCard.tsx'
+import { DetailsPanel } from '../../ui-conversation/src/client/skeleton/DetailsPanel.tsx'
+import { SearchRow, searchToolview } from '../src/client/tool/toolviews/search-row.tsx'
+import { renderToolDetails, SessionProviderStub } from './tool-details-render.tsx'
 
 /** SearchRow now composes ToolRow, so its props include the locale `t` seat. */
 type SearchRowProps = Parameters<typeof SearchRow>[0]
@@ -385,6 +386,8 @@ describe('DetailsPanel Output section (search)', () => {
     })
     return render(
       <DetailsPanel
+        SessionProvider={SessionProviderStub}
+        renderSlot={renderToolDetails(t)}
         sessionId={SID}
         useSession={bindSnapshotSelector({ getSnapshot: () => snapshot, subscribe: () => () => {} })}
         useSessions={bindSnapshotSelector(sessions)}

+ 11 - 6
packages/client/ui-conversation/tests/terminal-card.spec.tsx → packages/client/ui-tool/tests/terminal-card.spec.tsx

@@ -15,12 +15,13 @@ import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-client-conne
 import type { SelectionTarget } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
 import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
-import { terminalCardModel, terminalFailed } from '../src/client/contract/terminal-card-model.ts'
-import { createChatStore } from '../src/client/stores.ts'
-import { GenericToolCard, type GenericToolCardProps } from '../src/client/chat/GenericToolCard.tsx'
-import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx'
-import { BashRow } from '../src/client/toolviews/bash-sample.tsx'
-import { zh } from '../src/client/locales.ts'
+import { terminalCardModel, terminalFailed } from '../src/client/tool/models/terminal-card-model.ts'
+import { createChatStore } from '../../ui-conversation/src/client/stores.ts'
+import { GenericToolCard, type GenericToolCardProps } from '../src/client/tool/toolviews/GenericToolCard.tsx'
+import { DetailsPanel } from '../../ui-conversation/src/client/skeleton/DetailsPanel.tsx'
+import { BashRow } from '../src/client/tool/toolviews/bash-sample.tsx'
+import { renderToolDetails, SessionProviderStub } from './tool-details-render.tsx'
+import { zh } from '../../ui-conversation/src/client/locales.ts'
 
 type BashRowProps = Parameters<typeof BashRow>[0]
 
@@ -460,6 +461,8 @@ describe('DetailsPanel Output section', () => {
     })
     return render(
       <DetailsPanel
+        SessionProvider={SessionProviderStub}
+        renderSlot={renderToolDetails(t)}
         sessionId={SID}
         useSession={bindSnapshotSelector({ getSnapshot: () => snapshot, subscribe: () => () => {} })}
         useSessions={bindSnapshotSelector(sessions)}
@@ -628,6 +631,8 @@ describe('DetailsPanel Output section', () => {
     const snap = snapshot()
     const view = render(
       <DetailsPanel
+        SessionProvider={SessionProviderStub}
+        renderSlot={renderToolDetails(t)}
         sessionId={SID}
         useSession={bindSnapshotSelector({ getSnapshot: () => snap, subscribe: () => () => {} })}
         useSessions={bindSnapshotSelector(createSnapshotStore<SessionListState>(

+ 158 - 0
packages/client/ui-tool/tests/todo-row.spec.tsx

@@ -0,0 +1,158 @@
+// @vitest-environment jsdom
+/** todo_write atomic Tool presentation and its plan-summary model. */
+import { cleanup, fireEvent, render, screen } from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import type { TodoItem, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
+import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
+import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
+import { TodoRow, todoToolview } from '../src/client/tool/toolviews/todo-row.tsx'
+import { planSummary } from '../src/client/tool/toolviews/plan-summary.ts'
+import { CONVERSATION_NS as NS } from '../src/client/locale.ts'
+import { zh } from '../../ui-conversation/src/client/locales.ts'
+
+type TodoRowProps = Parameters<typeof TodoRow>[0]
+
+const t: TodoRowProps['t'] = makeTranslate(zh, commonZh)
+
+afterEach(cleanup)
+
+const LIST: TodoItem[] = [
+  { content: '搭骨架', status: 'completed' },
+  { content: '写组件', status: 'in_progress' },
+  { content: '补测试', status: 'pending' },
+]
+
+const PARALLEL: TodoItem[] = [
+  { content: '搭骨架', status: 'completed' },
+  { content: '写组件', status: 'in_progress' },
+  { content: '跑后台构建', status: 'in_progress' },
+  { content: '读源码', status: 'in_progress' },
+  { content: '补测试', status: 'pending' },
+]
+
+describe('planSummary', () => {
+  it('counts done/total and names the single active item with no extra count', () => {
+    expect(planSummary(LIST)).toEqual({ done: 1, total: 3, activeContent: '写组件', activeExtra: 0 })
+  })
+
+  it('reports the extra active count separately when several items are in progress', () => {
+    expect(planSummary(PARALLEL)).toEqual({ done: 1, total: 5, activeContent: '写组件', activeExtra: 2 })
+  })
+
+  it('has no hint when nothing is in progress', () => {
+    expect(planSummary([{ content: '都完了', status: 'completed' }]))
+      .toEqual({ done: 1, total: 1, activeContent: null, activeExtra: 0 })
+  })
+
+  it('has no hint when the first active item carries no usable content', () => {
+    expect(planSummary([{ status: 'in_progress' }, { content: 'x', status: 'in_progress' }]))
+      .toMatchObject({ activeContent: null, activeExtra: 0 })
+    expect(planSummary([{ content: 42, status: 'in_progress' }]).activeContent).toBeNull()
+    expect(planSummary([{ content: '', status: 'in_progress' }]).activeContent).toBeNull()
+    expect(planSummary([{ content: '   ', status: 'in_progress' }, { content: 'x', status: 'in_progress' }]))
+      .toMatchObject({ activeContent: null, activeExtra: 0 })
+  })
+
+  it('is empty-safe', () => {
+    expect(planSummary([])).toEqual({ done: 0, total: 0, activeContent: null, activeExtra: 0 })
+  })
+})
+
+const resultNode = (argsRaw: string, over?: Partial<ToolResultNode>): ToolResultNode => ({
+  kind: 'tool-result', seq: 10, time: 2_000, callTime: 1_000, callId: 'c1',
+  call: { name: 'todo_write', argsRaw },
+  content: [], isError: false, callView: null, resultView: null, ...over,
+})
+
+function rowProps(block: unknown): TodoRowProps {
+  return {
+    callId: 'c1', toolName: 'todo_write', block,
+    openFile: vi.fn(),
+    sessionId: 's1',
+    useSessions: () => undefined,
+    t,
+  } as unknown as TodoRowProps
+}
+
+describe('TodoRow', () => {
+  const ARGS = JSON.stringify({ todos: LIST })
+
+  it('summarizes counts and the active item from the call args', () => {
+    render(<TodoRow {...rowProps(resultNode(ARGS))} />)
+    expect(screen.getByText('更新任务清单')).toBeTruthy()
+    expect(screen.getByText('1/3 已完成 · 写组件')).toBeTruthy()
+  })
+
+  it('reports the extra active count outside the ellipsized summary text', () => {
+    const { container } = render(<TodoRow {...rowProps(resultNode(JSON.stringify({ todos: PARALLEL })))} />)
+    const text = screen.getByText('1/5 已完成 · 写组件')
+    const extra = screen.getByText('+2')
+    expect(text.contains(extra)).toBe(false)
+    expect(container.textContent).toContain('1/5 已完成 · 写组件+2')
+  })
+
+  it('omits the active clause when no item is in progress and reads running-call args', () => {
+    const args = JSON.stringify({ todos: [{ content: 'x', status: 'completed' }] })
+    render(<TodoRow {...rowProps({ callId: 'c1', name: 'todo_write', argsRaw: args, turn: 1, step: 1, time: 1_000, callView: null })} />)
+    expect(screen.getByText('1/1 已完成')).toBeTruthy()
+  })
+
+  it('keeps the counts when an active item has unusable content', () => {
+    const args = JSON.stringify({ todos: [{ content: 'done', status: 'completed' }, { content: 42, status: 'in_progress' }] })
+    const { container } = render(<TodoRow {...rowProps(resultNode(args))} />)
+    expect(screen.getByText('1/2 已完成')).toBeTruthy()
+    expect(container.textContent).not.toContain('+')
+  })
+
+  it('keeps non-ok execution states visible through the shared row states', () => {
+    const args = JSON.stringify({ todos: LIST })
+    const running = render(<TodoRow {...rowProps({ callId: 'c1', name: 'todo_write', argsRaw: args, turn: 1, step: 1, time: 1_000, callView: null })} />)
+    expect(running.container.querySelector('[data-state="running"]')).not.toBeNull()
+    expect(running.container.querySelector('[data-state="running"] svg')).not.toBeNull()
+    running.unmount()
+    const stopped = render(<TodoRow {...rowProps(resultNode(args, { isError: true, error: { name: 'Interrupted', code: 'interrupted' } }))} />)
+    expect(stopped.container.querySelector('[data-state="stopped"]')).not.toBeNull()
+  })
+
+  it('falls back to the generic summary on malformed args and marks the error state', () => {
+    const view = render(<TodoRow {...rowProps(resultNode('not json', { isError: true }))} />)
+    expect(view.container.querySelector('[data-state="error"]')).not.toBeNull()
+    expect(screen.getByText('todo_write · not json')).toBeTruthy()
+  })
+
+  it('falls back when parsed args carry no todos array', () => {
+    render(<TodoRow {...rowProps(resultNode('{"other":1}'))} />)
+    expect(screen.getByText('todo_write · {"other":1}')).toBeTruthy()
+  })
+
+  it('leading toggle expands the raw args body', () => {
+    render(<TodoRow {...rowProps(resultNode(ARGS))} />)
+    fireEvent.click(screen.getByRole('button', { expanded: false }))
+    expect(screen.getByRole('button', { expanded: true })).toBeTruthy()
+    expect(screen.getByText(/搭骨架/)).toBeTruthy()
+  })
+
+  it.each([
+    { label: 'null root', argsRaw: 'null' },
+    { label: 'non-object root', argsRaw: '42' },
+    { label: 'null items', argsRaw: '{"todos":[null]}' },
+  ])('falls back to the generic summary on valid JSON with an invalid shape ($label)', ({ argsRaw }) => {
+    render(<TodoRow {...rowProps(resultNode(argsRaw))} />)
+    expect(screen.getByText(`todo_write · ${argsRaw}`)).toBeTruthy()
+  })
+
+  it('window-truncated result falls back to the callId summary', () => {
+    render(<TodoRow {...rowProps(resultNode('', { call: null }))} />)
+    expect(screen.getByText('todo_write · c1')).toBeTruthy()
+  })
+
+  it('injects the keyed toolview declaration directly', () => {
+    expect(todoToolview.name).toBe('todo-toolview')
+    expect(todoToolview.inject).toEqual(['slots'])
+    const register = vi.fn(() => () => undefined)
+    const inject = vi.fn((_name: string, callback: () => () => void) => callback())
+    todoToolview.apply({ slots: { inject, register } } as never)
+    expect(inject).toHaveBeenCalledWith('tool.call.toolview', expect.any(Function))
+    expect(register).toHaveBeenCalledWith({ name: 'tool.call.toolview', key: 'todo_write', locale: NS }, TodoRow)
+  })
+})

+ 63 - 0
packages/client/ui-tool/tests/tool-call-tree.spec.tsx

@@ -0,0 +1,63 @@
+// @vitest-environment jsdom
+/** ToolCallTree-owned root/subcall markers and selection projection. */
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { cleanup, render } from '@testing-library/react'
+import type { CodeSubCall, ConversationSnapshot, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
+import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
+import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
+import type { ToolTreeProps } from '../src/client/contract/slots.ts'
+import { ToolCallTree } from '../src/client/tool/ToolCallTree.tsx'
+import { zh } from '../../ui-conversation/src/client/locales.ts'
+
+afterEach(cleanup)
+
+const t: ToolTreeProps['t'] = makeTranslate(zh, commonZh)
+
+const root = (callId: string, call: ToolResultNode['call']): ToolResultNode => ({
+  kind: 'tool-result', seq: 3, time: 3_000, callId, call, callTime: 2_000,
+  content: [], isError: false, callView: null, resultView: null,
+})
+
+function props(
+  block: ToolResultNode,
+  codeDispatches: ReadonlyMap<string, readonly CodeSubCall[]> = new Map(),
+  selectedCallId?: string,
+): ToolTreeProps {
+  const snapshot = { codeDispatches } as ConversationSnapshot
+  const useSession = ((selector: (value: ConversationSnapshot) => unknown) => selector(snapshot)) as ToolTreeProps['useSession']
+  const renderSlot = ((_key: string, _owner: object, options?: { fallback?: React.ReactNode }) =>
+    options?.fallback ?? null) as unknown as ToolTreeProps['renderSlot']
+  return {
+    useSession,
+    renderSlot,
+    callId: block.callId,
+    toolName: block.call?.name ?? '',
+    block,
+    selectedCallId,
+    openFile: vi.fn(),
+    inspectCall: vi.fn(),
+    t,
+  } as unknown as ToolTreeProps
+}
+
+describe('ToolCallTree', () => {
+  it('owns the root marker, generic fallback, and selected state for a window-truncated call', () => {
+    const block = root('w1', null)
+    const view = render(<ToolCallTree {...props(block, new Map(), 'w1')} />)
+    const row = view.container.querySelector('[data-chat-call-id="w1"]')
+    expect(row?.getAttribute('data-chat-anchor-key')).toBe('call:w1')
+    expect(row?.getAttribute('data-selected')).toBe('true')
+    expect(view.container.querySelector('[data-variant="others"]')).not.toBeNull()
+    expect(view.getByText('w1')).toBeTruthy()
+  })
+
+  it('marks a selected subcall without selecting its root', () => {
+    const block = root('parent', { name: 'run_code', argsRaw: '{"code":"return 1"}' })
+    const child: CodeSubCall = root('parent:code:1', { name: 'read', argsRaw: '{"path":"a.ts"}' })
+    const view = render(
+      <ToolCallTree {...props(block, new Map([['parent', [child]]]), child.callId)} />,
+    )
+    expect(view.container.querySelector('[data-chat-call-id="parent"]')?.hasAttribute('data-selected')).toBe(false)
+    expect(view.container.querySelector('[data-chat-call-id="parent:code:1"]')?.getAttribute('data-selected')).toBe('true')
+  })
+})

+ 22 - 0
packages/client/ui-tool/tests/tool-details-render.tsx

@@ -0,0 +1,22 @@
+/** Test adapter for the production conversation.details.tool registration. */
+import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
+import type { SessionProviderComponent, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
+import type { DetailsSlotProps, DetailsToolOwnerProps } from '../../ui-conversation/src/client/contract/slots.ts'
+import { ToolDetails } from '../src/client/tool/ToolDetails.tsx'
+
+/** Framework session-area seat used by direct DetailsPanel tests. */
+export const SessionProviderStub: SessionProviderComponent = ({ children }) => children('s1' as SessionId)
+
+/**
+ * Bind ui-tool's details renderer to the conversation slot callback shape.
+ * @param t - conversation locale seat used by Tool cards.
+ * @returns a direct-test renderSlot implementation.
+ */
+export function renderToolDetails(t: TranslateNS<'conversation'>): DetailsSlotProps['renderSlot'] {
+  return (_key, owner) => {
+    // PropsRenderSlots keeps its key generic even for this one-key share;
+    // recover the concrete owner selected by the adapter's fixed slot.
+    const details = owner as DetailsToolOwnerProps
+    return <ToolDetails block={details.block} cwd={details.cwd} t={t} />
+  }
+}

+ 1 - 1
packages/client/ui-conversation/tests/tool-row-styles.spec.ts → packages/client/ui-tool/tests/tool-row-styles.spec.ts

@@ -8,7 +8,7 @@ import { readFileSync } from 'node:fs'
 import { fileURLToPath } from 'node:url'
 import { describe, expect, it } from 'vitest'
 
-const css = readFileSync(fileURLToPath(new URL('../src/client/chat/ToolRow.module.css', import.meta.url)), 'utf8')
+const css = readFileSync(fileURLToPath(new URL('../src/client/tool/components/ToolRow.module.css', import.meta.url)), 'utf8')
 /** Declarations only: the sheet's prose names the properties it explains. */
 const declarationText = css.replace(/\/\*[\s\S]*?\*\//g, ' ')
 

+ 5 - 109
packages/client/ui-conversation/tests/chat-tool-row.spec.tsx → packages/client/ui-tool/tests/tool-row.spec.tsx

@@ -1,44 +1,17 @@
 // @vitest-environment jsdom
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { afterEach, describe, expect, it, vi } from 'vitest'
 import { cleanup, fireEvent, render } from '@testing-library/react'
 
 import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
 import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
 import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
-import { classifyTool, resolveToolPath, resultText, toolRowModel } from '../src/client/contract/tool-call-model.ts'
-import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
-import { ToolRow } from '../src/client/chat/ToolRow.tsx'
-import { GenericToolCard, type GenericToolCardProps } from '../src/client/chat/GenericToolCard.tsx'
-import { zh } from '../src/client/locales.ts'
-
-let nextAnimationFrameId = 1
-let animationFrames = new Map<number, FrameRequestCallback>()
-
-function flushAnimationFrames(count: number): void {
-  for (let index = 0; index < count; index += 1) {
-    const callbacks = [...animationFrames.values()]
-    animationFrames.clear()
-    for (const callback of callbacks) callback(index)
-  }
-}
-
-beforeEach(() => {
-  nextAnimationFrameId = 1
-  animationFrames = new Map()
-  vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
-    const id = nextAnimationFrameId
-    nextAnimationFrameId += 1
-    animationFrames.set(id, callback)
-    return id
-  })
-  vi.stubGlobal('cancelAnimationFrame', (id: number) => {
-    animationFrames.delete(id)
-  })
-})
+import { classifyTool, resolveToolPath, resultText, toolRowModel } from '../src/client/tool/models/tool-call-model.ts'
+import { ToolRow } from '../src/client/tool/components/ToolRow.tsx'
+import { GenericToolCard, type GenericToolCardProps } from '../src/client/tool/toolviews/GenericToolCard.tsx'
+import { zh } from '../../ui-conversation/src/client/locales.ts'
 
 afterEach(() => {
   cleanup()
-  vi.unstubAllGlobals()
 })
 
 // Mirrors the real lookup chain (conversation namespace, then common).
@@ -369,83 +342,6 @@ describe('ToolRow', () => {
   })
 })
 
-describe('ThinkRow', () => {
-  it('follows the latest streaming line, scrolls to its end, then restores the settled first line', () => {
-    const view = render(
-      <AssistantMarkdown
-        t={t}
-        blocks={[{ kind: 'reasoning', text: 'Inspect the session\nNewest reasoning tokens' }]}
-        streaming
-      />,
-    )
-    const summary = view.getByText('Newest reasoning tokens')
-    Object.defineProperties(summary, {
-      scrollWidth: { configurable: true, value: 300 },
-      clientWidth: { configurable: true, value: 100 },
-    })
-
-    view.rerender(
-      <AssistantMarkdown
-        t={t}
-        blocks={[{ kind: 'reasoning', text: 'Inspect the session\nNewest reasoning tokens keep arriving' }]}
-        streaming
-      />,
-    )
-    expect(summary.scrollLeft).toBe(0)
-    flushAnimationFrames(2)
-    expect(summary.scrollLeft).toBe(0)
-    flushAnimationFrames(1)
-    expect(summary.scrollLeft).toBe(200)
-    expect(summary.getAttribute('data-follow-end')).toBe('true')
-
-    view.rerender(
-      <AssistantMarkdown
-        t={t}
-        blocks={[{ kind: 'reasoning', text: 'Inspect the session\nNewest reasoning tokens keep arriving\n' }]}
-        streaming={false}
-      />,
-    )
-    flushAnimationFrames(3)
-    expect(view.getByText('Inspect the session')).toBeTruthy()
-    expect(summary.scrollLeft).toBe(0)
-    expect(summary.hasAttribute('data-follow-end')).toBe(false)
-  })
-
-  it('expands from either Think or the reasoning summary', () => {
-    const view = render(
-      <AssistantMarkdown
-        t={t}
-        blocks={[{ kind: 'reasoning', text: 'Inspect the session\nCheck persistence' }]}
-        streaming={false}
-      />,
-    )
-    const row = view.getByRole('button')
-
-    fireEvent.click(view.getByText('Inspect the session'))
-    expect(row.getAttribute('aria-expanded')).toBe('true')
-    expect(view.getByText(/Check persistence/)).toBeTruthy()
-
-    fireEvent.click(view.getByText('Think'))
-    expect(row.getAttribute('aria-expanded')).toBe('false')
-  })
-
-  it('expanded Think drops the inline summary and renders plain prose, no IN card', () => {
-    const view = render(
-      <AssistantMarkdown
-        t={t}
-        blocks={[{ kind: 'reasoning', text: 'Inspect the session\nCheck persistence' }]}
-        streaming={false}
-      />,
-    )
-    fireEvent.click(view.getByText('Think'))
-    // The summary (first line) is gone from the row; only the body carries it.
-    expect(view.getAllByText(/Inspect the session/)).toHaveLength(1)
-    expect(view.queryByText('IN')).toBeNull()
-    expect(view.container.querySelector('[class*="ioCard"]')).toBeNull()
-    expect(view.container.querySelector('[class*="thinkBody"]')).not.toBeNull()
-  })
-})
-
 describe('GenericToolCard', () => {
   const props = (toolName: string, block: RunningToolCall | ToolResultNode): GenericToolCardProps => ({
     callId: 'c1', toolName, block, openFile: vi.fn(), t,

+ 20 - 17
packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx → packages/client/ui-tool/tests/toolview-slot.spec.tsx

@@ -1,8 +1,8 @@
 // @vitest-environment jsdom
-// The dissolved tool ring's acceptance chain on the REAL machinery stack:
+// The Tool presentation package's acceptance chain on the REAL machinery stack:
 // SlotTestRuntime (cordis Context + SlotsService ledger + the web-react
-// renderer) + this package's own apply — no outlet twins. Proves the keyed
-// 'conversation.chat.toolview' hole end to end: registered rows dispatch by
+// renderer) + ui-conversation and ui-tool apply — no outlet twins. Proves the
+// keyed 'tool.call.toolview' hole end to end: registered rows dispatch by
 // entryKey (the bash sample lands through its plugin), unregistered tools
 // fall back to GenericToolCard at the render site, live registration/unload
 // flips rows in place, duplicate keys fail loud, the inject channel feeds
@@ -15,8 +15,9 @@ import type { ISession, SessionId, ToolResultNode } from '@deepseek-ai/dsh-clien
 import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
 import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
 import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
-import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
-import type { ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
+import { apply as applyConversation, inject as injectConversation } from '@deepseek-ai/dsh-client-ui-conversation/client'
+import { apply as applyTool, inject as injectTool } from '@deepseek-ai/dsh-client-ui-tool/client'
+import type { ToolCallViewProps } from '@deepseek-ai/dsh-client-ui-tool/client'
 
 const SID = 's1' as SessionId
 
@@ -77,8 +78,9 @@ async function bench(nodes: ToolResultNode[]) {
     },
   })
   await runtime.root.declare(LAYOUT_CHILDREN, AppRoot)
-  const feature = await runtime.mount({ inject: [...inject], apply })
-  return { runtime, slots: runtime.slots, feature, layout }
+  await runtime.mount({ inject: [...injectConversation], apply: applyConversation })
+  await runtime.mount({ inject: [...injectTool], apply: applyTool })
+  return { runtime, slots: runtime.slots, layout }
 }
 
 describe('keyed toolview hole through the real machinery', () => {
@@ -144,7 +146,7 @@ describe('keyed toolview hole through the real machinery', () => {
     expect(view.getByText('Tool call')).toBeTruthy()
     let dispose = (): void => {}
     dispose = b.slots.register(
-      { name: 'conversation.chat.toolview', key: 'mystery' },
+      { name: 'tool.call.toolview', key: 'mystery' },
       () => <div data-testid="mystery-row" />)
     await b.runtime.flush()
     // Per-key version tick: the row flipped without a remount of the view.
@@ -160,7 +162,7 @@ describe('keyed toolview hole through the real machinery', () => {
   it('a duplicate key registration fails loud at load', async () => {
     const b = await bench([])
     expect(() => b.slots.register(
-      { name: 'conversation.chat.toolview', key: 'bash' },
+      { name: 'tool.call.toolview', key: 'bash' },
       () => null,
     )).toThrow(/key "bash"/)
     await b.runtime.dispose()
@@ -170,7 +172,7 @@ describe('keyed toolview hole through the real machinery', () => {
     const b = await bench([toolResult(3, 'c3', 'probe', '{"x":1}')])
     const poked: string[] = []
     b.slots.register({
-      name: 'conversation.chat.toolview',
+      name: 'tool.call.toolview',
       key: 'probe',
       // Two-way business face: data derived from the session id out, a
       // callback closing over it back in — the askuser-pattern inject shape.
@@ -178,7 +180,7 @@ describe('keyed toolview hole through the real machinery', () => {
         mark: `for:${sessionId}`,
         poke: () => { poked.push(sessionId) },
       }),
-    }, ({ mark, poke }: ToolRowProps & { mark: string; poke: () => void }) => (
+    }, ({ mark, poke }: ToolCallViewProps & { mark: string; poke: () => void }) => (
       <button data-testid="probe-row" onClick={poke}>{mark}</button>
     ))
     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()
   })

+ 34 - 0
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')
+  })
+})

+ 12 - 8
packages/client/ui-conversation/tests/web-card.spec.tsx → 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 '../../ui-conversation/src/client/stores.ts'
+import { GenericToolCard } from '../src/client/tool/toolviews/GenericToolCard.tsx'
+import { DetailsPanel } from '../../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 '../../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(
       <DetailsPanel
+        SessionProvider={SessionProviderStub}
+        renderSlot={renderToolDetails(t)}
         sessionId={SID}
         useSession={bindSnapshotSelector({ getSnapshot: () => snapshot, subscribe: () => () => {} })}
         useSessions={bindSnapshotSelector(sessions)}

+ 33 - 0
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"
+    }
+  ]
+}

+ 3 - 0
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'])

+ 55 - 3
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':

+ 1 - 0
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"],

+ 1 - 0
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" },

+ 1 - 0
vitest.config.ts

@@ -162,6 +162,7 @@ export default defineConfig({
         'packages/client/web-react/src/*',
         'packages/client/runtime/src/*',
         'packages/client/ui-conversation/src/*',
+        'packages/client/ui-tool/src/*',
         'packages/client/ui-slots/src/*',
         'packages/client/ui-layout/src/*',
         'packages/client/web/src/*',

Kaikkia tiedostoja ei voida näyttää, sillä liian monta tiedostoa muuttui tässä diffissä