fix(web-ui): cwd-relative path summaries, sweep glare rework, uniform 16px chat rhythm

Tool row summaries strip the session workspace root; the running sweep
becomes a glare-band overlay (deepsuite ShimmerText pattern); assistant
nodes that render nothing no longer split tool-row groups; block and
tool-row spacing collapse to one 16px rhythm.
This commit is contained in:
Yif
2026-07-28 12:05:58 +08:00
parent 484e0f70cc
commit 16b54aa4c4
12 changed files with 152 additions and 71 deletions

View File

@@ -1,5 +1,6 @@
/* Chat flow: block gap 16 between narration/bubbles/tool groups (figma); /* Chat flow: one 16px rhythm everywhere — between blocks (prose <-> tool
tool rows inside a group gap 10. Input padding cap rides the skeleton. */ runs) via the column gap and between consecutive tool rows via the group
gap. Input padding cap rides the skeleton. */
.root { .root {
position: relative; position: relative;
@@ -30,7 +31,7 @@
.toolGroup { .toolGroup {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 10px; gap: 16px;
} }
.callRow { .callRow {
@@ -58,6 +59,10 @@
.turnDots { .turnDots {
align-self: flex-start; align-self: flex-start;
flex: none; flex: none;
display: flex;
align-items: center;
/* One message line box: the dots center inside the text line height. */
height: 26px;
/* Same pin as StateDot: ongoing blue has no alias token (business-primary /* Same pin as StateDot: ongoing blue has no alias token (business-primary
is the 500 step, not this 450). */ is the 500 step, not this 450). */
color: var(--dsw-static-deepseek-450); color: var(--dsw-static-deepseek-450);

View File

@@ -49,19 +49,20 @@ type UseConversation = SnapshotSelectorHook<ConversationSnapshot>
* top-level call (same registrations, same fallback), nested by the parent. * top-level call (same registrations, same fallback), nested by the parent.
* A started-but-unsettled sub-call arrives as the RunningToolCall shape and * A started-but-unsettled sub-call arrives as the RunningToolCall shape and
* renders the running state exactly as a native in-flight row. */ * renders the running state exactly as a native in-flight row. */
const SubCallRow = memo(function SubCallRow({ renderSlot, node, onOpenDetails, selected }: { const SubCallRow = memo(function SubCallRow({ renderSlot, node, onOpenDetails, selected, cwd }: {
renderSlot: RenderToolRow renderSlot: RenderToolRow
node: CodeSubCall node: CodeSubCall
onOpenDetails: OpenDetails onOpenDetails: OpenDetails
selected: boolean selected: boolean
cwd: string | undefined
}) { }) {
const settled = 'kind' in node const settled = 'kind' in node
const toolName = settled ? node.call?.name ?? '' : node.name const toolName = settled ? node.call?.name ?? '' : node.name
const seq = settled ? node.seq : node.time const seq = settled ? node.seq : node.time
const owner = useMemo(() => ({ const owner = useMemo(() => ({
callId: node.callId, toolName, block: node, callId: node.callId, toolName, block: node, cwd,
openDetails: () => { onOpenDetails({ turnSeq: seq, callId: node.callId, toolName }) }, openDetails: () => { onOpenDetails({ turnSeq: seq, callId: node.callId, toolName }) },
}), [node, toolName, seq, onOpenDetails]) }), [node, toolName, seq, cwd, onOpenDetails])
return ( return (
<div className={css.callRow} data-selected={selected || undefined}> <div className={css.callRow} data-selected={selected || undefined}>
{renderSlot('conversation.chat.toolview', owner, { {renderSlot('conversation.chat.toolview', owner, {
@@ -77,7 +78,9 @@ const SubCallRow = memo(function SubCallRow({ renderSlot, node, onOpenDetails, s
* GenericToolCard at this render site. A `run_code` call additionally * GenericToolCard at this render site. A `run_code` call additionally
* renders its logged sub-dispatches as always-visible indented rows — * renders its logged sub-dispatches as always-visible indented rows —
* each one the same keyed-slot dispatch as a native top-level call. */ * each one the same keyed-slot dispatch as a native top-level call. */
const CallRow = memo(function CallRow({ renderSlot, callId, toolName, block, seq, onOpenDetails, selected, subCalls, selectedCallId }: { const CallRow = memo(function CallRow({
renderSlot, callId, toolName, block, seq, onOpenDetails, selected, subCalls, selectedCallId, cwd,
}: {
renderSlot: RenderToolRow renderSlot: RenderToolRow
callId: string callId: string
toolName: string toolName: string
@@ -91,11 +94,13 @@ const CallRow = memo(function CallRow({ renderSlot, callId, toolName, block, seq
subCalls?: readonly CodeSubCall[] | undefined subCalls?: readonly CodeSubCall[] | undefined
/** The store's selected callId, matched against sub-rows (undefined when no sub-row here is selected). */ /** The store's selected callId, matched against sub-rows (undefined when no sub-row here is selected). */
selectedCallId?: string | undefined selectedCallId?: string | undefined
/** Session workspace root for path-relative summaries. */
cwd: string | undefined
}) { }) {
const owner = useMemo(() => ({ const owner = useMemo(() => ({
callId, toolName, block, callId, toolName, block, cwd,
openDetails: () => { onOpenDetails({ turnSeq: seq, callId, toolName }) }, openDetails: () => { onOpenDetails({ turnSeq: seq, callId, toolName }) },
}), [callId, toolName, block, seq, onOpenDetails]) }), [callId, toolName, block, seq, cwd, onOpenDetails])
return ( return (
<div className={css.callRow} data-selected={selected || undefined}> <div className={css.callRow} data-selected={selected || undefined}>
{renderSlot('conversation.chat.toolview', owner, { {renderSlot('conversation.chat.toolview', owner, {
@@ -111,6 +116,7 @@ const CallRow = memo(function CallRow({ renderSlot, callId, toolName, block, seq
node={node} node={node}
onOpenDetails={onOpenDetails} onOpenDetails={onOpenDetails}
selected={node.callId === selectedCallId} selected={node.callId === selectedCallId}
cwd={cwd}
/> />
))} ))}
</div> </div>
@@ -119,8 +125,8 @@ const CallRow = memo(function CallRow({ renderSlot, callId, toolName, block, seq
) )
}) })
/** Consecutive tool results as one step-run group (figma VERTICAL gap10). */ /** Consecutive tool results as one step-run group (uniform 16px rhythm). */
const ToolGroup = memo(function ToolGroup({ renderSlot, results, onOpenDetails, selectedCallId, codeDispatches }: { const ToolGroup = memo(function ToolGroup({ renderSlot, results, onOpenDetails, selectedCallId, codeDispatches, cwd }: {
renderSlot: RenderToolRow renderSlot: RenderToolRow
results: readonly ToolResultNode[] results: readonly ToolResultNode[]
onOpenDetails: OpenDetails onOpenDetails: OpenDetails
@@ -128,6 +134,8 @@ const ToolGroup = memo(function ToolGroup({ renderSlot, results, onOpenDetails,
selectedCallId: string | undefined selectedCallId: string | undefined
/** Sub-dispatch index off the snapshot (map reference is chunk-storm stable). */ /** Sub-dispatch index off the snapshot (map reference is chunk-storm stable). */
codeDispatches: ReadonlyMap<string, readonly CodeSubCall[]> codeDispatches: ReadonlyMap<string, readonly CodeSubCall[]>
/** Session workspace root for path-relative summaries. */
cwd: string | undefined
}) { }) {
return ( return (
<div className={css.toolGroup}> <div className={css.toolGroup}>
@@ -143,6 +151,7 @@ const ToolGroup = memo(function ToolGroup({ renderSlot, results, onOpenDetails,
selected={node.callId === selectedCallId} selected={node.callId === selectedCallId}
subCalls={codeDispatches.get(node.callId)} subCalls={codeDispatches.get(node.callId)}
selectedCallId={selectedCallId} selectedCallId={selectedCallId}
cwd={cwd}
/> />
))} ))}
</div> </div>
@@ -157,27 +166,29 @@ const LOADER_CELLS = [0, 5, 10, 15] as const
function TurnDots() { function TurnDots() {
return ( return (
<svg /* The wrapper is a 26px line box (message line height) so the loader
className={css.turnDots} occupies one text line and centers the dots inside it. */
width="17.5" <div className={css.turnDots} aria-hidden="true">
height="2.5" <svg
viewBox="0 0 17.5 2.5" width="17.5"
shapeRendering="crispEdges" height="2.5"
aria-hidden="true" viewBox="0 0 17.5 2.5"
> shapeRendering="crispEdges"
{LOADER_CELLS.map((x, index) => ( >
<rect {LOADER_CELLS.map((x, index) => (
key={x} <rect
className={css.turnDotCell} key={x}
x={x} className={css.turnDotCell}
y="0" x={x}
width="2.5" y="0"
height="2.5" width="2.5"
/* Negative delay phases the chase so every cell animates from mount. */ height="2.5"
style={{ animationDelay: `${(index - LOADER_CELLS.length) * 250}ms` }} /* Negative delay phases the chase so every cell animates from mount. */
/> style={{ animationDelay: `${(index - LOADER_CELLS.length) * 250}ms` }}
))} />
</svg> ))}
</svg>
</div>
) )
} }
@@ -199,8 +210,10 @@ function StreamingTail({ useSession, onGrow }: {
* The chat view slot entry: pure component over the composed props (tool rows * The chat view slot entry: pure component over the composed props (tool rows
* render through the declared keyed hole's renderSlot share). * render through the declared keyed hole's renderSlot share).
*/ */
export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOlder }: ChatViewSlotProps) { export function ChatView({ useSession, useSessions, useStore, renderSlot, sessionId, openDetails, loadOlder }: ChatViewSlotProps) {
const nodes = useSession(s => s.nodes) 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)
const running = useSession(s => s.running) const running = useSession(s => s.running)
const runningCalls = useSession(s => s.runningCalls) const runningCalls = useSession(s => s.runningCalls)
const codeDispatches = useSession(s => s.codeDispatches) const codeDispatches = useSession(s => s.codeDispatches)
@@ -301,6 +314,7 @@ export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOl
onOpenDetails={openDetails} onOpenDetails={openDetails}
selectedCallId={inGroup ? selectedCallId : undefined} selectedCallId={inGroup ? selectedCallId : undefined}
codeDispatches={codeDispatches} codeDispatches={codeDispatches}
cwd={cwd}
/> />
) )
} }
@@ -342,6 +356,7 @@ export function ChatView({ useSession, useStore, renderSlot, openDetails, loadOl
selected={call.callId === selectedCallId} selected={call.callId === selectedCallId}
subCalls={codeDispatches.get(call.callId)} subCalls={codeDispatches.get(call.callId)}
selectedCallId={selectedCallId} selectedCallId={selectedCallId}
cwd={cwd}
/> />
))} ))}
</div> </div>

View File

@@ -25,8 +25,8 @@ const VARIANT_ICONS: Record<ToolRowVariant, ReactNode> = {
others: <IconSparkle16 size={14} />, others: <IconSparkle16 size={14} />,
} }
export function GenericToolCard({ toolName, block, openDetails }: ToolRowOwnerProps) { export function GenericToolCard({ toolName, block, cwd, openDetails }: ToolRowOwnerProps) {
const model = toolRowModel(toolName, block) const model = toolRowModel(toolName, block, cwd)
return ( return (
<ToolRow <ToolRow
variant={model.variant} variant={model.variant}

View File

@@ -6,34 +6,39 @@
flex-direction: column; flex-direction: column;
} }
/* Row sweep mask (running signal): the row content itself is the medium —
an animated mask band modulates the alpha of everything in the row
(glyphs, icon, dot), so the light reads as passing THROUGH the content,
not behind it. The mask is ALWAYS mounted with the band parked off-screen
(-50% = past the right edge; tiled copies sit outside the view too), so
at rest it is a no-op. Running only adds the position animation. On exit
the animation drops and `transition: mask-position` carries the band from
its animated position onward off-screen right (before-change style
includes animation effects per css-transitions-1) — the sweep finishes
instead of snapping. */
.row { .row {
position: relative; /* sweep-glare overlay anchor */
overflow: hidden;
display: flex; display: flex;
align-items: center; align-items: center;
height: 24px; height: 24px;
min-width: 0; min-width: 0;
mask-image: linear-gradient(100deg, #000 30%, rgba(0, 0, 0, 0.35) 50%, #000 70%);
mask-size: 200% 100%;
mask-position: -50% 0;
transition: mask-position 1.6s ease-out;
} }
.root[data-state='running'] .row { /* Running sweep (deepsuite ShimmerText pattern): a fixed-width glare band —
animation: dsh-tool-row-sweep 2.2s linear infinite; theme background at 60% — glides over the row content from off-left to
off-right, washing glyphs and icon toward the background as it passes.
ease-out with a 10% end hold gives each pass a beat before the next. */
.root[data-state='running'] .row::after {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 300px;
background: linear-gradient(
90deg,
transparent 0%,
color-mix(in srgb, var(--dsw-alias-bg-base) 60%, transparent) 55%,
transparent 100%
);
animation: dsh-tool-row-sweep 2.6s ease-out infinite;
pointer-events: none;
} }
@keyframes dsh-tool-row-sweep { @keyframes dsh-tool-row-sweep {
from { mask-position: 150% 0; } 0% { left: -300px; }
to { mask-position: -50% 0; } 90%, 100% { left: 100%; }
} }
/* Clickable rows keep only the cursor affordance — no hover fill. */ /* Clickable rows keep only the cursor affordance — no hover fill. */

View File

@@ -12,6 +12,16 @@ export type ChatFlowItem =
| { kind: 'node'; key: string; node: ConversationNode } | { kind: 'node'; key: string; node: ConversationNode }
| { kind: 'tool-group'; key: string; results: readonly ToolResultNode[] } | { kind: 'tool-group'; key: string; results: readonly ToolResultNode[] }
/** An assistant node that renders nothing: only tool-call heads (rows render
* via the grouping pass) and blank text/reasoning. Skipped by the flow so it
* neither costs column gaps nor splits a tool-row run. Interrupted nodes
* always render (the 已停止 marker). */
function rendersNothing(node: ConversationNode): boolean {
return node.kind === 'assistant' && node.interrupted !== true
&& node.blocks.every(b => b.kind === 'tool-call'
|| ((b.kind === 'text' || b.kind === 'reasoning') && b.text.trim() === ''))
}
/** /**
* Group finalized nodes into the step-summary flow. * Group finalized nodes into the step-summary flow.
* @param nodes - snapshot nodes (surface order). * @param nodes - snapshot nodes (surface order).
@@ -21,6 +31,7 @@ export function deriveChatFlow(nodes: readonly ConversationNode[]): ChatFlowItem
const items: ChatFlowItem[] = [] const items: ChatFlowItem[] = []
let group: ToolResultNode[] | null = null let group: ToolResultNode[] | null = null
for (const node of nodes) { for (const node of nodes) {
if (rendersNothing(node)) continue
if (node.kind === 'tool-result') { if (node.kind === 'tool-result') {
if (group === null) { if (group === null) {
group = [node] group = [node]

View File

@@ -143,6 +143,8 @@ export interface ToolRowOwnerProps {
toolName: string toolName: string
/** Frozen call slice: the running call or the settled result node. */ /** Frozen call slice: the running call or the settled result node. */
block: ToolCallBlock block: ToolCallBlock
/** Session workspace root; path summaries display relative to it. */
cwd?: string | undefined
/** Open the details panel for this call (session-level facility, supplied by the view). */ /** Open the details panel for this call (session-level facility, supplied by the view). */
openDetails: () => void openDetails: () => void
} }

View File

@@ -101,6 +101,14 @@ const SUMMARY_KEYS: Record<ToolRowVariant, readonly string[]> = {
others: [], others: [],
} }
/** Strip the workspace root from workspace-rooted absolute paths (display only). */
function relativizeToCwd(text: string, cwd: string | undefined): string {
if (cwd === undefined || cwd === '') return text
const root = cwd.replace(/[/\\]+$/, '')
if (text.startsWith(`${root}/`) || text.startsWith(`${root}\\`)) return text.slice(root.length + 1)
return text
}
function deriveSummary(variant: ToolRowVariant, argsRaw: string): string { function deriveSummary(variant: ToolRowVariant, argsRaw: string): string {
const parsed = parseArgs(argsRaw) const parsed = parseArgs(argsRaw)
if (typeof parsed !== 'object' || parsed === null) return firstLine(argsRaw) if (typeof parsed !== 'object' || parsed === null) return firstLine(argsRaw)
@@ -130,16 +138,17 @@ function deriveBody(variant: ToolRowVariant, argsRaw: string): string | null {
* Derive the full row model from a frozen call slice. * Derive the full row model from a frozen call slice.
* @param toolName - wire tool name (dispatch-supplied; survives windowless results). * @param toolName - wire tool name (dispatch-supplied; survives windowless results).
* @param block - RunningToolCall or ToolResultNode off the snapshot caches. * @param block - RunningToolCall or ToolResultNode off the snapshot caches.
* @param cwd - session workspace root; workspace-rooted path summaries display relative to it.
* @returns the row model. * @returns the row model.
*/ */
export function toolRowModel(toolName: string, block: ToolCallBlock): ToolRowModel { export function toolRowModel(toolName: string, block: ToolCallBlock, cwd?: string): ToolRowModel {
const variant = classifyTool(toolName) const variant = classifyTool(toolName)
const done = 'kind' in block const done = 'kind' in block
const argsRaw = (done ? block.call?.argsRaw : block.argsRaw) ?? '' const argsRaw = (done ? block.call?.argsRaw : block.argsRaw) ?? ''
const state: ToolRowState = !done ? 'running' const state: ToolRowState = !done ? 'running'
: block.error?.code === 'interrupted' ? 'stopped' : block.error?.code === 'interrupted' ? 'stopped'
: block.isError ? 'error' : 'ok' : block.isError ? 'error' : 'ok'
const base = argsRaw === '' ? block.callId : deriveSummary(variant, argsRaw) const base = argsRaw === '' ? block.callId : relativizeToCwd(deriveSummary(variant, argsRaw), cwd)
const toolTitle = TOOL_TITLES[toolName] const toolTitle = TOOL_TITLES[toolName]
// Others keeps the static "Tool call" title (figma literal); the real tool // Others keeps the static "Tool call" title (figma literal); the real tool
// name rides the mutable summary slot unless the tool owns a specific title. // name rides the mutable summary slot unless the tool owns a specific title.

View File

@@ -66,7 +66,7 @@ export function WorkspaceChip({ buttonRef, label, menuOpen = false, onClick }: {
* @param props.className - positioning class from the owner. * @param props.className - positioning class from the owner.
* @returns the blurred-ellipse svg element. * @returns the blurred-ellipse svg element.
*/ */
export function HeroGlow({ className }: { className?: string }) { export function HeroGlow({ className }: { className?: string | undefined }) {
// Stable filter id so multiple hero mounts do not collide in the DOM. // Stable filter id so multiple hero mounts do not collide in the DOM.
const glowFilterId = `empty-glow-${useId().replace(/:/g, '')}` const glowFilterId = `empty-glow-${useId().replace(/:/g, '')}`
return ( return (

View File

@@ -92,15 +92,15 @@
color: var(--dsw-alias-state-success-primary); color: var(--dsw-alias-state-success-primary);
} }
.glyphPending {
color: var(--dsw-alias-label-caption);
}
.glyphProgress { .glyphProgress {
color: var(--dsw-alias-state-business-primary); color: var(--dsw-alias-state-business-primary);
animation: todo-progress-spin 1s linear infinite; animation: todo-progress-spin 1s linear infinite;
} }
.glyphPending {
color: var(--dsw-alias-label-caption);
}
@keyframes todo-progress-spin { @keyframes todo-progress-spin {
to { to {
transform: rotate(360deg); transform: rotate(360deg);

View File

@@ -1,28 +1,37 @@
/* Bash toolview: same geometry/tokens as ToolRow (figma Bash · description). */ /* Bash toolview: same geometry/tokens as ToolRow (figma Bash · description). */
/* Row sweep mask — same masked in-flight signal and exit-glide contract as
ToolRow (mask always mounted, band parked off-screen; running only adds
the animation; the transition finishes the sweep on exit). */
.root { .root {
position: relative; /* sweep-glare overlay anchor */
overflow: hidden;
display: flex; display: flex;
align-items: center; align-items: center;
height: 24px; height: 24px;
min-width: 0; min-width: 0;
cursor: pointer; cursor: pointer;
border-radius: 6px; border-radius: 6px;
mask-image: linear-gradient(100deg, #000 30%, rgba(0, 0, 0, 0.35) 50%, #000 70%);
mask-size: 200% 100%;
mask-position: -50% 0;
transition: mask-position 1.6s ease-out;
} }
.root[data-state='running'] { /* Running sweep glare — same deepsuite ShimmerText pattern as ToolRow. */
animation: dsh-bash-row-sweep 2.2s linear infinite; .root[data-state='running']::after {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 300px;
background: linear-gradient(
90deg,
transparent 0%,
color-mix(in srgb, var(--dsw-alias-bg-base) 60%, transparent) 55%,
transparent 100%
);
animation: dsh-bash-row-sweep 2.6s ease-out infinite;
pointer-events: none;
} }
@keyframes dsh-bash-row-sweep { @keyframes dsh-bash-row-sweep {
from { mask-position: 150% 0; } 0% { left: -300px; }
to { mask-position: -50% 0; } 90%, 100% { left: 100%; }
} }
.leading { .leading {

View File

@@ -64,6 +64,16 @@ describe('tool-call-model', () => {
expect(toolRowModel('', running({ argsRaw: '' })).summary).toBe('c1') expect(toolRowModel('', running({ argsRaw: '' })).summary).toBe('c1')
}) })
it('displays workspace-rooted paths relative to the session cwd', () => {
const cwd = '/Users/u/ws/'
expect(toolRowModel('edit', running({ name: 'edit', argsRaw: '{"file_path":"/Users/u/ws/src/x.ts"}' }), cwd).summary).toBe('src/x.ts')
expect(toolRowModel('read', running({ name: 'read', argsRaw: '{"path":"/Users/u/ws/a.md"}' }), cwd).summary).toBe('a.md')
// Paths outside the workspace (and non-path summaries) stay verbatim.
expect(toolRowModel('read', running({ name: 'read', argsRaw: '{"path":"/etc/hosts"}' }), cwd).summary).toBe('/etc/hosts')
expect(toolRowModel('bash', running({ argsRaw: '{"command":"pwd"}' }), cwd).summary).toBe('pwd')
expect(toolRowModel('read', running({ name: 'read', argsRaw: '{"path":"/Users/u/ws/a.md"}' }), '').summary).toBe('/Users/u/ws/a.md')
})
it('body pretty-prints JSON args, keeps raw non-JSON, null when empty', () => { it('body pretty-prints JSON args, keeps raw non-JSON, null when empty', () => {
expect(toolRowModel('bash', running({ argsRaw: '{"a":1}' })).body).toBe('{\n "a": 1\n}') expect(toolRowModel('bash', running({ argsRaw: '{"a":1}' })).body).toBe('{\n "a": 1\n}')
expect(toolRowModel('bash', running({ argsRaw: 'raw' })).body).toBe('raw') expect(toolRowModel('bash', running({ argsRaw: 'raw' })).body).toBe('raw')

View File

@@ -131,6 +131,21 @@ describe('chat-flow derivation', () => {
expect(flowKeys(items)).toBe('n1|n2|g3|n5|g6') expect(flowKeys(items)).toBe('n1|n2|g3|n5|g6')
expect(flowKeys(deriveChatFlow([...nodes, toolResult(7, 'd')]))).toBe('n1|n2|g3|n5|g6') expect(flowKeys(deriveChatFlow([...nodes, toolResult(7, 'd')]))).toBe('n1|n2|g3|n5|g6')
}) })
it('skips render-nothing assistant nodes so tool runs stay one group', () => {
// A tool-call-only step message (and blank text/reasoning) renders nothing:
// it must not split the run into two groups with an empty line between.
const headsOnly: AssistantMessageNode = {
kind: 'assistant', seq: 4, time: 4_000, turn: 1, step: 2,
blocks: [{ kind: 'tool-call', callId: 'b', name: 'read', argsRaw: '{}' }, { kind: 'text', text: ' \n' }, { kind: 'reasoning', text: '' }],
}
const items = deriveChatFlow([toolResult(3, 'a'), headsOnly, toolResult(5, 'b')])
expect(flowKeys(items)).toBe('g3')
expect(items[0]!.kind === 'tool-group' && items[0].results.map(r => r.callId)).toEqual(['a', 'b'])
// Interrupted and visible-content nodes still render (已停止 marker / prose).
expect(flowKeys(deriveChatFlow([toolResult(3, 'a'), { ...headsOnly, interrupted: true }, toolResult(5, 'b')]))).toBe('g3|n4|g5')
expect(flowKeys(deriveChatFlow([toolResult(3, 'a'), assistant(4, 'found'), toolResult(5, 'b')]))).toBe('g3|n4|g5')
})
}) })
describe('ChatView', () => { describe('ChatView', () => {