Merge origin/master into codex/status-bar-token-metrics

Resolve the agent-loop import conflict by retaining both durable request context and runtime policy context. Refresh the combined session fixtures and regenerate documentation catalogs. Mark PDF artifacts as binary so staged whitespace checks do not parse PDF bytes as text.
This commit is contained in:
Hypatia May
2026-07-31 16:53:03 +08:00
249 changed files with 72476 additions and 2957 deletions

View File

@@ -20,6 +20,7 @@ import { InputBar } from './skeleton/InputBar.tsx'
import { ChatView } from './chat/ChatView.tsx'
import { StatsLine } from './chat/StatsLine.tsx'
import { bashToolviewSample } from './toolviews/bash-sample.tsx'
import { readToolview } from './toolviews/read-row.tsx'
import { fileMutationToolview } from './toolviews/file-mutation-row.tsx'
import { webToolview } from './toolviews/web-row.tsx'
import { ApprovalPanel } from './skeleton/ApprovalPanel.tsx'
@@ -320,6 +321,10 @@ export function apply(ctx: Context): void {
// (ToolRow-matching Bash · {description} chrome; scoped badge in child sessions).
ctx.plugin(bashToolviewSample)
// The read row rides the same seam (a product registration, not a sample):
// Read · {path} chrome with the file's read card resident below it.
ctx.plugin(readToolview)
// The write/edit rows ride the same seam: a file-mutation call declares the
// diff render intent, so these rows stack the applied diff card under their
// path-link summary (the terminal card's posture, applied to diffs).

View File

@@ -1,7 +1,8 @@
/* The generic card grows a resident web card under its summary row when the
tool declares the `web` render intent but has no keyed row of its own (the
web_search/web_fetch rows register their own WebRow). A column around the
ToolRow keeps the row's own 24px height. */
/* GenericToolCard resident cards: a read-declaring or web-declaring tool
without its own keyed row (e.g. web_fetch) grows a resident card under its
summary row. A column around the ToolRow keeps the row's own 24px height, so
the read card renders identically to the keyed ReadRow and the web card to
the web_search/web_fetch WebRow. */
.card {
display: flex;
@@ -10,6 +11,7 @@
/* Row indentation matches ToolRow's expanded bodies (16px leading + 6px gap),
and replaces the primitive's standalone vertical margin with the flow's. */
.read,
.web {
margin: 4px 0 4px 22px;
}

View File

@@ -7,9 +7,10 @@
import type { ReactNode } from 'react'
import {
IconApiOutline14, IconBrowseOutline16, IconCodeOutline16, IconEditOutline16, IconSearchOutline16, IconSparkle16,
IconThinkOutline14, WebBlock,
IconThinkOutline14, ReadBlock, WebBlock,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatViewSlotProps, ToolRowOwnerProps } from '../contract/slots.ts'
import { CHAT_READ_MAX_LINES, readCardModel } from '../contract/read-card-model.ts'
import { diffCardModel } from '../contract/diff-card-model.ts'
import { terminalCardModel, terminalFailed } from '../contract/terminal-card-model.ts'
import { CHAT_WEB_MAX_SOURCES, webCardModel } from '../contract/web-card-model.ts'
@@ -37,6 +38,7 @@ export interface GenericToolCardProps extends ToolRowOwnerProps {
export function GenericToolCard({ toolName, block, cwd, openFile, inspect, t }: GenericToolCardProps) {
const model = toolRowModel(toolName, block, cwd)
const terminal = terminalCardModel(block, cwd)
const read = readCardModel(block, cwd)
const diff = diffCardModel(block)
const web = webCardModel(block)
// A failing exit status is the terminal card's own error signal (the call
@@ -69,6 +71,18 @@ export function GenericToolCard({ toolName, block, cwd, openFile, inspect, t }:
inspect={inspect}
/>
)
// A read-declaring tool without its own keyed row lands here (e.g. web_fetch),
// so the file's read card is resident below the summary row exactly as the
// keyed ReadRow draws it. Only wrap when a card is present, so every other
// tool keeps the bare ToolRow.
if (read !== null) {
return (
<div className={css.card}>
{row}
<ReadBlock {...read} maxLines={CHAT_READ_MAX_LINES} className={css.read} />
</div>
)
}
// A web-declaring tool without its own keyed row lands here; its card is
// resident under the summary, mirroring WebRow (and BashRow's terminal card).
if (web === null) return row

View File

@@ -0,0 +1,76 @@
/**
* Pure derivation of the read-card props from a frozen call slice: the
* `card:'read'` render intent the read tool declares arrives on the snapshot as
* the settled result node's `resultView`, and this is the one place that turns
* it into what {@link ReadBlock} draws. Both conversation render sites (the chat
* tool row's resident body and the details panel's Output section) call this, so
* the path, lines, total, and language they show are derived once.
*
* The read card is result-side only ([read card note](../../../../../../.agents/notes/implemented/feature/2026-07-30-web-read-card.md)):
* a call carries no file content until `execute` returns, so the pending call
* stays a generic card (`kind: 'read'`). A running read therefore has no read
* card, and this returns null for it — the row keeps its args-derived summary
* until the result arrives.
* @module
*/
import type { ReadBlockLine, ReadBlockProps } from '@deepseek-ai/dsh-client-ui-primitives'
import { relativizeToCwd, type ToolCallBlock } from './tool-call-model.ts'
/**
* Content lines the chat row's resident read 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. The
* same split [`CHAT_TERMINAL_MAX_LINES`](./terminal-card-model.ts) draws for
* terminal output.
*/
export const CHAT_READ_MAX_LINES = 8
/**
* The {@link ReadBlock} props this derivation owns. Picked off the primitive's
* props so the two stay in step; `maxLines`/`className` belong to each render
* site.
*/
export type ReadCardModel = Pick<ReadBlockProps, 'label' | 'lines' | 'totalLines' | 'lang'>
/**
* Derive the read-card props for a tool call, or null when this call is not a
* read card and belongs on the generic path.
*
* The read card is result-side only, so only a settled call whose result view
* declares `card:'read'` produces one. Every other case is null — the
* documented generic-card default:
*
* - A running call: it has no result view yet, and a read carries no content at
* call time.
* - A settled call whose result view is not a read card — including a `card`
* value this UI version does not know, which arrives over the wire and cannot
* be trusted to be one of the compiled variants, and the read tool's own
* generic fallback for an error result or a non-envelope body.
*
* The label is the read view's `title` when the tool supplied one (the
* presentation contract's replacement-title rule), otherwise the file path
* relativized to the session workspace so a workspace-rooted absolute path
* displays the same short form the row summary shows.
* @param block - RunningToolCall or ToolResultNode off the snapshot caches.
* @param sessionCwd - the session workspace root; a workspace-rooted absolute
* path label displays relative to it. Absent leaves the path as authored.
* @returns the read-card props, or null for the generic path.
*/
export function readCardModel(block: ToolCallBlock, sessionCwd?: string): ReadCardModel | null {
// Running has no result view; a read carries no content until execute returns.
if (!('kind' in block)) return null
const result = block.resultView?.card === 'read' ? block.resultView : null
if (result === null) return null
// Lines arrive frozen off the snapshot; copy into the primitive's own line
// shape so the card never holds a reference into the runtime's cache.
const lines: ReadBlockLine[] = result.lines.map(line => ({ number: line.number, text: line.text }))
return {
label: result.title ?? relativizeToCwd(result.path, sessionCwd),
lines,
totalLines: result.totalLines,
lang: result.lang,
}
}

View File

@@ -133,8 +133,13 @@ const SUMMARY_KEYS: Record<ToolRowVariant, readonly string[]> = {
others: [],
}
/** Strip the workspace root from workspace-rooted absolute paths (display only). */
function relativizeToCwd(text: string, cwd: string | undefined): string {
/**
* Strip the workspace root from a workspace-rooted absolute path (display only).
* @param text - the path to shorten.
* @param cwd - session workspace root; absent or empty leaves the path unchanged.
* @returns the path relative to the workspace root, or unchanged when it is not rooted there.
*/
export 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)

View File

@@ -108,8 +108,9 @@
margin: 0;
}
/* Same rule for the web card: it sits under the section label, so the section
owns the spacing rather than the primitive's own vertical margin. */
/* The read and web cards sit directly under their section label, same as the
terminal card: drop the primitive's standalone vertical margin. */
.read,
.web {
margin: 0;
}

View File

@@ -7,10 +7,11 @@
// share the store seat exists for) and derives the call material from the
// session snapshot — no data of its own.
import { CodeBlock, DiffBlock, TerminalBlock, WebBlock } from '@deepseek-ai/dsh-client-ui-primitives'
import { CodeBlock, DiffBlock, ReadBlock, TerminalBlock, WebBlock } from '@deepseek-ai/dsh-client-ui-primitives'
import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client'
import type { ConversationSnapshot, RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import type { DetailsSlotProps } from '../contract/slots.ts'
import { readCardModel } from '../contract/read-card-model.ts'
import { diffCardModel } from '../contract/diff-card-model.ts'
import { terminalBlockLabels, terminalCardModel } from '../contract/terminal-card-model.ts'
import { webCardModel } from '../contract/web-card-model.ts'
@@ -129,7 +130,9 @@ export function DetailsPanel({ useSession, useSessions, sessionId, useStore, clo
* The Output section's body for the selected call. A terminal-card call — a
* shell command's call/result views — renders through the shared TerminalBlock
* at the primitive's own full height allowance, so column-aligned output keeps
* its alignment and scrolls sideways instead of folding. A diff-card call — a
* its alignment and scrolls sideways instead of folding. A read-card call
* renders through the shared ReadBlock at that same full height, so the whole
* returned window is line-numbered and highlighted. A diff-card call — a
* write/edit's applied change — renders through the shared DiffBlock at the same
* full height. A web-card call — a `web_search`/`web_fetch` result — renders
* through WebBlock at its own full source-list allowance. Every other call, and
@@ -153,6 +156,10 @@ function OutputBody({ material, cwd, t }: { material: CallMaterial; cwd: string
</>
)
}
const read = readCardModel(material.block, cwd)
// The panel takes the primitive's own default cap, not the row's tighter one:
// it is the single-call reading surface, so the whole window is available.
if (read !== null) return <ReadBlock {...read} className={css.read} />
const diff = diffCardModel(material.block)
if (diff !== null) return <DiffBlock {...diff.card} className={css.cardBody} />
const web = webCardModel(material.block)

View File

@@ -0,0 +1,119 @@
/* Read toolview: same geometry/tokens as ToolRow (figma Read · {path}), plus
the read card the row stacks under its summary line. */
/* Summary line over the read card; the summary row keeps its own 24px height,
so the card is a column around it rather than a change to it. */
.card {
display: flex;
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. */
.read {
margin: 4px 0 4px 22px;
}
.root {
position: relative; /* sweep-glare overlay anchor */
overflow: hidden;
display: flex;
align-items: center;
height: 24px;
min-width: 0;
}
/* Running sweep glare — same pattern as BashRow/ToolRow, so a running read row
gives the same executing feedback a running command row does. The leading
read icon stays static (a read has no per-step state to animate); the sweep
is the row-level running signal. */
.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-read-row-sweep 2.6s ease-out infinite;
pointer-events: none;
}
@keyframes dsh-read-row-sweep {
0% { left: -300px; }
90%, 100% { left: 100%; }
}
.leading {
flex: none;
width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
margin-right: 6px;
color: var(--dsw-alias-label-tertiary);
}
.title {
flex: none;
font-size: 14px;
line-height: 24px;
color: var(--dsw-alias-label-secondary);
}
.sep {
flex: none;
width: 2px;
height: 2px;
border-radius: 1px;
margin: 0 8px;
background: var(--dsw-alias-label-caption);
}
.summary {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
line-height: 24px;
color: var(--dsw-alias-label-tertiary);
}
/* File path: same geometry as .summary; hover underline + pointer. */
.fileLink {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin: 0;
padding: 0;
border: none;
background: none;
text-align: left;
font-size: 14px;
line-height: 24px;
color: var(--dsw-alias-label-tertiary);
cursor: pointer;
}
.fileLink:hover {
text-decoration: underline;
}
.visuallyHidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}

View File

@@ -0,0 +1,105 @@
// Read toolview registrant: the keyed toolview hole for the read tool
// (ctx.slots.register + ToolRowProps only — never imports the chat domain).
// Product chrome matches ToolRow (figma: Read · {path}); the summary is the
// file path as an openable link, exactly as the generic read row draws it.
//
// A read RESULT declares the read render intent, so this row renders the file's
// own line-numbered, syntax-highlighted content through ReadBlock resident
// below its summary line — the same posture BashRow gives a terminal card. The
// card is capped at CHAT_READ_MAX_LINES (the chat flow's tighter cap over the
// block's own default of 16) with the block's internal expander keeping a long
// read from taking over the message flow. A running read (no result yet) and a
// non-read result both render the summary row alone. The read intent is
// result-side only, so there is no running-state read card to draw.
import type { Context } from 'cordis'
import { IconBrowseOutline16, ReadBlock, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ToolRowProps } from '../contract/slots.ts'
import { CHAT_READ_MAX_LINES, readCardModel } from '../contract/read-card-model.ts'
import { toolRowModel, type ToolRowState } from '../contract/tool-call-model.ts'
import css from './read-row.module.css'
/** Leading-slot state substitution: the tool icon yields to the state dot
* (error = red, interrupted = amber). Running keeps the icon. */
function leadingFor(state: ToolRowState) {
switch (state) {
case 'error': return <StateDot state="error" />
case 'stopped': return <StateDot state="warning" />
default: return <IconBrowseOutline16 size={14} />
}
}
/** Visually hidden status — StateDot is aria-hidden; AT needs a text label. */
function stateStatus(state: ToolRowState): string | null {
switch (state) {
case 'running': return '运行中'
case 'error': return '失败'
case 'stopped': return '已停止'
default: return null
}
}
/**
* Read row: icon + Read · {path} in the shared ToolRow chrome, with the file's
* read card resident below it. The summary path is an openable host link when
* the row names a single file; the card's copy and expand controls plus that
* link are the row's only interactions (tool rows are not details-panel
* targets).
*/
export function ReadRow({ toolName, block, sessionId, useSessions, openFile }: ToolRowProps) {
// Session workspace root: the read view's path relativizes against it (a
// workspace-rooted absolute path shows its short form), which the pure
// presenter cannot do.
const cwd = useSessions(list => list.byId[sessionId]?.cwd)
const model = toolRowModel(toolName, block, cwd)
const read = readCardModel(block, cwd)
const status = stateStatus(model.state)
const filePath = model.filePath
return (
<div className={css.card}>
{/* jscpd:ignore-start — the summary-line chrome (leading, status, title,
sep, path-link/summary) is the shared ToolRow row shape every keyed
toolview draws; extracting it into one component is a separate change
tracked for all rows at once, not this read-card PR. */}
<div className={css.root} data-variant="read" data-state={model.state}>
<span className={css.leading}>{leadingFor(model.state)}</span>
{status !== null && <span className={css.visuallyHidden}>{status}</span>}
<span className={css.title}>{model.title}</span>
<span className={css.sep} aria-hidden />
{filePath !== undefined ? (
<button
type="button"
className={css.fileLink}
onClick={() => { openFile(filePath) }}
>
{model.summary}
</button>
) : (
<span className={css.summary}>{model.summary}</span>
)}
</div>
{/* jscpd:ignore-end */}
{read !== null && (
<ReadBlock {...read} maxLines={CHAT_READ_MAX_LINES} className={css.read} />
)}
</div>
)
}
/**
* The read row as a plain registrant plugin. `inject` carries the load-order
* seam: requiring the conversation service guarantees the chat entry (and with
* it the 'conversation.chat.toolview' declaration) is registered —
* ui-conversation's apply mounts the service after the chat entry.
*/
export const readToolview = {
name: 'read-toolview',
inject: ['slots', 'conversation'],
/**
* Register the read row into the chat view's keyed toolview hole.
* @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
*/
apply(ctx: Context): void {
ctx.slots.register({ name: 'conversation.chat.toolview', key: 'read' }, ReadRow)
},
}

View File

@@ -84,14 +84,14 @@ describe('apply wiring', () => {
await b.runtime.dispose()
})
it('mounts the bash sample, the file-mutation rows, the web rows, and the product rows as keyed entries through the load-order seam', async () => {
it('mounts the bash sample, the read row, the file-mutation rows, the web rows, and the product rows as keyed entries through the load-order seam', async () => {
const b = await bench()
// Every registrant plugin's inject: ['slots', 'conversation'] resolved — the
// service being present implies the chat entry declared the hole first. The
// file-mutation registrant claims both write and edit for the diff card; the
// web rows register one component under both web tool names.
const entries = b.slots.entries('conversation.chat.toolview')
expect(entries.map(e => e.options.key)).toEqual(['bash', 'edit', 'write', 'web_search', 'web_fetch', 'todo_write', 'ask_user_question'])
expect(entries.map(e => e.options.key)).toEqual(['bash', 'read', 'edit', 'write', 'web_search', 'web_fetch', 'todo_write', 'ask_user_question'])
// Stats stick with the composer (not inside ChatView).
expect(b.slots.entries('conversation.composer.dock').map(e => e.options.id)).toEqual(['stats'])
await b.runtime.dispose()

View File

@@ -0,0 +1,293 @@
// @vitest-environment jsdom
// The read render intent on the web side: the pure readCardModel derivation
// over the settled result view, and both conversation render sites that consume
// it — the chat tool row (the keyed ReadRow and the GenericToolCard fallback,
// each with the read card resident under the summary) and the details panel's
// Output section. Also pins the keyed 'read' toolview registration.
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render } from '@testing-library/react'
import { Context } from 'cordis'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import type {
ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, WorkspaceListState,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
import type { SelectionTarget, ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { CHAT_READ_MAX_LINES, readCardModel } from '../src/client/contract/read-card-model.ts'
import { createChatStore } from '../src/client/stores.ts'
import { GenericToolCard, type GenericToolCardProps } from '../src/client/chat/GenericToolCard.tsx'
import { zh } from '../src/client/locales.ts'
import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx'
import { ReadRow, readToolview } from '../src/client/toolviews/read-row.tsx'
afterEach(cleanup)
const SID = 's1' as SessionId
/** The chat-view locale seat: this package's namespace over the common fallback. */
const t: GenericToolCardProps['t'] = makeTranslate(zh, commonZh)
// The read tool's real schema key is `file_path`; the top-level read samples
// use it so the row exercises a production-shaped call. `web_fetch` (below) has
// its own schema whose key is not `file_path`, so it keeps a `url`-less `path`.
const ARGS = '{"file_path":"src/a.ts","offset":41}'
const WEB_FETCH_ARGS = '{"path":"src/a.ts","offset":41}'
/** The read block's rendered content cells, one string per row (highlighting
* breaks a line across token spans, so match on the row's textContent). */
function contentTexts(container: HTMLElement): string[] {
return [...container.querySelectorAll('[data-read] [class^="_content_"]')].map(cell => cell.textContent ?? '')
}
/** Three windowed lines starting at file line 41 (a read past an offset). */
const sampleLines = [
{ number: 41, text: 'export const a = 1' },
{ number: 42, text: 'export const b = 2' },
{ number: 43, text: 'export const c = 3' },
]
/** The read tool's own result view for a settled file read. */
const resultRead = (over?: Partial<Extract<ToolResultView, { card: 'read' }>>): ToolResultView => ({
card: 'read', path: 'src/a.ts', offset: 41, lines: sampleLines, totalLines: 180, lang: 'ts', ...over,
})
const running = (over?: Partial<RunningToolCall>): RunningToolCall => ({
callId: 'c1', name: 'read', argsRaw: ARGS,
turn: 1, step: 1, time: 1_000, callView: { card: 'generic', title: 'Read src/a.ts', kind: 'read' }, ...over,
})
const settled = (over?: Partial<ToolResultNode>): ToolResultNode => ({
kind: 'tool-result', seq: 10, time: 2_000, callId: 'c1',
call: { name: 'read', argsRaw: ARGS },
callTime: 1_000,
content: [{ type: 'text', text: '41: export const a = 1' }], isError: false,
callView: { card: 'generic', title: 'Read src/a.ts', kind: 'read' }, resultView: resultRead(), ...over,
})
describe('readCardModel', () => {
it('derives the card from a settled read result view', () => {
expect(readCardModel(settled())).toEqual({
label: 'src/a.ts', lines: sampleLines, totalLines: 180, lang: 'ts',
})
})
it('copies the lines into the primitive shape rather than aliasing the frozen slice', () => {
const model = readCardModel(settled())
expect(model?.lines).toEqual(sampleLines)
expect(model?.lines).not.toBe(sampleLines)
expect(model?.lines[0]).not.toBe(sampleLines[0])
})
it('takes the result view\'s replacement title over the relativized path', () => {
// The presentation contract defines a result title as REPLACING the pending
// one, so a tool that supplies a label wins over the path here.
expect(readCardModel(settled({ resultView: resultRead({ title: 'Read (head) src/a.ts' }) }))?.label)
.toBe('Read (head) src/a.ts')
})
it('relativizes a workspace-rooted path label, and leaves others as authored', () => {
// A workspace-rooted absolute path shows its short form.
expect(readCardModel(settled({ resultView: resultRead({ path: '/w/app/src/a.ts' }) }), '/w/app')?.label)
.toBe('src/a.ts')
// A path outside the workspace stays as authored.
expect(readCardModel(settled({ resultView: resultRead({ path: '/srv/other.ts' }) }), '/w/app')?.label)
.toBe('/srv/other.ts')
// With no session cwd there is nothing to relativize against.
expect(readCardModel(settled({ resultView: resultRead({ path: '/w/app/src/a.ts' }) }))?.label)
.toBe('/w/app/src/a.ts')
})
it('carries an omitted language through as undefined', () => {
const noLang = resultRead()
delete (noLang as { lang?: string }).lang
expect(readCardModel(settled({ resultView: noLang }))?.lang).toBeUndefined()
})
it('returns null for a running read: the read intent is result-side only', () => {
// A read carries no content until execute returns, so the pending call is a
// generic card and there is no read card to draw yet.
expect(readCardModel(running())).toBeNull()
})
it('returns null for every non-read settled call: no view, generic view, unknown card', () => {
expect(readCardModel(settled({ resultView: null }))).toBeNull()
expect(readCardModel(settled({ resultView: { card: 'generic' } }))).toBeNull()
// A card tag this UI version does not know arrives over the wire; the
// documented generic-card default takes it, not a crash.
const future = { card: 'chart' } as unknown as ToolResultView
expect(readCardModel(settled({ resultView: future }))).toBeNull()
})
})
describe('GenericToolCard read body', () => {
const ownerProps = (block: RunningToolCall | ToolResultNode): GenericToolCardProps => ({
callId: 'c1', toolName: 'web_fetch', block, openFile: vi.fn(), t,
})
it('renders the read card resident under the summary, capped tighter than the panel', () => {
expect(CHAT_READ_MAX_LINES).toBeLessThan(16)
// web_fetch lands on the read variant without its own keyed row, so the
// fallback card owns the resident read block.
const view = render(<GenericToolCard {...ownerProps(settled({ call: { name: 'web_fetch', argsRaw: WEB_FETCH_ARGS } }))} />)
expect(view.container.querySelector('[data-read]')).not.toBeNull()
expect(contentTexts(view.container)).toContain('export const a = 1')
// The gutter keeps the file's own line numbers.
expect(view.getByText('41')).toBeTruthy()
})
it('a non-read tool renders the bare row with no read card', () => {
const view = render(<GenericToolCard {...({
callId: 'c1', toolName: 'echo', block: settled({
call: { name: 'echo', argsRaw: '{"text":"x"}' }, callView: null, resultView: null,
}), openFile: vi.fn(), t,
})} />)
expect(view.container.querySelector('[data-read]')).toBeNull()
})
it('a running read renders the summary row alone (no result view yet)', () => {
const view = render(<GenericToolCard {...ownerProps(running({ name: 'web_fetch' }))} />)
expect(view.container.querySelector('[data-read]')).toBeNull()
})
})
describe('ReadRow keyed toolview', () => {
const list = () => createSnapshotStore<SessionListState>({
ids: [SID],
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, waitingApproval: false, updatedAt: 0, cwd: '/w/app' } },
current: SID,
phase: 'ready',
})
const rowProps = (block: RunningToolCall | ToolResultNode): ToolRowProps => ({
callId: 'c1', toolName: 'read', block, openFile: vi.fn(),
sessionId: SID, useSessions: bindSnapshotSelector(list()),
} as unknown as ToolRowProps)
it('renders the file path summary and the resident read card', () => {
const view = render(<ReadRow {...rowProps(settled())} />)
expect(view.getByText('Read')).toBeTruthy()
// The path appears twice: the row summary link and the card's banner label.
expect(view.getAllByText('src/a.ts').length).toBe(2)
expect(view.container.querySelector('[data-read]')).not.toBeNull()
expect(contentTexts(view.container)).toContain('export const a = 1')
expect(view.getByText('显示 3 / 180 行')).toBeTruthy()
})
it('the path summary opens the file through the host', () => {
const openFile = vi.fn()
const view = render(<ReadRow {...{ ...rowProps(settled()), openFile }} />)
fireEvent.click(view.getByRole('button', { name: 'src/a.ts' }))
// The row derives the file path from args; the chat view resolves it against
// the cwd before this callback opens it, so the arg path is what arrives.
expect(openFile).toHaveBeenCalledWith('src/a.ts')
})
it('a running read renders the summary row alone, and its state', () => {
const view = render(<ReadRow {...rowProps(running())} />)
expect(view.container.querySelector('[data-variant="read"]')?.getAttribute('data-state')).toBe('running')
expect(view.container.querySelector('[data-read]')).toBeNull()
})
it('an error read result shows the error state and no read card', () => {
const view = render(<ReadRow {...rowProps(settled({
resultView: { card: 'generic' }, isError: true,
content: [{ type: 'text', text: 'ENOENT' }],
}))} />)
expect(view.container.querySelector('[data-variant="read"]')?.getAttribute('data-state')).toBe('error')
expect(view.container.querySelector('[data-read]')).toBeNull()
})
it('an interrupted read shows the stopped state', () => {
const view = render(<ReadRow {...rowProps(settled({
resultView: null, isError: true, error: { name: 'ToolError', code: 'interrupted' },
}))} />)
expect(view.container.querySelector('[data-variant="read"]')?.getAttribute('data-state')).toBe('stopped')
})
it('registers under the read key of the keyed toolview slot', () => {
const registered: { name: unknown; key?: unknown }[] = []
const ctx = { slots: { register: (options: { name: unknown; key?: unknown }) => { registered.push(options) } } } as unknown as Context
readToolview.apply(ctx)
expect(registered).toEqual([{ name: 'conversation.chat.toolview', key: 'read' }])
expect(readToolview.inject).toContain('conversation')
})
})
describe('DetailsPanel Output section (read)', () => {
function mount(snapshot: ConversationSnapshot, selection: SelectionTarget | null, cwd?: string) {
localStorage.clear()
const chat = createChatStore().create()
if (selection !== null) chat.actions.select(selection)
const sessions = createSnapshotStore<SessionListState>(cwd === undefined
? { ids: [], byId: {}, current: undefined, phase: 'ready' }
: {
ids: [SID],
byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, waitingApproval: false, updatedAt: 0, cwd } },
current: SID,
phase: 'ready',
})
const workspaces = createSnapshotStore<WorkspaceListState>({
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
})
return render(
<DetailsPanel
sessionId={SID}
t={t}
useSession={bindSnapshotSelector({ getSnapshot: () => snapshot, subscribe: () => () => {} })}
useSessions={bindSnapshotSelector(sessions)}
useWorkspaces={bindSnapshotSelector(workspaces)}
useInput={(() => { throw new Error('unused') })}
inputActions={{ setDraft: () => {}, submit: () => {} }}
useProjection={(() => undefined)}
useStore={bindSnapshotSelector(chat)}
actions={chat.actions}
closeDetails={vi.fn()}
/>,
)
}
function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return {
sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, lastAgentError: null, ...over,
}
}
const target: SelectionTarget = { turnSeq: 10, callId: 'c1', toolName: 'read' }
it('renders the read card at full height, keeping the JSON Input section', () => {
const long = Array.from({ length: 20 }, (_, i) => ({ number: i + 1, text: `row-${i}` }))
const view = mount(snapshot({
nodes: [settled({ resultView: resultRead({ lines: long, totalLines: 20 }) })],
}), target)
expect(view.getByText(/"file_path"/)).toBeTruthy()
expect(view.container.querySelector('[data-read]')).not.toBeNull()
// The panel takes the primitive's own default cap (16), not the row's.
expect(view.getByText(`… 其余 ${20 - 16} 行`)).toBeTruthy()
expect(contentTexts(view.container)).toContain('row-0')
})
it('a non-read result keeps the flattened pre form', () => {
const view = mount(snapshot({
nodes: [settled({
callView: null, resultView: null,
content: [{ type: 'text', text: 'plain result' }],
})],
}), target)
expect(view.container.querySelector('[data-read]')).toBeNull()
expect(view.getByText('输出').closest('section')?.querySelector('pre')?.textContent).toBe('plain result')
})
it('a running read keeps the 运行中… placeholder (no result view)', () => {
const view = mount(snapshot({ runningCalls: [running()] }), target)
expect(view.getByText('运行中…')).toBeTruthy()
expect(view.container.querySelector('[data-read]')).toBeNull()
})
})