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.
This commit is contained in:
Yif
2026-07-30 21:21:29 +08:00
parent 86fa88a012
commit 78e4d36214
38 changed files with 1241 additions and 268 deletions
@@ -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
@@ -0,0 +1,34 @@
# Agent Note: Web tool-row unified expand and trajectory Inspect
Status: implemented
English | [中文](2026-07-30-web-tool-row-unified-expand-and-inspect.zh.md)
## Problem
The chat view's tool rows had drifted into per-surface interaction dialects: ToolRow expanded through a leading-icon toggle and only for calls with an args body, the bash sample had its own expand affordance, todo/ask-question rows expanded raw args only, single-file tools were not expandable at all, and a call's OUTPUT was reachable only through the details panel. A failing bash command (exit≠0 settles `isError:false`) showed no collapsed-row failure signal. There was also no path from a chat row to its trajectory record, and switching chat → trajectory → chat lost the reader's scroll position because the tab ring unmounts inactive views.
## Decision
**Every expandable tool row shares one interaction — the whole row toggles (click / Enter / Space) with an icon→chevron hover preview — and one expanded body: an IN/OUT gutter-labeled card with per-section scroll caps; a hover-revealed Inspect pill jumps to the call's trajectory record through a one-shot store handoff; the chat view preserves its scroll offset across view switches through an in-memory per-session map.**
- `toolRowModel` now derives result material alongside args: `output` (the `resultText` flatten, moved from DetailsPanel into the contract), and `errorSummary` (the failure's first line, shown as the collapsed summary in the error color). A row with body, output, or terminal material is expandable; the row itself is the toggle (`role="button"`, `aria-expanded`), and file-path summaries stay independent links via `stopPropagation`.
- The expanded card (figma 1249:35657) is a column of IN/OUT sections: each section is its own scrollport (max-height 150px) with a sticky gutter label, and the l2 divider spans the full card width. Think prose and the run_code CodeBlock keep their non-card bodies; context injection reuses the row with a label-less `plainBody` card.
- `terminalFailed` reads a settled terminal card's exit status so BashRow and GenericToolCard surface a failing command as the row's red state dot — the only failure signal the collapsed row has, since the call itself settles `isError:false`.
- TerminalBlock's banner joins the same reading model: it shares the card surface (no banner token), an l2 hairline separates it from the body, the command column caps at 150px and scrolls with sticky copy/status controls top-aligned to the first prompt row.
- Inspect: `ToolRowOwnerProps.inspect` (absent for rows without a call identity) renders a pill in real flow under the expanded body's bottom-left, revealed by hovering anywhere on the tool call. Clicking writes `{ callId }` to the chat store's one-shot `inspect` field and switches to the trajectory view; TrajectoryTable finds the record, opens its summary, and acknowledges by clearing the field.
- Scroll preservation: the chat view saves its offset on every scroll (null when pinned to bottom) into an apply-scope `Map<SessionId, number>` exposed as `chatScroll` on the injected props; the open-jump branch restores it on remount. Deliberately not persisted — a fresh page load keeps the open-jump-to-bottom default.
## Alternatives considered
**Keeping the leading-icon toggle and per-registrant expand affordances.** Rejected: three surfaces had already diverged; the registrant posture (bash sample replicates CSS locally) makes drift permanent unless the interaction contract itself is uniform and small — whole-row toggle plus hover preview.
**Routing Inspect through a URL or a trajectory-view prop.** Rejected: the view ring renders through the slot registry, so the two views share no parent that could carry a prop; the chat store already crosses that boundary and the one-shot field keeps the handoff replay-safe (persisted snapshots from before the field rehydrate with `?? null`).
**Persisting the chat scroll offset.** Rejected: restoring a days-old offset into a conversation that has since grown reads as a bug; the in-memory map scopes the memory to exactly the view-switch case that loses it.
**A per-row expanded OUTPUT fetched from the details panel's material.** Unnecessary: the settled result node already rides the snapshot's frozen call slice, so the contract-level `resultText` flatten serves both the row and the panel from one derivation.
## Consequences
Any registered toolview gets input AND output inspection in place, with the details panel and trajectory remaining the deep-dive surfaces. The unified interaction is contract-visible (`ToolRowProps.output/errorSummary/inspect`), so third-party rows opt in by passing model fields through. The bash sample intentionally re-replicates the new CSS (registrant posture), so future interaction changes still touch it by hand. `--dsw-font-markdown-code-block-small` (12/18) is a hand-added token pending a design-platform export. The web-cordis `distIndex` fix (plain concatenation, not URL.pathname) unblocks preview boots from a cwd with spaces.
@@ -0,0 +1,34 @@
# Agent NoteWeb 工具行统一展开交互与 trajectory Inspect
状态:已实现
[English](2026-07-30-web-tool-row-unified-expand-and-inspect.md) | 中文
## 问题
聊天视图的工具行交互已经分裂成多种方言:ToolRow 通过前导图标切换展开、且仅限有 args body 的调用,bash 示例有自己的一套展开方式,todo / ask-question 行只能展开原始 args,单文件工具完全不可展开,而调用的 OUTPUT 只能通过右侧详情面板查看。失败的 bash 命令(exit≠0 但结算为 `isError:false`)在折叠行上没有任何失败信号。此外聊天行没有跳转到 trajectory 记录的入口,且 chat → trajectory → chat 切换会丢失阅读位置(标签环会卸载非活跃视图)。
## 决定
**所有可展开工具行共享同一交互——整行即开关(点击 / Enter / 空格),图标 hover 时渐变为 chevron 预览——以及同一展开体:带 IN/OUT 侧栏标签的卡片,各分区独立滚动上限;hover 显示的 Inspect 胶囊通过 store 的一次性交接跳到该调用的 trajectory 记录;聊天视图用内存态的按会话 Map 在视图切换间保留滚动位置。**
- `toolRowModel` 在 args 之外同时派生结果材料:`output``resultText` 拍平逻辑从 DetailsPanel 移入 contract)和 `errorSummary`(失败首行,以错误色作为折叠摘要)。有 body、output 或 terminal 材料的行即可展开;行本身是开关(`role="button"``aria-expanded`),文件路径摘要通过 `stopPropagation` 保持独立链接。
- 展开卡片(figma 1249:35657)是 IN/OUT 分区列:每个分区是独立滚动区(max-height 150px),侧栏标签 sticky 固定,l2 分割线横贯整卡宽度。Think 的推理文本和 run_code 的 CodeBlock 保持非卡片体;上下文注入复用此行并以无标签的 `plainBody` 卡片展开。
- `terminalFailed` 读取已结算 terminal 卡片的退出状态,让 BashRow 和 GenericToolCard 把失败命令显示为行的红色状态点——这是折叠行唯一的失败信号,因为调用本身结算为 `isError:false`
- TerminalBlock 的横幅并入同一阅读模型:与卡片共用同一表面(不再用 banner token),与正文之间是 l2 细线,命令列上限 150px 内部滚动,复制/状态控件 sticky 且顶对齐第一行提示符。
- Inspect`ToolRowOwnerProps.inspect`(无调用身份的行不提供)在展开体左下角以真实布局位置渲染胶囊,hover 整个 tool call 任意位置显示。点击将 `{ callId }` 写入 chat store 的一次性 `inspect` 字段并切换到 trajectory 视图;TrajectoryTable 找到记录、打开其摘要,并通过清空字段确认。
- 滚动保留:聊天视图在每次滚动时保存偏移(贴底时为 null)到 apply 作用域的 `Map<SessionId, number>`,经注入 props 的 `chatScroll` 暴露;重挂载时 open-jump 分支恢复它。刻意不持久化——新页面加载保持打开即贴底的默认行为。
## 曾考虑的替代方案
**保留前导图标开关和各注册方自有的展开方式。** 否决:三个表面已经分化;注册方姿态(bash 示例本地复刻 CSS)意味着除非交互契约本身统一且足够小——整行开关加 hover 预览——否则漂移会永久存在。
**通过 URL 或 trajectory 视图 prop 传递 Inspect。** 否决:视图环经由 slot 注册表渲染,两个视图没有可携带 prop 的共同父级;chat store 本就跨越该边界,一次性字段让交接可安全重放(字段出现之前的持久化快照以 `?? null` 复水)。
**持久化聊天滚动偏移。** 否决:把几天前的偏移恢复到已经增长的会话里读起来像 bug;内存 Map 把记忆精确限定在会丢位置的视图切换场景。
**从详情面板的材料为每行单独取展开 OUTPUT。** 不必要:已结算结果节点本就在快照的冻结调用切片上,contract 层的 `resultText` 拍平让行和面板共用一份派生。
## 后果
任何已注册 toolview 都能就地查看输入与输出,详情面板和 trajectory 仍是深查表面。统一交互契约可见(`ToolRowProps.output/errorSummary/inspect`),第三方行透传模型字段即可接入。bash 示例有意重新复刻新 CSS(注册方姿态),未来交互变更仍需手动同步它。`--dsw-font-markdown-code-block-small`12/18)是手工补充的 token,待设计平台导出后替换。web-cordis 的 `distIndex` 修复(纯拼接而非 URL.pathname)解除了含空格 cwd 下预览无法启动的问题。
@@ -98,6 +98,11 @@ export function apply(ctx: Context): void {
// Apply-time construction keeps store identity bound to this fiber.
const chatStore = createChatStore()
// Chat scroll offsets by session, surviving view switches (the chat view
// unmounts under the tab ring). Deliberately not persisted: a fresh page
// load should keep the open-jump-to-bottom default.
const chatScrollTops = new Map<SessionId, number>()
const viewTabs = (): ViewTab[] => {
const tabs: ViewTab[] = []
for (const entry of slots.entries('conversation.view')) {
@@ -262,6 +267,19 @@ export function apply(ctx: Context): void {
})
},
loadOlder: () => { void scoped.loadOlder() },
// Unregistered 'trajectory' id is safe: the tab ring falls back to
// the first view, and the untouched inspect target stays inert.
inspectCall: (callId) => {
actions.setInspect({ callId })
actions.setView('trajectory')
},
chatScroll: {
save: (top) => {
if (top === null) chatScrollTops.delete(sessionId)
else chatScrollTops.set(sessionId, top)
},
read: () => chatScrollTops.get(sessionId) ?? null,
},
}
},
}, ChatView)
@@ -54,7 +54,6 @@ function ThinkRow({ text, running }: { text: string; running: boolean }) {
summary={firstLine(text)}
body={text}
state={running ? 'running' : 'ok'}
expandOnRowClick
/>
)
}
@@ -46,6 +46,8 @@ function scrollerOf(from: HTMLElement): HTMLElement {
type OpenFile = (path: string) => void
type InspectCall = (callId: string) => void
/** The declared toolview hole's render share (stable framework binding, passed through memoized rows). */
type RenderToolRow = ChatViewSlotProps['renderSlot']
@@ -57,18 +59,20 @@ type UseConversation = SnapshotSelectorHook<ConversationSnapshot>
* top-level call (same registrations, same fallback), nested by the parent.
* A started-but-unsettled sub-call arrives as the RunningToolCall shape and
* renders the running state exactly as a native in-flight row. */
const SubCallRow = memo(function SubCallRow({ renderSlot, node, openFile, selected, cwd }: {
const SubCallRow = memo(function SubCallRow({ renderSlot, node, openFile, selected, cwd, inspectCall }: {
renderSlot: RenderToolRow
node: CodeSubCall
openFile: OpenFile
selected: boolean
cwd: string | undefined
inspectCall: InspectCall
}) {
const settled = 'kind' in node
const toolName = settled ? node.call?.name ?? '' : node.name
const owner = useMemo(() => ({
callId: node.callId, toolName, block: node, openFile, cwd,
}), [node, toolName, openFile, cwd])
inspect: () => { inspectCall(node.callId) },
}), [node, toolName, openFile, cwd, inspectCall])
return (
<div className={css.callRow} data-selected={selected || undefined}>
{renderSlot('conversation.chat.toolview', owner, {
@@ -85,7 +89,7 @@ const SubCallRow = memo(function SubCallRow({ renderSlot, node, openFile, select
* renders its logged sub-dispatches as always-visible indented rows —
* each one the same keyed-slot dispatch as a native top-level call. */
const CallRow = memo(function CallRow({
renderSlot, callId, toolName, block, openFile, selected, subCalls, selectedCallId, cwd,
renderSlot, callId, toolName, block, openFile, selected, subCalls, selectedCallId, cwd, inspectCall,
}: {
renderSlot: RenderToolRow
callId: string
@@ -100,10 +104,12 @@ const CallRow = memo(function CallRow({
selectedCallId?: string | undefined
/** Session workspace root for path-relative summaries. */
cwd: string | undefined
inspectCall: InspectCall
}) {
const owner = useMemo(() => ({
callId, toolName, block, openFile, cwd,
}), [callId, toolName, block, openFile, cwd])
inspect: () => { inspectCall(callId) },
}), [callId, toolName, block, openFile, cwd, inspectCall])
return (
<div className={css.callRow} data-selected={selected || undefined}>
{renderSlot('conversation.chat.toolview', owner, {
@@ -120,6 +126,7 @@ const CallRow = memo(function CallRow({
openFile={openFile}
selected={node.callId === selectedCallId}
cwd={cwd}
inspectCall={inspectCall}
/>
))}
</div>
@@ -129,7 +136,7 @@ const CallRow = memo(function CallRow({
})
/** Consecutive tool results as one step-run group (uniform 16px rhythm). */
const ToolGroup = memo(function ToolGroup({ renderSlot, results, openFile, selectedCallId, codeDispatches, cwd }: {
const ToolGroup = memo(function ToolGroup({ renderSlot, results, openFile, selectedCallId, codeDispatches, cwd, inspectCall }: {
renderSlot: RenderToolRow
results: readonly ToolResultNode[]
openFile: OpenFile
@@ -139,6 +146,7 @@ const ToolGroup = memo(function ToolGroup({ renderSlot, results, openFile, selec
codeDispatches: ReadonlyMap<string, readonly CodeSubCall[]>
/** Session workspace root for path-relative summaries. */
cwd: string | undefined
inspectCall: InspectCall
}) {
return (
<div className={css.toolGroup}>
@@ -154,6 +162,7 @@ const ToolGroup = memo(function ToolGroup({ renderSlot, results, openFile, selec
subCalls={codeDispatches.get(node.callId)}
selectedCallId={selectedCallId}
cwd={cwd}
inspectCall={inspectCall}
/>
))}
</div>
@@ -230,7 +239,9 @@ function StreamingTail({ useSession, onGrow }: {
* The chat view slot entry: pure component over the composed props (tool rows
* render through the declared keyed hole's renderSlot share).
*/
export function ChatView({ useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder }: ChatViewSlotProps) {
export function ChatView({
useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder, inspectCall, chatScroll,
}: ChatViewSlotProps) {
const nodes = useSession(s => s.nodes)
// Workspace root off the session list row: path summaries display relative to it.
const cwd = useSessions(s => s.byId[sessionId]?.cwd)
@@ -274,10 +285,20 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
/* v8 ignore next -- ref-null guard: React attaches the ref before layout effects run. */
if (local === null) return
const el = scrollerOf(local)
// Open completed: jump to the bottom once.
// Open completed: jump to the bottom once — unless a scroll position
// survives from a previous mount (view-tab switch away and back), which
// is restored instead of snapping the reader back to the floor.
if (openState === 'open' && !openedRef.current) {
openedRef.current = true
toBottom(el)
const saved = chatScroll.read()
if (saved === null) {
toBottom(el)
} else {
el.scrollTop = saved
const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= FOLLOW_THRESHOLD + 1
atBottomRef.current = isAtBottom
setAtBottom(isAtBottom)
}
firstSeqRef.current = firstSeq
lastKeyRef.current = lastKey
followSigRef.current = followSig
@@ -315,6 +336,9 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= FOLLOW_THRESHOLD + 1
atBottomRef.current = isAtBottom
setAtBottom(isAtBottom)
// Continuous save (unmount happens after ref detach, so saving there is
// too late); pinned-to-bottom clears so a remount keeps following.
chatScroll.save(isAtBottom ? null : el.scrollTop)
}
// Bind scroll to the resolved scrollport (host or local) once per mount.
@@ -365,6 +389,7 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
selectedCallId={inGroup ? selectedCallId : undefined}
codeDispatches={codeDispatches}
cwd={cwd}
inspectCall={inspectCall}
/>
)
}
@@ -417,6 +442,7 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
subCalls={codeDispatches.get(call.callId)}
selectedCallId={selectedCallId}
cwd={cwd}
inspectCall={inspectCall}
/>
))}
</div>
@@ -27,7 +27,7 @@ export function GenericCommandCard({ node }: CommandRowOwnerProps) {
return (
<ToolRow
variant="others"
icon={<IconApiOutline14 size={16} />}
icon={<IconApiOutline14 size={14} />}
title={title}
summary={summary}
// Expandable only when the outcome text overflows a one-line summary.
@@ -10,7 +10,7 @@ import {
IconThinkOutline14,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { ToolRowOwnerProps } from '../contract/slots.ts'
import { terminalCardModel } from '../contract/terminal-card-model.ts'
import { terminalCardModel, terminalFailed } from '../contract/terminal-card-model.ts'
import { toolRowModel, type ToolRowVariant } from '../contract/tool-call-model.ts'
import { ToolRow } from './ToolRow.tsx'
@@ -26,9 +26,14 @@ const VARIANT_ICONS: Record<ToolRowVariant, ReactNode> = {
others: <IconSparkle16 size={14} />,
}
export function GenericToolCard({ toolName, block, cwd, openFile }: ToolRowOwnerProps) {
export function GenericToolCard({ toolName, block, cwd, openFile, inspect }: ToolRowOwnerProps) {
const model = toolRowModel(toolName, block, cwd)
const terminal = terminalCardModel(block, cwd)
// A failing exit status is the terminal card's own error signal (the call
// itself settles isError:false), surfaced as the row's red state dot.
const state = model.state === 'ok' && terminal !== null && terminalFailed(terminal)
? 'error'
: model.state
const singleFile = model.filePath !== undefined
return (
<ToolRow
@@ -39,12 +44,14 @@ export function GenericToolCard({ toolName, block, cwd, openFile }: ToolRowOwner
// A terminal presenter's description is the contract's above-card text, so
// it outranks the args-derived summary here exactly as it does in BashRow.
summary={terminal?.description ?? model.summary}
// Single-file tools never expose an args body — the path link is the only action.
body={singleFile ? null : model.body}
body={model.body}
output={model.output}
errorSummary={model.errorSummary}
terminal={terminal}
state={model.state}
state={state}
filePath={model.filePath}
onOpenFile={singleFile ? openFile : undefined}
inspect={inspect}
/>
)
}
@@ -1,16 +1,19 @@
// MessageItem: the four simple node kinds — user bubble (right-aligned, with
// clock + copy / branch / edit IconActions), steering (badged bubble), context
// injection and unknown-surface JSON rows. Props are frozen node slices off
// the snapshot cache; memo holds across streaming because unchanged nodes
// keep their references.
// injection (a ToolRow-chromed collapsible row: the injection reads as "the
// harness read something into context", so it borrows the read variant's icon
// and the IN-card expanded body) and unknown-surface JSON rows. Props are
// frozen node slices off the snapshot cache; memo holds across streaming
// because unchanged nodes keep their references.
import { memo } from 'react'
import type { ReactNode } from 'react'
import type {
ContextMessageNode, SteeringMessageNode, UnknownSurfaceNode, UserMessageNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import { JsonBlock, MessageText } from '@deepseek-ai/dsh-client-ui-primitives'
import { IconBrowseOutline16, JsonBlock, MessageText } from '@deepseek-ai/dsh-client-ui-primitives'
import { MessageIconActions } from './MessageIconActions.tsx'
import { ToolRow } from './ToolRow.tsx'
import css from './MessageItem.module.css'
export interface MessageItemProps {
@@ -92,12 +95,29 @@ export const MessageItem = memo(function MessageItem({ node }: MessageItemProps)
</div>
)
}
case 'context':
case 'context': {
// Pure-text injections show their text; anything with non-text blocks
// (or nothing at all) keeps the full JSON payload so no material is lost.
// Title-only collapsed row (no summary), label-less expanded card: the
// injection is ambient context, not a call's input.
const { text, rest } = contentText(node.content)
const body = rest.length === 0 && text !== ''
? text
: JSON.stringify({ content: node.content, source: node.source }, null, 2)
return (
<div className={css.contextRow}>
<JsonBlock label="上下文注入" payload={{ content: node.content, source: node.source }} />
<ToolRow
variant="read"
icon={<IconBrowseOutline16 size={14} />}
title="上下文注入"
summary=""
body={body}
plainBody
state="ok"
/>
</div>
)
}
default:
return (
<div className={css.contextRow}>
@@ -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);
}
@@ -1,17 +1,25 @@
// ToolRow: the single-line tool summary row (figma component set 122:9479) —
// 16px leading slot (state dot / tool icon, chevron on hover or expanded) + title +
// separator dot + FILL-truncated summary. The collapsed row is always one
// line; the expanded body is indented gray text, the run_code program through
// CodeBlock, or — for a call whose render intent is a terminal card — the
// command's own output through TerminalBlock, capped at
// CHAT_TERMINAL_MAX_LINES so the message flow stays scannable. Expand state is
// component-local view state. File-tool summaries are path links that open
// through the host; the row itself is not a details-panel control.
// line; every row with body, output, or terminal material is a whole-row
// expand toggle (click / Enter / Space, icon→chevron hover preview); the
// summary stays inline while open, except Think, whose body opens with the
// same first line and would repeat it.
// The expanded body — an IN/OUT gutter-labeled card (figma 1249:35657) for
// text input/output, the run_code program through CodeBlock, or a terminal
// card's command output through TerminalBlock — lives in a max-height scroll
// container so a long payload scrolls internally instead of taking over the
// message flow; Think's prose is the exception and flows uncapped like
// message text. Expand state is component-local view state. File-tool summaries are path links that open
// through the host (stopPropagation keeps the two gestures independent); an
// error row's collapsed summary is the failure's first line in the error
// color.
import { useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
import clsx from 'clsx'
import { CodeBlock, StateDot, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives'
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
import { CHAT_TERMINAL_MAX_LINES, type TerminalCardModel } from '../contract/terminal-card-model.ts'
import type { TerminalCardModel } from '../contract/terminal-card-model.ts'
import type { ToolRowState, ToolRowVariant } from '../contract/tool-call-model.ts'
import css from './ToolRow.module.css'
@@ -23,18 +31,25 @@ export interface ToolRowProps {
icon: ReactNode
title: string
summary: string
/** Expanded-body text; null = no text body (`terminal` is the other body source). */
/** Expanded-body input text; null = no input section. */
body: string | null
/** Flattened result text for the expanded Output section; null/absent = no output section. */
output?: string | null | undefined
/** Error first line shown as the collapsed summary on an error row; null/absent = keep `summary`. */
errorSummary?: string | null | undefined
/**
* Terminal-card material for a call whose render intent is a terminal card
* (derived by `terminalCardModel`); it replaces the text body when present.
* Null or absent leaves the text body, and a row with neither is not
* expandable (its leading slot never toggles).
* (derived by `terminalCardModel`); it replaces the text sections when
* present. A row with no body, no output, and no terminal material is not
* expandable.
*/
terminal?: TerminalCardModel | null | undefined
/**
* Render the expanded body in the card without the IN gutter label — for
* material that is not a call's input payload (context injection).
*/
plainBody?: boolean | undefined
state: ToolRowState
/** Makes the row itself the expand control instead of only its leading icon. */
expandOnRowClick?: boolean | undefined
/**
* Filesystem path from tool args; when set with onOpenFile, the summary
* renders as a hover-underline link that opens the host default app.
@@ -42,6 +57,21 @@ export interface ToolRowProps {
filePath?: string | undefined
/** Open the path with the host OS default application (already cwd-resolved). */
onOpenFile?: ((path: string) => void) | undefined
/**
* Jump to this call in the trajectory view: a hover-revealed Inspect pill
* over the expanded body. Absent = no affordance (rows without a call
* identity, like Think and context injection).
*/
inspect?: (() => void) | undefined
}
/** The Inspect pill's code glyph (user-supplied 16×16), fill follows text color. */
function IconInspect() {
return (
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden>
<path d="M16 8L10.8571 12V10.552L14.1383 8L10.8571 5.448V4L16 8ZM5.14286 10.552L1.86171 8L5.14286 5.448V4L0 8L5.14286 12V10.552ZM9.02514 4L5.59657 12H6.84057L10.2691 4H9.02514Z" fill="currentColor" />
</svg>
)
}
/** Leading-slot state substitution: the tool icon yields to the terminal state
@@ -62,36 +92,31 @@ export function ToolRow({
title,
summary,
body,
output,
errorSummary,
terminal,
plainBody,
state,
expandOnRowClick = false,
filePath,
onOpenFile,
inspect,
}: ToolRowProps) {
const [expanded, setExpanded] = useState(false)
const terminalBody = terminal ?? null
// A row that names a single file keeps one interaction (open that path);
// args expand is off whether or not the open callback is wired yet. Terminal
// material still expands: only the file variants carry a path, so a terminal
// card and a file link never land on the same row.
const singleFile = filePath !== undefined
const fileLink = singleFile && onOpenFile !== undefined
const expandable = (body !== null && !singleFile) || terminalBody !== null
// The text arms take the empty string for a null body: a row expandable
// only through its terminal material renders the terminal body instead, so
// this substitution never shows.
const text = body ?? ''
const outputText = output ?? null
const expandable = body !== null || outputText !== null || terminalBody !== null
const open = expanded && expandable
const rowExpands = expandable && expandOnRowClick
// An error row's collapsed summary IS the failure: the first error line in
// the error color outranks both the args summary and a terminal description.
const failureLine = state === 'error' ? errorSummary ?? null : null
const summaryText = failureLine ?? summary
// The failure line is error prose, not the path: no open-file affordance.
const fileLink = filePath !== undefined && onOpenFile !== undefined && failureLine === null
const toggleExpand = () => {
setExpanded(v => !v)
}
const toggleFromLeading = (event: MouseEvent<HTMLButtonElement>) => {
event.stopPropagation()
toggleExpand()
}
const toggleFromKeyboard = (event: KeyboardEvent<HTMLDivElement>) => {
if (!rowExpands || (event.key !== 'Enter' && event.key !== ' ')) return
if (!expandable || (event.key !== 'Enter' && event.key !== ' ')) return
event.preventDefault()
toggleExpand()
}
@@ -99,46 +124,47 @@ export function ToolRow({
event.stopPropagation()
if (filePath !== undefined) onOpenFile?.(filePath)
}
// Expandable rows preview the toggle on hover: the tool icon yields to a
// down chevron (CSS swap on .row:hover); state dots still take precedence.
// Think reasoning is prose, not an input payload: expanded, it renders as
// plain indented text (no IN/OUT card) and the inline summary — the body's
// own first line — yields to avoid repeating itself.
const isThink = variant === 'think'
// The code variant's program renders through CodeBlock (shiki), so only its
// output joins the IN/OUT card; every other variant's input does too.
const cardBody = variant === 'code' ? null : body
// Expandable rows preview the toggle on hover: the idle leading — the tool
// icon OR the state dot — yields to a down chevron (CSS swap on .row:hover).
// The state substitution happens inside the idle slot so an error row keeps
// the hover preview instead of losing it with the icon.
const idleLeading = leadingFor(state, icon)
const collapsedIcon = expandable
? (
<>
<span className={css.iconIdle}>{icon}</span>
<IconChevronDownOutline14 className={css.chevronHover} />
<span className={css.iconIdle}>{idleLeading}</span>
<IconChevronDownOutline14 className={clsx(css.chevron, css.chevronHover)} />
</>
)
: icon
: idleLeading
const leading = open
? <IconChevronDownOutline14 />
: leadingFor(state, collapsedIcon)
? <IconChevronDownOutline14 className={css.chevron} />
: collapsedIcon
return (
<div className={css.root} data-variant={variant} data-tool={toolName} data-state={state}>
<div
className={css.row}
data-expandable={rowExpands || undefined}
role={rowExpands ? 'button' : undefined}
tabIndex={rowExpands ? 0 : undefined}
aria-expanded={rowExpands ? open : undefined}
onClick={rowExpands ? toggleExpand : undefined}
onKeyDown={rowExpands ? toggleFromKeyboard : undefined}
data-expandable={expandable || undefined}
role={expandable ? 'button' : undefined}
tabIndex={expandable ? 0 : undefined}
aria-expanded={expandable ? open : undefined}
onClick={expandable ? toggleExpand : undefined}
onKeyDown={expandable ? toggleFromKeyboard : undefined}
>
{expandable && !rowExpands ? (
<button
type="button"
className={css.leading}
aria-expanded={open}
onClick={toggleFromLeading}
>
{leading}
</button>
) : (
<span className={css.leading}>
{leading}
</span>
)}
<span className={css.leading}>
{leading}
</span>
<span className={css.title}>{title}</span>
{!open && (
{/* An empty summary drops the separator with it (a row that is only
its title, like the context-injection row, shows no trailing dot). */}
{!(open && isThink) && summaryText !== '' && (
<>
<span className={css.sep} aria-hidden />
{fileLink ? (
@@ -147,25 +173,71 @@ export function ToolRow({
className={css.fileLink}
onClick={openFile}
>
{summary}
{summaryText}
</button>
) : (
<span className={css.summary}>{summary}</span>
<span className={clsx(css.summary, failureLine !== null && css.errorSummary)}>
{summaryText}
</span>
)}
</>
)}
</div>
{/* The terminal presenter's description belongs ABOVE the card per the
render-intent contract, so an expanded terminal row keeps showing it
even though the collapsed summary is hidden while open. */}
{open && terminalBody?.description !== undefined && (
<div className={css.terminalDescription}>{terminalBody.description}</div>
{open && (
/* The wrapper (sibling of .row, so clicks inside never toggle the
row) carries the expanded body and the Inspect pill below it. */
<div className={css.bodyWrap}>
{terminalBody !== null
? <TerminalBlock {...terminalBody.card} maxLines={Infinity} className={css.terminalBody} />
: isThink
? <div className={css.thinkBody}>{body}</div>
: (
<>
{variant === 'code' && body !== null && (
<div className={css.bodyScroll}>
<CodeBlock code={body} lang="typescript" className={css.codeBody} />
</div>
)}
{plainBody === true && cardBody !== null && (
<div className={clsx(css.ioCard, css.ioCardPlain)}>
<span className={css.ioText}>{cardBody}</span>
</div>
)}
{plainBody !== true && (cardBody !== null || outputText !== null) && (
<div className={css.ioCard}>
{cardBody !== null && (
<div className={css.ioSection}>
<span className={css.ioLabel}>IN</span>
<span className={css.ioText}>{cardBody}</span>
</div>
)}
{cardBody !== null && outputText !== null && (
<span className={css.ioDivider} aria-hidden />
)}
{outputText !== null && (
<div className={css.ioSection}>
<span className={css.ioLabel}>OUT</span>
<span className={css.ioText} data-error={state === 'error' || undefined}>
{outputText}
</span>
</div>
)}
</div>
)}
</>
)}
{inspect !== undefined && (
<button
type="button"
className={css.inspectButton}
onClick={inspect}
>
<IconInspect />
Inspect
</button>
)}
</div>
)}
{open && (terminalBody !== null
? <TerminalBlock {...terminalBody.card} maxLines={CHAT_TERMINAL_MAX_LINES} className={css.terminalBody} />
: variant === 'code'
? <CodeBlock code={text} lang="typescript" className={css.codeBody} />
: <div className={css.body}>{text}</div>)}
</div>
)
}
@@ -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. */
@@ -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
@@ -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,
}
}
@@ -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
}
@@ -35,6 +35,8 @@ export function ConversationSession({
const blank = useSession(s => s.blank)
const inputState = useInput(s => s)
const storedDraft = useStore(s => s.draft)
// `?? null`: persisted snapshots from before the inspect field rehydrate without it.
const inspect = useStore(s => s.inspect ?? null)
useEffect(() => {
if (inputState.draft === '' && storedDraft !== '') inputActions.setDraft(storedDraft)
@@ -52,7 +54,10 @@ export function ConversationSession({
const view: ReactNode = hideChrome ? null : (
<div className={css.viewArea}>
{active !== undefined && renderSlot('conversation.view', {}, { only: active.id })}
{active !== undefined && renderSlot('conversation.view', {
inspect,
onInspectDone: () => { actions.setInspect(null) },
}, { only: active.id })}
</div>
)
@@ -12,7 +12,7 @@ import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client'
import type { ConversationSnapshot, RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import type { DetailsSlotProps } from '../contract/slots.ts'
import { terminalCardModel } from '../contract/terminal-card-model.ts'
import type { ToolCallBlock } from '../contract/tool-call-model.ts'
import { resultText, type ToolCallBlock } from '../contract/tool-call-model.ts'
import css from './DetailsPanel.module.css'
/** Full props composed by reference from the contract (automatic shares & injected share). */
@@ -153,20 +153,7 @@ function OutputBody({ material, cwd }: { material: CallMaterial; cwd: string | u
const result = material.block
return (
<pre className={css.code} data-error={result.isError || undefined}>
{renderResult(result)}
{resultText(result)}
</pre>
)
}
/** Flatten result content blocks to display text (text blocks verbatim, others as JSON). */
function renderResult(node: ToolResultNode): string {
const parts: string[] = []
for (const block of node.content) {
if (block.type === 'text') parts.push(block.text)
else parts.push(JSON.stringify(block, null, 2))
}
if (parts.length === 0 && node.error !== undefined) {
parts.push(`${node.error.name}: ${node.error.code}`)
}
return parts.join('\n')
}
@@ -3,7 +3,7 @@
* The plugin creates its handle at apply time so identity follows the fiber.
*/
import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-runtime/client'
import type { ChatStoreState, SelectionTarget } from './contract/views.ts'
import type { CallId, ChatStoreState, SelectionTarget } from './contract/views.ts'
/** Declared action shape used to give the exported factory a stable return type. */
type ChatActions = {
@@ -12,6 +12,7 @@ type ChatActions = {
clearDraft: (draft: ChatStoreState) => void
restoreDraft: (draft: ChatStoreState, text: string) => void
setView: (draft: ChatStoreState, view: string) => void
setInspect: (draft: ChatStoreState, target: { callId: CallId } | null) => void
}
/**
@@ -20,7 +21,7 @@ type ChatActions = {
*/
export function createChatStore(): EngineStoreHandle<ChatStoreState, ChatActions> {
return defineStore({
init: (): ChatStoreState => ({ selection: null, draft: '', view: null }),
init: (): ChatStoreState => ({ selection: null, draft: '', view: null, inspect: null }),
persist: 'dsh.conversation.chat',
actions: {
select: (d, target: SelectionTarget | null) => { d.selection = target },
@@ -30,6 +31,7 @@ export function createChatStore(): EngineStoreHandle<ChatStoreState, ChatActions
// since the clear (send choreography lives in the inject factory).
restoreDraft: (d, text: string) => { if (d.draft === '') d.draft = text },
setView: (d, view: string) => { d.view = view },
setInspect: (d, target: { callId: CallId } | null) => { d.inspect = target },
},
})
}
@@ -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}
/>
)
}
@@ -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;
@@ -4,19 +4,23 @@
// Child sessions keep a scoped badge so session-dimension differentiation stays
// observable inside the component (no parallel registry).
//
// A bash call declares the terminal render intent, so this row also renders
// the command's own output through TerminalBlock. This row has no expand
// control and is not a details-panel target either (tool rows stopped being
// one), so its terminal body is resident rather than expand-gated as in
// ToolRow, and the card's own copy and expand controls are the row's only
// interactions. CHAT_TERMINAL_MAX_LINES is passed as `maxLines` — the chat
// flow's tighter cap over the block's own default of 16 — and the block's
// internal expander keeps a long output from taking over the message flow.
// A bash call declares the terminal render intent, so this row renders the
// command's own output through TerminalBlock — expand-gated exactly like
// ToolRow's unified interaction: collapsed by default, the whole summary row
// is the toggle (click / Enter / Space, icon→chevron hover preview; the
// summary stays inline while open),
// and the expanded card max-height-scrolls inside its own surface with the
// full output (maxLines Infinity — no middle collapse). An error row's
// collapsed summary is the failure's first line in the error color.
import { useState, type KeyboardEvent } from 'react'
import type { Context } from 'cordis'
import { IconApiOutline14, StateDot, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives'
import clsx from 'clsx'
import {
IconApiOutline14, IconChevronDownOutline14, StateDot, TerminalBlock,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { ToolRowProps } from '../contract/slots.ts'
import { CHAT_TERMINAL_MAX_LINES, terminalCardModel } from '../contract/terminal-card-model.ts'
import { terminalCardModel, terminalFailed } from '../contract/terminal-card-model.ts'
import { toolRowModel, type ToolRowState } from '../contract/tool-call-model.ts'
import css from './bash-sample.module.css'
@@ -40,38 +44,84 @@ function stateStatus(state: ToolRowState): string | null {
}
/**
* Bash row: icon + Bash · {description} in the shared ToolRow chrome, with the
* command's terminal card resident below it. The summary row is not a
* details-panel control (tool rows stopped being one), so the card's copy and
* expand controls are the row's only interactions.
* Bash row: icon + Bash · {description} in the shared ToolRow chrome, the
* whole row toggling the command's terminal card (ToolRow's unified
* expand interaction, replicated locally per the registrant posture).
*/
export function BashRow({ toolName, block, sessionId, useSessions }: ToolRowProps) {
export function BashRow({ toolName, block, sessionId, useSessions, inspect }: ToolRowProps) {
const model = toolRowModel(toolName, block)
// Session workspace root: the terminal view's cwd resolves against it (an
// omitted workdir IS the workspace), which the pure presenter cannot do.
const cwd = useSessions(list => list.byId[sessionId]?.cwd)
const terminal = terminalCardModel(block, cwd)
// A failing exit status is the terminal card's own error signal (the call
// itself settles isError:false), surfaced as the row's red state dot.
const state = model.state === 'ok' && terminal !== null && terminalFailed(terminal)
? 'error'
: model.state
const isChild = useSessions(list => list.byId[sessionId]?.parentId !== undefined)
const status = stateStatus(model.state)
const status = stateStatus(state)
const [expanded, setExpanded] = useState(false)
const expandable = terminal !== null
const open = expanded && expandable
const failureLine = model.state === 'error' ? model.errorSummary : null
const toggleExpand = () => {
setExpanded(v => !v)
}
const toggleFromKeyboard = (event: KeyboardEvent<HTMLDivElement>) => {
if (!expandable || (event.key !== 'Enter' && event.key !== ' ')) return
event.preventDefault()
toggleExpand()
}
const leading = open
? <IconChevronDownOutline14 className={css.chevron} />
: expandable
? (
<>
<span className={css.iconIdle}>{leadingFor(state)}</span>
<IconChevronDownOutline14 className={clsx(css.chevron, css.chevronHover)} />
</>
)
: leadingFor(state)
return (
<div className={css.card}>
<div
className={css.root}
data-sample={isChild ? 'bash-scoped' : 'bash-global'}
data-variant="bash"
data-state={model.state}
data-state={state}
data-expandable={expandable || undefined}
role={expandable ? 'button' : undefined}
tabIndex={expandable ? 0 : undefined}
aria-expanded={expandable ? open : undefined}
onClick={expandable ? toggleExpand : undefined}
onKeyDown={expandable ? toggleFromKeyboard : undefined}
>
<span className={css.leading}>{leadingFor(model.state)}</span>
<span className={css.leading}>{leading}</span>
{status !== null && <span className={css.visuallyHidden}>{status}</span>}
{isChild && <span className={css.scopeBadge}>scoped</span>}
<span className={css.title}>{model.title}</span>
<span className={css.sep} aria-hidden />
{/* The terminal presenter's description is the contractual
above-card summary; it outranks the args-derived one. */}
<span className={css.summary}>{terminal?.description ?? model.summary}</span>
above-card summary; a failure's first line outranks both. */}
<span className={clsx(css.summary, failureLine !== null && css.errorSummary)}>
{failureLine ?? terminal?.description ?? model.summary}
</span>
</div>
{terminal !== null && (
<TerminalBlock {...terminal.card} maxLines={CHAT_TERMINAL_MAX_LINES} className={css.terminal} />
{terminal !== null && open && (
/* Same hover-Inspect posture as ToolRow's expanded body, replicated
locally per the registrant posture. */
<div className={css.bodyWrap}>
<TerminalBlock {...terminal.card} maxLines={Infinity} className={css.terminal} />
{inspect !== undefined && (
<button type="button" className={css.inspectButton} onClick={inspect}>
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden>
<path d="M16 8L10.8571 12V10.552L14.1383 8L10.8571 5.448V4L16 8ZM5.14286 10.552L1.86171 8L5.14286 5.448V4L0 8L5.14286 12V10.552ZM9.02514 4L5.59657 12H6.84057L10.2691 4H9.02514Z" fill="currentColor" />
</svg>
Inspect
</button>
)}
</div>
)}
</div>
)
@@ -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}
/>
)
}
@@ -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()
})
@@ -107,11 +107,32 @@ describe('MessageItem arms', () => {
expect(view.queryByRole('button', { name: '复制' })).toBeNull()
})
it('context and unknown nodes render their JSON rows', () => {
const ctxView = render(
it('context nodes render a title-only tool row that expands the injected text without labels', () => {
const view = render(
<MessageItem node={{
kind: 'context', seq: 3, source: null,
content: [{ type: 'text', text: 'memory line one\nsecond line' }] as never,
} as never}
/>,
)
const row = view.getByRole('button', { name: /上下文注入/ })
// Title-only collapsed row: the injected text stays behind the expand.
expect(view.queryByText(/memory line one/)).toBeNull()
fireEvent.click(row)
expect(view.getByText(/second line/)).toBeTruthy()
// Label-less card: the injection is ambient context, not a call's IN payload.
expect(view.queryByText('IN')).toBeNull()
})
it('a context node without pure text expands to the full JSON payload', () => {
const view = render(
<MessageItem node={{ kind: 'context', seq: 3, content: [], source: null } as never} />,
)
expect(ctxView.getByText(/上下文注入/)).toBeTruthy()
fireEvent.click(view.getByRole('button', { name: /上下文注入/ }))
expect(view.getByText(/"source": null/)).toBeTruthy()
})
it('unknown nodes render their JSON rows', () => {
const unknownView = render(
<MessageItem node={{ kind: 'unknown', seq: 4, type: 'surface/next', data: { x: 1 } } as never} />,
)
@@ -203,7 +203,7 @@ describe('run_code sub-calls through the real chat machinery', () => {
expect(nest.querySelector('[data-tool="cordis_unmount"]')?.textContent)
.toContain('Unmount temporary Plugindyn-2')
fireEvent.click(mounted!.querySelector('button[aria-expanded]')!)
fireEvent.click(mounted!.querySelector('[data-expandable]')!)
expect(mounted!.querySelector('pre.shiki')?.textContent).toBe(code)
})
@@ -211,8 +211,8 @@ describe('run_code sub-calls through the real chat machinery', () => {
const parent = 'call-64'
const b = await bench(snapshotWith([codeResult(10, parent)], new Map()))
const view = mountApp(b.slots)
// The code row is expandable via its leading control (body = the program).
const toggle = view.container.querySelector('[data-variant="code"] button[aria-expanded]')
// The code row is expandable via the whole summary row (body = the program).
const toggle = view.container.querySelector('[data-variant="code"] [data-expandable]')
expect(toggle).not.toBeNull()
fireEvent.click(toggle!)
// Shiki splits the program into token spans inside one <pre class="shiki">:
@@ -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)', () => {
@@ -4,7 +4,7 @@ import { cleanup, fireEvent, render } from '@testing-library/react'
afterEach(cleanup)
import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import { classifyTool, resolveToolPath, toolRowModel } from '../src/client/contract/tool-call-model.ts'
import { classifyTool, resolveToolPath, resultText, toolRowModel } from '../src/client/contract/tool-call-model.ts'
import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx'
import { ToolRow } from '../src/client/chat/ToolRow.tsx'
import { GenericToolCard } from '../src/client/chat/GenericToolCard.tsx'
@@ -102,6 +102,29 @@ describe('tool-call-model', () => {
.toBe('{\n "code": ""\n}')
})
it('resultText flattens text blocks verbatim, other shapes as JSON, empty error content to name: code', () => {
expect(resultText(result({ content: [{ type: 'text', text: 'a\nb' }] }))).toBe('a\nb')
expect(resultText(result({ content: [{ type: 'text', text: 'a' }, { type: 'image', data: 'x' } as never] })))
.toBe(`a\n${JSON.stringify({ type: 'image', data: 'x' }, null, 2)}`)
expect(resultText(result({ content: [], isError: true, error: { name: 'ToolError', code: 'denied' } })))
.toBe('ToolError: denied')
expect(resultText(result({ content: [] }))).toBe('')
})
it('derives output from the settled result and null while running or blank', () => {
expect(toolRowModel('bash', result({ content: [{ type: 'text', text: 'out' }] })).output).toBe('out')
expect(toolRowModel('bash', running()).output).toBeNull()
expect(toolRowModel('bash', result({ content: [] })).output).toBeNull()
})
it('derives errorSummary as the first output line on error rows only', () => {
const failed = result({ content: [{ type: 'text', text: 'boom\ndetail' }], isError: true })
expect(toolRowModel('bash', failed).errorSummary).toBe('boom')
expect(toolRowModel('bash', result({ content: [{ type: 'text', text: 'boom' }] })).errorSummary).toBeNull()
expect(toolRowModel('bash', result({ content: [], isError: true })).errorSummary).toBeNull()
expect(toolRowModel('bash', running()).errorSummary).toBeNull()
})
it('gives Cordis lifecycle tools action titles over their generic variants', () => {
expect(toolRowModel('cordis_inspect', running({
name: 'cordis_inspect',
@@ -144,14 +167,15 @@ describe('ToolRow', () => {
expect(view.container.querySelector('[aria-expanded]')?.getAttribute('aria-expanded')).toBe('false')
})
it('expanding swaps the leading slot to a chevron, hides summary, shows body', () => {
it('row click expands: chevron leading, summary kept inline, body in the scrolling card', () => {
const view = render(<ToolRow {...rowProps} />)
fireEvent.click(view.container.querySelector('button')!)
fireEvent.click(view.getByRole('button'))
expect(view.queryByTestId('tool-icon')).toBeNull()
expect(view.container.querySelector('svg')).not.toBeNull()
expect(view.queryByText('List files')).toBeNull()
expect(view.getByText('List files')).toBeTruthy()
expect(view.getByText(/"a": 1/)).toBeTruthy()
fireEvent.click(view.container.querySelector('button')!)
expect(view.container.querySelector('[class*="ioCard"]')).not.toBeNull()
fireEvent.click(view.getByRole('button'))
expect(view.queryByTestId('tool-icon')).not.toBeNull()
expect(view.getByText('List files')).toBeTruthy()
})
@@ -162,16 +186,20 @@ describe('ToolRow', () => {
expect(runningView.container.querySelector('[data-state="running"]')).not.toBeNull()
const errorView = render(<ToolRow {...rowProps} state="error" />)
expect(errorView.container.querySelector('[data-testid="tool-icon"]')).toBeNull()
// The dot rides the idle slot, so an expandable error row keeps the
// icon→chevron hover preview instead of losing it with the icon.
expect(errorView.container.querySelector('[class*="chevronHover"]')).not.toBeNull()
})
it('non-expandable rows render a passive leading slot', () => {
it('non-expandable rows render a passive leading slot and no row button', () => {
const view = render(<ToolRow {...rowProps} body={null} />)
expect(view.container.querySelector('button')).toBeNull()
expect(view.queryByRole('button')).toBeNull()
expect(view.container.querySelector('[aria-expanded]')).toBeNull()
expect(view.queryByTestId('tool-icon')).not.toBeNull()
})
it('an expandOnRowClick row toggles from Enter and Space, ignoring other keys', () => {
const view = render(<ToolRow {...rowProps} expandOnRowClick />)
it('the row toggles from Enter and Space, ignoring other keys', () => {
const view = render(<ToolRow {...rowProps} />)
const row = view.getByRole('button')
fireEvent.keyDown(row, { key: 'Tab' })
expect(row.getAttribute('aria-expanded')).toBe('false')
@@ -181,32 +209,31 @@ describe('ToolRow', () => {
expect(row.getAttribute('aria-expanded')).toBe('false')
})
it('a non-expandable expandOnRowClick row exposes no row button', () => {
const view = render(<ToolRow {...rowProps} body={null} expandOnRowClick />)
expect(view.queryByRole('button')).toBeNull()
})
it('file-path summary opens through onOpenFile; the leading slot is not an expand control', () => {
it('file rows expand from the row while the path link opens without toggling', () => {
const open = vi.fn()
const view = render(
<ToolRow {...rowProps} variant="read" title="Read" summary="src/a.ts" filePath="src/a.ts" onOpenFile={open} />,
)
const row = view.getByRole('button', { name: /Read/ })
// Path click opens the file and leaves the row collapsed.
fireEvent.click(view.getByText('src/a.ts'))
expect(open).toHaveBeenCalledWith('src/a.ts')
// Only the path link is a button — no args-expand affordance on file rows.
expect(view.container.querySelectorAll('button')).toHaveLength(1)
expect(view.container.querySelector('[aria-expanded]')).toBeNull()
expect(view.queryByText(/"a": 1/)).toBeNull()
expect(row.getAttribute('aria-expanded')).toBe('false')
// Row click (outside the link) expands the args body.
fireEvent.click(row)
expect(row.getAttribute('aria-expanded')).toBe('true')
expect(view.getByText(/"a": 1/)).toBeTruthy()
})
it('a single-file path disables expand even when onOpenFile is absent', () => {
it('a file path without onOpenFile renders a plain summary on an expandable row', () => {
const view = render(
<ToolRow {...rowProps} variant="write" title="Write" summary="作文.md" filePath="作文.md" />,
)
expect(view.container.querySelector('button')).toBeNull()
expect(view.container.querySelector('[aria-expanded]')).toBeNull()
fireEvent.click(view.getByText('作文.md'))
expect(view.queryByText(/"a": 1/)).toBeNull()
const row = view.getByRole('button')
fireEvent.click(row)
expect(row.getAttribute('aria-expanded')).toBe('true')
expect(view.getByText(/"a": 1/)).toBeTruthy()
})
it('non-file rows do not open anything when the summary is clicked', () => {
@@ -215,6 +242,75 @@ describe('ToolRow', () => {
fireEvent.click(view.getByText('List files'))
expect(open).not.toHaveBeenCalled()
})
it('an error row shows the failure first line in the collapsed summary and the full text expanded', () => {
const view = render(
<ToolRow {...rowProps} state="error" errorSummary="boom" output={'boom\ndetail'} />,
)
expect(view.getByText('boom')).toBeTruthy()
expect(view.queryByText('List files')).toBeNull()
fireEvent.click(view.getByRole('button'))
expect(view.getByText(/detail/)).toBeTruthy()
expect(view.container.querySelector('[data-error]')).not.toBeNull()
})
it('an error row without an error summary keeps the args summary', () => {
const view = render(<ToolRow {...rowProps} state="error" errorSummary={null} />)
expect(view.getByText('List files')).toBeTruthy()
})
it('an error file row drops the open-file link (the summary is failure prose, not the path)', () => {
const open = vi.fn()
const view = render(
<ToolRow
{...rowProps}
variant="write" title="Write" state="error" errorSummary="cannot overwrite"
filePath="src/a.ts" onOpenFile={open}
/>,
)
fireEvent.click(view.getByText('cannot overwrite'))
expect(open).not.toHaveBeenCalled()
// The failure line renders as plain text, not the underlined link button.
expect(view.container.querySelector('[class*="fileLink"]')).toBeNull()
})
it('the expanded body carries a hover Inspect pill that fires the callback', () => {
const inspect = vi.fn()
const view = render(<ToolRow {...rowProps} inspect={inspect} />)
// Collapsed: no pill.
expect(view.queryByText('Inspect')).toBeNull()
fireEvent.click(view.getByRole('button', { name: /Bash/ }))
const pill = view.getByText('Inspect')
fireEvent.click(pill)
expect(inspect).toHaveBeenCalledTimes(1)
// The pill click must not collapse the row (body is a .row sibling).
expect(view.getByRole('button', { name: /Bash/ }).getAttribute('aria-expanded')).toBe('true')
})
it('no inspect callback, no pill', () => {
const view = render(<ToolRow {...rowProps} />)
fireEvent.click(view.getByRole('button'))
expect(view.queryByText('Inspect')).toBeNull()
})
it('the expanded card gutter-labels each section it carries (IN / OUT)', () => {
const both = render(<ToolRow {...rowProps} output="result text" />)
fireEvent.click(both.getByRole('button'))
expect(both.getByText('IN')).toBeTruthy()
expect(both.getByText('OUT')).toBeTruthy()
expect(both.getByText('result text')).toBeTruthy()
cleanup()
const inputOnly = render(<ToolRow {...rowProps} />)
fireEvent.click(inputOnly.getByRole('button'))
expect(inputOnly.getByText('IN')).toBeTruthy()
expect(inputOnly.queryByText('OUT')).toBeNull()
cleanup()
const outputOnly = render(<ToolRow {...rowProps} body={null} output="only out" />)
fireEvent.click(outputOnly.getByRole('button'))
expect(outputOnly.queryByText('IN')).toBeNull()
expect(outputOnly.getByText('OUT')).toBeTruthy()
expect(outputOnly.getByText('only out')).toBeTruthy()
})
})
describe('ThinkRow', () => {
@@ -234,6 +330,21 @@ describe('ThinkRow', () => {
fireEvent.click(view.getByText('Think'))
expect(row.getAttribute('aria-expanded')).toBe('false')
})
it('expanded Think drops the inline summary and renders plain prose, no IN card', () => {
const view = render(
<AssistantMarkdown
blocks={[{ kind: 'reasoning', text: 'Inspect the session\nCheck persistence' }]}
streaming={false}
/>,
)
fireEvent.click(view.getByText('Think'))
// The summary (first line) is gone from the row; only the body carries it.
expect(view.getAllByText(/Inspect the session/)).toHaveLength(1)
expect(view.queryByText('IN')).toBeNull()
expect(view.container.querySelector('[class*="ioCard"]')).toBeNull()
expect(view.container.querySelector('[class*="thinkBody"]')).not.toBeNull()
})
})
describe('GenericToolCard', () => {
@@ -283,6 +394,14 @@ describe('GenericToolCard', () => {
expect(view.container.querySelector('svg')).not.toBeNull()
})
it('passes the owner inspect callback through to the expanded row pill', () => {
const inspect = vi.fn()
const view = render(<GenericToolCard {...props('bash', result())} inspect={inspect} />)
fireEvent.click(view.getByRole('button', { name: /Bash/ }))
fireEvent.click(view.getByText('Inspect'))
expect(inspect).toHaveBeenCalledTimes(1)
})
it('file-path summary click reaches openFile; bash summary does not', () => {
const file = props('read', running({ name: 'read', argsRaw: '{"path":"src/x.ts"}' }))
const fileView = render(<GenericToolCard {...file} />)
@@ -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()
})
@@ -94,6 +94,13 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
const openDetails = vi.fn<(t: SelectionTarget) => void>()
const openFile = vi.fn<(path: string) => void>()
const loadOlder = vi.fn()
const inspectCall = vi.fn<(callId: string) => void>()
// In-memory scroll memory matching the apply.ts per-session map contract.
let savedScrollTop: number | null = null
const chatScroll = {
save: (top: number | null) => { savedScrollTop = top },
read: () => savedScrollTop,
}
// Selection rides the REAL chat store (same construction path as
// production; the view reads it through the PropsStore useStore share).
// renderSlot stub renders the render-site fallback (an empty keyed ledger:
@@ -120,9 +127,11 @@ function makeHarness(init?: Partial<ConversationSnapshot>) {
openDetails,
openFile,
loadOlder,
inspectCall,
chatScroll,
}
const setSelection = (next: SelectionTarget | null): void => { chat.actions.select(next) }
return { set, ChatView, props, openDetails, openFile, loadOlder, setSelection }
return { set, ChatView, props, openDetails, openFile, loadOlder, inspectCall, chatScroll, setSelection }
}
describe('chat-flow derivation', () => {
@@ -194,6 +203,16 @@ describe('ChatView', () => {
expect(view.getByText('run a')).toBeTruthy()
})
it('the expanded row Inspect pill hands the call id to inspectCall', () => {
const h = makeHarness({
nodes: [toolResult(3, 'a')],
})
const view = render(<h.ChatView {...h.props} />)
fireEvent.click(view.getByRole('button', { name: /Bash/ }))
fireEvent.click(view.getByText('Inspect'))
expect(h.inspectCall).toHaveBeenCalledWith('a')
})
it('renders assistant Markdown across history, streaming, final, and interrupted states while user text stays literal', () => {
const markdown = '# Rendered\n\n- **one**\n- `two`'
const h = makeHarness({ nodes: [user(1, markdown), assistant(2, markdown)] })
@@ -280,11 +299,11 @@ describe('ChatView', () => {
expect(rowRenders).toBe(afterMount)
})
it('tool row expands to the args body via the leading slot toggle', () => {
it('tool row expands to the args body via the whole-row toggle', () => {
const h = makeHarness({ nodes: [toolResult(3, 'a')] })
const view = render(<h.ChatView {...h.props} />)
expect(view.queryByText(/"command": "cmd-a"/)).toBeNull()
fireEvent.click(view.container.querySelector('button[aria-expanded]')!)
fireEvent.click(view.container.querySelector('[data-expandable]')!)
expect(view.getByText(/"command": "cmd-a"/)).toBeTruthy()
})
@@ -407,6 +426,55 @@ describe('ChatView', () => {
}
})
it('a remount restores the saved scroll position instead of re-jumping to the bottom', () => {
const host = document.createElement('div')
host.setAttribute('data-conversation-scroll', '')
Object.defineProperty(host, 'scrollHeight', { value: 2000, writable: true, configurable: true })
Object.defineProperty(host, 'clientHeight', { value: 500, writable: true, configurable: true })
Object.defineProperty(host, 'scrollTop', { value: 0, writable: true, configurable: true })
document.body.appendChild(host)
try {
const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
// Fresh open (nothing saved): the bottom jump stands.
const view = render(<h.ChatView {...h.props} />, { container: host })
expect(host.scrollTop).toBe(2000)
// Reader scrolls up; the position is recorded continuously.
host.scrollTop = 100
fireEvent.scroll(host)
// View-tab switch away and back: the view unmounts, then remounts.
view.rerender(<div />)
host.scrollTop = 0
view.rerender(<h.ChatView {...h.props} />)
expect(host.scrollTop).toBe(100)
// The restored position is above the floor: follow stays disarmed.
expect(view.getByLabelText('回到底部')).toBeTruthy()
} finally {
host.remove()
}
})
it('a remount while pinned to the bottom keeps the bottom jump', () => {
const host = document.createElement('div')
host.setAttribute('data-conversation-scroll', '')
Object.defineProperty(host, 'scrollHeight', { value: 2000, writable: true, configurable: true })
Object.defineProperty(host, 'clientHeight', { value: 500, writable: true, configurable: true })
Object.defineProperty(host, 'scrollTop', { value: 0, writable: true, configurable: true })
document.body.appendChild(host)
try {
const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
const view = render(<h.ChatView {...h.props} />, { container: host })
// At the bottom: the scroll event records the pinned state (null).
fireEvent.scroll(host)
expect(h.chatScroll.read()).toBeNull()
view.rerender(<div />)
host.scrollTop = 0
view.rerender(<h.ChatView {...h.props} />)
expect(host.scrollTop).toBe(2000)
} finally {
host.remove()
}
})
it('paging button loads older and shows its busy label', () => {
const h = makeHarness({ nodes: [user(5, 'later')], hasMore: true })
const view = render(<h.ChatView {...h.props} />)
@@ -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()
})
})
@@ -13,7 +13,7 @@ import type {
} from '@deepseek-ai/dsh-client-runtime/client'
import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
import type { SelectionTarget, ToolRowOwnerProps, ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { CHAT_TERMINAL_MAX_LINES, terminalCardModel } from '../src/client/contract/terminal-card-model.ts'
import { terminalCardModel, terminalFailed } from '../src/client/contract/terminal-card-model.ts'
import { createChatStore } from '../src/client/stores.ts'
import { GenericToolCard } from '../src/client/chat/GenericToolCard.tsx'
import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx'
@@ -87,6 +87,19 @@ describe('terminalCardModel', () => {
}))?.card.signal).toBe('SIGTERM')
})
it('flags a failing exit as terminalFailed; clean exits and running cards are not', () => {
// isError stays false on a failing command (the exit status is result
// data), so this predicate is the row's only failure signal.
expect(terminalFailed(terminalCardModel(settled({
resultView: resultTerminal({ exitCode: 2 }),
}))!)).toBe(true)
expect(terminalFailed(terminalCardModel(settled({
resultView: { card: 'terminal', output: '', signal: 'SIGTERM' },
}))!)).toBe(true)
expect(terminalFailed(terminalCardModel(settled())!)).toBe(false)
expect(terminalFailed(terminalCardModel(running())!)).toBe(false)
})
it('takes the result view\'s replacement title over the pending one', () => {
// The presentation contract defines a result title as REPLACING the pending
// title, so a tool that rewrites it at settle time must win here.
@@ -221,36 +234,39 @@ describe('chat row terminal body', () => {
callId: 'c1', toolName: 'bash', block, openFile: vi.fn(),
})
it('the expanded body is the command output, capped tighter than the panel', () => {
expect(CHAT_TERMINAL_MAX_LINES).toBeLessThan(16)
/** The whole summary row is the expand toggle (ToolRow's unified interaction). */
const toggleRow = (view: { container: HTMLElement }) => {
fireEvent.click(view.container.querySelector('[data-expandable]')!)
}
it('the expanded body is the command output inside the row scroll container', () => {
const view = render(<GenericToolCard {...ownerProps(settled())} />)
// Collapsed: the one-line summary row only, no output.
expect(view.getByText('List files')).toBeTruthy()
expect(view.queryByText(/a\.ts/)).toBeNull()
fireEvent.click(view.container.querySelector('button')!)
toggleRow(view)
expect(view.getByText('a.ts b.ts', RAW)).toBeTruthy()
expect(view.getByText('ls -la')).toBeTruthy()
// The args JSON body the generic path would have shown is gone.
expect(view.queryByText(/"command"/)).toBeNull()
})
it('the cap collapses a long output inside the row, expandable in place', () => {
const lines = Array.from({ length: CHAT_TERMINAL_MAX_LINES + 3 }, (_, i) => `line-${i}`)
it('a long output renders in full — the scroll container replaces the middle collapse', () => {
const lines = Array.from({ length: 20 }, (_, i) => `line-${i}`)
const view = render(<GenericToolCard {...ownerProps(settled({
resultView: resultTerminal({ output: `${lines.join('\n')}\n` }),
}))} />)
fireEvent.click(view.container.querySelector('button')!)
expect(view.getByText('… 其余 3 行')).toBeTruthy()
expect(view.queryByText('line-5')).toBeNull()
fireEvent.click(view.getByRole('button', { name: '展开其余 3 行输出' }))
toggleRow(view)
expect(view.getByText('line-5')).toBeTruthy()
expect(view.getByText('line-19')).toBeTruthy()
expect(view.queryByText(/其余/)).toBeNull()
})
it('renders a multi-line command as one prompt row per line', () => {
const view = render(<GenericToolCard {...ownerProps(settled({
callView: callTerminal({ title: 'ls -la\necho done' }),
}))} />)
fireEvent.click(view.container.querySelector('button')!)
toggleRow(view)
const rows = view.container.querySelectorAll('[class^="_promptLine_"]')
expect([...rows].map(row => row.textContent)).toEqual(['$ls -la', '$echo done'])
// Still one dot for the call, on the first row.
@@ -275,14 +291,14 @@ describe('chat row terminal body', () => {
callView: callTerminal({ description: 'Terminal 3' }),
}))} />)
expect(view.getByText('Terminal 3')).toBeTruthy()
fireEvent.click(view.container.querySelector('button')!)
toggleRow(view)
expect(view.container.querySelector('[data-terminal]')).not.toBeNull()
expect(view.getByText('Terminal 3')).toBeTruthy()
})
it('a running terminal call expands to the prompt line with no output yet', () => {
const view = render(<GenericToolCard {...ownerProps(running())} />)
fireEvent.click(view.container.querySelector('button')!)
toggleRow(view)
expect(view.getByText('ls -la')).toBeTruthy()
expect(view.queryByText('复制')).toBeNull()
// The card states its own run state: a running command reads as running
@@ -294,7 +310,7 @@ describe('chat row terminal body', () => {
const view = render(<GenericToolCard {...ownerProps(settled({
callView: null, resultView: null,
}))} />)
fireEvent.click(view.container.querySelector('button')!)
toggleRow(view)
expect(view.getByText(/"command"/)).toBeTruthy()
})
@@ -303,9 +319,16 @@ describe('chat row terminal body', () => {
const view = render(<GenericToolCard {...ownerProps(settled({
call: { name: 'bash', argsRaw: '' },
}))} />)
fireEvent.click(view.container.querySelector('button')!)
toggleRow(view)
expect(view.getByText('a.ts b.ts', RAW)).toBeTruthy()
})
it('a failing exit status surfaces as the collapsed row\'s error state', () => {
const view = render(<GenericToolCard {...ownerProps(settled({
resultView: resultTerminal({ exitCode: 2 }),
}))} />)
expect(view.container.querySelector('[data-state]')?.getAttribute('data-state')).toBe('error')
})
})
describe('BashRow terminal card', () => {
@@ -321,14 +344,17 @@ describe('BashRow terminal card', () => {
sessionId: SID, useSessions: bindSnapshotSelector(list()),
} as unknown as ToolRowProps)
it('renders the command output under the summary row, without an expand gesture', () => {
it('collapses to the summary row; the whole row toggles the command output', () => {
const view = render(<BashRow {...rowProps(settled())} />)
expect(view.getByText('List files')).toBeTruthy()
expect(view.queryByText(/a\.ts/)).toBeNull()
fireEvent.click(view.container.querySelector('[data-expandable]')!)
expect(view.getByText('a.ts b.ts', RAW)).toBeTruthy()
// The card's controls are the row's only interactions: a bash row is not a
// path link and no longer a details-panel target, so nothing here navigates.
expect(view.container.querySelector('[data-clickable]')).toBeNull()
expect(view.getByText('复制')).toBeTruthy()
// Collapse back in place: the summary row returns, the card unmounts.
fireEvent.click(view.container.querySelector('[data-expandable]')!)
expect(view.queryByText(/a\.ts/)).toBeNull()
expect(view.getByText('List files')).toBeTruthy()
})
// The row's leading StateDot and the card's run-state dot describe the same
@@ -337,13 +363,22 @@ describe('BashRow terminal card', () => {
it('agrees with the summary row about the run state', () => {
const runningView = render(<BashRow {...rowProps(running())} />)
expect(runningView.container.querySelector('[data-variant="bash"]')?.getAttribute('data-state')).toBe('running')
fireEvent.click(runningView.container.querySelector('[data-expandable]')!)
expect(runStateOf(runningView.container)).toBe('ongoing')
cleanup()
const settledView = render(<BashRow {...rowProps(settled())} />)
expect(settledView.container.querySelector('[data-variant="bash"]')?.getAttribute('data-state')).toBe('ok')
fireEvent.click(settledView.container.querySelector('[data-expandable]')!)
expect(runStateOf(settledView.container)).toBe('done')
})
it('a failing exit status surfaces as the collapsed row\'s error state', () => {
const view = render(<BashRow {...rowProps(settled({
resultView: resultTerminal({ exitCode: 2 }),
}))} />)
expect(view.container.querySelector('[data-variant="bash"]')?.getAttribute('data-state')).toBe('error')
})
it('shows the terminal presenter\'s description instead of the args summary', () => {
// `terminal_send`-style presenters author a description the args do not
// repeat; the contract puts it above the card, which is this row's summary.
@@ -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);
}
@@ -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
@@ -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 {
@@ -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;
@@ -235,6 +235,10 @@ export interface TrajectoryTableProps {
collapsedAssistants: ReadonlySet<number>
/** Toggle tool calls under one assistant record. */
onToggleAssistant: (index: number) => void
/** One-shot cross-view inspect: open and scroll to this call's record. */
inspectCallId?: string | null
/** Acknowledge a consumed (or unresolvable) inspect request. */
onInspectApplied?: (() => void) | undefined
}
/** One request identity paired with its session-global number. */
@@ -1402,6 +1406,8 @@ export function TrajectoryTable({
onToggleTurn,
collapsedAssistants,
onToggleAssistant,
inspectCallId = null,
onInspectApplied,
}: TrajectoryTableProps) {
const [selectedIndex, setSelectedIndex] = useState<number | null>(null)
const [selectedRequest, setSelectedRequest] = useState<SelectedRequest | null>(null)
@@ -1574,8 +1580,37 @@ export function TrajectoryTable({
if (target !== undefined) openRecordSummary(target)
}
// Cross-view inspect handoff: resolve the requested call to its record,
// open its summary, and remember the row to scroll once the un-collapsed
// ledger has rendered. Not-found leaves the request pending (`turns` in the
// deps retries as history pages in); the ack clears the store field.
const rootRef = useRef<HTMLDivElement>(null)
const pendingScrollIndex = useRef<number | null>(null)
const openRecordSummaryRef = useRef(openRecordSummary)
openRecordSummaryRef.current = openRecordSummary
useEffect(() => {
if (inspectCallId === null) return
const target = flattenRecords(turns).find(record => record.cell.callId === inspectCallId)
if (target === undefined) return
openRecordSummaryRef.current(target)
pendingScrollIndex.current = target.cell.index
onInspectApplied?.()
}, [inspectCallId, turns, onInspectApplied])
useEffect(() => {
const index = pendingScrollIndex.current
if (index === null) return
const row = rootRef.current
?.querySelector<HTMLElement>(`tr[data-record-index="${index}"]`)
if (row === undefined || row === null) return
pendingScrollIndex.current = null
/* v8 ignore next -- jsdom lacks scrollIntoView; browsers always have it. */
if (typeof row.scrollIntoView === 'function') {
row.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
})
return (
<div className={css.split} style={splitStyle}>
<div ref={rootRef} className={css.split} style={splitStyle}>
<div
className={css.tablePane}
onClick={(event) => {
@@ -134,7 +134,7 @@ function searchMatches(
}
export function TrajectoryView({
useHistory, loadAllHistory,
useHistory, loadAllHistory, inspect, onInspectDone,
}: ConvViewProps & InjectFace<TrajectoryViewInjected>) {
const [collapsedTurns, setCollapsedTurns] = useState<ReadonlySet<number>>(EMPTY_IDS)
const [collapsedAssistants, setCollapsedAssistants] =
@@ -502,6 +502,8 @@ export function TrajectoryView({
onToggleTurn={toggleTurn}
collapsedAssistants={collapsedAssistants}
onToggleAssistant={toggleAssistant}
inspectCallId={inspect?.callId ?? null}
onInspectApplied={onInspectDone}
/>
</div>
</div>
@@ -187,4 +187,50 @@ describe('TrajectoryTable', () => {
expect(screen.getByRole('row', { name: /ASSISTANT/ })).toBeTruthy()
expect(screen.getByRole('row', { name: /Collapsed turn summary/ })).toBeTruthy()
})
const CALL_TURNS: readonly TrajectoryTurnModel[] = [{
turn: 1,
groups: [{
title: 'Step 1',
cells: [{
index: 1,
kind: 'tool',
text: 'bash · {"command":"pwd"}',
inputDetail: '{"command":"pwd"}',
callId: 'call-1',
timeSeconds: 0.1,
}],
}],
}]
it('an inspect target opens the matching record and acknowledges once', () => {
const onInspectApplied = vi.fn()
render(
<TrajectoryTable
turns={CALL_TURNS}
{...FOLD_PROPS}
inspectCallId="call-1"
onInspectApplied={onInspectApplied}
/>,
)
expect(screen.getByRole('row', { name: /TOOL/ }).getAttribute('aria-selected')).toBe('true')
expect(screen.getByRole('complementary', { name: 'Event details' })).toBeTruthy()
expect(onInspectApplied).toHaveBeenCalledOnce()
})
it('an unmatched inspect target stays pending without acknowledgement', () => {
const onInspectApplied = vi.fn()
render(
<TrajectoryTable
turns={CALL_TURNS}
{...FOLD_PROPS}
inspectCallId="call-missing"
onInspectApplied={onInspectApplied}
/>,
)
expect(screen.getByRole('row', { name: /TOOL/ }).getAttribute('aria-selected')).toBe('false')
expect(onInspectApplied).not.toHaveBeenCalled()
})
})