+ )
+}
diff --git a/packages/client/ui-conversation/src/client/chat/use-throttled-visual-update.ts b/packages/client/ui-conversation/src/client/chat/use-throttled-visual-update.ts
index e282b4b087..8fec1832d1 100644
--- a/packages/client/ui-conversation/src/client/chat/use-throttled-visual-update.ts
+++ b/packages/client/ui-conversation/src/client/chat/use-throttled-visual-update.ts
@@ -1,14 +1,12 @@
/** Frame-throttled scheduling for non-essential visual alignment. */
-
import { useCallback, useLayoutEffect, useRef } from 'react'
const DEFAULT_INTERVAL_FRAMES = 3
/**
* Return a stable scheduler that coalesces visual updates over a frame interval.
- * Repeated calls retain the latest callback, and unmount cancels pending work.
* @param update - DOM alignment to run after the throttle interval.
- * @param intervalFrames - Frames to wait before applying the latest alignment.
+ * @param intervalFrames - frames to wait before applying the latest alignment.
* @returns a stable function that schedules the latest update.
*/
export function useThrottledVisualUpdate(
diff --git a/packages/client/ui-conversation/tests/reasoning-row.spec.tsx b/packages/client/ui-conversation/tests/reasoning-row.spec.tsx
new file mode 100644
index 0000000000..243b665ce2
--- /dev/null
+++ b/packages/client/ui-conversation/tests/reasoning-row.spec.tsx
@@ -0,0 +1,115 @@
+// @vitest-environment jsdom
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { cleanup, fireEvent, render } from '@testing-library/react'
+import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
+import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
+import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
+import { zh } from '../src/client/locales.ts'
+
+let nextAnimationFrameId = 1
+let animationFrames = new Map()
+
+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)
+ })
+})
+
+afterEach(() => {
+ cleanup()
+ vi.unstubAllGlobals()
+})
+
+const t = makeTranslate(zh, commonZh)
+
+describe('ReasoningRow', () => {
+ it('follows the latest streaming line, scrolls to its end, then restores the settled first line', () => {
+ const view = render(
+ ,
+ )
+ const summary = view.getByText('Newest reasoning tokens')
+ Object.defineProperties(summary, {
+ scrollWidth: { configurable: true, value: 300 },
+ clientWidth: { configurable: true, value: 100 },
+ })
+
+ view.rerender(
+ ,
+ )
+ 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(
+ ,
+ )
+ 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(
+ ,
+ )
+ 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(
+ ,
+ )
+ fireEvent.click(view.getByText('Think'))
+ 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()
+ })
+})
From 7cc554ef16b4a77aeff4d728d878160d674fb033 Mon Sep 17 00:00:00 2001
From: imccyu <276526105+imccyu@users.noreply.github.com>
Date: Sat, 8 Aug 2026 15:18:52 +0800
Subject: [PATCH 02/17] cleanup(client): extract Tool presentation into ui-tool
---
packages/bundle/web-app/cordis.patch.yml | 4 +
packages/bundle/web-app/package.json | 1 +
packages/client/AGENTS.md | 2 +-
packages/client/README.i18n.yaml | 4 +-
packages/client/README.md | 1 +
packages/client/README.zh.md | 1 +
.../client/connection/src/client/fixture.ts | 22 +--
packages/client/runtime/src/client/index.ts | 2 +-
.../client/ui-conversation/README.i18n.yaml | 4 +-
packages/client/ui-conversation/README.md | 22 +--
packages/client/ui-conversation/README.zh.md | 22 +--
packages/client/ui-conversation/package.json | 2 +-
.../ui-conversation/src/client/apply.ts | 50 +-----
.../src/client/chat/ChatView.module.css | 11 --
.../src/client/chat/ChatView.tsx | 135 +++------------
.../src/client/contract/slots.ts | 62 ++++---
.../src/client/contract/tool-path.ts | 15 ++
.../ui-conversation/src/client/index.ts | 7 +-
.../client/skeleton/DetailsPanel.module.css | 33 ----
.../src/client/skeleton/DetailsPanel.tsx | 113 +++----------
.../client/ui-conversation/src/invariant.ts | 2 +-
.../tests/assembly-surfaces.spec.tsx | 134 +--------------
.../ui-conversation/tests/chat-apply.spec.tsx | 24 ++-
...sh-sample.spec.tsx => chat-stats.spec.tsx} | 53 +-----
.../ui-conversation/tests/chat-view.spec.tsx | 122 +++++++-------
.../tests/coverage-tails.spec.tsx | 90 +---------
.../tests/gate-branch-tails.spec.tsx | 32 +++-
.../ui-conversation/tests/todo-panel.spec.tsx | 157 +----------------
.../tests/views-type-chain.spec.tsx | 37 +---
packages/client/ui-skill/README.i18n.yaml | 4 +-
packages/client/ui-skill/README.md | 2 +-
packages/client/ui-skill/README.zh.md | 2 +-
packages/client/ui-skill/package.json | 6 +-
.../client/ui-skill/src/client/SkillRow.tsx | 12 +-
packages/client/ui-skill/src/client/index.ts | 4 +-
.../ui-skill/tests/browser-plugin.spec.ts | 6 +-
packages/client/ui-skill/tsconfig.json | 2 +-
packages/client/ui-tool/README.i18n.yaml | 6 +
packages/client/ui-tool/README.md | 44 +++++
packages/client/ui-tool/README.zh.md | 44 +++++
packages/client/ui-tool/package.json | 73 ++++++++
packages/client/ui-tool/src/client/apply.ts | 43 +++++
.../ui-tool/src/client/contract/slots.ts | 39 +++++
packages/client/ui-tool/src/client/index.ts | 3 +
packages/client/ui-tool/src/client/locale.ts | 2 +
.../src/client/tool/ToolCallTree.module.css | 12 ++
.../ui-tool/src/client/tool/ToolCallTree.tsx | 88 ++++++++++
.../src/client/tool/ToolDetails.module.css | 46 +++++
.../ui-tool/src/client/tool/ToolDetails.tsx | 66 ++++++++
.../tool/components/DisclosureRow.module.css | 69 ++++++++
.../client/tool/components/DisclosureRow.tsx | 104 ++++++++++++
.../tool/components}/ToolRow.module.css | 18 --
.../src/client/tool/components}/ToolRow.tsx | 106 +++++-------
.../client/tool/models}/diff-card-model.ts | 0
.../client/tool/models}/read-card-model.ts | 0
.../client/tool/models}/search-card-model.ts | 0
.../tool/models}/terminal-card-model.ts | 0
.../client/tool/models}/tool-call-model.ts | 10 +-
.../src/client/tool/models}/web-card-model.ts | 0
.../tool/toolviews}/GenericToolCard.tsx | 28 ++--
.../tool}/toolviews/ask-question-row.tsx | 18 +-
.../tool}/toolviews/bash-sample.module.css | 0
.../client/tool}/toolviews/bash-sample.tsx | 16 +-
.../tool}/toolviews/file-mutation-row.tsx | 20 +--
.../client/tool}/toolviews/plan-summary.ts | 0
.../src/client/tool}/toolviews/read-row.tsx | 20 +--
.../src/client/tool}/toolviews/search-row.tsx | 22 +--
.../src/client/tool}/toolviews/todo-row.tsx | 20 +--
.../src/client/tool}/toolviews/web-row.tsx | 20 +--
packages/client/ui-tool/src/css-modules.d.ts | 4 +
packages/client/ui-tool/src/index.ts | 4 +
packages/client/ui-tool/src/invariant.ts | 30 ++++
.../tests/ask-question-row.spec.tsx | 8 +-
.../ui-tool/tests/assembly-surfaces.spec.tsx | 149 +++++++++++++++++
.../tests/chat-code-subcalls.spec.tsx | 11 +-
.../ui-tool/tests/coverage-tails.spec.tsx | 116 +++++++++++++
.../tests/diff-card.spec.tsx | 15 +-
.../tests/read-card.spec.tsx | 17 +-
.../tests/search-card.spec.tsx | 15 +-
.../tests/terminal-card.spec.tsx | 17 +-
.../client/ui-tool/tests/todo-row.spec.tsx | 158 ++++++++++++++++++
.../ui-tool/tests/tool-call-tree.spec.tsx | 63 +++++++
.../ui-tool/tests/tool-details-render.tsx | 22 +++
.../tests/tool-row-styles.spec.ts | 2 +-
.../tests/tool-row.spec.tsx} | 114 +------------
.../tests/toolview-slot.spec.tsx} | 37 ++--
.../tests/toolview-type-chain.spec.tsx | 34 ++++
.../tests/web-card.spec.tsx | 20 ++-
packages/client/ui-tool/tsconfig.json | 33 ++++
packages/client/ui-tool/tsdown.config.ts | 3 +
pnpm-lock.yaml | 58 ++++++-
tsconfig.base.json | 1 +
tsconfig.client.json | 1 +
vitest.config.ts | 1 +
94 files changed, 1797 insertions(+), 1275 deletions(-)
create mode 100644 packages/client/ui-conversation/src/client/contract/tool-path.ts
rename packages/client/ui-conversation/tests/{chat-stats-bash-sample.spec.tsx => chat-stats.spec.tsx} (87%)
create mode 100644 packages/client/ui-tool/README.i18n.yaml
create mode 100644 packages/client/ui-tool/README.md
create mode 100644 packages/client/ui-tool/README.zh.md
create mode 100644 packages/client/ui-tool/package.json
create mode 100644 packages/client/ui-tool/src/client/apply.ts
create mode 100644 packages/client/ui-tool/src/client/contract/slots.ts
create mode 100644 packages/client/ui-tool/src/client/index.ts
create mode 100644 packages/client/ui-tool/src/client/locale.ts
create mode 100644 packages/client/ui-tool/src/client/tool/ToolCallTree.module.css
create mode 100644 packages/client/ui-tool/src/client/tool/ToolCallTree.tsx
create mode 100644 packages/client/ui-tool/src/client/tool/ToolDetails.module.css
create mode 100644 packages/client/ui-tool/src/client/tool/ToolDetails.tsx
create mode 100644 packages/client/ui-tool/src/client/tool/components/DisclosureRow.module.css
create mode 100644 packages/client/ui-tool/src/client/tool/components/DisclosureRow.tsx
rename packages/client/{ui-conversation/src/client/chat => ui-tool/src/client/tool/components}/ToolRow.module.css (94%)
rename packages/client/{ui-conversation/src/client/chat => ui-tool/src/client/tool/components}/ToolRow.tsx (76%)
rename packages/client/{ui-conversation/src/client/contract => ui-tool/src/client/tool/models}/diff-card-model.ts (100%)
rename packages/client/{ui-conversation/src/client/contract => ui-tool/src/client/tool/models}/read-card-model.ts (100%)
rename packages/client/{ui-conversation/src/client/contract => ui-tool/src/client/tool/models}/search-card-model.ts (100%)
rename packages/client/{ui-conversation/src/client/contract => ui-tool/src/client/tool/models}/terminal-card-model.ts (100%)
rename packages/client/{ui-conversation/src/client/contract => ui-tool/src/client/tool/models}/tool-call-model.ts (95%)
rename packages/client/{ui-conversation/src/client/contract => ui-tool/src/client/tool/models}/web-card-model.ts (100%)
rename packages/client/{ui-conversation/src/client/chat => ui-tool/src/client/tool/toolviews}/GenericToolCard.tsx (76%)
rename packages/client/{ui-conversation/src/client => ui-tool/src/client/tool}/toolviews/ask-question-row.tsx (85%)
rename packages/client/{ui-conversation/src/client => ui-tool/src/client/tool}/toolviews/bash-sample.module.css (100%)
rename packages/client/{ui-conversation/src/client => ui-tool/src/client/tool}/toolviews/bash-sample.tsx (93%)
rename packages/client/{ui-conversation/src/client => ui-tool/src/client/tool}/toolviews/file-mutation-row.tsx (77%)
rename packages/client/{ui-conversation/src/client => ui-tool/src/client/tool}/toolviews/plan-summary.ts (100%)
rename packages/client/{ui-conversation/src/client => ui-tool/src/client/tool}/toolviews/read-row.tsx (75%)
rename packages/client/{ui-conversation/src/client => ui-tool/src/client/tool}/toolviews/search-row.tsx (80%)
rename packages/client/{ui-conversation/src/client => ui-tool/src/client/tool}/toolviews/todo-row.tsx (83%)
rename packages/client/{ui-conversation/src/client => ui-tool/src/client/tool}/toolviews/web-row.tsx (77%)
create mode 100644 packages/client/ui-tool/src/css-modules.d.ts
create mode 100644 packages/client/ui-tool/src/index.ts
create mode 100644 packages/client/ui-tool/src/invariant.ts
rename packages/client/{ui-conversation => ui-tool}/tests/ask-question-row.spec.tsx (95%)
create mode 100644 packages/client/ui-tool/tests/assembly-surfaces.spec.tsx
rename packages/client/{ui-conversation => ui-tool}/tests/chat-code-subcalls.spec.tsx (96%)
create mode 100644 packages/client/ui-tool/tests/coverage-tails.spec.tsx
rename packages/client/{ui-conversation => ui-tool}/tests/diff-card.spec.tsx (97%)
rename packages/client/{ui-conversation => ui-tool}/tests/read-card.spec.tsx (95%)
rename packages/client/{ui-conversation => ui-tool}/tests/search-card.spec.tsx (97%)
rename packages/client/{ui-conversation => ui-tool}/tests/terminal-card.spec.tsx (98%)
create mode 100644 packages/client/ui-tool/tests/todo-row.spec.tsx
create mode 100644 packages/client/ui-tool/tests/tool-call-tree.spec.tsx
create mode 100644 packages/client/ui-tool/tests/tool-details-render.tsx
rename packages/client/{ui-conversation => ui-tool}/tests/tool-row-styles.spec.ts (94%)
rename packages/client/{ui-conversation/tests/chat-tool-row.spec.tsx => ui-tool/tests/tool-row.spec.tsx} (83%)
rename packages/client/{ui-conversation/tests/chat-toolview-slot.spec.tsx => ui-tool/tests/toolview-slot.spec.tsx} (84%)
create mode 100644 packages/client/ui-tool/tests/toolview-type-chain.spec.tsx
rename packages/client/{ui-conversation => ui-tool}/tests/web-card.spec.tsx (94%)
create mode 100644 packages/client/ui-tool/tsconfig.json
create mode 100644 packages/client/ui-tool/tsdown.config.ts
diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml
index 739f045ce6..62ed35bd49 100644
--- a/packages/bundle/web-app/cordis.patch.yml
+++ b/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
diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json
index 4e2d9cf70b..9b8e15fd66 100644
--- a/packages/bundle/web-app/package.json
+++ b/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:^",
diff --git a/packages/client/AGENTS.md b/packages/client/AGENTS.md
index df7374d7c7..d534e5bb83 100644
--- a/packages/client/AGENTS.md
+++ b/packages/client/AGENTS.md
@@ -9,7 +9,7 @@ Packages here are named with the directory prefix: `@deepseek-ai/dsh-client-..` (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: `..` (e.g. `'tool.call.toolview'`).
3. **Component props are the four shares, all derived**: `PropsRuntime` (SlotMap: owner params + `useSession`/`sessionId` on session scope + global `useSessions`/`useWorkspaces`) & `PropsRenderSlots` (children keys) & `PropsStore` (store factory) & the inject face. Never hand-write a member a share already derives; never re-type a share locally.
4. **Hooks are framework-made only**: `useSession`, `useSessions`, `useWorkspaces`, `useStore`, `renderSlot` are the five standing seats, plus the `use` hooks the renderer binds from provide contributions and inject `hooks` compartments. Business code never creates a hook or selector as a prop value — pass plain data and callbacks. (Component-internal behavioral hooks that subscribe to nothing external are fine.)
5. **Live data has exactly three channels**: parent knows it → owner props at the renderSlot site; only the component knows it → local state; shared across entries or survives remounts → a store declared at register. Derived data is a pure function over framework-hook data (`useMemo`), never its own subscription.
diff --git a/packages/client/README.i18n.yaml b/packages/client/README.i18n.yaml
index 503a747087..e65d5246a4 100644
--- a/packages/client/README.i18n.yaml
+++ b/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
diff --git a/packages/client/README.md b/packages/client/README.md
index b950772d4c..b6fa426fbe 100644
--- a/packages/client/README.md
+++ b/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. |
diff --git a/packages/client/README.zh.md b/packages/client/README.zh.md
index 8f1f7f4677..5a55bb8c2c 100644
--- a/packages/client/README.zh.md
+++ b/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) | 提供会话感知的命令发现与分发。 |
diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts
index a6cf9c1d7c..ec3199ee02 100644
--- a/packages/client/connection/src/client/fixture.ts
+++ b/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',
]
/**
diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts
index ceee6a1f10..67e6c0e61c 100644
--- a/packages/client/runtime/src/client/index.ts
+++ b/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
/**
diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml
index db7db5db5f..126729ad9d 100644
--- a/packages/client/ui-conversation/README.i18n.yaml
+++ b/packages/client/ui-conversation/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
-README.md: 6b541b840ed67ee6fd735a0643dde8c60f1ec22d
-README.zh.md: 01692c395cdb0f50e0fd41ab92f51d9e3ceecb4f
+README.md: 3aade8409e74c38cb86a66076cca50d51a1ccbf8
+README.zh.md: 19d08bbc072f1f242417b91ae4dd771f970244b1
diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md
index 6b541b840e..fa0c4b7b9b 100644
--- a/packages/client/ui-conversation/README.md
+++ b/packages/client/ui-conversation/README.md
@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
-Conversation domain: skeleton (header/tabs/composer/empty state), chat view (grouped step-summary flow, streaming tail isolation, an animated left-to-right gradient `Deep diving...` turn status, per-tool row slot with a bash sample registrant and the todo row), composer dock (session stats sticky with the input), input dock (hairline-separated queue rows plus the todo plan strip), minimal details panel, scope-addressed ConversationService. Contract: api-contracts v3 §7 plus the slot terminal design (store seat / props shares).
+Conversation domain: skeleton (header/tabs/composer/empty state), chat view (grouped step-summary flow, streaming tail isolation, and turn status), composer dock (session stats sticky with the input), input dock (queue rows plus the todo plan strip), details shell, and scope-addressed ConversationService. Tool presentation belongs to [`ui-tool`](../ui-tool/README.md).
Compaction renders as one collapsed row at the checkpoint's flow position without replacing the transcript above it. Automatic compaction uses the context-compacted title. Every completed marker with structured summary provenance shows the replaced-item and estimated-token counts and discloses the summary on click. Manual `/compact` starts as a running `compact` row; on successful settlement its explicit summary-event reference folds that command into the checkpoint row under the same React key. A completed checkpoint keeps the context-compaction icon at rest and replaces it with the collapsed or expanded disclosure only on hover or keyboard focus. Input rejection, no compactable history, cancellation, and failure retain the generic command row and its handler-authored text. Pairing never depends on adjacency because durable context may be injected while compaction is running. The framed checkpoint payload is model-facing and never renders; when summary provenance is outside the loaded window, the checkpoint remains visible but non-expandable.
@@ -16,27 +16,15 @@ Approvals take over the composer through the chain this package declares: `Appro
The session header declares and renders the session-scoped `'conversation.session.header.actions'` list beside the title, allowing feature plugins to contribute controls without entering the skeleton. The composer chain currency includes the current conversation `session`; ui-subagent selects one-shot or parent-unavailable addressed sessions for reason-specific read-only copy, while the ordinary InputBar keeps every addressed child Send-only because the continuation service exposes no public per-Activation cancellation operation and `session.cancel` would bypass its ownership.
-Logged non-user messages render as a default-collapsed disclosure whose header names the role the runtime projected for the message — `上下文注入` for an injection, `跨会话召回` for a recalled session — followed by the producer name that projection read out of the durable source, so a reader distinguishes a skill catalog from a workspace instruction file or a recalled session without expanding. A source that names no producer shows the role alone. The header shares the Tool calls geometry and interaction with `ToolRow` through the package-internal `DisclosureRow`, while retaining context semantics: the expanded body follows its content height up to a 141px scrolling cap and synthesizes no tool state, summary, or keyed toolview dispatch ([historical disclosure decision](../../../.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md), [provenance decision](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md)). That body follows the form the producer declared on its durable source: `instructions` names the reconciled files above their text, `catalog` lists the entries the source recorded instead of the model-facing prose, and every other value — absent, unknown to this version, or carrying no usable fields — renders the opaque body, which shows the model-facing text with its real line breaks and the remaining provenance as fields. The opaque body is the documented default, not a leftover: a resumed, forked, or foreign log must render whether or not its producer is mounted here. A durable or pending steering bubble carries an `插话` / `Interjection` caption above it, the only thing distinguishing a mid-turn interjection from the turn-opening prompt that shares its bubble.
+Logged non-user messages render as a default-collapsed disclosure whose header names the role the runtime projected for the message — `上下文注入` for an injection, `跨会话召回` for a recalled session — followed by the producer name that projection read out of the durable source, so a reader distinguishes a skill catalog from a workspace instruction file or a recalled session without expanding. A source that names no producer shows the role alone. The package-internal `DisclosureRow` gives this context surface the same compact geometry as other flow rows while retaining context semantics: the expanded body follows its content height up to a 141px scrolling cap and synthesizes no tool state or summary ([historical disclosure decision](../../../.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md), [provenance decision](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md)). That body follows the form the producer declared on its durable source: `instructions` names the reconciled files above their text, `catalog` lists the entries the source recorded instead of the model-facing prose, and every other value — absent, unknown to this version, or carrying no usable fields — renders the opaque body, which shows the model-facing text with its real line breaks and the remaining provenance as fields. The opaque body is the documented default, not a leftover: a resumed, forked, or foreign log must render whether or not its producer is mounted here. A durable or pending steering bubble carries an `插话` / `Interjection` caption above it, the only thing distinguishing a mid-turn interjection from the turn-opening prompt that shares its bubble.
A Think row stays collapsed by default and exposes live reasoning throughput without expanding the chain of thought: while its reasoning block is the streaming tail, the summary switches from the settled first line to the latest non-blank line and its one-line scrollport follows each delta to the inline end. Expanding the row removes the moving summary and leaves the full reasoning in ordinary page flow, so page reading never fights an internal follower; settlement restores the stable first-line summary at the left edge ([decision](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md)).
-Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and a path summary; that path is an underlined link — it reads as one at rest, not only on hover, because a path styled like the surrounding prose is an affordance nobody finds — and it opens the file through the Host (`host.openPath`, relative paths resolve against the session cwd). A document a browser renders prefers the default browser where the Host platform can name one; Windows and WSL use the Windows registered association. The Host opens it on the Host's own machine: a client reached over a network sees nothing, which is the deliberate scope of this surface. Tool rows are not whole-row click targets and do not open the details panel. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged). Cordis lifecycle tools reuse those generic variants while presenting `Inspect`, `Mount temporary Plugin`, and `Unmount temporary Plugin` with a shared Cordis accent; mount keeps the code variant's expandable source rendering.
-
-A tool call declaring the `terminal` render intent renders its command output inline, at both conversation render sites, through ui-primitives' `TerminalBlock`. `contract/terminal-card-model.ts` is the single derivation from the snapshot's `callView`/`resultView` pair, so the sites cannot disagree about a command, its cwd, or its exit status; it yields null — the generic path — for any other card tag, including one this client version does not know. Both sites therefore also show the card's run-state dot, which is the same `StateDot` semantic a tool row's leading icon carries, so a row and its own card always agree about one command's state. A multi-line command gets one prompt row per line, with the dot marking the call once on the first row — the exit status is the whole call's, so a dot per line would claim a per-line outcome bash does not report. The keyed `BashRow` carries the card below its summary row; tool rows are summary surfaces, so the card's copy and expand controls are the row's only interactions. The render-site fallback row keeps the card behind its existing expand control. Rows cap at `CHAT_TERMINAL_MAX_LINES` (8) against the panel's 16, which keeps the summary bounded; the panel stays the single-call reading surface. Inline output is licensed per render intent — the terminal and web cards, each with its own bound. A Bash execution failure that settles on the generic path instead exposes its original arguments and full error through the same bounded IN/OUT disclosure, while successful generic results such as a background-start acknowledgement remain summary-only ([decision](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)).
-
-A tool call declaring the `web` render intent renders its web retrieval inline, at both conversation render sites, through ui-primitives' `WebBlock`. `contract/web-card-model.ts` is the single derivation from the snapshot's `resultView`, mirroring the terminal card, so the sites cannot disagree about what a web call shows; it yields null — the generic path — for a running call, a non-web result view, a generic result view, a `card` tag this client version does not know, or a web card whose `kind` this client version does not know (a newer host's value, which the wire cannot be trusted to be `search` or `fetch`). The keyed `WebRow` registers one component under both `web_search` and `web_fetch`, discriminating on the tool name only for its icon and title; it composes the shared `ToolRow`, feeding the card as ToolRow's `web` body, so the retrieval is the row's collapsed-by-default expanded card (the same unified expand every card row has). A web-declaring tool without a keyed row lands on the `GenericToolCard` fallback, which routes the card through ToolRow the same way, and the details panel renders it and, below the card, the flattened model-visible result content — a fetch body is readable only there, since its card carries only the URL and status. Both render sites show the same complete source list — the one the tool returned and the model saw — bounded only by the card's own scroll container height, with no row-versus-panel cap ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md), [source scroll](../../../.agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.md)).
-
-A `read` call declaring the `read` render intent renders the returned file window inline, at both conversation render sites, through ui-primitives' `ReadBlock` — the line-numbered, syntax-highlighted content the tool projects. `contract/read-card-model.ts` is the single derivation from the snapshot's `resultView`; the read card is result-side only (a call carries no file content until `execute` returns), so a running read shows its summary alone and it yields null — the generic path — for a non-read result view or a `card` tag this client version does not know. The keyed `ReadRow` composes the shared `ToolRow`, feeding the card as ToolRow's `read` body, so it is the row's collapsed-by-default expanded card; the summary stays a path link that opens the file through the host. The render-site fallback and the details panel are read-aware too. Rows cap at `CHAT_READ_MAX_LINES` (8) against the panel's 16 ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.md)).
-
-A tool call declaring the `diff` render intent (the `write`/`edit` tools) renders its applied change inline through ui-primitives' `DiffBlock`, the same four-layer shape. `contract/diff-card-model.ts` is the single derivation from the `callView`/`resultView` pair; the settled result's hunks replace the call-time diff, and it yields null — the generic path — for any other card tag or a generic result view (write/edit's execution errors). The keyed `FileMutationRow` (registered under both `write` and `edit`) composes the shared `ToolRow`, feeding the diff as ToolRow's `diff` body, so it is the row's collapsed-by-default expanded card; the summary path link still opens the file through the host, and an errored mutation (no diff card) surfaces its error text through ToolRow's Output section with the first line in the collapsed summary. The render-site fallback and the details panel are diff-aware too. Rows cap at `CHAT_DIFF_MAX_LINES` (8) against the panel's 16 ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.md)).
+The chat view keeps Tool placement but delegates Tool presentation. It passes each ordered root call through `conversation.chat.tool`, and the details shell passes the selected call through `conversation.details.tool`. The assembled Web bundle fills the whole-Tool seat with [`ui-tool`](../ui-tool/README.md), which selects Runtime-projected Code Dispatch children and owns root/child composition, per-name dispatch, generic rendering, and render-intent cards; the details seat alone retains a raw-result fallback when that renderer is absent.
The chat flow projects consecutive model-retry nodes across retry turns into one stable, muted status row updated to the latest attempt; every retry event remains in the runtime snapshot and session log. Its frontend countdown anchors the scheduled delay to client receipt, avoiding host/browser clock skew, rounds remaining time up to seconds, and has a one-second floor. The latest unresolved retry uses a left-to-right text shimmer. Subsequent turn facts distinguish an attempt that started from one cancelled during backoff, while the Host running bit only controls the live animation; the row then shows a static completed or cancelled label. Normal policy rows show the finite retry maximum; always policy rows show `∞`. Activating the row reveals the latest exact retry delay and failure message. The client runtime removes each failed step's streaming tail before its retry node arrives, while the status remains visible after a later attempt succeeds. An unretried terminal failure renders as a persistent inline status at its turn boundary, showing the display-safe durable message and optional error code without offering an action the Host cannot fulfill; AUTH copy never echoes provider-supplied credential fragments.
-A `grep`/`glob` call declaring the `search` render intent renders its result inline, at the same render sites, through ui-primitives' `SearchBlock` — grep's matches grouped by file (each a collapsible header of `lineNumber: line` rows), glob's flat path list. `contract/search-card-model.ts` is the single derivation from the snapshot's `resultView`; unlike the terminal card it reads no `callView`, since a search has no matches or paths before `execute`, so a running search shows its summary alone. It yields null — the generic path — for any non-search result view, a `card` or `kind` this client version does not compile, and (because those ride the untrusted wire frame) a known kind whose `files`/`paths` is malformed. The keyed `SearchRow`, registered under both `grep` and `glob` since the derived `kind` decides the shape, composes the shared `ToolRow`, feeding the card as ToolRow's `search` body, so it is the row's collapsed-by-default expanded card; the render-site fallback routes it the same way. Both cap at `CHAT_SEARCH_MAX_LINES` (8) against the panel's 16. A capped search drops rows from the card, but the locator to the rest — grep/glob's `Full … stored at …` footer — lives only in the result text, so the derivation surfaces that as a recovery footer below the card when (and only when) the result was truncated; a settled call with no card at all (an errored search, a nested `run_code` sub-dispatch, a legacy generic result) surfaces its flattened result text through ToolRow's Output section so nothing is lost behind a bare summary ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md)).
-
-Tool rows use the keyed, session-scoped `'conversation.chat.toolview'` slot; its render site dispatches via `entryKey: toolName` with `GenericToolCard` as the call-site fallback. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openFile`), and `ToolRowProps` composes it with the session standard kit. A registrant is a plain plugin with only the slot service edge: `ctx.slots.inject('conversation.chat.toolview', () => ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row))`. The declaration is the activation and reload dependency; `ConversationService` is required only by registrations that call its actions. Trajectory and waterfall toolview slots share this shape and use their own render sites; RendersCheck rejects a declaration nobody renders.
-
-The todo surfaces are two registrations over that shape, both using slot declaration injection without a `ConversationService` edge. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`/ completed · ` plus a `+` count of the other active ones, parsed from its args through `toolviews/plan-summary.ts` `planSummary`, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). When the deployment permits parallel work, several items may be `in_progress` at once, so `planSummary` names the first and counts the rest, and deliberately returns the two unjoined: the row ellipsizes its summary text, so a count concatenated onto the end of the task name would be the first thing a narrow row clips. The row hands the count to `ToolRow`'s `summarySuffix`, the shared row's non-shrinking slot beside that ellipsized text (an error row drops it, since its collapsed summary is the failure line). `TodoDock` takes the `'conversation.input.dock'` list slot at `order: 0` — before Goal and Queue — and is the plan strip: it reads the host-computed `todos` projection via `useProjection` (standing plan: latest `todo/write` with no later `turn/start`) and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and starts collapsed as a header of title plus its own `·`-joined per-status counts (localized, `1 completed · 2 in progress · 1 pending`, zero-count segments omitted; status glyphs are the figma check / progress / dashed-pending set), so it reports the parallel count without needing a name to truncate. The dock adapter owns the selection so the panel stays a pure function of its props; the standing list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included.
+`TodoDock` takes the `'conversation.input.dock'` list slot at `order: 0` — before Goal and Queue — and is the plan strip: it reads the host-computed `todos` projection via `useProjection` (standing plan: latest `todo/write` with no later `turn/start`) and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and starts collapsed as a header of title plus its own `·`-joined per-status counts (localized, `1 completed · 2 in progress · 1 pending`, zero-count segments omitted). The dock adapter owns selection so the panel stays a pure function of its props. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included. The `todo_write` Tool row belongs to [`ui-tool`](../ui-tool/README.md).
`QueueDock` is the terminal input-dock entry at `order: 20`. It hides while empty, renders one pending row directly, and defaults two or more rows to a collapsed `" 条排队消息"` header whose button expands or collapses the complete list. The header exposes `aria-expanded` and `aria-controls`; the expanded list scrolls within a 180px height bound. An active edit or mutation keeps its rows visible, and emptying the queue restores the collapsed default for the next queue. Each visible ordinary-session row remains a single-line preview with its exact-occurrence edit, delete, and strict-steer actions; addressed subagents retain the rows as a read-only projection because their continuation transport does not expose queue mutation. If strict steer loses to a closed window, the original occurrence remains queued for normal delivery; if the driver already claimed it, normal delivery is already underway. Neither converged race displays a failure, while transport and unknown failures do.
@@ -50,7 +38,7 @@ The composer bar declares session-scoped single seats for `'conversation.input.p
The chat stats line takes its token accounting from the generic token-meter `tokenUsage` projection read through the standard-kit `useProjection`: billed input is uncached input plus cache reads and writes; cache hit divides cache reads by that total. Visible nodes supply only the turn and step counts plus the LLM and tool wall times, which are window-scoped facts about what is on screen rather than accounting; durable token and context groups remain visible when compaction leaves no assistant node in the loaded window. The same window fold averages each recorded step's TTFT and divides sampled output tokens by their summed decode spans into a latency/throughput group localized through the `conversation` locale namespace (`TTFT avg … · … tok/s` in English); a step missing a timing boundary or a usage sample drops out of those figures instead of skewing them. The turn-count, step-count, duration, cache, and token labels use the same namespace. Each settled turn additionally appends hover-revealed `TTFT {s}s · {tps} tok/s` labels to its assistant footer after the `Ran for` duration — the turn's first-step TTFT and its turn-aggregate decode throughput — gated on the turn's timing being in the loaded window (a contiguous log suffix, so an in-window turn carries every one of its steps) and omitting whichever figure is unrecorded. A deployment without token-meter drops the token groups; when the line overflows, it elides with an ellipsis and a delayed hover tooltip carries the full text only while actually clipped. Context occupancy moved off the row onto the composer's trailing ContextMeter: a 14px occupancy ring after the model seat, fed by `contextPressure` and rendered only once both a numerator and a route capacity are known, that click-opens a panel pairing the `percent used` header and `~used / capacity` figures with a color-segmented bar and `~`-prefixed heuristic composition rows (system prompt, tools, messages) from the `contextBreakdown` projection. The ring and header read `projectedTokens` — the provider sample carried forward over the surface's movement since — so a compaction registers immediately instead of after a further turn; the composition rows stay wholly heuristic and therefore still do not sum to the header ([rationale](../../llm/token-meter/README.md)). Occupancy is deliberately an approximation: numerator and capacity are independent last-wins projection fields, not one atomic request observation.
-`src/client/` is organized by domain. `contract/` is the sole inter-domain shared face (`slots.ts` slot declarations and composed props, `views.ts` shared primitives, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` directories import contract files and never each other. `apply.ts` is the only assembly point allowed to import all three domains. The `/client` export surface is the contract only — `apply`/`inject`, the two service classes, and the `contract/` type families; implementation components and the store factory stay internal and reach the page through apply's slot registrations.
+`src/client/` is organized by domain. `contract/` is the shared face for slot declarations, composed props, and cross-domain types; `skeleton/`, `chat/`, `input/`, `queue/`, and `settings/` keep their implementations internal, while `apply.ts` is their assembly point. The `/client` export surface contains only loader entries, service classes, and contract types; components and store factories reach the page through slot registrations.
A finished turn ends with a turn-tail hole: the chat view renders the `conversation.chat.turnTail` list slot between the closing assistant's body and its IconActions, once per turn at the seq `assistantActionsSeqs` elects, dispatching `TurnTailOwnerProps` (the snapshot nodes, the closing seq, and the tool rows' `openFile`). This package owns only the hole; the produced-files row that fills it — derivation from the mutation tools' `locations`, the chip cap, the copy — lives in `@deepseek-ai/dsh-client-ui-deliverables`, so composing that plugin out of cordis.yml turns the surface off while the hole renders empty at zero cost.
diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md
index 01692c395c..6dc803ee72 100644
--- a/packages/client/ui-conversation/README.zh.md
+++ b/packages/client/ui-conversation/README.zh.md
@@ -2,7 +2,7 @@
[English](README.md) | 中文
-会话领域:骨架(标题栏/标签页/编辑器/空状态)、聊天视图(分组步骤摘要流、流式尾部隔离、带从左到右动态渐变的 `Deep diving...` 轮次状态、逐工具行 slot 及一个 bash 示例注册方与 todo 行)、编辑器 dock(与输入区一同 sticky 的会话统计行)、输入区 dock(带发丝分界线的队列行加 todo 计划条)、最小详情面板、按 scope 寻址的 ConversationService。契约:api-contracts v3 §7 加 slot 终端设计(store seat/props share)。
+会话领域:骨架(标题栏/标签页/编辑器/空状态)、聊天视图(分组步骤摘要流、流式尾部隔离与轮次状态)、编辑器 dock(与输入区一同 sticky 的会话统计行)、输入区 dock(队列行加 todo 计划条)、详情壳层,以及按 scope 寻址的 ConversationService。Tool 展示属于 [`ui-tool`](../ui-tool/README.md)。
压缩(compaction)在检查点自身的消息流位置渲染为一行折叠标记,不替换其上方的 transcript(文本记录)。自动压缩使用「上下文已压缩」标题。每个具备结构化摘要溯源的完成标记都会显示被替换条目数量和估算 token 数量,并可点击展开摘要。手动 `/compact` 开始时显示为运行中的 `compact` 行;成功结算后,其显式摘要事件引用会在保持同一 React key 的前提下把该命令折叠进检查点行。完成的检查点静止时保留上下文压缩图标,仅在悬停或键盘聚焦时将其替换为收起/展开指示图标。输入被拒绝、没有可压缩历史、取消和失败时仍使用通用命令行及处理器撰写的文本。配对绝不依赖相邻关系,因为压缩运行期间可能注入持久上下文。面向模型的带框检查点载荷绝不渲染;摘要溯源位于已加载窗口之外时,检查点仍然可见但不可展开。
@@ -14,29 +14,17 @@
会话页头会在标题旁声明并渲染 Session scope 的 `'conversation.session.header.actions'` 列表,使功能插件无需进入骨架即可贡献控件。编辑器链的 currency 包含当前对话 `session`;ui-subagent 会选取 one-shot 或 parent 不可用的已寻址会话,并按原因显示只读文案,而普通 InputBar 会让所有已寻址 child 仅保留 Send,因为继续执行服务不公开逐 Activation 取消操作,`session.cancel` 也会绕过其所有权。
-已记录的非用户消息渲染为默认折叠的展开项,标题栏先给出运行时为该消息投影出的角色——注入为 `上下文注入`,召回为 `跨会话召回`——其后是该投影从持久来源读出的生产者名称,因此读者无需展开即可区分 skill(技能)目录、工作区指令文件与被召回的会话。来源未提供生产者名称时只显示角色。标题栏通过包内部的 `DisclosureRow` 与 `ToolRow` 共享 Tool calls 的几何与交互,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px,超出后滚动,且不会合成工具状态、摘要或键控 toolview 分发([历史展开项决策](../../../.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md)、[来源决策](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md))。该内容区按生产方在持久来源上声明的形态渲染:`instructions` 在正文之上列出它对账过的文件,`catalog` 列出来源记录的条目而非面向模型的散文,其余取值——未声明、本版本不认识、或字段不可用——一律渲染 opaque 内容区,即按真实换行展示面向模型的文本,并把剩余来源信息列成字段。opaque 不是兜底剩余物而是有文档的默认:恢复的、fork 的、外部写入的日志,无论其生产方是否挂载在此处,都必须渲染得出来。持久或待处理的 steering(中途引导)气泡上方带有 `插话` / `Interjection` 标注,这是把中途插话与共用同一气泡的开轮提示区分开的唯一标识。
+已记录的非用户消息渲染为默认折叠的展开项,标题栏先给出运行时为该消息投影出的角色——注入为 `上下文注入`,召回为 `跨会话召回`——其后是该投影从持久来源读出的生产者名称,因此读者无需展开即可区分 skill(技能)目录、工作区指令文件与被召回的会话。来源未提供生产者名称时只显示角色。包内部的 `DisclosureRow` 让该上下文界面与消息流中的其他紧凑行保持相同几何,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px,超出后滚动,且不会合成工具状态或摘要([历史展开项决策](../../../.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md)、[来源决策](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md))。该内容区按生产方在持久来源上声明的形态渲染:`instructions` 在正文之上列出它对账过的文件,`catalog` 列出来源记录的条目而非面向模型的散文,其余取值——未声明、本版本不认识、或字段不可用——一律渲染 opaque 内容区,即按真实换行展示面向模型的文本,并把剩余来源信息列成字段。opaque 不是兜底剩余物而是有文档的默认:恢复的、fork 的、外部写入的日志,无论其生产方是否挂载在此处,都必须渲染得出来。持久或待处理的 steering(中途引导)气泡上方带有 `插话` / `Interjection` 标注,这是把中途插话与共用同一气泡的开轮提示区分开的唯一标识。
Think 行默认保持折叠,并在不展开思维链的情况下暴露实时推理(reasoning)吞吐:当推理块是流式输出尾部时,摘要从结算后的首行切换到最新的非空行,其单行滚动区会随每个 delta 追到行内末端。展开该行会移除移动摘要,让完整推理进入普通页面流,因此页面阅读不会与内部跟随器争夺滚动;结算后恢复左对齐的稳定首行摘要([决策](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md))。
-通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和路径摘要;该路径是带下划线的链接——静止状态下就读得出是链接,而不只在悬停时,因为一条与周围正文同样样式的路径是没人会发现的交互——点击即经由 Host 打开文件(`host.openPath`,相对路径相对会话 cwd 解析)。浏览器能渲染的文档会在 Host 平台能够确定默认浏览器时优先使用它;Windows 与 WSL 则使用 Windows 注册的文件关联。Host 在它自己的机器上打开:经网络访问的客户端看不到任何东西,这是本交互面刻意划定的范围。工具行不再是整行点击目标,也不会打开 details 面板。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行)。Cordis 生命周期工具复用这些通用变体,同时以统一的 Cordis 强调色呈现 `Inspect`、`Mount temporary Plugin` 和 `Unmount temporary Plugin`;mount 行保留 code 变体的可展开源码渲染。
-
-声明 `terminal` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `TerminalBlock` 内联渲染其命令输出。`contract/terminal-card-model.ts` 是从快照的 `callView`/`resultView` 对推导的唯一位置,因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧;对任何其他 card 标签——包括当前客户端版本不认识的标签——它返回 null,落回通用路径。因此两个渲染点也都显示卡片的运行状态点,它与工具行行首图标承载同一套 `StateDot` 语义,所以一行与其自身的卡片对同一条命令的状态总是一致。多行命令的每一行各占一个提示行,状态点只在第一行为整次调用标记一次——退出状态属于整次调用,因此每行一枚就会声称一个 bash 并不报告的逐行结果。键控的 `BashRow` 把卡片放在摘要行下方;工具行是摘要 surface,因此卡片的复制与展开控件是该行唯一的交互。渲染点兜底行则保持其既有的展开控件。行的上限是 `CHAT_TERMINAL_MAX_LINES`(8),面板为 16,因此摘要保持有界;面板仍是单次调用的阅读 surface。内联输出按渲染意图开放——终端卡片与 web 卡片各有自己的上限。若 Bash 执行失败时落在通用路径,则改用同样有界的 IN/OUT 展开区暴露原始参数和完整错误;后台启动确认等成功的通用结果仍只显示摘要([决策](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md))。
-
-声明 `web` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `WebBlock` 内联渲染其 web 检索。`contract/web-card-model.ts` 是从快照的 `resultView` 推导的唯一位置,镜像终端卡片,因此两个渲染点不可能对一次 web 调用的显示产生分歧;对运行中的调用、非 web 的 result view、generic result view、本客户端版本不认识的 `card` 标签,或本客户端版本不认识 `kind` 的 web 卡片(更新的 host 发来的值,wire 上不可信其为 `search` 或 `fetch`),它返回 null,落回通用路径。键控的 `WebRow` 把一个组件注册在 `web_search` 与 `web_fetch` 两个键下,仅根据工具名判别以选取图标与标题;它组合共享的 `ToolRow`,把卡片作为 ToolRow 的 `web` body 传入,因此检索成为该行默认折叠的展开卡片(与每个卡片行相同的统一展开交互)。没有自己键控行的 web 声明工具落到 `GenericToolCard` 兜底,它以同样方式经 ToolRow 渲染卡片,详情面板渲染它,并在卡片下方渲染摊平的模型可见结果内容——fetch 正文只在此处可读,因为其卡片只携带 URL 和状态。两个渲染点显示同一份完整来源列表——工具返回、模型看到的那一份——仅受卡片自身滚动容器的高度约束,不存在行与面板的两级上限([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md)、[来源滚动](../../../.agents/notes/implemented/feature/2026-08-03-web-search-source-scroll.md))。
-
-声明 `read` 渲染意图的 `read` 调用,会在两个对话渲染点上都通过 ui-primitives 的 `ReadBlock` 内联渲染返回的文件窗口——工具投影出的带行号、语法高亮的内容。`contract/read-card-model.ts` 是从快照的 `resultView` 推导的唯一位置;read 卡片是仅结果侧的(调用在 `execute` 返回前不携带文件内容),所以运行中的 read 只显示摘要,且对非 read 的 result view 或本客户端版本不认识的 `card` 标签返回 null,落回通用路径。键控的 `ReadRow` 组合共享的 `ToolRow`,把卡片作为 ToolRow 的 `read` body 传入,因此它是该行默认折叠的展开卡片;摘要仍是一个经 host 打开文件的路径链接。渲染点兜底行与详情面板同样感知 read。行的上限是 `CHAT_READ_MAX_LINES`(8),面板为 16([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.md))。
-
-声明 `diff` 渲染意图的工具调用(`write`/`edit` 工具),通过 ui-primitives 的 `DiffBlock` 内联渲染其已应用的改动,采用同一套四层结构。`contract/diff-card-model.ts` 是从 `callView`/`resultView` 对推导的唯一位置;已结算 result 的 hunk 替换 call 时 diff,对任何其他 card 标签或 generic result view(write/edit 的执行错误)它返回 null,落回通用路径。键控的 `FileMutationRow`(在 `write` 与 `edit` 下都注册)组合共享的 `ToolRow`,把 diff 作为 ToolRow 的 `diff` body 传入,因此它是该行默认折叠的展开卡片;摘要路径链接仍经 host 打开文件,而出错的改动(没有 diff 卡片)经 ToolRow 的 Output 区呈现其错误文本,首行进入折叠摘要。渲染点兜底行与详情面板同样感知 diff。行的上限是 `CHAT_DIFF_MAX_LINES`(8),面板为 16([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.md))。
+聊天视图保留 Tool 的消息流位置,但委托其展示。它通过 `conversation.chat.tool` 传递每个已排序的 root call;详情壳层则通过 `conversation.details.tool` 传递当前选中的调用。组装后的 Web bundle 由 [`ui-tool`](../ui-tool/README.md) 填充整体 Tool 席位,并由后者选择 Runtime 已投影的 Code Dispatch 子调用,负责 root/child 编排、按名称分发、通用展示和 render-intent 卡片;只有详情席位会在该 renderer 缺席时保留 raw-result fallback。
聊天流会将跨重试轮次连续出现的模型重试节点投影为一个稳定的弱化状态行,并用最新一次尝试更新该行;每个重试事件仍保留在运行时快照与会话日志中。前端倒计时以客户端收到事件的时刻为计划延迟的起点,避免 Host 与浏览器的时钟偏差;剩余时间向上取整到秒,且下限为 1 秒。最近一次尚未完成的重试会显示从左到右的文字渐变动画。后续轮次事实用于区分已开始的尝试与在退避期间取消的尝试,Host 的 running 位只控制实时动画;随后该行会显示静态的已完成或已取消标签。normal 策略行显示有限重试上限;always 策略行显示 `∞`。激活该行会显示最近一次重试的精确延迟和失败消息。客户端运行时会在相应重试节点到达前移除每个失败步骤的流式输出尾部;后续某次尝试成功后,该状态仍保持可见。未进入重试的终态失败会在其轮次边界渲染为持久的内联状态,展示适合显示的持久消息与可选错误码,但不会提供 Host 无法兑现的操作;AUTH 文案绝不会回显提供方给出的凭据片段。
-声明 `search` 渲染意图的 `grep`/`glob` 调用,会在同样的渲染点上通过 ui-primitives 的 `SearchBlock` 内联渲染其结果——grep 的匹配按文件分组(每个是一个可折叠的头,下辖 `lineNumber: line` 行),glob 是扁平路径列表。`contract/search-card-model.ts` 是从快照的 `resultView` 推导的唯一位置;与终端卡片不同,它不读 `callView`,因为搜索在 `execute` 前没有匹配或路径,所以运行中的搜索只显示摘要。对任何非搜索的结果视图、当前客户端版本无法编译的 `card` 或 `kind`、以及(因为这些都与不可信的 wire 帧同行)一个 `files`/`paths` 格式错误的已知 kind,它都返回 null,落回通用路径。键控的 `SearchRow` 因推导出的 `kind` 决定形态而同时注册在 `grep` 与 `glob` 下,组合共享的 `ToolRow`,把卡片作为 ToolRow 的 `search` body 传入,因此它是该行默认折叠的展开卡片;渲染点兜底行以同样方式渲染它。两者上限都是 `CHAT_SEARCH_MAX_LINES`(8),面板为 16。被截断的搜索会从卡片里丢掉一些行,但通往其余部分的定位符——grep/glob 的 `Full … stored at …` 脚注——只存在于结果文本里,因此推导在(且仅在)结果被截断时把它作为恢复脚注画在卡片下方;一个完全没有卡片的已结算调用(出错的搜索、嵌套 `run_code` 子派发、旧日志的 generic 结果)则经 ToolRow 的 Output 区呈现其压平后的结果文本,从而不让任何内容丢失在一个光秃秃的摘要之后([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md))。
-
-工具行使用键控、Session scope 的 `'conversation.chat.toolview'` slot;其渲染点通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 fallback。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openFile`),`ToolRowProps` 将其与 Session 标准工具包组合。注册方是只依赖 slot 服务的普通插件:`ctx.slots.inject('conversation.chat.toolview', () => ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row))`。声明本身就是激活与重载依赖;只有调用 `ConversationService` 操作的注册项才需要该服务。Trajectory 与 waterfall(瀑布式事件)工具视图 slot 共享此形状并使用各自的渲染点;RendersCheck 会拒绝没有任何渲染方的声明。
-
审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项(ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。运行时 manager 会将所有审批或问题等待通过 `SessionSummary.pendingInteraction` 投影出来,未实例化的 Session 也不例外;`ui-workspace` 负责其侧边栏呈现。未决等待完全离开消息流:问题(ui-question)与审批(ApprovalPanel)都经编辑器接管作答,不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数(key 缺席即隐藏 chip);chip 打开 Menu 原语下拉,其中 kebab-case 预设名渲染为 Title Case 标签;普通安全预设会立即经输入栏注入的 `command` 回调提交 `/permission `,而 `danger-full-access` 在界面中显示为 `Full access`,选择后先打开页面内的 Modal 风险确认。用户勾选确认项前启用按钮始终不可用;取消、Escape、关闭按钮与点击遮罩都不会提交命令。
-todo 两个面就是在该形状上的两个注册项,都使用 slot 声明注入,不依赖 `ConversationService`。`TodoRow` 占用 `'conversation.chat.toolview'` 的 `todo_write` key,摘要该次调用「试图写入」的内容(从其 args 经 `toolviews/plan-summary.ts` 的 `planSummary` 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`,以及「其余活跃项的数量」`+`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。部署允许并行工作时,可以有多个条目同时处于 `in_progress`,因此 `planSummary` 给出第一个活跃条目并计数其余,且刻意不把两者拼成一个字符串:行会对摘要文本做省略号截断,把数量接在任务名末尾时,窄行最先裁掉的正是这个数量。该行把数量交给 `ToolRow` 的 `summarySuffix`——共享行在被截断文本旁的不收缩位(出错的行会丢弃它,因为其折叠摘要是失败首行)。`TodoDock` 以 `order: 0` 占用 `'conversation.input.dock'` 列表 slot(位于 Goal 与 Queue 之前),是计划条:它经 `useProjection` 读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏;列表非空时面板初始折叠,表头显示标题加它自行计算的、以 `·` 连接的各状态计数(本地化,形如 `1 已完成 · 2 进行中 · 1 待处理`,计数为零的段落省略;状态图标为 figma 的勾选/进行中/虚线未开始一组),因此它无需一个可被截断的任务名即可报告并行数量。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。
+`TodoDock` 以 `order: 0` 占用 `'conversation.input.dock'` 列表 slot(位于 Goal 与 Queue 之前),作为计划条读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`。面板接收纯列表,列表为空时自我隐藏;列表非空时默认折叠,表头显示标题及以 `·` 连接的各状态计数(如 `1 已完成 · 2 进行中 · 1 待处理`,省略零计数)。dock adapter 拥有 selection,因此面板保持为 props 的纯函数。输入区 composer 链隐藏的一切也会隐藏整个 dock。`todo_write` Tool 行属于 [`ui-tool`](../ui-tool/README.md)。
`QueueDock` 是 `order: 20` 的末端 input-dock 条目。队列为空时隐藏;只有一个待处理项时直接渲染该行;存在两个或更多待处理项时,默认收起为 `" 条排队消息"` 表头,其按钮可展开或收起完整列表。表头暴露 `aria-expanded` 和 `aria-controls`;展开后的列表以 180px 为高度上限,并可滚动。存在进行中的编辑或变更时,列表行会保持可见;队列清空后,下一次出现队列时会恢复默认收起状态。普通会话中的每条可见行仍是单行预览,并提供针对精确单次入队项的编辑、删除和严格 steering 操作;已寻址 subagent 则保留只读行,因为其继续执行传输不提供 Queue 变更。如果严格 steering 输给已关闭的窗口,原单次入队项会留在 Queue 中正常投递;如果驱动器已经认领该项,正常投递就已开始。这两种已收敛的竞态都不显示失败,传输和未知错误仍会显示。
@@ -50,7 +38,7 @@ Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。Qu
聊天统计行的 token 账目来自经标准套件 `useProjection` 读取的通用 token-meter 投影 `tokenUsage`:计费输入为未缓存输入、缓存读取与缓存写入之和;缓存命中率以缓存读取除以该总量。可见节点只提供轮次与步骤计数,以及 LLM(大语言模型)和工具的墙钟时间:这些是关于「屏幕上有什么」的窗口作用域事实,而非账目;压缩(compaction)使已加载窗口不再包含 assistant 节点时,持久 token 与上下文分组仍保持可见。同一次窗口折算还会把每个有完整记录的步骤的 TTFT(首 token 延迟)取平均,并用采样到的输出 token 数除以其解码时长之和,得到经 `conversation` locale 命名空间本地化的延迟/吞吐分组(中文为 `首 token 平均 … · … tok/s`);缺少某个 timing 边界或 usage 采样的步骤会直接退出这些数字,而不是让它们失真。轮次计数、步骤计数、耗时、缓存与 token 各项的标签也使用同一命名空间。每个已结算轮次还会在其 assistant footer 的 `用时` 之后追加 hover 才显示的 `首 token {s}秒 · {tps} tok/s` 标签——即该轮次首个步骤的 TTFT 与轮次聚合的解码吞吐——仅当该轮次的 timing 位于已加载窗口内才显示(窗口是日志的连续后缀,因此窗口内的轮次必然带着它的全部步骤),未记录的数字会各自省略。未组合 token-meter 的部署会整组省略 token 分组;统计行过长时以省略号截断,仅在内容真的被裁切时由延迟 hover tooltip 承载完整文本。上下文占用率从统计行移到了 composer 尾部的 ContextMeter:模型座位之后的一枚 14px 占用圆环,由 `contextPressure` 供数,仅当分子与路由容量都已知时才渲染;点击弹出的面板把「已用百分比」标题与 `~已用 / 容量` 数字,与来自 `contextBreakdown` 投影、带 `~` 前缀的启发式组成明细行(系统提示词、工具、对话消息)及分色分段进度条并列。圆环与标题读取 `projectedTokens`——把提供方样本沿此后表层的增减推进到当下——因此压缩会立刻反映出来,而不必再等一整轮;组成明细行仍是纯启发式,因此加起来依然不等于标题数字([原理](../../llm/token-meter/README.md))。占用率是刻意为之的近似值:分子与容量是两个相互独立的「后写覆盖」投影字段,并非同一次请求的原子观测。
-`src/client/` 按领域组织。`contract/` 是唯一的跨领域共享表层(`slots.ts` slot 声明与组合后的 props、`views.ts` 共享原语、`tool-call-model.ts`);`skeleton/`、`chat/` 和 `toolviews/` 目录只导入 contract 文件,彼此之间从不互相导入。`apply.ts` 是唯一允许导入全部三个领域的组装点。`/client` 导出表层只包含契约:`apply`/`inject`、两个服务类和 `contract/` 类型家族;实现组件与 store factory 保持内部,经 apply 的 slot 注册抵达页面。
+`src/client/` 按领域组织。`contract/` 是 slot 声明、组合 props 与跨领域类型的共享表层;`skeleton/`、`chat/`、`input/`、`queue/` 和 `settings/` 保持内部实现,`apply.ts` 是它们的组装点。`/client` 导出表层只包含 loader entry、service class 和 contract 类型;组件与 store factory 经 slot 注册抵达页面。
完成的一轮以一个 turn-tail 空位收尾:chat 视图在收尾 assistant 正文与其 IconActions 之间渲染 `conversation.chat.turnTail` list slot,每轮一次、位于 `assistantActionsSeqs` 选出的 seq,派发 `TurnTailOwnerProps`(快照节点、收尾 seq,以及工具行的 `openFile`)。本包只拥有空位;填充它的产物行——从改写工具 `locations` 的派生、chip 上限、文案——都在 `@deepseek-ai/dsh-client-ui-deliverables` 里,因此把那个插件从 cordis.yml 中组合掉即可关闭该交互面,空位以零成本渲染为空。
diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json
index ff4e74da5e..2ff87cc0e2 100644
--- a/packages/client/ui-conversation/package.json
+++ b/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",
diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts
index b6a3fc791c..74533b5e2d 100644
--- a/packages/client/ui-conversation/src/client/apply.ts
+++ b/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() },
diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.module.css b/packages/client/ui-conversation/src/client/chat/ChatView.module.css
index 1b5b58ce47..c600b3b0aa 100644
--- a/packages/client/ui-conversation/src/client/chat/ChatView.module.css
+++ b/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 {
diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx
index 18fba234de..c52a4623be 100644
--- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx
+++ b/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>
@@ -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 (
-
- )
+ 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
/** Session workspace root for path-relative summaries. */
cwd: string | undefined
inspectCall: InspectCall
- t: ChatViewSlotProps['t']
}) {
return (
{results.map(node => (
-
))}
@@ -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
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 (
)
}
@@ -676,19 +588,16 @@ export function ChatView({
{runningCalls.length > 0 && (
{runningCalls.map(call => (
-
))}
diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts
index be57f08523..2ef67f4aa8 100644
--- a/packages/client/ui-conversation/src/client/contract/slots.ts
+++ b/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` 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 & 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 & 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 & DetailsInjected & PropsLocale<'conversation'>
/** Owner share common to the hero / New-Session Workspace pickers. */
export interface EmptyWorkspaceOwnerProps {
diff --git a/packages/client/ui-conversation/src/client/contract/tool-path.ts b/packages/client/ui-conversation/src/client/contract/tool-path.ts
new file mode 100644
index 0000000000..38a55937d3
--- /dev/null
+++ b/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}`
+}
diff --git a/packages/client/ui-conversation/src/client/index.ts b/packages/client/ui-conversation/src/client/index.ts
index 725868d57a..b91628b825 100644
--- a/packages/client/ui-conversation/src/client/index.ts
+++ b/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.
diff --git a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.module.css b/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.module.css
index fbaebd1193..abdece3e25 100644
--- a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.module.css
+++ b/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;
-}
diff --git a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx b/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx
index 1114236143..c2104c23c7 100644
--- a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx
+++ b/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. */}
-
+
+ {renderSlot('conversation.details.tool', { block: material.block, cwd: sessionCwd }, {
+ fallback: 'kind' in material.block
+ ? (
+
+ {rawResultText(material.block)}
+
+ )
+ :
{t('details.running')}
,
+ })}
+
>
)}
@@ -126,83 +139,3 @@ export function DetailsPanel({ useSession, useSessions, sessionId, useStore, clo
)
}
-
-/**
- * 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 && (
-
{terminal.description}
- )}
-
- >
- )
- }
- 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
- const diff = diffCardModel(material.block)
- if (diff !== null) return
- const search = searchCardModel(material.block)
- if (search !== null) {
- return (
- <>
-
- {/* 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 && (
-
{search.recovery}
- )}
- >
- )
- }
- 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 (
- <>
-
- {body !== '' &&
{body}
}
- >
- )
- }
- // 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
{t('details.running')}
- const result = material.block
- return (
-
- {resultText(result)}
-
- )
-}
diff --git a/packages/client/ui-conversation/src/invariant.ts b/packages/client/ui-conversation/src/invariant.ts
index f4ecd7e260..f9a7d46553 100644
--- a/packages/client/ui-conversation/src/invariant.ts
+++ b/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 = () => {}
diff --git a/packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx b/packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx
index 16163065eb..9ea1c937ff 100644
--- a/packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx
+++ b/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 => ({
- 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)
diff --git a/packages/client/ui-conversation/tests/chat-apply.spec.tsx b/packages/client/ui-conversation/tests/chat-apply.spec.tsx
index df8fff6719..00001275d2 100644
--- a/packages/client/ui-conversation/tests/chat-apply.spec.tsx
+++ b/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()
diff --git a/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx b/packages/client/ui-conversation/tests/chat-stats.spec.tsx
similarity index 87%
rename from packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx
rename to packages/client/ui-conversation/tests/chat-stats.spec.tsx
index 7187851420..cdb0ffa8c9 100644
--- a/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx
+++ b/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[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({
- 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()
- const row = view.container.querySelector('[data-sample="bash"]')!
- expect(row.textContent).toContain('Bash')
- expect(row.textContent).toContain('Build')
- expect(row.getAttribute('data-clickable')).toBeNull()
- })
-})
diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx
index f42aed6731..0a25e9b198 100644
--- a/packages/client/ui-conversation/tests/chat-view.spec.tsx
+++ b/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) {
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 (
+
+ {tool.toolName || '(unnamed)'}:{tool.callId}
+
+ )
+ }) 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) {
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()
- // 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()
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()
- fireEvent.click(view.getByRole('button', { name: /Bash/ }))
- fireEvent.click(view.getByText('Inspect'))
- expect(h.inspectCall).toHaveBeenCalledWith('a')
+ render()
+ 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
})
@@ -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()
- 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()
- fireEvent.click(view.getByText('run a'))
- expect(h.openDetails).not.toHaveBeenCalled()
- expect(h.openFile).not.toHaveBeenCalled()
- expect(view.container.querySelector('[data-selected]')).toBeNull()
+ render()
+ 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()
- 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()
- 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()
- // 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', () => {
diff --git a/packages/client/ui-conversation/tests/coverage-tails.spec.tsx b/packages/client/ui-conversation/tests/coverage-tails.spec.tsx
index c92e43db6c..88e6c04141 100644
--- a/packages/client/ui-conversation/tests/coverage-tails.spec.tsx
+++ b/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[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(
- } 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(
{
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()
- // 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({
- 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()
- expect(runningView.container.querySelector('[data-state="running"]')).not.toBeNull()
- expect(runningView.getByText('Bash')).toBeTruthy()
- expect(runningView.getByText('List')).toBeTruthy()
- runningView.unmount()
-
- const errorView = render()
- 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()
- expect(stoppedView.container.querySelector('[data-state="stopped"]')).not.toBeNull()
- expect(stoppedView.getByText('已停止')).toBeTruthy()
- })
})
diff --git a/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx
index 5adb4d817e..eacd2a754a 100644
--- a/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx
+++ b/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
+ }
+}
+
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(
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(
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 }],
+ })
})
})
diff --git a/packages/client/ui-conversation/tests/todo-panel.spec.tsx b/packages/client/ui-conversation/tests/todo-panel.spec.tsx
index 3ab95168a7..445d8139e6 100644
--- a/packages/client/ui-conversation/tests/todo-panel.spec.tsx
+++ b/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[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()
@@ -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 => ({
- 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()
- expect(screen.getByText('更新任务清单')).toBeTruthy()
- expect(screen.getByText('1/3 已完成 · 写组件')).toBeTruthy()
- })
-
- it('reports the extra active count outside the ellipsized summary text', () => {
- const { container } = render()
- 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()
- 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()
- 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()
- 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()
- 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()
- expect(view.container.querySelector('[data-state="error"]')).not.toBeNull()
- // Generic others summary: " · ".
- expect(screen.getByText('todo_write · not json')).toBeTruthy()
- })
-
- it('falls back when parsed args carry no todos array', () => {
- render()
- expect(screen.getByText('todo_write · {"other":1}')).toBeTruthy()
- })
-
- it('leading toggle expands the raw args body', () => {
- render()
- 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()
- // 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()
- 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)
- })
-})
diff --git a/packages/client/ui-conversation/tests/views-type-chain.spec.tsx b/packages/client/ui-conversation/tests/views-type-chain.spec.tsx
index 69e8355870..b055c51560 100644
--- a/packages/client/ui-conversation/tests/views-type-chain.spec.tsx
+++ b/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')
diff --git a/packages/client/ui-skill/README.i18n.yaml b/packages/client/ui-skill/README.i18n.yaml
index a1d1a2c9d5..5aa997d003 100644
--- a/packages/client/ui-skill/README.i18n.yaml
+++ b/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
diff --git a/packages/client/ui-skill/README.md b/packages/client/ui-skill/README.md
index bdd772662a..44953fe36a 100644
--- a/packages/client/ui-skill/README.md
+++ b/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
diff --git a/packages/client/ui-skill/README.zh.md b/packages/client/ui-skill/README.zh.md
index 959ff0ede6..8c21ef35ed 100644
--- a/packages/client/ui-skill/README.zh.md
+++ b/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 或其描述发生变化,回放仍保持稳定。
## 模型体验
diff --git a/packages/client/ui-skill/package.json b/packages/client/ui-skill/package.json
index c9d2dd4ed8..420b1c4322 100644
--- a/packages/client/ui-skill/package.json
+++ b/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:^",
diff --git a/packages/client/ui-skill/src/client/SkillRow.tsx b/packages/client/ui-skill/src/client/SkillRow.tsx
index 65b474825a..a26c41ada5 100644
--- a/packages/client/ui-skill/src/client/SkillRow.tsx
+++ b/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
diff --git a/packages/client/ui-skill/src/client/index.ts b/packages/client/ui-skill/src/client/index.ts
index 4e23be06be..139398b648 100644
--- a/packages/client/ui-skill/src/client/index.ts
+++ b/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,
))
diff --git a/packages/client/ui-skill/tests/browser-plugin.spec.ts b/packages/client/ui-skill/tests/browser-plugin.spec.ts
index f73a8d8bda..4679ef2c98 100644
--- a/packages/client/ui-skill/tests/browser-plugin.spec.ts
+++ b/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)
})
})
diff --git a/packages/client/ui-skill/tsconfig.json b/packages/client/ui-skill/tsconfig.json
index f83486aa36..d6ec931648 100644
--- a/packages/client/ui-skill/tsconfig.json
+++ b/packages/client/ui-skill/tsconfig.json
@@ -21,7 +21,7 @@
"path": "../runtime"
},
{
- "path": "../ui-conversation"
+ "path": "../ui-tool"
},
{
"path": "../ui-primitives"
diff --git a/packages/client/ui-tool/README.i18n.yaml b/packages/client/ui-tool/README.i18n.yaml
new file mode 100644
index 0000000000..828cfa25ef
--- /dev/null
+++ b/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
diff --git a/packages/client/ui-tool/README.md b/packages/client/ui-tool/README.md
new file mode 100644
index 0000000000..381253f4ed
--- /dev/null
+++ b/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: '',
+ }, 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.
diff --git a/packages/client/ui-tool/README.zh.md b/packages/client/ui-tool/README.zh.md
new file mode 100644
index 0000000000..ae53913119
--- /dev/null
+++ b/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: '',
+ }, 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 独立迁回各自业务包。
diff --git a/packages/client/ui-tool/package.json b/packages/client/ui-tool/package.json
new file mode 100644
index 0000000000..965a084cd2
--- /dev/null
+++ b/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"
+ ]
+}
diff --git a/packages/client/ui-tool/src/client/apply.ts b/packages/client/ui-tool/src/client/apply.ts
new file mode 100644
index 0000000000..48a9a4c812
--- /dev/null
+++ b/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)
+}
diff --git a/packages/client/ui-tool/src/client/contract/slots.ts b/packages/client/ui-tool/src/client/contract/slots.ts
new file mode 100644
index 0000000000..4b74055b2c
--- /dev/null
+++ b/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'>
diff --git a/packages/client/ui-tool/src/client/index.ts b/packages/client/ui-tool/src/client/index.ts
new file mode 100644
index 0000000000..357506b1db
--- /dev/null
+++ b/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'
diff --git a/packages/client/ui-tool/src/client/locale.ts b/packages/client/ui-tool/src/client/locale.ts
new file mode 100644
index 0000000000..0dd6721101
--- /dev/null
+++ b/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'
diff --git a/packages/client/ui-tool/src/client/tool/ToolCallTree.module.css b/packages/client/ui-tool/src/client/tool/ToolCallTree.module.css
new file mode 100644
index 0000000000..b33fb477c1
--- /dev/null
+++ b/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);
+}
diff --git a/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx b/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx
new file mode 100644
index 0000000000..8091f26dff
--- /dev/null
+++ b/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 & {
+ 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 (
+
+ )
+}
diff --git a/packages/client/ui-conversation/src/client/chat/ToolRow.module.css b/packages/client/ui-tool/src/client/tool/components/ToolRow.module.css
similarity index 94%
rename from packages/client/ui-conversation/src/client/chat/ToolRow.module.css
rename to packages/client/ui-tool/src/client/tool/components/ToolRow.module.css
index 36f4c2b76a..9d9bce9eed 100644
--- a/packages/client/ui-conversation/src/client/chat/ToolRow.module.css
+++ b/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
diff --git a/packages/client/ui-conversation/src/client/chat/ToolRow.tsx b/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx
similarity index 76%
rename from packages/client/ui-conversation/src/client/chat/ToolRow.tsx
rename to packages/client/ui-tool/src/client/tool/components/ToolRow.tsx
index 48c0c825cb..61c677ea98 100644
--- a/packages/client/ui-conversation/src/client/chat/ToolRow.tsx
+++ b/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(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) => {
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({
) : (
{summaryText}
@@ -285,38 +261,36 @@ export function ToolRow({
)
: webBody !== null
?
- : isThink
- ?