From 78e4d36214a2ef3c5d85ac5fe5c874e77ed3ce19 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Thu, 30 Jul 2026 21:21:29 +0800 Subject: [PATCH] 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. --- ...l-row-unified-expand-and-inspect.i18n.yaml | 6 + ...web-tool-row-unified-expand-and-inspect.md | 34 +++ ...-tool-row-unified-expand-and-inspect.zh.md | 34 +++ .../ui-conversation/src/client/apply.ts | 18 ++ .../src/client/chat/AssistantMarkdown.tsx | 1 - .../src/client/chat/ChatView.tsx | 42 +++- .../src/client/chat/GenericCommandCard.tsx | 2 +- .../src/client/chat/GenericToolCard.tsx | 17 +- .../src/client/chat/MessageItem.tsx | 32 ++- .../src/client/chat/ToolRow.module.css | 178 +++++++++++++-- .../src/client/chat/ToolRow.tsx | 214 ++++++++++++------ .../src/client/contract/slots.ts | 32 ++- .../client/contract/terminal-card-model.ts | 25 +- .../src/client/contract/tool-call-model.ts | 41 +++- .../src/client/contract/views.ts | 6 + .../client/skeleton/ConversationSession.tsx | 7 +- .../src/client/skeleton/DetailsPanel.tsx | 17 +- .../ui-conversation/src/client/stores.ts | 6 +- .../src/client/toolviews/ask-question-row.tsx | 9 +- .../client/toolviews/bash-sample.module.css | 96 +++++++- .../src/client/toolviews/bash-sample.tsx | 94 ++++++-- .../src/client/toolviews/todo-row.tsx | 16 +- .../tests/assembly-surfaces.spec.tsx | 17 +- .../tests/chat-branch-tails.spec.tsx | 27 ++- .../tests/chat-code-subcalls.spec.tsx | 6 +- .../ui-conversation/tests/chat-store.spec.ts | 7 +- .../tests/chat-tool-row.spec.tsx | 165 ++++++++++++-- .../tests/chat-toolview-slot.spec.tsx | 2 +- .../ui-conversation/tests/chat-view.spec.tsx | 74 +++++- .../tests/selection-survival.spec.tsx | 2 +- .../tests/terminal-card.spec.tsx | 73 ++++-- .../src/TerminalBlock.module.css | 89 ++++++-- .../ui-primitives/src/TerminalBlock.tsx | 2 +- .../src/client/QuestionComposer.module.css | 23 +- .../src/styles/gradient-shadow-text.css | 8 + .../src/client/TrajectoryTable.tsx | 37 ++- .../src/client/TrajectoryView.tsx | 4 +- .../client/ui-trajectory/tests/table.spec.tsx | 46 ++++ 38 files changed, 1241 insertions(+), 268 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-30-web-tool-row-unified-expand-and-inspect.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-30-web-tool-row-unified-expand-and-inspect.md create mode 100644 .agents/notes/implemented/feature/2026-07-30-web-tool-row-unified-expand-and-inspect.zh.md diff --git a/.agents/notes/implemented/feature/2026-07-30-web-tool-row-unified-expand-and-inspect.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-tool-row-unified-expand-and-inspect.i18n.yaml new file mode 100644 index 0000000000..455c89ebcd --- /dev/null +++ b/.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 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-tool-row-unified-expand-and-inspect.md b/.agents/notes/implemented/feature/2026-07-30-web-tool-row-unified-expand-and-inspect.md new file mode 100644 index 0000000000..ba2f4ead80 --- /dev/null +++ b/.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` 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. diff --git a/.agents/notes/implemented/feature/2026-07-30-web-tool-row-unified-expand-and-inspect.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-tool-row-unified-expand-and-inspect.zh.md new file mode 100644 index 0000000000..ac4835c742 --- /dev/null +++ b/.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`,经注入 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 下预览无法启动的问题。 diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index c6b597ae79..903a12f556 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/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() + 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) diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx index 904aeee0d8..c931e389f5 100644 --- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx +++ b/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 /> ) } diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index b9e1e3351f..0909ac0811 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/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 * 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 (
{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 (
{renderSlot('conversation.chat.toolview', owner, { @@ -120,6 +126,7 @@ const CallRow = memo(function CallRow({ openFile={openFile} selected={node.callId === selectedCallId} cwd={cwd} + inspectCall={inspectCall} /> ))}
@@ -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 /** Session workspace root for path-relative summaries. */ cwd: string | undefined + inspectCall: InspectCall }) { return (
@@ -154,6 +162,7 @@ const ToolGroup = memo(function ToolGroup({ renderSlot, results, openFile, selec subCalls={codeDispatches.get(node.callId)} selectedCallId={selectedCallId} cwd={cwd} + inspectCall={inspectCall} /> ))}
@@ -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} /> ))}
diff --git a/packages/client/ui-conversation/src/client/chat/GenericCommandCard.tsx b/packages/client/ui-conversation/src/client/chat/GenericCommandCard.tsx index 1dfea5488b..3c83483fd3 100644 --- a/packages/client/ui-conversation/src/client/chat/GenericCommandCard.tsx +++ b/packages/client/ui-conversation/src/client/chat/GenericCommandCard.tsx @@ -27,7 +27,7 @@ export function GenericCommandCard({ node }: CommandRowOwnerProps) { return ( } + icon={} title={title} summary={summary} // Expandable only when the outcome text overflows a one-line summary. diff --git a/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx b/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx index ce55d84f57..d064045968 100644 --- a/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx +++ b/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 = { others: , } -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 ( ) } diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index a149d37337..22f31dde7c 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/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) ) } - 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 (
- + } + title="上下文注入" + summary="" + body={body} + plainBody + state="ok" + />
) + } default: return (
diff --git a/packages/client/ui-conversation/src/client/chat/ToolRow.module.css b/packages/client/ui-conversation/src/client/chat/ToolRow.module.css index 08031f570c..3332882518 100644 --- a/packages/client/ui-conversation/src/client/chat/ToolRow.module.css +++ b/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); } diff --git a/packages/client/ui-conversation/src/client/chat/ToolRow.tsx b/packages/client/ui-conversation/src/client/chat/ToolRow.tsx index fe0df556ce..4182f72a52 100644 --- a/packages/client/ui-conversation/src/client/chat/ToolRow.tsx +++ b/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 ( + + + + ) } /** 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) => { - event.stopPropagation() - toggleExpand() - } const toggleFromKeyboard = (event: KeyboardEvent) => { - 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 ? ( <> - {icon} - + {idleLeading} + ) - : icon + : idleLeading const leading = open - ? - : leadingFor(state, collapsedIcon) + ? + : collapsedIcon return (
- {expandable && !rowExpands ? ( - - ) : ( - - {leading} - - )} + + {leading} + {title} - {!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 !== '' && ( <> {fileLink ? ( @@ -147,25 +173,71 @@ export function ToolRow({ className={css.fileLink} onClick={openFile} > - {summary} + {summaryText} ) : ( - {summary} + + {summaryText} + )} )}
- {/* 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 && ( -
{terminalBody.description}
+ {open && ( + /* The wrapper (sibling of .row, so clicks inside never toggle the + row) carries the expanded body and the Inspect pill below it. */ +
+ {terminalBody !== null + ? + : isThink + ?
{body}
+ : ( + <> + {variant === 'code' && body !== null && ( +
+ +
+ )} + {plainBody === true && cardBody !== null && ( +
+ {cardBody} +
+ )} + {plainBody !== true && (cardBody !== null || outputText !== null) && ( +
+ {cardBody !== null && ( +
+ IN + {cardBody} +
+ )} + {cardBody !== null && outputText !== null && ( + + )} + {outputText !== null && ( +
+ OUT + + {outputText} + +
+ )} +
+ )} + + )} + {inspect !== undefined && ( + + )} +
)} - {open && (terminalBody !== null - ? - : variant === 'code' - ? - :
{text}
)}
) } diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 1684e616b2..8e20f4054f 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/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. */ diff --git a/packages/client/ui-conversation/src/client/contract/terminal-card-model.ts b/packages/client/ui-conversation/src/client/contract/terminal-card-model.ts index e25d68cbbb..8d7750c3ba 100644 --- a/packages/client/ui-conversation/src/client/contract/terminal-card-model.ts +++ b/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 diff --git a/packages/client/ui-conversation/src/client/contract/tool-call-model.ts b/packages/client/ui-conversation/src/client/contract/tool-call-model.ts index b53ef95c01..d7735fdab2 100644 --- a/packages/client/ui-conversation/src/client/contract/tool-call-model.ts +++ b/packages/client/ui-conversation/src/client/contract/tool-call-model.ts @@ -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, } } diff --git a/packages/client/ui-conversation/src/client/contract/views.ts b/packages/client/ui-conversation/src/client/contract/views.ts index 9ef9515f19..a8da4121b9 100644 --- a/packages/client/ui-conversation/src/client/contract/views.ts +++ b/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 } diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx index 33b24f5245..d335cd9218 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx +++ b/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 : (
- {active !== undefined && renderSlot('conversation.view', {}, { only: active.id })} + {active !== undefined && renderSlot('conversation.view', { + inspect, + onInspectDone: () => { actions.setInspect(null) }, + }, { only: active.id })}
) diff --git a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx b/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx index 9fc5a04ff6..fb0b69624e 100644 --- a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx +++ b/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 (
-      {renderResult(result)}
+      {resultText(result)}
     
) } - -/** 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') -} diff --git a/packages/client/ui-conversation/src/client/stores.ts b/packages/client/ui-conversation/src/client/stores.ts index 4c27a87ed8..8a1cd6f068 100644 --- a/packages/client/ui-conversation/src/client/stores.ts +++ b/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 { 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 { if (d.draft === '') d.draft = text }, setView: (d, view: string) => { d.view = view }, + setInspect: (d, target: { callId: CallId } | null) => { d.inspect = target }, }, }) } diff --git a/packages/client/ui-conversation/src/client/toolviews/ask-question-row.tsx b/packages/client/ui-conversation/src/client/toolviews/ask-question-row.tsx index 97d24088cd..946151cee2 100644 --- a/packages/client/ui-conversation/src/client/toolviews/ask-question-row.tsx +++ b/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} /> ) } diff --git a/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css b/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css index e9ee5286dc..fa607a0880 100644 --- a/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css +++ b/packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css @@ -1,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; diff --git a/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx b/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx index c9385cab04..1f979e899c 100644 --- a/packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx +++ b/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) => { + if (!expandable || (event.key !== 'Enter' && event.key !== ' ')) return + event.preventDefault() + toggleExpand() + } + const leading = open + ? + : expandable + ? ( + <> + {leadingFor(state)} + + + ) + : leadingFor(state) return (
- {leadingFor(model.state)} + {leading} {status !== null && {status}} {isChild && scoped} {model.title} {/* The terminal presenter's description is the contractual - above-card summary; it outranks the args-derived one. */} - {terminal?.description ?? model.summary} + above-card summary; a failure's first line outranks both. */} + + {failureLine ?? terminal?.description ?? model.summary} +
- {terminal !== null && ( - + {terminal !== null && open && ( + /* Same hover-Inspect posture as ToolRow's expanded body, replicated + locally per the registrant posture. */ +
+ + {inspect !== undefined && ( + + )} +
)}
) diff --git a/packages/client/ui-conversation/src/client/toolviews/todo-row.tsx b/packages/client/ui-conversation/src/client/toolviews/todo-row.tsx index 8a1f20e3e5..22b51b1890 100644 --- a/packages/client/ui-conversation/src/client/toolviews/todo-row.tsx +++ b/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} /> ) } diff --git a/packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx b/packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx index 2d143764bb..7201b89bc3 100644 --- a/packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx +++ b/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() }) diff --git a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx index fc5a94af61..9dc75715c4 100644 --- a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx +++ b/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( + , + ) + 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( , ) - 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( , ) diff --git a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx index bd1ac63f1f..3f04f9ad70 100644 --- a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx +++ b/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
:
diff --git a/packages/client/ui-conversation/tests/chat-store.spec.ts b/packages/client/ui-conversation/tests/chat-store.spec.ts
index 50ec5542ef..17d90cfbde 100644
--- a/packages/client/ui-conversation/tests/chat-store.spec.ts
+++ b/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)', () => {
diff --git a/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx b/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx
index 546b4a18e8..e4e5af28d0 100644
--- a/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx
+++ b/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()
-    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()
     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()
