Przeglądaj źródła

feat(web): unify tool-row expand interaction with IN/OUT card and trajectory Inspect

Every expandable tool row shares one interaction (whole-row toggle,
icon-to-chevron hover preview) and one expanded body: an IN/OUT
gutter-labeled card with per-section 150px scroll caps and sticky labels.
toolRowModel derives result output and the error first line, terminalFailed
surfaces a failing exit as the collapsed row's red dot, a hover Inspect
pill jumps to the call's trajectory record through a one-shot store
handoff, and the chat view keeps its scroll offset across view switches.
Yif 1 miesiąc temu
rodzic
commit
78e4d36214
38 zmienionych plików z 1241 dodań i 268 usunięć
  1. 6 0
      .agents/notes/implemented/feature/2026-07-30-web-tool-row-unified-expand-and-inspect.i18n.yaml
  2. 34 0
      .agents/notes/implemented/feature/2026-07-30-web-tool-row-unified-expand-and-inspect.md
  3. 34 0
      .agents/notes/implemented/feature/2026-07-30-web-tool-row-unified-expand-and-inspect.zh.md
  4. 18 0
      packages/client/ui-conversation/src/client/apply.ts
  5. 0 1
      packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx
  6. 34 8
      packages/client/ui-conversation/src/client/chat/ChatView.tsx
  7. 1 1
      packages/client/ui-conversation/src/client/chat/GenericCommandCard.tsx
  8. 12 5
      packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx
  9. 26 6
      packages/client/ui-conversation/src/client/chat/MessageItem.tsx
  10. 161 17
      packages/client/ui-conversation/src/client/chat/ToolRow.module.css
  11. 143 71
      packages/client/ui-conversation/src/client/chat/ToolRow.tsx
  12. 27 5
      packages/client/ui-conversation/src/client/contract/slots.ts
  13. 14 11
      packages/client/ui-conversation/src/client/contract/terminal-card-model.ts
  14. 36 5
      packages/client/ui-conversation/src/client/contract/tool-call-model.ts
  15. 6 0
      packages/client/ui-conversation/src/client/contract/views.ts
  16. 6 1
      packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx
  17. 2 15
      packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx
  18. 4 2
      packages/client/ui-conversation/src/client/stores.ts
  19. 6 3
      packages/client/ui-conversation/src/client/toolviews/ask-question-row.tsx
  20. 92 4
      packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css
  21. 72 22
      packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx
  22. 10 6
      packages/client/ui-conversation/src/client/toolviews/todo-row.tsx
  23. 11 6
      packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx
  24. 24 3
      packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx
  25. 3 3
      packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx
  26. 6 1
      packages/client/ui-conversation/tests/chat-store.spec.ts
  27. 142 23
      packages/client/ui-conversation/tests/chat-tool-row.spec.tsx
  28. 1 1
      packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx
  29. 71 3
      packages/client/ui-conversation/tests/chat-view.spec.tsx
  30. 1 1
      packages/client/ui-conversation/tests/selection-survival.spec.tsx
  31. 54 19
      packages/client/ui-conversation/tests/terminal-card.spec.tsx
  32. 74 15
      packages/client/ui-primitives/src/TerminalBlock.module.css
  33. 1 1
      packages/client/ui-primitives/src/TerminalBlock.tsx
  34. 16 7
      packages/client/ui-question/src/client/QuestionComposer.module.css
  35. 8 0
      packages/client/ui-theme/src/styles/gradient-shadow-text.css
  36. 36 1
      packages/client/ui-trajectory/src/client/TrajectoryTable.tsx
  37. 3 1
      packages/client/ui-trajectory/src/client/TrajectoryView.tsx
  38. 46 0
      packages/client/ui-trajectory/tests/table.spec.tsx

+ 6 - 0
.agents/notes/implemented/feature/2026-07-30-web-tool-row-unified-expand-and-inspect.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-web-tool-row-unified-expand-and-inspect.md
+2026-07-30-web-tool-row-unified-expand-and-inspect.md: ba2f4ead8023772fad578ca0b647241ecc332905
+2026-07-30-web-tool-row-unified-expand-and-inspect.zh.md: ac4835c7429a3ff7d3042f73d26d267911533132

+ 34 - 0
.agents/notes/implemented/feature/2026-07-30-web-tool-row-unified-expand-and-inspect.md

@@ -0,0 +1,34 @@
+# Agent Note: Web tool-row unified expand and trajectory Inspect
+
+Status: implemented
+
+English | [中文](2026-07-30-web-tool-row-unified-expand-and-inspect.zh.md)
+
+## Problem
+
+The chat view's tool rows had drifted into per-surface interaction dialects: ToolRow expanded through a leading-icon toggle and only for calls with an args body, the bash sample had its own expand affordance, todo/ask-question rows expanded raw args only, single-file tools were not expandable at all, and a call's OUTPUT was reachable only through the details panel. A failing bash command (exit≠0 settles `isError:false`) showed no collapsed-row failure signal. There was also no path from a chat row to its trajectory record, and switching chat → trajectory → chat lost the reader's scroll position because the tab ring unmounts inactive views.
+
+## Decision
+
+**Every expandable tool row shares one interaction — the whole row toggles (click / Enter / Space) with an icon→chevron hover preview — and one expanded body: an IN/OUT gutter-labeled card with per-section scroll caps; a hover-revealed Inspect pill jumps to the call's trajectory record through a one-shot store handoff; the chat view preserves its scroll offset across view switches through an in-memory per-session map.**
+
+- `toolRowModel` now derives result material alongside args: `output` (the `resultText` flatten, moved from DetailsPanel into the contract), and `errorSummary` (the failure's first line, shown as the collapsed summary in the error color). A row with body, output, or terminal material is expandable; the row itself is the toggle (`role="button"`, `aria-expanded`), and file-path summaries stay independent links via `stopPropagation`.
+- The expanded card (figma 1249:35657) is a column of IN/OUT sections: each section is its own scrollport (max-height 150px) with a sticky gutter label, and the l2 divider spans the full card width. Think prose and the run_code CodeBlock keep their non-card bodies; context injection reuses the row with a label-less `plainBody` card.
+- `terminalFailed` reads a settled terminal card's exit status so BashRow and GenericToolCard surface a failing command as the row's red state dot — the only failure signal the collapsed row has, since the call itself settles `isError:false`.
+- TerminalBlock's banner joins the same reading model: it shares the card surface (no banner token), an l2 hairline separates it from the body, the command column caps at 150px and scrolls with sticky copy/status controls top-aligned to the first prompt row.
+- Inspect: `ToolRowOwnerProps.inspect` (absent for rows without a call identity) renders a pill in real flow under the expanded body's bottom-left, revealed by hovering anywhere on the tool call. Clicking writes `{ callId }` to the chat store's one-shot `inspect` field and switches to the trajectory view; TrajectoryTable finds the record, opens its summary, and acknowledges by clearing the field.
+- Scroll preservation: the chat view saves its offset on every scroll (null when pinned to bottom) into an apply-scope `Map<SessionId, number>` exposed as `chatScroll` on the injected props; the open-jump branch restores it on remount. Deliberately not persisted — a fresh page load keeps the open-jump-to-bottom default.
+
+## Alternatives considered
+
+**Keeping the leading-icon toggle and per-registrant expand affordances.** Rejected: three surfaces had already diverged; the registrant posture (bash sample replicates CSS locally) makes drift permanent unless the interaction contract itself is uniform and small — whole-row toggle plus hover preview.
+
+**Routing Inspect through a URL or a trajectory-view prop.** Rejected: the view ring renders through the slot registry, so the two views share no parent that could carry a prop; the chat store already crosses that boundary and the one-shot field keeps the handoff replay-safe (persisted snapshots from before the field rehydrate with `?? null`).
+
+**Persisting the chat scroll offset.** Rejected: restoring a days-old offset into a conversation that has since grown reads as a bug; the in-memory map scopes the memory to exactly the view-switch case that loses it.
+
+**A per-row expanded OUTPUT fetched from the details panel's material.** Unnecessary: the settled result node already rides the snapshot's frozen call slice, so the contract-level `resultText` flatten serves both the row and the panel from one derivation.
+
+## Consequences
+
+Any registered toolview gets input AND output inspection in place, with the details panel and trajectory remaining the deep-dive surfaces. The unified interaction is contract-visible (`ToolRowProps.output/errorSummary/inspect`), so third-party rows opt in by passing model fields through. The bash sample intentionally re-replicates the new CSS (registrant posture), so future interaction changes still touch it by hand. `--dsw-font-markdown-code-block-small` (12/18) is a hand-added token pending a design-platform export. The web-cordis `distIndex` fix (plain concatenation, not URL.pathname) unblocks preview boots from a cwd with spaces.

+ 34 - 0
.agents/notes/implemented/feature/2026-07-30-web-tool-row-unified-expand-and-inspect.zh.md

@@ -0,0 +1,34 @@
+# Agent Note:Web 工具行统一展开交互与 trajectory Inspect
+
+状态:已实现
+
+[English](2026-07-30-web-tool-row-unified-expand-and-inspect.md) | 中文
+
+## 问题
+
+聊天视图的工具行交互已经分裂成多种方言:ToolRow 通过前导图标切换展开、且仅限有 args body 的调用,bash 示例有自己的一套展开方式,todo / ask-question 行只能展开原始 args,单文件工具完全不可展开,而调用的 OUTPUT 只能通过右侧详情面板查看。失败的 bash 命令(exit≠0 但结算为 `isError:false`)在折叠行上没有任何失败信号。此外聊天行没有跳转到 trajectory 记录的入口,且 chat → trajectory → chat 切换会丢失阅读位置(标签环会卸载非活跃视图)。
+
+## 决定
+
+**所有可展开工具行共享同一交互——整行即开关(点击 / Enter / 空格),图标 hover 时渐变为 chevron 预览——以及同一展开体:带 IN/OUT 侧栏标签的卡片,各分区独立滚动上限;hover 显示的 Inspect 胶囊通过 store 的一次性交接跳到该调用的 trajectory 记录;聊天视图用内存态的按会话 Map 在视图切换间保留滚动位置。**
+
+- `toolRowModel` 在 args 之外同时派生结果材料:`output`(`resultText` 拍平逻辑从 DetailsPanel 移入 contract)和 `errorSummary`(失败首行,以错误色作为折叠摘要)。有 body、output 或 terminal 材料的行即可展开;行本身是开关(`role="button"`、`aria-expanded`),文件路径摘要通过 `stopPropagation` 保持独立链接。
+- 展开卡片(figma 1249:35657)是 IN/OUT 分区列:每个分区是独立滚动区(max-height 150px),侧栏标签 sticky 固定,l2 分割线横贯整卡宽度。Think 的推理文本和 run_code 的 CodeBlock 保持非卡片体;上下文注入复用此行并以无标签的 `plainBody` 卡片展开。
+- `terminalFailed` 读取已结算 terminal 卡片的退出状态,让 BashRow 和 GenericToolCard 把失败命令显示为行的红色状态点——这是折叠行唯一的失败信号,因为调用本身结算为 `isError:false`。
+- TerminalBlock 的横幅并入同一阅读模型:与卡片共用同一表面(不再用 banner token),与正文之间是 l2 细线,命令列上限 150px 内部滚动,复制/状态控件 sticky 且顶对齐第一行提示符。
+- Inspect:`ToolRowOwnerProps.inspect`(无调用身份的行不提供)在展开体左下角以真实布局位置渲染胶囊,hover 整个 tool call 任意位置显示。点击将 `{ callId }` 写入 chat store 的一次性 `inspect` 字段并切换到 trajectory 视图;TrajectoryTable 找到记录、打开其摘要,并通过清空字段确认。
+- 滚动保留:聊天视图在每次滚动时保存偏移(贴底时为 null)到 apply 作用域的 `Map<SessionId, number>`,经注入 props 的 `chatScroll` 暴露;重挂载时 open-jump 分支恢复它。刻意不持久化——新页面加载保持打开即贴底的默认行为。
+
+## 曾考虑的替代方案
+
+**保留前导图标开关和各注册方自有的展开方式。** 否决:三个表面已经分化;注册方姿态(bash 示例本地复刻 CSS)意味着除非交互契约本身统一且足够小——整行开关加 hover 预览——否则漂移会永久存在。
+
+**通过 URL 或 trajectory 视图 prop 传递 Inspect。** 否决:视图环经由 slot 注册表渲染,两个视图没有可携带 prop 的共同父级;chat store 本就跨越该边界,一次性字段让交接可安全重放(字段出现之前的持久化快照以 `?? null` 复水)。
+
+**持久化聊天滚动偏移。** 否决:把几天前的偏移恢复到已经增长的会话里读起来像 bug;内存 Map 把记忆精确限定在会丢位置的视图切换场景。
+
+**从详情面板的材料为每行单独取展开 OUTPUT。** 不必要:已结算结果节点本就在快照的冻结调用切片上,contract 层的 `resultText` 拍平让行和面板共用一份派生。
+
+## 后果
+
+任何已注册 toolview 都能就地查看输入与输出,详情面板和 trajectory 仍是深查表面。统一交互契约可见(`ToolRowProps.output/errorSummary/inspect`),第三方行透传模型字段即可接入。bash 示例有意重新复刻新 CSS(注册方姿态),未来交互变更仍需手动同步它。`--dsw-font-markdown-code-block-small`(12/18)是手工补充的 token,待设计平台导出后替换。web-cordis 的 `distIndex` 修复(纯拼接而非 URL.pathname)解除了含空格 cwd 下预览无法启动的问题。

+ 18 - 0
packages/client/ui-conversation/src/client/apply.ts

@@ -98,6 +98,11 @@ export function apply(ctx: Context): void {
   // Apply-time construction keeps store identity bound to this fiber.
   const chatStore = createChatStore()
 
+  // Chat scroll offsets by session, surviving view switches (the chat view
+  // unmounts under the tab ring). Deliberately not persisted: a fresh page
+  // load should keep the open-jump-to-bottom default.
+  const chatScrollTops = new Map<SessionId, number>()
+
   const viewTabs = (): ViewTab[] => {
     const tabs: ViewTab[] = []
     for (const entry of slots.entries('conversation.view')) {
@@ -262,6 +267,19 @@ export function apply(ctx: Context): void {
           })
         },
         loadOlder: () => { void scoped.loadOlder() },
+        // Unregistered 'trajectory' id is safe: the tab ring falls back to
+        // the first view, and the untouched inspect target stays inert.
+        inspectCall: (callId) => {
+          actions.setInspect({ callId })
+          actions.setView('trajectory')
+        },
+        chatScroll: {
+          save: (top) => {
+            if (top === null) chatScrollTops.delete(sessionId)
+            else chatScrollTops.set(sessionId, top)
+          },
+          read: () => chatScrollTops.get(sessionId) ?? null,
+        },
       }
     },
   }, ChatView)

+ 0 - 1
packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx

