feat(experimental): scroll the performance table like Chat

This commit is contained in:
mektpoy committed 2026-10-26 13:25:51 +08:00
1 parent 2917b99d5c
commit 6f545ff50e
4 files changed
+650 -209

No files matched your search

@@ -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<PerfSnapshot>
}
/**
* Load one older history page into the published window.
* @returns false when the snapshot produced no new page.
*/
loadOlder: () => Promise<boolean>
}
/** 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<Record<number, boolean>>
readonly stepViews: Readonly<Record<number, StepViewState>>
@@ -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 (
<button
type="button"
@@ -485,20 +499,30 @@ function CopyButton({ clipboardValue, t }: CopyButtonProps): ReactElement {
title={t('detail.copy.title')}
onClick={() => {
try {
void navigator.clipboard.writeText(clipboardValue).then(
() => { setCopied(true) },
() => undefined,
void clipboard.writeText(clipboardValue).then(
() => { setStatus('copied') },
() => { setStatus('failed') },
)
} catch {
// The Clipboard API is absent outside secure contexts; the click stays silent.
// A synchronous refusal (missing writer, blocked frame) is a failed copy, not a silent one.
setStatus('failed')
}
}}
>
{copied ? t('detail.copied') : t('detail.copy')}
{label}
</button>
)
}
/**
* 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 (
<div className={css.block}>
<div className={css.blockHead}>
@@ -532,7 +559,9 @@ function CallBlock({ call, t, wrap, raw, onToggleRaw }: CallBlockProps): ReactEl
{'{}'}
</button>
)}
{clipboardValue === '' ? null : <CopyButton clipboardValue={clipboardValue} t={t} />}
{clipboardValue === '' || clipboard === undefined
? null
: <CopyButton clipboard={clipboard} clipboardValue={clipboardValue} t={t} />}
</div>
{callBody(call, t, wrap, raw)}
</div>
@@ -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}
>
<td className={css.stepCell}>
{t('step.label', { step: step.step })}
{hasDetail
? (
<button
type="button"
className={css.rowToggle}
aria-expanded={expanded}
aria-label={t('step.toggle.aria', { step: step.step })}
title={t('detail.title')}
onClick={(event) => {
event.stopPropagation()
onToggleDetail(step.step)
}}
>
{t('step.label', { step: step.step })}
</button>
)
: 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')}
>
<td className={css.turnCell}>
{t('turn.label', { turn: turn.turn })}
<button
type="button"
className={css.rowToggle}
aria-expanded={open}
aria-label={t('turn.toggle.aria', { turn: turn.turn })}
title={t('turn.toggle.title')}
onClick={(event) => {
event.stopPropagation()
onToggle()
}}
>
{t('turn.label', { turn: turn.turn })}
</button>
<span className={css.stepCount}>{` · ${t('turn.stepCount', { count: turn.steps.length })}`}</span>
<span className={css.toggle}>{` · ${toggleLabel}`}</span>
{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<ViewState>(createViewState)
const [now, setNow] = useState(() => Date.now())
const [loading, setLoading] = useState(false)
const [exhausted, setExhausted] = useState(false)
const scrollRef = useRef<HTMLDivElement>(null)
const loadingRef = useRef(false)
const pendingPrepend = useRef<PendingPrepend | null>(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<void> => {
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<HTMLDivElement>): void => {
if (event.currentTarget.scrollTop > LOAD_OLDER_THRESHOLD) return
void requestOlder()
}
return (
<div className={css.root}>
{turns.length === 0 ? (
<div className={css.empty}>{t('empty')}</div>
) : (
<table className={css.table} aria-label={t('view.perf')}>
<thead>
<tr>
<th className={css.thLeft} title={t('column.scope.title')}>{t('column.scope')}</th>
<th className={css.thLeft} title={t('column.prompt.title')}>{t('column.prompt')}</th>
<th className={css.thLeft} title={t('column.did.title')}>{t('column.did')}</th>
{COLUMNS.map(name => (
<th key={name} className={css.th} title={t(`column.${name}.title`)}>
{t(`column.${name}`)}
</th>
))}
<th className={css.th} title={t('column.time.title')}>{t('column.time')}</th>
</tr>
</thead>
<tbody>
{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) },
}))}
</tbody>
</table>
)}
<div className={css.toolbar}>
<span className={css.pageInfo}>
{t('page.info', { turns: turns.length, steps: snapshot.stepCount, page, pages })}
</span>
<TableButton
caption={allOpen ? t('collapse.all') : t('expand.all')}
enabled={turns.length > 0}
onClick={toggleAllTurns}
/>
{pages > 1 ? (
<TableButton caption={t('page.older')} enabled={start > 0} onClick={goOlder} />
) : null}
{pages > 1 ? (
<TableButton caption={t('page.newer')} enabled={end < turns.length} onClick={goNewer} />
) : null}
<div className={css.root} data-conversation-composer-overlay="">
<div
ref={scrollRef}
className={css.scroll}
role="region"
aria-label={t('view.perf')}
tabIndex={0}
onScroll={handleScroll}
>
{turns.length === 0 ? null : (
<div className={css.older}>
{loading ? (
<span className={css.olderStatus} role="status">{t('loadOlder.loading')}</span>
) : exhausted ? (
<span className={css.olderStatus}>{t('loadOlder.done')}</span>
) : (
<button type="button" className={css.button} onClick={() => { void requestOlder() }}>
{t('loadOlder')}
</button>
)}
</div>
)}
{turns.length === 0 ? (
<div className={css.empty}>{t('empty')}</div>
) : (
<table className={css.table} aria-label={t('view.perf')}>
<thead>
<tr>
<th className={css.thLeft} title={t('column.scope.title')}>{t('column.scope')}</th>
<th className={css.thLeft} title={t('column.prompt.title')}>{t('column.prompt')}</th>
<th className={css.thLeft} title={t('column.did.title')}>{t('column.did')}</th>
{COLUMNS.map(name => (
<th key={name} className={css.th} title={t(`column.${name}.title`)}>
{t(`column.${name}`)}
</th>
))}
<th className={css.th} title={t('column.time.title')}>{t('column.time')}</th>
</tr>
</thead>
<tbody>
{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) },
}))}
</tbody>
</table>
)}
<div className={css.toolbar}>
<span className={css.summary}>
{t('summary', { turns: turns.length, steps: snapshot.stepCount })}
</span>
<TableButton
caption={allOpen ? t('collapse.all') : t('expand.all')}
enabled={turns.length > 0}
onClick={toggleAllTurns}
/>
</div>
</div>
</div>
)
@@ -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<PerfKey, string> = {
'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<PerfKey, string> = {
'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<PerfKey, string> = {
'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<PerfKey, string> = {
'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<PerfKey, string> = {
'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',
@@ -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);
}
@@ -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> = {}): PerfCall {
text: null,
truncated: false,
ms: null,
startedAt: null,
endedAt: null,
...overrides,
}
}
@@ -79,6 +82,7 @@ function step(overrides: Partial<PerfStepMetrics> = {}): PerfStepMetrics {
tps: 10,
output: 300,
tool: 200,
toolWall: null,
tools: [],
calls: [],
toolOutput: 0,
@@ -97,13 +101,7 @@ function step(overrides: Partial<PerfStepMetrics> = {}): 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<PerfTurnMetrics> & {
readonly cacheRead?: number | null
readonly cacheWrite?: number | null
}
function turn(overrides: TurnOverrides = {}): PerfTurnMetrics {
function turn(overrides: Partial<PerfTurnMetrics> = {}): 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<PerfTurnMetrics> = {},
): 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<typeof createSnapshotStore<PerfSnapshot>> {
/** Loader contract the view receives from the inject face. */
type LoadOlder = () => Promise<boolean>
/** Render the view with every prop it consumes. */
function mount(
snapshot: PerfSnapshot,
loadOlder: LoadOlder = async () => false,
): ReturnType<typeof createSnapshotStore<PerfSnapshot>> {
const store = createSnapshotStore<PerfSnapshot>(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<T>(): {
readonly promise: Promise<T>
readonly resolve: (value: T) => void
} {
let settle: (value: T) => void = () => undefined
const promise = new Promise<T>((resolve) => {
settle = resolve
})
return { promise, resolve: settle }
}
/** Give the navigator a clipboard mock for copy-control cases. */
function stubClipboard(writeText: () => Promise<void>): 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<HTMLTableRowElement>('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<boolean>()
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<boolean>()
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' }),