Merge remote-tracking branch 'origin/master' into worktree/context-source-cards

# Conflicts:
#	.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.i18n.yaml
#	.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md
#	.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md
#	apps/web/tests/seeded-history.e2e.ts
#	apps/web/tests/snapshots/queue-actions/layout.expected.md
#	docs/core-data-structures/core.i18n.yaml
#	examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl
#	examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl
#	examples/acp-agent/tests/snapshots/skill-load/session.jsonl
#	examples/acp-agent/tests/snapshots/workspace-context/session.jsonl
#	examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl
#	examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl
#	examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl
#	examples/headless-agent/tests/snapshots/pty-tools/session.jsonl
#	packages/client/runtime/README.i18n.yaml
#	packages/client/runtime/tests/history-fold.spec.ts
#	packages/client/ui-conversation/README.i18n.yaml
#	packages/client/ui-conversation/src/client/chat/MessageItem.tsx
#	packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx
#	packages/context/workspace-context/src/index.ts
#	packages/context/workspace-context/tests/workspace-context.spec.ts
#	packages/skill/tool-skill/README.i18n.yaml
#	packages/skill/tool-skill/README.md
#	packages/skill/tool-skill/README.zh.md
#	packages/skill/tool-skill/src/index.ts
#	packages/skill/tool-skill/tests/tool-skill.spec.ts
This commit is contained in:
creatixchu
2026-08-06 11:28:48 +08:00
823 changed files with 18562 additions and 14742 deletions

View File