@@ -54,7 +54,6 @@ function ThinkRow({ text, running }: { text: string; running: boolean }) {
       summary={firstLine(text)}
       body={text}
       state={running ? 'running' : 'ok'}
-      expandOnRowClick
     />
   )
 }

+ 34 - 8
packages/client/ui-conversation/src/client/chat/ChatView.tsx

@@ -46,6 +46,8 @@ function scrollerOf(from: HTMLElement): HTMLElement {
 
 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']
 
@@ -57,18 +59,20 @@ type UseConversation = SnapshotSelectorHook<ConversationSnapshot>
  *  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 }: {
+const SubCallRow = memo(function SubCallRow({ renderSlot, node, openFile, selected, cwd, inspectCall }: {
   renderSlot: RenderToolRow
   node: CodeSubCall
   openFile: OpenFile
   selected: boolean
   cwd: string | undefined
+  inspectCall: InspectCall
 }) {
   const settled = 'kind' in node
   const toolName = settled ? node.call?.name ?? '' : node.name
   const owner = useMemo(() => ({
     callId: node.callId, toolName, block: node, openFile, cwd,
-  }), [node, toolName, openFile, cwd])
+    inspect: () => { inspectCall(node.callId) },
+  }), [node, toolName, openFile, cwd, inspectCall])
   return (
     <div className={css.callRow} data-selected={selected || undefined}>
       {renderSlot('conversation.chat.toolview', owner, {
@@ -85,7 +89,7 @@ const SubCallRow = memo(function SubCallRow({ renderSlot, node, openFile, select
  *  renders its logged sub-dispatches as always-visible indented rows —
  *  each one the same keyed-slot dispatch as a native top-level call. */
 const CallRow = memo(function CallRow({
-  renderSlot, callId, toolName, block, openFile, selected, subCalls, selectedCallId, cwd,
+  renderSlot, callId, toolName, block, openFile, selected, subCalls, selectedCallId, cwd, inspectCall,
 }: {
   renderSlot: RenderToolRow
   callId: string
@@ -100,10 +104,12 @@ const CallRow = memo(function CallRow({
   selectedCallId?: string | undefined
   /** Session workspace root for path-relative summaries. */
   cwd: string | undefined
+  inspectCall: InspectCall
 }) {
   const owner = useMemo(() => ({
     callId, toolName, block, openFile, cwd,
-  }), [callId, toolName, block, openFile, cwd])
+    inspect: () => { inspectCall(callId) },
+  }), [callId, toolName, block, openFile, cwd, inspectCall])
   return (
     <div className={css.callRow} data-selected={selected || undefined}>
       {renderSlot('conversation.chat.toolview', owner, {
@@ -120,6 +126,7 @@ const CallRow = memo(function CallRow({
               openFile={openFile}
               selected={node.callId === selectedCallId}
               cwd={cwd}
+              inspectCall={inspectCall}
             />
           ))}
         </div>
@@ -129,7 +136,7 @@ const CallRow = memo(function CallRow({
 })
 
 /** Consecutive tool results as one step-run group (uniform 16px rhythm). */
-const ToolGroup = memo(function ToolGroup({ renderSlot, results, openFile, selectedCallId, codeDispatches, cwd }: {
+const ToolGroup = memo(function ToolGroup({ renderSlot, results, openFile, selectedCallId, codeDispatches, cwd, inspectCall }: {
   renderSlot: RenderToolRow
   results: readonly ToolResultNode[]
   openFile: OpenFile
@@ -139,6 +146,7 @@ const ToolGroup = memo(function ToolGroup({ renderSlot, results, openFile, selec
   codeDispatches: ReadonlyMap<string, readonly CodeSubCall[]>
   /** Session workspace root for path-relative summaries. */
   cwd: string | undefined
+  inspectCall: InspectCall
 }) {
   return (
     <div className={css.toolGroup}>
@@ -154,6 +162,7 @@ const ToolGroup = memo(function ToolGroup({ renderSlot, results, openFile, selec
           subCalls={codeDispatches.get(node.callId)}
           selectedCallId={selectedCallId}
           cwd={cwd}
+          inspectCall={inspectCall}
         />
       ))}
     </div>
@@ -230,7 +239,9 @@ function StreamingTail({ useSession, onGrow }: {
  * The chat view slot entry: pure component over the composed props (tool rows
  * render through the declared keyed hole's renderSlot share).
  */
-export function ChatView({ useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder }: ChatViewSlotProps) {
+export function ChatView({
+  useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder, inspectCall, chatScroll,
+}: ChatViewSlotProps) {
   const nodes = useSession(s => s.nodes)
   // Workspace root off the session list row: path summaries display relative to it.
   const cwd = useSessions(s => s.byId[sessionId]?.cwd)
@@ -274,10 +285,20 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
     /* v8 ignore next -- ref-null guard: React attaches the ref before layout effects run. */
     if (local === null) return
     const el = scrollerOf(local)
-    // Open completed: jump to the bottom once.
+    // Open completed: jump to the bottom once — unless a scroll position
+    // survives from a previous mount (view-tab switch away and back), which
+    // is restored instead of snapping the reader back to the floor.
     if (openState === 'open' && !openedRef.current) {
       openedRef.current = true
-      toBottom(el)
+      const saved = chatScroll.read()
+      if (saved === null) {
+        toBottom(el)
+      } else {
+        el.scrollTop = saved
+        const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= FOLLOW_THRESHOLD + 1
+        atBottomRef.current = isAtBottom
+        setAtBottom(isAtBottom)
+      }
       firstSeqRef.current = firstSeq
       lastKeyRef.current = lastKey
       followSigRef.current = followSig
@@ -315,6 +336,9 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
     const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= FOLLOW_THRESHOLD + 1
     atBottomRef.current = isAtBottom
     setAtBottom(isAtBottom)
+    // Continuous save (unmount happens after ref detach, so saving there is
+    // too late); pinned-to-bottom clears so a remount keeps following.
+    chatScroll.save(isAtBottom ? null : el.scrollTop)
   }
 
   // Bind scroll to the resolved scrollport (host or local) once per mount.
@@ -365,6 +389,7 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
           selectedCallId={inGroup ? selectedCallId : undefined}
           codeDispatches={codeDispatches}
           cwd={cwd}
+          inspectCall={inspectCall}
         />
       )
     }
@@ -417,6 +442,7 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
                   subCalls={codeDispatches.get(call.callId)}
                   selectedCallId={selectedCallId}
                   cwd={cwd}
+                  inspectCall={inspectCall}
                 />
               ))}
             </div>

+ 1 - 1
packages/client/ui-conversation/src/client/chat/GenericCommandCard.tsx

@@ -27,7 +27,7 @@ export function GenericCommandCard({ node }: CommandRowOwnerProps) {
   return (
     <ToolRow
       variant="others"
-      icon={<IconApiOutline14 size={16} />}
+      icon={<IconApiOutline14 size={14} />}
       title={title}
       summary={summary}
       // Expandable only when the outcome text overflows a one-line summary.

+ 12 - 5
packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx

@@ -10,7 +10,7 @@ import {
   IconThinkOutline14,
 } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { ToolRowOwnerProps } from '../contract/slots.ts'
-import { terminalCardModel } from '../contract/terminal-card-model.ts'
+import { terminalCardModel, terminalFailed } from '../contract/terminal-card-model.ts'
 import { toolRowModel, type ToolRowVariant } from '../contract/tool-call-model.ts'
 import { ToolRow } from './ToolRow.tsx'
 
@@ -26,9 +26,14 @@ const VARIANT_ICONS: Record<ToolRowVariant, ReactNode> = {
   others: <IconSparkle16 size={14} />,
 }
 
-export function GenericToolCard({ toolName, block, cwd, openFile }: ToolRowOwnerProps) {
+export function GenericToolCard({ toolName, block, cwd, openFile, inspect }: ToolRowOwnerProps) {
   const model = toolRowModel(toolName, block, cwd)
   const terminal = terminalCardModel(block, cwd)
+  // A failing exit status is the terminal card's own error signal (the call
+  // itself settles isError:false), surfaced as the row's red state dot.
+  const state = model.state === 'ok' && terminal !== null && terminalFailed(terminal)
+    ? 'error'
+    : model.state
   const singleFile = model.filePath !== undefined
   return (
     <ToolRow
@@ -39,12 +44,14 @@ export function GenericToolCard({ toolName, block, cwd, openFile }: ToolRowOwner
       // A terminal presenter's description is the contract's above-card text, so
       // it outranks the args-derived summary here exactly as it does in BashRow.
       summary={terminal?.description ?? model.summary}
-      // Single-file tools never expose an args body — the path link is the only action.
-      body={singleFile ? null : model.body}
+      body={model.body}
+      output={model.output}
+      errorSummary={model.errorSummary}
       terminal={terminal}
-      state={model.state}
+      state={state}
       filePath={model.filePath}
       onOpenFile={singleFile ? openFile : undefined}
+      inspect={inspect}
     />
   )
 }

+ 26 - 6
packages/client/ui-conversation/src/client/chat/MessageItem.tsx

@@ -1,16 +1,19 @@
 // MessageItem: the four simple node kinds — user bubble (right-aligned, with
 // clock + copy / branch / edit IconActions), steering (badged bubble), context
-// injection and unknown-surface JSON rows. Props are frozen node slices off
-// the snapshot cache; memo holds across streaming because unchanged nodes
-// keep their references.
+// injection (a ToolRow-chromed collapsible row: the injection reads as "the
+// harness read something into context", so it borrows the read variant's icon
+// and the IN-card expanded body) and unknown-surface JSON rows. Props are
+// frozen node slices off the snapshot cache; memo holds across streaming
+// because unchanged nodes keep their references.
 
 import { memo } from 'react'
 import type { ReactNode } from 'react'
 import type {
   ContextMessageNode, SteeringMessageNode, UnknownSurfaceNode, UserMessageNode,
 } from '@deepseek-ai/dsh-client-runtime/client'
-import { JsonBlock, MessageText } from '@deepseek-ai/dsh-client-ui-primitives'
+import { IconBrowseOutline16, JsonBlock, MessageText } from '@deepseek-ai/dsh-client-ui-primitives'
 import { MessageIconActions } from './MessageIconActions.tsx'
+import { ToolRow } from './ToolRow.tsx'
 import css from './MessageItem.module.css'
 
 export interface MessageItemProps {
@@ -92,12 +95,29 @@ export const MessageItem = memo(function MessageItem({ node }: MessageItemProps)
         </div>
       )
     }
-    case 'context':
+    case 'context': {
+      // Pure-text injections show their text; anything with non-text blocks
+      // (or nothing at all) keeps the full JSON payload so no material is lost.
+      // Title-only collapsed row (no summary), label-less expanded card: the
+      // injection is ambient context, not a call's input.
+      const { text, rest } = contentText(node.content)
+      const body = rest.length === 0 && text !== ''
+        ? text
+        : JSON.stringify({ content: node.content, source: node.source }, null, 2)
       return (
         <div className={css.contextRow}>
-          <JsonBlock label="上下文注入" payload={{ content: node.content, source: node.source }} />
+          <ToolRow
+            variant="read"
+            icon={<IconBrowseOutline16 size={14} />}
+            title="上下文注入"
+            summary=""
+            body={body}
+            plainBody
+            state="ok"
+          />
         </div>
       )
+    }
     default:
       return (
         <div className={css.contextRow}>

+ 161 - 17
packages/client/ui-conversation/src/client/chat/ToolRow.module.css

@@ -41,7 +41,8 @@
   90%, 100% { left: 100%; }
 }
 
-/* Expand-on-row (Think / code): pointer only — no row fill hover. */
+/* Every expandable row is the expand control: pointer only — the icon→chevron
+   hover preview is the affordance, no row fill. */
 .row[data-expandable] {
   cursor: pointer;
 }
@@ -55,9 +56,6 @@
   align-items: center;
   justify-content: center;
   margin-right: 6px;
-  padding: 0;
-  border: none;
-  background: none;
   color: var(--dsw-alias-label-tertiary);
 }
 
@@ -76,8 +74,8 @@
   background: var(--dsw-alias-state-business-primary);
 }
 
-button.leading {
-  cursor: pointer;
+.chevron {
+  color: var(--dsw-alias-label-secondary);
 }
 
 /* Hover preview on expandable rows: the idle tool icon crossfades (100ms)
@@ -155,8 +153,65 @@ button.leading {
   text-decoration: underline;
 }
 
-/* Expanded body: pad-left 22 indented gray text, no border, no fill. */
-.body {
+/* Error row's collapsed summary: the failure's first line in the error color. */
+.errorSummary {
+  color: var(--dsw-alias-state-error-primary);
+}
+
+/* Expanded body + Inspect pill wrapper (sibling of .row: clicks never toggle). */
+.bodyWrap {
+  display: flex;
+  flex-direction: column;
+}
+
+/* Hover-revealed jump to the trajectory record: a small pill in real flow
+   under the expanded body's bottom-left corner (it reserves its line, so
+   revealing never shifts layout); revealed by hovering anywhere on the tool
+   call — title row included — or by keyboard focus. */
+.inspectButton {
+  display: inline-flex;
+  align-self: flex-start;
+  align-items: center;
+  gap: 4px;
+  margin: 4px 0 2px 4px;
+  padding: 2px 8px;
+  border: 1px solid var(--dsw-alias-border-l2);
+  border-radius: 999px;
+  /* Base background, not bg-overlay: the overlay token is a raised dark
+     surface and reads too heavy for a quiet in-flow affordance. */
+  background: var(--dsw-alias-bg-base);
+  color: var(--dsw-alias-label-secondary);
+  font-size: 11px;
+  line-height: 16px;
+  cursor: pointer;
+  opacity: 0;
+  transition: opacity 100ms ease;
+}
+
+.root:hover .inspectButton,
+.inspectButton:focus-visible {
+  opacity: 1;
+}
+
+/* Solid hover fill (a translucent token would let content bleed through). */
+.inspectButton:hover {
+  background: var(--dsw-alias-interactive-bg-hover-solid);
+  color: var(--dsw-alias-label-primary);
+}
+
+/* Expanded-body scroll wrapper for the run_code CodeBlock; the IN/OUT card
+   and the terminal card scroll INSIDE their own surface instead, so the
+   scrollbar sits within the rounded card. */
+.bodyScroll {
+  max-height: 260px;
+  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;
@@ -165,6 +220,89 @@ button.leading {
   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
+   so the divider spans the full card width and each section scrolls alone. */
+.ioCard {
+  display: flex;
+  flex-direction: column;
+  margin: 4px 0 4px 4px;
+  border: 1px solid var(--dsw-alias-border-l1);
+  border-radius: 12px;
+  background: var(--dsw-alias-markdown-code-block);
+  font: var(--dsw-font-markdown-code-block-small);
+}
+
+/* One card section (IN or OUT): the gutter-label grid, capped and scrolling
+   independently so a long input never buries a short output (and vice versa). */
+.ioSection {
+  display: grid;
+  grid-template-columns: max-content 1fr;
+  column-gap: 14px;
+  align-items: baseline;
+  padding: 12px 16px;
+  max-height: 150px;
+  overflow-y: auto;
+}
+
+/* Card-internal scrollbar: a 2px transparent border clips the thumb inward so
+   it floats off the rounded card edge instead of hugging it (the terminal
+   card's own output scroller carries the same treatment in TerminalBlock). */
+.ioSection::-webkit-scrollbar-thumb,
+.ioCardPlain::-webkit-scrollbar-thumb {
+  border: 2px solid transparent;
+  background-clip: padding-box;
+  border-radius: 6px;
+}
+
+/* Track end-margins keep the thumb's travel out of the rounded corners. */
+.ioSection::-webkit-scrollbar-track,
+.ioCardPlain::-webkit-scrollbar-track {
+  margin: 6px 0;
+}
+
+/* Label-less variant of the card (plainBody): the text is not an IN/OUT pair,
+   so it renders as one plain padded block scrolling as a whole. */
+.ioCardPlain {
+  display: block;
+  padding: 12px 16px;
+  max-height: 260px;
+  overflow-y: auto;
+}
+
+/* Caption (not tertiary): one step dimmer than the payload text so the
+   gutter labels read as labels, not as part of the content. Sticky against
+   the section's own scroll so the label stays readable while its payload
+   scrolls underneath (top 0 = the section's padding edge inside the
+   scrollport; start-aligned because sticky needs a block-start anchor). */
+.ioLabel {
+  position: sticky;
+  top: 0;
+  align-self: start;
+  color: var(--dsw-alias-label-caption);
+}
+
+/* l2 hairline between the IN and OUT sections, spanning the full card width
+   (it sits between the padded sections, not inside their grid). */
+.ioDivider {
+  flex: none;
+  height: 1px;
+  background: var(--dsw-alias-border-l2);
+}
+
+.ioText {
+  min-width: 0;
+  white-space: pre-wrap;
+  word-break: break-word;
+  color: var(--dsw-alias-label-secondary);
+}
+
+/* A failed call's OUT text shares the collapsed summary's error color. */
+.ioText[data-error] {
+  color: var(--dsw-alias-state-error-primary);
+}
+
 /* The two block-shaped expanded bodies: the code variant's run_code program
    through CodeBlock (shiki-highlighted TypeScript) and a terminal card's
    command output through TerminalBlock. Both are drawn by the shared
@@ -173,15 +311,21 @@ button.leading {
    flow's row rhythm. */
 .codeBody,
 .terminalBody {
-  margin: 4px 0 4px 22px;
+  margin: 4px 0 4px 4px;
 }
 
-/* Indented to the body's own column so the description reads as the card's
-   heading rather than as another summary row, and sits tight against the card
-   below it. Its own rule: grouping it with a body would put description
-   typography on a `CodeBlock` wrapper and change that body's spacing. */
-.terminalDescription {
-  margin: 4px 0 0 22px;
-  color: var(--dsw-alias-label-secondary);
-  font: var(--dsw-font-xs-13);
+/* In-row code renders at the smaller code size (12/18) via each primitive's
+   rebindable content-font seam; standalone markdown code blocks keep 13/22. */
+.codeBody {
+  --dsl-code-block-content-font: var(--dsw-font-markdown-code-block-small);
+}
+
+/* The terminal card scrolls its OUTPUT inside its own surface (same l1
+   hairline as the IN/OUT card): the banner stays pinned and the scrollbar
+   never rides over it. 224px = the 260px card cap minus the ~36px banner. */
+.terminalBody {
+  --dsl-terminal-font: var(--dsw-font-markdown-code-block-small);
+  --dsl-terminal-line-height: 18px;
+  --dsl-terminal-output-max-height: 224px;
+  border: 1px solid var(--dsw-alias-border-l1);
 }

+ 143 - 71
packages/client/ui-conversation/src/client/chat/ToolRow.tsx

@@ -1,17 +1,25 @@
 // ToolRow: the single-line tool summary row (figma component set 122:9479) —
 // 16px leading slot (state dot / tool icon, chevron on hover or expanded) + title +
 // separator dot + FILL-truncated summary. The collapsed row is always one
-// line; the expanded body is indented gray text, the run_code program through
-// CodeBlock, or — for a call whose render intent is a terminal card — the
-// command's own output through TerminalBlock, capped at
-// CHAT_TERMINAL_MAX_LINES so the message flow stays scannable. Expand state is
-// component-local view state. File-tool summaries are path links that open
-// through the host; the row itself is not a details-panel control.
+// line; every row with body, output, or terminal material is a whole-row
+// expand toggle (click / Enter / Space, icon→chevron hover preview); the
+// summary stays inline while open, except Think, whose body opens with the
+// same first line and would repeat it.
+// The expanded body — an IN/OUT gutter-labeled card (figma 1249:35657) for
+// text input/output, the run_code program through CodeBlock, or a terminal
+// card's command output through TerminalBlock — 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. 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 { useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
+import clsx from 'clsx'
 import { CodeBlock, StateDot, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives'
 import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
-import { CHAT_TERMINAL_MAX_LINES, type TerminalCardModel } from '../contract/terminal-card-model.ts'
+import type { TerminalCardModel } from '../contract/terminal-card-model.ts'
 import type { ToolRowState, ToolRowVariant } from '../contract/tool-call-model.ts'
 import css from './ToolRow.module.css'
 
@@ -23,18 +31,25 @@ export interface ToolRowProps {
   icon: ReactNode
   title: string
   summary: string
-  /** Expanded-body text; null = no text body (`terminal` is the other body source). */
+  /** Expanded-body input text; null = no input section. */
   body: string | null
+  /** Flattened result text for the expanded Output section; null/absent = no output section. */
+  output?: string | null | undefined
+  /** Error first line shown as the collapsed summary on an error row; null/absent = keep `summary`. */
+  errorSummary?: string | null | undefined
   /**
    * Terminal-card material for a call whose render intent is a terminal card
-   * (derived by `terminalCardModel`); it replaces the text body when present.
-   * Null or absent leaves the text body, and a row with neither is not
-   * expandable (its leading slot never toggles).
+   * (derived by `terminalCardModel`); it replaces the text sections when
+   * present. A row with no body, no output, and no terminal material is not
+   * expandable.
    */
   terminal?: TerminalCardModel | null | undefined
+  /**
+   * Render the expanded body in the card without the IN gutter label — for
+   * material that is not a call's input payload (context injection).
+   */
+  plainBody?: boolean | undefined
   state: ToolRowState
-  /** Makes the row itself the expand control instead of only its leading icon. */
-  expandOnRowClick?: boolean | undefined
   /**
    * Filesystem path from tool args; when set with onOpenFile, the summary
    * renders as a hover-underline link that opens the host default app.
@@ -42,6 +57,21 @@ export interface ToolRowProps {
   filePath?: string | undefined
   /** Open the path with the host OS default application (already cwd-resolved). */
   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 and context injection).
+   */
+  inspect?: (() => void) | undefined
+}
+
+/** The Inspect pill's code glyph (user-supplied 16×16), fill follows text color. */
+function IconInspect() {
+  return (
+    <svg width="12" height="12" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden>
+      <path d="M16 8L10.8571 12V10.552L14.1383 8L10.8571 5.448V4L16 8ZM5.14286 10.552L1.86171 8L5.14286 5.448V4L0 8L5.14286 12V10.552ZM9.02514 4L5.59657 12H6.84057L10.2691 4H9.02514Z" fill="currentColor" />
+    </svg>
+  )
 }
 
 /** Leading-slot state substitution: the tool icon yields to the terminal state
@@ -62,36 +92,31 @@ export function ToolRow({
   title,
   summary,
   body,
+  output,
+  errorSummary,
   terminal,
+  plainBody,
   state,
-  expandOnRowClick = false,
   filePath,
   onOpenFile,
+  inspect,
 }: ToolRowProps) {
   const [expanded, setExpanded] = useState(false)
   const terminalBody = terminal ?? null
-  // A row that names a single file keeps one interaction (open that path);
-  // args expand is off whether or not the open callback is wired yet. Terminal
-  // material still expands: only the file variants carry a path, so a terminal
-  // card and a file link never land on the same row.
-  const singleFile = filePath !== undefined
-  const fileLink = singleFile && onOpenFile !== undefined
-  const expandable = (body !== null && !singleFile) || terminalBody !== null
-  // The text arms take the empty string for a null body: a row expandable
-  // only through its terminal material renders the terminal body instead, so
-  // this substitution never shows.
-  const text = body ?? ''
+  const outputText = output ?? null
+  const expandable = body !== null || outputText !== null || terminalBody !== null
   const open = expanded && expandable
-  const rowExpands = expandable && expandOnRowClick
+  // An error row's collapsed summary IS the failure: the first error line in
+  // the error color outranks both the args summary and a terminal description.
+  const failureLine = state === 'error' ? errorSummary ?? null : null
+  const summaryText = failureLine ?? summary
+  // The failure line is error prose, not the path: no open-file affordance.
+  const fileLink = filePath !== undefined && onOpenFile !== undefined && failureLine === null
   const toggleExpand = () => {
     setExpanded(v => !v)
   }
-  const toggleFromLeading = (event: MouseEvent<HTMLButtonElement>) => {
-    event.stopPropagation()
-    toggleExpand()
-  }
   const toggleFromKeyboard = (event: KeyboardEvent<HTMLDivElement>) => {
-    if (!rowExpands || (event.key !== 'Enter' && event.key !== ' ')) return
+    if (!expandable || (event.key !== 'Enter' && event.key !== ' ')) return
     event.preventDefault()
     toggleExpand()
   }
@@ -99,46 +124,47 @@ export function ToolRow({
     event.stopPropagation()
     if (filePath !== undefined) onOpenFile?.(filePath)
   }
-  // Expandable rows preview the toggle on hover: the tool icon yields to a
-  // down chevron (CSS swap on .row:hover); state dots still take precedence.
+  // Think reasoning is prose, not an input payload: expanded, it renders as
+  // plain indented text (no IN/OUT card) and the inline summary — the body's
+  // own first line — yields to avoid repeating itself.
+  const isThink = variant === 'think'
+  // 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
+  // Expandable rows preview the toggle on hover: the idle leading — the tool
+  // icon OR the state dot — yields to a down chevron (CSS swap on .row:hover).
+  // The state substitution happens inside the idle slot so an error row keeps
+  // the hover preview instead of losing it with the icon.
+  const idleLeading = leadingFor(state, icon)
   const collapsedIcon = expandable
     ? (
       <>
-        <span className={css.iconIdle}>{icon}</span>
-        <IconChevronDownOutline14 className={css.chevronHover} />
+        <span className={css.iconIdle}>{idleLeading}</span>
+        <IconChevronDownOutline14 className={clsx(css.chevron, css.chevronHover)} />
       </>
     )
-    : icon
+    : idleLeading
   const leading = open
-    ? <IconChevronDownOutline14 />
-    : leadingFor(state, collapsedIcon)
+    ? <IconChevronDownOutline14 className={css.chevron} />
+    : collapsedIcon
   return (
     <div className={css.root} data-variant={variant} data-tool={toolName} data-state={state}>
       <div
         className={css.row}
-        data-expandable={rowExpands || undefined}
-        role={rowExpands ? 'button' : undefined}
-        tabIndex={rowExpands ? 0 : undefined}
-        aria-expanded={rowExpands ? open : undefined}
-        onClick={rowExpands ? toggleExpand : undefined}
-        onKeyDown={rowExpands ? toggleFromKeyboard : undefined}
+        data-expandable={expandable || undefined}
+        role={expandable ? 'button' : undefined}
+        tabIndex={expandable ? 0 : undefined}
+        aria-expanded={expandable ? open : undefined}
+        onClick={expandable ? toggleExpand : undefined}
+        onKeyDown={expandable ? toggleFromKeyboard : undefined}
       >
-        {expandable && !rowExpands ? (
-          <button
-            type="button"
-            className={css.leading}
-            aria-expanded={open}
-            onClick={toggleFromLeading}
-          >
-            {leading}
-          </button>
-        ) : (
-          <span className={css.leading}>
-            {leading}
-          </span>
-        )}
+        <span className={css.leading}>
+          {leading}
+        </span>
         <span className={css.title}>{title}</span>
-        {!open && (
+        {/* An empty summary drops the separator with it (a row that is only
+            its title, like the context-injection row, shows no trailing dot). */}
+        {!(open && isThink) && summaryText !== '' && (
           <>
             <span className={css.sep} aria-hidden />
             {fileLink ? (
@@ -147,25 +173,71 @@ export function ToolRow({
                 className={css.fileLink}
                 onClick={openFile}
               >
-                {summary}
+                {summaryText}
               </button>
             ) : (
-              <span className={css.summary}>{summary}</span>
+              <span className={clsx(css.summary, failureLine !== null && css.errorSummary)}>
+                {summaryText}
+              </span>
             )}
           </>
         )}
       </div>
-      {/* The terminal presenter's description belongs ABOVE the card per the
-          render-intent contract, so an expanded terminal row keeps showing it
-          even though the collapsed summary is hidden while open. */}
-      {open && terminalBody?.description !== undefined && (
-        <div className={css.terminalDescription}>{terminalBody.description}</div>
+      {open && (
+        /* The wrapper (sibling of .row, so clicks inside never toggle the
+           row) carries the expanded body and the Inspect pill below it. */
+        <div className={css.bodyWrap}>
+          {terminalBody !== null
+            ? <TerminalBlock {...terminalBody.card} maxLines={Infinity} className={css.terminalBody} />
+            : isThink
+              ? <div className={css.thinkBody}>{body}</div>
+              : (
+                <>
+                  {variant === 'code' && body !== null && (
+                    <div className={css.bodyScroll}>
+                      <CodeBlock code={body} lang="typescript" className={css.codeBody} />
+                    </div>
+                  )}
+                  {plainBody === true && cardBody !== null && (
+                    <div className={clsx(css.ioCard, css.ioCardPlain)}>
+                      <span className={css.ioText}>{cardBody}</span>
+                    </div>
+                  )}
+                  {plainBody !== true && (cardBody !== null || outputText !== null) && (
+                    <div className={css.ioCard}>
+                      {cardBody !== null && (
+                        <div className={css.ioSection}>
+                          <span className={css.ioLabel}>IN</span>
+                          <span className={css.ioText}>{cardBody}</span>
+                        </div>
+                      )}
+                      {cardBody !== null && outputText !== null && (
+                        <span className={css.ioDivider} aria-hidden />
+                      )}
+                      {outputText !== null && (
+                        <div className={css.ioSection}>
+                          <span className={css.ioLabel}>OUT</span>
+                          <span className={css.ioText} data-error={state === 'error' || undefined}>
+                            {outputText}
+                          </span>
+                        </div>
+                      )}
+                    </div>
+                  )}
+                </>
+              )}
+          {inspect !== undefined && (
+            <button
+              type="button"
+              className={css.inspectButton}
+              onClick={inspect}
+            >
+              <IconInspect />
+              Inspect
+            </button>
+          )}
+        </div>
       )}
-      {open && (terminalBody !== null
-        ? <TerminalBlock {...terminalBody.card} maxLines={CHAT_TERMINAL_MAX_LINES} className={css.terminalBody} />
-        : variant === 'code'
-          ? <CodeBlock code={text} lang="typescript" className={css.codeBody} />
-          : <div className={css.body}>{text}</div>)}
     </div>
   )
 }

+ 27 - 5
packages/client/ui-conversation/src/client/contract/slots.ts

@@ -147,13 +147,17 @@ export interface InputZone {
 }
 
 /**
- * View-slot owner share: deliberately empty — ConversationRoot supplies
- * nothing at its renderSlot site (sessionId and the snapshot hook arrive as
+ * View-slot owner share: the cross-view inspect handoff (otherwise views need
+ * nothing from the render site — sessionId and the snapshot hook arrive as
  * framework-standard props; tool rows go through each view's own declared
- * toolview hole). Kept as the named owner seat so a future cross-view
- * payload has a home.
+ * toolview hole).
  */
-export interface ConvViewOwnerProps {}
+export interface ConvViewOwnerProps {
+  /** One-shot inspect request from another view (chat's Inspect button); null when idle. */
+  inspect?: { callId: CallId } | null
+  /** Acknowledge the inspect request once applied (clears the store field). */
+  onInspectDone?: () => void
+}
 
 /**
  * Owner share of a per-view toolview slot: the call material the rendering
@@ -176,6 +180,11 @@ export interface ToolRowOwnerProps {
    * The chat view 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.
+   */
+  inspect?: (() => void) | undefined
 }
 
 /**
@@ -419,6 +428,19 @@ export interface ChatViewInjected {
    */
   openFile: (path: string) => void
   loadOlder: () => void
+  /** Hand a call off to the trajectory view: write the one-shot inspect target and switch tabs. */
+  inspectCall: (callId: CallId) => void
+  /**
+   * Per-session scroll memory surviving view switches (in-memory, never
+   * persisted): the view saves on every scroll and restores on remount; a
+   * fresh page load starts empty and keeps the open-jump-to-bottom default.
+   */
+  chatScroll: {
+    /** Record the scroll offset; null clears it (pinned to bottom). */
+    save: (top: number | null) => void
+    /** Last recorded offset, or null when pinned or never recorded. */
+    read: () => number | null
+  }
 }
 
 /** Full chat-view component props: runtime share & the declared toolview/commandview holes' render share & store share & injected share. */

+ 14 - 11
packages/client/ui-conversation/src/client/contract/terminal-card-model.ts

@@ -11,17 +11,6 @@
 import type { TerminalBlockProps } from '@deepseek-ai/dsh-client-ui-primitives'
 import { resolveToolPath, type ToolCallBlock } from './tool-call-model.ts'
 
-/**
- * Output lines the chat row's expanded terminal body shows before collapsing
- * the middle — half the primitive's own default, which the details panel
- * keeps. A chat row is a summary surface inside the message flow: the flow
- * must stay scannable across many calls, while the details panel is the
- * single-call reading surface. A design constant of this UI's row geometry,
- * not a deployment choice, so it is fixed here rather than a plugin Config
- * field.
- */
-export const CHAT_TERMINAL_MAX_LINES = 8
-
 /**
  * The {@link TerminalBlock} props this derivation owns. Picked off the
  * primitive's props so the two stay in step; `home` is absent because the web
@@ -44,6 +33,20 @@ export interface TerminalCardModel {
   description: string | undefined
 }
 
+/**
+ * True when a settled terminal card reports a failing exit — a non-zero code
+ * or a terminating signal. The bash tool settles a failing command as a
+ * completed call (`isError` stays false: the exit status is result data), so
+ * this is the collapsed row's only failure signal; without it the red exit
+ * pill would be visible only after expanding the card.
+ * @param model - a derived terminal card.
+ * @returns whether the card's exit status is a failure.
+ */
+export function terminalFailed(model: TerminalCardModel): boolean {
+  const { exitCode, signal, running } = model.card
+  return running !== true && ((exitCode !== undefined && exitCode !== 0) || signal !== undefined)
+}
+
 /**
  * Resolve a terminal view's working directory the way the render-intent
  * contract assigns to the UI bridge: an absolute path is used as-is, a relative

+ 36 - 5
packages/client/ui-conversation/src/client/contract/tool-call-model.ts

@@ -1,14 +1,15 @@
 /**
  * Pure row-model derivation for tool summary rows: variant classification,
- * one-line summary and expanded-body text from the frozen call slice. This
- * derivation reads the call ARGUMENTS only; a call whose render intent is a
- * terminal card gets its expanded body from the views instead, through
+ * one-line summary, expanded-body text, and flattened result output from the
+ * frozen call slice. Input material comes from the call ARGUMENTS; output and
+ * error material from the settled result node. A call whose render intent is
+ * a terminal card gets its expanded body from the views instead, through
  * `terminalCardModel` in terminal-card-model.ts.
  */
 // The block union's defining home is runtime (fold-product types); this
 // contract only forwards it (type-definition authority stays with the layer
 // that produces the values).
-import type { ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client'
+import type { ToolCallBlock, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
 
 export type { ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client'
 
@@ -70,11 +71,34 @@ export interface ToolRowModel {
    * relative values against the session cwd before opening.
    */
   filePath: string | undefined
-  /** Expanded-body text (pretty args); null = row not expandable. */
+  /** Expanded-body input text (pretty args); null = no input section. */
   body: string | null
+  /** Flattened result text ({@link resultText}); null while running or when the result carries no text. */
+  output: string | null
+  /** First line of the result text on an error row; null for every other state. */
+  errorSummary: string | null
   state: ToolRowState
 }
 
+/**
+ * Flatten a settled result's content blocks to display text: text blocks
+ * verbatim, other block shapes as pretty JSON. Empty content on a failed call
+ * falls back to the structured error's `name: code` line.
+ * @param node - the settled result node.
+ * @returns the flattened result text (may be empty).
+ */
+export function resultText(node: ToolResultNode): string {
+  const parts: string[] = []
+  for (const block of node.content) {
+    if (block.type === 'text') parts.push(block.text)
+    else parts.push(JSON.stringify(block, null, 2))
+  }
+  if (parts.length === 0 && node.error !== undefined) {
+    parts.push(`${node.error.name}: ${node.error.code}`)
+  }
+  return parts.join('\n')
+}
+
 function parseArgs(argsRaw: string): unknown {
   try {
     return JSON.parse(argsRaw)
@@ -192,12 +216,19 @@ export function toolRowModel(toolName: string, block: ToolCallBlock, cwd?: strin
   const summary = variant === 'others' && toolName !== '' && toolTitle === undefined
     ? `${toolName} · ${base}`
     : base
+  // The empty string is "no text" for both derived result fields: a settled
+  // call with blank content has nothing to expand, and a blank first line
+  // would erase the collapsed error row's summary slot.
+  const output = done ? (resultText(block) || null) : null
+  const errorSummary = state === 'error' && output !== null ? firstLine(output) : null
   return {
     variant,
     title: toolTitle ?? VARIANT_TITLES[variant],
     summary,
     filePath: deriveFilePath(variant, argsRaw),
     body: deriveBody(variant, argsRaw),
+    output,
+    errorSummary,
     state,
   }
 }

+ 6 - 0
packages/client/ui-conversation/src/client/contract/views.ts

@@ -23,4 +23,10 @@ export interface ChatStoreState {
   draft: string
   /** Active conversation view id ('conversation.view' entry id); null falls back to the first view. */
   view: string | null
+  /**
+   * One-shot inspect handoff: chat writes the call to reveal, the trajectory
+   * view consumes it and acknowledges by clearing. Read with `?? null` —
+   * persisted snapshots from before this field rehydrate without it.
+   */
+  inspect: { callId: CallId } | null
 }

+ 6 - 1
packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx

@@ -35,6 +35,8 @@ export function ConversationSession({
   const blank = useSession(s => s.blank)
   const inputState = useInput(s => s)
   const storedDraft = useStore(s => s.draft)
+  // `?? null`: persisted snapshots from before the inspect field rehydrate without it.
+  const inspect = useStore(s => s.inspect ?? null)
 
   useEffect(() => {
     if (inputState.draft === '' && storedDraft !== '') inputActions.setDraft(storedDraft)
@@ -52,7 +54,10 @@ export function ConversationSession({
 
   const view: ReactNode = hideChrome ? null : (
     <div className={css.viewArea}>
-      {active !== undefined && renderSlot('conversation.view', {}, { only: active.id })}
+      {active !== undefined && renderSlot('conversation.view', {
+        inspect,
+        onInspectDone: () => { actions.setInspect(null) },
+      }, { only: active.id })}
     </div>
   )
 

+ 2 - 15
packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx

@@ -12,7 +12,7 @@ import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client'
 import type { ConversationSnapshot, RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
 import type { DetailsSlotProps } from '../contract/slots.ts'
 import { terminalCardModel } from '../contract/terminal-card-model.ts'
-import type { ToolCallBlock } from '../contract/tool-call-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). */
@@ -153,20 +153,7 @@ function OutputBody({ material, cwd }: { material: CallMaterial; cwd: string | u
   const result = material.block
   return (
     <pre className={css.code} data-error={result.isError || undefined}>
-      {renderResult(result)}
+      {resultText(result)}
     </pre>
   )
 }
-
-/** Flatten result content blocks to display text (text blocks verbatim, others as JSON). */
-function renderResult(node: ToolResultNode): string {
-  const parts: string[] = []
-  for (const block of node.content) {
-    if (block.type === 'text') parts.push(block.text)
-    else parts.push(JSON.stringify(block, null, 2))
-  }
-  if (parts.length === 0 && node.error !== undefined) {
-    parts.push(`${node.error.name}: ${node.error.code}`)
-  }
-  return parts.join('\n')
-}

+ 4 - 2
packages/client/ui-conversation/src/client/stores.ts

@@ -3,7 +3,7 @@
  * The plugin creates its handle at apply time so identity follows the fiber.
  */
 import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-runtime/client'
-import type { ChatStoreState, SelectionTarget } from './contract/views.ts'
+import type { CallId, ChatStoreState, SelectionTarget } from './contract/views.ts'
 
 /** Declared action shape used to give the exported factory a stable return type. */
 type ChatActions = {
@@ -12,6 +12,7 @@ type ChatActions = {
   clearDraft: (draft: ChatStoreState) => void
   restoreDraft: (draft: ChatStoreState, text: string) => void
   setView: (draft: ChatStoreState, view: string) => void
+  setInspect: (draft: ChatStoreState, target: { callId: CallId } | null) => void
 }
 
 /**
@@ -20,7 +21,7 @@ type ChatActions = {
  */
 export function createChatStore(): EngineStoreHandle<ChatStoreState, ChatActions> {
   return defineStore({
-    init: (): ChatStoreState => ({ selection: null, draft: '', view: null }),
+    init: (): ChatStoreState => ({ selection: null, draft: '', view: null, inspect: null }),
     persist: 'dsh.conversation.chat',
     actions: {
       select: (d, target: SelectionTarget | null) => { d.selection = target },
@@ -30,6 +31,7 @@ export function createChatStore(): EngineStoreHandle<ChatStoreState, ChatActions
       // since the clear (send choreography lives in the inject factory).
       restoreDraft: (d, text: string) => { if (d.draft === '') d.draft = text },
       setView: (d, view: string) => { d.view = view },
+      setInspect: (d, target: { callId: CallId } | null) => { d.inspect = target },
     },
   })
 }

+ 6 - 3
packages/client/ui-conversation/src/client/toolviews/ask-question-row.tsx

@@ -1,7 +1,7 @@
 // ask_user_question toolview: question-flavored summary row replacing the
 // generic "Tool call" card, registered into the keyed
 // 'conversation.chat.toolview' hole like todo-row. The row composes ToolRow
-// (chrome, running sweep, leading expansion) and swaps in the interaction
+// (chrome, running sweep, whole-row expand) and swaps in the interaction
 // outcome — `waiting` while pending, answered-count once settled, `cancelled`
 // when the user dismissed the whole set — because the questions themselves
 // render in the composer takeover.
@@ -37,8 +37,9 @@ function answeredSummary(text: string): string | null {
   return `${answered}/${answers.length} answered`
 }
 
-/** One-line question-interaction row (leading toggle expands the raw args). */
-export function AskQuestionRow({ toolName, block }: ToolRowProps) {
+/** One-line question-interaction row (the whole row toggles the call's
+ *  Input/Output sections, ToolRow's unified expand). */
+export function AskQuestionRow({ toolName, block, inspect }: ToolRowProps) {
   const model = toolRowModel(toolName, block)
   // Composer verdicts settle the call as specific UserInteractionErrors
   // (apiproxy ask_user_question handler): 'ASK_CANCELLED' is the user's own
@@ -68,7 +69,9 @@ export function AskQuestionRow({ toolName, block }: ToolRowProps) {
       title="Ask question"
       summary={summary}
       body={model.body}
+      output={model.output}
       state={state}
+      inspect={inspect}
     />
   )
 }

+ 92 - 4
packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css

@@ -1,5 +1,5 @@
 /* Bash toolview: same geometry/tokens as ToolRow (figma Bash · description),
-   plus the terminal card the row stacks under its summary line. */
+   plus the expand-gated terminal card under the summary line. */
 
 /* Summary line over the terminal card; the summary row keeps its own 24px
    height, so the card is a column around it rather than a change to it. */
@@ -8,10 +8,23 @@
   flex-direction: column;
 }
 
-/* Row indentation matches ToolRow's expanded bodies (16px leading + 6px gap),
-   and replaces the primitive's standalone vertical margin with the flow's. */
+/* Expanded terminal card, matching ToolRow's terminalBody: 4px indent, l1
+   hairline, and the max-height scroll on the card's own OUTPUT (banner stays
+   pinned; 224px = the 260px card cap minus the ~36px banner); the margin
+   replaces the primitive's standalone vertical margin with the flow's. */
 .terminal {
-  margin: 4px 0 4px 22px;
+  --dsl-terminal-font: var(--dsw-font-markdown-code-block-small);
+  --dsl-terminal-line-height: 18px;
+  --dsl-terminal-output-max-height: 224px;
+  margin: 4px 0 4px 4px;
+  border: 1px solid var(--dsw-alias-border-l1);
+}
+
+/* ToolRow's unified expand interaction, replicated per the registrant
+   posture: pointer on the expandable row (the icon→chevron hover preview is
+   the affordance, no row fill). */
+.root[data-expandable] {
+  cursor: pointer;
 }
 
 .root {
@@ -47,6 +60,7 @@
 }
 
 .leading {
+  position: relative; /* .chevronHover overlay anchor */
   flex: none;
   width: 16px;
   height: 16px;
@@ -57,6 +71,34 @@
   color: var(--dsw-alias-label-tertiary);
 }
 
+.chevron {
+  color: var(--dsw-alias-label-secondary);
+}
+
+/* Hover preview on the expandable row: the idle icon crossfades (100ms) into
+   a down chevron before the row is opened — same overlay as ToolRow. */
+.iconIdle {
+  display: inline-flex;
+  opacity: 1;
+  transition: opacity 100ms ease;
+}
+
+.chevronHover {
+  position: absolute;
+  inset: 0;
+  margin: auto;
+  opacity: 0;
+  transition: opacity 100ms ease;
+}
+
+.root:hover .iconIdle {
+  opacity: 0;
+}
+
+.root:hover .chevronHover {
+  opacity: 1;
+}
+
 .scopeBadge {
   flex: none;
   margin-right: 8px;
@@ -95,6 +137,52 @@
   color: var(--dsw-alias-label-tertiary);
 }
 
+/* Error row's collapsed summary: the failure's first line in the error color. */
+.errorSummary {
+  color: var(--dsw-alias-state-error-primary);
+}
+
+/* Hover-revealed Inspect pill under the expanded terminal's bottom-left —
+   ToolRow's .bodyWrap/.inspectButton treatment, replicated per the registrant
+   posture: real flow (it reserves its line), revealed by hovering anywhere on
+   the tool call — title row included — or by keyboard focus. */
+.bodyWrap {
+  display: flex;
+  flex-direction: column;
+}
+
+.inspectButton {
+  display: inline-flex;
+  align-self: flex-start;
+  align-items: center;
+  gap: 4px;
+  margin: 4px 0 2px 4px;
+  padding: 2px 8px;
+  border: 1px solid var(--dsw-alias-border-l2);
+  border-radius: 999px;
+  /* Base background, not bg-overlay: the overlay token reads too heavy. */
+  background: var(--dsw-alias-bg-base);
+  color: var(--dsw-alias-label-secondary);
+  font-size: 11px;
+  line-height: 16px;
+  cursor: pointer;
+  opacity: 0;
+  transition: opacity 100ms ease;
+}
+
+.card:hover .inspectButton,
+.inspectButton:focus-visible {
+  opacity: 1;
+}
+
+/* Solid hover fill: the pill floats over terminal output, so a translucent
+   hover token would let the text underneath bleed through. */
+.inspectButton:hover {
+  background: var(--dsw-alias-interactive-bg-hover-solid);
+  color: var(--dsw-alias-label-primary);
+}
+
+
 .visuallyHidden {
   position: absolute;
   width: 1px;

+ 72 - 22
packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx

@@ -4,19 +4,23 @@
 // Child sessions keep a scoped badge so session-dimension differentiation stays
 // observable inside the component (no parallel registry).
 //
-// A bash call declares the terminal render intent, so this row also renders
-// the command's own output through TerminalBlock. This row has no expand
-// control and is not a details-panel target either (tool rows stopped being
-// one), so its terminal body is resident rather than expand-gated as in
-// ToolRow, and the card's own copy and expand controls are the row's only
-// interactions. CHAT_TERMINAL_MAX_LINES is passed as `maxLines` — the chat
-// flow's tighter cap over the block's own default of 16 — and the block's
-// internal expander keeps a long output from taking over the message flow.
+// A bash call declares the terminal render intent, so this row renders the
+// command's own output through TerminalBlock — expand-gated exactly like
+// ToolRow's unified interaction: collapsed by default, the whole summary row
+// is the toggle (click / Enter / Space, icon→chevron hover preview; the
+// summary stays inline while open),
+// and the expanded card max-height-scrolls inside its own surface with the
+// full output (maxLines Infinity — no middle collapse). An error row's
+// collapsed summary is the failure's first line in the error color.
 
+import { useState, type KeyboardEvent } from 'react'
 import type { Context } from 'cordis'
-import { IconApiOutline14, StateDot, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives'
+import clsx from 'clsx'
+import {
+  IconApiOutline14, IconChevronDownOutline14, StateDot, TerminalBlock,
+} from '@deepseek-ai/dsh-client-ui-primitives'
 import type { ToolRowProps } from '../contract/slots.ts'
-import { CHAT_TERMINAL_MAX_LINES, terminalCardModel } from '../contract/terminal-card-model.ts'
+import { terminalCardModel, terminalFailed } from '../contract/terminal-card-model.ts'
 import { toolRowModel, type ToolRowState } from '../contract/tool-call-model.ts'
 import css from './bash-sample.module.css'
 
@@ -40,38 +44,84 @@ function stateStatus(state: ToolRowState): string | null {
 }
 
 /**
- * Bash row: icon + Bash · {description} in the shared ToolRow chrome, with the
- * command's terminal card resident below it. The summary row is not a
- * details-panel control (tool rows stopped being one), so the card's copy and
- * expand controls are the row's only interactions.
+ * Bash row: icon + Bash · {description} in the shared ToolRow chrome, the
+ * whole row toggling the command's terminal card (ToolRow's unified
+ * expand interaction, replicated locally per the registrant posture).
  */
-export function BashRow({ toolName, block, sessionId, useSessions }: ToolRowProps) {
+export function BashRow({ toolName, block, sessionId, useSessions, inspect }: ToolRowProps) {
   const model = toolRowModel(toolName, block)
   // Session workspace root: the terminal view's cwd resolves against it (an
   // omitted workdir IS the workspace), which the pure presenter cannot do.
   const cwd = useSessions(list => list.byId[sessionId]?.cwd)
   const terminal = terminalCardModel(block, cwd)
+  // A failing exit status is the terminal card's own error signal (the call
+  // itself settles isError:false), surfaced as the row's red state dot.
+  const state = model.state === 'ok' && terminal !== null && terminalFailed(terminal)
+    ? 'error'
+    : model.state
   const isChild = useSessions(list => list.byId[sessionId]?.parentId !== undefined)
-  const status = stateStatus(model.state)
+  const status = stateStatus(state)
+  const [expanded, setExpanded] = useState(false)
+  const expandable = terminal !== null
+  const open = expanded && expandable
+  const failureLine = model.state === 'error' ? model.errorSummary : null
+  const toggleExpand = () => {
+    setExpanded(v => !v)
+  }
+  const toggleFromKeyboard = (event: KeyboardEvent<HTMLDivElement>) => {
+    if (!expandable || (event.key !== 'Enter' && event.key !== ' ')) return
+    event.preventDefault()
+    toggleExpand()
+  }
+  const leading = open
+    ? <IconChevronDownOutline14 className={css.chevron} />
+    : expandable
+      ? (
+        <>
+          <span className={css.iconIdle}>{leadingFor(state)}</span>
+          <IconChevronDownOutline14 className={clsx(css.chevron, css.chevronHover)} />
+        </>
+      )
+      : leadingFor(state)
   return (
     <div className={css.card}>
       <div
         className={css.root}
         data-sample={isChild ? 'bash-scoped' : 'bash-global'}
         data-variant="bash"
-        data-state={model.state}
+        data-state={state}
+        data-expandable={expandable || undefined}
+        role={expandable ? 'button' : undefined}
+        tabIndex={expandable ? 0 : undefined}
+        aria-expanded={expandable ? open : undefined}
+        onClick={expandable ? toggleExpand : undefined}
+        onKeyDown={expandable ? toggleFromKeyboard : undefined}
       >
-        <span className={css.leading}>{leadingFor(model.state)}</span>
+        <span className={css.leading}>{leading}</span>
         {status !== null && <span className={css.visuallyHidden}>{status}</span>}
         {isChild && <span className={css.scopeBadge}>scoped</span>}
         <span className={css.title}>{model.title}</span>
         <span className={css.sep} aria-hidden />
         {/* The terminal presenter's description is the contractual
-            above-card summary; it outranks the args-derived one. */}
-        <span className={css.summary}>{terminal?.description ?? model.summary}</span>
+            above-card summary; a failure's first line outranks both. */}
+        <span className={clsx(css.summary, failureLine !== null && css.errorSummary)}>
+          {failureLine ?? terminal?.description ?? model.summary}
+        </span>
       </div>
-      {terminal !== null && (
-        <TerminalBlock {...terminal.card} maxLines={CHAT_TERMINAL_MAX_LINES} className={css.terminal} />
+      {terminal !== null && open && (
+        /* Same hover-Inspect posture as ToolRow's expanded body, replicated
+           locally per the registrant posture. */
+        <div className={css.bodyWrap}>
+          <TerminalBlock {...terminal.card} maxLines={Infinity} className={css.terminal} />
+          {inspect !== undefined && (
+            <button type="button" className={css.inspectButton} onClick={inspect}>
+              <svg width="12" height="12" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden>
+                <path d="M16 8L10.8571 12V10.552L14.1383 8L10.8571 5.448V4L16 8ZM5.14286 10.552L1.86171 8L5.14286 5.448V4L0 8L5.14286 12V10.552ZM9.02514 4L5.59657 12H6.84057L10.2691 4H9.02514Z" fill="currentColor" />
+              </svg>
+              Inspect
+            </button>
+          )}
+        </div>
       )}
     </div>
   )

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

@@ -1,10 +1,10 @@
 // todo_write toolview: plan-flavored summary row replacing the generic
 // "Tool call" card, registered into the keyed 'conversation.chat.toolview'
 // hole like the bash sample (a product registration, not a sample). The row
-// composes ToolRow (chrome, running sweep, leading expansion) and swaps in a
+// composes ToolRow (chrome, running sweep, whole-row expand) and swaps in a
 // summary of the written list (counts + active item) from the call args; the
 // durable list itself renders in the TodoPanel above the composer, so the
-// row stays one line.
+// row stays one line until expanded.
 
 import { IconChecklistOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { Context } from 'cordis'
@@ -40,10 +40,11 @@ function summarize(argsRaw: string): string | null {
     : head
 }
 
-/** One-line plan update row (leading toggle expands the raw args). Non-ok
- *  execution states keep the shared row's dot semantics — a cancelled call
- *  wrote no todo/write, so it must not read as a completed update. */
-export function TodoRow({ toolName, block }: ToolRowProps) {
+/** One-line plan update row (the whole row toggles the call's Input/Output
+ *  sections, ToolRow's unified expand). Non-ok execution states keep the
+ *  shared row's dot semantics — a cancelled call wrote no todo/write, so it
+ *  must not read as a completed update. */
+export function TodoRow({ toolName, block, inspect }: ToolRowProps) {
   const model = toolRowModel(toolName, block)
   const argsRaw = ('kind' in block ? block.call?.argsRaw : block.argsRaw) ?? ''
   const summary = summarize(argsRaw) ?? model.summary
@@ -55,7 +56,10 @@ export function TodoRow({ toolName, block }: ToolRowProps) {
       title="更新任务清单"
       summary={summary}
       body={model.body}
+      output={model.output}
+      errorSummary={model.errorSummary}
       state={model.state}
+      inspect={inspect}
     />
   )
 }

+ 11 - 6
packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx

@@ -134,7 +134,7 @@ describe('todo_write assembly (product registrations, no outlet twins)', () => {
 })
 
 describe('terminal card assembly', () => {
-  it('the keyed bash row carries a resident terminal card; the fallback row reaches one through expand', async () => {
+  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.
@@ -142,15 +142,20 @@ describe('terminal card assembly', () => {
     ])
     const view = runtime.renderRoot()
 
-    // Keyed BashRow renders the card residently (no expand gesture).
-    const keyed = view.container.querySelector('[data-sample="bash-global"]')?.parentElement
-    expect(keyed?.querySelector('[data-terminal]')).not.toBeNull()
+    // Keyed BashRow: collapsed by default, the whole summary row is the toggle.
+    const keyedRow = view.container.querySelector('[data-sample="bash-global"]')
+    const keyed = keyedRow?.parentElement
+    expect(keyed?.querySelector('[data-terminal]')).toBeNull()
+    fireEvent.click(keyedRow!)
+    await waitFor(() => {
+      expect(keyed!.querySelector('[data-terminal]')).not.toBeNull()
+    })
 
-    // Fallback row: card appears only after its expand control.
+    // 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('button[aria-expanded]')!)
+    fireEvent.click(fallback!.querySelector('[data-expandable]')!)
     await waitFor(() => {
       expect(fallback!.querySelector('[data-terminal]')).not.toBeNull()
     })

+ 24 - 3
packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx

@@ -107,11 +107,32 @@ describe('MessageItem arms', () => {
     expect(view.queryByRole('button', { name: '复制' })).toBeNull()
   })
 
-  it('context and unknown nodes render their JSON rows', () => {
-    const ctxView = render(
+  it('context nodes render a title-only tool row that expands the injected text without labels', () => {
+    const view = render(
+      <MessageItem node={{
+        kind: 'context', seq: 3, source: null,
+        content: [{ type: 'text', text: 'memory line one\nsecond line' }] as never,
+      } as never}
+      />,
+    )
+    const row = view.getByRole('button', { name: /上下文注入/ })
+    // Title-only collapsed row: the injected text stays behind the expand.
+    expect(view.queryByText(/memory line one/)).toBeNull()
+    fireEvent.click(row)
+    expect(view.getByText(/second line/)).toBeTruthy()
+    // Label-less card: the injection is ambient context, not a call's IN payload.
+    expect(view.queryByText('IN')).toBeNull()
+  })
+
+  it('a context node without pure text expands to the full JSON payload', () => {
+    const view = render(
       <MessageItem node={{ kind: 'context', seq: 3, content: [], source: null } as never} />,
     )
-    expect(ctxView.getByText(/上下文注入/)).toBeTruthy()
+    fireEvent.click(view.getByRole('button', { name: /上下文注入/ }))
+    expect(view.getByText(/"source": null/)).toBeTruthy()
+  })
+
+  it('unknown nodes render their JSON rows', () => {
     const unknownView = render(
       <MessageItem node={{ kind: 'unknown', seq: 4, type: 'surface/next', data: { x: 1 } } as never} />,
     )

+ 3 - 3
packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx

@@ -203,7 +203,7 @@ describe('run_code sub-calls through the real chat machinery', () => {
     expect(nest.querySelector('[data-tool="cordis_unmount"]')?.textContent)
       .toContain('Unmount temporary Plugindyn-2')
 
-    fireEvent.click(mounted!.querySelector('button[aria-expanded]')!)
+    fireEvent.click(mounted!.querySelector('[data-expandable]')!)
     expect(mounted!.querySelector('pre.shiki')?.textContent).toBe(code)
   })
 
@@ -211,8 +211,8 @@ describe('run_code sub-calls through the real chat machinery', () => {
     const parent = 'call-64'
     const b = await bench(snapshotWith([codeResult(10, parent)], new Map()))
     const view = mountApp(b.slots)
-    // The code row is expandable via its leading control (body = the program).
-    const toggle = view.container.querySelector('[data-variant="code"] button[aria-expanded]')
+    // The code row is expandable via the whole summary row (body = the program).
+    const toggle = view.container.querySelector('[data-variant="code"] [data-expandable]')
     expect(toggle).not.toBeNull()
     fireEvent.click(toggle!)
     // Shiki splits the program into token spans inside one <pre class="shiki">:

+ 6 - 1
packages/client/ui-conversation/tests/chat-store.spec.ts

@@ -12,7 +12,7 @@ beforeEach(() => {
 describe('createChatStore', () => {
   it('init shape: empty selection/draft/view', () => {
     const store = createChatStore().create()
-    expect(store.store.getSnapshot()).toEqual({ selection: null, draft: '', view: null })
+    expect(store.store.getSnapshot()).toEqual({ selection: null, draft: '', view: null, inspect: null })
   })
 
   it('actions cover the declared write set', () => {
@@ -30,6 +30,11 @@ describe('createChatStore', () => {
 
     store.actions.setView('chat')
     expect(store.store.getSnapshot().view).toBe('chat')
+
+    store.actions.setInspect({ callId: 'c1' })
+    expect(store.store.getSnapshot().inspect).toEqual({ callId: 'c1' })
+    store.actions.setInspect(null)
+    expect(store.store.getSnapshot().inspect).toBeNull()
   })
 
   it('restoreDraft only fills an empty draft (optimistic-send rollback contract)', () => {

+ 142 - 23
packages/client/ui-conversation/tests/chat-tool-row.spec.tsx

@@ -4,7 +4,7 @@ import { cleanup, fireEvent, render } from '@testing-library/react'
 
 afterEach(cleanup)
 import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
-import { classifyTool, resolveToolPath, toolRowModel } from '../src/client/contract/tool-call-model.ts'
+import { classifyTool, resolveToolPath, resultText, toolRowModel } from '../src/client/contract/tool-call-model.ts'
 import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
 import { ToolRow } from '../src/client/chat/ToolRow.tsx'
 import { GenericToolCard } from '../src/client/chat/GenericToolCard.tsx'
@@ -102,6 +102,29 @@ describe('tool-call-model', () => {
       .toBe('{\n  "code": ""\n}')
   })
 
+  it('resultText flattens text blocks verbatim, other shapes as JSON, empty error content to name: code', () => {
+    expect(resultText(result({ content: [{ type: 'text', text: 'a\nb' }] }))).toBe('a\nb')
+    expect(resultText(result({ content: [{ type: 'text', text: 'a' }, { type: 'image', data: 'x' } as never] })))
+      .toBe(`a\n${JSON.stringify({ type: 'image', data: 'x' }, null, 2)}`)
+    expect(resultText(result({ content: [], isError: true, error: { name: 'ToolError', code: 'denied' } })))
+      .toBe('ToolError: denied')
+    expect(resultText(result({ content: [] }))).toBe('')
+  })
+
+  it('derives output from the settled result and null while running or blank', () => {
+    expect(toolRowModel('bash', result({ content: [{ type: 'text', text: 'out' }] })).output).toBe('out')
+    expect(toolRowModel('bash', running()).output).toBeNull()
+    expect(toolRowModel('bash', result({ content: [] })).output).toBeNull()
+  })
+
+  it('derives errorSummary as the first output line on error rows only', () => {
+    const failed = result({ content: [{ type: 'text', text: 'boom\ndetail' }], isError: true })
+    expect(toolRowModel('bash', failed).errorSummary).toBe('boom')
+    expect(toolRowModel('bash', result({ content: [{ type: 'text', text: 'boom' }] })).errorSummary).toBeNull()
+    expect(toolRowModel('bash', result({ content: [], isError: true })).errorSummary).toBeNull()
+    expect(toolRowModel('bash', running()).errorSummary).toBeNull()
+  })
+
   it('gives Cordis lifecycle tools action titles over their generic variants', () => {
     expect(toolRowModel('cordis_inspect', running({
       name: 'cordis_inspect',
@@ -144,14 +167,15 @@ describe('ToolRow', () => {
     expect(view.container.querySelector('[aria-expanded]')?.getAttribute('aria-expanded')).toBe('false')
   })
 
-  it('expanding swaps the leading slot to a chevron, hides summary, shows body', () => {
+  it('row click expands: chevron leading, summary kept inline, body in the scrolling card', () => {
     const view = render(<ToolRow {...rowProps} />)
-    fireEvent.click(view.container.querySelector('button')!)
+    fireEvent.click(view.getByRole('button'))
     expect(view.queryByTestId('tool-icon')).toBeNull()
     expect(view.container.querySelector('svg')).not.toBeNull()
-    expect(view.queryByText('List files')).toBeNull()
+    expect(view.getByText('List files')).toBeTruthy()
     expect(view.getByText(/"a": 1/)).toBeTruthy()
-    fireEvent.click(view.container.querySelector('button')!)
+    expect(view.container.querySelector('[class*="ioCard"]')).not.toBeNull()
+    fireEvent.click(view.getByRole('button'))
     expect(view.queryByTestId('tool-icon')).not.toBeNull()
     expect(view.getByText('List files')).toBeTruthy()
   })
@@ -162,16 +186,20 @@ describe('ToolRow', () => {
     expect(runningView.container.querySelector('[data-state="running"]')).not.toBeNull()
     const errorView = render(<ToolRow {...rowProps} state="error" />)
     expect(errorView.container.querySelector('[data-testid="tool-icon"]')).toBeNull()
+    // The dot rides the idle slot, so an expandable error row keeps the
+    // icon→chevron hover preview instead of losing it with the icon.
+    expect(errorView.container.querySelector('[class*="chevronHover"]')).not.toBeNull()
   })
 
-  it('non-expandable rows render a passive leading slot', () => {
+  it('non-expandable rows render a passive leading slot and no row button', () => {
     const view = render(<ToolRow {...rowProps} body={null} />)
-    expect(view.container.querySelector('button')).toBeNull()
+    expect(view.queryByRole('button')).toBeNull()
+    expect(view.container.querySelector('[aria-expanded]')).toBeNull()
     expect(view.queryByTestId('tool-icon')).not.toBeNull()
   })
 
-  it('an expandOnRowClick row toggles from Enter and Space, ignoring other keys', () => {
-    const view = render(<ToolRow {...rowProps} expandOnRowClick />)
+  it('the row toggles from Enter and Space, ignoring other keys', () => {
+    const view = render(<ToolRow {...rowProps} />)
     const row = view.getByRole('button')
     fireEvent.keyDown(row, { key: 'Tab' })
     expect(row.getAttribute('aria-expanded')).toBe('false')
@@ -181,32 +209,31 @@ describe('ToolRow', () => {
     expect(row.getAttribute('aria-expanded')).toBe('false')
   })
 
-  it('a non-expandable expandOnRowClick row exposes no row button', () => {
-    const view = render(<ToolRow {...rowProps} body={null} expandOnRowClick />)
-    expect(view.queryByRole('button')).toBeNull()
-  })
-
-  it('file-path summary opens through onOpenFile; the leading slot is not an expand control', () => {
+  it('file rows expand from the row while the path link opens without toggling', () => {
     const open = vi.fn()
     const view = render(
       <ToolRow {...rowProps} variant="read" title="Read" summary="src/a.ts" filePath="src/a.ts" onOpenFile={open} />,
     )
+    const row = view.getByRole('button', { name: /Read/ })
+    // Path click opens the file and leaves the row collapsed.
     fireEvent.click(view.getByText('src/a.ts'))
     expect(open).toHaveBeenCalledWith('src/a.ts')
-    // Only the path link is a button — no args-expand affordance on file rows.
-    expect(view.container.querySelectorAll('button')).toHaveLength(1)
-    expect(view.container.querySelector('[aria-expanded]')).toBeNull()
-    expect(view.queryByText(/"a": 1/)).toBeNull()
+    expect(row.getAttribute('aria-expanded')).toBe('false')
+    // Row click (outside the link) expands the args body.
+    fireEvent.click(row)
+    expect(row.getAttribute('aria-expanded')).toBe('true')
+    expect(view.getByText(/"a": 1/)).toBeTruthy()
   })
 
-  it('a single-file path disables expand even when onOpenFile is absent', () => {
+  it('a file path without onOpenFile renders a plain summary on an expandable row', () => {
     const view = render(
       <ToolRow {...rowProps} variant="write" title="Write" summary="作文.md" filePath="作文.md" />,
     )
     expect(view.container.querySelector('button')).toBeNull()
-    expect(view.container.querySelector('[aria-expanded]')).toBeNull()
-    fireEvent.click(view.getByText('作文.md'))
-    expect(view.queryByText(/"a": 1/)).toBeNull()
+    const row = view.getByRole('button')
+    fireEvent.click(row)
+    expect(row.getAttribute('aria-expanded')).toBe('true')
+    expect(view.getByText(/"a": 1/)).toBeTruthy()
   })
 
   it('non-file rows do not open anything when the summary is clicked', () => {
@@ -215,6 +242,75 @@ describe('ToolRow', () => {
     fireEvent.click(view.getByText('List files'))
     expect(open).not.toHaveBeenCalled()
   })
+
+  it('an error row shows the failure first line in the collapsed summary and the full text expanded', () => {
+    const view = render(
+      <ToolRow {...rowProps} state="error" errorSummary="boom" output={'boom\ndetail'} />,
+    )
+    expect(view.getByText('boom')).toBeTruthy()
+    expect(view.queryByText('List files')).toBeNull()
+    fireEvent.click(view.getByRole('button'))
+    expect(view.getByText(/detail/)).toBeTruthy()
+    expect(view.container.querySelector('[data-error]')).not.toBeNull()
+  })
+
+  it('an error row without an error summary keeps the args summary', () => {
+    const view = render(<ToolRow {...rowProps} state="error" errorSummary={null} />)
+    expect(view.getByText('List files')).toBeTruthy()
+  })
+
+  it('an error file row drops the open-file link (the summary is failure prose, not the path)', () => {
+    const open = vi.fn()
+    const view = render(
+      <ToolRow
+        {...rowProps}
+        variant="write" title="Write" state="error" errorSummary="cannot overwrite"
+        filePath="src/a.ts" onOpenFile={open}
+      />,
+    )
+    fireEvent.click(view.getByText('cannot overwrite'))
+    expect(open).not.toHaveBeenCalled()
+    // The failure line renders as plain text, not the underlined link button.
+    expect(view.container.querySelector('[class*="fileLink"]')).toBeNull()
+  })
+
+  it('the expanded body carries a hover Inspect pill that fires the callback', () => {
+    const inspect = vi.fn()
+    const view = render(<ToolRow {...rowProps} inspect={inspect} />)
+    // Collapsed: no pill.
+    expect(view.queryByText('Inspect')).toBeNull()
+    fireEvent.click(view.getByRole('button', { name: /Bash/ }))
+    const pill = view.getByText('Inspect')
+    fireEvent.click(pill)
+    expect(inspect).toHaveBeenCalledTimes(1)
+    // The pill click must not collapse the row (body is a .row sibling).
+    expect(view.getByRole('button', { name: /Bash/ }).getAttribute('aria-expanded')).toBe('true')
+  })
+
+  it('no inspect callback, no pill', () => {
+    const view = render(<ToolRow {...rowProps} />)
+    fireEvent.click(view.getByRole('button'))
+    expect(view.queryByText('Inspect')).toBeNull()
+  })
+
+  it('the expanded card gutter-labels each section it carries (IN / OUT)', () => {
+    const both = render(<ToolRow {...rowProps} output="result text" />)
+    fireEvent.click(both.getByRole('button'))
+    expect(both.getByText('IN')).toBeTruthy()
+    expect(both.getByText('OUT')).toBeTruthy()
+    expect(both.getByText('result text')).toBeTruthy()
+    cleanup()
+    const inputOnly = render(<ToolRow {...rowProps} />)
+    fireEvent.click(inputOnly.getByRole('button'))
+    expect(inputOnly.getByText('IN')).toBeTruthy()
+    expect(inputOnly.queryByText('OUT')).toBeNull()
+    cleanup()
+    const outputOnly = render(<ToolRow {...rowProps} body={null} output="only out" />)
+    fireEvent.click(outputOnly.getByRole('button'))
+    expect(outputOnly.queryByText('IN')).toBeNull()
+    expect(outputOnly.getByText('OUT')).toBeTruthy()
+    expect(outputOnly.getByText('only out')).toBeTruthy()
+  })
 })
 
 describe('ThinkRow', () => {
@@ -234,6 +330,21 @@ describe('ThinkRow', () => {
     fireEvent.click(view.getByText('Think'))
     expect(row.getAttribute('aria-expanded')).toBe('false')
   })
+
+  it('expanded Think drops the inline summary and renders plain prose, no IN card', () => {
+    const view = render(
+      <AssistantMarkdown
+        blocks={[{ kind: 'reasoning', text: 'Inspect the session\nCheck persistence' }]}
+        streaming={false}
+      />,
+    )
+    fireEvent.click(view.getByText('Think'))
+    // The summary (first line) is gone from the row; only the body carries it.
+    expect(view.getAllByText(/Inspect the session/)).toHaveLength(1)
+    expect(view.queryByText('IN')).toBeNull()
+    expect(view.container.querySelector('[class*="ioCard"]')).toBeNull()
+    expect(view.container.querySelector('[class*="thinkBody"]')).not.toBeNull()
+  })
 })
 
 describe('GenericToolCard', () => {
@@ -283,6 +394,14 @@ describe('GenericToolCard', () => {
     expect(view.container.querySelector('svg')).not.toBeNull()
   })
 
+  it('passes the owner inspect callback through to the expanded row pill', () => {
+    const inspect = vi.fn()
+    const view = render(<GenericToolCard {...props('bash', result())} inspect={inspect} />)
+    fireEvent.click(view.getByRole('button', { name: /Bash/ }))
+    fireEvent.click(view.getByText('Inspect'))
+    expect(inspect).toHaveBeenCalledTimes(1)
+  })
+
   it('file-path summary click reaches openFile; bash summary does not', () => {
     const file = props('read', running({ name: 'read', argsRaw: '{"path":"src/x.ts"}' }))
     const fileView = render(<GenericToolCard {...file} />)

+ 1 - 1
packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx

@@ -112,7 +112,7 @@ describe('keyed toolview hole through the real machinery', () => {
     expect(view.container.querySelector('[data-tool="cordis_unmount"]')?.textContent)
       .toContain('Unmount temporary Plugindyn-2')
 
-    fireEvent.click(mounted!.querySelector('button[aria-expanded]')!)
+    fireEvent.click(mounted!.querySelector('[data-expandable]')!)
     expect(mounted!.querySelector('pre.shiki')?.textContent).toBe(code)
     await b.runtime.dispose()
   })

+ 71 - 3
packages/client/ui-conversation/tests/chat-view.spec.tsx

@@ -94,6 +94,13 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
   const openDetails = vi.fn<(t: SelectionTarget) => void>()
   const openFile = vi.fn<(path: string) => void>()
   const loadOlder = vi.fn()
+  const inspectCall = vi.fn<(callId: string) => void>()
+  // In-memory scroll memory matching the apply.ts per-session map contract.
+  let savedScrollTop: number | null = null
+  const chatScroll = {
+    save: (top: number | null) => { savedScrollTop = top },
+    read: () => savedScrollTop,
+  }
   // 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:
@@ -120,9 +127,11 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
     openDetails,
     openFile,
     loadOlder,
+    inspectCall,
+    chatScroll,
   }
   const setSelection = (next: SelectionTarget | null): void => { chat.actions.select(next) }
-  return { set, ChatView, props, openDetails, openFile, loadOlder, setSelection }
+  return { set, ChatView, props, openDetails, openFile, loadOlder, inspectCall, chatScroll, setSelection }
 }
 
 describe('chat-flow derivation', () => {
@@ -194,6 +203,16 @@ describe('ChatView', () => {
     expect(view.getByText('run a')).toBeTruthy()
   })
 
+  it('the expanded row Inspect pill hands the call id to inspectCall', () => {
+    const h = makeHarness({
+      nodes: [toolResult(3, 'a')],
+    })
+    const view = render(<h.ChatView {...h.props} />)
+    fireEvent.click(view.getByRole('button', { name: /Bash/ }))
+    fireEvent.click(view.getByText('Inspect'))
+    expect(h.inspectCall).toHaveBeenCalledWith('a')
+  })
+
   it('renders assistant Markdown across history, streaming, final, and interrupted states while user text stays literal', () => {
     const markdown = '# Rendered\n\n- **one**\n- `two`'
     const h = makeHarness({ nodes: [user(1, markdown), assistant(2, markdown)] })
@@ -280,11 +299,11 @@ describe('ChatView', () => {
     expect(rowRenders).toBe(afterMount)
   })
 
-  it('tool row expands to the args body via the leading slot toggle', () => {
+  it('tool row expands to the args body via the whole-row toggle', () => {
     const h = makeHarness({ nodes: [toolResult(3, 'a')] })
     const view = render(<h.ChatView {...h.props} />)
     expect(view.queryByText(/"command": "cmd-a"/)).toBeNull()
-    fireEvent.click(view.container.querySelector('button[aria-expanded]')!)
+    fireEvent.click(view.container.querySelector('[data-expandable]')!)
     expect(view.getByText(/"command": "cmd-a"/)).toBeTruthy()
   })
 
@@ -407,6 +426,55 @@ describe('ChatView', () => {
     }
   })
 
+  it('a remount restores the saved scroll position instead of re-jumping to the bottom', () => {
+    const host = document.createElement('div')
+    host.setAttribute('data-conversation-scroll', '')
+    Object.defineProperty(host, 'scrollHeight', { value: 2000, writable: true, configurable: true })
+    Object.defineProperty(host, 'clientHeight', { value: 500, writable: true, configurable: true })
+    Object.defineProperty(host, 'scrollTop', { value: 0, writable: true, configurable: true })
+    document.body.appendChild(host)
+    try {
+      const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
+      // Fresh open (nothing saved): the bottom jump stands.
+      const view = render(<h.ChatView {...h.props} />, { container: host })
+      expect(host.scrollTop).toBe(2000)
+      // Reader scrolls up; the position is recorded continuously.
+      host.scrollTop = 100
+      fireEvent.scroll(host)
+      // View-tab switch away and back: the view unmounts, then remounts.
+      view.rerender(<div />)
+      host.scrollTop = 0
+      view.rerender(<h.ChatView {...h.props} />)
+      expect(host.scrollTop).toBe(100)
+      // The restored position is above the floor: follow stays disarmed.
+      expect(view.getByLabelText('回到底部')).toBeTruthy()
+    } finally {
+      host.remove()
+    }
+  })
+
+  it('a remount while pinned to the bottom keeps the bottom jump', () => {
+    const host = document.createElement('div')
+    host.setAttribute('data-conversation-scroll', '')
+    Object.defineProperty(host, 'scrollHeight', { value: 2000, writable: true, configurable: true })
+    Object.defineProperty(host, 'clientHeight', { value: 500, writable: true, configurable: true })
+    Object.defineProperty(host, 'scrollTop', { value: 0, writable: true, configurable: true })
+    document.body.appendChild(host)
+    try {
+      const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
+      const view = render(<h.ChatView {...h.props} />, { container: host })
+      // At the bottom: the scroll event records the pinned state (null).
+      fireEvent.scroll(host)
+      expect(h.chatScroll.read()).toBeNull()
+      view.rerender(<div />)
+      host.scrollTop = 0
+      view.rerender(<h.ChatView {...h.props} />)
+      expect(host.scrollTop).toBe(2000)
+    } finally {
+      host.remove()
+    }
+  })
+
   it('paging button loads older and shows its busy label', () => {
     const h = makeHarness({ nodes: [user(5, 'later')], hasMore: true })
     const view = render(<h.ChatView {...h.props} />)

+ 1 - 1
packages/client/ui-conversation/tests/selection-survival.spec.tsx

@@ -103,7 +103,7 @@ describe('selection survives on the store seat', () => {
     // ...and a re-created same-id session starts from a FRESH instance.
     const reborn = storeFor(b, 'conversation.session', sid('s1'))
     expect(reborn).not.toBe(doomed)
-    expect(reborn.store.getSnapshot()).toEqual({ selection: null, draft: '', view: null })
+    expect(reborn.store.getSnapshot()).toEqual({ selection: null, draft: '', view: null, inspect: null })
     await b.runtime.dispose()
   })
 })

+ 54 - 19
packages/client/ui-conversation/tests/terminal-card.spec.tsx

@@ -13,7 +13,7 @@ import type {
 } from '@deepseek-ai/dsh-client-runtime/client'
 import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
 import type { SelectionTarget, ToolRowOwnerProps, ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
-import { CHAT_TERMINAL_MAX_LINES, terminalCardModel } from '../src/client/contract/terminal-card-model.ts'
+import { terminalCardModel, terminalFailed } from '../src/client/contract/terminal-card-model.ts'
 import { createChatStore } from '../src/client/stores.ts'
 import { GenericToolCard } from '../src/client/chat/GenericToolCard.tsx'
 import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx'
@@ -87,6 +87,19 @@ describe('terminalCardModel', () => {
     }))?.card.signal).toBe('SIGTERM')
   })
 
+  it('flags a failing exit as terminalFailed; clean exits and running cards are not', () => {
+    // isError stays false on a failing command (the exit status is result
+    // data), so this predicate is the row's only failure signal.
+    expect(terminalFailed(terminalCardModel(settled({
+      resultView: resultTerminal({ exitCode: 2 }),
+    }))!)).toBe(true)
+    expect(terminalFailed(terminalCardModel(settled({
+      resultView: { card: 'terminal', output: '', signal: 'SIGTERM' },
+    }))!)).toBe(true)
+    expect(terminalFailed(terminalCardModel(settled())!)).toBe(false)
+    expect(terminalFailed(terminalCardModel(running())!)).toBe(false)
+  })
+
   it('takes the result view\'s replacement title over the pending one', () => {
     // The presentation contract defines a result title as REPLACING the pending
     // title, so a tool that rewrites it at settle time must win here.
@@ -221,36 +234,39 @@ describe('chat row terminal body', () => {
     callId: 'c1', toolName: 'bash', block, openFile: vi.fn(),
   })
 
-  it('the expanded body is the command output, capped tighter than the panel', () => {
-    expect(CHAT_TERMINAL_MAX_LINES).toBeLessThan(16)
+  /** The whole summary row is the expand toggle (ToolRow's unified interaction). */
+  const toggleRow = (view: { container: HTMLElement }) => {
+    fireEvent.click(view.container.querySelector('[data-expandable]')!)
+  }
+
+  it('the expanded body is the command output inside the row scroll container', () => {
     const view = render(<GenericToolCard {...ownerProps(settled())} />)
     // Collapsed: the one-line summary row only, no output.
     expect(view.getByText('List files')).toBeTruthy()
     expect(view.queryByText(/a\.ts/)).toBeNull()
-    fireEvent.click(view.container.querySelector('button')!)
+    toggleRow(view)
     expect(view.getByText('a.ts  b.ts', RAW)).toBeTruthy()
     expect(view.getByText('ls -la')).toBeTruthy()
     // The args JSON body the generic path would have shown is gone.
     expect(view.queryByText(/"command"/)).toBeNull()
   })
 
-  it('the cap collapses a long output inside the row, expandable in place', () => {
-    const lines = Array.from({ length: CHAT_TERMINAL_MAX_LINES + 3 }, (_, i) => `line-${i}`)
+  it('a long output renders in full — the scroll container replaces the middle collapse', () => {
+    const lines = Array.from({ length: 20 }, (_, i) => `line-${i}`)
     const view = render(<GenericToolCard {...ownerProps(settled({
       resultView: resultTerminal({ output: `${lines.join('\n')}\n` }),
     }))} />)
-    fireEvent.click(view.container.querySelector('button')!)
-    expect(view.getByText('… 其余 3 行')).toBeTruthy()
-    expect(view.queryByText('line-5')).toBeNull()
-    fireEvent.click(view.getByRole('button', { name: '展开其余 3 行输出' }))
+    toggleRow(view)
     expect(view.getByText('line-5')).toBeTruthy()
+    expect(view.getByText('line-19')).toBeTruthy()
+    expect(view.queryByText(/其余/)).toBeNull()
   })
 
   it('renders a multi-line command as one prompt row per line', () => {
     const view = render(<GenericToolCard {...ownerProps(settled({
       callView: callTerminal({ title: 'ls -la\necho done' }),
     }))} />)
-    fireEvent.click(view.container.querySelector('button')!)
+    toggleRow(view)
     const rows = view.container.querySelectorAll('[class^="_promptLine_"]')
     expect([...rows].map(row => row.textContent)).toEqual(['$ls -la', '$echo done'])
     // Still one dot for the call, on the first row.
@@ -275,14 +291,14 @@ describe('chat row terminal body', () => {
       callView: callTerminal({ description: 'Terminal 3' }),
     }))} />)
     expect(view.getByText('Terminal 3')).toBeTruthy()
-    fireEvent.click(view.container.querySelector('button')!)
+    toggleRow(view)
     expect(view.container.querySelector('[data-terminal]')).not.toBeNull()
     expect(view.getByText('Terminal 3')).toBeTruthy()
   })
 
   it('a running terminal call expands to the prompt line with no output yet', () => {
     const view = render(<GenericToolCard {...ownerProps(running())} />)
-    fireEvent.click(view.container.querySelector('button')!)
+    toggleRow(view)
     expect(view.getByText('ls -la')).toBeTruthy()
     expect(view.queryByText('复制')).toBeNull()
     // The card states its own run state: a running command reads as running
@@ -294,7 +310,7 @@ describe('chat row terminal body', () => {
     const view = render(<GenericToolCard {...ownerProps(settled({
       callView: null, resultView: null,
     }))} />)
-    fireEvent.click(view.container.querySelector('button')!)
+    toggleRow(view)
     expect(view.getByText(/"command"/)).toBeTruthy()
   })
 
@@ -303,9 +319,16 @@ describe('chat row terminal body', () => {
     const view = render(<GenericToolCard {...ownerProps(settled({
       call: { name: 'bash', argsRaw: '' },
     }))} />)
-    fireEvent.click(view.container.querySelector('button')!)
+    toggleRow(view)
     expect(view.getByText('a.ts  b.ts', RAW)).toBeTruthy()
   })
+
+  it('a failing exit status surfaces as the collapsed row\'s error state', () => {
+    const view = render(<GenericToolCard {...ownerProps(settled({
+      resultView: resultTerminal({ exitCode: 2 }),
+    }))} />)
+    expect(view.container.querySelector('[data-state]')?.getAttribute('data-state')).toBe('error')
+  })
 })
 
 describe('BashRow terminal card', () => {
@@ -321,14 +344,17 @@ describe('BashRow terminal card', () => {
     sessionId: SID, useSessions: bindSnapshotSelector(list()),
   } as unknown as ToolRowProps)
 
-  it('renders the command output under the summary row, without an expand gesture', () => {
+  it('collapses to the summary row; the whole row toggles the command output', () => {
     const view = render(<BashRow {...rowProps(settled())} />)
     expect(view.getByText('List files')).toBeTruthy()
+    expect(view.queryByText(/a\.ts/)).toBeNull()
+    fireEvent.click(view.container.querySelector('[data-expandable]')!)
     expect(view.getByText('a.ts  b.ts', RAW)).toBeTruthy()
-    // The card's controls are the row's only interactions: a bash row is not a
-    // path link and no longer a details-panel target, so nothing here navigates.
-    expect(view.container.querySelector('[data-clickable]')).toBeNull()
     expect(view.getByText('复制')).toBeTruthy()
+    // Collapse back in place: the summary row returns, the card unmounts.
+    fireEvent.click(view.container.querySelector('[data-expandable]')!)
+    expect(view.queryByText(/a\.ts/)).toBeNull()
+    expect(view.getByText('List files')).toBeTruthy()
   })
 
   // The row's leading StateDot and the card's run-state dot describe the same
@@ -337,13 +363,22 @@ describe('BashRow terminal card', () => {
   it('agrees with the summary row about the run state', () => {
     const runningView = render(<BashRow {...rowProps(running())} />)
     expect(runningView.container.querySelector('[data-variant="bash"]')?.getAttribute('data-state')).toBe('running')
+    fireEvent.click(runningView.container.querySelector('[data-expandable]')!)
     expect(runStateOf(runningView.container)).toBe('ongoing')
     cleanup()
     const settledView = render(<BashRow {...rowProps(settled())} />)
     expect(settledView.container.querySelector('[data-variant="bash"]')?.getAttribute('data-state')).toBe('ok')
+    fireEvent.click(settledView.container.querySelector('[data-expandable]')!)
     expect(runStateOf(settledView.container)).toBe('done')
   })
 
+  it('a failing exit status surfaces as the collapsed row\'s error state', () => {
+    const view = render(<BashRow {...rowProps(settled({
+      resultView: resultTerminal({ exitCode: 2 }),
+    }))} />)
+    expect(view.container.querySelector('[data-variant="bash"]')?.getAttribute('data-state')).toBe('error')
+  })
+
   it('shows the terminal presenter\'s description instead of the args summary', () => {
     // `terminal_send`-style presenters author a description the args do not
     // repeat; the contract puts it above the card, which is this row's summary.

+ 74 - 15
packages/client/ui-primitives/src/TerminalBlock.module.css

@@ -7,6 +7,10 @@
 .block {
   --dsl-terminal-radius: 12px;
   --dsl-terminal-line-height: 22px;
+  /* Rebindable by consumers (CodeBlock's --dsl-code-block-content-font
+     pattern): a surface wanting the smaller code size rebinds this together
+     with --dsl-terminal-line-height on its own container. */
+  --dsl-terminal-font: var(--dsw-font-markdown-code-block);
   /* The card's own left inset, holding the run-state dot in a column of its own
      so it never competes with the commands for horizontal space. */
   --dsl-terminal-gutter: 30px;
@@ -22,26 +26,49 @@
   color: var(--dsw-alias-label-primary);
   background: var(--dsw-alias-markdown-code-block);
   border-radius: var(--dsl-terminal-radius);
+  /* Clip the banner to the card's own radius: when a consumer adds a border,
+     the banner's equal corner radius no longer nests inside it and leaves a
+     notch at the corner. Nothing inside renders out of the box. */
+  overflow: hidden;
 }
 
-/* Top-aligned: the status pill and copy control stay on the first prompt row
-   however many command lines the card carries. */
+/* The status pill and copy control top-align to the FIRST prompt row (their
+   heights are capped to the prompt line, so on a multi-line command they sit
+   with the first command instead of floating mid-banner). */
 .header {
   display: flex;
   align-items: flex-start;
   gap: 12px;
-  /* Pulled back across the card's gutter padding so the banner background and
-     its top-left radius span the FULL surface, then re-inset by the same amount
-     so the prompt text and the dot keep their positions. A plain block child
-     only reaches the content box, which left the gutter column painted in the
-     body color and drew the card's top-left corner in it — invisible in the
-     light theme, where banner and body share a token, and visible in the dark
-     one, where they do not. */
+  /* Pulled back across the card's gutter padding so the banner spans the FULL
+     surface, then re-inset by the same amount so the prompt text and the dot
+     keep their positions. The banner shares the card's own surface (no banner
+     token): the l2 divider below is the section boundary. */
   margin-left: calc(-1 * var(--dsl-terminal-gutter));
   padding: 9px 14px 9px var(--dsl-terminal-gutter);
-  background: var(--dsw-alias-markdown-code-block-banner);
   border-top-left-radius: var(--dsl-terminal-radius);
   border-top-right-radius: var(--dsl-terminal-radius);
+  /* A long multi-line command scrolls inside the banner (same cap as the
+     IN/OUT card's sections) instead of pushing the output off screen. */
+  max-height: 150px;
+  overflow-y: auto;
+}
+
+/* Banner scrollbar floats off the card edge like the output's. */
+.header::-webkit-scrollbar-thumb {
+  border: 2px solid transparent;
+  background-clip: padding-box;
+  border-radius: 6px;
+}
+
+.header::-webkit-scrollbar-track {
+  margin: 6px;
+}
+
+/* Full-width l2 hairline between the command banner and the body — the same
+   divider the IN/OUT card draws between its sections. A running card is
+   banner-only, so it draws none. */
+.block:not([data-running]) .header {
+  border-bottom: 1px solid var(--dsw-alias-border-l2);
 }
 
 /* One row per command line. The prompt column is the only element allowed to
@@ -51,7 +78,7 @@
   flex-direction: column;
   min-width: 0;
   flex: 1;
-  font: var(--dsw-font-markdown-code-block);
+  font: var(--dsl-terminal-font);
 }
 
 .promptLine {
@@ -100,27 +127,59 @@
   white-space: pre;
 }
 
+/* Capped to the prompt's line height (Pill's own 24px height would exceed a
+   smaller-font prompt row and stretch the banner). Sticky against the
+   banner's own scroll so the pill and the copy control stay in reach while a
+   long command scrolls underneath. */
 .status {
   flex: none;
+  position: sticky;
+  top: 0;
+  height: var(--dsl-terminal-line-height);
   color: var(--dsw-alias-state-error-primary);
 }
 
 .copyButton {
   flex: none;
-  background-color: transparent;
+  position: sticky;
+  top: 0;
+  /* Card surface, not transparent: the control is sticky over the banner's
+     own scroll, so scrolled command text must not bleed through it. */
+  background-color: var(--dsw-alias-markdown-code-block);
   border: none;
   padding: 0;
   margin: 0;
   color: var(--dsw-alias-label-secondary);
   cursor: pointer;
   font: var(--dsw-font-xs-13);
+  line-height: var(--dsl-terminal-line-height);
 }
 
+/* Vertical scrolling lives on the OUTPUT, not the card root: a root scroller
+   would run its scrollbar over the banner (and the copy control), while here
+   the banner stays pinned and the bar sits inside the output's right padding.
+   Unset, the max-height is none and the auto overflow never engages. */
 .output {
+  max-height: var(--dsl-terminal-output-max-height, none);
   padding: 12px 14px 12px 0;
-  font: var(--dsw-font-markdown-code-block);
+  font: var(--dsl-terminal-font);
   overflow-x: auto;
-  overflow-y: hidden;
+  overflow-y: auto;
+}
+
+/* Both output scrollbars (vertical cap, horizontal pre overflow) float 2px
+   off the card edge: a transparent border clips the thumb inward so it never
+   hugs the rounded corner. */
+.output::-webkit-scrollbar-thumb {
+  border: 2px solid transparent;
+  background-clip: padding-box;
+  border-radius: 6px;
+}
+
+/* Track end-margins keep the thumb's travel out of the card's rounded
+   corners in both directions. */
+.output::-webkit-scrollbar-track {
+  margin: 6px;
 }
 
 /* No wrapping, no word-break: alignment is the payload of terminal output. */
@@ -147,6 +206,6 @@
 
 .empty {
   padding: 12px 14px 12px 0;
-  font: var(--dsw-font-markdown-code-block);
+  font: var(--dsl-terminal-font);
   color: var(--dsw-alias-label-tertiary);
 }

+ 1 - 1
packages/client/ui-primitives/src/TerminalBlock.tsx

@@ -35,7 +35,7 @@ export interface TerminalBlockProps {
   signal?: string | undefined
   /** The command is still running: the block shows the prompt line alone. */
   running?: boolean | undefined
-  /** Height cap in output lines before the middle collapses (default {@link DEFAULT_TERMINAL_MAX_LINES}). */
+  /** Height cap in output lines before the middle collapses (default {@link DEFAULT_TERMINAL_MAX_LINES}); Infinity disables the cap. */
   maxLines?: number | undefined
   /** Extra class merged onto the wrapper (callers position; this component draws). */
   className?: string | undefined

+ 16 - 7
packages/client/ui-question/src/client/QuestionComposer.module.css

@@ -139,7 +139,11 @@
 
 .option {
   display: flex;
-  align-items: center;
+  /* flex-start, not center: with a wrapped description the indicator must
+     stay on the FIRST line (centering drifts it down the taller copy block).
+     The 8px padding makes a single-line row 40px exactly, so nothing reads
+     as top-heavy; .number/.checkbox re-center against the first line box. */
+  align-items: flex-start;
   gap: 8px;
   width: 100%;
   min-height: 40px;
@@ -148,7 +152,7 @@
      intrinsic height, and centered content then paints outside the row box —
      over the title and the next row. Overflow belongs to .options. */
   flex-shrink: 0;
-  padding: 6px 12px 6px 8px;
+  padding: 8px 12px 8px 8px;
   border: 1px solid transparent;
   border-radius: 12px;
   background: transparent;
@@ -180,6 +184,9 @@
   flex: 0 0 20px;
   width: 20px;
   height: 20px;
+  /* (24px first-line box − 20px indicator) / 2: centers the indicator against
+     the first text line under the row's flex-start alignment. */
+  margin-top: 2px;
   border-radius: 6px;
   background: var(--dsw-alias-bg-overlay);
   color: var(--dsw-alias-label-secondary);
@@ -197,6 +204,8 @@
   flex: 0 0 20px;
   width: 20px;
   height: 20px;
+  /* Same first-line centering as .number under flex-start alignment. */
+  margin-top: 2px;
 }
 
 .checkbox::before {
@@ -263,14 +272,16 @@
    inline text input; focus or a typed draft lifts it to the selected look. */
 .customRow {
   display: flex;
-  align-items: center;
+  /* Same first-line alignment as .option — the indicator seat carries the
+     2px re-centering margin. */
+  align-items: flex-start;
   gap: 8px;
   width: 100%;
   min-height: 40px;
   /* Same reason as .option: the custom row is scroll content, and shrinking
      it pushes the inline input past the footer. */
   flex-shrink: 0;
-  padding: 6px 12px 6px 8px;
+  padding: 8px 12px 8px 8px;
   border: 1px solid transparent;
   border-radius: 12px;
   transition: background-color 120ms ease, border-color 120ms ease;
@@ -378,9 +389,7 @@
 
   .option,
   .customRow {
-    align-items: flex-start;
-    gap: 8px;
-    padding: 6px;
+    padding: 8px 6px;
   }
 
   .footer {

+ 8 - 0
packages/client/ui-theme/src/styles/gradient-shadow-text.css

@@ -131,6 +131,14 @@ body {
   --dsw-font-markdown-code-block-font-size: 13px;
   --dsw-font-markdown-code-block-font-style: normal;
 
+  /* 手工补充(非插件导出):tool row 展开卡片内的小号 code 字体。 */
+  --dsw-font-markdown-code-block-small: 12px/18px var(--ds-font-family-code);
+  --dsw-font-markdown-code-block-small-font-family: var(--ds-font-family-code);
+  --dsw-font-markdown-code-block-small-font-weight: 400;
+  --dsw-font-markdown-code-block-small-line-height: 18px;
+  --dsw-font-markdown-code-block-small-font-size: 12px;
+  --dsw-font-markdown-code-block-small-font-style: normal;
+
   --dsw-font-xl-24: 600 24px/32px var(--dsw-font-family);
   --dsw-font-xl-24-font-family: var(--dsw-font-family);
   --dsw-font-xl-24-font-weight: 600;

+ 36 - 1
packages/client/ui-trajectory/src/client/TrajectoryTable.tsx

@@ -235,6 +235,10 @@ export interface TrajectoryTableProps {
   collapsedAssistants: ReadonlySet<number>
   /** Toggle tool calls under one assistant record. */
   onToggleAssistant: (index: number) => void
+  /** One-shot cross-view inspect: open and scroll to this call's record. */
+  inspectCallId?: string | null
+  /** Acknowledge a consumed (or unresolvable) inspect request. */
+  onInspectApplied?: (() => void) | undefined
 }
 
 /** One request identity paired with its session-global number. */
@@ -1402,6 +1406,8 @@ export function TrajectoryTable({
   onToggleTurn,
   collapsedAssistants,
   onToggleAssistant,
+  inspectCallId = null,
+  onInspectApplied,
 }: TrajectoryTableProps) {
   const [selectedIndex, setSelectedIndex] = useState<number | null>(null)
   const [selectedRequest, setSelectedRequest] = useState<SelectedRequest | null>(null)
@@ -1574,8 +1580,37 @@ export function TrajectoryTable({
     if (target !== undefined) openRecordSummary(target)
   }
 
+  // Cross-view inspect handoff: resolve the requested call to its record,
+  // open its summary, and remember the row to scroll once the un-collapsed
+  // ledger has rendered. Not-found leaves the request pending (`turns` in the
+  // deps retries as history pages in); the ack clears the store field.
+  const rootRef = useRef<HTMLDivElement>(null)
+  const pendingScrollIndex = useRef<number | null>(null)
+  const openRecordSummaryRef = useRef(openRecordSummary)
+  openRecordSummaryRef.current = openRecordSummary
+  useEffect(() => {
+    if (inspectCallId === null) return
+    const target = flattenRecords(turns).find(record => record.cell.callId === inspectCallId)
+    if (target === undefined) return
+    openRecordSummaryRef.current(target)
+    pendingScrollIndex.current = target.cell.index
+    onInspectApplied?.()
+  }, [inspectCallId, turns, onInspectApplied])
+  useEffect(() => {
+    const index = pendingScrollIndex.current
+    if (index === null) return
+    const row = rootRef.current
+      ?.querySelector<HTMLElement>(`tr[data-record-index="${index}"]`)
+    if (row === undefined || row === null) return
+    pendingScrollIndex.current = null
+    /* v8 ignore next -- jsdom lacks scrollIntoView; browsers always have it. */
+    if (typeof row.scrollIntoView === 'function') {
+      row.scrollIntoView({ behavior: 'smooth', block: 'center' })
+    }
+  })
+
   return (
-    <div className={css.split} style={splitStyle}>
+    <div ref={rootRef} className={css.split} style={splitStyle}>
       <div
         className={css.tablePane}
         onClick={(event) => {

+ 3 - 1
packages/client/ui-trajectory/src/client/TrajectoryView.tsx

@@ -134,7 +134,7 @@ function searchMatches(
 }
 
 export function TrajectoryView({
-  useHistory, loadAllHistory,
+  useHistory, loadAllHistory, inspect, onInspectDone,
 }: ConvViewProps & InjectFace<TrajectoryViewInjected>) {
   const [collapsedTurns, setCollapsedTurns] = useState<ReadonlySet<number>>(EMPTY_IDS)
   const [collapsedAssistants, setCollapsedAssistants] =
@@ -502,6 +502,8 @@ export function TrajectoryView({
           onToggleTurn={toggleTurn}
           collapsedAssistants={collapsedAssistants}
           onToggleAssistant={toggleAssistant}
+          inspectCallId={inspect?.callId ?? null}
+          onInspectApplied={onInspectDone}
         />
       </div>
     </div>

+ 46 - 0
packages/client/ui-trajectory/tests/table.spec.tsx

@@ -187,4 +187,50 @@ describe('TrajectoryTable', () => {
     expect(screen.getByRole('row', { name: /ASSISTANT/ })).toBeTruthy()
     expect(screen.getByRole('row', { name: /Collapsed turn summary/ })).toBeTruthy()
   })
+
+  const CALL_TURNS: readonly TrajectoryTurnModel[] = [{
+    turn: 1,
+    groups: [{
+      title: 'Step 1',
+      cells: [{
+        index: 1,
+        kind: 'tool',
+        text: 'bash · {"command":"pwd"}',
+        inputDetail: '{"command":"pwd"}',
+        callId: 'call-1',
+        timeSeconds: 0.1,
+      }],
+    }],
+  }]
+
+  it('an inspect target opens the matching record and acknowledges once', () => {
+    const onInspectApplied = vi.fn()
+    render(
+      <TrajectoryTable
+        turns={CALL_TURNS}
+        {...FOLD_PROPS}
+        inspectCallId="call-1"
+        onInspectApplied={onInspectApplied}
+      />,
+    )
+
+    expect(screen.getByRole('row', { name: /TOOL/ }).getAttribute('aria-selected')).toBe('true')
+    expect(screen.getByRole('complementary', { name: 'Event details' })).toBeTruthy()
+    expect(onInspectApplied).toHaveBeenCalledOnce()
+  })
+
+  it('an unmatched inspect target stays pending without acknowledgement', () => {
+    const onInspectApplied = vi.fn()
+    render(
+      <TrajectoryTable
+        turns={CALL_TURNS}
+        {...FOLD_PROPS}
+        inspectCallId="call-missing"
+        onInspectApplied={onInspectApplied}
+      />,
+    )
+
+    expect(screen.getByRole('row', { name: /TOOL/ }).getAttribute('aria-selected')).toBe('false')
+    expect(onInspectApplied).not.toHaveBeenCalled()
+  })
 })