-    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()
+  it('the row toggles from Enter and Space, ignoring other keys', () => {
+    const view = render()
     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()
-    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(
       ,
     )
+    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(
       ,
     )
     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(
+      ,
+    )
+    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()
+    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(
+      ,
+    )
+    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()
+    // 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()
+    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()
+    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()
+    fireEvent.click(inputOnly.getByRole('button'))
+    expect(inputOnly.getByText('IN')).toBeTruthy()
+    expect(inputOnly.queryByText('OUT')).toBeNull()
+    cleanup()
+    const outputOnly = render()
+    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(
+      ,
+    )
+    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()
+    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()
diff --git a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx
index ccf940d430..2d1e4b25e5 100644
--- a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx
+++ b/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()
   })
diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx
index c0cb4dcb78..1462b2f9c5 100644
--- a/packages/client/ui-conversation/tests/chat-view.spec.tsx
+++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx
@@ -94,6 +94,13 @@ function makeHarness(init?: Partial) {
   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) {
     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()
+    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()
     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(, { 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(
) + host.scrollTop = 0 + view.rerender() + 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(, { container: host }) + // At the bottom: the scroll event records the pinned state (null). + fireEvent.scroll(host) + expect(h.chatScroll.read()).toBeNull() + view.rerender(
) + host.scrollTop = 0 + view.rerender() + 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() diff --git a/packages/client/ui-conversation/tests/selection-survival.spec.tsx b/packages/client/ui-conversation/tests/selection-survival.spec.tsx index 70162105a9..4559618a8a 100644 --- a/packages/client/ui-conversation/tests/selection-survival.spec.tsx +++ b/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() }) }) diff --git a/packages/client/ui-conversation/tests/terminal-card.spec.tsx b/packages/client/ui-conversation/tests/terminal-card.spec.tsx index 968c1e4461..ef470162f6 100644 --- a/packages/client/ui-conversation/tests/terminal-card.spec.tsx +++ b/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() // 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() - 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() - 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() - 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() - 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() - 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() + 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() 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() 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() 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() + 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. diff --git a/packages/client/ui-primitives/src/TerminalBlock.module.css b/packages/client/ui-primitives/src/TerminalBlock.module.css index 704ea0e808..0d3ff07d3b 100644 --- a/packages/client/ui-primitives/src/TerminalBlock.module.css +++ b/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); } diff --git a/packages/client/ui-primitives/src/TerminalBlock.tsx b/packages/client/ui-primitives/src/TerminalBlock.tsx index c707711f69..1b30a7f58f 100644 --- a/packages/client/ui-primitives/src/TerminalBlock.tsx +++ b/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 diff --git a/packages/client/ui-question/src/client/QuestionComposer.module.css b/packages/client/ui-question/src/client/QuestionComposer.module.css index 7b5cc1d1d8..c96aefa0ef 100644 --- a/packages/client/ui-question/src/client/QuestionComposer.module.css +++ b/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 { diff --git a/packages/client/ui-theme/src/styles/gradient-shadow-text.css b/packages/client/ui-theme/src/styles/gradient-shadow-text.css index aea8d9cb83..e062b4ba93 100644 --- a/packages/client/ui-theme/src/styles/gradient-shadow-text.css +++ b/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; diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx index 09ad60b427..a979be2642 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx @@ -235,6 +235,10 @@ export interface TrajectoryTableProps { collapsedAssistants: ReadonlySet /** 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(null) const [selectedRequest, setSelectedRequest] = useState(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(null) + const pendingScrollIndex = useRef(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(`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 ( -
+
{ diff --git a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx index c7f4e193da..6946f747ff 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx +++ b/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) { const [collapsedTurns, setCollapsedTurns] = useState>(EMPTY_IDS) const [collapsedAssistants, setCollapsedAssistants] = @@ -502,6 +502,8 @@ export function TrajectoryView({ onToggleTurn={toggleTurn} collapsedAssistants={collapsedAssistants} onToggleAssistant={toggleAssistant} + inspectCallId={inspect?.callId ?? null} + onInspectApplied={onInspectDone} />
diff --git a/packages/client/ui-trajectory/tests/table.spec.tsx b/packages/client/ui-trajectory/tests/table.spec.tsx index fb21ca45b7..8d5b321064 100644 --- a/packages/client/ui-trajectory/tests/table.spec.tsx +++ b/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( + , + ) + + 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( + , + ) + + expect(screen.getByRole('row', { name: /TOOL/ }).getAttribute('aria-selected')).toBe('false') + expect(onInspectApplied).not.toHaveBeenCalled() + }) })