Merge remote-tracking branch 'origin/master' into worktree/web-multimodal-image-input
# Conflicts: # .agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml # .agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md # .agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md # .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml # .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md # .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md # THIRD_PARTY_NOTICES.md # apps/cli/composition.md # apps/cli/config/base.cordis.yml # apps/cli/package.json # apps/cli/src/app-cli-entry.ts # apps/cli/src/bin.ts # apps/cli/tests/args.spec.ts # apps/web/tests/built-boot.snapshot.ts # apps/web/tests/navigation-panes.e2e.ts # docs/architecture.i18n.yaml # docs/architecture.md # docs/architecture.zh.md # docs/config-catalog.md # docs/cordis-catalog/services.md # docs/core-data-structures/core.i18n.yaml # docs/core-data-structures/llm-streaming.i18n.yaml # docs/event-producer-consumer.md # docs/module-graph.md # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl # packages/README.i18n.yaml # packages/bundle/README.i18n.yaml # packages/client/connection/README.i18n.yaml # packages/client/connection/README.md # packages/client/connection/README.zh.md # packages/client/connection/src/client/fixture.ts # packages/client/connection/src/http-bridge.ts # packages/client/connection/src/index.ts # packages/client/connection/tests/fixture.spec.ts # packages/client/connection/tests/node-half.spec.ts # packages/client/runtime/README.i18n.yaml # packages/client/runtime/README.md # packages/client/runtime/README.zh.md # packages/client/runtime/src/client/contract/session.ts # packages/client/runtime/src/client/sessions/session.ts # packages/client/ui-conversation/README.i18n.yaml # packages/client/ui-conversation/README.md # packages/client/ui-conversation/README.zh.md # packages/client/ui-conversation/src/client/apply.ts # packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx # packages/client/ui-conversation/src/client/chat/ChatView.tsx # packages/client/ui-conversation/src/client/chat/MessageItem.module.css # packages/client/ui-conversation/src/client/chat/MessageItem.tsx # packages/client/ui-conversation/src/client/contract/slots.ts # packages/client/ui-conversation/src/client/index.ts # packages/client/ui-conversation/src/client/input/contract.ts # packages/client/ui-conversation/src/client/input/facade.ts # packages/client/ui-conversation/src/client/input/hub.ts # packages/client/ui-conversation/src/client/locales.ts # packages/client/ui-conversation/src/client/service.ts # packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx # packages/client/ui-conversation/src/client/skeleton/InputBar.tsx # packages/client/ui-conversation/tests/apply-inject.spec.tsx # packages/client/ui-conversation/tests/input-bar.spec.tsx # packages/client/ui-conversation/tests/input-matrix.spec.tsx # packages/client/ui-conversation/tests/input-scenarios.spec.tsx # packages/client/ui-conversation/tests/service-orchestration.spec.ts # packages/client/ui-conversation/tests/skeleton.spec.tsx # packages/client/ui-trajectory/tests/views.spec.tsx # packages/compact/compact-basic/README.i18n.yaml # packages/cordis/tool-cordis/src/api-catalog.ts # packages/host/apiproxy/README.i18n.yaml # packages/host/apiproxy/README.md # packages/host/apiproxy/README.zh.md # packages/host/apiproxy/src/api-proxy.ts # packages/host/apiproxy/src/api/rpc.ts # packages/host/apiproxy/src/api/sessions.ts # packages/host/apiproxy/src/index.ts # packages/host/apiproxy/tests/api-proxy-models.spec.ts # packages/host/apiproxy/tests/rpc-schemas.spec.ts # packages/llm/llm-pi-ai/README.i18n.yaml # packages/llm/llm-pi-ai/README.md # packages/llm/llm-pi-ai/README.zh.md # packages/llm/llm-pi-ai/src/adapter.ts # packages/llm/llm/README.i18n.yaml # packages/ui/tui/README.md # packages/ui/tui/README.zh.md # packages/ui/tui/src/components/content.ts # packages/ui/tui/src/components/transcript.ts # packages/ui/tui/tests/tui.spec.ts # pnpm-lock.yaml
This commit is contained in:
@@ -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', () => {
|
||||
|
||||
@@ -38,15 +38,16 @@ describe('tsdown client artifact', () => {
|
||||
async function loadArtifact() {
|
||||
let handoff: Handoff | undefined
|
||||
;(window as Win).__ModuleLoader__ = { load: (h) => { handoff = h } }
|
||||
// Same execution form the loader uses (inline script eval, window scope) —
|
||||
// the implied-eval ban targets accidental string execution, not this
|
||||
// deliberate bundle-execution fixture.
|
||||
// The implied-eval ban targets accidental string execution, not this
|
||||
// deliberate built-bundle fixture running in the window scope.
|
||||
// oxlint-disable-next-line typescript/no-implied-eval, typescript/no-unsafe-call
|
||||
new Function(code!)()
|
||||
expect(handoff).toBeDefined()
|
||||
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')],
|
||||
])
|
||||
const surface = handoff!.factory((spec) => {
|
||||
@@ -60,7 +61,7 @@ describe('tsdown client artifact', () => {
|
||||
const { handoff, surface } = await loadArtifact()
|
||||
expect(handoff.id).toBe(PLUGIN_ID)
|
||||
expect(surface.apply).toBeTypeOf('function')
|
||||
expect(surface.inject).toEqual(['slots', 'conversation', 'sessionHistory'])
|
||||
expect(surface.inject).toEqual(['slots', 'sessionHistory'])
|
||||
})
|
||||
|
||||
it.skipIf(code === undefined)('mounted as an object plugin, apply registers the view tab on the real ring', async () => {
|
||||
@@ -72,10 +73,8 @@ describe('tsdown client artifact', () => {
|
||||
name: 'root',
|
||||
children: { 'conversation.view': { kind: 'list', scope: 'session' } },
|
||||
}, (_p: { renderSlot?: unknown }) => null)
|
||||
// The plugin injects 'conversation' as an ordering edge and
|
||||
// 'sessionHistory' for its per-session history source; this bench
|
||||
// supplies both.
|
||||
ctx.provide('conversation', {})
|
||||
// The plugin reads sessionHistory for its per-session history source;
|
||||
// slot availability is tracked by slots.inject.
|
||||
ctx.provide('sessionHistory', {})
|
||||
const fiber = ctx.plugin(surface as { apply: (ctx: Context) => void })
|
||||
await fiber.await()
|
||||
|
||||
@@ -13,6 +13,8 @@ const checkpoint = {
|
||||
time: 100,
|
||||
content: [],
|
||||
source: { kind: 'plugin', plugin: 'compact' },
|
||||
provenance: { role: 'inject', label: 'compact' },
|
||||
form: null,
|
||||
} as ConversationNode
|
||||
|
||||
const abandoned = {
|
||||
@@ -71,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,
|
||||
@@ -85,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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,21 +60,64 @@ 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' })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Timing' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Request Timing' }))
|
||||
|
||||
expect(screen.getByText('500 ms')).toBeTruthy()
|
||||
expect(screen.getByText('1.00 s')).toBeTruthy()
|
||||
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[] = [{
|
||||
@@ -101,10 +159,13 @@ describe('TrajectoryTable', () => {
|
||||
render(<TrajectoryTable turns={turns} {...FOLD_PROPS} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('row', { name: /ASSISTANT/ }))
|
||||
const toggle = screen.getByRole('button', { name: 'Thinking ...' })
|
||||
const toggle = screen.getByRole('button', { name: 'Thinking' })
|
||||
expect(toggle.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(screen.queryByText(thinking)).toBeNull()
|
||||
|
||||
fireEvent.click(toggle)
|
||||
expect(screen.getByRole('button', { name: 'Thinking' })).toBe(toggle)
|
||||
expect(toggle.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(toggle.parentElement?.textContent?.length).toBeGreaterThan(thinking.length)
|
||||
})
|
||||
|
||||
@@ -160,6 +221,350 @@ 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
|
||||
let scrollHeight = 200
|
||||
Object.defineProperties(tablePane, {
|
||||
clientHeight: { configurable: true, get: () => 100 },
|
||||
scrollHeight: { configurable: true, get: () => scrollHeight },
|
||||
})
|
||||
tablePane.scrollTop = 100
|
||||
fireEvent.scroll(tablePane)
|
||||
|
||||
scrollHeight = 260
|
||||
view.rerender(
|
||||
<TrajectoryTable
|
||||
turns={[...TURNS, {
|
||||
turn: 2,
|
||||
groups: [{
|
||||
title: 'Step 1',
|
||||
cells: [{ index: 4, kind: 'message', text: 'new reply', timeSeconds: 0.1 }],
|
||||
}],
|
||||
}]}
|
||||
{...FOLD_PROPS}
|
||||
/>,
|
||||
)
|
||||
expect(tablePane.scrollTop).toBe(260)
|
||||
|
||||
tablePane.scrollTop = 20
|
||||
fireEvent.scroll(tablePane)
|
||||
scrollHeight = 320
|
||||
view.rerender(
|
||||
<TrajectoryTable
|
||||
turns={[...TURNS, {
|
||||
turn: 2,
|
||||
groups: [{
|
||||
title: 'Step 1',
|
||||
cells: [
|
||||
{ index: 4, kind: 'message', text: 'new reply', timeSeconds: 0.1 },
|
||||
{ index: 5, kind: 'tool', text: 'new tool', timeSeconds: 0.1 },
|
||||
],
|
||||
}],
|
||||
}]}
|
||||
{...FOLD_PROPS}
|
||||
/>,
|
||||
)
|
||||
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()
|
||||
@@ -175,17 +580,79 @@ describe('TrajectoryTable', () => {
|
||||
expect(errorResult.closest('[class*="errorPayload"]')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders responsive role icons with a custom tooltip', () => {
|
||||
it('marks failed requests and lays coincident request markers left to right', () => {
|
||||
const turns: readonly TrajectoryTurnModel[] = [
|
||||
{
|
||||
turn: 1,
|
||||
groups: [{
|
||||
title: 'Step 1',
|
||||
cells: [{
|
||||
index: 1,
|
||||
kind: 'message',
|
||||
text: '',
|
||||
requestOnly: true,
|
||||
isError: true,
|
||||
timeSeconds: 0.1,
|
||||
}],
|
||||
}],
|
||||
},
|
||||
{
|
||||
turn: 2,
|
||||
groups: [{
|
||||
title: 'Step 1',
|
||||
cells: [{
|
||||
index: 2,
|
||||
kind: 'message',
|
||||
text: '',
|
||||
requestOnly: true,
|
||||
isError: true,
|
||||
timeSeconds: 0.1,
|
||||
}],
|
||||
}],
|
||||
},
|
||||
{
|
||||
turn: 3,
|
||||
groups: [{
|
||||
title: 'Step 1',
|
||||
cells: [{
|
||||
index: 3,
|
||||
kind: 'message',
|
||||
text: 'Recovered response',
|
||||
timeSeconds: 0.1,
|
||||
}],
|
||||
}],
|
||||
},
|
||||
]
|
||||
render(<TrajectoryTable turns={turns} {...FOLD_PROPS} />)
|
||||
|
||||
const failed = screen.getByRole('button', { name: 'Request #1' })
|
||||
const retry = screen.getByRole('button', { name: 'Request #2' })
|
||||
const recovered = screen.getByRole('button', { name: 'Request #3' })
|
||||
expect(failed.getAttribute('data-request-status')).toBe('error')
|
||||
expect(failed.getAttribute('data-request-run-index')).toBe('0')
|
||||
expect(failed.style.getPropertyValue('--request-boundary-offset')).toBe('0px')
|
||||
expect(retry.getAttribute('data-request-run-index')).toBe('1')
|
||||
expect(retry.style.getPropertyValue('--request-boundary-offset')).toBe('8px')
|
||||
expect(recovered.getAttribute('data-request-run-index')).toBe('2')
|
||||
expect(recovered.style.getPropertyValue('--request-boundary-offset')).toBe('16px')
|
||||
})
|
||||
|
||||
it('shows the custom role tooltip only from the responsive icon', () => {
|
||||
const view = render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
|
||||
const toolTag = view.container.querySelector<HTMLElement>('[data-role-kind="tool"]')
|
||||
const toolIcon = toolTag?.querySelector<HTMLElement>('[data-role-icon="wrench"]')
|
||||
|
||||
expect(toolTag).not.toBeNull()
|
||||
expect(toolTag?.getAttribute('title')).toBeNull()
|
||||
expect(toolTag?.querySelector('[data-role-icon="wrench"]')).toBeTruthy()
|
||||
expect(toolIcon).toBeTruthy()
|
||||
|
||||
fireEvent.mouseEnter(toolTag as HTMLElement)
|
||||
expect(screen.getByRole('tooltip').textContent).toBe('TOOL')
|
||||
fireEvent.mouseLeave(toolTag as HTMLElement)
|
||||
expect(screen.queryByRole('tooltip')).toBeNull()
|
||||
fireEvent.mouseEnter(toolIcon as HTMLElement)
|
||||
const tooltip = screen.getByRole('tooltip')
|
||||
expect(tooltip.textContent).toBe('TOOL')
|
||||
expect(tooltip.getAttribute('data-side')).toBe('right')
|
||||
fireEvent.mouseLeave(toolIcon as HTMLElement)
|
||||
expect(screen.queryByRole('tooltip')).toBeNull()
|
||||
})
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
*/
|
||||
import { Context } from 'cordis'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { createElement, type ComponentProps, type FC, type ReactNode } from 'react'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
@@ -21,7 +21,10 @@ import type {
|
||||
SessionHistorySnapshot, SessionId, SessionListState, WorkspaceListState,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ConvViewProps, ViewTab } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { ConversationSession, type ConversationSessionProps } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/ConversationSession.tsx'
|
||||
import {
|
||||
ConversationSession, ConversationSessionHeader,
|
||||
type ConversationSessionHeaderProps, type ConversationSessionProps,
|
||||
} from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/ConversationSession.tsx'
|
||||
import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/client/stores.ts'
|
||||
import { zh as conversationZh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts'
|
||||
import { apply, inject } from '@deepseek-ai/dsh-client-ui-trajectory/client'
|
||||
@@ -31,15 +34,13 @@ import { TrajectoryTimeline } from '../src/client/TrajectoryTimeline.tsx'
|
||||
import {
|
||||
TrajectoryView, type TrajectoryViewInjected,
|
||||
} from '../src/client/TrajectoryView.tsx'
|
||||
import { createTrajectoryDurationStore } from '../src/client/duration-store.ts'
|
||||
import { deriveTrajectoryTimeline } from '../src/client/timeline.ts'
|
||||
|
||||
const SID = 's1' as SessionId
|
||||
|
||||
// Stub of the conversation package's standard locale seat (this spec mounts
|
||||
// its ConversationSession chrome); answers from the zh dictionary and falls
|
||||
// back to the key like the real chain.
|
||||
const tConversation: ConversationSessionProps['t'] =
|
||||
const tConversation: ConversationSessionHeaderProps['t'] =
|
||||
key => (conversationZh as Record<string, string>)[key] ?? key
|
||||
|
||||
afterEach(cleanup)
|
||||
// The chat store persists under its declared key; clear so one case's active
|
||||
// view cannot rehydrate into the next.
|
||||
@@ -72,6 +73,7 @@ function historySnapshot(
|
||||
state: 'ready',
|
||||
error: null,
|
||||
hasMore: false,
|
||||
baseSeq: nodes[0]?.seq ?? 0,
|
||||
inspection: {
|
||||
eventNodes: nodes,
|
||||
contexts: [{ id: 0, nodes }],
|
||||
@@ -88,11 +90,25 @@ 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),
|
||||
}
|
||||
}
|
||||
|
||||
function standaloneDuration(): Pick<
|
||||
ComponentProps<typeof TrajectoryView>, 'useDuration' | 'setActualDuration'
|
||||
> {
|
||||
const duration = createSnapshotStore(false)
|
||||
return {
|
||||
useDuration: bindSnapshotSelector(duration),
|
||||
setActualDuration: (value) => { duration.set(value) },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,7 +123,7 @@ function fakeSession(nodes: ConversationSnapshot['nodes']) {
|
||||
/** Empty sessions-list hook; breadcrumbs therefore fall back to the raw id. */
|
||||
function emptySessions() {
|
||||
const store = createSnapshotStore<SessionListState>(
|
||||
{ ids: [], byId: {}, current: undefined, phase: 'ready' })
|
||||
{ ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined })
|
||||
return bindSnapshotSelector(store)
|
||||
}
|
||||
|
||||
@@ -134,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({
|
||||
@@ -150,13 +168,10 @@ async function bench(snapshot = historySnapshot(NODES)) {
|
||||
const chatBody = vi.fn(() => <div data-testid="chat-body" />)
|
||||
slots.register(
|
||||
{ name: 'conversation.view', id: 'chat', order: 0, label: 'Chat' } as never, chatBody as never)
|
||||
// 'conversation' inject is an ordering edge; the bench declares the ring
|
||||
// itself, so a stub satisfies the wait.
|
||||
ctx.provide('conversation', {})
|
||||
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). */
|
||||
@@ -165,7 +180,7 @@ function tabsOf(slots: SlotsService): ViewTab[] {
|
||||
.map(e => ({ id: e.options.id!, label: resolveSlotLabel(e.options.label) ?? e.options.id! }))
|
||||
}
|
||||
|
||||
/** Mount the strict session content over the ring ledger with an outlet-faithful renderSlot. */
|
||||
/** Mount the strict Session header/body over the ring ledger with outlet-faithful render shares. */
|
||||
function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES) {
|
||||
const sessionSnapshot = createSnapshotStore({
|
||||
running: false, removed: false, promptError: null, nodes,
|
||||
@@ -175,6 +190,17 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES
|
||||
})
|
||||
const useSession = bindSnapshotSelector(sessionSnapshot) as unknown as UseSession<ConversationSnapshot>
|
||||
const chat = createChatStore().create()
|
||||
const views = {
|
||||
list: () => tabsOf(slots),
|
||||
subscribe: (fn: () => void) => slots.subscribe('conversation.view', fn),
|
||||
version: () => slots.getVersion('conversation.view'),
|
||||
}
|
||||
const useInput = bindSnapshotSelector(createSnapshotStore({
|
||||
draft: '', imageIds: [], draftRev: 0, phase: 'plain', occurrences: [], queue: [],
|
||||
})) as never
|
||||
const inputActions = {
|
||||
setDraft: vi.fn(), addImages: vi.fn(), removeImage: vi.fn(), pruneImages: vi.fn(), submit: vi.fn(),
|
||||
}
|
||||
// Minimal outlet twin: resolve the ring entry by the `only` filter and
|
||||
// render it with the session standard kit (what SlotOutlet does for a
|
||||
// list-kind session slot, minus machinery).
|
||||
@@ -187,12 +213,16 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES
|
||||
? {}
|
||||
: injectEntry(SID)
|
||||
const injectedProps = 'hooks' in injected
|
||||
? {
|
||||
loadAllHistory: (injected as TrajectoryViewInjected).loadAllHistory,
|
||||
useHistory: bindSnapshotSelector(
|
||||
(injected as TrajectoryViewInjected).hooks.history,
|
||||
),
|
||||
}
|
||||
? (() => {
|
||||
const trajectory = injected as TrajectoryViewInjected
|
||||
return {
|
||||
loadHistoryTail: trajectory.loadHistoryTail,
|
||||
loadOlderHistory: trajectory.loadOlderHistory,
|
||||
setActualDuration: trajectory.setActualDuration,
|
||||
useHistory: bindSnapshotSelector(trajectory.hooks.history),
|
||||
useDuration: bindSnapshotSelector(trajectory.hooks.duration),
|
||||
}
|
||||
})()
|
||||
: injected
|
||||
return (
|
||||
<View
|
||||
@@ -203,32 +233,40 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES
|
||||
)
|
||||
}) as unknown as ConversationSessionProps['renderSlot']
|
||||
return render(
|
||||
<ConversationSession
|
||||
sessionId={SID}
|
||||
t={tConversation}
|
||||
SessionProvider={({ children }) => children(SID)}
|
||||
useSession={useSession}
|
||||
useSessions={emptySessions()}
|
||||
useWorkspaces={emptyWorkspaces()}
|
||||
useProjection={(() => undefined)}
|
||||
useStore={bindSnapshotSelector(chat)}
|
||||
actions={chat.actions}
|
||||
renderSlot={renderSlot}
|
||||
views={{
|
||||
list: () => tabsOf(slots),
|
||||
subscribe: (fn: () => void) => slots.subscribe('conversation.view', fn),
|
||||
version: () => slots.getVersion('conversation.view'),
|
||||
}}
|
||||
releaseSessionImages={vi.fn()}
|
||||
useInput={bindSnapshotSelector(createSnapshotStore({
|
||||
draft: '', imageIds: [], draftRev: 0, phase: 'plain', occurrences: [], queue: [],
|
||||
})) as never}
|
||||
inputActions={{
|
||||
setDraft: vi.fn(), addImages: vi.fn(), removeImage: vi.fn(), pruneImages: vi.fn(), submit: vi.fn(),
|
||||
}}
|
||||
bindDraftMirror={() => () => {}}
|
||||
open={vi.fn()}
|
||||
/>,
|
||||
<>
|
||||
<ConversationSessionHeader
|
||||
sessionId={SID}
|
||||
SessionProvider={({ children }) => children(SID)}
|
||||
useSession={useSession}
|
||||
useSessions={emptySessions()}
|
||||
useWorkspaces={emptyWorkspaces()}
|
||||
useProjection={(() => undefined)}
|
||||
useStore={bindSnapshotSelector(chat)}
|
||||
actions={chat.actions}
|
||||
renderSlot={() => null}
|
||||
views={views}
|
||||
useInput={useInput}
|
||||
inputActions={inputActions}
|
||||
open={vi.fn()}
|
||||
t={tConversation}
|
||||
/>
|
||||
<ConversationSession
|
||||
sessionId={SID}
|
||||
SessionProvider={({ children }) => children(SID)}
|
||||
useSession={useSession}
|
||||
useSessions={emptySessions()}
|
||||
useWorkspaces={emptyWorkspaces()}
|
||||
useProjection={(() => undefined)}
|
||||
useStore={bindSnapshotSelector(chat)}
|
||||
actions={chat.actions}
|
||||
renderSlot={renderSlot}
|
||||
views={views}
|
||||
releaseSessionImages={vi.fn()}
|
||||
useInput={useInput}
|
||||
inputActions={inputActions}
|
||||
bindDraftMirror={() => () => {}}
|
||||
/>
|
||||
</>,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -246,6 +284,24 @@ describe('plugin registration', () => {
|
||||
await b.fiber.dispose()
|
||||
expect(tabsOf(b.slots).map(v => v.id)).toEqual(['chat'])
|
||||
})
|
||||
|
||||
it('shares one browser-wide duration preference across session injections', async () => {
|
||||
const b = await bench()
|
||||
const entry = b.slots.entries('conversation.view')
|
||||
.find(candidate => candidate.options.id === 'trajectory')
|
||||
expect(entry).toBeDefined()
|
||||
const injectEntry = entry!.inject as unknown as (
|
||||
sessionId: SessionId,
|
||||
) => TrajectoryViewInjected
|
||||
const first = injectEntry(SID)
|
||||
const second = injectEntry('s2' as SessionId)
|
||||
|
||||
expect(second.hooks.duration).toBe(first.hooks.duration)
|
||||
first.setActualDuration(true)
|
||||
expect(second.hooks.duration.getSnapshot()).toBe(true)
|
||||
expect(localStorage.getItem('dsh.trajectory.duration')).toBe('true')
|
||||
expect(localStorage.getItem(`dsh.trajectory.duration.${SID}`)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('tab switching in ConversationRoot', () => {
|
||||
@@ -261,15 +317,16 @@ describe('tab switching in ConversationRoot', () => {
|
||||
expect(screen.queryByRole('columnheader')).toBeNull()
|
||||
expect(screen.getByRole('toolbar', { name: 'Trajectory toolbar' })).toBeTruthy()
|
||||
expect(screen.getByRole('region', { name: 'Trajectory timeline' })).toBeTruthy()
|
||||
expect(view.container.querySelector('[data-conversation-composer-overlay]')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Collapse turns' }))
|
||||
expect(view.container.querySelector('[data-collapsed-summary="turn"]')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Expand turns' }))
|
||||
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)
|
||||
@@ -418,7 +475,7 @@ describe('tab switching in ConversationRoot', () => {
|
||||
.toBe('outside')
|
||||
fireEvent.contextMenu(plot)
|
||||
expect(screen.getByRole('row', { name: /USER/ }).getAttribute('data-timeline-focus'))
|
||||
.toBeNull()
|
||||
.toBe('outside')
|
||||
})
|
||||
|
||||
it('clicking a timeline block clears the range, selects the record, and opens its inspector', async () => {
|
||||
@@ -465,6 +522,12 @@ describe('tab switching in ConversationRoot', () => {
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
|
||||
expect(screen.getByRole('toolbar', { name: 'Trajectory toolbar' })).toBeTruthy()
|
||||
expect(screen.getByText('No timing data')).toBeTruthy()
|
||||
expect(screen.getByRole<HTMLButtonElement>('button', {
|
||||
name: 'Collapse turns',
|
||||
}).disabled).toBe(false)
|
||||
expect(screen.getByRole<HTMLButtonElement>('button', {
|
||||
name: 'Collapse calls',
|
||||
}).disabled).toBe(false)
|
||||
expect(screen.queryByRole('row')).toBeNull()
|
||||
expect(screen.queryByText(/turns ·/)).toBeNull()
|
||||
})
|
||||
@@ -495,6 +558,242 @@ describe('timeline projection', () => {
|
||||
}],
|
||||
}] satisfies readonly TrajectoryTurnModel[]
|
||||
|
||||
it('splits assistant time into recorded TTFT and decoding proportions with a delayed tooltip', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const view = render(
|
||||
<TrajectoryTimeline
|
||||
turns={[{
|
||||
turn: 1,
|
||||
groups: [{
|
||||
title: 'Step 1',
|
||||
cells: [{
|
||||
index: 1,
|
||||
kind: 'message',
|
||||
text: 'assistant',
|
||||
startedAt: 1_000,
|
||||
timeSeconds: 2,
|
||||
assistantMetrics: {
|
||||
timingRecorded: true,
|
||||
stepStartTime: 1_000,
|
||||
firstTokenTime: 1_500,
|
||||
completedTime: 3_000,
|
||||
usageProvided: false,
|
||||
outputTokens: null,
|
||||
},
|
||||
}],
|
||||
}],
|
||||
}]}
|
||||
mode="duration"
|
||||
range={null}
|
||||
onRangeChange={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
const span = view.container.querySelector<HTMLElement>(
|
||||
'[data-timeline-span="message"]',
|
||||
)
|
||||
expect(span?.getAttribute('title')).toBeNull()
|
||||
expect(span?.getAttribute('data-assistant-timing')).toBe('true')
|
||||
expect(span?.style.getPropertyValue('--trajectory-assistant-ttft')).toBe('25%')
|
||||
|
||||
fireEvent.mouseEnter(span as HTMLElement)
|
||||
act(() => { vi.advanceTimersByTime(499) })
|
||||
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,000 ms')
|
||||
expect(tooltip?.textContent).toContain('TTFT 500 ms')
|
||||
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
|
||||
turns={longTurns}
|
||||
mode="sequence"
|
||||
range={null}
|
||||
onRangeChange={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
const plot = screen.getByLabelText('Timeline overview; drag horizontally to focus events')
|
||||
vi.spyOn(plot, 'getBoundingClientRect').mockReturnValue({
|
||||
x: 44, y: 0, left: 44, top: 0, right: 144, bottom: 50, width: 100, height: 50,
|
||||
toJSON: () => ({}),
|
||||
})
|
||||
|
||||
expect(fireEvent.wheel(plot, { clientX: 94, deltaY: -100 })).toBe(false)
|
||||
expect(fireEvent.wheel(screen.getByText('Input'), {
|
||||
clientX: 20,
|
||||
deltaY: -100,
|
||||
})).toBe(false)
|
||||
})
|
||||
|
||||
it('scales sequence gutters with narrow operation spans', () => {
|
||||
const view = render(
|
||||
<TrajectoryTimeline
|
||||
turns={longTurns}
|
||||
mode="sequence"
|
||||
range={null}
|
||||
onRangeChange={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
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('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', () => {
|
||||
const onRangeChange = vi.fn()
|
||||
const view = render(
|
||||
<TrajectoryTimeline
|
||||
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%')
|
||||
|
||||
fireEvent.pointerDown(plot, { button: 2, clientX: 50, pointerId: 1 })
|
||||
expect(fireEvent.contextMenu(plot)).toBe(false)
|
||||
fireEvent.pointerUp(plot, { button: 2, clientX: 50, pointerId: 1 })
|
||||
|
||||
expect(onRangeChange).toHaveBeenCalledOnce()
|
||||
expect(onRangeChange).toHaveBeenCalledWith(null)
|
||||
expect(domain?.style.getPropertyValue('--trajectory-domain-width')).toBe(domainWidth)
|
||||
})
|
||||
|
||||
it('clears the selection and suppresses the context menu at full zoom', () => {
|
||||
const onRangeChange = vi.fn()
|
||||
render(
|
||||
<TrajectoryTimeline
|
||||
turns={longTurns}
|
||||
mode="sequence"
|
||||
range={{ start: 2, end: 4 }}
|
||||
onRangeChange={onRangeChange}
|
||||
/>,
|
||||
)
|
||||
const plot = screen.getByLabelText('Timeline overview; drag horizontally to focus events')
|
||||
|
||||
fireEvent.pointerDown(plot, { button: 2, clientX: 50, pointerId: 1 })
|
||||
expect(fireEvent.contextMenu(plot)).toBe(false)
|
||||
fireEvent.pointerUp(plot, { button: 2, clientX: 50, pointerId: 1 })
|
||||
expect(onRangeChange).toHaveBeenCalledOnce()
|
||||
expect(onRangeChange).toHaveBeenCalledWith(null)
|
||||
})
|
||||
|
||||
it('pans the zoomed viewport with a right-button drag without changing the selection', () => {
|
||||
const onRangeChange = vi.fn()
|
||||
const view = render(
|
||||
<TrajectoryTimeline
|
||||
turns={longTurns}
|
||||
mode="sequence"
|
||||
range={{ start: 2, end: 4 }}
|
||||
onRangeChange={onRangeChange}
|
||||
/>,
|
||||
)
|
||||
const plot = screen.getByLabelText('Timeline overview; drag horizontally to focus events')
|
||||
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 })
|
||||
const domain = view.container.querySelector<HTMLElement>('[data-timeline-domain]')
|
||||
const before = domain?.style.getPropertyValue('--trajectory-domain-left')
|
||||
|
||||
fireEvent.pointerDown(plot, { button: 2, clientX: 50, pointerId: 1 })
|
||||
expect(plot.getAttribute('data-panning')).toBe('true')
|
||||
expect(fireEvent.contextMenu(plot)).toBe(false)
|
||||
fireEvent.pointerMove(plot, { buttons: 2, clientX: 75, pointerId: 1 })
|
||||
fireEvent.pointerUp(plot, { button: 2, clientX: 75, pointerId: 1 })
|
||||
|
||||
expect(domain?.style.getPropertyValue('--trajectory-domain-left')).not.toBe(before)
|
||||
expect(onRangeChange).not.toHaveBeenCalled()
|
||||
expect(plot.getAttribute('data-panning')).toBeNull()
|
||||
})
|
||||
|
||||
it('pans the zoomed viewport only far enough to reveal a newly selected record', async () => {
|
||||
const onRangeChange = vi.fn()
|
||||
const view = render(
|
||||
@@ -547,7 +846,7 @@ describe('timeline projection', () => {
|
||||
|
||||
it('auto-pans a zoomed viewport while a range drag pushes against an edge', () => {
|
||||
const onRangeChange = vi.fn()
|
||||
render(
|
||||
const view = render(
|
||||
<TrajectoryTimeline
|
||||
turns={longTurns}
|
||||
mode="sequence"
|
||||
@@ -565,13 +864,26 @@ describe('timeline projection', () => {
|
||||
for (let index = 0; index < 24; index++) {
|
||||
fireEvent.pointerMove(plot, { clientX: 99, pointerId: 1 })
|
||||
}
|
||||
const draftSelection = view.container.querySelectorAll<HTMLElement>(
|
||||
'[data-dragging="true"]',
|
||||
)
|
||||
expect(draftSelection).toHaveLength(2)
|
||||
for (const overlay of draftSelection) {
|
||||
expect(Number.parseFloat(
|
||||
overlay.style.getPropertyValue('--trajectory-selection-left'),
|
||||
)).toBeLessThan(0)
|
||||
}
|
||||
fireEvent.pointerUp(plot, { clientX: 99, pointerId: 1 })
|
||||
|
||||
const selectedRange = onRangeChange.mock.calls.at(-1)?.[0] as
|
||||
| { start: number; end: number }
|
||||
| undefined
|
||||
const fullRange = deriveTrajectoryTimeline(longTurns)
|
||||
expect(selectedRange).toBeDefined()
|
||||
expect(fullRange).not.toBeNull()
|
||||
expect((selectedRange?.end ?? 0) - (selectedRange?.start ?? 0)).toBeGreaterThan(4)
|
||||
expect(selectedRange?.start).toBeGreaterThanOrEqual(fullRange?.start ?? 0)
|
||||
expect(selectedRange?.end).toBeLessThanOrEqual(fullRange?.end ?? 0)
|
||||
})
|
||||
|
||||
it('uses equal-width operation slots and stable semantic lanes', () => {
|
||||
@@ -662,6 +974,53 @@ describe('timeline projection', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('compresses every idle gap in duration mode while actual mode retains wall time', () => {
|
||||
const separatedTurns = [
|
||||
{
|
||||
turn: 1,
|
||||
groups: [{
|
||||
title: 'Step 1',
|
||||
cells: [
|
||||
{ index: 1, kind: 'message', text: 'first', startedAt: 1_000, timeSeconds: 1 },
|
||||
{ index: 2, kind: 'tool', text: 'within-turn gap', startedAt: 4_000, timeSeconds: 1 },
|
||||
],
|
||||
}],
|
||||
},
|
||||
{
|
||||
turn: 2,
|
||||
groups: [{
|
||||
title: 'Step 1',
|
||||
cells: [
|
||||
{ index: 3, kind: 'message', text: 'after user idle', startedAt: 40_000, timeSeconds: 1 },
|
||||
],
|
||||
}],
|
||||
},
|
||||
] satisfies readonly TrajectoryTurnModel[]
|
||||
|
||||
expect(deriveTrajectoryTimeline(separatedTurns, 'duration')).toMatchObject({
|
||||
start: 1_000,
|
||||
end: 4_000,
|
||||
spans: [
|
||||
{ index: 1, start: 1_000, end: 2_000 },
|
||||
{ index: 2, start: 2_000, end: 3_000 },
|
||||
{ index: 3, start: 3_000, end: 4_000 },
|
||||
],
|
||||
turnBoundaries: [
|
||||
{ turn: 1, time: 1_000 },
|
||||
{ turn: 2, time: 3_000 },
|
||||
],
|
||||
})
|
||||
expect(deriveTrajectoryTimeline(separatedTurns, 'actual')).toMatchObject({
|
||||
start: 1_000,
|
||||
end: 41_000,
|
||||
spans: [
|
||||
{ index: 1, start: 1_000, end: 2_000 },
|
||||
{ index: 2, start: 4_000, end: 5_000 },
|
||||
{ index: 3, start: 40_000, end: 41_000 },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('projects between-turn compaction without inventing a turn boundary', () => {
|
||||
const withStandaloneCompaction = [
|
||||
{
|
||||
@@ -707,6 +1066,7 @@ describe('timeline projection', () => {
|
||||
{
|
||||
...standaloneProps([]),
|
||||
...standaloneHistory(historySnapshot([])),
|
||||
...standaloneDuration(),
|
||||
},
|
||||
))
|
||||
expect(screen.getByRole('toolbar', { name: 'Trajectory toolbar' })).toBeTruthy()
|
||||
@@ -715,6 +1075,38 @@ describe('timeline projection', () => {
|
||||
})
|
||||
|
||||
describe('TrajectoryView branches', () => {
|
||||
it('persists the duration preference through the runtime snapshot-store seam', () => {
|
||||
const firstDuration = createTrajectoryDurationStore()
|
||||
const commonProps = {
|
||||
...standaloneProps(NODES),
|
||||
...standaloneHistory(historySnapshot(NODES)),
|
||||
}
|
||||
const first = render(
|
||||
<TrajectoryView
|
||||
{...commonProps}
|
||||
useDuration={bindSnapshotSelector(firstDuration)}
|
||||
setActualDuration={(value) => { firstDuration.set(value) }}
|
||||
/>,
|
||||
)
|
||||
const duration = screen.getByRole('button', { name: 'Use actual duration' })
|
||||
|
||||
expect(duration.getAttribute('aria-pressed')).toBe('false')
|
||||
fireEvent.click(duration)
|
||||
expect(localStorage.getItem('dsh.trajectory.duration')).toBe('true')
|
||||
first.unmount()
|
||||
|
||||
const restoredDuration = createTrajectoryDurationStore()
|
||||
render(
|
||||
<TrajectoryView
|
||||
{...commonProps}
|
||||
useDuration={bindSnapshotSelector(restoredDuration)}
|
||||
setActualDuration={(value) => { restoredDuration.set(value) }}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByRole('button', { name: 'Use actual duration' }).getAttribute('aria-pressed'))
|
||||
.toBe('true')
|
||||
})
|
||||
|
||||
it('renders only the selected rewind branch while retaining session-global requests', () => {
|
||||
const retained = {
|
||||
kind: 'user',
|
||||
@@ -770,8 +1162,10 @@ describe('TrajectoryView branches', () => {
|
||||
const view = render(
|
||||
<TrajectoryView
|
||||
{...standaloneProps([])}
|
||||
{...standaloneDuration()}
|
||||
useHistory={bindSnapshotSelector(store)}
|
||||
loadAllHistory={vi.fn(() => Promise.resolve())}
|
||||
loadHistoryTail={vi.fn(() => Promise.resolve())}
|
||||
loadOlderHistory={vi.fn(() => Promise.resolve(false))}
|
||||
/>,
|
||||
)
|
||||
|
||||
@@ -781,6 +1175,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,
|
||||
@@ -812,8 +1274,10 @@ describe('TrajectoryView branches', () => {
|
||||
render(
|
||||
<TrajectoryView
|
||||
{...standaloneProps([])}
|
||||
{...standaloneDuration()}
|
||||
useHistory={bindSnapshotSelector(store)}
|
||||
loadAllHistory={vi.fn(() => Promise.resolve())}
|
||||
loadHistoryTail={vi.fn(() => Promise.resolve())}
|
||||
loadOlderHistory={vi.fn(() => Promise.resolve(false))}
|
||||
/>,
|
||||
)
|
||||
|
||||
|
||||
95
packages/client/ui-trajectory/tests/virtual-rows.spec.ts
Normal file
95
packages/client/ui-trajectory/tests/virtual-rows.spec.ts
Normal 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',
|
||||
)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user