Files
dsh-context/tests/client/components/browser.spec.ts
T
Bowen Liang 4d144147a0 refactor(client): import React members directly, dropping the central react module
Every client file reached hooks and element types through the central
require seam (src/client/react.ts) and spelled them React.useState /
ReactNS.ReactElement. Import the members directly instead — named value
imports for hooks, inline type-only imports for element types — and let
the bundler own the single require('react'), which is exactly what the
platform module table answers.

This needs JSX off the classic runtime (it required a React identifier
in every tsx scope): tsconfig moves to jsx: react-jsx, so the bundle
sources jsx from 'react/jsx-runtime', already seeded by every supported
harness (verified against the dsh-v0.1.2-rc.1 platform.ts). The bundle
smoke's fake module table gains the specifier to stay a faithful shell
stand-in. h() survives as a local createElement alias at the slot
render-prop seams and the lightbox portal imports createPortal from
react-dom directly.

Verified: the built client.js requires react, react/jsx-runtime, and
react-dom exactly once each; the compat lane (bundle smoke + the
real-baseline matrix) passes against the fresh artifacts; the full gate
stays green with per-file coverage at 100%.
2026-09-07 08:59:03 +08:00

1507 lines
74 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ContextBrowser (src/client/components/browser.tsx) rendered with real
// React in jsdom: picker, category accordion, header content (schema
// narrowing matrix), conversation join (block cascade, tail-status matrix),
// targeted content fetch, hover linkage, and the focus bridges.
import { act, createElement as h } from 'react'
import assert from 'node:assert/strict'
import { describe, test } from 'vitest'
import { makeContextBrowser, type ContextBrowserProps } from '../../../src/client/components/browser'
import { makeStackedBar } from '../../../src/client/components/stackedBar'
import { DICT_EN } from '../../../src/client/i18n'
import { UNKNOWN_TOOL_SOURCE, type ContextHeaders, type ContextTimeline, type HeaderEpochContent, type RequestRecord, type SurfaceNode } from '../../../src/shared/types'
import { headersOf, type ConversationNodeLike, type ImageLoader } from '../../../src/client/services'
import { click, flush, hover, makeKit, mount, query, queryAll, text, unhover, type Mounted } from '../helpers/kit'
const kit = makeKit()
const Browser = makeContextBrowser(kit, makeStackedBar(kit))
// Category row order is CATS: system, tools, user, inject, assistant, tool.
const ROW = { system: 0, tools: 1, user: 2, inject: 3, assistant: 4, tool: 5 } as const
function tl(over: Partial<ContextTimeline>): ContextTimeline {
return {
ok: true,
current: { system: 0, tools: 0, user: 0, inject: 0, assistant: 0, tool: 0, total: 0 },
requests: [], events: [], nodes: [], droppedNodes: 0, archive: [],
...over,
}
}
function req(over: Partial<RequestRecord>): RequestRecord {
return {
time: 1_000, seq: 1, turn: 1, step: 0,
system: 0, tools: 0, user: 0, inject: 0, assistant: 0, tool: 0, total: 0,
...over,
}
}
function node(over: Partial<SurfaceNode> & { seq: number }): SurfaceNode {
return { cat: 'user', tokens: 5, ...over }
}
function catRow(m: Mounted, cat: keyof typeof ROW): HTMLElement {
return queryAll(m.container, '.lc-br-cat-row')[ROW[cat]]
}
function elemRows(m: Mounted): HTMLElement[] {
return queryAll(m.container, '.lc-br-elem-row')
}
/** Type into the tool-schema search box like a real user (native setter + input event). */
async function typeToolSearch(m: Mounted, value: string): Promise<void> {
await act(async () => {
const input = query<HTMLInputElement>(m.container, '.lc-br-tool-search')
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set
setter?.call(input, value)
input.dispatchEvent(new Event('input', { bubbles: true }))
})
}
async function pickStep(m: Mounted, value: string): Promise<void> {
const sel = query<HTMLSelectElement>(m.container, 'select.lc-br-pick')
await act(async () => {
sel.value = value
sel.dispatchEvent(new Event('change', { bubbles: true }))
})
}
function props(over: Partial<ContextBrowserProps>): ContextBrowserProps {
return { data: tl({}), headers: null, ...over }
}
/**
* Pair metadata-only `contextHeaders` with a fetchHeader serving per-epoch
* CONTENT — the lazy-load contract the browser consumes: the projection
* carries boundaries and token prices, the content arrives on demand.
*/
function withEpochContent(
headers: ContextHeaders,
contents: Record<number, HeaderEpochContent>,
): { headers: ContextHeaders; fetchHeader: (seq: number) => Promise<HeaderEpochContent | null> } {
return { headers, fetchHeader: seq => Promise.resolve(contents[seq] ?? null) }
}
describe('ContextBrowser live surface', () => {
test('title, picker, live meta, category rows; empty categories stay shut', async () => {
const data = tl({
current: { system: 100, tools: 200, user: 50, inject: 0, assistant: 0, tool: 0, total: 350 },
requests: [
req({ seq: 10, turn: 1, step: 0 }),
req({ seq: 20, turn: 1, step: 1, prompt: 800 }),
req({ seq: 7, time: 500, turn: undefined, step: undefined }),
],
nodes: [node({ seq: 1, text: 'hello' })],
})
const m = await mount(h(Browser, props({ data })))
assert.ok(text(query(m.container, '.lc-card-title-text')).includes('Context Browser'))
assert.ok(text(query(m.container, '.lc-br-hint')).includes('vs previous turn'))
const sel = query<HTMLSelectElement>(m.container, 'select.lc-br-pick')
assert.equal(sel.value, 'live')
const options = queryAll(sel, 'option').map(o => text(o))
assert.equal(options.length, 4, 'live + one option per request')
assert.equal(options[0], 'Live (Next Request)')
assert.ok(options.some(o => o.includes('Turn 0 · Step 0')), 'requests without turn/step degrade to zeroes')
const meta = text(query(m.container, '.lc-br-meta'))
assert.ok(meta.includes('Live · Next Request'))
assert.ok(meta.includes('Estimated ≈ 350'))
assert.ok(!meta.includes('Actual Prompt'), 'no actual figure on the live surface')
assert.equal(queryAll(m.container, '.lc-br-cat-row').length, 6)
assert.ok(text(catRow(m, 'user')).includes('1 Items'))
assert.ok(queryAll(m.container, '.lc-br-cat')[ROW.inject].className.includes('lc-br-cat-empty'), 'empty category is marked')
// Picking the stamp-less request: meta degrades to zeroes, no baseline exists.
await pickStep(m, '7')
const meta2 = text(query(m.container, '.lc-br-meta'))
assert.ok(meta2.includes('Turn 0 · Step 0'))
assert.equal(queryAll(m.container, '.lc-br-delta').length, 0, 'no previous turn to compare against')
await pickStep(m, 'live')
await click(catRow(m, 'inject'))
assert.equal(queryAll(m.container, '.lc-br-body').length, 0)
assert.ok(queryAll(m.container, '.lc-br-pct').some(el => text(el).endsWith('%')))
await m.unmount()
})
test('zero-total surface renders no percentages; no requests means no delta pills', async () => {
const m = await mount(h(Browser, props({ data: tl({}) })))
assert.ok(queryAll(m.container, '.lc-br-pct').every(el => text(el) === ''))
assert.equal(queryAll(m.container, '.lc-br-delta').length, 0)
assert.equal(queryAll(m.container, '.lc-br-tdelta').length, 0)
assert.ok(text(query(m.container, '.lc-br-meta')).includes('Estimated ≈ 0'))
await m.unmount()
})
test('dropped live nodes raise the missing-window note, live and on later steps only', async () => {
const data = tl({
current: { system: 0, tools: 0, user: 10, inject: 0, assistant: 0, tool: 0, total: 10 },
requests: [req({ seq: 3, turn: 1, step: 0 }), req({ seq: 10, turn: 1, step: 1 })],
nodes: [node({ seq: 6, text: 'served' })],
droppedNodes: 2,
surfaceFloor: 5,
archiveFloor: 8,
})
const m = await mount(h(Browser, props({ data })))
assert.ok(text(m.container).includes('2 earlier messages are also part of the context'), 'live flags the dropped tail')
// Step before the floor: the dropped slice is not attributable → no note.
await pickStep(m, '3')
assert.ok(!text(m.container).includes('earlier messages are also part'))
assert.ok(text(m.container).includes('approximate'), 'seq below the archive floor is approximate')
// Step after the floor: the dropped slice is inside the context.
await pickStep(m, '10')
assert.ok(text(m.container).includes('2 earlier messages are also part of the context'))
assert.ok(!text(m.container).includes('approximate'), 'seq at/after the archive floor is exact')
await m.unmount()
})
test('step picking switches the assembled view; back-to-live restores the surface', async () => {
const data = tl({
current: { system: 0, tools: 0, user: 30, inject: 0, assistant: 10, tool: 0, total: 40 },
requests: [req({ seq: 10, turn: 1, step: 0, user: 25 }), req({ seq: 20, turn: 1, step: 1, user: 30, assistant: 10, prompt: 800 })],
nodes: [node({ seq: 1, text: 'first question' }), node({ seq: 11, cat: 'assistant', tokens: 10, text: 'first answer' })],
archive: [node({ seq: 0, tokens: 5, text: 'archived hello', gone: 15 })],
})
const m = await mount(h(Browser, props({ data })))
await pickStep(m, '10')
const meta = text(query(m.container, '.lc-br-meta'))
assert.ok(meta.includes('Turn 1 · Step 0'))
assert.ok(!meta.includes('Actual Prompt'), 'this step reported no usage')
// The step's surface: archived node (gone 15 > 10) + seq 1; seq 11 is the response.
await click(catRow(m, 'user'))
assert.equal(elemRows(m).length, 2, 'archived + live nodes reconstructed')
assert.ok(text(m.container).includes('archived hello'))
await pickStep(m, '20')
const meta2 = text(query(m.container, '.lc-br-meta'))
assert.ok(meta2.includes('Turn 1 · Step 1'))
assert.ok(meta2.includes('Actual Prompt 800'))
assert.equal(queryAll(m.container, '.lc-br-body').length, 0, 'picking a step collapses the accordion')
await pickStep(m, 'live')
await click(catRow(m, 'user'))
assert.equal(elemRows(m).length, 1, 'live excludes removed nodes')
assert.ok(!text(m.container).includes('archived hello'))
await m.unmount()
})
test('delta pills read against the previous turn’s last step; live reads the last request', async () => {
const data = tl({
current: { system: 1, tools: 2, user: 99, inject: 0, assistant: 0, tool: 0, total: 102 },
requests: [
req({ seq: 1, turn: 1, step: 0, system: 1, tools: 2, user: 10, total: 13 }),
req({ seq: 2, turn: 1, step: 1, system: 1, tools: 2, user: 20, total: 23 }),
req({ seq: 3, turn: 2, step: 0, system: 1, tools: 2, user: 30, total: 33 }),
req({ seq: 4, turn: 2, step: 1, system: 1, tools: 2, user: 40, total: 43 }),
req({ seq: 5, turn: 3, step: 0, system: 1, tools: 2, user: 50, total: 53 }),
],
nodes: [1, 2, 3, 4, 5].map(seq => node({ seq, tokens: seq })),
})
const m = await mount(h(Browser, props({ data })))
await pickStep(m, '4')
const countPills = queryAll(m.container, '.lc-br-delta')
assert.equal(countPills.length, 1, 'only the user count changed')
assert.equal(text(countPills[0]), '+2')
assert.ok(countPills[0].className.includes('lc-br-delta-up'))
const tokenPills = queryAll(m.container, '.lc-br-tdelta')
assert.equal(tokenPills.length, 1)
assert.equal(text(tokenPills[0]), '+20')
// First turn: no previous-turn baseline → no pills at all.
await pickStep(m, '1')
assert.equal(queryAll(m.container, '.lc-br-delta').length, 0)
assert.equal(queryAll(m.container, '.lc-br-tdelta').length, 0)
// Live: baseline is the most recent request.
await pickStep(m, 'live')
assert.equal(text(query(m.container, '.lc-br-delta')), '+1')
assert.equal(text(query(m.container, '.lc-br-tdelta')), '+49')
await m.unmount()
})
test('shrinking categories render downward pills', async () => {
const data = tl({
current: { system: 0, tools: 0, user: 15, inject: 0, assistant: 0, tool: 0, total: 15 },
requests: [
req({ seq: 2, turn: 1, step: 1, user: 25, total: 25 }),
req({ seq: 3, turn: 2, step: 0, user: 15, total: 15 }),
],
nodes: [node({ seq: 1, tokens: 10, text: 'kept' })],
archive: [node({ seq: 0, tokens: 15, text: 'pruned away', gone: 3 })],
})
const m = await mount(h(Browser, props({ data })))
await pickStep(m, '3')
const countPill = query(m.container, '.lc-br-delta')
assert.equal(text(countPill), '-1', 'the archived node left the step’s surface')
assert.ok(countPill.className.includes('lc-br-delta-down'))
const tokenPill = query(m.container, '.lc-br-tdelta')
assert.equal(text(tokenPill), '-10')
assert.ok(tokenPill.className.includes('lc-br-tdelta-down'))
await m.unmount()
})
test('hover linkage: category rows report hovers and mirror the shared key while live', async () => {
const data = tl({
current: { system: 0, tools: 0, user: 10, inject: 0, assistant: 0, tool: 0, total: 10 },
requests: [req({ seq: 10, turn: 1, step: 0, user: 10, total: 10 })],
nodes: [node({ seq: 1, text: 'hi' })],
})
const hovers: (string | null)[] = []
const el = h(Browser, props({ data, hoverKey: null, onHoverKey: (k) => { hovers.push(k) } }))
const m = await mount(el)
await hover(catRow(m, 'user'))
await unhover(catRow(m, 'user'))
assert.deepEqual(hovers, ['user', null])
// Mirrored hover lights the row; the 'free' key has no category row.
await m.update(h(Browser, props({ data, hoverKey: 'user', onHoverKey: (k) => { hovers.push(k) } })))
assert.ok(catRow(m, 'user').className.includes('lc-br-cat-on'))
await m.update(h(Browser, props({ data, hoverKey: 'free', onHoverKey: (k) => { hovers.push(k) } })))
assert.ok(!catRow(m, 'user').className.includes('lc-br-cat-on'))
// A picked step has a different composition: the linkage drops.
await pickStep(m, '10')
const before = hovers.length
await hover(catRow(m, 'user'))
assert.equal(hovers.length, before, 'no hover reporting while a step is shown')
assert.ok(!catRow(m, 'user').className.includes('lc-br-cat-on'))
await m.unmount()
// No hoverKey wiring at all: hovering is a no-op.
const m2 = await mount(h(Browser, props({ data })))
await hover(catRow(m2, 'user'))
await m2.unmount()
})
test('previewSeq transiently shows a step; unknown preview/pin seqs fall back to live', async () => {
const data = tl({
current: { system: 0, tools: 0, user: 10, inject: 0, assistant: 0, tool: 0, total: 10 },
requests: [req({ seq: 10, turn: 1, step: 0, user: 10, total: 10, prompt: 700 })],
nodes: [node({ seq: 1, text: 'hi' })],
})
const m = await mount(h(Browser, props({ data, previewSeq: 10 })))
const meta = text(query(m.container, '.lc-br-meta'))
assert.ok(meta.includes('Turn 1 · Step 0'))
assert.ok(meta.includes('Preview'))
assert.ok(meta.includes('Actual Prompt 700'))
// Unknown preview seq: no request matches → live surface.
await m.update(h(Browser, props({ data, previewSeq: 999 })))
assert.ok(text(query(m.container, '.lc-br-meta')).includes('Live · Next Request'))
// A pinned step trimmed out of retention falls back to live too.
await m.update(h(Browser, props({ data, previewSeq: null, pinSeq: 999 })))
assert.ok(text(query(m.container, '.lc-br-meta')).includes('Live · Next Request'))
assert.equal(query<HTMLSelectElement>(m.container, 'select.lc-br-pick').value, 'live')
await m.unmount()
})
})
describe('ContextBrowser header epochs', () => {
// The projection carries METADATA only; the epoch content arrives via fetchHeader.
const HEADERS: ContextHeaders = {
headers: [
{ seq: 15, time: 1500, systemTokens: 8, tools: [{ name: 'old', tokens: 1 }] },
{ seq: 35, time: 3500, systemTokens: 12, tools: [{ name: 'fresh', tokens: 9 }] },
],
}
const CONTENTS: Record<number, HeaderEpochContent> = {
15: { system: 'SYS A', tools: [{ name: 'old', schema: {} }] },
35: { system: 'SYS B\nsecond line', tools: [{ name: 'fresh', description: 'fresh tool', schema: {} }] },
}
const lazy = withEpochContent(HEADERS, CONTENTS)
test('system category opens its prompt row directly; rich switch toggles raw/markdown', async () => {
const data = tl({ current: { system: 30, tools: 9, user: 0, inject: 0, assistant: 0, tool: 0, total: 39 } })
const m = await mount(h(Browser, props({ data, ...lazy })))
await click(catRow(m, 'system'))
await flush()
const body = query(m.container, '.lc-br-body')
assert.ok(text(body).includes('SYS B'), 'the newest epoch’s prompt shows on the live surface')
assert.ok(text(body).includes('2 lines'), 'line count rides the section head')
// The single system row is already expanded.
assert.equal(queryAll(m.container, '.lc-br-content').length, 1)
assert.ok(queryAll(m.container, '.lc-ts-desc-md').length >= 1, 'markdown view by default')
const rawBtn = queryAll(m.container, '.lc-rich-seg-btn')[0]
assert.equal(text(rawBtn), 'Raw')
await click(rawBtn)
const lines = queryAll(m.container, '.lc-ts-line').map(line => line.textContent)
assert.deepEqual(lines, ['SYS B', 'second line'])
await click(queryAll(m.container, '.lc-rich-seg-btn')[1])
assert.ok(queryAll(m.container, '.lc-ts-desc-md').length >= 1, 'markdown restored')
await click(catRow(m, 'system'))
assert.equal(queryAll(m.container, '.lc-br-body').length, 0)
await m.unmount()
})
test('a past step reads the epoch in force at its seq', async () => {
const data = tl({
current: { system: 30, tools: 9, user: 0, inject: 0, assistant: 0, tool: 0, total: 39 },
requests: [req({ seq: 20, turn: 1, step: 0 }), req({ seq: 40, turn: 1, step: 1 })],
})
const m = await mount(h(Browser, props({ data, ...lazy })))
await pickStep(m, '20')
await click(catRow(m, 'system'))
await flush()
assert.ok(text(query(m.container, '.lc-br-body')).includes('SYS A'), 'epoch before the request applies')
await m.unmount()
})
test('an epoch fetches once and the content stays cached across steps', async () => {
const data = tl({
current: { system: 30, tools: 9, user: 0, inject: 0, assistant: 0, tool: 0, total: 39 },
requests: [req({ seq: 20, turn: 1, step: 0 }), req({ seq: 40, turn: 1, step: 1 })],
})
let calls = 0
const counting = { ...lazy, fetchHeader: (seq: number) => { calls += 1; return lazy.fetchHeader(seq) } }
const m = await mount(h(Browser, props({ data, ...counting })))
await click(catRow(m, 'system'))
await flush()
assert.ok(text(query(m.container, '.lc-br-body')).includes('SYS B'))
assert.equal(calls, 1)
// The other epoch costs its own fetch.
await pickStep(m, '20')
await click(catRow(m, 'system'))
await flush()
assert.ok(text(query(m.container, '.lc-br-body')).includes('SYS A'))
assert.equal(calls, 2)
// Re-opening the first epoch's section hits the cache, not the fetcher.
await pickStep(m, 'live')
await click(catRow(m, 'system'))
await flush()
assert.ok(text(query(m.container, '.lc-br-body')).includes('SYS B'))
assert.equal(calls, 2)
await m.unmount()
})
test('a failed epoch fetch arms the retry button; retrying succeeds', async () => {
const data = tl({ current: { system: 30, tools: 9, user: 0, inject: 0, assistant: 0, tool: 0, total: 39 } })
const pendings: Array<{ resolve: (v: HeaderEpochContent | null) => void; reject: (e: unknown) => void }> = []
const deferred: () => Promise<HeaderEpochContent | null> = () =>
new Promise((resolve, reject) => { pendings.push({ resolve, reject }) })
const m = await mount(h(Browser, props({ data, headers: HEADERS, fetchHeader: deferred })))
await click(catRow(m, 'system'))
await flush()
assert.ok(text(query(m.container, '.lc-br-body')).includes('Loading'), 'the fetch is in flight')
await act(async () => { pendings[0]!.reject(new Error('down')) })
await flush()
assert.ok(text(query(m.container, '.lc-br-body')).includes('Load failed'), 'a rejected read arms the retry')
await click(query(m.container, '.lc-br-retry'))
await flush()
assert.ok(text(query(m.container, '.lc-br-body')).includes('Loading'), 'the retry refetches')
await act(async () => { pendings[1]!.resolve(CONTENTS[35]!) })
await flush()
assert.ok(text(query(m.container, '.lc-br-body')).includes('SYS B'), 'the retried read renders')
await m.unmount()
})
test('a stale epoch fetch resolves and rejects ignored once another epoch takes over', async () => {
const data = tl({
current: { system: 30, tools: 9, user: 0, inject: 0, assistant: 0, tool: 0, total: 39 },
requests: [req({ seq: 20, turn: 1, step: 0 }), req({ seq: 40, turn: 1, step: 1 })],
})
const pendings: Array<{ resolve: (v: HeaderEpochContent | null) => void; reject: (e: unknown) => void }> = []
const deferred = (): Promise<HeaderEpochContent | null> =>
new Promise((resolve, reject) => { pendings.push({ resolve, reject }) })
const m = await mount(h(Browser, props({ data, headers: HEADERS, fetchHeader: deferred })))
// Epoch 35's fetch is in flight; picking step 20 re-arms for epoch 15.
await click(catRow(m, 'system'))
await flush()
await pickStep(m, '20')
await click(catRow(m, 'system'))
await flush()
assert.equal(pendings.length, 2)
// The stale epoch REJECTS first — ignored: no retry button, still loading epoch 15.
await act(async () => { pendings[0]!.reject(new Error('stale')) })
await flush()
assert.ok(!text(m.container).includes('Load failed'))
assert.ok(text(query(m.container, '.lc-br-body')).includes('Loading'))
// The live epoch settles and renders.
await act(async () => { pendings[1]!.resolve(CONTENTS[15]!) })
await flush()
assert.ok(text(query(m.container, '.lc-br-body')).includes('SYS A'))
// Back to live: epoch 35's fetch is in flight; the cached epoch 15 renders
// without a fetch — then the stale resolve lands, ignored.
await pickStep(m, 'live')
await click(catRow(m, 'system'))
await flush()
assert.equal(pendings.length, 3)
await pickStep(m, '20')
await click(catRow(m, 'system'))
await flush()
assert.equal(pendings.length, 3, 'epoch 15 is cached')
assert.ok(text(query(m.container, '.lc-br-body')).includes('SYS A'))
await act(async () => { pendings[2]!.resolve(CONTENTS[35]!) })
await flush()
assert.ok(!text(m.container).includes('SYS B'), 'the stale content is ignored')
await m.unmount()
})
test('an epoch missing from the durable log degrades to the not-in-log note', async () => {
const data = tl({ current: { system: 30, tools: 9, user: 0, inject: 0, assistant: 0, tool: 0, total: 39 } })
const m = await mount(h(Browser, props({
data, headers: HEADERS, fetchHeader: () => Promise.resolve(null),
})))
await click(catRow(m, 'system'))
await flush()
assert.ok(text(query(m.container, '.lc-br-body')).includes('not in the session log'))
await click(catRow(m, 'tools'))
await flush()
assert.ok(text(queryAll(m.container, '.lc-br-body')[0]).includes('not in the session log'))
await m.unmount()
})
test('a host without the history face degrades to the metadata-only note', async () => {
const data = tl({ current: { system: 30, tools: 9, user: 0, inject: 0, assistant: 0, tool: 0, total: 39 } })
const m = await mount(h(Browser, props({ data, headers: HEADERS })))
await click(catRow(m, 'system'))
assert.ok(text(query(m.container, '.lc-br-body')).includes('token estimates only'))
await click(catRow(m, 'tools'))
assert.equal(elemRows(m).length, 1, 'metadata rows still render')
// The lone metadata row auto-opens with the category; its body explains the degradation.
assert.ok(text(query(m.container, '.lc-br-content')).includes('token estimates only'))
await m.unmount()
})
test('absent headers projection degrades to the tokens-only note', async () => {
const data = tl({ current: { system: 30, tools: 9, user: 0, inject: 0, assistant: 0, tool: 0, total: 39 } })
const m = await mount(h(Browser, props({ data, headers: null })))
await click(catRow(m, 'system'))
assert.ok(text(query(m.container, '.lc-br-body')).includes('older plugin build'))
await click(catRow(m, 'tools'))
assert.ok(text(queryAll(m.container, '.lc-br-body')[0]).includes('older plugin build'))
await m.unmount()
})
test('an epoch outside retention degrades to the no-epoch note', async () => {
const data = tl({
current: { system: 30, tools: 9, user: 0, inject: 0, assistant: 0, tool: 0, total: 39 },
requests: [req({ seq: 10, turn: 1, step: 0 })],
})
const m = await mount(h(Browser, props({ data, ...lazy })))
await pickStep(m, '10')
await click(catRow(m, 'system'))
assert.ok(text(query(m.container, '.lc-br-body')).includes('outside retention'))
await click(catRow(m, 'tools'))
assert.ok(text(queryAll(m.container, '.lc-br-body')[0]).includes('outside retention'))
await m.unmount()
})
test('an epoch without a system prompt keeps the system category shut', async () => {
const headers: ContextHeaders = { headers: [{ seq: 1, time: 1, tools: [{ name: 'x', tokens: 1 }] }] }
const data = tl({ current: { system: 0, tools: 1, user: 0, inject: 0, assistant: 0, tool: 0, total: 1 } })
const m = await mount(h(Browser, props({ data, ...withEpochContent(headers, { 1: { tools: [] } }) })))
assert.ok(text(catRow(m, 'system')).includes('0 Items'))
await click(catRow(m, 'system'))
assert.equal(queryAll(m.container, '.lc-br-body').length, 0, 'no prompt, no body')
await m.unmount()
})
test('a legacy content-bearing epoch (pre-#37 wire) still counts the system prompt', async () => {
// The value shape a host running the pre-#37 view serves: the system TEXT
// rides the epoch and no systemTokens price exists (the real cached row
// that read 0 项 while the breakdown still showed ≈1.6k). Routed through
// headersOf — the boundary the real callers sanitize the projection with.
const legacy = {
headers: [{
seq: 12,
time: 1_000,
system: 'You are an AI agent.\n',
tools: [{ name: 'bash', tokens: 815, description: 'run', schema: { type: 'object' } }],
}],
} as unknown as ContextHeaders
const data = tl({ current: { system: 9, tools: 815, user: 0, inject: 0, assistant: 0, tool: 0, total: 824 } })
const m = await mount(h(Browser, props({ data, headers: headersOf(legacy) })))
assert.ok(text(catRow(m, 'system')).includes(kit.t('browser.items', { n: 1 })))
await m.unmount()
})
test('a fetched epoch without a system prompt explains its absence', async () => {
const headers: ContextHeaders = { headers: [{ seq: 3, time: 3, systemTokens: 0, tools: [] }] }
const data = tl({ current: { system: 0, tools: 0, user: 0, inject: 0, assistant: 0, tool: 0, total: 0 } })
const m = await mount(h(Browser, props({
data, headers, fetchHeader: () => Promise.resolve({ tools: [] }),
})))
await click(catRow(m, 'system'))
await flush()
assert.ok(text(query(m.container, '.lc-br-body')).includes('carried no system prompt'))
await m.unmount()
})
})
describe('ContextBrowser tool schemas', () => {
// Metadata rows (name/token/plugin) pair with fetched CONTENT carrying the
// descriptions and schemas — the lazy epoch contract.
const headers: ContextHeaders = {
headers: [{
seq: 1, time: 1, systemTokens: 3,
tools: [
// Producer order is NOT meaningful: rows re-rank by token price.
{ name: 'omega', tokens: 5, plugin: 'mcp:github' },
{ name: 'rho', tokens: 6 },
{ name: 'theta', tokens: 8 },
{ name: 'zeta', tokens: 10 },
{ name: 'epsilon', tokens: 20 },
{ name: 'delta', tokens: 30 },
{ name: 'gamma', tokens: 40 },
{ name: 'beta', tokens: 50 },
{ name: 'mega', tokens: 100 },
],
}],
}
const content: HeaderEpochContent = {
system: 'SYS',
tools: [
{ name: 'omega', schema: {} },
{ name: 'rho', schema: 'nope' },
{ name: 'theta', schema: { type: 'object', properties: null } },
{ name: 'zeta', schema: { type: 'object' } },
{ name: 'epsilon', schema: { type: 'object', properties: { z: { type: 'integer' } } } },
{ name: 'delta', schema: { parameters: 'junk', input_schema: { properties: { y: { type: 'integer' } } } } },
{ name: 'gamma', schema: { inputSchema: { properties: 'nope' } } },
{ name: 'beta', schema: { input_schema: { properties: { x: { type: 'string' } } } } },
{
name: 'mega', description: 'does everything',
schema: {
name: 'mega',
parameters: {
type: 'object',
properties: {
a: { type: 'string', description: 'the a param' },
b: { type: 'object', properties: { x: {}, y: {} } },
c: { type: 'array', items: { type: 'number' } },
d: { type: 'array' },
d2: { type: 'array', items: null },
e: { type: 'string', enum: ['x', 'y'] },
e2: { type: 'string', enum: [] },
f: { enum: [1, 2] },
g: { anyOf: [{ type: 'string' }, { type: 'number' }] },
h: { oneOf: [{ type: 'boolean' }] },
i: { anyOf: [null, { type: 'string' }] },
j: { anyOf: [] },
k: { anyOf: [42, null] },
l: { type: 'object', properties: {} },
m: { type: 'object', properties: null },
n: { type: 'object', properties: 42 },
o: 'not-an-object',
p: { description: 42 },
q: { description: '' },
},
required: ['a', 42],
},
},
},
],
}
const data = tl({ current: { system: 10, tools: 248, user: 0, inject: 0, assistant: 0, tool: 0, total: 258 } })
const lazyProps = () => props({ data, headers, fetchHeader: () => Promise.resolve(content) })
test('rows rank by token price; the schema narrowing matrix renders', async () => {
const m = await mount(h(Browser, lazyProps()))
await click(catRow(m, 'tools'))
await flush()
const rows = elemRows(m)
assert.equal(rows.length, 9)
assert.deepEqual(rows.map(r => text(query(r, '.lc-br-preview'))),
['mega', 'beta', 'gamma', 'delta', 'epsilon', 'zeta', 'theta', 'rho', 'omega'])
// mega: the full parameter matrix.
await click(rows[0])
const body = query(m.container, '.lc-br-content')
assert.ok(text(body).includes('does everything'), 'description section renders')
const paramRows = queryAll(body, '.lc-ts-param-row')
assert.equal(paramRows.length, 18, 'one row per object-valued property')
const byName = (n: string) => paramRows.find(r => text(query(r, '.lc-ts-param-name')) === n)
const typeOf = (n: string) => text(query(byName(n) as HTMLElement, '.lc-ts-param-type'))
assert.equal(typeOf('a'), 'string')
assert.equal(typeOf('b'), 'object{2}')
assert.equal(typeOf('c'), 'array<number>')
assert.equal(typeOf('d'), 'array')
assert.equal(typeOf('d2'), 'array')
assert.equal(typeOf('e'), 'string (enum)')
assert.equal(typeOf('e2'), 'string')
assert.equal(typeOf('f'), '(enum)')
assert.equal(typeOf('g'), 'string | number')
assert.equal(typeOf('h'), 'boolean')
assert.equal(typeOf('i'), 'string')
assert.equal(typeOf('j'), 'unknown')
assert.equal(typeOf('k'), 'unknown')
assert.equal(typeOf('l'), 'object')
assert.equal(typeOf('m'), 'object')
assert.equal(typeOf('n'), 'object')
assert.equal(typeOf('p'), 'unknown')
assert.equal(text(query(byName('a') as HTMLElement, '.lc-ts-param-req')), '✓')
assert.equal(text(query(byName('b') as HTMLElement, '.lc-ts-param-req-off')), '·')
assert.ok(text(byName('a') as HTMLElement).includes('the a param'))
assert.equal(queryAll(byName('p') as HTMLElement, '.lc-ts-param-desc').length, 0, 'non-string description hidden')
assert.equal(queryAll(byName('q') as HTMLElement, '.lc-ts-param-desc').length, 0, 'empty description hidden')
assert.ok(text(body).includes('Parameters'))
const toggle = query(body, '.lc-ts-json-toggle')
assert.ok(text(toggle).includes('View Raw JSON'))
await click(toggle)
assert.ok(text(query(body, '.lc-ts-desc-body')).includes('"parameters"'))
assert.ok(text(query(body, '.lc-ts-json-toggle')).includes('Collapse'))
await click(query(body, '.lc-ts-json-toggle'))
assert.equal(queryAll(body, 'pre').length, 0)
await click(elemRows(m)[0])
assert.equal(queryAll(m.container, '.lc-br-content').length, 0)
await m.unmount()
})
test('a text filter and the size/name sort narrow and re-rank the rows', async () => {
const m = await mount(h(Browser, lazyProps()))
await click(catRow(m, 'tools'))
await flush()
const input = query<HTMLInputElement>(m.container, '.lc-br-tool-search')
assert.equal(input.placeholder, 'Filter by name, description, or parameters…')
const sortBtns = queryAll(m.container, '.lc-br-toolctl .lc-gran-btn')
assert.equal(sortBtns.length, 2)
assert.ok(sortBtns[0].className.includes('lc-gran-on'), 'size is the default sort')
const names = () => elemRows(m).map(r => text(query(r, '.lc-br-preview')))
await typeToolSearch(m, 'gamma')
assert.deepEqual(names(), ['gamma'])
// The producer description matches: mega's 'does everything'.
await typeToolSearch(m, 'everything')
assert.deepEqual(names(), ['mega'])
// The parameter JSON matches: 'the a param' lives in mega's schema.
await typeToolSearch(m, 'the a param')
assert.deepEqual(names(), ['mega'])
// The plugin chip matches: omega carries mcp:github.
await typeToolSearch(m, 'github')
assert.deepEqual(names(), ['omega'])
// No match degrades to a note; clearing restores every row.
await typeToolSearch(m, 'zzz')
assert.equal(elemRows(m).length, 0)
assert.ok(text(m.container).includes('No tools match the current filter'))
await typeToolSearch(m, '')
assert.equal(elemRows(m).length, 9)
// Name sort re-ranks alphabetically; size restores the token-price ranking.
await click(sortBtns[1])
assert.ok(sortBtns[1].className.includes('lc-gran-on'))
assert.deepEqual(names(), ['beta', 'delta', 'epsilon', 'gamma', 'mega', 'omega', 'rho', 'theta', 'zeta'])
await click(sortBtns[0])
assert.deepEqual(names(), ['mega', 'beta', 'gamma', 'delta', 'epsilon', 'zeta', 'theta', 'rho', 'omega'])
await m.unmount()
})
test('before the epoch content loads, the filter scans names and plugins only', async () => {
const m = await mount(h(Browser, props({ data, headers, fetchHeader: () => new Promise(() => {}) })))
await click(catRow(m, 'tools'))
const names = () => elemRows(m).map(r => text(query(r, '.lc-br-preview')))
// The plugin chip is metadata: it matches pre-fetch.
await typeToolSearch(m, 'github')
assert.deepEqual(names(), ['omega'])
// Content-borne text cannot match before the fetch resolves.
await typeToolSearch(m, 'everything')
assert.equal(names().length, 0)
assert.ok(text(m.container).includes('No tools match the current filter'))
await m.unmount()
})
test('a hostile schema degrades the text filter, not the rows', async () => {
const cyclic: Record<string, unknown> = { type: 'object' }
cyclic['self'] = cyclic
const hostileContent: HeaderEpochContent = {
system: 'SYS',
tools: [
{ name: 'loop', schema: cyclic },
{ name: 'nully', schema: null },
{ name: 'plain', schema: {} },
],
}
const hostile: ContextHeaders = { headers: [{ seq: 1, time: 1, systemTokens: 3, tools: [
{ name: 'loop', tokens: 3 },
{ name: 'nully', tokens: 1 },
{ name: 'plain', tokens: 2 },
] }] }
const m = await mount(h(Browser, props({ data, headers: hostile, fetchHeader: () => Promise.resolve(hostileContent) })))
await click(catRow(m, 'tools'))
await flush()
assert.equal(elemRows(m).length, 3)
// The name still matches; the unstringifiable schema contributes no text.
await typeToolSearch(m, 'loop')
assert.deepEqual(elemRows(m).map(r => text(query(r, '.lc-br-preview'))), ['loop'])
// Searching schema-only text finds nothing here and shows the note.
await typeToolSearch(m, 'properties')
assert.equal(elemRows(m).length, 0)
assert.ok(text(m.container).includes('No tools match the current filter'))
await m.unmount()
})
test('schema nesting variants and the empty/degenerate arms', async () => {
const m = await mount(h(Browser, lazyProps()))
await click(catRow(m, 'tools'))
await flush()
const rows = elemRows(m)
const open = async (name: string) => {
const row = rows.find(r => text(query(r, '.lc-br-preview')) === name) as HTMLElement
await click(row)
return query(m.container, '.lc-br-content')
}
const close = async (name: string) => {
await click(rows.find(r => text(query(r, '.lc-br-preview')) === name) as HTMLElement)
}
// beta: input_schema nesting, no description.
let body = await open('beta')
assert.equal(queryAll(body, '.lc-ts-param-row').length, 1)
assert.ok(!text(body).includes('Description'), 'no description section without one')
await close('beta')
// gamma: inputSchema nesting with non-object properties → params empty.
body = await open('gamma')
assert.ok(text(body).includes('(no parameters)'))
await close('gamma')
// delta: a non-object `parameters` falls through to input_schema.
body = await open('delta')
assert.equal(queryAll(body, '.lc-ts-param-row').length, 1)
await close('delta')
// epsilon: bare-root object schema is itself the parameter object.
body = await open('epsilon')
assert.equal(queryAll(body, '.lc-ts-param-row').length, 1)
assert.ok(text(body).includes('integer'))
await close('epsilon')
// zeta: object type without properties → no params section at all.
body = await open('zeta')
assert.equal(queryAll(body, '.lc-ts-param-row').length, 0)
assert.ok(!text(body).includes('(no parameters)'))
assert.ok(queryAll(body, '.lc-ts-json-toggle').length === 1, 'raw JSON still available')
await close('zeta')
// theta: null properties counts as a (bare) params object with no rows.
body = await open('theta')
assert.ok(text(body).includes('(no parameters)'))
await close('theta')
// rho: non-object schema → no params, JSON toggle shows the literal.
body = await open('rho')
assert.equal(queryAll(body, '.lc-ts-param-row').length, 0)
await click(query(body, '.lc-ts-json-toggle'))
assert.ok(text(query(body, 'pre')).includes('"nope"'))
await close('rho')
// omega: the fetched raw entry carries no description and a bare schema —
// only the JSON toggle renders behind the row.
body = await open('omega')
assert.equal(queryAll(body, '.lc-ts-param-row').length, 0)
assert.ok(!text(body).includes('does everything'))
assert.ok(queryAll(body, '.lc-ts-json-toggle').length === 1)
await m.unmount()
})
test('a metadata row missing from the fetched content degrades to the not-in-log note', async () => {
const partial: HeaderEpochContent = { system: 'SYS', tools: [{ name: 'other', schema: {} }] }
const m = await mount(h(Browser, props({ data, headers, fetchHeader: () => Promise.resolve(partial) })))
await click(catRow(m, 'tools'))
await flush()
await click(elemRows(m).find(r => text(query(r, '.lc-br-preview')) === 'mega') as HTMLElement)
assert.ok(text(query(m.container, '.lc-br-content')).includes('not in the session log'))
await m.unmount()
})
test('tool rows tag the registering plugin when attribution exists', async () => {
const attributed: ContextHeaders = {
headers: [{ seq: 1, time: 1, systemTokens: 3, tools: [
{ name: 'write', tokens: 5, plugin: '@deepseek-ai/dsh-tool-fs' },
{ name: 'mcp__github__get_issue', tokens: 3, plugin: 'mcp:github' },
{ name: 'agent_teams_add_member', tokens: 2 }, // no attribution → no chip
// Boot-predating tools arrive with the unknown sentinel → localized tag.
{ name: 'claim_files', tokens: 2, plugin: UNKNOWN_TOOL_SOURCE },
] }],
}
const data = tl({ current: { system: 0, tools: 10, user: 0, inject: 0, assistant: 0, tool: 0, total: 10 } })
const m = await mount(h(Browser, props({ data, headers: attributed })))
await click(catRow(m, 'tools'))
const rows = elemRows(m)
assert.equal(rows.length, 4)
const chips = queryAll(m.container, '.lc-br-tool-plugin')
assert.equal(chips.length, 3, 'only attributed tools chip')
assert.ok(text(chips[0]).includes('@deepseek-ai/dsh-tool-fs'))
assert.ok(text(chips[1]).includes('mcp:github'))
assert.ok((chips[0] as HTMLElement).title.includes('The registering plugin of this tool'), 'the chip carries the i18n tooltip')
assert.ok(!text(rows[2]).includes('@'), 'unattributed tools stay untagged')
// The unknown-source sentinel renders the localized tag with the
// boot-timing explanation, never the raw sentinel string.
assert.equal(chips[2].textContent, 'Unknown plugin')
assert.ok((chips[2] as HTMLElement).title.includes('registered before the context plugin loaded'), 'unknown chips explain the boot gap')
// Layout: the tool name leads the row, the plugin chip trails it directly
// — one frame only, so `lc-br-tag` must not nest another `lc-br-tag`.
assert.equal(text(query(rows[0], '.lc-br-preview')), 'write', 'tool name leads')
const chip = chips[0] as HTMLElement
assert.equal(chip.previousElementSibling, query(rows[0], '.lc-br-preview'), 'plugin chip sits right after the tool name')
assert.equal(chip.parentElement!.className, 'lc-br-elem-row', 'plugin chip is a single frame, a direct row child')
assert.equal(queryAll(chip, '.lc-br-tag').length, 0, 'no nested tag wrapper')
await m.unmount()
})
test('a single-tool category opens its schema row with the category; multi stays collapsed', async () => {
const lone: ContextHeaders = {
headers: [{ seq: 1, time: 1, systemTokens: 3, tools: [
{ name: 'only', tokens: 5 },
] }],
}
const loneContent: HeaderEpochContent = {
system: 'SYS',
tools: [{ name: 'only', schema: { type: 'object', properties: { x: { type: 'string' } } } }],
}
const data = tl({ current: { system: 10, tools: 5, user: 0, inject: 0, assistant: 0, tool: 0, total: 15 } })
const m = await mount(h(Browser, props({ data, headers: lone, fetchHeader: () => Promise.resolve(loneContent) })))
await click(catRow(m, 'tools'))
await flush()
assert.equal(elemRows(m).length, 1)
const content = queryAll(m.container, '.lc-br-content')
assert.equal(content.length, 1, 'the lone tool schema is already expanded')
assert.equal(queryAll(content[0], '.lc-ts-param-row').length, 1, 'the schema body renders directly')
await click(catRow(m, 'tools'))
assert.equal(queryAll(m.container, '.lc-br-content').length, 0, 'toggling shut closes the row too')
await m.unmount()
const pair: ContextHeaders = {
headers: [{ seq: 1, time: 1, systemTokens: 3, tools: [{ name: 'a', tokens: 2 }, { name: 'b', tokens: 1 }] }],
}
const m2 = await mount(h(Browser, props({ data, headers: pair })))
await click(catRow(m2, 'tools'))
assert.equal(elemRows(m2).length, 2)
assert.equal(queryAll(m2.container, '.lc-br-content').length, 0, 'multi-tool categories open collapsed')
await m2.unmount()
})
})
describe('ContextBrowser message categories', () => {
// One rich live surface exercising every NodeContent/BlocksBody branch.
const convNodes: ConversationNodeLike[] = [
// user with two images then text (image group flush mid-loop + trailing)
{ kind: 'user', seq: 1, content: [
{ type: 'image', attachment: { attachmentId: 'a1', name: 'one.png', bytes: 2048, width: 100, height: 50 } },
{ type: 'image', attachment: { attachmentId: 'a2' } },
{ type: 'text', text: 'with images' },
] },
// user with text then one image
{ kind: 'user', seq: 2, content: [
{ type: 'text', text: 'one pic' },
{ kind: 'image', attachment: { attachmentId: 'a3' } },
] },
// user without a content array → hint fallback
{ kind: 'user', seq: 5 },
// assistant full block cascade
{ kind: 'assistant', seq: 68, blocks: [
{ kind: 'text', text: 'final answer' },
{ type: 'reasoning', text: 'thinking hard' },
{ kind: 'tool-call', name: 'bash', argsRaw: '{"command":"ls","timeout":30,"opts":{"a":1}}' },
{ kind: 'tool-call', name: 'broken', argsRaw: 'not json' },
{ type: 'tool-call', arguments: '{"file_path":"x.ts"}' },
{ kind: 'tool-call', name: 'noargs' },
{ type: 'tool-result', content: [
{ type: 'text', text: 'inner result' },
{ type: 'image', attachment: { attachmentId: 'b1' } },
] },
{ type: 'tool-result', content: 'not-an-array' },
{ kind: 'image', attachment: { attachmentId: 'b2', name: 'pic.png', bytes: 4096, width: 640, height: 480 } },
{ type: 'mystery', foo: 1 },
{ type: 'text', text: 42 },
'plain string block',
{ foo: 'bar' },
] },
// assistant without blocks but with content (legacy shape)
{ kind: 'assistant', seq: 67, content: [{ type: 'text', text: 'legacy body' }] },
// assistant summary sources
{ kind: 'assistant', seq: 62, blocks: [{ kind: 'tool-call', name: 'write', argsRaw: '{"file_path":"a.ts"}' }] },
{ kind: 'assistant', seq: 63, blocks: [{ kind: 'tool-call', name: 'read', argsRaw: '{}' }] },
{ kind: 'assistant', seq: 64, blocks: [{ kind: 'tool-call', name: 'edit', argsRaw: '{"path":"b.ts"}' }] },
// compaction nodes
{ kind: 'compaction', seq: 70, summary: 'SUMMARY BODY' },
{ kind: 'compaction', seq: 71, summary: null },
{ kind: 'compaction', seq: 72, summary: '' },
// inject join
{ kind: 'user', seq: 54, content: [{ type: 'text', text: 'relay body full' }] },
]
const data = tl({
current: { system: 0, tools: 0, user: 40, inject: 20, assistant: 60, tool: 30, total: 150 },
nodes: [
node({ seq: 1, tokens: 10, text: 'with images', time: 100 }),
node({ seq: 2, tokens: 9, text: 'one pic', time: 200 }),
node({ seq: 3, tokens: 5, text: 'fallback text', time: 300 }),
node({ seq: 4, tokens: 4, text: '', time: 400 }),
node({ seq: 5, tokens: 4, text: 'no content array', time: 500 }),
node({ seq: 50, cat: 'inject', tokens: 5, form: 'snapshot', text: 'state' }),
node({ seq: 51, cat: 'inject', tokens: 5, form: 'notice', text: 'heads up' }),
node({ seq: 52, cat: 'inject', tokens: 5, skill: 'code-review', text: 'skill body' }),
node({ seq: 53, cat: 'inject', tokens: 5, form: 'catalog' }),
node({ seq: 55, cat: 'inject', tokens: 5, form: 'notice', text: '' }),
node({ seq: 56, cat: 'inject', tokens: 5, text: 'no form' }),
node({ seq: 54, cat: 'inject', tokens: 5, form: 'relay', text: 'relay body' }),
node({ seq: 61, cat: 'assistant', tokens: 8, calls: ['bash', 'write'], text: 'done all' }),
node({ seq: 62, cat: 'assistant', tokens: 8, calls: ['write'] }),
node({ seq: 63, cat: 'assistant', tokens: 8, calls: ['read'] }),
node({ seq: 64, cat: 'assistant', tokens: 8 }),
node({ seq: 65, cat: 'assistant', tokens: 8 }),
node({ seq: 66, cat: 'assistant', tokens: 8, calls: [], text: 'plain text' }),
node({ seq: 67, cat: 'assistant', tokens: 8, text: 'legacy' }),
node({ seq: 68, cat: 'assistant', tokens: 20, text: 'full cascade' }),
node({ seq: 70, tokens: 6, text: 'summary node' }),
node({ seq: 71, tokens: 6, text: '' }),
node({ seq: 72, tokens: 6, text: 'empty summary' }),
],
})
const loadImage: ImageLoader = async (att) => {
if (att.attachmentId === 'a3' || att.attachmentId === 'b1') throw new Error('denied')
return 'blob:' + att.attachmentId
}
const mountBrowser = async (over: Partial<ContextBrowserProps> = {}) =>
mount(h(Browser, props({ data, convNodes, loadImage, ...over })))
test('user rows: image chips on collapsed rows, join content, fallbacks', async () => {
const m = await mountBrowser()
await click(catRow(m, 'user'))
const rows = elemRows(m)
assert.equal(rows.length, 8, 'user + compaction nodes, newest first')
assert.equal(text(query(rows[0], '.lc-br-preview')), 'empty summary')
// Image chips ride the collapsed row (seq 1 ×2, seq 2 ×1).
const rowOf = (preview: string) => rows.find(r => text(r).includes(preview)) as HTMLElement
assert.ok(text(rowOf('with images')).includes('Image ×2'))
assert.ok(text(rowOf('one pic')).includes('Image'))
assert.ok(!text(rowOf('one pic')).includes('×'))
// Open seq 1: the chip makes way for the grid; loader resolves two cards.
await click(rowOf('with images'))
await flush()
let content = query(m.container, '.lc-br-content')
assert.ok(!text(elemRows(m).find(r => text(r).includes('with images')) as HTMLElement).includes('Image ×2'),
'expanded row drops the chip')
assert.ok(text(content).includes('Images'))
const imgs = queryAll<HTMLImageElement>(content, '.lc-att-thumb img')
assert.equal(imgs.length, 2, 'both loads resolved')
assert.ok(imgs[0].src.includes('blob:a1'))
assert.ok(text(content).includes('one.png'))
assert.ok(text(content).includes('100×50'))
await click(elemRows(m).find(r => text(r).includes('with images')) as HTMLElement)
// Open seq 2: single image, load REJECTS → error placeholder.
await click(rowOf('one pic'))
await flush()
content = query(m.container, '.lc-br-content')
assert.equal(queryAll(content, '.lc-att-err').length, 1)
assert.ok(text(query(content, '.lc-att-err')).includes('⚠'))
await click(elemRows(m).find(r => text(r).includes('one pic')) as HTMLElement)
// Join missed with preview text: content section + the window note.
await click(rowOf('fallback text'))
content = query(m.container, '.lc-br-content')
assert.ok(text(content).includes('fallback text'))
assert.ok(text(content).includes('outside the loaded message window'))
await click(elemRows(m).find(r => text(r).includes('fallback text')) as HTMLElement)
// Join missed without text (seq 4, rows newest-first: 72,71,70,5,4,…): the note alone.
assert.ok(text(rows[4]).includes('(non-text message)'))
await click(rows[4])
content = query(m.container, '.lc-br-content')
assert.ok(text(content).includes('outside the loaded message window'))
assert.ok(!text(content).includes('Content'))
await m.unmount()
})
test('user node whose conversation entry carries no content array shows the note', async () => {
const m = await mountBrowser()
await click(catRow(m, 'user'))
await click(elemRows(m).find(r => text(r).includes('no content array')) as HTMLElement)
const content = query(m.container, '.lc-br-content')
assert.ok(text(content).includes('outside the loaded message window'))
await m.unmount()
})
test('a single-node category opens its node with the category', async () => {
const solo = tl({
current: { system: 0, tools: 0, user: 10, inject: 0, assistant: 0, tool: 0, total: 10 },
nodes: [node({ seq: 1, tokens: 10, text: 'only message', time: 100 })],
})
const m = await mount(h(Browser, props({
data: solo,
convNodes: [{ kind: 'user', seq: 1, content: [{ type: 'text', text: 'only message' }] }],
})))
await click(catRow(m, 'user'))
assert.equal(elemRows(m).length, 1)
assert.equal(queryAll(m.container, '.lc-br-content').length, 1, 'the lone node is already expanded')
assert.ok(text(query(m.container, '.lc-br-content')).includes('only message'))
await m.unmount()
})
test('message rows filter by tag and preview text; another category opens unfiltered', async () => {
const m = await mountBrowser()
await click(catRow(m, 'user'))
const input = query<HTMLInputElement>(m.container, '.lc-br-tool-search')
assert.equal(input.placeholder, 'Filter by message text…')
const previews = () => elemRows(m).map(r => text(query(r, '.lc-br-preview')))
await typeToolSearch(m, 'summary')
assert.deepEqual(previews(), ['empty summary', 'summary node'])
// No match keeps the toolbar mounted so the filter can be cleared.
await typeToolSearch(m, 'zzz')
assert.equal(elemRows(m).length, 0)
assert.ok(text(m.container).includes('No rows match the current filter'))
assert.equal(queryAll(m.container, '.lc-br-tool-search').length, 1)
await typeToolSearch(m, '')
assert.equal(elemRows(m).length, 8)
// The call-breadcrumb tag matches too (an assistant row's 'bash › write').
await click(catRow(m, 'assistant'))
assert.equal(query<HTMLInputElement>(m.container, '.lc-br-tool-search').value, '', 'another category opens unfiltered')
await typeToolSearch(m, 'bash')
assert.deepEqual(previews(), ['done all'])
await m.unmount()
})
test('user images render a placeholder when no loader is wired', async () => {
const m = await mountBrowser({ loadImage: undefined })
await click(catRow(m, 'user'))
await click(elemRows(m).find(r => text(r).includes('one pic')) as HTMLElement)
assert.equal(queryAll(m.container, '.lc-att-ph').length, 1)
await m.unmount()
})
test('assistant rows: preview cascade and the full block vocabulary', async () => {
const m = await mountBrowser()
await click(catRow(m, 'assistant'))
const rows = elemRows(m)
const rowOf = (preview: string) => rows.find(r => text(r).includes(preview)) as HTMLElement
assert.ok(text(rowOf('done all')).includes('bash › write'), 'call breadcrumb tag')
assert.ok(text(rowOf('a.ts')).includes('write'), 'block summary previews a textless turn')
const tags = rows.map(r => {
const tag = r.querySelector<HTMLElement>('.lc-br-tag')
const preview = text(query(r, '.lc-br-preview'))
return `${tag === null ? '∅' : text(tag)}|${preview}`
})
assert.ok(tags.includes('read|(empty reply)'), 'no self-summarizing call → empty marker')
assert.ok(tags.includes('∅|b.ts'), 'textless turn previews the joined call summary')
assert.ok(tags.includes('∅|(empty reply)'), 'no join, no calls → empty marker')
assert.ok(tags.includes('∅|Calls '), 'empty call list previews as a bare Calls label (nodeText)')
await click(rowOf('full cascade'))
const content = query(m.container, '.lc-br-content')
const heads = queryAll(content, '.lc-ts-card-head').map(el => text(el))
assert.ok(heads.some(s => s.includes('Response')))
assert.ok(heads.some(s => s.includes('Reasoning')))
assert.ok(heads.some(s => s.includes('→ bash')))
assert.ok(heads.some(s => s.includes('→ broken')))
assert.ok(heads.some(s => s.includes('→ ?')), 'nameless call card')
assert.ok(heads.some(s => s.includes('→ noargs')))
assert.ok(heads.some(s => s.includes('Result')), 'nested tool-result text section')
assert.ok(heads.filter(s => s.includes('Other content')).length === 5, 'unknown blocks render raw JSON')
assert.ok(heads.some(s => s.includes('Images')))
// Call arg rows: string, number and object values.
const argVals = queryAll(content, '.lc-ts-arg-row').map(el => text(el))
assert.ok(argVals.some(s => s.includes('command') && s.includes('ls')))
assert.ok(argVals.some(s => s.includes('timeout') && s.includes('30')))
assert.ok(argVals.some(s => s.includes('opts') && s.includes('{"a":1}')))
// Unparseable args show raw; absent args show nothing.
assert.ok(text(content).includes('not json'))
assert.ok(text(content).includes('inner result'))
await flush()
// b1 rejects (error), b2 resolves.
assert.equal(queryAll(content, '.lc-att-err').length, 1)
assert.equal(queryAll(content, '.lc-att-thumb img').length, 1)
await m.unmount()
})
test('assistant with a legacy content array renders the generic content section', async () => {
const m = await mountBrowser()
await click(catRow(m, 'assistant'))
await click(elemRows(m).find(r => text(r).includes('legacy')) as HTMLElement)
const content = query(m.container, '.lc-br-content')
assert.ok(text(content).includes('Content'))
assert.ok(text(content).includes('legacy body'))
await m.unmount()
})
test('assistant without a join shows only the window note', async () => {
const m = await mountBrowser()
await click(catRow(m, 'assistant'))
const row = elemRows(m).find(r => text(r).includes('(empty reply)') && !text(r).includes('read')) as HTMLElement
await click(row)
const content = query(m.container, '.lc-br-content')
assert.ok(text(content).includes('outside the loaded message window'))
await m.unmount()
})
test('compaction nodes: summary text, null and empty summaries', async () => {
const m = await mountBrowser()
await click(catRow(m, 'user'))
const rows = elemRows(m)
// Newest first: 72 (empty summary), 71 (null summary), 70 (summary node).
await click(rows[2])
let content = query(m.container, '.lc-br-content')
assert.ok(text(content).includes('Summary'))
assert.ok(text(content).includes('SUMMARY BODY'))
// Null summary → empty body.
await click(rows[1])
content = query(m.container, '.lc-br-content')
assert.equal(text(content), '')
// Empty-string summary → empty body.
await click(rows[0])
content = query(m.container, '.lc-br-content')
assert.equal(text(content), '')
await m.unmount()
})
test('inject rows: form tags, snapshot prefix, skill injects', async () => {
const m = await mountBrowser()
await click(catRow(m, 'inject'))
const rows = elemRows(m)
const tags = rows.map(r => {
const tag = r.querySelector<HTMLElement>('.lc-br-tag')
return `${tag === null ? '∅' : text(tag)}|${text(query(r, '.lc-br-preview'))}`
})
assert.ok(tags.includes('State Snapshot|Snapshot: state'))
assert.ok(tags.includes('Notice|heads up'))
assert.ok(tags.includes('∅|Skill: code-review'), 'skill injects keep the node-text label')
assert.ok(tags.includes('Catalog Update|Catalog Update'), 'textless inject previews its form')
assert.ok(tags.includes('Notice|Notice'), 'empty-string text keeps the form label')
assert.ok(tags.includes('Context Injection|no form'), 'formless inject defaults to the context label')
assert.ok(tags.includes('Agent Relay|relay body'))
// Expanded inject joins the conversation content.
await click(rows.find(r => text(r).includes('relay body')) as HTMLElement)
const content = query(m.container, '.lc-br-content')
assert.ok(text(content).includes('relay body full'))
await m.unmount()
})
})
describe('ContextBrowser tool results', () => {
// The tail-status matrix: dsh settles failing commands as completed calls,
// so trailing markers are the failure signal.
const cases: { seq: number; tail: string; err: boolean; label: string }[] = [
{ seq: 80, tail: 'boom\n[exit code: 3]', err: true, label: 'Failed · exit 3' },
{ seq: 81, tail: '[killed by signal: SIGKILL]', err: true, label: 'Failed' },
{ seq: 82, tail: '[shell killed by signal: SIGTERM]', err: true, label: 'Failed' },
{ seq: 83, tail: '[shell exited: code 2]', err: true, label: 'Failed · exit 2' },
{ seq: 84, tail: '[shell exited: code 0]', err: false, label: 'OK' },
{ seq: 85, tail: '[shell exited]', err: false, label: 'OK' },
{ seq: 86, tail: 'oops [exit code: 7]\n[shell killed by signal: SIGTERM]', err: true, label: 'Failed · exit 7' },
{ seq: 87, tail: '[status: killed]', err: true, label: 'Failed' },
{ seq: 88, tail: '[status: failed, boom]', err: true, label: 'Failed' },
{ seq: 89, tail: 'all good', err: false, label: 'OK' },
{ seq: 90, tail: 'a quoted [exit code: 9] marker mid-text\nmore output', err: false, label: 'OK' },
]
const convNodes: ConversationNodeLike[] = cases.map(c => ({
kind: 'tool-result', seq: c.seq,
call: { name: 'bash', argsRaw: '{"description":"run it"}' },
content: [{ type: 'image', attachment: { attachmentId: 'x' + String(c.seq) } }, null, { type: 'text', text: c.tail }],
}))
convNodes.push(
// Fold-stamped failure without any marker.
{ kind: 'tool-result', seq: 91, call: { name: 'read', argsRaw: '{}' }, content: [{ type: 'text', text: 'denied' }], isError: true },
// No call record → no call card.
{ kind: 'tool-result', seq: 92, call: null, content: [{ type: 'text', text: 'orphan' }] },
// Content that is not an array → no body sections.
{ kind: 'tool-result', seq: 93, call: { name: 'bash', argsRaw: '' }, content: 'raw-text' as never },
)
const nodes: SurfaceNode[] = [
...cases.map(c => node({ seq: c.seq, cat: 'tool', tokens: 9, tool: 'bash' })),
node({ seq: 91, cat: 'tool', tokens: 9, tool: 'read' }),
node({ seq: 92, cat: 'tool', tokens: 9, tool: 'bash' }),
node({ seq: 93, cat: 'tool', tokens: 9, tool: 'bash' }),
// Fold-stamped err flag, no join.
node({ seq: 94, cat: 'tool', tokens: 9, tool: 'write', err: true }),
// No join at all → placeholder preview; missing tool name → '?'.
node({ seq: 95, cat: 'tool', tokens: 9, tool: 'bash' }),
node({ seq: 96, cat: 'tool', tokens: 9 }),
// A skill result is labeled by skill name.
node({ seq: 97, cat: 'tool', tokens: 9, tool: 'skill', skill: 'grilling' }),
]
convNodes.push({ kind: 'tool-result', seq: 97, call: { name: 'skill', argsRaw: '{"description":"grill the plan"}' }, content: [{ type: 'text', text: 'skill body' }] })
const data = tl({
current: { system: 0, tools: 0, user: 0, inject: 0, assistant: 0, tool: 162, total: 162 },
nodes,
})
test('tail-status matrix drives the error dot; tags and previews render on collapsed rows', async () => {
const m = await mount(h(Browser, props({ data, convNodes })))
await click(catRow(m, 'tool'))
const rows = elemRows(m)
assert.equal(rows.length, nodes.length)
// Dot presence per case (collapsed rows): every marker failure + the two
// fold-stamped failures (isError, err flag) — nothing else.
const dots = queryAll(m.container, '.lc-br-err-dot')
assert.equal(dots.length, 9, 'one red dot per failing result')
// Newest-first order: rows[0] is seq 97 (skill), then 96, 95, 94, 93, ...
assert.ok(text(rows[0]).includes('Skill · grilling'), 'skill results label by name')
assert.ok(text(rows[0]).includes('grill the plan'), 'call summary previews')
assert.ok(text(rows[1]).includes('?'), 'missing tool name tags as ?')
assert.ok(text(rows[1]).includes('Tool Result'), 'no join → placeholder preview')
assert.ok(text(rows[2]).includes('Tool Result'))
assert.ok(text(rows[3]).includes('write'))
const byIdx = Object.fromEntries([97, 96, 95, 94, 93, 92, 91, 90, 89, 88, 87, 86, 85, 84, 83, 82, 81, 80].map((s, i) => [s, i]))
for (const c of cases) {
const hasDot = rows[byIdx[c.seq]].querySelector('.lc-br-err-dot') !== null
assert.equal(hasDot, c.err, `seq ${c.seq}: dot ${c.err ? 'shown' : 'hidden'} for tail ${JSON.stringify(c.tail)}`)
}
assert.ok(rows[byIdx[91]].querySelector('.lc-br-err-dot') !== null, 'isError stamps the dot')
assert.ok(rows[byIdx[94]].querySelector('.lc-br-err-dot') !== null, 'the fold err flag stamps the dot')
assert.ok(rows[byIdx[93]].querySelector('.lc-br-err-dot') === null, 'non-array content has no markers')
await click(rows[0])
const content = query(m.container, '.lc-br-content')
assert.ok(text(content).includes('OK'))
assert.ok(text(content).includes('skill body'))
await m.unmount()
})
test('clean results render the OK state; orphan results render no call card', async () => {
const m = await mount(h(Browser, props({ data, convNodes })))
await click(catRow(m, 'tool'))
const rows = elemRows(m)
// Newest first: index map by seq (97, 96, 95, 94, 93, 92, 91, 90..80).
const byIdx = Object.fromEntries([97, 96, 95, 94, 93, 92, 91, 90, 89, 88, 87, 86, 85, 84, 83, 82, 81, 80].map((s, i) => [s, i]))
const expand = async (seq: number) => {
await click(rows[byIdx[seq]])
return query(m.container, '.lc-br-content')
}
let content = await expand(89)
assert.ok(text(content).includes('OK'))
assert.ok(!queryAll(content, '.lc-br-err-dot').length)
await click(elemRows(m)[byIdx[89]])
// Exit-code failure with the pill.
content = await expand(80)
assert.ok(text(content).includes('Failed · exit 3'))
await click(elemRows(m)[byIdx[80]])
// Killed by signal: failure without an exit code.
content = await expand(81)
assert.ok(text(content).includes('Failed'))
assert.ok(!text(content).includes('exit'))
await click(elemRows(m)[byIdx[81]])
// Shell killed after a failing command: the command's code wins.
content = await expand(86)
assert.ok(text(content).includes('Failed · exit 7'))
await click(elemRows(m)[byIdx[86]])
// Orphan result: no call card, but the result body renders.
content = await expand(92)
assert.ok(!text(content).includes('→'), 'no call card without a call record')
assert.ok(text(content).includes('orphan'))
await click(elemRows(m)[byIdx[92]])
// Non-array content: call card only.
content = await expand(93)
assert.ok(text(content).includes('OK'))
assert.ok(!text(content).includes('raw-text'))
await m.unmount()
})
})
describe('ContextBrowser targeted content fetch', () => {
const data = tl({
current: { system: 0, tools: 0, user: 10, inject: 0, assistant: 0, tool: 0, total: 10 },
nodes: [node({ seq: 5, tokens: 5, text: 'pageable' }), node({ seq: 6, tokens: 5, text: 'also pageable' })],
})
const openFirstRow = async (m: Mounted) => {
await click(catRow(m, 'user'))
await click(elemRows(m)[1]) // seq 5 (newest first: 6, 5)
}
test('a missed join fetches the seq once, and the fetched body renders', async () => {
let calls = 0
let release: (() => void) | null = null
const fetchContent = async (seq: number) => {
calls += 1
return new Promise<void>((resolve) => { release = resolve })
.then(() => ({ kind: 'user', seq, content: [{ type: 'text', text: 'FULL BODY' }] }) as ConversationNodeLike)
}
const m = await mount(h(Browser, props({ data, convNodes: [], fetchContent })))
await openFirstRow(m)
assert.equal(calls, 1, 'one targeted read')
assert.ok(text(query(m.container, '.lc-br-content')).includes('Loading full content from older session history'), 'in-flight note')
await act(async () => { release?.() })
await flush()
assert.ok(text(query(m.container, '.lc-br-content')).includes('FULL BODY'))
// Close and reopen the same row: the fetched node is merged state — no re-read.
await click(elemRows(m)[1])
await click(elemRows(m)[1])
assert.ok(text(query(m.container, '.lc-br-content')).includes('FULL BODY'))
assert.equal(calls, 1, 'no second fetch for a merged seq')
await m.unmount()
})
test('a page without the seq reports it as absent from the session log', async () => {
let calls = 0
const fetchContent = async () => {
calls += 1
return null
}
const m = await mount(h(Browser, props({ data, convNodes: [], fetchContent })))
await openFirstRow(m)
await flush()
assert.equal(calls, 1)
assert.ok(text(query(m.container, '.lc-br-content')).includes('not in the session log anymore'))
await m.unmount()
})
test('a failed read arms the retry button; retrying succeeds', async () => {
let calls = 0
const fetchContent = async (seq: number) => {
calls += 1
if (calls === 1) throw new Error('transport down')
return { kind: 'user', seq, content: [{ type: 'text', text: 'RETRY BODY' }] } as ConversationNodeLike
}
const m = await mount(h(Browser, props({ data, convNodes: [], fetchContent })))
await openFirstRow(m)
await flush()
const failed = query(m.container, '.lc-br-content')
assert.ok(text(failed).includes('Load failed'))
await click(query(failed, '.lc-br-retry'))
await flush()
assert.ok(text(query(m.container, '.lc-br-content')).includes('RETRY BODY'))
assert.equal(calls, 2)
await m.unmount()
})
test('without a fetcher (older host) an un-joined row keeps the static note', async () => {
const m = await mount(h(Browser, props({ data, convNodes: [] })))
await openFirstRow(m)
await flush()
assert.ok(text(query(m.container, '.lc-br-content')).includes('outside the loaded message window'))
await m.unmount()
})
test('a late fetch for an abandoned row never lands; the next row fetches its own seq', async () => {
const deferreds: { resolve: (node: ConversationNodeLike | null) => void; reject: (reason: Error) => void }[] = []
const fetchedFor: number[] = []
const fetchContent = (seq: number): Promise<ConversationNodeLike | null> => {
fetchedFor.push(seq)
if (seq === 5) return new Promise((resolve, reject) => { deferreds.push({ resolve, reject }) })
return Promise.resolve({ kind: 'user', seq, content: [{ type: 'text', text: 'BODY ' + String(seq) }] } as ConversationNodeLike)
}
let snap: { nodes?: readonly ConversationNodeLike[] } = { nodes: [] }
const el = () => h(Browser, props({ data, convNodes: snap.nodes, fetchContent }))
const m = await mount(el())
await openFirstRow(m) // seq 5 hangs in flight
assert.equal(deferreds.length, 1)
await click(elemRows(m)[0]) // switch to seq 6 while seq 5 is pending
await flush()
assert.ok(text(query(m.container, '.lc-br-content')).includes('BODY 6'))
// The abandoned promise settles after the row was left — both outcomes are ignored.
await act(async () => {
deferreds[0]?.resolve({ kind: 'user', seq: 5, content: [{ type: 'text', text: 'STALE BODY' }] })
})
assert.ok(!text(m.container).includes('STALE BODY'), 'stale result ignored')
await click(elemRows(m)[1]) // seq 5 again: another deferred hangs
await click(elemRows(m)[0])
await flush()
await act(async () => {
deferreds[1]?.reject(new Error('gone'))
})
await flush()
assert.ok(text(query(m.container, '.lc-br-content')).includes('BODY 6'), 'stale rejection ignored')
// Reopening seq 5 renders once its own fetch settles.
await click(elemRows(m)[1])
await act(async () => {
deferreds[2]?.resolve({ kind: 'user', seq: 5, content: [{ type: 'text', text: 'BODY 5' }] })
})
await flush()
assert.ok(text(query(m.container, '.lc-br-content')).includes('BODY 5'))
assert.deepEqual(fetchedFor, [5, 6, 5, 5], 'one call per miss (seq 6 stays cached)')
// The conversation window catching up later wins over fetched state.
snap = { nodes: [{ kind: 'user', seq: 5, content: [{ type: 'text', text: 'WINDOW BODY' }] }] }
await m.update(el())
assert.ok(text(query(m.container, '.lc-br-content')).includes('WINDOW BODY'))
assert.ok(!text(m.container).includes('BODY 5'), 'the join takes precedence over the fetch cache')
await m.unmount()
})
})
describe('ContextBrowser focus bridges', () => {
const headers: ContextHeaders = {
headers: [{
seq: 1, time: 1, systemTokens: 3,
tools: [
{ name: 'beta', tokens: 50 },
{ name: 'alpha', tokens: 10 },
],
}],
}
const data = tl({
current: { system: 10, tools: 60, user: 10, inject: 0, assistant: 0, tool: 0, total: 80 },
requests: [req({ seq: 10, turn: 1, step: 0, total: 80 }), req({ seq: 20, turn: 1, step: 1, total: 80 })],
nodes: [node({ seq: 1, tokens: 5, text: 'focusable' }), node({ seq: 11, cat: 'assistant', tokens: 5, text: 'answer' })],
})
const convNodes: ConversationNodeLike[] = [{ kind: 'user', seq: 1, content: [{ type: 'text', text: 'focusable' }] }]
test('nodeFocus selects the step, opens the node, scrolls it into view, and clears', async () => {
const scrolls: unknown[] = []
const orig = Element.prototype.scrollIntoView
Element.prototype.scrollIntoView = function (this: Element, arg?: unknown) { scrolls.push(arg) } as never
try {
let handled = 0
const m = await mount(h(Browser, props({ data, headers, convNodes })))
await m.update(h(Browser, props({
data, headers, convNodes,
nodeFocus: { step: 'live', seq: 1, cat: 'user' },
onNodeFocusHandled: () => { handled += 1 },
})))
assert.equal(handled, 1)
const onRows = queryAll(m.container, '.lc-br-elem-on')
assert.equal(onRows.length, 1)
assert.ok(text(onRows[0]).includes('focusable'))
assert.deepEqual(scrolls, [{ block: 'nearest' }], 'the open row scrolled into view')
// A step focus switches the assembled view.
await m.update(h(Browser, props({
data, headers, convNodes,
nodeFocus: { step: 20, seq: 1, cat: 'user' },
onNodeFocusHandled: () => { handled += 1 },
})))
assert.ok(text(query(m.container, '.lc-br-meta')).includes('Turn 1 · Step 1'))
assert.equal(handled, 2)
// A node outside the assembled surface leaves nothing to scroll to.
await m.update(h(Browser, props({
data, headers, convNodes,
nodeFocus: { step: 'live', seq: 999, cat: 'user' },
})))
assert.equal(queryAll(m.container, '.lc-br-elem-on').length, 0)
await m.unmount()
} finally {
Element.prototype.scrollIntoView = orig
}
})
test('pinSeq selects the pinned step and unpinning returns to live (accordion resets)', async () => {
const m = await mount(h(Browser, props({ data, headers })))
await click(catRow(m, 'user'))
assert.equal(queryAll(m.container, '.lc-br-body').length, 1)
await m.update(h(Browser, props({ data, headers, pinSeq: 10 })))
assert.ok(text(query(m.container, '.lc-br-meta')).includes('Turn 1 · Step 0'))
assert.equal(queryAll(m.container, '.lc-br-body').length, 0, 'pinning resets the accordion')
assert.equal(query<HTMLSelectElement>(m.container, 'select.lc-br-pick').value, '10')
await m.update(h(Browser, props({ data, headers, pinSeq: null })))
assert.ok(text(query(m.container, '.lc-br-meta')).includes('Live · Next Request'))
await m.unmount()
})
})
describe('ContextBrowser — the split generation detail states', () => {
test('a pending detail read names the state on the note strip', async () => {
const m = await mount(h(Browser, { data: tl({}), headers: null, detailState: 'loading' }))
assert.ok(text(m.container).includes(DICT_EN['detail.loading']))
// The picker still offers the live surface (the sections read zero until the detail lands).
assert.equal(queryAll(m.container, '.lc-br-pick option').length, 1)
await m.unmount()
})
test('a failed detail read arms the retry button on the strip', async () => {
let retries = 0
const m = await mount(h(Browser, {
data: tl({}),
headers: null,
detailState: 'failed',
onDetailRetry: () => { retries++ },
}))
await click(query(m.container, '.lc-br-retry'))
assert.equal(retries, 1)
await m.unmount()
})
test('a landed (or inline) detail renders no strip', async () => {
for (const state of ['ready', 'legacy'] as const) {
const m = await mount(h(Browser, { data: tl({}), headers: null, detailState: state }))
assert.ok(!text(m.container).includes(DICT_EN['detail.loading']))
assert.ok(!text(m.container).includes(DICT_EN['detail.loadFailed']))
await m.unmount()
}
// And the prop-less caller (older hosts) renders nothing either.
const m = await mount(h(Browser, { data: tl({}), headers: null }))
assert.ok(queryAll(m.container, '.lc-br-retry').length === 0)
await m.unmount()
})
})