diff --git a/packages/experimental/client-ui-perf/src/client/PerfView.tsx b/packages/experimental/client-ui-perf/src/client/PerfView.tsx index 48b52701ef..ed5b51b75a 100644 --- a/packages/experimental/client-ui-perf/src/client/PerfView.tsx +++ b/packages/experimental/client-ui-perf/src/client/PerfView.tsx @@ -3,7 +3,10 @@ * rows, the time-composition bars, and the per-step tool-call accordion. */ -import { Fragment, useEffect, useState, type ReactElement, type ReactNode } from 'react' +import { + Fragment, useEffect, useLayoutEffect, useRef, useState, + type ReactElement, type ReactNode, type UIEvent, +} from 'react' import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { InjectFace, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' @@ -15,15 +18,16 @@ import { formatTokens, formatTokensDelta, formatTokensShort, formatTps, } from './perf-format.ts' import { isNumber } from './perf-usage.ts' +import { CALL_LIMIT } from './perf-fold.ts' import type { PerfTranslate } from './locales.ts' import css from './perf.module.css' -/** Turns rendered per page; the newest page is the tail of the turn list. */ -const PAGE_SIZE = 10 /** Steps one turn renders before the "show all" control appears. */ const STEP_LIMIT = 24 -/** Tool calls one step's accordion lists; the builder keeps at most this many. */ -const CALL_LIMIT = 12 +/** Distance from the scroll top, in pixels, that requests one older history page. */ +const LOAD_OLDER_THRESHOLD = 64 +/** How long the clipboard button reports its outcome, in milliseconds. */ +const COPY_FEEDBACK_MS = 1_200 /** Lines one code block renders before the line-cap notice. */ const CODE_LINE_LIMIT = 400 /** Line count above which a diff falls back to whole-side removal and addition. */ @@ -80,6 +84,11 @@ export interface PerfViewInjected { /** This target's snapshot; InjectFace exposes it as `usePerf`. */ perf: ObservableSnapshot } + /** + * Load one older history page into the published window. + * @returns false when the snapshot produced no new page. + */ + loadOlder: () => Promise } /** Full props of the performance conversation View. */ @@ -98,9 +107,8 @@ interface StepViewState { readonly wrap?: boolean } -/** Component-local table state: page anchor, turn folds, and per-turn step state. */ +/** Component-local table state: turn folds and per-turn step state. */ interface ViewState { - readonly anchor: number | null readonly mode: ViewMode readonly openTurns: Readonly> readonly stepViews: Readonly> @@ -115,8 +123,8 @@ interface MetricsValues { readonly model: number | null readonly tps: number | null readonly tool: number | null - readonly cacheRead?: number | null - readonly cacheWrite?: number | null + readonly cacheRead: number | null + readonly cacheWrite: number | null readonly ttftPending?: number readonly modelPending?: number readonly toolPending?: number @@ -139,6 +147,8 @@ interface TimeState { readonly ttft: number | null readonly model: number | null readonly tool: number | null + /** Non-overlapping union of the paired tool calls; the bar prefers it over `tool`. */ + readonly toolWall: number | null } /** Context growth from the previous step of the same turn. */ @@ -213,8 +223,9 @@ interface CallBlockProps { readonly onToggleRaw: () => void } -/** Props of the clipboard button. */ +/** Props of the clipboard button; it renders only where the host exposes the Clipboard API. */ interface CopyButtonProps { + readonly clipboard: Clipboard readonly clipboardValue: string readonly t: PerfTranslate } @@ -297,9 +308,9 @@ function defaultTurnOpen(mode: ViewMode, turnNumber: number, latestTurn: number return turnNumber === latestTurn } -/** Fresh table state: follow the newest page, expand the newest turn. */ +/** Fresh table state: follow the newest turn's open default. */ function createViewState(): ViewState { - return { anchor: null, mode: 'newest', openTurns: {}, stepViews: {} } + return { mode: 'newest', openTurns: {}, stepViews: {} } } /** Read an array element whose index the surrounding bounds already prove. */ @@ -470,14 +481,17 @@ function callBody(call: PerfCall, t: PerfTranslate, wrap: boolean, raw: boolean) ) } -/** Clipboard button: copies the body and reports success for a moment. */ -function CopyButton({ clipboardValue, t }: CopyButtonProps): ReactElement { - const [copied, setCopied] = useState(false) +/** Clipboard button: copies the displayed body and reports the outcome for a moment. */ +function CopyButton({ clipboard, clipboardValue, t }: CopyButtonProps): ReactElement { + const [status, setStatus] = useState<'idle' | 'copied' | 'failed'>('idle') useEffect(() => { - if (!copied) return undefined - const timer = setTimeout(() => { setCopied(false) }, 1200) + if (status === 'idle') return undefined + const timer = setTimeout(() => { setStatus('idle') }, COPY_FEEDBACK_MS) return () => { clearTimeout(timer) } - }, [copied]) + }, [status]) + const label = status === 'copied' + ? t('detail.copied') + : status === 'failed' ? t('detail.copyFailed') : t('detail.copy') return ( ) } +/** + * Host Clipboard API, or undefined where the app cannot copy. + * lib.dom declares the member non-optional, but insecure contexts and jsdom omit it. + * @returns the clipboard, or undefined when the host exposes none. + */ +function clipboardOf(): Clipboard | undefined { + return navigator.clipboard +} + /** One tool call's accordion block: head metadata plus the selected body. */ function CallBlock({ call, t, wrap, raw, onToggleRaw }: CallBlockProps): ReactElement { const meta: string[] = [] @@ -506,7 +530,10 @@ function CallBlock({ call, t, wrap, raw, onToggleRaw }: CallBlockProps): ReactEl if (isNumber(call.lines)) meta.push(t('detail.lines', { lines: formatTokens(call.lines) })) if (isNumber(call.ms)) meta.push(formatMs(call.ms)) const fallback = [call.before ?? '', call.after ?? ''].filter(part => part !== '').join('\n') - const clipboardValue = call.text ?? fallback + // Copy exactly what the body shows: the raw arguments while that view is active, + // otherwise the result text or the before/after fallback. + const clipboardValue = raw && call.args !== null ? call.args : call.text ?? fallback + const clipboard = clipboardOf() return (
@@ -532,7 +559,9 @@ function CallBlock({ call, t, wrap, raw, onToggleRaw }: CallBlockProps): ReactEl {'{}'} )} - {clipboardValue === '' ? null : } + {clipboardValue === '' || clipboard === undefined + ? null + : }
{callBody(call, t, wrap, raw)}
@@ -743,8 +772,10 @@ const COLUMN_DETAIL: Readonly< > = { cache: (value, t) => { if (!isNumber(value.cacheHit)) return '' - const read = isNumber(value.cacheRead) ? value.cacheRead : 0 - const write = isNumber(value.cacheWrite) ? value.cacheWrite : 0 + // A row without a recorded breakdown keeps the column's formula explanation. + if (!isNumber(value.cacheRead) && !isNumber(value.cacheWrite)) return t('column.cache.title') + const read = value.cacheRead ?? 0 + const write = value.cacheWrite ?? 0 const context = isNumber(value.context) ? value.context : read + write const input = Math.max(0, context - read - write) return t('cache.detail', { @@ -814,7 +845,9 @@ function TimeCell({ value, scale, gap, className, trackClass, now, t }: TimeCell const running = isTimeRunning(value) const ttft = isNumber(value.ttft) ? Math.min(value.ttft, total) : 0 const model = isNumber(value.model) ? Math.min(value.model, total) : ttft - const tool = isNumber(value.tool) ? Math.min(value.tool, total) : 0 + // The bar shows the non-overlapping tool span, not the sum the tool column shows. + const toolSpan = isNumber(value.toolWall) ? value.toolWall : value.tool + const tool = isNumber(toolSpan) ? Math.min(toolSpan, total) : 0 const decode = Math.max(0, model - ttft) const rest = Math.max(0, total - model - tool) const share = barShare(total, scale) @@ -1125,7 +1158,23 @@ function stepRows(options: StepRowsOptions): ReactNode[] { title={hasDetail ? t('detail.title') : undefined} > - {t('step.label', { step: step.step })} + {hasDetail + ? ( + + ) + : t('step.label', { step: step.step })} {scopeMarkers(step, t)} {isNumber(step.startTime) ? null @@ -1178,7 +1227,19 @@ function turnRows(options: TurnRowsOptions): ReactNode[] { title={t('turn.toggle.title')} > - {t('turn.label', { turn: turn.turn })} + {` · ${t('turn.stepCount', { count: turn.steps.length })}`} {` · ${toggleLabel}`} {isNumber(turn.startTime) ? ( @@ -1227,32 +1288,38 @@ function TableButton({ caption, enabled, onClick }: TableButtonProps): ReactElem ) } +/** Scroll metrics captured before an older page loads, applied once its turns commit. */ +interface PendingPrepend { + readonly height: number + readonly top: number + readonly firstTurn: number | null +} + /** - * Render the performance table of the current conversation view target. - * @param props - composed conversation View props: the perf snapshot hook and the locale seat. - * @returns the table, its empty state, and the pagination footer. + * Render the performance table of the current conversation view target. The + * table owns the viewport scroll: reaching the top asks the injected loader + * for one older page while the already published turns stay in place. + * @param props - composed conversation View props: the perf snapshot hook, the older-page loader, and the locale seat. + * @returns the scrolling table, its empty state, and the expand/collapse footer. */ export function PerfView(props: PerfViewProps): ReactElement { - const { usePerf, t } = props + const { usePerf, t, loadOlder } = props const snapshot = usePerf(value => value) const [view, setView] = useState(createViewState) const [now, setNow] = useState(() => Date.now()) + const [loading, setLoading] = useState(false) + const [exhausted, setExhausted] = useState(false) + const scrollRef = useRef(null) + const loadingRef = useRef(false) + const pendingPrepend = useRef(null) + const initialized = useRef(false) const turns = snapshot.turns - const pages = Math.max(1, Math.ceil(turns.length / PAGE_SIZE)) - let end = turns.length - if (view.anchor !== null) { - const at = turns.findIndex(turn => turn.turn === view.anchor) - if (at >= 0) end = at + 1 - } - const start = Math.max(0, end - PAGE_SIZE) - const visible = turns.slice(start, end) - const page = Math.max(1, Math.min(pages, Math.ceil(end / PAGE_SIZE))) - const newest = visible.at(-1) - const latestTurn = newest === undefined ? null : newest.turn + const firstTurn = turns[0]?.turn ?? null + const latestTurn = turns.at(-1)?.turn ?? null const isTurnOpen = (turnNumber: number): boolean => view.openTurns[turnNumber] ?? defaultTurnOpen(view.mode, turnNumber, latestTurn) - const allOpen = visible.length > 0 && visible.every(turn => isTurnOpen(turn.turn)) - const hasRunning = visible.some(turn => + const allOpen = turns.length > 0 && turns.every(turn => isTurnOpen(turn.turn)) + const hasRunning = turns.some(turn => isTimeRunning(turn) || turn.steps.some(step => isTimeRunning(step))) useEffect(() => { if (!hasRunning) return undefined @@ -1260,8 +1327,8 @@ export function PerfView(props: PerfViewProps): ReactElement { const timer = setInterval(() => { setNow(Date.now()) }, 1000) return () => { clearInterval(timer) } }, [hasRunning]) - // Turns compare only with turns on this page. - const turnScale = visible.reduce((max, turn) => { + // Turns compare only with the other turns in the window. + const turnScale = turns.reduce((max, turn) => { const span = totalSpan(turn, now) return span !== null && span > max ? span : max }, 0) @@ -1295,66 +1362,121 @@ export function PerfView(props: PerfViewProps): ReactElement { } }) } - const goOlder = (): void => { - const target = turns[start - 1] - /* v8 ignore next -- the older button is disabled while start is 0, so this guard only satisfies the index type. */ - if (target === undefined) return - setView(current => ({ ...current, anchor: target.turn })) + const requestOlder = async (): Promise => { + if (loadingRef.current || exhausted || turns.length === 0) return + const element = scrollRef.current + /* v8 ignore next -- the scrollport mounts with the first render, so a request never runs without it. */ + if (element === null) return + loadingRef.current = true + setLoading(true) + const before: PendingPrepend = { + height: element.scrollHeight, + top: element.scrollTop, + firstTurn, + } + let grew: boolean + try { + grew = await loadOlder() + } catch { + // A failed page leaves the trigger in place: the next scroll or click tries again. + loadingRef.current = false + setLoading(false) + return + } + if (grew) { + pendingPrepend.current = before + } else { + pendingPrepend.current = null + setExhausted(true) + } + loadingRef.current = false + setLoading(false) } - const goNewer = (): void => { - const next = Math.min(turns.length, end + PAGE_SIZE) - const target = next >= turns.length ? undefined : turns[next - 1] - setView(current => ({ ...current, anchor: target === undefined ? null : target.turn })) + // After every commit: open at the newest turn, then restore the reading + // position once an older page has prepended its turns above the viewport. + useLayoutEffect(() => { + const element = scrollRef.current + /* v8 ignore next -- the scrollport mounts with the first render. */ + if (element === null) return + if (!initialized.current && turns.length > 0) { + initialized.current = true + element.scrollTop = element.scrollHeight + return + } + const pending = pendingPrepend.current + if (pending === null || firstTurn === pending.firstTurn) return + pendingPrepend.current = null + element.scrollTop = pending.top + Math.max(0, element.scrollHeight - pending.height) + }) + const handleScroll = (event: UIEvent): void => { + if (event.currentTarget.scrollTop > LOAD_OLDER_THRESHOLD) return + void requestOlder() } return ( -
- {turns.length === 0 ? ( -
{t('empty')}
- ) : ( - - - - - - - {COLUMNS.map(name => ( - - ))} - - - - - {visible.flatMap(turn => turnRows({ - turn, - t, - now, - open: isTurnOpen(turn.turn), - state: view.stepViews[turn.turn] ?? EMPTY_STEP_VIEW, - turnScale, - onToggle: () => { toggleTurn(turn.turn) }, - onToggleDetail: (stepNumber) => { toggleStepDetail(turn.turn, stepNumber) }, - onControl: (patch) => { patchStepView(turn.turn, patch) }, - }))} - -
{t('column.scope')}{t('column.prompt')}{t('column.did')} - {t(`column.${name}`)} - {t('column.time')}
- )} -
- - {t('page.info', { turns: turns.length, steps: snapshot.stepCount, page, pages })} - - 0} - onClick={toggleAllTurns} - /> - {pages > 1 ? ( - 0} onClick={goOlder} /> - ) : null} - {pages > 1 ? ( - - ) : null} +
+
+ {turns.length === 0 ? null : ( +
+ {loading ? ( + {t('loadOlder.loading')} + ) : exhausted ? ( + {t('loadOlder.done')} + ) : ( + + )} +
+ )} + {turns.length === 0 ? ( +
{t('empty')}
+ ) : ( + + + + + + + {COLUMNS.map(name => ( + + ))} + + + + + {turns.flatMap(turn => turnRows({ + turn, + t, + now, + open: isTurnOpen(turn.turn), + state: view.stepViews[turn.turn] ?? EMPTY_STEP_VIEW, + turnScale, + onToggle: () => { toggleTurn(turn.turn) }, + onToggleDetail: (stepNumber) => { toggleStepDetail(turn.turn, stepNumber) }, + onControl: (patch) => { patchStepView(turn.turn, patch) }, + }))} + +
{t('column.scope')}{t('column.prompt')}{t('column.did')} + {t(`column.${name}`)} + {t('column.time')}
+ )} +
+ + {t('summary', { turns: turns.length, steps: snapshot.stepCount })} + + 0} + onClick={toggleAllTurns} + /> +
) diff --git a/packages/experimental/client-ui-perf/src/client/locales.ts b/packages/experimental/client-ui-perf/src/client/locales.ts index 3b86449cad..93f78b0878 100644 --- a/packages/experimental/client-ui-perf/src/client/locales.ts +++ b/packages/experimental/client-ui-perf/src/client/locales.ts @@ -31,6 +31,7 @@ export const zh = { 'column.time.title': '总用时拆分:浅蓝 首 token、蓝 生成、橙 工具调用、灰 其余。条长按平方根刻度,步只和同一轮的步比,轮只和轮比', 'turn.label': '第 {turn} 轮', 'step.label': '第 {step} 步', + 'step.toggle.aria': '展开或折叠第 {step} 步的工具详情', 'step.partial': '起止未知', 'step.partial.title': '这一步的开始与结束事件不在当前事件窗口里(页签重连或窗口重建时被截断),首 token、模型用时、总用时都没有起点可以相减;重新加载页签会从完整窗口重新装配', 'state.running': '进行中', @@ -42,18 +43,19 @@ export const zh = { 'unknown': '未记录', 'partial': '还有 {count} 次工具调用没有拿到结果,这里是已知耗时的下限', 'empty': '当前窗口里还没有可以测量的步骤。', - 'page.info': '{turns} 轮 · {steps} 步 · 第 {page} / {pages} 页', - 'page.newer': '较新', - 'page.older': '较早', + 'loadOlder': '加载更早', + 'loadOlder.loading': '正在加载更早的轮次…', + 'loadOlder.done': '已经是最早的记录', + 'summary': '{turns} 轮 · {steps} 步', 'expand.all': '展开全部', 'collapse.all': '折叠全部', 'turn.expand': '展开', 'turn.collapse': '折叠', 'turn.toggle.title': '展开或折叠这一轮的每一步', + 'turn.toggle.aria': '展开或折叠第 {turn} 轮', 'did.answer': '回答', 'did.working': '生成中', 'did.aborted': '中断', - 'did.empty': '无输出', 'did.more': '其他 {count} 项', 'did.rest': '还有 {count} 次调用', 'did.title': '共调用 {calls} 次:{tools}', @@ -70,6 +72,7 @@ export const zh = { 'detail.copy': '复制', 'detail.copy.title': '复制这段正文', 'detail.copied': '已复制', + 'detail.copyFailed': '复制失败', 'detail.chars': '{chars} 字符', 'detail.lines': '{lines} 行', 'detail.file': '文件', @@ -77,7 +80,6 @@ export const zh = { 'detail.emptyBody': '这条调用没有留下正文', 'detail.hidden': '还有 {count} 次调用没有列出(一步最多列 {limit} 次)', 'detail.args': '原始参数', - 'detail.noArgs': '这条调用没有记录参数', 'detail.wrap': '换行', 'detail.wrap.title': '长行换行显示,不出现横向滚动', 'detail.raw.title': '查看原始 JSON 参数', @@ -98,7 +100,6 @@ export const zh = { 'bar.start': '开始于 {clock}', 'turn.stepCount': '{count} 步', 'turn.started': '这一轮开始于 {time}', - 'turn.started.title': '这一轮第一步开始的时刻', 'turn.images': '{count} 张图', 'steps.total': '共 {total} 步', 'steps.tail': '只看最新 {limit} 步', @@ -149,6 +150,7 @@ export const en: Record = { 'column.time.title': 'total split: light blue first token, blue generation, orange tool calls, gray everything else. Bar length uses a square-root scale; steps compare only with steps of the same turn, turns with turns', 'turn.label': 'Turn {turn}', 'step.label': 'Step {step}', + 'step.toggle.aria': 'Expand or collapse the tool details of step {step}', 'step.partial': 'start unknown', 'step.partial.title': 'This step\'s start and end events are not in the current event window (trimmed while the tab reconnected or the window was rebuilt), so first-token, model time and total have no start to subtract from; reloading the tab reassembles from the full window', 'state.running': 'running', @@ -160,18 +162,19 @@ export const en: Record = { 'unknown': 'not recorded', 'partial': '{count} tool calls have no result yet; this is the lower bound of known time', 'empty': 'No measurable step in the current window.', - 'page.info': '{turns} turns · {steps} steps · page {page} / {pages}', - 'page.newer': 'Newer', - 'page.older': 'Older', + 'loadOlder': 'Load earlier', + 'loadOlder.loading': 'Loading earlier turns…', + 'loadOlder.done': 'Beginning of history reached', + 'summary': '{turns} turns · {steps} steps', 'expand.all': 'Expand all', 'collapse.all': 'Collapse all', 'turn.expand': 'Show', 'turn.collapse': 'Hide', 'turn.toggle.title': 'Expand or collapse the steps of this turn', + 'turn.toggle.aria': 'Expand or collapse turn {turn}', 'did.answer': 'answer', 'did.working': 'generating', 'did.aborted': 'no answer', - 'did.empty': 'no output', 'did.more': '{count} more', 'did.rest': '{count} more calls', 'did.title': '{calls} calls: {tools}', @@ -188,6 +191,7 @@ export const en: Record = { 'detail.copy': 'Copy', 'detail.copy.title': 'Copy this text', 'detail.copied': 'Copied', + 'detail.copyFailed': 'Copy failed', 'detail.chars': '{chars} chars', 'detail.lines': '{lines} lines', 'detail.file': 'file', @@ -195,7 +199,6 @@ export const en: Record = { 'detail.emptyBody': 'This call left no text', 'detail.hidden': '{count} more calls are not listed (at most {limit} per step)', 'detail.args': 'raw arguments', - 'detail.noArgs': 'No arguments were recorded for this call', 'detail.wrap': 'Wrap', 'detail.wrap.title': 'Wrap long lines instead of scrolling sideways', 'detail.raw.title': 'Show the raw JSON arguments', @@ -216,7 +219,6 @@ export const en: Record = { 'bar.start': 'started {clock}', 'turn.stepCount': '{count} steps', 'turn.started': 'this turn started at {time}', - 'turn.started.title': 'when the first step of this turn began', 'turn.images': '{count} images', 'steps.total': '{total} steps', 'steps.tail': 'newest {limit} only', diff --git a/packages/experimental/client-ui-perf/src/client/perf.module.css b/packages/experimental/client-ui-perf/src/client/perf.module.css index 0d58460820..ddc4949cc5 100644 --- a/packages/experimental/client-ui-perf/src/client/perf.module.css +++ b/packages/experimental/client-ui-perf/src/client/perf.module.css @@ -1,12 +1,27 @@ /* Performance table: turn totals, expandable step rows, time bars, tool accordion. */ +/* Full-height host: the table owns the viewport scroll and the composer + overlays its bottom edge, so the content reserves the published height. */ .root { + display: flex; + flex-direction: column; box-sizing: border-box; - padding: 12px 20px 48px; + height: 100%; + min-height: 0; + overflow: hidden; color: var(--dsw-alias-label-primary); font: var(--dsw-font-xxs-12); } +.scroll { + box-sizing: border-box; + flex: 1; + height: 100%; + min-height: 0; + overflow-y: auto; + padding: 12px 20px calc(var(--dsh-composer-height, 152px) + 16px); +} + .empty { padding: 24px 0; color: var(--dsw-alias-label-secondary); @@ -85,6 +100,34 @@ font-weight: 400; } +/* The row label is a real button so the row toggles from the keyboard too. */ +.rowToggle { + padding: 0; + border: 0; + background: none; + color: inherit; + cursor: pointer; + font: inherit; + text-align: left; +} + +.rowToggle:focus-visible { + outline: 1px solid var(--dsw-alias-state-business-primary); + outline-offset: 2px; +} + +/* Older-history control above the table: the keyboard fallback and load state. */ +.older { + display: flex; + justify-content: center; + padding: 2px 0 10px; +} + +.olderStatus { + color: var(--dsw-alias-label-secondary); + font: var(--dsw-font-xxs-12); +} + .turnClock { color: var(--dsw-alias-label-tertiary); font-weight: 400; @@ -583,7 +626,7 @@ color: var(--dsw-alias-state-error-primary); } -/* Footer controls: pagination plus expand/collapse everything. */ +/* Footer controls: the passive table summary plus expand/collapse everything. */ .toolbar { display: flex; align-items: center; @@ -592,7 +635,7 @@ font: var(--dsw-font-xxs-12); } -.pageInfo { +.summary { margin-right: auto; color: var(--dsw-alias-label-secondary); } diff --git a/packages/experimental/client-ui-perf/tests/perf-view.client.spec.tsx b/packages/experimental/client-ui-perf/tests/perf-view.client.spec.tsx index ad896ea673..27c479f6b8 100644 --- a/packages/experimental/client-ui-perf/tests/perf-view.client.spec.tsx +++ b/packages/experimental/client-ui-perf/tests/perf-view.client.spec.tsx @@ -1,10 +1,11 @@ // @vitest-environment jsdom /** - * PerfView presentation behavior: the empty state and footer paging, turn and - * step rows with their scope markers and tooltips, the step tool-call - * accordion, code-block caps, step filters, and the live clock. Fixtures are - * built from the published metrics contract so each case varies one field and - * asserts visible labels. + * PerfView presentation behavior: the empty state and footer summary, the + * scrolling table with its older-history trigger, turn and step rows with + * their scope markers and tooltips, keyboard row controls, the copy control, + * the step tool-call accordion, code-block caps, step filters, and the live + * clock. Fixtures are built from the published metrics contract so each case + * varies one field and asserts visible labels. */ import { createElement } from 'react' @@ -56,6 +57,8 @@ function call(overrides: Partial = {}): PerfCall { text: null, truncated: false, ms: null, + startedAt: null, + endedAt: null, ...overrides, } } @@ -79,6 +82,7 @@ function step(overrides: Partial = {}): PerfStepMetrics { tps: 10, output: 300, tool: 200, + toolWall: null, tools: [], calls: [], toolOutput: 0, @@ -97,13 +101,7 @@ function step(overrides: Partial = {}): PerfStepMetrics { } /** One turn aggregate; defaults describe a settled turn with no steps. */ -/** Turn overrides; the row also reads the optional cache breakdown fields the step contract carries. */ -type TurnOverrides = Partial & { - readonly cacheRead?: number | null - readonly cacheWrite?: number | null -} - -function turn(overrides: TurnOverrides = {}): PerfTurnMetrics { +function turn(overrides: Partial = {}): PerfTurnMetrics { return { turn: 1, steps: [], @@ -117,9 +115,12 @@ function turn(overrides: TurnOverrides = {}): PerfTurnMetrics { tps: 10, output: 300, tool: 200, + toolWall: null, toolPending: 0, tools: [], cacheHit: null, + cacheRead: null, + cacheWrite: null, retries: 0, toolErrors: 0, toolOutput: 0, @@ -137,7 +138,7 @@ function turn(overrides: TurnOverrides = {}): PerfTurnMetrics { /** A one-turn snapshot carrying the given steps. */ function turnSnapshot( steps: readonly PerfStepMetrics[], - overrides: TurnOverrides = {}, + overrides: Partial = {}, ): PerfSnapshot { return { turns: [turn({ steps, ...overrides })], stepCount: steps.length } } @@ -150,14 +151,69 @@ function callSnapshot( return turnSnapshot([step({ calls, tools: [tool('read', calls.length)], ...overrides })]) } -/** Render the view with the two props it consumes. */ -function mount(snapshot: PerfSnapshot): ReturnType> { +/** Loader contract the view receives from the inject face. */ +type LoadOlder = () => Promise + +/** Render the view with every prop it consumes. */ +function mount( + snapshot: PerfSnapshot, + loadOlder: LoadOlder = async () => false, +): ReturnType> { const store = createSnapshotStore(snapshot) const usePerf = bindSnapshotSelector(store) - render(createElement(PerfView, { usePerf, t } as never)) + render(createElement(PerfView, { usePerf, t, loadOlder } as never)) return store } +/** The scrollport that owns the table's viewport. */ +function scrollRegion(): HTMLElement { + return screen.getByRole('region', { name: say('view.perf') }) +} + +/** Mutable scroll metrics a test can move; jsdom performs no layout. */ +interface ScrollDouble { + height: number + top: number +} + +/** Replace one element's scroll metrics because jsdom never lays content out. */ +function scrollDouble(element: HTMLElement, height: number, top: number): ScrollDouble { + const state: ScrollDouble = { height, top } + Object.defineProperty(element, 'scrollHeight', { configurable: true, get: () => state.height }) + Object.defineProperty(element, 'scrollTop', { + configurable: true, + get: () => state.top, + set: (next: number) => { state.top = next }, + }) + return state +} + +/** Patch the element prototype scroll metrics before a mount-time measurement. */ +function stubInitialScroll(height: number): { top: number } { + const state = { top: 0 } + vi.spyOn(Element.prototype, 'scrollHeight', 'get').mockReturnValue(height) + vi.spyOn(Element.prototype, 'scrollTop', 'get').mockImplementation(() => state.top) + vi.spyOn(Element.prototype, 'scrollTop', 'set').mockImplementation((value: number) => { state.top = value }) + return state +} + +/** A promise a test settles explicitly, to observe an in-flight page load. */ +function deferred(): { + readonly promise: Promise + readonly resolve: (value: T) => void +} { + let settle: (value: T) => void = () => undefined + const promise = new Promise((resolve) => { + settle = resolve + }) + return { promise, resolve: settle } +} + +/** Give the navigator a clipboard mock for copy-control cases. */ +function stubClipboard(writeText: () => Promise): void { + Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true }) +} + /** Body rows in render order (turn headers, step rows, detail rows, step controls). */ function bodyRows(): HTMLTableRowElement[] { return [...document.querySelectorAll('tbody tr')] @@ -226,23 +282,21 @@ afterEach(() => { }) describe('empty state and footer', () => { - it('shows the empty message, page info, and a disabled expand-all button', () => { + it('shows the empty message, the summary, and a disabled expand-all button', () => { mount({ turns: [], stepCount: 0 }) expect(screen.getByText(say('empty'))).toBeTruthy() expect(screen.queryByRole('table')).toBeNull() - expect(screen.getByText(say('page.info', { turns: 0, steps: 0, page: 1, pages: 1 }))).toBeTruthy() - const expandAll = screen.getByRole('button', { name: say('expand.all') }) - expect((expandAll as HTMLButtonElement).disabled).toBe(true) - expect(screen.queryByRole('button', { name: say('page.older') })).toBeNull() - expect(screen.queryByRole('button', { name: say('page.newer') })).toBeNull() + expect(screen.getByText(say('summary', { turns: 0, steps: 0 }))).toBeTruthy() + expect(button(say('expand.all')).disabled).toBe(true) + expect(screen.queryByRole('button', { name: say('loadOlder') })).toBeNull() }) - it('leaves older and newer controls out of a one-page table', () => { - mount({ turns: [turn()], stepCount: 0 }) - expect(screen.getByText(say('page.info', { turns: 1, steps: 0, page: 1, pages: 1 }))).toBeTruthy() - expect(screen.queryByRole('button', { name: say('page.older') })).toBeNull() - expect(screen.queryByRole('button', { name: say('page.newer') })).toBeNull() - expect(button(say('collapse.all')).disabled).toBe(false) + it('renders every published turn with a summary and an expand-all button', () => { + mount({ turns: [turn({ turn: 1 }), turn({ turn: 2 }), turn({ turn: 3 })], stepCount: 0 }) + expect(screen.getByText(say('summary', { turns: 3, steps: 0 }))).toBeTruthy() + expect(bodyRows()).toHaveLength(3) + expect(button(say('expand.all')).disabled).toBe(false) + expect(screen.getByRole('button', { name: say('loadOlder') })).toBeTruthy() }) it('toggles one turn row and all turns from the footer', () => { @@ -265,49 +319,133 @@ describe('empty state and footer', () => { }) }) -describe('pagination', () => { - /** 25 turns with no steps, so only paging shapes the table. */ - function manyTurns(): PerfSnapshot { - return { - turns: Array.from({ length: 25 }, (_, index) => turn({ turn: index + 1 })), - stepCount: 0, - } +describe('older history', () => { + /** Two settled turns, so one scroll can request an older page. */ + function twoTurns(): PerfSnapshot { + return { turns: [turn({ turn: 1 }), turn({ turn: 2 })], stepCount: 2 } } - it('walks older and newer pages and falls back to the tail anchor', () => { - const store = mount(manyTurns()) - expect(screen.getByText(say('page.info', { turns: 25, steps: 0, page: 3, pages: 3 }))).toBeTruthy() - expect(bodyRows()).toHaveLength(10) - expect(rowWithPrefix(say('turn.label', { turn: 16 }))).toBeTruthy() - expect(button(say('page.newer')).disabled).toBe(true) + it('loads near the top, reports the load, and stops after an exhausted page', async () => { + const gate = deferred() + const loadOlder = vi.fn(() => gate.promise) + mount(twoTurns(), loadOlder) + const scroller = scrollRegion() + scrollDouble(scroller, 1_000, 10) - fireEvent.click(screen.getByRole('button', { name: say('page.older') })) - expect(screen.getByText(say('page.info', { turns: 25, steps: 0, page: 2, pages: 3 }))).toBeTruthy() - expect(bodyRows()).toHaveLength(10) - expect(rowWithPrefix(say('turn.label', { turn: 6 }))).toBeTruthy() + fireEvent.scroll(scroller) + expect(loadOlder).toHaveBeenCalledTimes(1) + expect(screen.getByRole('status').textContent).toBe(say('loadOlder.loading')) + expect(screen.queryByRole('button', { name: say('loadOlder') })).toBeNull() - fireEvent.click(screen.getByRole('button', { name: say('page.older') })) - expect(screen.getByText(say('page.info', { turns: 25, steps: 0, page: 1, pages: 3 }))).toBeTruthy() - expect(bodyRows()).toHaveLength(5) - expect(rowWithPrefix(say('turn.label', { turn: 1 }))).toBeTruthy() - expect(button(say('page.older')).disabled).toBe(true) + // A second scroll while the page is in flight must not start another load. + fireEvent.scroll(scroller) + expect(loadOlder).toHaveBeenCalledTimes(1) - fireEvent.click(screen.getByRole('button', { name: say('page.newer') })) - expect(screen.getByText(say('page.info', { turns: 25, steps: 0, page: 2, pages: 3 }))).toBeTruthy() + await act(async () => { gate.resolve(false) }) + expect(screen.getByText(say('loadOlder.done'))).toBeTruthy() + expect(screen.queryByRole('status')).toBeNull() - fireEvent.click(screen.getByRole('button', { name: say('page.newer') })) - expect(screen.getByText(say('page.info', { turns: 25, steps: 0, page: 3, pages: 3 }))).toBeTruthy() - expect(bodyRows()).toHaveLength(10) + fireEvent.scroll(scroller) + expect(loadOlder).toHaveBeenCalledTimes(1) + }) - // An anchored turn that leaves the snapshot drops the anchor and falls - // back to the newest page. - fireEvent.click(screen.getByRole('button', { name: say('page.older') })) - const replaced = manyTurns().turns.filter(candidate => candidate.turn !== 15) - act(() => { - store.set({ turns: replaced, stepCount: 0 }) + it('loads from the visible button and stops once a page reports no progress', async () => { + let pages = 0 + const loadOlder = vi.fn(async () => { + pages += 1 + return pages === 1 }) - expect(screen.getByText(say('page.info', { turns: 24, steps: 0, page: 3, pages: 3 }))).toBeTruthy() - expect(bodyRows()).toHaveLength(10) + mount(twoTurns(), loadOlder) + + fireEvent.click(screen.getByRole('button', { name: say('loadOlder') })) + expect(loadOlder).toHaveBeenCalledTimes(1) + expect(screen.getByRole('status').textContent).toBe(say('loadOlder.loading')) + await act(async () => {}) + expect(screen.getByRole('button', { name: say('loadOlder') })).toBeTruthy() + expect(screen.queryByText(say('loadOlder.done'))).toBeNull() + + fireEvent.click(screen.getByRole('button', { name: say('loadOlder') })) + expect(loadOlder).toHaveBeenCalledTimes(2) + await act(async () => {}) + expect(screen.getByText(say('loadOlder.done'))).toBeTruthy() + expect(screen.queryByRole('button', { name: say('loadOlder') })).toBeNull() + + fireEvent.scroll(scrollRegion()) + expect(loadOlder).toHaveBeenCalledTimes(2) + }) + + it('ignores a scroll away from the top and keeps an empty snapshot quiet', () => { + const emptyLoad = vi.fn(async () => true) + mount({ turns: [], stepCount: 0 }, emptyLoad) + fireEvent.scroll(scrollRegion()) + expect(emptyLoad).not.toHaveBeenCalled() + + cleanup() + const loadOlder = vi.fn(async () => true) + mount(twoTurns(), loadOlder) + const scroller = scrollRegion() + const metrics = scrollDouble(scroller, 1_000, 200) + fireEvent.scroll(scroller) + expect(loadOlder).not.toHaveBeenCalled() + metrics.top = 64 + fireEvent.scroll(scroller) + expect(loadOlder).toHaveBeenCalledTimes(1) + }) + + it('keeps the trigger available after a rejected page', async () => { + const loadOlder = vi.fn(async () => { + throw new Error('history unavailable') + }) + mount(twoTurns(), loadOlder) + const scroller = scrollRegion() + scrollDouble(scroller, 1_000, 0) + + await act(async () => { fireEvent.scroll(scroller) }) + expect(loadOlder).toHaveBeenCalledTimes(1) + expect(screen.queryByRole('status')).toBeNull() + expect(screen.queryByText(say('loadOlder.done'))).toBeNull() + expect(screen.getByRole('button', { name: say('loadOlder') })).toBeTruthy() + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: say('loadOlder') })) + }) + expect(loadOlder).toHaveBeenCalledTimes(2) + }) + + it('restores the reading position when older turns prepend above the viewport', async () => { + const gate = deferred() + const loadOlder = vi.fn(() => gate.promise) + const store = mount({ turns: [turn({ turn: 2 }), turn({ turn: 3 })], stepCount: 2 }, loadOlder) + const scroller = scrollRegion() + const metrics = scrollDouble(scroller, 1_000, 0) + + fireEvent.scroll(scroller) + expect(loadOlder).toHaveBeenCalledTimes(1) + // The older page commits before the promise settles, and the content grows. + act(() => { + store.set({ + turns: [turn({ turn: 1 }), turn({ turn: 2 }), turn({ turn: 3 })], + stepCount: 3, + }) + }) + metrics.height = 1_500 + await act(async () => { gate.resolve(true) }) + expect(metrics.top).toBe(500) + }) + + it('opens at the newest turn on first mount and when the first turns arrive', () => { + const initial = stubInitialScroll(1_200) + mount(twoTurns()) + expect(initial.top).toBe(1_200) + + cleanup() + vi.restoreAllMocks() + const later = stubInitialScroll(900) + const store = mount({ turns: [], stepCount: 0 }) + act(() => { + store.set({ turns: [turn({ turn: 1 })], stepCount: 1 }) + }) + expect(later.top).toBe(900) }) }) @@ -464,24 +602,49 @@ describe('turn rows', () => { expect(cellsOf(rowWithPrefix(say('turn.label', { turn: 3 })))[1]?.textContent).toBe('') }) - it('falls back to zero cache breakdown parts and skips the marker above half', () => { + it('shows the recorded cache breakdown and the formula fallback when none was recorded', () => { mount({ turns: [ - turn({ turn: 1, cacheHit: 0.8, cacheRead: 100, cacheWrite: 100 }), - turn({ turn: 2, cacheHit: 0.1, cacheRead: null, cacheWrite: null, context: null }), + turn({ turn: 1, cacheHit: 0.8, cacheRead: 100, cacheWrite: 200, context: 1_000 }), + turn({ turn: 2, cacheHit: 0.1, cacheRead: null, cacheWrite: 200, context: 1_000 }), + turn({ turn: 3, cacheHit: 0.4, cacheRead: 100, cacheWrite: null, context: null }), + turn({ turn: 4, cacheHit: 0.2, cacheRead: null, cacheWrite: null }), ], - stepCount: 2, + stepCount: 4, }) - const high = cellsOf(rowWithPrefix(say('turn.label', { turn: 1 }))) - expect(high[5]?.getAttribute('title')).toBe(say('cache.detail', { - read: formatTokens(100), input: formatTokens(800), write: formatTokens(100), + const full = cellsOf(rowWithPrefix(say('turn.label', { turn: 1 }))) + expect(full[5]?.getAttribute('title')).toBe(say('cache.detail', { + read: formatTokens(100), input: formatTokens(700), write: formatTokens(200), })) - expect(high[0]?.textContent).not.toContain(say('state.cacheMiss')) - const low = cellsOf(rowWithPrefix(say('turn.label', { turn: 2 }))) - expect(low[5]?.getAttribute('title')).toBe(say('cache.detail', { - read: formatTokens(0), input: formatTokens(0), write: formatTokens(0), + expect(full[0]?.textContent).not.toContain(say('state.cacheMiss')) + const writeOnly = cellsOf(rowWithPrefix(say('turn.label', { turn: 2 }))) + expect(writeOnly[5]?.getAttribute('title')).toBe(say('cache.detail', { + read: formatTokens(0), input: formatTokens(800), write: formatTokens(200), })) - expect(low[3]?.textContent).toBe('—') + const readOnly = cellsOf(rowWithPrefix(say('turn.label', { turn: 3 }))) + expect(readOnly[5]?.getAttribute('title')).toBe(say('cache.detail', { + read: formatTokens(100), input: formatTokens(0), write: formatTokens(0), + })) + expect(readOnly[3]?.textContent).toBe('—') + const absent = cellsOf(rowWithPrefix(say('turn.label', { turn: 4 }))) + expect(absent[5]?.getAttribute('title')).toBe(say('column.cache.title')) + }) + + it('draws the bar tool segment from the non-overlapping span and keeps the column sum', () => { + mount({ + turns: [turn({ tool: 900, toolWall: 300, wall: 1_000, ttft: 100, model: 400 })], + stepCount: 0, + }) + const row = rowWithPrefix(say('turn.label', { turn: 1 })) + expect(cellsOf(row)[9]?.textContent).toBe(formatMs(900)) + expect(timeTitle(row)).toBe([ + say('bar.start', { clock: formatClock(BASE) }), + say('bar.ttft', { value: formatMs(100) }), + say('bar.gen', { value: formatMs(300) }), + say('bar.tool', { value: formatMs(300) }), + say('bar.rest', { value: formatMs(300) }), + say('bar.wall', { value: formatMs(1_000) }), + ].join(' · ')) }) }) @@ -512,6 +675,8 @@ describe('step rows', () => { expect(firstCells[0]?.textContent).toContain(say('state.cacheMiss')) expect(firstCells[0]?.textContent).toContain(` · ${say('detail.show')}`) expect(first.getAttribute('title')).toBe(say('detail.title')) + // No recorded breakdown on this step: the cache cell keeps the formula explanation. + expect(firstCells[5]?.getAttribute('title')).toBe(say('column.cache.title')) const failed = within(firstCells[2] as HTMLTableCellElement).getByText(say('tool.failed', { count: 1 })) expect(failed.getAttribute('title')).toBe(say('tool.failed.title', { count: 1 })) expect(within(firstCells[2] as HTMLTableCellElement).getByText('read')).toBeTruthy() @@ -681,6 +846,66 @@ describe('step rows', () => { }) }) +describe('keyboard row controls', () => { + it('toggles a turn from its focusable button and reports aria-expanded', () => { + mount({ turns: [turn({ turn: 1 }), turn({ turn: 2 })], stepCount: 2 }) + const labels = (): string | null => screen + .getByRole('button', { name: say('turn.toggle.aria', { turn: 2 }) }) + .getAttribute('aria-expanded') + expect(labels()).toBe('true') + expect(screen.getByRole('button', { name: say('turn.toggle.aria', { turn: 2 }) }) + .getAttribute('title')).toBe(say('turn.toggle.title')) + + fireEvent.click(screen.getByRole('button', { name: say('turn.toggle.aria', { turn: 2 }) })) + expect(labels()).toBe('false') + expect(rowWithPrefix(say('turn.label', { turn: 2 })).textContent).toContain(say('turn.expand')) + + // Mouse row clicks keep toggling the same state. + fireEvent.click(rowWithPrefix(say('turn.label', { turn: 2 }))) + expect(labels()).toBe('true') + + // A native button activates on Enter and Space; jsdom does not synthesize + // the click those keys produce, so the test dispatches it explicitly. + const enter = screen.getByRole('button', { name: say('turn.toggle.aria', { turn: 2 }) }) + fireEvent.keyDown(enter, { key: 'Enter' }) + fireEvent.click(enter) + expect(labels()).toBe('false') + const space = screen.getByRole('button', { name: say('turn.toggle.aria', { turn: 2 }) }) + fireEvent.keyDown(space, { key: ' ' }) + fireEvent.click(space) + expect(labels()).toBe('true') + }) + + it('toggles step details from their focusable button and reports aria-expanded', () => { + mount(turnSnapshot([ + step({ step: 1, tools: [tool('read', 1)], calls: [call({ callId: 'c1', text: 'body' })] }), + step({ step: 2 }), + ])) + const toggle = (): HTMLElement => screen.getByRole('button', { name: say('step.toggle.aria', { step: 1 }) }) + expect(toggle().getAttribute('aria-expanded')).toBe('false') + expect(toggle().getAttribute('title')).toBe(say('detail.title')) + + fireEvent.click(toggle()) + expect(toggle().getAttribute('aria-expanded')).toBe('true') + expect(detailRow().textContent).toContain('body') + + // A row click still toggles the same state. + fireEvent.click(stepRow(1)) + expect(toggle().getAttribute('aria-expanded')).toBe('false') + expect(bodyRows().some(row => (row.textContent ?? '').includes(DETAIL_MARKER))).toBe(false) + + fireEvent.keyDown(toggle(), { key: 'Enter' }) + fireEvent.click(toggle()) + expect(toggle().getAttribute('aria-expanded')).toBe('true') + fireEvent.keyDown(toggle(), { key: ' ' }) + fireEvent.click(toggle()) + expect(toggle().getAttribute('aria-expanded')).toBe('false') + + // A step without a detail body keeps the row non-interactive. + expect(screen.queryByRole('button', { name: say('step.toggle.aria', { step: 2 }) })).toBeNull() + }) +}) + describe('time cell', () => { it('shows the dash for a missing total and for a zero total', () => { mount({ @@ -703,6 +928,7 @@ describe('time cell', () => { step({ step: 1, wall: 1_000, ttft: 5_000, model: 5_000, tool: 5_000, startTime: BASE }), step({ step: 2, wall: 1_000, ttft: null, model: null, tool: null, startTime: BASE }), step({ step: 3, wall: 1_000, ttft: 100, model: 800, tool: 500, startTime: BASE }), + step({ step: 4, wall: 1_000, ttft: 100, model: 400, tool: 700, toolWall: 200, startTime: BASE }), ])) expect(timeTitle(stepRow(1))).toBe([ say('bar.start', { clock: formatClock(BASE) }), @@ -728,6 +954,16 @@ describe('time cell', () => { say('bar.rest', { value: formatMs(0) }), say('bar.wall', { value: formatMs(1_000) }), ].join(' · ')) + // The column keeps the summed tool time while the bar uses the wall union. + expect(cellsOf(stepRow(4))[9]?.textContent).toBe(formatMs(700)) + expect(timeTitle(stepRow(4))).toBe([ + say('bar.start', { clock: formatClock(BASE) }), + say('bar.ttft', { value: formatMs(100) }), + say('bar.gen', { value: formatMs(300) }), + say('bar.tool', { value: formatMs(200) }), + say('bar.rest', { value: formatMs(400) }), + say('bar.wall', { value: formatMs(1_000) }), + ].join(' · ')) }) }) @@ -784,7 +1020,7 @@ describe('step detail accordion', () => { const write = blocks[2] as HTMLElement expect(within(write).getByText(say('detail.emptyBody'))).toBeTruthy() - expect(within(detail).getAllByRole('button', { name: say('detail.copy') })).toHaveLength(2) + expect(within(detail).queryAllByRole('button', { name: say('detail.copy') })).toHaveLength(0) expect(within(detail).getAllByRole('button', { name: '{}' })).toHaveLength(2) const wrap = within(detail).getByRole('button', { name: say('detail.wrap') }) @@ -795,6 +1031,14 @@ describe('step detail accordion', () => { .toBe('true') }) + it('renders the copy control only for bodies with copied text', () => { + stubClipboard(vi.fn(() => Promise.resolve())) + mount(accordionSnapshot()) + const detail = openDetail(1) + // Read has result text, edit falls back to its diff, write has an empty body. + expect(within(detail).getAllByRole('button', { name: say('detail.copy') })).toHaveLength(2) + }) + it('toggles raw arguments per call and keeps the toggle when arguments disappear', () => { const store = mount(accordionSnapshot()) openDetail(1) @@ -829,7 +1073,7 @@ describe('step detail accordion', () => { it('reports the copied label and resets it after the timeout', async () => { vi.useFakeTimers() const writeText = vi.fn(() => Promise.resolve()) - Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true }) + stubClipboard(writeText) mount(callSnapshot([call({ text: 'copied body' })])) openDetail(1) const copy = within(detailRow()).getByRole('button', { name: say('detail.copy') }) @@ -848,32 +1092,62 @@ describe('step detail accordion', () => { expect(within(detailRow()).getByRole('button', { name: say('detail.copy') })).toBeTruthy() }) - it('stays silent when the clipboard rejects or throws or is absent', async () => { + it('reports a rejected copy and resets the failure label', async () => { + vi.useFakeTimers() const rejected = vi.fn(() => Promise.reject(new Error('denied'))) - Object.defineProperty(navigator, 'clipboard', { value: { writeText: rejected }, configurable: true }) + stubClipboard(rejected) mount(callSnapshot([call({ text: 'nope' })])) openDetail(1) - const copy = within(detailRow()).getByRole('button', { name: say('detail.copy') }) await act(async () => { - fireEvent.click(copy) + fireEvent.click(within(detailRow()).getByRole('button', { name: say('detail.copy') })) }) expect(rejected).toHaveBeenCalledWith('nope') - expect(within(detailRow()).getByRole('button', { name: say('detail.copy') })).toBeTruthy() + expect(within(detailRow()).getByRole('button', { name: say('detail.copyFailed') })).toBeTruthy() + act(() => { + vi.advanceTimersByTime(1_200) + }) + expect(within(detailRow()).getByRole('button', { name: say('detail.copy') })).toBeTruthy() + }) + + it('reports a synchronous clipboard refusal as a failed copy', () => { const throwing = vi.fn(() => { throw new Error('no clipboard') }) - Object.defineProperty(navigator, 'clipboard', { value: { writeText: throwing }, configurable: true }) + stubClipboard(throwing) + mount(callSnapshot([call({ text: 'nope' })])) + openDetail(1) fireEvent.click(within(detailRow()).getByRole('button', { name: say('detail.copy') })) expect(throwing).toHaveBeenCalledTimes(1) - expect(within(detailRow()).getByRole('button', { name: say('detail.copy') })).toBeTruthy() + expect(within(detailRow()).getByRole('button', { name: say('detail.copyFailed') })).toBeTruthy() + }) - Reflect.deleteProperty(navigator, 'clipboard') - fireEvent.click(within(detailRow()).getByRole('button', { name: say('detail.copy') })) - expect(within(detailRow()).getByRole('button', { name: say('detail.copy') })).toBeTruthy() + it('renders no copy control when the host has no Clipboard API', () => { + mount(callSnapshot([call({ text: 'body' })])) + openDetail(1) + expect(within(detailRow()).queryByRole('button', { name: say('detail.copy') })).toBeNull() + }) + + it('copies the raw arguments while the raw view is active', async () => { + const writeText = vi.fn(() => Promise.resolve()) + stubClipboard(writeText) + mount(callSnapshot([call({ name: 'bash', args: '{"command":"ls"}', text: 'done' })])) + openDetail(1) + fireEvent.click(within(detailRow()).getByRole('button', { name: '{}' })) + await act(async () => { + fireEvent.click(within(detailRow()).getByRole('button', { name: say('detail.copy') })) + }) + expect(writeText).toHaveBeenCalledWith('{"command":"ls"}') + + // Leaving the raw view copies the rendered body again. + fireEvent.click(within(detailRow()).getByRole('button', { name: '{}' })) + await act(async () => { + fireEvent.click(within(detailRow()).getByRole('button', { name: say('detail.copied') })) + }) + expect(writeText).toHaveBeenLastCalledWith('done') }) it('copies the diff fallback when a call left no result text', async () => { const writeText = vi.fn(() => Promise.resolve()) - Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true }) + stubClipboard(writeText) mount(callSnapshot([ call({ callId: 'both', name: 'bash', before: 'old', after: 'new' }), call({ callId: 'after', name: 'bash', before: null, after: 'newer' }),