feat(experimental): scroll the performance table like Chat
This commit is contained in:
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' }),
|
||||
|
||||
Reference in new issue
Block a user