@@ -10,17 +10,33 @@ import {
TrajectoryCell,
type TrajectoryCellKind,
} from '../src/client/TrajectoryCell.tsx'
import { formatDurationMillis } from '../src/client/trajectory-record.ts'
afterEach(cleanup)
describe('formatDurationMillis', () => {
it('formats exact millisecond labels with thousands separators', () => {
expect(formatDurationMillis(0)).toBe('0 ms')
expect(formatDurationMillis(29)).toBe('29 ms')
expect(formatDurationMillis(500)).toBe('500 ms')
expect(formatDurationMillis(1_500)).toBe('1,500 ms')
expect(formatDurationMillis(235_200)).toBe('235,200 ms')
expect(formatDurationMillis(null)).toBe('—')
expect(formatDurationMillis(Number.NaN)).toBe('—')
})
})
describe('formatElapsedSeconds', () => {
it('formats known durations and uses an em dash when absent', () => {
expect(formatElapsedSeconds(null)).toBe('—')
expect(formatElapsedSeconds(235)).toBe('235 s')
expect(formatElapsedSeconds(235.0)).toBe('235 s')
expect(formatElapsedSeconds(235.2)).toBe('235.2 s')
expect(formatElapsedSeconds(235.25)).toBe('235.3 s')
expect(formatElapsedSeconds(0)).toBe('0 s')
expect(formatElapsedSeconds(235)).toBe('235,000 ms')
expect(formatElapsedSeconds(235.0)).toBe('235,000 ms')
expect(formatElapsedSeconds(235.2)).toBe('235,200 ms')
expect(formatElapsedSeconds(235.25)).toBe('235,250 ms')
expect(formatElapsedSeconds(0)).toBe('0 ms')
expect(formatElapsedSeconds(0.029)).toBe('29 ms')
expect(formatElapsedSeconds(0.5)).toBe('500 ms')
expect(formatElapsedSeconds(1.5)).toBe('1,500 ms')
expect(formatElapsedSeconds(Number.NaN)).toBe('—')
})
})
@@ -38,7 +54,7 @@ describe('TrajectoryCell', () => {
expect(screen.getByText('#6')).toBeTruthy()
expect(screen.getByText('Tool')).toBeTruthy()
expect(screen.getByText('bash · Read src/index.ts')).toBeTruthy()
expect(screen.getByText('5 s')).toBeTruthy()
expect(screen.getByText('5,000 ms')).toBeTruthy()
})
it('Message rows expose Input / Output / Think metric columns before time', () => {
@@ -57,11 +73,11 @@ describe('TrajectoryCell', () => {
expect(screen.getByText('136')).toBeTruthy()
expect(screen.getByText('381')).toBeTruthy()
expect(screen.getByText('155')).toBeTruthy()
expect(screen.getByText('235.2 s')).toBeTruthy()
expect(screen.getByText('235,200 ms')).toBeTruthy()
const texts = [...container.querySelectorAll('span')].map(el => el.textContent)
expect(texts.indexOf('136')).toBeLessThan(texts.indexOf('381'))
expect(texts.indexOf('381')).toBeLessThan(texts.indexOf('155'))
expect(texts.indexOf('155')).toBeLessThan(texts.indexOf('235.2 s'))
expect(texts.indexOf('155')).toBeLessThan(texts.indexOf('235,200 ms'))
})
it('selected marks the row for the brand-primary inset ring', () => {

View File

@@ -46,6 +46,7 @@ describe('tsdown client artifact', () => {
const modules = new Map<string, unknown>([
['react', await import('react')],
['react/jsx-runtime', await import('react/jsx-runtime')],
['react-dom', await import('react-dom')],
['@deepseek-ai/dsh-client-runtime/client', await import('@deepseek-ai/dsh-client-runtime/client')],
['@deepseek-ai/dsh-client-ui-primitives', await import('@deepseek-ai/dsh-client-ui-primitives')],
])

View File

@@ -73,6 +73,7 @@ describe('trajectory context branches', () => {
const branches = deriveTrajectoryContextBranches(contexts)
const successor = branches[1]!
expect(successor.key).toBe('rewind:110')
expect(successor.nodes.map(node => node.seq)).toEqual([110])
expect(trajectoryBranchContainsRequest(
successor,
@@ -87,4 +88,15 @@ describe('trajectory context branches', () => {
request('assistant', 111),
)).toBe(true)
})
it('keeps branch identity when prepended generations shift local ids', () => {
const branch = (id: number) => deriveTrajectoryContextBranches([{
id,
origin: 'rewind',
originSeq: 110,
nodes: [current],
}])[0]
expect(branch(1)?.key).toBe(branch(9)?.key)
})
})

View File

@@ -11,7 +11,9 @@ import type {
import { TrajectoryGroupHeader } from '../src/client/TrajectoryGroupHeader.tsx'
import { TrajectoryTurn } from '../src/client/TrajectoryTurn.tsx'
import { TrajectoryTurnHeader } from '../src/client/TrajectoryTurnHeader.tsx'
import { deriveTrajectoryLayout } from '../src/client/layout.ts'
import {
appendTrajectoryPartialLayout, deriveTrajectoryLayout,
} from '../src/client/layout.ts'
afterEach(cleanup)
@@ -102,6 +104,70 @@ describe('deriveTrajectoryLayout', () => {
})
})
it('appends a streaming partial without rebuilding unaffected finalized turns', () => {
const nodes = [{
kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1,
blocks: [{ kind: 'text', text: 'finalized' }],
}] as unknown as ConversationSnapshot['nodes']
const partial = {
turn: 2,
step: 1,
blocks: [{ kind: 'reasoning' as const, text: 'streaming' }],
}
const request = {
purpose: 'assistant', startSeq: 3, turn: 2, step: 1,
startedAt: 3_000, completedAt: null, status: 'running',
} as unknown as RequestView
const base = deriveTrajectoryLayout({
codeDispatches: new Map(),
nodes,
partial: { ...partial, blocks: [] },
requests: [request],
runningCalls: [],
})
expect(base).toHaveLength(1)
const streamed = appendTrajectoryPartialLayout(base, partial, 1)
expect(streamed[0]).toBe(base[0])
expect(streamed).toHaveLength(2)
expect(streamed[1]?.groups[0]?.cells).toMatchObject([{
index: 2,
kind: 'message',
text: 'streaming',
timeSeconds: null,
}])
expect(streamed[1]?.groups[0]?.cells[0]?.requestOnly).toBeUndefined()
})
it('replaces a running-call placeholder with the matching streamed tool call', () => {
const partial = {
turn: 1,
step: 1,
blocks: [{
kind: 'tool-call' as const,
callId: 'c1',
name: 'bash',
argsRaw: '{"command":"pwd"}',
}],
}
const base = deriveTrajectoryLayout({
codeDispatches: new Map(),
nodes: [],
partial: { ...partial, blocks: [] },
runningCalls: [{
callId: 'c1', name: 'bash', argsRaw: '{"command":"pwd"}',
turn: 1, step: 1, time: 9_000, callView: null,
}],
})
const streamed = appendTrajectoryPartialLayout(base, partial, 1)
const cells = streamed[0]?.groups[0]?.cells ?? []
expect(cells.map(cell => cell.kind)).toEqual(['message', 'tool'])
expect(cells.filter(cell => cell.callId === 'c1')).toHaveLength(1)
})
it('omits duration when node times are missing instead of rendering NaN', () => {
const nodes = [
{ kind: 'user', seq: 1, content: [{ type: 'text', text: 'hi' }], source: null },
@@ -141,7 +207,7 @@ describe('deriveTrajectoryLayout', () => {
},
] as unknown as ConversationSnapshot['nodes']
const turns = deriveTrajectoryLayout({ codeDispatches: new Map(), nodes, partial: null, runningCalls: [] })
expect(turns[0]?.groups[0]?.description).toBe('3 s bash×2')
expect(turns[0]?.groups[0]?.description).toBe('3,000 ms bash×2')
})
it('assigns each user message to its enclosing turn instead of pooling into Turn 1', () => {

View File

@@ -2,11 +2,15 @@
/** Trajectory ledger selection, details, status, and fold behavior. */
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { TrajectoryTable } from '../src/client/TrajectoryTable.tsx'
import type { TrajectoryTurnModel } from '../src/client/layout.ts'
afterEach(cleanup)
afterEach(() => {
cleanup()
vi.restoreAllMocks()
Reflect.deleteProperty(HTMLElement.prototype, 'scrollTo')
})
const TURNS: readonly TrajectoryTurnModel[] = [{
turn: 1,
@@ -56,11 +60,33 @@ const TURNS: readonly TrajectoryTurnModel[] = [{
const FOLD_PROPS = {
collapsedTurns: new Set<number>(),
onToggleTurn: () => {},
collapsedAssistants: new Set<number>(),
collapsedAssistants: new Set<string>(),
onToggleAssistant: () => {},
}
describe('TrajectoryTable', () => {
it('shows a muted placeholder for an assistant response containing only tool calls', () => {
const turns: readonly TrajectoryTurnModel[] = [{
turn: 1,
groups: [{
title: 'Step 1',
cells: [{
index: 1,
kind: 'message',
text: 'Tool call only',
sourceBlocks: [{
type: 'tool-call', content: '{}', callId: 'call-1', toolName: 'read',
}],
timeSeconds: 1,
}],
}],
}]
render(<TrajectoryTable turns={turns} {...FOLD_PROPS} />)
expect(screen.getByText('(tool call only)')).toBeTruthy()
})
it('shows assistant timing facts after keyboard selection', () => {
render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
fireEvent.keyDown(screen.getByRole('row', { name: /ASSISTANT/ }), { key: 'Enter' })
@@ -71,6 +97,27 @@ describe('TrajectoryTable', () => {
expect(screen.getByText('20.0 tok/s')).toBeTruthy()
})
it('shows a tool record Duration as exact milliseconds', () => {
const turns: readonly TrajectoryTurnModel[] = [{
turn: 1,
groups: [{
title: 'Step 1',
cells: [{
index: 1,
kind: 'tool',
text: 'bash · {"command":"pwd"}',
inputDetail: '{"command":"pwd"}',
timeSeconds: 1.5,
}],
}],
}]
render(<TrajectoryTable turns={turns} {...FOLD_PROPS} />)
fireEvent.click(screen.getByRole('row', { name: /TOOL/ }))
expect(screen.getByText('1,500 ms', { selector: 'dd' })).toBeTruthy()
})
it('breaks output tokens into labeled reasoning and content rows', () => {
render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
fireEvent.click(screen.getByRole('row', { name: /ASSISTANT/ }))
@@ -83,6 +130,17 @@ describe('TrajectoryTable', () => {
expect(screen.getByText('15 tok')).toBeTruthy()
})
it('marks Summary scroll regions for interaction-only scrollbar thumbs', () => {
render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
fireEvent.click(screen.getByRole('row', { name: /ASSISTANT/ }))
const panel = screen.getByRole('tabpanel')
expect(panel.querySelectorAll('[data-summary-scroll-region]').length).toBeGreaterThan(1)
fireEvent.click(screen.getByRole('tab', { name: 'Preview' }))
expect(panel.querySelector('[data-summary-scroll-region]')).toBeNull()
})
it('keeps long thinking collapsed until the user asks to render it', () => {
const thinking = 'private chain '.repeat(1_000)
const turns: readonly TrajectoryTurnModel[] = [{
@@ -163,6 +221,93 @@ describe('TrajectoryTable', () => {
expect(onClearSelection).toHaveBeenCalledOnce()
})
it('keeps the selected record when older rows shift projection indexes', () => {
const tail = (index: number): TrajectoryTurnModel => ({
turn: 2,
groups: [{
title: 'Step 1',
cells: [{
index,
kind: 'message',
sourceSeq: 100,
text: 'selected tail response',
outputDetail: 'selected tail response detail',
timeSeconds: 1,
}],
}],
})
const view = render(
<TrajectoryTable turns={[tail(1)]} {...FOLD_PROPS} />,
)
fireEvent.click(screen.getByRole('row', { name: /selected tail response/ }))
view.rerender(
<TrajectoryTable
turns={[{
turn: 1,
groups: [{
title: 'Message',
cells: [{
index: 1,
kind: 'user',
sourceSeq: 1,
text: 'older prompt',
timeSeconds: 0,
}],
}],
}, tail(2)]}
{...FOLD_PROPS}
/>,
)
expect(screen.getByRole('row', { name: /selected tail response/ })
.getAttribute('aria-selected')).toBe('true')
expect(screen.getByText('selected tail response detail')).toBeTruthy()
})
it('keeps a selected request when prepending changes its display number', () => {
const tail = (index: number): TrajectoryTurnModel => ({
turn: 2,
groups: [{
title: 'Step 1',
cells: [{
index,
kind: 'message',
sourceSeq: 100,
text: 'tail response',
timeSeconds: 1,
}],
}],
})
const view = render(
<TrajectoryTable turns={[tail(1)]} {...FOLD_PROPS} />,
)
fireEvent.click(screen.getByRole('button', { name: 'Request #1' }))
view.rerender(
<TrajectoryTable
turns={[{
turn: 1,
groups: [{
title: 'Step 1',
cells: [{
index: 1,
kind: 'message',
sourceSeq: 1,
text: 'older response',
timeSeconds: 1,
}],
}],
}, tail(2)]}
{...FOLD_PROPS}
/>,
)
expect(screen.getByRole('button', { name: 'Request #2' })
.getAttribute('aria-pressed')).toBe('true')
expect(screen.getByText('Request #2')).toBeTruthy()
})
it('follows appended records only while the ledger is already at the bottom', () => {
const view = render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
const tablePane = screen.getByRole('table').parentElement as HTMLElement
@@ -210,6 +355,216 @@ describe('TrajectoryTable', () => {
expect(tablePane.scrollTop).toBe(20)
})
it('preserves the visible anchor when the last older page disables virtualization', async () => {
let resolveOlder: ((advanced: boolean) => void) | undefined
const older = new Promise<boolean>((resolve) => { resolveOlder = resolve })
const onLoadOlder = vi.fn(() => older)
const view = render(
<TrajectoryTable
turns={TURNS}
{...FOLD_PROPS}
historyStartSeq={1}
hasOlderRecords
onLoadOlder={onLoadOlder}
/>,
)
const tablePane = screen.getByRole('table').parentElement as HTMLElement
let scrollHeight = 200
Object.defineProperties(tablePane, {
clientHeight: { configurable: true, get: () => 100 },
scrollHeight: { configurable: true, get: () => scrollHeight },
})
tablePane.scrollTop = 0
fireEvent.scroll(tablePane)
fireEvent.scroll(tablePane)
await waitFor(() => { expect(onLoadOlder).toHaveBeenCalledOnce() })
expect(screen.getByRole('status').textContent).toContain('Loading earlier history…')
resolveOlder?.(true)
await waitFor(() => { expect(screen.queryByRole('status')).toBeNull() })
scrollHeight = 260
view.rerender(
<TrajectoryTable
turns={[{
turn: 0,
groups: [{
title: 'Step 1',
cells: [{ index: 0, kind: 'user', text: 'older prompt', timeSeconds: 0 }],
}],
}, ...TURNS]}
{...FOLD_PROPS}
historyStartSeq={0}
onLoadOlder={onLoadOlder}
/>,
)
expect(tablePane.scrollTop).toBe(60)
})
it('covers the ledger while the initial tail is loading', () => {
const view = render(
<TrajectoryTable turns={TURNS} {...FOLD_PROPS} historyLoading />,
)
expect(screen.getByRole('status').textContent).toContain('Loading trajectory…')
expect(screen.getByRole('table').getAttribute('data-scroll-ready')).toBeNull()
view.rerender(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
expect(screen.queryByRole('status')).toBeNull()
expect(screen.getByRole('table').getAttribute('data-scroll-ready')).toBe('true')
})
it('keeps a paged tail virtualized before its loaded window crosses the row threshold', async () => {
vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(600)
Object.defineProperty(HTMLElement.prototype, 'scrollTo', {
configurable: true,
value: vi.fn(),
})
const view = render(
<TrajectoryTable turns={TURNS} {...FOLD_PROPS} hasOlderRecords />,
)
await waitFor(() => {
expect(view.container.querySelector('tr[data-virtual-position]')).toBeTruthy()
})
})
it('mounts only the visible window for a long ledger', async () => {
vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(600)
const scrollTo = vi.fn()
Object.defineProperty(HTMLElement.prototype, 'scrollTo', {
configurable: true,
value: scrollTo,
})
const cells = Array.from({ length: 500 }, (_, index) => ({
index: index + 1,
kind: 'context' as const,
text: `Context ${index + 1}`,
timeSeconds: 0,
}))
const turns: readonly TrajectoryTurnModel[] = [{
turn: 1,
groups: [{ title: 'Context', cells }],
}]
const view = render(<TrajectoryTable turns={turns} {...FOLD_PROPS} />)
await waitFor(() => {
expect(view.container.querySelectorAll('tr[data-virtual-position]').length)
.toBeGreaterThan(0)
})
expect(view.container.querySelectorAll('tr[data-virtual-position]').length)
.toBeLessThan(cells.length)
expect(screen.getByRole('table').getAttribute('aria-rowcount')).toBe('500')
expect(view.container.querySelector('tr[data-trajectory-row-key]')
?.getAttribute('aria-rowindex')).toBe('1')
expect(scrollTo).toHaveBeenCalled()
expect(view.container.querySelector('tr[data-virtual-spacer="bottom"]')).toBeTruthy()
expect(screen.getByText('Context 1')).toBeTruthy()
expect(screen.queryByText('Context 500')).toBeNull()
const tablePane = screen.getByRole('table').parentElement as HTMLElement
tablePane.scrollTop = 9_000
fireEvent.scroll(tablePane)
await waitFor(() => {
expect(Number(view.container.querySelector(
'tr[data-virtual-position]',
)?.getAttribute('data-virtual-position'))).toBeGreaterThan(0)
})
expect(view.container.querySelector('tr[data-virtual-spacer="top"]')).toBeTruthy()
expect(screen.queryByText('Context 1')).toBeNull()
})
it('does not re-scroll a virtual ledger when streaming only changes row content', async () => {
vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(600)
const scrollTo = vi.fn()
Object.defineProperty(HTMLElement.prototype, 'scrollTo', {
configurable: true,
value: scrollTo,
})
const cells = Array.from({ length: 500 }, (_, index) => ({
index: index + 1,
kind: 'context' as const,
sourceSeq: index + 1,
text: `Context ${index + 1}`,
timeSeconds: 0,
}))
const turns: readonly TrajectoryTurnModel[] = [{
turn: 1,
groups: [{ title: 'Context', cells }],
}]
const view = render(
<TrajectoryTable
turns={turns}
{...FOLD_PROPS}
/>,
)
await waitFor(() => {
expect(view.container.querySelector('tr[data-virtual-position]')).toBeTruthy()
})
scrollTo.mockClear()
view.rerender(
<TrajectoryTable
turns={turns}
streamingCells={[{ ...cells[0]!, text: 'Context 1 streaming update' }]}
{...FOLD_PROPS}
/>,
)
expect(scrollTo).not.toHaveBeenCalled()
expect(screen.getByText('Context 1 streaming update')).toBeTruthy()
})
it('keeps the virtual tail reachable with collapsed-summary row heights', async () => {
vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(600)
Object.defineProperty(HTMLElement.prototype, 'scrollTo', {
configurable: true,
value: vi.fn(),
})
const turns: readonly TrajectoryTurnModel[] = Array.from(
{ length: 101 },
(_, index) => ({
turn: index + 1,
groups: [{
title: 'Step 1',
cells: [
{
index: index * 2 + 1,
kind: 'message' as const,
sourceSeq: index * 2 + 1,
text: `Message ${index + 1}`,
timeSeconds: 1,
},
{
index: index * 2 + 2,
kind: 'tool' as const,
callId: `call-${index + 1}`,
text: `Tool ${index + 1}`,
timeSeconds: 1,
},
],
}],
}),
)
const collapsedTurns = new Set(turns.flatMap(turn =>
turn.turn === null ? [] : [turn.turn]))
const view = render(
<TrajectoryTable
turns={turns}
{...FOLD_PROPS}
collapsedTurns={collapsedTurns}
/>,
)
const tablePane = screen.getByRole('table').parentElement as HTMLElement
tablePane.scrollTop = 5_000
fireEvent.scroll(tablePane)
await waitFor(() => {
expect(view.container.querySelector('tr[data-virtual-position="201"]')).toBeTruthy()
})
})
it('keeps running and failure semantics distinct from record roles', () => {
const view = render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
expect(view.container.querySelector('tr[data-kind="tool"][data-running="true"]')).toBeTruthy()

View File

@@ -73,6 +73,7 @@ function historySnapshot(
state: 'ready',
error: null,
hasMore: false,
baseSeq: nodes[0]?.seq ?? 0,
inspection: {
eventNodes: nodes,
contexts: [{ id: 0, nodes }],
@@ -89,11 +90,15 @@ function historySnapshot(
function standaloneHistory(
snapshot: SessionHistorySnapshot,
): Pick<ComponentProps<typeof TrajectoryView>, 'useHistory' | 'loadAllHistory'> {
): Pick<
ComponentProps<typeof TrajectoryView>,
'useHistory' | 'loadHistoryTail' | 'loadOlderHistory'
> {
const store = createSnapshotStore(snapshot)
return {
useHistory: bindSnapshotSelector(store),
loadAllHistory: () => Promise.resolve(),
loadHistoryTail: () => Promise.resolve(),
loadOlderHistory: () => Promise.resolve(false),
}
}
@@ -145,13 +150,15 @@ function standaloneProps(nodes: ConversationSnapshot['nodes']): ConvViewProps {
async function bench(snapshot = historySnapshot(NODES)) {
const ctx = new Context()
const slots = new SlotsService(ctx)
const loadAllHistory = vi.fn((_signal: AbortSignal) => Promise.resolve())
const loadHistoryTail = vi.fn((_signal: AbortSignal) => Promise.resolve())
const loadOlderHistory = vi.fn((_signal: AbortSignal) => Promise.resolve(false))
const historyStore = createSnapshotStore(snapshot)
const history: SessionHistoryFace = {
sessionId: SID,
getSnapshot: () => historyStore.getSnapshot(),
subscribe: listener => historyStore.subscribe(listener),
loadAll: loadAllHistory,
loadTail: loadHistoryTail,
loadOlder: loadOlderHistory,
}
// The conversation entry's role: declare the ring, then seed the chat entry.
slots.register({
@@ -167,7 +174,7 @@ async function bench(snapshot = historySnapshot(NODES)) {
ctx.provide('sessionHistory', { source: () => history })
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
return { ctx, slots, fiber, loadAllHistory }
return { ctx, slots, fiber, loadHistoryTail, loadOlderHistory }
}
/** Tab projection twin of apply's viewTabs (the render-side consumption path). */
@@ -201,7 +208,8 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES
? (() => {
const trajectory = injected as TrajectoryViewInjected
return {
loadAllHistory: trajectory.loadAllHistory,
loadHistoryTail: trajectory.loadHistoryTail,
loadOlderHistory: trajectory.loadOlderHistory,
setActualDuration: trajectory.setActualDuration,
useHistory: bindSnapshotSelector(trajectory.hooks.history),
useDuration: bindSnapshotSelector(trajectory.hooks.duration),
@@ -295,9 +303,9 @@ describe('tab switching in ConversationRoot', () => {
expect(screen.getByRole('row', { name: /USER/ })).toBeTruthy()
expect(screen.queryByTestId('chat-body')).toBeNull()
await vi.waitFor(() => {
expect(b.loadAllHistory).toHaveBeenCalledOnce()
expect(b.loadHistoryTail).toHaveBeenCalledOnce()
})
const signal = b.loadAllHistory.mock.calls[0]?.[0]
const signal = b.loadHistoryTail.mock.calls[0]?.[0]
expect(signal?.aborted).toBe(false)
fireEvent.click(screen.getByRole('tab', { name: 'Chat' }))
expect(signal?.aborted).toBe(true)
@@ -572,14 +580,53 @@ describe('timeline projection', () => {
expect(view.container.querySelector('[role="tooltip"]')).toBeNull()
act(() => { vi.advanceTimersByTime(1) })
const tooltip = view.container.querySelector<HTMLElement>('[role="tooltip"]')
expect(tooltip?.textContent).toContain('Total 2.0 s')
expect(tooltip?.textContent).toContain('Total 2,000 ms')
expect(tooltip?.textContent).toContain('TTFT 500 ms')
expect(tooltip?.textContent).toContain('Decoding 1.5 s')
expect(tooltip?.textContent).toContain('Decoding 1,500 ms')
} finally {
vi.useRealTimers()
}
})
it('marks an unloaded history prefix without inventing timeline duration', () => {
const onLoadEarlier = vi.fn(() => new Promise<boolean>(() => {}))
const view = render(
<TrajectoryTimeline
turns={turns}
mode="sequence"
range={null}
hasEarlierRecords
onLoadEarlier={onLoadEarlier}
onRangeChange={vi.fn()}
/>,
)
const boundary = screen.getByLabelText('Load earlier history')
expect(boundary.getAttribute('data-earlier-history')).not.toBeNull()
const plot = screen.getByLabelText('Timeline overview; drag horizontally to focus events')
fireEvent.pointerMove(plot, { clientX: 50, pointerId: 1 })
expect(view.container.querySelector('[data-timeline-hover-line]')).toBeTruthy()
fireEvent.pointerEnter(boundary)
expect(view.container.querySelector('[data-timeline-hover-line]')).toBeNull()
fireEvent.focus(boundary)
expect(screen.getByRole('tooltip').textContent)
.toContain('Click to load earlier history')
fireEvent.click(boundary)
expect(onLoadEarlier).toHaveBeenCalledOnce()
expect(screen.getByLabelText('Loading earlier history')).toBeTruthy()
view.rerender(
<TrajectoryTimeline
turns={turns}
mode="sequence"
range={null}
onRangeChange={vi.fn()}
/>,
)
expect(screen.queryByLabelText('Load earlier history')).toBeNull()
expect(screen.queryByLabelText('Loading earlier history')).toBeNull()
})
it('cancels native scrolling across the timeline while zooming', () => {
render(
<TrajectoryTimeline
@@ -614,7 +661,35 @@ describe('timeline projection', () => {
const span = view.container.querySelector<HTMLElement>('[data-timeline-span]')
expect(span?.style.getPropertyValue('--trajectory-span-width')).toBe('10%')
expect(span?.style.getPropertyValue('--trajectory-span-gap'))
.toBe('clamp(0.25px, 0.8%, 1px)')
.toBe('min(0.8%, 1px)')
})
it('keeps dense sequence spans proportional before applying the pixel floor', () => {
const denseTurns = [{
turn: 1,
groups: [{
title: 'Step 1',
cells: Array.from({ length: 400 }, (_, index) => ({
index,
kind: 'message' as const,
text: `message ${index}`,
timeSeconds: 1,
})),
}],
}]
const view = render(
<TrajectoryTimeline
turns={denseTurns}
mode="sequence"
range={null}
onRangeChange={vi.fn()}
/>,
)
const span = view.container.querySelector<HTMLElement>('[data-timeline-span]')
expect(span?.style.getPropertyValue('--trajectory-span-width')).toBe('0.25%')
expect(span?.style.getPropertyValue('--trajectory-span-gap'))
.toBe('min(0.02%, 1px)')
})
it('clears the selection without changing zoom on a zoomed right click', () => {
@@ -624,15 +699,18 @@ describe('timeline projection', () => {
turns={longTurns}
mode="sequence"
range={{ start: 2, end: 4 }}
hasEarlierRecords
onRangeChange={onRangeChange}
/>,
)
const plot = screen.getByLabelText('Timeline overview; drag horizontally to focus events')
expect(screen.getByLabelText('Load earlier history')).toBeTruthy()
vi.spyOn(plot, 'getBoundingClientRect').mockReturnValue({
x: 0, y: 0, left: 0, top: 0, right: 100, bottom: 72, width: 100, height: 72,
toJSON: () => ({}),
})
fireEvent.wheel(plot, { clientX: 50, deltaY: -1_000 })
expect(screen.queryByLabelText('Load earlier history')).toBeNull()
const domain = view.container.querySelector<HTMLElement>('[data-timeline-domain]')
const domainWidth = domain?.style.getPropertyValue('--trajectory-domain-width')
expect(domainWidth).not.toBe('100%')
@@ -1065,7 +1143,8 @@ describe('TrajectoryView branches', () => {
{...standaloneProps([])}
{...standaloneDuration()}
useHistory={bindSnapshotSelector(store)}
loadAllHistory={vi.fn(() => Promise.resolve())}
loadHistoryTail={vi.fn(() => Promise.resolve())}
loadOlderHistory={vi.fn(() => Promise.resolve(false))}
/>,
)
@@ -1075,6 +1154,74 @@ describe('TrajectoryView branches', () => {
expect(view.container.querySelectorAll('[data-request-only="true"]')).toHaveLength(0)
})
it('does not remount the ledger when prepending shifts a rewind generation id', () => {
const current = {
kind: 'assistant',
seq: 5,
time: 5_000,
turn: 2,
step: 1,
blocks: [{ kind: 'text', text: 'stable rewind response' }],
} as unknown as ConversationSnapshot['nodes'][number]
const snapshot = (id: number) => historySnapshot([current], {
contexts: [{
id,
origin: 'rewind' as const,
originSeq: 4,
nodes: [current],
}],
})
const store = createSnapshotStore(snapshot(1))
render(
<TrajectoryView
{...standaloneProps([])}
{...standaloneDuration()}
useHistory={bindSnapshotSelector(store)}
loadHistoryTail={vi.fn(() => Promise.resolve())}
loadOlderHistory={vi.fn(() => Promise.resolve(false))}
/>,
)
const row = screen.getByRole('row', { name: /stable rewind response/ })
fireEvent.click(row)
expect(row.getAttribute('aria-selected')).toBe('true')
act(() => { store.set(snapshot(2)) })
expect(screen.getByRole('row', { name: /stable rewind response/ })
.getAttribute('aria-selected')).toBe('true')
})
it('keeps ledger and timeline selection on the same event after prepend', () => {
const older = {
kind: 'user', seq: 1, time: 1_000,
content: [{ type: 'text', text: 'older prompt' }], source: null,
} as unknown as ConversationSnapshot['nodes'][number]
const current = {
kind: 'assistant', seq: 100, time: 5_000, turn: 2, step: 1,
blocks: [{ kind: 'text', text: 'selected current response' }],
} as unknown as ConversationSnapshot['nodes'][number]
const store = createSnapshotStore(historySnapshot([current]))
const view = render(
<TrajectoryView
{...standaloneProps([])}
{...standaloneDuration()}
useHistory={bindSnapshotSelector(store)}
loadHistoryTail={vi.fn(() => Promise.resolve())}
loadOlderHistory={vi.fn(() => Promise.resolve(false))}
/>,
)
fireEvent.click(screen.getByRole('row', { name: /selected current response/ }))
act(() => { store.set(historySnapshot([older, current])) })
const row = screen.getByRole('row', { name: /selected current response/ })
expect(row.getAttribute('aria-selected')).toBe('true')
const currentIndex = row.getAttribute('data-record-index')
expect(view.container.querySelector(
`[data-timeline-record-index="${currentIndex}"][data-current="true"]`,
)).toBeTruthy()
})
it('retains cancellation-frozen assistant and tool nodes outside raw contexts', () => {
const retained = {
kind: 'user', seq: 1, time: 1_000,
@@ -1108,7 +1255,8 @@ describe('TrajectoryView branches', () => {
{...standaloneProps([])}
{...standaloneDuration()}
useHistory={bindSnapshotSelector(store)}
loadAllHistory={vi.fn(() => Promise.resolve())}
loadHistoryTail={vi.fn(() => Promise.resolve())}
loadOlderHistory={vi.fn(() => Promise.resolve(false))}
/>,
)

View File

@@ -0,0 +1,95 @@
/** Measurable virtual-row grouping and durable identity contracts. */
import { describe, expect, it } from 'vitest'
import type { TrajectoryCellProps } from '../src/client/trajectory-record.ts'
import {
groupTrajectoryVirtualRows, trajectoryVirtualRecordKey,
type VirtualizableTrajectoryRecord,
} from '../src/client/trajectory-virtual-rows.ts'
function record(
index: number,
cell: Partial<TrajectoryCellProps> = {},
collapsedSummaryKind?: 'turn' | 'assistant',
): VirtualizableTrajectoryRecord {
return {
cell: {
index,
kind: 'message',
text: `record ${index}`,
timeSeconds: 0,
...cell,
},
...(collapsedSummaryKind === undefined ? {} : { collapsedSummaryKind }),
}
}
describe('trajectory virtual rows', () => {
it('groups zero-height request boundaries with the following content row', () => {
const first = record(1, { requestOnly: true, sourceSeq: 10 })
const second = record(2, { requestOnly: true, sourceSeq: 11 })
const content = record(3, { sourceSeq: 12 })
expect(groupTrajectoryVirtualRows([first, second, content])).toEqual([{
entries: [
{ logicalIndex: 0, record: first },
{ logicalIndex: 1, record: second },
{ logicalIndex: 2, record: content },
],
height: 30,
key: trajectoryVirtualRecordKey(content),
}])
})
it('retains terminal request-boundary clearance as a measurable row', () => {
const content = record(1, { sourceSeq: 10 })
const boundary = record(2, { requestOnly: true, sourceSeq: 11 })
const rows = groupTrajectoryVirtualRows([content, boundary])
expect(rows).toHaveLength(2)
expect(rows[1]).toEqual({
entries: [{ logicalIndex: 1, record: boundary }],
height: 9,
key: trajectoryVirtualRecordKey(boundary),
})
})
it('uses the rendered collapsed-summary height', () => {
const summary = record(1, { sourceSeq: 10 }, 'turn')
expect(groupTrajectoryVirtualRows([summary])[0]?.height).toBe(20)
})
it('keeps an existing row key stable when older history is prepended', () => {
const existing = record(2, { sourceSeq: 100 })
const prepended = record(1, { sourceSeq: 10 })
const before = groupTrajectoryVirtualRows([existing])[0]?.key
const after = groupTrajectoryVirtualRows([prepended, existing])[1]?.key
expect(after).toBe(before)
})
it('keeps the content key when a request boundary joins its row', () => {
const content = record(2, { sourceSeq: 100 })
const boundary = record(1, { requestOnly: true, sourceSeq: 99 })
expect(groupTrajectoryVirtualRows([boundary, content])[0]?.key)
.toBe(groupTrajectoryVirtualRows([content])[0]?.key)
})
it('distinguishes a folded summary from its source record', () => {
const source = record(1, { sourceSeq: 10 })
const summary = record(1, { sourceSeq: 10 }, 'assistant')
expect(trajectoryVirtualRecordKey(summary)).not.toBe(trajectoryVirtualRecordKey(source))
})
it('exposes a DOM-safe semantic key', () => {
const source = record(1, { callId: 'call with spaces/and?punctuation' })
expect(trajectoryVirtualRecordKey(source)).toBe(
'message%00call%00call%20with%20spaces%2Fand%3Fpunctuation',
)
})
})