The Context Stats card's inject/compaction/prune cells duplicated what the events card already tracks. Drop the three cells (the grid falls to five: turns, steps, tool calls, cache hit, cost) and show each tally inside the matching kind-filter button instead, sourced from the same precomputed counts the stats card used.
1348 lines
61 KiB
TypeScript
1348 lines
61 KiB
TypeScript
// ContextView (src/client/components/contextView.tsx) — the Context tab root
|
||
// rendered for real: stats, composition, trend chart, events, file activity
|
||
// and the composed Context browser, driven by real projection values and real
|
||
// plugin settings. Covers the view's own branches (projections absent /
|
||
// garbage / well-formed, granularity/trend-mode state, brief→browser
|
||
// locate bridge, kind filter, scroll ledger, locale arms, error boundary).
|
||
|
||
import { act, createElement as h, type ReactElement } from 'react'
|
||
import assert from 'node:assert/strict'
|
||
import { createRoot } from 'react-dom/client'
|
||
import { afterEach, describe, test, vi } from 'vitest'
|
||
import { makeContextView } from '../../../src/client/components/contextView'
|
||
import { watchHistoryFaces } from '../../../src/client/historyPage'
|
||
import { requestContextFocus, takeContextFocus } from '../../../src/client/viewFocus'
|
||
import { createContextSettings } from '../../../src/client/settings'
|
||
import type { SettingsScopeLike } from '../../../src/client/settings'
|
||
import type { UseChatLike } from '../../../src/client/services'
|
||
import type { ContextTimeline } from '../../../src/shared/types'
|
||
import { DICT_EN } from '../../../src/client/i18n'
|
||
import { TestClientCtx, TestLocale, asClientCtx } from '../helpers/harness'
|
||
import { click, flush, hover, makeKit, mount, query, queryAll, silenceWindowErrors, text, unhover } from '../helpers/kit'
|
||
|
||
// pluginInfo's npm-registry probe stays inert (and '0.0.0-dev' short-circuits
|
||
// it anyway).
|
||
vi.stubGlobal('fetch', () => Promise.resolve({ ok: false } as Response))
|
||
|
||
const kit = makeKit()
|
||
|
||
afterEach(() => {
|
||
vi.restoreAllMocks()
|
||
})
|
||
|
||
|
||
function timeline(over: Record<string, unknown> = {}): ContextTimeline {
|
||
return {
|
||
ok: true,
|
||
current: { system: 100, tools: 200, user: 300, inject: 50, assistant: 400, tool: 150, total: 1200 },
|
||
requests: [],
|
||
events: [],
|
||
nodes: [],
|
||
droppedNodes: 0,
|
||
archive: [],
|
||
...over,
|
||
} as ContextTimeline
|
||
}
|
||
|
||
const T0 = 1700000000000
|
||
|
||
/** Two steps of one turn plus a turn-less trailing step, with their nodes. */
|
||
function richTimeline(over: Record<string, unknown> = {}): ContextTimeline {
|
||
return timeline({
|
||
model: 'deepseek-v4-flash',
|
||
provider: 'deepseek',
|
||
contextWindow: 128000,
|
||
toolCalls: 3,
|
||
images: 1,
|
||
requests: [
|
||
{ seq: 2, turn: 1, step: 1, time: T0 + 1000, system: 100, tools: 200, user: 10, inject: 0, assistant: 20, tool: 0, total: 330, prompt: 350, output: 20, cacheRead: 100 },
|
||
{ seq: 4, turn: 1, step: 2, time: T0 + 3000, system: 100, tools: 200, user: 10, inject: 0, assistant: 60, tool: 30, total: 400 },
|
||
{ seq: 6, time: T0 + 5000, system: 100, tools: 200, user: 10, inject: 0, assistant: 80, tool: 30, total: 420 },
|
||
],
|
||
events: [
|
||
{ seq: 3, time: T0 + 2000, kind: 'compaction', count: 2, turn: 1, step: 2, fromTurn: 1, fromStep: 1, tokens: 500 },
|
||
{ seq: 5, time: T0 + 4000, kind: 'inject', form: 'notice', name: 'heads-up', tokens: 12, turn: 1, step: 3 },
|
||
],
|
||
nodes: [
|
||
{ seq: 1, cat: 'user', tokens: 10, text: 'hello there', time: T0 + 500 },
|
||
{ seq: 2, cat: 'assistant', tokens: 20, text: 'reply one', time: T0 + 1000 },
|
||
{ seq: 3, cat: 'tool', tokens: 30, tool: 'bash', text: 'file output', time: T0 + 2000 },
|
||
{ seq: 4, cat: 'assistant', tokens: 60, text: 'reply two', time: T0 + 3000 },
|
||
{ seq: 6, cat: 'assistant', tokens: 80, text: 'reply three', time: T0 + 5000 },
|
||
],
|
||
droppedNodes: 2,
|
||
...over,
|
||
})
|
||
}
|
||
|
||
function projectionsFor(data: ContextTimeline, extra: Record<string, unknown> = {}) {
|
||
const projections: Record<string, unknown> = { contextTimeline: data, ...extra }
|
||
return (key: string) => projections[key]
|
||
}
|
||
|
||
function makeView(ctx: TestClientCtx, settings = createContextSettings()) {
|
||
return makeContextView(asClientCtx(ctx), kit, settings)
|
||
}
|
||
|
||
/** A button whose text matches (case-sensitively) the given label. */
|
||
function buttonByText(container: ParentNode, label: string): HTMLElement {
|
||
const hit = queryAll(container, 'button').find(b => text(b) === label)
|
||
if (hit === undefined) throw new Error(`button not found: ${label}`)
|
||
return hit
|
||
}
|
||
|
||
/** Mount inside a `[data-conversation-scroll]` scroller so the view's shared-scrollport probes find it. */
|
||
async function mountInScroller(el: ReactElement, scroller: HTMLElement) {
|
||
const inner = document.createElement('div')
|
||
scroller.appendChild(inner)
|
||
document.body.appendChild(scroller)
|
||
const root = createRoot(inner)
|
||
await act(async () => {
|
||
root.render(el)
|
||
})
|
||
return {
|
||
container: inner,
|
||
async update(next: ReactElement) {
|
||
await act(async () => {
|
||
root.render(next)
|
||
})
|
||
},
|
||
async unmount() {
|
||
await act(async () => {
|
||
root.unmount()
|
||
})
|
||
scroller.remove()
|
||
},
|
||
}
|
||
}
|
||
|
||
describe('ContextView — projection guards', () => {
|
||
test('loading screen while useProjection is absent, empty, or the session id is missing', async () => {
|
||
const View = makeView(new TestClientCtx())
|
||
|
||
const m1 = await mount(h(View, { sessionId: 'sv-none' }))
|
||
assert.ok(text(m1.container).includes(DICT_EN.loading))
|
||
await m1.unmount()
|
||
|
||
const m2 = await mount(h(View, { sessionId: '', useProjection: () => undefined }))
|
||
assert.ok(text(m2.container).includes(DICT_EN.loading))
|
||
await m2.unmount()
|
||
|
||
const m3 = await mount(h(View, { useProjection: () => undefined }))
|
||
assert.ok(text(m3.container).includes(DICT_EN.loading))
|
||
await m3.unmount()
|
||
})
|
||
|
||
test('renders the full tab without a session id (the agent card anchors nothing)', async () => {
|
||
const View = makeView(new TestClientCtx())
|
||
const m = await mount(h(View, { useProjection: projectionsFor(timeline()) }))
|
||
assert.ok(text(m.container).includes(DICT_EN['overview.title']))
|
||
assert.ok(text(m.container).includes(DICT_EN.footer))
|
||
await m.unmount()
|
||
})
|
||
|
||
test('a corrupt timeline is sanitized and the whole tab still renders empty-section states', async () => {
|
||
const View = makeView(new TestClientCtx())
|
||
const m = await mount(h(View, {
|
||
sessionId: 'sv-garbage',
|
||
useProjection: projectionsFor(timeline(), {}),
|
||
}))
|
||
// Well-formed-but-empty: every section renders its empty state.
|
||
assert.ok(text(m.container).includes(DICT_EN['overview.title']))
|
||
assert.ok(text(m.container).includes(DICT_EN['stats.title']))
|
||
assert.ok(text(m.container).includes(DICT_EN['trend.empty']))
|
||
assert.ok(text(m.container).includes(DICT_EN['events.empty']))
|
||
assert.ok(text(m.container).includes(DICT_EN['files.empty']))
|
||
assert.ok(text(m.container).includes(DICT_EN['footer']))
|
||
assert.ok(m.container.querySelector('.lc-br-cats') !== null)
|
||
// No model/provider: the composition card carries no subtitle.
|
||
const comp = queryAll(m.container, '.lc-card').find(c => text(c).includes(DICT_EN['overview.title']))
|
||
assert.ok(comp !== undefined && comp.querySelector('.lc-card-sub') === null)
|
||
await m.unmount()
|
||
})
|
||
|
||
test('companion projections feed the headline, stats, and browser headers', async () => {
|
||
const View = makeView(new TestClientCtx())
|
||
const m = await mount(h(View, {
|
||
sessionId: 'sv-proj',
|
||
useProjection: projectionsFor(timeline({ model: 'm-only' }), {
|
||
contextPressure: { projectedTokens: 100, contextWindow: 128000 },
|
||
tokenUsage: { uncachedInputTokens: 100, outputTokens: 50, cacheReadTokens: 200, cacheWriteTokens: 0 },
|
||
contextBreakdown: { systemTokens: 100, toolsTokens: 200, messageTokens: 900 },
|
||
contextHeaders: { headers: [{ seq: 1, time: T0, system: 'SYS', tools: [{ name: 'bash', tokens: 12 }] }] },
|
||
}),
|
||
}))
|
||
// Anchored headline: projected 100 of a 128k window.
|
||
assert.ok(text(m.container).includes(DICT_EN['overview.used']))
|
||
// The Token card's center is the chat stats line's whole-session billed
|
||
// total off the official tokenUsage projection (100 + 200 + 0 + 50), and
|
||
// the Context card's cache-hit cell shows the line's own rate one decimal
|
||
// deep (200 / 300, truncated).
|
||
const tokensCard = queryAll(m.container, '.lc-head > .lc-col-donut')[0]
|
||
assert.equal(query(tokensCard, '.lc-donut-center b').textContent, '350')
|
||
assert.ok(text(m.container).includes('66.6%'))
|
||
assert.ok(text(m.container).includes('m-only'))
|
||
await m.unmount()
|
||
})
|
||
})
|
||
|
||
describe('ContextView — the sidebar host', () => {
|
||
test('the panel drops the context-stats and plugin-info head cards, keeping the two donut cards', async () => {
|
||
const View = makeView(new TestClientCtx())
|
||
const projections = projectionsFor(richTimeline())
|
||
|
||
const tab = await mount(h(View, { useProjection: projections }))
|
||
assert.equal(queryAll(tab.container, '.lc-stats').length, 1, 'the tab keeps the context stats')
|
||
assert.equal(queryAll(tab.container, '.lc-pi-grid').length, 1, 'the tab keeps the plugin info')
|
||
await tab.unmount()
|
||
|
||
const panel = await mount(h(View, { host: 'sidebar', useProjection: projections }))
|
||
assert.equal(queryAll(panel.container, '.lc-stats').length, 0, 'the panel drops the context stats')
|
||
assert.equal(queryAll(panel.container, '.lc-pi-grid').length, 0, 'the panel drops the plugin info')
|
||
assert.equal(queryAll(panel.container, '.lc-head > .lc-card').length, 2, 'the head row keeps its two donut cards')
|
||
assert.equal(queryAll(panel.container, '.lc-head > .lc-col-donut').length, 2, 'token usage and timing')
|
||
await panel.unmount()
|
||
})
|
||
|
||
test('both hosts arrange the main row as composition+trend beside the browser', async () => {
|
||
const View = makeView(new TestClientCtx())
|
||
const projections = projectionsFor(richTimeline())
|
||
|
||
for (const props of [{ useProjection: projections }, { host: 'sidebar' as const, useProjection: projections }]) {
|
||
const m = await mount(h(View, props))
|
||
const cols = queryAll(m.container, '.lc-cols-main > .lc-col')
|
||
assert.equal(cols.length, 2, 'one two-column split')
|
||
assert.ok(cols[0].querySelector('.lc-overview-num') !== null, 'composition leads the left column')
|
||
assert.ok(cols[0].querySelector('.lc-trend-ctl') !== null, 'the trend follows in the same column')
|
||
assert.ok(cols[1].querySelector('.lc-br-dna-ctl') !== null, 'the browser owns the right column')
|
||
await m.unmount()
|
||
}
|
||
})
|
||
})
|
||
|
||
describe('ContextView — baseline gate', () => {
|
||
test('a gated host renders the zeroed cards under the upgrade modal', async () => {
|
||
const View = makeView(new TestClientCtx())
|
||
const m = await mount(h(View, {
|
||
sessionId: 'sv-gated',
|
||
useProjection: projectionsFor(timeline({ unsupported: { current: '0.1.1-rc.2', minimum: '0.1.2-rc.1' } })),
|
||
}))
|
||
// The modal pops over the tab, naming both versions.
|
||
assert.ok(m.container.querySelector('.lc-modal-backdrop') !== null)
|
||
const card = query(m.container, '.lc-gate-card')
|
||
assert.ok(text(card).includes(DICT_EN['gate.title']))
|
||
assert.ok(text(card).includes('v0.1.1-rc.2'))
|
||
assert.ok(text(card).includes('v0.1.2-rc.1'))
|
||
// The cards keep rendering the fallback's zeroed data behind it.
|
||
assert.ok(text(m.container).includes(DICT_EN['overview.title']))
|
||
assert.ok(text(m.container).includes(DICT_EN['trend.empty']))
|
||
assert.ok(text(m.container).includes(DICT_EN['events.empty']))
|
||
// Dismissal reveals the (blank) cards.
|
||
await click(buttonByText(m.container, DICT_EN['gate.ok']))
|
||
assert.equal(m.container.querySelector('.lc-modal-backdrop'), null)
|
||
assert.ok(text(m.container).includes(DICT_EN['stats.title']))
|
||
await m.unmount()
|
||
})
|
||
|
||
test('a malformed gate record never opens the modal', async () => {
|
||
const View = makeView(new TestClientCtx())
|
||
const m = await mount(h(View, {
|
||
sessionId: 'sv-gate-junk',
|
||
useProjection: projectionsFor(timeline({ unsupported: { current: 7 } })),
|
||
}))
|
||
assert.equal(m.container.querySelector('.lc-modal-backdrop'), null)
|
||
assert.ok(text(m.container).includes(DICT_EN['overview.title']))
|
||
await m.unmount()
|
||
})
|
||
})
|
||
|
||
/** The rich tab: full projection (stats, chart, markers, browser headers) over two turns plus a turn-less step. */
|
||
async function mountRich(sessionId: string) {
|
||
const View = makeView(new TestClientCtx())
|
||
const m = await mount(h(View, {
|
||
sessionId,
|
||
useProjection: projectionsFor(richTimeline(), {
|
||
contextHeaders: { headers: [{ seq: 1, time: T0, system: 'SYS', tools: [{ name: 'bash', tokens: 12, description: 'run' }] }] },
|
||
}),
|
||
useChat: (sel =>
|
||
sel({
|
||
legacy: { nodes: [] } })) as UseChatLike,
|
||
}))
|
||
return m
|
||
}
|
||
|
||
describe('ContextView — interactions', () => {
|
||
test('renders the rich tab: stats, chart bars, markers, events, nodes, browser', async () => {
|
||
const m = await mountRich('sv-rich')
|
||
assert.ok(text(m.container).includes('deepseek-v4-flash · deepseek'))
|
||
assert.equal(queryAll(m.container, '.lc-bar').length, 3)
|
||
// The compaction event lands as the ✂ marker on the first request after it.
|
||
assert.equal(queryAll(m.container, '.lc-bar-marker').length, 1)
|
||
assert.equal(queryAll(m.container, '.lc-event').length, 2)
|
||
assert.ok(text(m.container).includes(DICT_EN['files.title']))
|
||
assert.ok(text(m.container).includes(DICT_EN['files.scopeLatest']))
|
||
assert.ok(text(m.container).includes(DICT_EN['browser.liveNow']))
|
||
// Turn strip partitions the two turn groups (turn 1 + the turn-less 0).
|
||
assert.deepEqual(queryAll(m.container, '.lc-turn-label').map(el => text(el)), ['1', '0'])
|
||
await m.unmount()
|
||
})
|
||
|
||
test('bar hover previews in the browser; hover loss on a granularity switch falls back', async () => {
|
||
const m = await mountRich('sv-hover')
|
||
const chart = query(m.container, '.lc-chart')
|
||
const bar2 = query(m.container, '.lc-bar[data-seq="2"]')
|
||
await hover(bar2)
|
||
assert.ok(bar2.className.includes('lc-bar-hovered'))
|
||
// The hovered bar's turn lights the strip even without strip hover.
|
||
assert.ok(query(m.container, '.lc-chart-scroll').className.includes('lc-chart-dim'))
|
||
|
||
// Switching to turn granularity drops seq 2 (turn 1 keeps its LAST step):
|
||
// the stale hovered seq matches nothing and the active bar falls back.
|
||
await click(buttonByText(m.container, DICT_EN['gran.turn']))
|
||
assert.equal(queryAll(m.container, '.lc-bar').length, 2)
|
||
assert.equal(queryAll(m.container, '.lc-bar-hovered').length, 0)
|
||
await unhover(chart)
|
||
await click(buttonByText(m.container, DICT_EN['gran.step']))
|
||
assert.equal(queryAll(m.container, '.lc-bar').length, 3)
|
||
await m.unmount()
|
||
})
|
||
|
||
test('a request without a turn highlights nothing on hover', async () => {
|
||
const m = await mountRich('sv-noturn')
|
||
const chart = query(m.container, '.lc-chart')
|
||
await hover(query(m.container, '.lc-bar[data-seq="6"]'))
|
||
assert.ok(query(m.container, '.lc-bar[data-seq="6"]').className.includes('lc-bar-hovered'))
|
||
assert.ok(!query(m.container, '.lc-chart-scroll').className.includes('lc-chart-dim'))
|
||
await unhover(chart)
|
||
await m.unmount()
|
||
})
|
||
|
||
test('turn strip hover dims, strip click focuses the turn in turn granularity', async () => {
|
||
const m = await mountRich('sv-strip')
|
||
const turns = queryAll(m.container, '.lc-turn')
|
||
assert.equal(turns.length, 2)
|
||
await hover(turns[0])
|
||
assert.ok(query(m.container, '.lc-chart-scroll').className.includes('lc-chart-dim'))
|
||
await unhover(query(m.container, '.lc-turns'))
|
||
assert.ok(!query(m.container, '.lc-chart-scroll').className.includes('lc-chart-dim'))
|
||
|
||
// Strip click: granularity switches to turn and the focus is consumed once.
|
||
await click(turns[0])
|
||
assert.ok(buttonByText(m.container, DICT_EN['gran.turn']).className.includes('lc-gran-on'))
|
||
assert.equal(queryAll(m.container, '.lc-bar').length, 2)
|
||
await click(buttonByText(m.container, DICT_EN['gran.step']))
|
||
await m.unmount()
|
||
})
|
||
|
||
test('pinning a bar selects its step in the browser; unpinning returns to live', async () => {
|
||
const m = await mountRich('sv-pin')
|
||
const pick = query<HTMLSelectElement>(m.container, 'select.lc-br-pick')
|
||
assert.equal(pick.value, 'live')
|
||
|
||
const bar4 = query(m.container, '.lc-bar[data-seq="4"]')
|
||
await click(bar4)
|
||
assert.ok(bar4.className.includes('lc-bar-selected'))
|
||
assert.equal(pick.value, '4')
|
||
// The pinned step carries the compaction marker chip in the detail head.
|
||
assert.ok(query(m.container, '.lc-detail').textContent?.includes('✂') === true)
|
||
|
||
await click(bar4)
|
||
assert.ok(!bar4.className.includes('lc-bar-selected'))
|
||
assert.equal(pick.value, 'live')
|
||
await m.unmount()
|
||
})
|
||
|
||
test('brief rows locate their node in the browser (input, mid response, live response)', async () => {
|
||
const m = await mountRich('sv-brief')
|
||
const pick = query<HTMLSelectElement>(m.container, 'select.lc-br-pick')
|
||
|
||
// Pin the second bar: brief = opener + inputs (node 3) + response (node 4).
|
||
await click(query(m.container, '.lc-bar[data-seq="4"]'))
|
||
const briefRows = queryAll(m.container, '.lc-brief-row')
|
||
assert.equal(briefRows.length, 3)
|
||
|
||
// The In row reveals node 3 (tool) inside the step's OWN surface.
|
||
const inRow = briefRows.find(r => text(r).includes(DICT_EN['brief.input']))
|
||
assert.ok(inRow !== undefined)
|
||
await click(inRow)
|
||
assert.equal(pick.value, '4')
|
||
assert.ok(text(query(m.container, '.lc-br-elem-on')).includes('file output'))
|
||
|
||
// The response of a middle bar first appears in the NEXT step's surface.
|
||
const replyRow = briefRows.find(r => text(r).includes('reply two'))
|
||
assert.ok(replyRow !== undefined)
|
||
await click(replyRow)
|
||
assert.equal(pick.value, '6')
|
||
assert.ok(text(query(m.container, '.lc-br-elem-on')).includes('reply two'))
|
||
|
||
// The last bar's response lands on the LIVE surface.
|
||
await click(query(m.container, '.lc-bar[data-seq="6"]'))
|
||
const lastReply = queryAll(m.container, '.lc-brief-row').find(r => text(r).includes('reply three'))
|
||
assert.ok(lastReply !== undefined)
|
||
await click(lastReply)
|
||
assert.equal(pick.value, 'live')
|
||
assert.ok(text(query(m.container, '.lc-br-elem-on')).includes('reply three'))
|
||
await m.unmount()
|
||
})
|
||
|
||
test('composition legend hover lights the linked browser category', async () => {
|
||
const m = await mountRich('sv-link')
|
||
const comp = queryAll(m.container, '.lc-card').find(c => text(c).includes(DICT_EN['overview.title']))
|
||
assert.ok(comp !== undefined)
|
||
|
||
const chip = query(comp, '.lc-legend .lc-chip')
|
||
await hover(chip)
|
||
assert.ok(query(m.container, '.lc-br-cat-row').className.includes('lc-br-cat-on'))
|
||
await unhover(chip)
|
||
assert.ok(!query(m.container, '.lc-br-cat-row').className.includes('lc-br-cat-on'))
|
||
await m.unmount()
|
||
})
|
||
|
||
test('browser category hover lights the trend chart segments and the detail composition bar', async () => {
|
||
const m = await mountRich('sv-link-trend')
|
||
// Category rows render in CATS order; hover "tools" (second row).
|
||
const toolsRow = queryAll(m.container, '.lc-br-cat-row')[1]
|
||
await hover(toolsRow)
|
||
assert.equal(query(m.container, '.lc-chart').getAttribute('data-catdim'), 'tools')
|
||
// The detail's composition bar mirrors the same key with its tip off (covered in requestDetail.spec).
|
||
const detailBar = query(m.container, '.lc-detail .lc-stacked')
|
||
assert.ok(detailBar.className.includes('lc-stacked-dim'))
|
||
assert.ok(queryAll(detailBar, '.lc-stacked-seg-on').length === 1)
|
||
assert.ok(queryAll(m.container, '.lc-detail-row-on').length === 1, 'the matching detail row lights too')
|
||
|
||
await unhover(toolsRow)
|
||
assert.equal(query(m.container, '.lc-chart').hasAttribute('data-catdim'), false)
|
||
assert.ok(!query(m.container, '.lc-detail .lc-stacked').className.includes('lc-stacked-dim'))
|
||
|
||
// The overview's 'free' track hover names no category — the trend card stays neutral (same filter as the browser link).
|
||
await hover(query(m.container, '.lc-stacked-free'))
|
||
assert.equal(query(m.container, '.lc-chart').hasAttribute('data-catdim'), false)
|
||
assert.ok(!query(m.container, '.lc-detail .lc-stacked').className.includes('lc-stacked-dim'))
|
||
await unhover(query(m.container, '.lc-stacked-free'))
|
||
await m.unmount()
|
||
})
|
||
|
||
test('expanding a browser category focuses the trend bars on it; collapsing restores all', async () => {
|
||
const m = await mountRich('sv-focus')
|
||
assert.equal(text(query(m.container, '.lc-axis-top')), '420', 'unfocused: the axis tops at the largest whole-bar total')
|
||
// Five segments on the bar: richTimeline carries no injects, so inject renders none.
|
||
assert.equal(queryAll(m.container, '.lc-bar[data-seq="4"] .lc-bar-stack > .lc-cat-seg').length, 5)
|
||
|
||
// Expanding the browser's assistant category focuses every bar on it — one segment per bar, the axis
|
||
// rescaled to the category's own max (20/60/80; the first rides its provider-prompt anchor to 21).
|
||
await click(queryAll(m.container, '.lc-br-cat-row')[4])
|
||
assert.equal(text(query(m.container, '.lc-axis-top')), '80')
|
||
const segs = queryAll(m.container, '.lc-bar .lc-bar-stack > .lc-cat-seg')
|
||
assert.equal(segs.length, 3)
|
||
for (const s of segs) assert.equal(s.getAttribute('data-cat'), 'assistant')
|
||
assert.ok(text(m.container).includes(DICT_EN['trend.focus'].replace('{cat}', DICT_EN['cat.assistant'])),
|
||
'the card subtitle names the focused category')
|
||
|
||
// Collapsing the category restores the whole composition and drops the subtitle.
|
||
await click(queryAll(m.container, '.lc-br-cat-row')[4])
|
||
assert.equal(text(query(m.container, '.lc-axis-top')), '420')
|
||
assert.equal(queryAll(m.container, '.lc-bar[data-seq="4"] .lc-bar-stack > .lc-cat-seg').length, 5)
|
||
const trendCard = queryAll(m.container, '.lc-card').find(c => text(c).includes(DICT_EN['trend.title']))
|
||
assert.ok(trendCard !== undefined && trendCard.querySelector('.lc-card-sub') === null, 'unfocused: no subtitle')
|
||
await m.unmount()
|
||
})
|
||
|
||
test('the adaptive switch rides the trend title and toggles mount-locally', async () => {
|
||
const m = await mountRich('sv-adaptive')
|
||
const card = queryAll(m.container, '.lc-card').find(c => text(c).includes(DICT_EN['trend.title']))
|
||
assert.ok(card !== undefined)
|
||
const titleText = query(card, '.lc-card-title-text')
|
||
assert.equal(text(titleText), DICT_EN['trend.title'])
|
||
|
||
// The switch is the title text's NEXT sibling — left of the card's right-hand control cluster.
|
||
const toggleOf = () => buttonByText(m.container, DICT_EN['trend.adaptive'])
|
||
assert.equal(toggleOf().parentElement?.previousElementSibling, titleText)
|
||
assert.equal(toggleOf().parentElement?.getAttribute('title'), DICT_EN['trend.adaptiveHint'])
|
||
assert.ok(!toggleOf().className.includes('lc-gran-on'), 'off at mount')
|
||
|
||
// Toggling is mount-local: the chart keeps rendering (jsdom has no layout, so the zero-width viewport falls
|
||
// back to the whole-log axis instead of flattening the bars) and nothing is written back to settings.
|
||
await click(toggleOf())
|
||
assert.ok(toggleOf().className.includes('lc-gran-on'))
|
||
assert.equal(text(query(m.container, '.lc-axis-top')), '420')
|
||
await click(toggleOf())
|
||
assert.ok(!toggleOf().className.includes('lc-gran-on'))
|
||
assert.equal(text(query(m.container, '.lc-axis-top')), '420')
|
||
await m.unmount()
|
||
})
|
||
|
||
test('delta mode pairs the detail with the previous record; first bar has none', async () => {
|
||
const m = await mountRich('sv-delta')
|
||
const chart = query(m.container, '.lc-chart')
|
||
await click(buttonByText(m.container, DICT_EN['gran.delta']))
|
||
// Default active bar is the newest: detail is the signed change vs its predecessor.
|
||
assert.ok(queryAll(m.container, '.lc-detail-tag').some(el => text(el) === DICT_EN['gran.delta']))
|
||
// Hovering the FIRST bar pairs it with null (change from zero).
|
||
await hover(query(m.container, '.lc-bar[data-seq="2"]'))
|
||
assert.ok(query(m.container, '.lc-bar[data-seq="2"]').className.includes('lc-bar-hovered'))
|
||
assert.ok(queryAll(m.container, '.lc-detail-tag').some(el => text(el) === DICT_EN['gran.delta']))
|
||
await unhover(chart)
|
||
await click(buttonByText(m.container, DICT_EN['gran.total']))
|
||
assert.ok(!queryAll(m.container, '.lc-detail-tag').some(el => text(el) === DICT_EN['gran.delta']))
|
||
await m.unmount()
|
||
})
|
||
|
||
test('event-kind filter narrows, unions, drops, and resets', async () => {
|
||
const m = await mountRich('sv-kinds')
|
||
const countEvents = () => queryAll(m.container, '.lc-event').length
|
||
const kindBtn = (k: string) => query(m.container, `.lc-kinds [data-kind="${k}"]`) as HTMLElement
|
||
assert.equal(countEvents(), 2)
|
||
// The buttons carry their per-kind tallies (richTimeline: 1 inject, 1 compaction, 0 prunes).
|
||
assert.deepEqual(
|
||
queryAll(m.container, '.lc-kinds .lc-kind-n').map(el => text(el)),
|
||
['1', '1', '0'],
|
||
)
|
||
|
||
await click(kindBtn('inject'))
|
||
assert.equal(countEvents(), 1)
|
||
await click(kindBtn('compaction'))
|
||
assert.equal(countEvents(), 2)
|
||
await click(kindBtn('compaction'))
|
||
assert.equal(countEvents(), 1)
|
||
await click(kindBtn('inject'))
|
||
assert.equal(countEvents(), 2)
|
||
await m.unmount()
|
||
})
|
||
})
|
||
|
||
describe('ContextView — file activity card', () => {
|
||
/** Six steps with two file ops: a live read (seq 3) and an edit (seq 5) compacted away before seq 6 dispatched. */
|
||
function fileTimeline(): ContextTimeline {
|
||
const req = (seq: number, time: number, extra: Record<string, unknown> = {}) =>
|
||
({ seq, time, system: 10, tools: 20, user: 10, inject: 0, assistant: 20, tool: 10, total: 70, ...extra })
|
||
return timeline({
|
||
requests: [
|
||
req(2, T0 + 1000, { turn: 1, step: 1 }),
|
||
req(4, T0 + 3000, { turn: 1, step: 2 }),
|
||
req(6, T0 + 5000), // two turn-less mid-session steps form their own aggregate
|
||
req(7, T0 + 6000),
|
||
req(8, T0 + 7000, { turn: 2, step: 1 }), // a single-step turn, not the last bar
|
||
req(9, T0 + 8000, { turn: 3, step: 1 }),
|
||
],
|
||
nodes: [
|
||
{ seq: 1, cat: 'user', tokens: 10, text: 'hi', time: T0 + 500 },
|
||
{ seq: 2, cat: 'assistant', tokens: 20, text: 'r1', time: T0 + 1000 },
|
||
{ seq: 3, cat: 'tool', tokens: 30, tool: 'read', time: T0 + 2000 },
|
||
{ seq: 4, cat: 'assistant', tokens: 60, text: 'r2', time: T0 + 3000 },
|
||
{ seq: 6, cat: 'assistant', tokens: 80, text: 'r3', time: T0 + 5000 },
|
||
{ seq: 7, cat: 'assistant', tokens: 80, text: 'r3b', time: T0 + 6000 },
|
||
{ seq: 8, cat: 'assistant', tokens: 80, text: 'r4', time: T0 + 7000 },
|
||
{ seq: 9, cat: 'assistant', tokens: 80, text: 'r5', time: T0 + 8000 },
|
||
],
|
||
archive: [
|
||
{ seq: 5, cat: 'tool', tokens: 30, tool: 'edit', gone: 6, time: T0 + 4000 },
|
||
],
|
||
})
|
||
}
|
||
|
||
const fileConv = [
|
||
{ kind: 'tool', seq: 3, call: { name: 'read', argsRaw: JSON.stringify({ file_path: '/src/a.ts' }) } },
|
||
{ kind: 'tool', seq: 5, call: { name: 'edit', argsRaw: JSON.stringify({ file_path: '/src/a.ts', old_string: 'a\nb', new_string: 'a' }) } },
|
||
]
|
||
|
||
async function mountFiles(sessionId: string) {
|
||
const View = makeView(new TestClientCtx())
|
||
const m = await mount(h(View, {
|
||
sessionId,
|
||
useProjection: projectionsFor(fileTimeline()),
|
||
useChat: (sel =>
|
||
sel({
|
||
legacy: { nodes: fileConv } })) as UseChatLike,
|
||
}))
|
||
const card = queryAll(m.container, '.lc-card').find(c => text(c).includes(DICT_EN['files.title']))
|
||
assert.ok(card !== undefined)
|
||
return { m, card }
|
||
}
|
||
|
||
test('follows the chart pick: the scope label and the exclusive next-step bound', async () => {
|
||
const { m, card } = await mountFiles('sv-files-scope')
|
||
// Default (latest bar): everything served, edit delta included.
|
||
assert.ok(text(card).includes(DICT_EN['files.scopeLatest']))
|
||
assert.ok(text(card).includes('+1') && text(card).includes('−2'))
|
||
|
||
// Hovering the FIRST bar bounds the fold at the second request: the edit drops out.
|
||
await hover(query(m.container, '.lc-bar[data-seq="2"]'))
|
||
assert.ok(text(card).includes('Turn 1 · Step 1'))
|
||
assert.equal(queryAll(card, '.lc-fa-meta-delta').length, 0)
|
||
assert.equal(queryAll(card, '.lc-fa-row').length, 1)
|
||
assert.ok(!text(card).includes('+1'))
|
||
|
||
// The turn-less mid step falls back to zero labels and still bounds at the next request.
|
||
await hover(query(m.container, '.lc-bar[data-seq="6"]'))
|
||
assert.ok(text(card).includes('Turn 0 · Step 0'))
|
||
assert.ok(text(card).includes('+1'))
|
||
|
||
await unhover(query(m.container, '.lc-chart'))
|
||
assert.ok(text(card).includes(DICT_EN['files.scopeLatest']))
|
||
await m.unmount()
|
||
})
|
||
|
||
test('turn aggregates label the scope; op clicks reveal the result in the browser', async () => {
|
||
const { m, card } = await mountFiles('sv-files-locate')
|
||
const pick = query<HTMLSelectElement>(m.container, 'select.lc-br-pick')
|
||
|
||
await click(buttonByText(m.container, DICT_EN['gran.turn']))
|
||
// Turn 1's aggregate (two steps) labels the scope as a turn.
|
||
await hover(query(m.container, '.lc-bar[data-seq="4"]'))
|
||
assert.ok(text(card).includes(DICT_EN['detail.turn'].replace('{t}', '1').replace('{n}', '2')))
|
||
// The turn-less pair aggregates too: a turn label with zeroed turn.
|
||
await hover(query(m.container, '.lc-bar[data-seq="7"]'))
|
||
assert.ok(text(card).includes(DICT_EN['detail.turn'].replace('{t}', '0').replace('{n}', '2')))
|
||
// A single-step turn aggregate labels as a plain step.
|
||
await hover(query(m.container, '.lc-bar[data-seq="8"]'))
|
||
assert.ok(text(card).includes('Turn 2 · Step 1'))
|
||
await unhover(query(m.container, '.lc-chart'))
|
||
|
||
// Expand the file row: the compacted edit (seq 5) has no viewing step and
|
||
// its click is a no-op; the read (seq 3) reveals in step 4's surface.
|
||
await click(query(card, '.lc-fa-row'))
|
||
const ops = queryAll(card, '.lc-fa-op')
|
||
assert.equal(ops.length, 2)
|
||
await click(ops[0]) // edit, gone = 6: unlocatable
|
||
assert.equal(pick.value, 'live')
|
||
await click(ops[1]) // read at seq 3
|
||
assert.equal(pick.value, '4')
|
||
assert.ok(text(query(m.container, '.lc-br-elem-on')).includes('/src/a.ts'))
|
||
await m.unmount()
|
||
})
|
||
|
||
test('a nested PTC op reveals on its parent run_code result, not on its dispatch seq', async () => {
|
||
const ptcTimeline = timeline({
|
||
requests: [
|
||
{ seq: 2, turn: 1, step: 1, time: T0 + 1000, system: 10, tools: 20, user: 10, inject: 0, assistant: 20, tool: 10, total: 70 },
|
||
{ seq: 4, turn: 1, step: 2, time: T0 + 3000, system: 10, tools: 20, user: 10, inject: 0, assistant: 20, tool: 10, total: 70 },
|
||
],
|
||
nodes: [
|
||
{ seq: 2, cat: 'assistant', tokens: 20, text: 'r1', time: T0 + 1000 },
|
||
{ seq: 3, cat: 'tool', tokens: 30, tool: 'run_code', time: T0 + 2000 },
|
||
{ seq: 4, cat: 'assistant', tokens: 60, text: 'r2', time: T0 + 3000 },
|
||
],
|
||
})
|
||
const ptcConv = [
|
||
{
|
||
kind: 'tool-result',
|
||
seq: 3,
|
||
call: { name: 'run_code', argsRaw: JSON.stringify({ code: 'await tools.edit(…)', description: 'Fix the failing test' }) },
|
||
subCalls: [
|
||
{
|
||
kind: 'tool-result',
|
||
seq: 2,
|
||
time: T0 + 1500,
|
||
call: { name: 'edit', argsRaw: JSON.stringify({ file_path: '/src/a.ts', old_string: 'a\nb', new_string: 'a' }) },
|
||
isError: false,
|
||
subCalls: [],
|
||
},
|
||
],
|
||
},
|
||
]
|
||
const View = makeView(new TestClientCtx())
|
||
const m = await mount(h(View, {
|
||
sessionId: 'sv-ptc-locate',
|
||
useProjection: projectionsFor(ptcTimeline),
|
||
useChat: (sel =>
|
||
sel({
|
||
legacy: { nodes: ptcConv } })) as UseChatLike,
|
||
}))
|
||
const card = queryAll(m.container, '.lc-card').find(c => text(c).includes(DICT_EN['files.title']))
|
||
assert.ok(card !== undefined)
|
||
await click(query(card, '.lc-fa-row'))
|
||
// The op is attributed to the nested tool, and says why (its program).
|
||
assert.ok(text(card).includes('edit'))
|
||
assert.ok(text(card).includes('Fix the failing test'))
|
||
// The click lands on the parent run_code node (seq 3 → step 4's surface).
|
||
await click(query(card, '.lc-fa-op'))
|
||
const pick = query<HTMLSelectElement>(m.container, 'select.lc-br-pick')
|
||
assert.equal(pick.value, '4')
|
||
assert.ok(text(query(m.container, '.lc-br-elem-on')).includes('Fix the failing test'))
|
||
await m.unmount()
|
||
})
|
||
|
||
test('the session workspace relativizes row paths; the host opener opens the resolved file', async () => {
|
||
const opened: string[] = []
|
||
const calls: string[] = []
|
||
const ctx = new TestClientCtx({
|
||
services: {
|
||
sessions: { list: { getSnapshot: () => ({ byId: { 'sv-files-open': { cwd: '/repo' } } }) } },
|
||
connection: {
|
||
isLoopback: true,
|
||
rpc: {
|
||
call: (_channel: string, endpoint: string, payload: unknown) => {
|
||
if (endpoint === 'session/canOpenWorkspacePath') return Promise.resolve({ ok: true, value: true })
|
||
calls.push(endpoint)
|
||
opened.push((payload as { args: { request: { path: string } } }).args.request.path)
|
||
return Promise.resolve({ ok: true, value: { opened: true } })
|
||
},
|
||
},
|
||
},
|
||
},
|
||
})
|
||
const conv = [
|
||
{ kind: 'tool', seq: 3, call: { name: 'read', argsRaw: JSON.stringify({ file_path: '/repo/src/a.ts' }) } },
|
||
]
|
||
const View = makeView(ctx)
|
||
const m = await mount(h(View, {
|
||
sessionId: 'sv-files-open',
|
||
useProjection: projectionsFor(fileTimeline()),
|
||
useChat: (sel =>
|
||
sel({
|
||
legacy: { nodes: conv } })) as UseChatLike,
|
||
}))
|
||
// The capability probe is an RPC round-trip: the answer lands in state on the next flush.
|
||
await flush()
|
||
const card = queryAll(m.container, '.lc-card').find(c => text(c).includes(DICT_EN['files.title']))
|
||
assert.ok(card !== undefined)
|
||
// The read inside the session cwd displays './'-relative…
|
||
const row = query(card, '.lc-fa-row')
|
||
assert.ok(row.querySelector('.lc-fa-path em')?.textContent === './src/')
|
||
// …and its name opens on the system through the session's open remote.
|
||
const name = query(row, '.lc-fa-file')
|
||
assert.equal(name.getAttribute('title'), DICT_EN['files.open'])
|
||
await click(name)
|
||
assert.deepEqual(opened, ['/repo/src/a.ts'])
|
||
assert.deepEqual(calls, ['session/openWorkspacePath'])
|
||
await m.unmount()
|
||
})
|
||
|
||
test('the Sidebar preview leads the name click; a refusal falls through to the system opener', async () => {
|
||
const previewed: string[] = []
|
||
const opened: string[] = []
|
||
const ctx = new TestClientCtx({
|
||
services: {
|
||
sessions: { list: { getSnapshot: () => ({ byId: { 'sv-preview': { cwd: '/repo' } } }) } },
|
||
connection: {
|
||
isLoopback: true,
|
||
rpc: {
|
||
call: (_channel: string, endpoint: string, payload: unknown) => {
|
||
if (endpoint === 'session/canOpenWorkspacePath') return Promise.resolve({ ok: true, value: true })
|
||
opened.push((payload as { args: { request: { path: string } } }).args.request.path)
|
||
return Promise.resolve({ ok: true, value: { opened: true } })
|
||
},
|
||
},
|
||
},
|
||
sidebarRight: { openResource: (address: string) => { previewed.push(address) } },
|
||
},
|
||
})
|
||
const conv = [
|
||
{ kind: 'tool', seq: 3, call: { name: 'read', argsRaw: JSON.stringify({ file_path: '/repo/src/a.ts' }) } },
|
||
]
|
||
const View = makeView(ctx)
|
||
const m = await mount(h(View, {
|
||
sessionId: 'sv-preview',
|
||
useProjection: projectionsFor(fileTimeline()),
|
||
useChat: (sel =>
|
||
sel({
|
||
legacy: { nodes: conv } })) as UseChatLike,
|
||
}))
|
||
await flush()
|
||
const card = queryAll(m.container, '.lc-card').find(c => text(c).includes(DICT_EN['files.title']))
|
||
assert.ok(card !== undefined)
|
||
const name = query(query(card, '.lc-fa-row'), '.lc-fa-file')
|
||
// The preview affordance leads where the column exists; the system open
|
||
// stays unreached.
|
||
assert.equal(name.getAttribute('title'), DICT_EN['files.preview'])
|
||
await click(name)
|
||
assert.deepEqual(previewed, ['dsh-resource://file/session/sv-preview/src/a.ts'])
|
||
assert.deepEqual(opened, [])
|
||
await m.unmount()
|
||
})
|
||
|
||
test('a Sidebar that refuses the address falls back to the system opener', async () => {
|
||
const opened: string[] = []
|
||
const ctx = new TestClientCtx({
|
||
services: {
|
||
sessions: { list: { getSnapshot: () => ({ byId: { 'sv-preview-no': { cwd: '/repo' } } }) } },
|
||
connection: {
|
||
isLoopback: true,
|
||
rpc: {
|
||
call: (_channel: string, endpoint: string, payload: unknown) => {
|
||
if (endpoint === 'session/canOpenWorkspacePath') return Promise.resolve({ ok: true, value: true })
|
||
opened.push((payload as { args: { request: { path: string } } }).args.request.path)
|
||
return Promise.resolve({ ok: true, value: { opened: true } })
|
||
},
|
||
},
|
||
},
|
||
// No preview type claims the address (or no surface is mounted): the throw
|
||
// is the face's own wiring-error report, and the card must fall back.
|
||
sidebarRight: { openResource: () => { throw new Error('no registered tab type claims it') } },
|
||
},
|
||
})
|
||
const conv = [
|
||
{ kind: 'tool', seq: 3, call: { name: 'read', argsRaw: JSON.stringify({ file_path: '/repo/src/a.ts' }) } },
|
||
]
|
||
const View = makeView(ctx)
|
||
const m = await mount(h(View, {
|
||
sessionId: 'sv-preview-no',
|
||
useProjection: projectionsFor(fileTimeline()),
|
||
useChat: (sel =>
|
||
sel({
|
||
legacy: { nodes: conv } })) as UseChatLike,
|
||
}))
|
||
await flush()
|
||
const card = queryAll(m.container, '.lc-card').find(c => text(c).includes(DICT_EN['files.title']))
|
||
assert.ok(card !== undefined)
|
||
await click(query(query(card, '.lc-fa-row'), '.lc-fa-file'))
|
||
assert.deepEqual(opened, ['/repo/src/a.ts'])
|
||
await m.unmount()
|
||
})
|
||
|
||
test('an unencodable path skips the preview and still opens on the system', async () => {
|
||
// Parsing resilience: a lone surrogate in a log path is unrepresentable in a
|
||
// resource address (the encoder throws), so the preview opener is never
|
||
// asked for one address and the system opener still takes the resolved path.
|
||
const opened: string[] = []
|
||
const previewed: string[] = []
|
||
const ctx = new TestClientCtx({
|
||
services: {
|
||
sessions: { list: { getSnapshot: () => ({ byId: { 'sv-preview-bad': { cwd: '/repo' } } }) } },
|
||
connection: {
|
||
isLoopback: true,
|
||
rpc: {
|
||
call: (_channel: string, endpoint: string, payload: unknown) => {
|
||
if (endpoint === 'session/canOpenWorkspacePath') return Promise.resolve({ ok: true, value: true })
|
||
opened.push((payload as { args: { request: { path: string } } }).args.request.path)
|
||
return Promise.resolve({ ok: true, value: { opened: true } })
|
||
},
|
||
},
|
||
},
|
||
sidebarRight: { openResource: (address: string) => { previewed.push(address) } },
|
||
},
|
||
})
|
||
const conv = [
|
||
{ kind: 'tool', seq: 3, call: { name: 'read', argsRaw: JSON.stringify({ file_path: '\uD800' }) } },
|
||
]
|
||
const View = makeView(ctx)
|
||
const m = await mount(h(View, {
|
||
sessionId: 'sv-preview-bad',
|
||
useProjection: projectionsFor(fileTimeline()),
|
||
useChat: (sel =>
|
||
sel({
|
||
legacy: { nodes: conv } })) as UseChatLike,
|
||
}))
|
||
await flush()
|
||
const card = queryAll(m.container, '.lc-card').find(c => text(c).includes(DICT_EN['files.title']))
|
||
assert.ok(card !== undefined)
|
||
await click(query(query(card, '.lc-fa-row'), '.lc-fa-file'))
|
||
assert.deepEqual(previewed, [])
|
||
assert.deepEqual(opened, ['/repo/\uD800'])
|
||
await m.unmount()
|
||
})
|
||
|
||
test('a capability probe that settles after unmount drops its answer', async () => {
|
||
let resolveProbe!: (value: unknown) => void
|
||
const ctx = new TestClientCtx({
|
||
services: {
|
||
connection: {
|
||
isLoopback: true,
|
||
rpc: { call: () => new Promise(resolve => { resolveProbe = resolve }) },
|
||
},
|
||
},
|
||
})
|
||
const View = makeView(ctx)
|
||
const m = await mount(h(View, {
|
||
sessionId: 'sv-open-late',
|
||
useProjection: projectionsFor(fileTimeline()),
|
||
}))
|
||
await m.unmount()
|
||
// The late "yes" arrives on a dead view: the stale answer is dropped whole.
|
||
resolveProbe({ ok: true, value: true })
|
||
await new Promise(resolve => setTimeout(resolve, 0))
|
||
})
|
||
|
||
test('a ctx without service access degrades the card wiring, not the view', async () => {
|
||
const View = makeView({} as unknown as TestClientCtx)
|
||
// No session id: the view stays on its loading screen; the workspace and
|
||
// opener wiring (both read ctx before the early return) resolve to nothing.
|
||
const m = await mount(h(View, { sessionId: '' }))
|
||
assert.ok(text(m.container).includes(DICT_EN.loading))
|
||
await m.unmount()
|
||
})
|
||
})
|
||
|
||
describe('ContextView — targeted content fetch and image loading', () => {
|
||
test('opening an un-joined node reads one seq-anchored history page through the gateway remote', async () => {
|
||
const calls: { sessionId: string; throughSeq: number; beforeSeq: number }[] = []
|
||
const ctx = new TestClientCtx({
|
||
services: {
|
||
remote: {
|
||
session: {
|
||
page: (request: { address: { sessionId: string }; throughSeq: number; beforeSeq: number }) => {
|
||
calls.push({ sessionId: request.address.sessionId, throughSeq: request.throughSeq, beforeSeq: request.beforeSeq })
|
||
return Promise.resolve({
|
||
ok: true,
|
||
value: {
|
||
records: [
|
||
{ type: 'event', event: { type: 'user/message', seq: request.throughSeq, data: { content: [{ type: 'text', text: 'OLD FULL BODY' }] } } },
|
||
],
|
||
},
|
||
})
|
||
},
|
||
},
|
||
},
|
||
},
|
||
})
|
||
// The direct `remote.session` service is hostile, as on the real host:
|
||
// the face resolves only through the declared inject.
|
||
ctx.setService('remote.session', { get page() { throw new Error('cannot get property "remote.session" without inject') } })
|
||
watchHistoryFaces(asClientCtx(ctx))
|
||
const View = makeView(ctx)
|
||
const m = await mount(h(View, {
|
||
sessionId: 'sv-page',
|
||
useProjection: projectionsFor(timeline({ nodes: [{ seq: 1, cat: 'user', tokens: 5, text: 'old msg' }] })),
|
||
useChat: (sel =>
|
||
sel({
|
||
legacy: { nodes: [] } })) as UseChatLike,
|
||
}))
|
||
const catRow = queryAll(m.container, '.lc-br-cat-row').find(r => text(r).includes(DICT_EN['cat.user']))
|
||
assert.ok(catRow !== undefined)
|
||
// The lone node auto-expands with the category, which triggers the fetch.
|
||
await click(catRow)
|
||
await flush()
|
||
assert.deepEqual(calls, [{ sessionId: 'sv-page', throughSeq: 1, beforeSeq: 2 }], 'one read anchored just past the seq')
|
||
assert.ok(text(m.container).includes('OLD FULL BODY'), 'mapped page content renders')
|
||
await m.unmount()
|
||
// Unload the declared slot so no face stales into the next test.
|
||
ctx.dispose()
|
||
})
|
||
|
||
test('without a history face an uncached node shows the static note', async () => {
|
||
const View = makeView(new TestClientCtx())
|
||
const m = await mount(h(View, {
|
||
sessionId: 'sv-nopage',
|
||
useProjection: projectionsFor(timeline({ nodes: [{ seq: 1, cat: 'user', tokens: 5, text: 'old msg' }] })),
|
||
useChat: (sel =>
|
||
sel({
|
||
legacy: { nodes: [] } })) as UseChatLike,
|
||
}))
|
||
const catRow = queryAll(m.container, '.lc-br-cat-row').find(r => text(r).includes(DICT_EN['cat.user']))
|
||
assert.ok(catRow !== undefined)
|
||
await click(catRow)
|
||
assert.ok(text(m.container).includes(DICT_EN['browser.noContent']))
|
||
await m.unmount()
|
||
})
|
||
|
||
test('image attachments resolve through the conversation service loader', async () => {
|
||
const resolved: [string, unknown][] = []
|
||
const ctx = new TestClientCtx({
|
||
services: {
|
||
uiConversation: {
|
||
imageUrl: (sessionId: string, attachment: unknown) => {
|
||
resolved.push([sessionId, attachment])
|
||
return Promise.resolve('blob:pic')
|
||
},
|
||
},
|
||
},
|
||
})
|
||
const View = makeView(ctx)
|
||
const m = await mount(h(View, {
|
||
sessionId: 'sv-img',
|
||
useProjection: projectionsFor(timeline({
|
||
images: 1,
|
||
nodes: [{ seq: 1, cat: 'user', tokens: 400, text: 'see this', imgs: 1 }],
|
||
})),
|
||
useChat: (sel =>
|
||
sel({
|
||
legacy: { nodes: [{
|
||
kind: 'user',
|
||
seq: 1,
|
||
content: [{ type: 'image', attachment: { attachmentId: 'att-1', name: 'pic.png', bytes: 2048, width: 640, height: 480 } }],
|
||
}] }
|
||
})) as UseChatLike,
|
||
}))
|
||
const catRow = queryAll(m.container, '.lc-br-cat-row').find(r => text(r).includes(DICT_EN['cat.user']))
|
||
assert.ok(catRow !== undefined)
|
||
await click(catRow)
|
||
await flush()
|
||
assert.equal(resolved.length, 1)
|
||
assert.equal(resolved[0][0], 'sv-img')
|
||
assert.equal((resolved[0][1] as { attachmentId: string }).attachmentId, 'att-1')
|
||
assert.ok(m.container.querySelector('.lc-att-item img') !== null)
|
||
await m.unmount()
|
||
})
|
||
|
||
test('a conversation service without imageUrl degrades quietly', async () => {
|
||
const ctx = new TestClientCtx({ services: { uiConversation: {} } })
|
||
const View = makeView(ctx)
|
||
const m = await mount(h(View, {
|
||
sessionId: 'sv-noimg',
|
||
useProjection: projectionsFor(timeline()),
|
||
}))
|
||
assert.ok(text(m.container).includes(DICT_EN['overview.title']))
|
||
await m.unmount()
|
||
})
|
||
})
|
||
|
||
describe('ContextView — scroll ledger', () => {
|
||
test('restores the saved position per session and re-applies only once per mount', async () => {
|
||
const View = makeView(new TestClientCtx())
|
||
const props = {
|
||
sessionId: 'sv-scroll',
|
||
useProjection: undefined as ((key: string) => unknown) | undefined,
|
||
}
|
||
|
||
// First visit: no ledger entry → the shared scroller is re-anchored at top.
|
||
const scroller1 = document.createElement('div')
|
||
scroller1.setAttribute('data-conversation-scroll', '')
|
||
const m1 = await mountInScroller(
|
||
h(View, { ...props, useProjection: projectionsFor(richTimeline()) }),
|
||
scroller1,
|
||
)
|
||
assert.equal(scroller1.scrollTop, 0)
|
||
|
||
// A data refresh re-runs the effect but the position is applied once.
|
||
scroller1.scrollTop = 17
|
||
await m1.update(h(View, { ...props, useProjection: projectionsFor(richTimeline({ droppedNodes: 3 })) }))
|
||
assert.equal(scroller1.scrollTop, 17)
|
||
|
||
// Unmount saves the position into the module ledger.
|
||
scroller1.scrollTop = 42
|
||
await m1.unmount()
|
||
|
||
// Remounting the same session restores the saved position.
|
||
const scroller2 = document.createElement('div')
|
||
scroller2.setAttribute('data-conversation-scroll', '')
|
||
const m2 = await mountInScroller(
|
||
h(View, { ...props, useProjection: projectionsFor(richTimeline()) }),
|
||
scroller2,
|
||
)
|
||
assert.equal(scroller2.scrollTop, 42)
|
||
await m2.unmount()
|
||
})
|
||
})
|
||
|
||
describe('ContextView — locale and settings', () => {
|
||
const costed = timeline({
|
||
cost: { flash: { off: { uncached: 1000000, output: 500000, cacheRead: 0, cacheWrite: 0 } } },
|
||
})
|
||
|
||
test('cost prices in USD by default (no locale service), CNY under zh', async () => {
|
||
const m1 = await mount(h(makeView(new TestClientCtx()), {
|
||
sessionId: 'sv-usd',
|
||
useProjection: projectionsFor(costed),
|
||
}))
|
||
assert.ok(text(m1.container).includes('$'))
|
||
assert.ok(!text(m1.container).includes('¥'))
|
||
await m1.unmount()
|
||
|
||
const ctxZh = new TestClientCtx({ services: { locale: new TestLocale('zh') } })
|
||
const m2 = await mount(h(makeView(ctxZh), {
|
||
sessionId: 'sv-cny',
|
||
useProjection: projectionsFor(costed),
|
||
}))
|
||
assert.ok(text(m2.container).includes('¥'))
|
||
await m2.unmount()
|
||
|
||
// A locale service without getLocale falls back to USD.
|
||
const ctxBare = new TestClientCtx({ services: { locale: {} } })
|
||
const m3 = await mount(h(makeView(ctxBare), {
|
||
sessionId: 'sv-bare',
|
||
useProjection: projectionsFor(costed),
|
||
}))
|
||
assert.ok(text(m3.container).includes('$'))
|
||
await m3.unmount()
|
||
})
|
||
|
||
test('mount-time granularity/trend/file-sort defaults come from the bound settings scope', async () => {
|
||
const settings = createContextSettings()
|
||
const scope: SettingsScopeLike = {
|
||
getSnapshot: () => ({
|
||
status: 'ready',
|
||
writable: true,
|
||
value: { defaultGranularity: 'turn', defaultTrendMode: 'delta', defaultFileSort: 'path' },
|
||
}),
|
||
subscribe: () => () => {},
|
||
set: async () => {},
|
||
}
|
||
const detach = settings.attach(scope)
|
||
const View = makeView(new TestClientCtx(), settings)
|
||
// Two file reads (the 2-op path sorts AFTER the 1-op one under 'count' but BEFORE it under 'path').
|
||
const m = await mount(h(View, {
|
||
sessionId: 'sv-settings',
|
||
useProjection: projectionsFor(richTimeline({
|
||
nodes: [
|
||
...richTimeline().nodes,
|
||
{ seq: 7, cat: 'tool', tokens: 30, tool: 'read', text: 'z', time: T0 + 6000 },
|
||
{ seq: 8, cat: 'tool', tokens: 30, tool: 'read', text: 'a', time: T0 + 7000 },
|
||
{ seq: 9, cat: 'tool', tokens: 30, tool: 'read', text: 'z', time: T0 + 8000 },
|
||
],
|
||
})),
|
||
useChat: (sel =>
|
||
sel({
|
||
legacy: { nodes: [
|
||
{ kind: 'tool', seq: 7, call: { name: 'read', argsRaw: JSON.stringify({ file_path: '/z.ts' }) } },
|
||
{ kind: 'tool', seq: 8, call: { name: 'read', argsRaw: JSON.stringify({ file_path: '/a.ts' }) } },
|
||
{ kind: 'tool', seq: 9, call: { name: 'read', argsRaw: JSON.stringify({ file_path: '/z.ts' }) } },
|
||
] } })) as UseChatLike,
|
||
}))
|
||
assert.ok(buttonByText(m.container, DICT_EN['gran.turn']).className.includes('lc-gran-on'))
|
||
assert.ok(buttonByText(m.container, DICT_EN['gran.delta']).className.includes('lc-gran-on'))
|
||
// Turn aggregation applies at mount: two bars (turn 1 aggregate + turn-less).
|
||
assert.equal(queryAll(m.container, '.lc-bar').length, 2)
|
||
// The File Activity card opens sorted by the 'path' preference, not by op count.
|
||
assert.deepEqual(queryAll(m.container, '.lc-fa-row').map(r => r.title), ['/a.ts', '/z.ts'])
|
||
await m.unmount()
|
||
detach()
|
||
})
|
||
})
|
||
|
||
describe('ContextView — error boundary', () => {
|
||
test('a render failure degrades to the error card and Retry recovers', async () => {
|
||
// React 18 dev replays the failed render through a fake DOM event (loud
|
||
// stderr via jsdom/vitest) and logs the boundary message to console —
|
||
// both silenced so the deliberate throw stays inside this test.
|
||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||
const silenceErrors = silenceWindowErrors()
|
||
try {
|
||
const real = createContextSettings()
|
||
// Flag-driven (not call-counted): React 18 dev replays a failed unit of
|
||
// work and retries an errored concurrent pass synchronously, so the
|
||
// failure must persist until the boundary catches, then clear for Retry.
|
||
let fail = true
|
||
const flaky = {
|
||
...real,
|
||
defaultGranularity: (): 'step' | 'turn' => {
|
||
if (fail) throw new Error('boom-settings')
|
||
return real.defaultGranularity()
|
||
},
|
||
}
|
||
const View = makeView(new TestClientCtx(), flaky)
|
||
const m = await mount(h(View, {
|
||
sessionId: 'sv-err',
|
||
useProjection: projectionsFor(richTimeline()),
|
||
}))
|
||
assert.ok(text(m.container).includes(DICT_EN.error))
|
||
assert.ok(text(m.container).includes('boom-settings'))
|
||
|
||
fail = false
|
||
await click(query(m.container, '.lc-error-retry'))
|
||
assert.ok(text(m.container).includes(DICT_EN['overview.title']))
|
||
assert.ok(!text(m.container).includes(DICT_EN.error))
|
||
await m.unmount()
|
||
} finally {
|
||
silenceErrors()
|
||
}
|
||
})
|
||
})
|
||
|
||
describe('ContextView — chat→Context jump', () => {
|
||
test('the relay flips the chart to turn bars, pins the reply\'s turn, and consumes itself', async () => {
|
||
// The clicked reply closed turn 1: its request seq (4) is that turn's LAST step — exactly the aggregate's record.
|
||
requestContextFocus('sv-jump', 4)
|
||
const m = await mountRich('sv-jump')
|
||
assert.ok(buttonByText(m.container, DICT_EN['gran.turn']).className.includes('lc-gran-on'), 'granularity flips to turn')
|
||
assert.equal(queryAll(m.container, '.lc-bar').length, 2, 'the chart renders turn aggregates')
|
||
assert.ok(query(m.container, '.lc-bar[data-seq="4"]').className.includes('lc-bar-selected'), 'the reply\'s turn bar is pinned')
|
||
assert.equal(query<HTMLSelectElement>(m.container, 'select.lc-br-pick').value, '4')
|
||
assert.equal(takeContextFocus('sv-jump'), null, 'the relay is one-shot')
|
||
await m.unmount()
|
||
})
|
||
|
||
test('no session id: the relay stays pending — the loading view never takes it', async () => {
|
||
requestContextFocus('sv-jumpnone', 4)
|
||
const View = makeView(new TestClientCtx())
|
||
const m = await mount(h(View, { sessionId: '', useProjection: () => undefined }))
|
||
assert.ok(text(m.container).includes(DICT_EN.loading))
|
||
await m.unmount()
|
||
assert.equal(takeContextFocus('sv-jumpnone'), 4, 'an absent session id skips leg 1')
|
||
takeContextFocus('sv-jumpnone')
|
||
})
|
||
|
||
test('an empty history consumes the relay and pins nothing', async () => {
|
||
requestContextFocus('sv-jumpempty', 4)
|
||
const View = makeView(new TestClientCtx())
|
||
const m = await mount(h(View, { sessionId: 'sv-jumpempty', useProjection: projectionsFor(timeline()) }))
|
||
assert.equal(queryAll(m.container, '.lc-bar').length, 0)
|
||
assert.equal(takeContextFocus('sv-jumpempty'), null)
|
||
await m.unmount()
|
||
})
|
||
|
||
test('a turn-less reply routes to the turn-0 bar', async () => {
|
||
// The trailing turn-less step: its group key derives to 0, the ?? 0 arm.
|
||
requestContextFocus('sv-jump0', 6)
|
||
const m = await mountRich('sv-jump0')
|
||
assert.ok(query(m.container, '.lc-bar[data-seq="6"]').className.includes('lc-bar-selected'))
|
||
assert.equal(takeContextFocus('sv-jump0'), null)
|
||
await m.unmount()
|
||
})
|
||
|
||
test('the jump resets the shared scroller even when a saved position was about to be restored', async () => {
|
||
const View = makeView(new TestClientCtx())
|
||
const props = { sessionId: 'sv-jumpscroll', useProjection: projectionsFor(richTimeline()) }
|
||
|
||
// Seed the ledger: a first visit scrolls, the unmount saves 42.
|
||
const scroller1 = document.createElement('div')
|
||
scroller1.setAttribute('data-conversation-scroll', '')
|
||
const m1 = await mountInScroller(h(View, props), scroller1)
|
||
scroller1.scrollTop = 42
|
||
await m1.unmount()
|
||
|
||
// The jump remount: restore applies 42, then the jump resolves and resets to top.
|
||
requestContextFocus('sv-jumpscroll', 4)
|
||
const scroller2 = document.createElement('div')
|
||
scroller2.setAttribute('data-conversation-scroll', '')
|
||
const m2 = await mountInScroller(h(View, props), scroller2)
|
||
assert.equal(scroller2.scrollTop, 0, 'the jump lands at the top of the tab')
|
||
assert.ok(query(m2.container, '.lc-bar[data-seq="4"]').className.includes('lc-bar-selected'))
|
||
await m2.unmount()
|
||
})
|
||
})
|
||
|
||
describe('ContextView — the split generation (slim head + detail channel)', () => {
|
||
/** A slim wire head: the pushed value with the collections empty and the counters/markers on. */
|
||
function slimHead(over: Record<string, unknown> = {}): ContextTimeline {
|
||
return timeline({
|
||
model: 'deepseek-v4-flash',
|
||
provider: 'deepseek',
|
||
contextWindow: 128000,
|
||
toolCalls: 3,
|
||
images: 1,
|
||
counts: { turns: 1, steps: 3, injects: 1, compactions: 1, prunes: 0 },
|
||
last: { seq: 6, total: 420, prompt: 410 },
|
||
detailRev: 6,
|
||
...over,
|
||
})
|
||
}
|
||
|
||
/** The matching detail payload (richTimeline's collections). */
|
||
function slimDetail(rev = 6): Record<string, unknown> {
|
||
const rich = richTimeline()
|
||
return {
|
||
rev,
|
||
requests: rich.requests,
|
||
events: rich.events,
|
||
nodes: rich.nodes,
|
||
droppedNodes: rich.droppedNodes,
|
||
archive: rich.archive,
|
||
}
|
||
}
|
||
|
||
/** A ctx whose connection.rpc.call serves (or fails) the detail endpoint. */
|
||
function slimCtx(serve: () => Promise<unknown>): TestClientCtx {
|
||
return new TestClientCtx({
|
||
services: {
|
||
connection: { rpc: { call: (_channel: string, _endpoint: string, _payload: unknown) => serve() } },
|
||
},
|
||
})
|
||
}
|
||
|
||
async function until(fn: () => boolean, message: string): Promise<void> {
|
||
for (let i = 0; i < 400; i++) {
|
||
if (fn()) return
|
||
await new Promise(resolve => setTimeout(resolve, 5))
|
||
}
|
||
assert.fail(message)
|
||
}
|
||
|
||
test('the head paints the counters immediately; the detail collections land through the channel', async () => {
|
||
const ctx = slimCtx(async () => ({ ok: true, value: slimDetail() }))
|
||
const View = makeView(ctx)
|
||
const m = await mount(h(View, { sessionId: 'sv-slim', useProjection: projectionsFor(slimHead()) }))
|
||
|
||
// First paint: the counters are real (no detail needed), the detail cards name the pending read.
|
||
const values = queryAll(m.container, '.lc-stat-value').map(el => text(el))
|
||
assert.deepEqual(values.slice(0, 2), ['1', '3'], 'turns/steps from the head counters')
|
||
assert.ok(text(m.container).includes(DICT_EN['detail.loading']))
|
||
assert.equal(queryAll(m.container, '.lc-bar').length, 0, 'the chart waits for the detail')
|
||
assert.equal(queryAll(m.container, '.lc-br-pick option').length, 1, 'the picker holds only the live row')
|
||
|
||
// The detail lands: every card renders its real content, the notes clear.
|
||
await until(() => queryAll(m.container, '.lc-bar').length === 3, 'the detail never landed')
|
||
assert.ok(!text(m.container).includes(DICT_EN['detail.loading']))
|
||
assert.ok(text(m.container).includes('heads-up'), 'the events list serves the detail')
|
||
assert.equal(queryAll(m.container, '.lc-br-pick option').length, 4, 'live + three steps')
|
||
assert.ok(text(m.container).includes('reply three'), 'the brief rows serve the detail')
|
||
assert.ok(text(m.container).includes(DICT_EN['files.empty']), 'no file ops in this detail')
|
||
await m.unmount()
|
||
})
|
||
|
||
test('a failed detail read arms the retry notes; one click refires and the cards land', async () => {
|
||
let online = false
|
||
const ctx = slimCtx(async () => {
|
||
if (!online) throw new Error('offline')
|
||
return { ok: true, value: slimDetail() }
|
||
})
|
||
const View = makeView(ctx)
|
||
const m = await mount(h(View, { sessionId: 'sv-slim-fail', useProjection: projectionsFor(slimHead()) }))
|
||
await until(() => text(m.container).includes(DICT_EN['detail.loadFailed']), 'the failure never surfaced')
|
||
assert.equal(queryAll(m.container, '.lc-bar').length, 0)
|
||
|
||
online = true
|
||
await click(query(m.container, '.lc-br-retry'))
|
||
await until(() => queryAll(m.container, '.lc-bar').length === 3, 'the retry never recovered the cards')
|
||
assert.ok(!text(m.container).includes(DICT_EN['detail.loadFailed']))
|
||
assert.ok(text(m.container).includes('heads-up'))
|
||
await m.unmount()
|
||
})
|
||
|
||
test('the jump relay survives the split: held while the detail reads, resolves when it lands', async () => {
|
||
const ctx = slimCtx(async () => ({ ok: true, value: slimDetail() }))
|
||
const View = makeView(ctx)
|
||
// The jump is armed BEFORE the view mounts (the chat action relay) — the
|
||
// detail read is still pending, so the pin must wait for it (not consume
|
||
// and clamp against an empty record list).
|
||
requestContextFocus('sv-slim-jump', 4)
|
||
const m = await mount(h(View, { sessionId: 'sv-slim-jump', useProjection: projectionsFor(slimHead()) }))
|
||
assert.equal(queryAll(m.container, '.lc-bar-selected').length, 0, 'nothing pins before the detail')
|
||
await until(
|
||
() => queryAll(m.container, '.lc-bar[data-seq="4"]')[0]?.className.includes('lc-bar-selected') === true,
|
||
'the jump never resolved',
|
||
)
|
||
assert.equal(takeContextFocus('sv-slim-jump'), null)
|
||
await m.unmount()
|
||
})
|
||
})
|
||
|
||
describe('ContextView — the op-log generation (fileOps on the detail payload)', () => {
|
||
test('the File Activity card renders the fold-derived ops, no conversation join needed', async () => {
|
||
const ctx = new TestClientCtx({
|
||
services: {
|
||
connection: {
|
||
rpc: {
|
||
call: async () => ({
|
||
ok: true,
|
||
value: {
|
||
rev: 1,
|
||
requests: [{ seq: 2, turn: 1, step: 1, time: T0, system: 1, tools: 2, user: 3, inject: 0, assistant: 4, tool: 5, total: 15 }],
|
||
events: [],
|
||
nodes: [],
|
||
droppedNodes: 0,
|
||
archive: [],
|
||
// The op log covers the full session — the conversation window
|
||
// join plays no role in this card on this generation.
|
||
fileOps: [
|
||
{ seq: 1, path: '/ws/README.md', kind: 'read', tool: 'read', err: false, added: 0, removed: 0, read: { start: 1, count: 12 } },
|
||
{ seq: 2, path: '/ws/src/a.ts', kind: 'write', tool: 'edit', err: false, added: 3, removed: 1 },
|
||
],
|
||
},
|
||
}),
|
||
},
|
||
},
|
||
},
|
||
})
|
||
const View = makeView(ctx)
|
||
const m = await mount(h(View, {
|
||
sessionId: 'sv-opslog',
|
||
useProjection: projectionsFor(timeline({
|
||
counts: { turns: 1, steps: 1, injects: 0, compactions: 0, prunes: 0 },
|
||
detailRev: 1,
|
||
})),
|
||
}))
|
||
const until2 = async (fn: () => boolean): Promise<void> => {
|
||
for (let i = 0; i < 400; i++) {
|
||
if (fn()) return
|
||
await new Promise(r => setTimeout(r, 5))
|
||
}
|
||
assert.fail('the detail never landed')
|
||
}
|
||
await until2(() => text(m.container).includes('README.md'))
|
||
assert.ok(text(m.container).includes('a.ts'), 'both served ops row')
|
||
assert.ok(text(m.container).includes('+3'), 'the edit delta shows')
|
||
assert.ok(!text(m.container).includes('No file reads'), 'not the empty state')
|
||
await m.unmount()
|
||
})
|
||
})
|