feat(web): render read tool output as a line-numbered code card
Consume the card:'read' result view (path, numbered lines, totalLines, lang) the read backend PR added. ReadBlock (ui-primitives) draws a per-line gutter with each line's own file number, shiki highlighting via a new highlightLines returning per-line token arrays, a 显示 X / Y 行 window note, a height cap matching TerminalBlock, and a copy control. read-card-model is the single resultView derivation; a keyed ReadRow registers under read with the card resident under its path-link summary. The generic fallback and the details panel are read-aware. Fixture gains a windowed read turn for the built-boot snapshot.
This commit is contained in:
@@ -136,6 +136,33 @@ const TERMINAL_EXIT_STATUS: Record<string, { exitCode: number } | { signal: stri
|
||||
[TERMINAL_OUTPUT_FIXTURE]: { exitCode: 1 },
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-card sample for the read turn: a WINDOW past an offset, so the line
|
||||
* numbers start above 1 (the card's gutter keeps the file's own numbering) and
|
||||
* `totalLines` exceeds the window (the card shows a "showing N of M" note). The
|
||||
* fixture is client-side and cannot import the read tool, so the structured
|
||||
* window is authored inline exactly as the tool would project it through
|
||||
* `presentationMeta`. `lang` is a `ts` hint so the shiki path highlights it.
|
||||
*/
|
||||
const READ_SAMPLE_FIRST_LINE = 41
|
||||
const READ_SAMPLE_SOURCE = [
|
||||
'export interface ReadBlockProps {',
|
||||
' label?: string | undefined',
|
||||
' lines: readonly ReadBlockLine[]',
|
||||
' totalLines: number',
|
||||
' lang?: string | undefined',
|
||||
' maxLines?: number | undefined',
|
||||
' className?: string | undefined',
|
||||
'}',
|
||||
'',
|
||||
'// A windowed read keeps the file line numbers in the gutter.',
|
||||
'const marker = "fixture read sample"',
|
||||
]
|
||||
const READ_SAMPLE_LINES = READ_SAMPLE_SOURCE.map((text, index) => ({ number: READ_SAMPLE_FIRST_LINE + index, text }))
|
||||
const READ_SAMPLE_PATH = 'packages/client/ui-primitives/src/ReadBlock.tsx'
|
||||
const READ_SAMPLE_TOTAL = 180
|
||||
const READ_SAMPLE_TEXT = READ_SAMPLE_SOURCE.map((text, index) => `${READ_SAMPLE_FIRST_LINE + index}: ${text}`).join('\n')
|
||||
|
||||
const DEEPSEEK_REASONING = {
|
||||
efforts: [
|
||||
{ id: 'off', name: 'Off' },
|
||||
@@ -275,7 +302,7 @@ function buildAlphaLog(): SessionEvent[] {
|
||||
push({ type: 'step/end', data: { turn, step: 0 } })
|
||||
push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } })
|
||||
}
|
||||
// Turn 65: todo_write sample — the TodoRow toolview in the flow plus the
|
||||
// Turn 67: todo_write sample — the TodoRow toolview in the flow plus the
|
||||
// todo/write snapshot event feeding the TodoPanel plan strip.
|
||||
const fixtureTodos = [
|
||||
{ content: '梳理需求', status: 'completed' },
|
||||
@@ -296,8 +323,19 @@ function buildAlphaLog(): SessionEvent[] {
|
||||
// strip empty and take the todo surfaces' own coverage with it.
|
||||
toolTurn(65, 'bash', '{"command":"pnpm run check","cwd":"/tmp/fixture/deep/nested"}', TERMINAL_OUTPUT_FIXTURE)
|
||||
|
||||
// Turn 66: the read sample — a WINDOW past an offset so the card draws file
|
||||
// line numbers starting above 1 and a "showing N of M" note (the window is
|
||||
// shorter than READ_SAMPLE_TOTAL), with a `ts` language hint the shiki path
|
||||
// highlights. Named `read`, so it exercises the keyed ReadRow registration
|
||||
// (the render-site fallback row is covered by the read sub-dispatches in the
|
||||
// turn 64 run_code sample). The read render intent is result-side only, so its
|
||||
// pending call stays a generic `kind: 'read'` card; presentResult carries the
|
||||
// structured window. Ordered BEFORE the todo turn for the same reason the
|
||||
// terminal sample is: the standing plan retires at the next `turn/start`.
|
||||
toolTurn(66, 'read', `{"path":${JSON.stringify(READ_SAMPLE_PATH)},"offset":${READ_SAMPLE_FIRST_LINE}}`, READ_SAMPLE_TEXT)
|
||||
|
||||
const todoArgs = JSON.stringify({ todos: fixtureTodos })
|
||||
toolTurn(66, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 1 in progress, 1 completed.')
|
||||
toolTurn(67, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 1 in progress, 1 completed.')
|
||||
// The real tool appends the snapshot mid-execution — between tool/call and
|
||||
// tool/result — so the fixture reproduces that exact ordering (the last
|
||||
// toolTurn events run ... tool/call, tool/result, step/end, turn/end).
|
||||
@@ -332,6 +370,12 @@ function presentCall(name: string, argsRaw: string): ToolCallView | undefined {
|
||||
card: 'diff', title: `Write ${str(args.path)}`,
|
||||
diffs: [{ path: str(args.path), oldText: null, newText: str(args.content) }],
|
||||
}
|
||||
// A read pending call is a GENERIC card (kind: 'read', a follow-along
|
||||
// location): the read render intent is result-side only, because a call
|
||||
// carries no file content until execute returns. The rich read card arrives
|
||||
// in presentResult.
|
||||
case 'read':
|
||||
return { card: 'generic', title: `Read ${str(args.path)}`, kind: 'read', locations: [{ path: str(args.path) }] }
|
||||
case 'edit':
|
||||
return { card: 'generic', title: `Edit ${str(args.file_path)}`, kind: 'edit', rawInput: args }
|
||||
case 'write':
|
||||
@@ -344,6 +388,16 @@ function presentCall(name: string, argsRaw: string): ToolCallView | undefined {
|
||||
function presentResult(name: string, argsRaw: string, resultText: string): ToolResultView | undefined {
|
||||
const call = presentCall(name, argsRaw)
|
||||
if (call === undefined) return undefined
|
||||
// The read result is the structured window the tool projects through
|
||||
// `presentationMeta`; the fixture authors it inline (it cannot import the
|
||||
// tool). Keyed on the name because the read pending call is a generic card,
|
||||
// so `call.card` alone does not distinguish it from edit/write.
|
||||
if (name === 'read') {
|
||||
return {
|
||||
card: 'read', path: READ_SAMPLE_PATH, lines: READ_SAMPLE_LINES,
|
||||
totalLines: READ_SAMPLE_TOTAL, lang: 'ts', content: text(resultText),
|
||||
}
|
||||
}
|
||||
switch (call.card) {
|
||||
case 'terminal':
|
||||
// The sample's own exit status, authored beside it: re-parsing the
|
||||
|
||||
@@ -19,6 +19,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 { ApprovalPanel } from './skeleton/ApprovalPanel.tsx'
|
||||
import { todoToolview } from './toolviews/todo-row.tsx'
|
||||
import { todoDockEntry } from './skeleton/TodoPanel.tsx'
|
||||
@@ -254,6 +255,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 todo_write row rides the same seam (a product registration, not a sample).
|
||||
ctx.plugin(todoToolview)
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
/* GenericToolCard read card: same column-around-summary layout and indentation
|
||||
the keyed ReadRow uses, so a read-declaring tool without its own row (e.g.
|
||||
web_fetch) renders its read card identically. */
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.read {
|
||||
margin: 4px 0 4px 22px;
|
||||
}
|
||||
@@ -7,12 +7,14 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import {
|
||||
IconApiOutline14, IconBrowseOutline16, IconCodeOutline16, IconEditOutline16, IconSearchOutline16, IconSparkle16,
|
||||
IconThinkOutline14,
|
||||
IconThinkOutline14, ReadBlock,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ToolRowOwnerProps } from '../contract/slots.ts'
|
||||
import { CHAT_READ_MAX_LINES, readCardModel } from '../contract/read-card-model.ts'
|
||||
import { terminalCardModel } from '../contract/terminal-card-model.ts'
|
||||
import { toolRowModel, type ToolRowVariant } from '../contract/tool-call-model.ts'
|
||||
import { ToolRow } from './ToolRow.tsx'
|
||||
import css from './GenericToolCard.module.css'
|
||||
|
||||
/** Variant leading icons (figma table); all glyphs render at 14 inside the 16px leading box. */
|
||||
const VARIANT_ICONS: Record<ToolRowVariant, ReactNode> = {
|
||||
@@ -29,8 +31,9 @@ const VARIANT_ICONS: Record<ToolRowVariant, ReactNode> = {
|
||||
export function GenericToolCard({ toolName, block, cwd, openFile }: ToolRowOwnerProps) {
|
||||
const model = toolRowModel(toolName, block, cwd)
|
||||
const terminal = terminalCardModel(block, cwd)
|
||||
const read = readCardModel(block, cwd)
|
||||
const singleFile = model.filePath !== undefined
|
||||
return (
|
||||
const row = (
|
||||
<ToolRow
|
||||
variant={model.variant}
|
||||
toolName={toolName}
|
||||
@@ -47,4 +50,15 @@ export function GenericToolCard({ toolName, block, cwd, openFile }: ToolRowOwner
|
||||
onOpenFile={singleFile ? openFile : undefined}
|
||||
/>
|
||||
)
|
||||
// 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 row
|
||||
return (
|
||||
<div className={css.card}>
|
||||
{row}
|
||||
<ReadBlock {...read} maxLines={CHAT_READ_MAX_LINES} className={css.read} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* 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 {@link CHAT_TERMINAL_MAX_LINES} 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,
|
||||
}
|
||||
}
|
||||
@@ -109,8 +109,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)
|
||||
|
||||
@@ -106,3 +106,9 @@
|
||||
.terminal {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* The read card sits directly under its section label, same as the terminal
|
||||
card: drop the primitive's standalone vertical margin. */
|
||||
.read {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -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, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { CodeBlock, ReadBlock, TerminalBlock } 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 { terminalCardModel } from '../contract/terminal-card-model.ts'
|
||||
import type { ToolCallBlock } from '../contract/tool-call-model.ts'
|
||||
import css from './DetailsPanel.module.css'
|
||||
@@ -127,8 +128,10 @@ 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. Every other call, and
|
||||
* a running call with no terminal card yet, keeps the flattened text form.
|
||||
* 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. Every other call, and a
|
||||
* running call with no card yet, keeps the flattened text form.
|
||||
* @param props.material - the selected call's material from {@link materialFor}.
|
||||
* @param props.cwd - the session workspace root, resolving the terminal view's cwd.
|
||||
* @returns the Output section's body element.
|
||||
@@ -147,6 +150,10 @@ function OutputBody({ material, cwd }: { material: CallMaterial; cwd: string | u
|
||||
</>
|
||||
)
|
||||
}
|
||||
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} />
|
||||
// A settled call always carries the result node the flattened form needs;
|
||||
// the running shape has no result to flatten.
|
||||
if (!('kind' in material.block)) return <div className={css.empty}>运行中…</div>
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/* 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;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 24px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// 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}>
|
||||
<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>
|
||||
{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)
|
||||
},
|
||||
}
|
||||
@@ -80,12 +80,12 @@ describe('apply wiring', () => {
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('mounts the bash sample and the todo row as keyed entries through the load-order seam', async () => {
|
||||
it('mounts the bash sample, the read row, and the todo row as keyed entries through the load-order seam', async () => {
|
||||
const b = await bench()
|
||||
// Both registrant plugins' inject: ['slots', 'conversation'] resolved — the
|
||||
// All registrant plugins' inject: ['slots', 'conversation'] resolved — the
|
||||
// service being present implies the chat entry declared the hole first.
|
||||
const entries = b.slots.entries('conversation.chat.toolview')
|
||||
expect(entries.map(e => e.options.key)).toEqual(['bash', 'todo_write'])
|
||||
expect(entries.map(e => e.options.key)).toEqual(['bash', 'read', 'todo_write'])
|
||||
// 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()
|
||||
|
||||
282
packages/client/ui-conversation/tests/read-card.spec.tsx
Normal file
282
packages/client/ui-conversation/tests/read-card.spec.tsx
Normal file
@@ -0,0 +1,282 @@
|
||||
// @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 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, ToolRowOwnerProps, 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 } from '../src/client/chat/GenericToolCard.tsx'
|
||||
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
|
||||
|
||||
const 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', 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): ToolRowOwnerProps => ({
|
||||
callId: 'c1', toolName: 'web_fetch', block, openFile: vi.fn(),
|
||||
})
|
||||
|
||||
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: 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(),
|
||||
})} />)
|
||||
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: [], state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: true, recentWorkspaceId: undefined,
|
||||
})
|
||||
return render(
|
||||
<DetailsPanel
|
||||
sessionId={SID}
|
||||
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(/"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('Output').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()
|
||||
})
|
||||
})
|
||||
117
packages/client/ui-primitives/src/ReadBlock.module.css
Normal file
117
packages/client/ui-primitives/src/ReadBlock.module.css
Normal file
@@ -0,0 +1,117 @@
|
||||
/* Geometry mirrors CodeBlock (12px radius, code-block surface + banner row,
|
||||
markdown code-block font) so a read card and a fenced code block read as one
|
||||
family. Content keeps `white-space: pre` and scrolls horizontally rather than
|
||||
folding, because a source line's indentation is part of what a reader is
|
||||
reading. */
|
||||
|
||||
.block {
|
||||
--dsl-read-radius: 12px;
|
||||
--dsl-read-line-height: 22px;
|
||||
/* Fixed-width gutter column for the line numbers, so the content edge stays
|
||||
put down the whole window regardless of how wide the numbers grow. */
|
||||
--dsl-read-gutter: 48px;
|
||||
|
||||
position: relative;
|
||||
margin: 16px 0;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
background: var(--dsw-alias-markdown-code-block);
|
||||
border-radius: var(--dsl-read-radius);
|
||||
}
|
||||
|
||||
.banner {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 9px 14px;
|
||||
background: var(--dsw-alias-markdown-code-block-banner);
|
||||
border-top-left-radius: var(--dsl-read-radius);
|
||||
border-top-right-radius: var(--dsl-read-radius);
|
||||
}
|
||||
|
||||
.label {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font-family: var(--ds-font-family-code);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.count {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font: var(--dsw-font-xs-13);
|
||||
}
|
||||
|
||||
.lang {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-family: var(--ds-font-family-code);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.copyButton {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
cursor: pointer;
|
||||
font: var(--dsw-font-xs-13);
|
||||
}
|
||||
|
||||
.body {
|
||||
padding: 12px 0;
|
||||
font: var(--dsw-font-markdown-code-block);
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
/* One row per file line: a fixed gutter column, then the content. No wrapping —
|
||||
a source line's leading whitespace is meaningful and scrolls sideways. */
|
||||
.line {
|
||||
display: flex;
|
||||
min-height: var(--dsl-read-line-height);
|
||||
line-height: var(--dsl-read-line-height);
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.gutter {
|
||||
flex: none;
|
||||
width: var(--dsl-read-gutter);
|
||||
padding-right: 14px;
|
||||
text-align: right;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
/* The gutter is chrome, not content: keep it out of a text selection so a
|
||||
copy of the visible rows carries the source, not the line numbers. */
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.content {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.expand {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0 0 0 var(--dsl-read-gutter);
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.expand:hover {
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
156
packages/client/ui-primitives/src/ReadBlock.tsx
Normal file
156
packages/client/ui-primitives/src/ReadBlock.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
// ReadBlock: the file surface for a read tool result — a banner (label +
|
||||
// language + a "showing N of M" note when the read is a window + a copy
|
||||
// control) over line-numbered, syntax-highlighted source. Each row carries the
|
||||
// file's OWN line number in a gutter, so a windowed read past an offset keeps
|
||||
// its file numbering rather than re-counting from 1. Highlighting reuses the
|
||||
// CodeBlock shiki path (highlight.ts) at the per-line granularity a gutter
|
||||
// needs; an unknown or absent language renders plain monospace. Long content is
|
||||
// height-capped with the same head/tail arithmetic TerminalBlock uses, so the
|
||||
// two cards collapse a long body at the same place. Colors resolve through
|
||||
// --shiki-*/--dsw-* tokens.
|
||||
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { writeClipboard } from './clipboard.ts'
|
||||
import { highlightLines, type HighlightSpan } from './markdown/highlight.ts'
|
||||
import css from './ReadBlock.module.css'
|
||||
|
||||
/**
|
||||
* Content lines shown before the height cap collapses the middle. Matches
|
||||
* TerminalBlock's default so a long read and a long command output cut at the
|
||||
* same place in the same flow.
|
||||
*/
|
||||
export const DEFAULT_READ_MAX_LINES = 16
|
||||
|
||||
/** One line of the read window: its file line number and its text (no trailing newline). */
|
||||
export interface ReadBlockLine {
|
||||
/** 1-based line number in the file (a window past an offset keeps the file's own numbering). */
|
||||
number: number
|
||||
/** The line's text, already truncated to the read tool's per-line cap. */
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface ReadBlockProps {
|
||||
/** Banner label (the file path, or a tool-supplied replacement title); omitted draws no label. */
|
||||
label?: string | undefined
|
||||
/** The returned window's lines, in file order, each keeping its file line number. */
|
||||
lines: readonly ReadBlockLine[]
|
||||
/** Exact total line count in the file, for the "showing N of M" note when the read is a window. */
|
||||
totalLines: number
|
||||
/** Grammar hint (a file-extension-derived language id); unknown or absent = plain monospace. */
|
||||
lang?: string | undefined
|
||||
/** Height cap in content lines before the middle collapses (default {@link DEFAULT_READ_MAX_LINES}). */
|
||||
maxLines?: number | undefined
|
||||
/** Extra class merged onto the wrapper (callers position; this component draws). */
|
||||
className?: string | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one line's highlighted runs. The css-variables theme colors every run,
|
||||
* so each run is a styled span; a line with no highlighting at all takes the
|
||||
* bare-text path in the caller instead (an unknown or absent language).
|
||||
* @param spans - the line's styled runs.
|
||||
* @returns the line's children.
|
||||
*/
|
||||
function renderSpans(spans: readonly HighlightSpan[]) {
|
||||
return spans.map((span, index) => <span key={index} style={span.style}>{span.text}</span>)
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a read tool result as a line-numbered, optionally syntax-highlighted
|
||||
* file view.
|
||||
* @param props - see {@link ReadBlockProps}.
|
||||
* @returns the read block element.
|
||||
*/
|
||||
export function ReadBlock({
|
||||
label,
|
||||
lines,
|
||||
totalLines,
|
||||
lang,
|
||||
maxLines = DEFAULT_READ_MAX_LINES,
|
||||
className,
|
||||
}: ReadBlockProps) {
|
||||
// The raw text the copy control writes and the highlighter tokenizes: the
|
||||
// window's lines joined by newlines, without the file numbers or any chrome.
|
||||
// Highlighting the whole window in one call (not line by line) keeps grammar
|
||||
// context across lines — a multi-line string or comment stays one construct.
|
||||
const raw = useMemo(() => lines.map(line => line.text).join('\n'), [lines])
|
||||
// Per-line highlighted runs aligned 1:1 with `lines`; undefined for an
|
||||
// unknown/absent language, when every line renders as bare text.
|
||||
const highlighted = useMemo(() => highlightLines(raw, lang), [raw, lang])
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const onCopy = useCallback(() => {
|
||||
if (copied) return
|
||||
// The window's raw text, never the rendered tree: the gutter numbers and the
|
||||
// banner are chrome the file does not contain.
|
||||
void writeClipboard(raw).then((ok) => {
|
||||
if (!ok) return
|
||||
setCopied(true)
|
||||
window.setTimeout(() => { setCopied(false) }, 1000)
|
||||
})
|
||||
}, [copied, raw])
|
||||
|
||||
const onToggle = useCallback(() => { setExpanded(value => !value) }, [])
|
||||
|
||||
const hidden = lines.length - maxLines
|
||||
const capped = hidden > 0 && !expanded
|
||||
// Same split arithmetic as TerminalBlock's height cap, so a long read and a
|
||||
// long command output slice their head and tail at the same place.
|
||||
const headLines = Math.ceil(maxLines / 2)
|
||||
const tailLines = maxLines - headLines
|
||||
// A read is a window when its returned lines are fewer than the file's total;
|
||||
// the note states that so a reader is not misled that the file ends here.
|
||||
const windowed = lines.length < totalLines
|
||||
|
||||
/**
|
||||
* Render a slice of the line array as gutter-numbered rows.
|
||||
* @param slice - the lines to draw, each with its aligned run array.
|
||||
* @returns the row elements.
|
||||
*/
|
||||
const rows = (slice: readonly (readonly [ReadBlockLine, readonly HighlightSpan[] | undefined])[]) =>
|
||||
slice.map(([line, spans]) => (
|
||||
<div key={line.number} className={css.line}>
|
||||
<span className={css.gutter} aria-hidden>{line.number}</span>
|
||||
<span className={css.content}>{spans === undefined ? line.text : renderSpans(spans)}</span>
|
||||
</div>
|
||||
))
|
||||
|
||||
// Pair each line with its aligned run array up front, so head/tail slicing
|
||||
// keeps the two in step without re-indexing.
|
||||
const paired = lines.map((line, index): readonly [ReadBlockLine, readonly HighlightSpan[] | undefined] =>
|
||||
[line, highlighted?.[index]])
|
||||
|
||||
return (
|
||||
<div className={clsx(css.block, className)} data-read="">
|
||||
<div className={css.banner}>
|
||||
<div className={css.label}>{label ?? ''}</div>
|
||||
<div className={css.action}>
|
||||
{windowed && (
|
||||
<span className={css.count}>{`显示 ${lines.length} / ${totalLines} 行`}</span>
|
||||
)}
|
||||
<span className={css.lang}>{lang ?? ''}</span>
|
||||
<button type="button" className={css.copyButton} onClick={onCopy}>
|
||||
{copied ? '复制成功' : '复制'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={css.body}>
|
||||
{rows(capped ? paired.slice(0, headLines) : paired)}
|
||||
{hidden > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className={css.expand}
|
||||
aria-expanded={expanded}
|
||||
aria-label={expanded ? '收起内容' : `展开其余 ${hidden} 行`}
|
||||
onClick={onToggle}
|
||||
>
|
||||
{expanded ? '收起' : `… 其余 ${hidden} 行`}
|
||||
</button>
|
||||
)}
|
||||
{capped && rows(paired.slice(paired.length - tailLines))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -22,6 +22,8 @@ export { JsonTree } from './JsonTree.tsx'
|
||||
export type { JsonTreeProps } from './JsonTree.tsx'
|
||||
export { TerminalBlock, DEFAULT_TERMINAL_MAX_LINES } from './TerminalBlock.tsx'
|
||||
export type { TerminalBlockProps } from './TerminalBlock.tsx'
|
||||
export { ReadBlock, DEFAULT_READ_MAX_LINES } from './ReadBlock.tsx'
|
||||
export type { ReadBlockProps, ReadBlockLine } from './ReadBlock.tsx'
|
||||
export { CodeBlock } from './markdown/CodeBlock.tsx'
|
||||
export { JsonBlock } from './markdown/JsonBlock.tsx'
|
||||
export { MarkdownText } from './markdown/MarkdownText.tsx'
|
||||
|
||||
@@ -17,6 +17,7 @@ import langTs from '@shikijs/langs/typescript'
|
||||
import langBash from '@shikijs/langs/shellscript'
|
||||
import langJson from '@shikijs/langs/json'
|
||||
import type { HighlighterCore } from 'shiki/core'
|
||||
import type { CSSProperties } from 'react'
|
||||
|
||||
/**
|
||||
* Language ids (and aliases) the singleton registers; everything else renders
|
||||
@@ -80,3 +81,42 @@ export function highlightToHtml(code: string, lang: string | undefined): string
|
||||
if (resolved === undefined) return undefined
|
||||
return highlighter().codeToHtml(code, { lang: resolved, theme: 'css-variables' })
|
||||
}
|
||||
|
||||
/**
|
||||
* One highlighted run of a line: the text and the inline style shiki assigned
|
||||
* it. The css-variables theme colors every run through a `--shiki-*` custom
|
||||
* property, so `style.color` is always present; it is held as a style object
|
||||
* rather than a bare color so a run spreads onto a `<span style>` uniformly.
|
||||
*/
|
||||
export interface HighlightSpan {
|
||||
text: string
|
||||
style: CSSProperties
|
||||
}
|
||||
|
||||
/**
|
||||
* Tokenize `code` into per-line highlighted runs when `lang` maps to a
|
||||
* registered grammar; `undefined` means the caller renders its plain fallback.
|
||||
* A line-numbered view needs the token runs split per line (one gutter number
|
||||
* per line), which the single-`<pre>` {@link highlightToHtml} does not expose,
|
||||
* so this returns shiki's own 2D line/token structure narrowed to what a run
|
||||
* renders. Each run's color is a `--shiki-*` custom property, keeping token
|
||||
* colors on the theme package's sheets exactly as the HTML path does; the
|
||||
* css-variables theme carries no font-style bits, matching that path's
|
||||
* color-only output. The trailing newline shiki appends as a final empty line
|
||||
* is dropped so the run count matches the caller's own line array.
|
||||
* @param code - the source text.
|
||||
* @param lang - the language hint (a file-extension-derived language id).
|
||||
* @returns one entry per source line (each an array of runs), or `undefined` for unknown languages.
|
||||
*/
|
||||
export function highlightLines(code: string, lang: string | undefined): HighlightSpan[][] | undefined {
|
||||
const resolved = lang === undefined ? undefined : LANG_ALIASES.get(lang.toLowerCase())
|
||||
if (resolved === undefined) return undefined
|
||||
const { tokens } = highlighter().codeToTokens(code, { lang: resolved, theme: 'css-variables' })
|
||||
// shiki tokenizes `a\nb` into two lines; a trailing newline (`a\n`) adds a
|
||||
// third, empty line the caller's own line array does not carry. Drop that
|
||||
// one terminator line so the two structures stay in step.
|
||||
const lines = tokens.length > 1 && tokens[tokens.length - 1]?.length === 0
|
||||
? tokens.slice(0, -1)
|
||||
: tokens
|
||||
return lines.map(line => line.map(token => ({ text: token.content, style: { color: token.color } })))
|
||||
}
|
||||
|
||||
215
packages/client/ui-primitives/tests/read-block.spec.tsx
Normal file
215
packages/client/ui-primitives/tests/read-block.spec.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
// @vitest-environment jsdom
|
||||
// ReadBlock + the highlightLines token path: the banner (label, language, the
|
||||
// "showing N of M" note only when the read is a window, copy control), the
|
||||
// gutter-numbered rows keeping the file's own line numbers, the shiki per-line
|
||||
// highlighting resolved to css-variables token spans with an identical-geometry
|
||||
// plain fallback for an unknown/absent language, the head/tail height cap and
|
||||
// its expand control, and the copy control writing the raw window text on both
|
||||
// the accepted and refused clipboard paths.
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { DEFAULT_READ_MAX_LINES, ReadBlock, type ReadBlockLine } from '../src/index.ts'
|
||||
import { highlightLines } from '../src/markdown/highlight.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
/** `count` lines starting at `first`, each with distinct text. */
|
||||
function lines(count: number, first = 1): ReadBlockLine[] {
|
||||
return Array.from({ length: count }, (_value, index) => ({ number: first + index, text: `line ${first + index}` }))
|
||||
}
|
||||
|
||||
/** The rendered rows as `<gutter><content>` strings (CSS-module class prefix). */
|
||||
function rowTexts(container: HTMLElement): string[] {
|
||||
return [...container.querySelectorAll('[class^="_line_"]')].map(row => row.textContent ?? '')
|
||||
}
|
||||
|
||||
/** The gutter numbers of the rendered rows, in order. */
|
||||
function gutters(container: HTMLElement): string[] {
|
||||
return [...container.querySelectorAll('[class^="_gutter_"]')].map(cell => cell.textContent ?? '')
|
||||
}
|
||||
|
||||
describe('highlightLines', () => {
|
||||
it('tokenizes a registered grammar into per-line css-variables runs', () => {
|
||||
const result = highlightLines('const x = 1\n// c', 'ts')
|
||||
expect(result).not.toBeUndefined()
|
||||
expect(result).toHaveLength(2)
|
||||
// The keyword run carries a color style through a --shiki-* custom property.
|
||||
const keyword = result![0]!.find(span => span.text === 'const')
|
||||
expect(keyword?.style?.color).toContain('var(--shiki-')
|
||||
// Whitespace between tokens is a run of its own; the comment is line two.
|
||||
expect(result![0]!.map(span => span.text).join('')).toBe('const x = 1')
|
||||
expect(result![1]!.map(span => span.text).join('')).toBe('// c')
|
||||
})
|
||||
|
||||
it('colors every run through a --shiki-* custom property', () => {
|
||||
// The css-variables theme colors even the whitespace run (as the foreground
|
||||
// token), so every run is a styled span; the plain fallback is the whole
|
||||
// unknown-language path, not a per-run one.
|
||||
const result = highlightLines('const x = 1', 'ts')
|
||||
for (const span of result!) for (const run of span) expect(run.style.color).toContain('var(--shiki-')
|
||||
})
|
||||
|
||||
it('drops the trailing terminator line so the run count matches the source lines', () => {
|
||||
// `a\n` tokenizes to two lines in shiki (the second empty); the caller's own
|
||||
// line array has one entry, so the terminator line is dropped.
|
||||
const result = highlightLines('const a = 1\n', 'ts')
|
||||
expect(result).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('keeps a genuinely blank final line when the source ends in two newlines', () => {
|
||||
const result = highlightLines('a\n\n', 'ts')
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result![1]).toEqual([])
|
||||
})
|
||||
|
||||
it('returns undefined for an unknown or absent language', () => {
|
||||
expect(highlightLines('x', 'cobol')).toBeUndefined()
|
||||
expect(highlightLines('x', undefined)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ReadBlock rows', () => {
|
||||
it('renders one gutter-numbered row per line, keeping the file line numbers', () => {
|
||||
const view = render(<ReadBlock label="a.ts" lines={lines(3, 41)} totalLines={3} />)
|
||||
expect(gutters(view.container)).toEqual(['41', '42', '43'])
|
||||
expect(rowTexts(view.container)).toEqual(['41line 41', '42line 42', '43line 43'])
|
||||
})
|
||||
|
||||
it('highlights the content for a known language into token spans', () => {
|
||||
const view = render(
|
||||
<ReadBlock label="a.ts" lang="ts" lines={[{ number: 1, text: 'const a = 1' }]} totalLines={1} />,
|
||||
)
|
||||
const content = view.container.querySelector('[class^="_content_"]')
|
||||
expect(content?.querySelectorAll('span[style]').length).toBeGreaterThan(1)
|
||||
expect(content?.textContent).toBe('const a = 1')
|
||||
})
|
||||
|
||||
it('renders the content as bare text with no span wrappers for an unknown language', () => {
|
||||
const view = render(
|
||||
<ReadBlock label="a.cob" lang="cobol" lines={[{ number: 1, text: 'IDENT DIVISION.' }]} totalLines={1} />,
|
||||
)
|
||||
const content = view.container.querySelector('[class^="_content_"]')
|
||||
expect(content?.querySelectorAll('span').length).toBe(0)
|
||||
expect(content?.textContent).toBe('IDENT DIVISION.')
|
||||
})
|
||||
|
||||
it('renders bare text when no language is given', () => {
|
||||
const view = render(<ReadBlock label="x" lines={[{ number: 1, text: 'plain' }]} totalLines={1} />)
|
||||
const content = view.container.querySelector('[class^="_content_"]')
|
||||
expect(content?.querySelectorAll('span').length).toBe(0)
|
||||
expect(view.getByText('plain')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ReadBlock banner', () => {
|
||||
it('shows the label, the language, and the count note when the read is a window', () => {
|
||||
const view = render(<ReadBlock label="src/a.ts" lang="ts" lines={lines(3, 41)} totalLines={180} />)
|
||||
expect(view.getByText('src/a.ts')).toBeTruthy()
|
||||
expect(view.getByText('ts')).toBeTruthy()
|
||||
expect(view.getByText('显示 3 / 180 行')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('omits the count note when the window is the whole file', () => {
|
||||
const view = render(<ReadBlock label="a.ts" lines={lines(3)} totalLines={3} />)
|
||||
expect(view.queryByText(/显示/u)).toBeNull()
|
||||
})
|
||||
|
||||
it('draws an empty label and empty language when neither is given', () => {
|
||||
const view = render(<ReadBlock lines={lines(1)} totalLines={1} />)
|
||||
expect(view.container.querySelector('[class^="_label_"]')?.textContent).toBe('')
|
||||
expect(view.container.querySelector('[class^="_lang_"]')?.textContent).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('ReadBlock height cap', () => {
|
||||
it('renders every line and no expand control under the cap', () => {
|
||||
const view = render(<ReadBlock label="a" lines={lines(4)} totalLines={4} maxLines={4} />)
|
||||
expect(rowTexts(view.container)).toHaveLength(4)
|
||||
expect(view.container.querySelector('[aria-expanded]')).toBeNull()
|
||||
})
|
||||
|
||||
it('slices head and tail over the cap and expands on click', () => {
|
||||
const view = render(<ReadBlock label="a" lines={lines(10)} totalLines={10} maxLines={4} />)
|
||||
// maxLines 4: head = ceil(4/2) = 2, tail = 4 - 2 = 2, 6 hidden.
|
||||
expect(gutters(view.container)).toEqual(['1', '2', '9', '10'])
|
||||
const toggle = view.getByRole('button', { name: '展开其余 6 行' })
|
||||
expect(toggle.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(toggle.textContent).toBe('… 其余 6 行')
|
||||
|
||||
fireEvent.click(toggle)
|
||||
expect(rowTexts(view.container)).toHaveLength(10)
|
||||
const collapse = view.getByRole('button', { name: '收起内容' })
|
||||
expect(collapse.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(collapse.textContent).toBe('收起')
|
||||
|
||||
fireEvent.click(collapse)
|
||||
expect(gutters(view.container)).toEqual(['1', '2', '9', '10'])
|
||||
})
|
||||
|
||||
it('renders the head slice alone when the cap leaves no tail', () => {
|
||||
const view = render(<ReadBlock label="a" lines={lines(5)} totalLines={5} maxLines={1} />)
|
||||
expect(gutters(view.container)).toEqual(['1'])
|
||||
expect(view.getByRole('button', { name: '展开其余 4 行' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('caps at the documented default when maxLines is absent', () => {
|
||||
const view = render(
|
||||
<ReadBlock label="a" lines={lines(DEFAULT_READ_MAX_LINES + 1)} totalLines={DEFAULT_READ_MAX_LINES + 1} />,
|
||||
)
|
||||
expect(rowTexts(view.container)).toHaveLength(DEFAULT_READ_MAX_LINES)
|
||||
expect(view.getByRole('button', { name: '展开其余 1 行' })).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ReadBlock copy', () => {
|
||||
it('copies the raw window text, joined by newlines, never the gutter numbers', async () => {
|
||||
vi.useFakeTimers()
|
||||
const writeText = vi.fn().mockResolvedValue(undefined)
|
||||
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
|
||||
render(<ReadBlock label="a" lines={lines(3, 41)} totalLines={180} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: '复制' }))
|
||||
expect(writeText).toHaveBeenCalledWith('line 41\nline 42\nline 43')
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(screen.getByRole('button', { name: '复制成功' })).toBeTruthy()
|
||||
// While the ok label is showing, further clicks are no-ops.
|
||||
fireEvent.click(screen.getByRole('button', { name: '复制成功' }))
|
||||
expect(writeText).toHaveBeenCalledTimes(1)
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('copies the whole window while the height cap hides its middle', async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined)
|
||||
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
|
||||
render(<ReadBlock label="a" lines={lines(10)} totalLines={10} maxLines={4} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: '复制' }))
|
||||
expect(writeText).toHaveBeenCalledWith(lines(10).map(line => line.text).join('\n'))
|
||||
expect(await screen.findByRole('button', { name: '复制成功' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('does not claim success when the host refuses the write', async () => {
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText: vi.fn().mockRejectedValue(new Error('denied')) },
|
||||
})
|
||||
render(<ReadBlock label="a" lines={lines(1)} totalLines={1} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: '复制' }))
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
|
||||
expect(screen.queryByRole('button', { name: '复制成功' })).toBeNull()
|
||||
})
|
||||
|
||||
it('merges className onto the wrapper', () => {
|
||||
const view = render(<ReadBlock className="x" label="a" lines={lines(1)} totalLines={1} />)
|
||||
expect(view.container.firstElementChild?.classList.contains('x')).toBe(true)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user