Files
dsh-context/tests/client/components/contextView.injectFocus.spec.ts
bowenliang123 3aed76b493 feat(client): reveal an injection's item from its events row
An injection row's label is now a link into the Context Browser: hovering
underlines it, and a click selects the step that first carried the item,
opens its category, expands its row, and scrolls the browser card into
view. An injection event's seq IS the seq of the surface node it added
(fold.ts), so the events row and the browser row are the same item; the
node's own `gone` stamp bounds the reveal to the steps before its removal.

The step comes from the existing locateStepOf(), which now sorts the wire
the way the browser's own entry stamps do — a re-ordered log must not move
the reveal past the step that actually carried the item.
2026-10-10 19:28:00 +08:00

131 lines
6.6 KiB
TypeScript
Raw Permalink 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.
// ContextView — the events card's injection rows reveal their own item in the Context Browser.
import { createElement as h } from 'react'
import assert from 'node:assert/strict'
import { afterEach, describe, test, vi } from 'vitest'
import { DICT_EN } from '../../../src/client/i18n'
import { resetTimelineDetailStores } from '../../../src/client/timelineSource'
import type { ContextTimeline } from '../../../src/shared/types'
import { TestClientCtx } from '../helpers/harness'
import { click, mount, query, queryAll, text } from '../helpers/kit'
import { makeView, mountInScroller, projectionsFor, T0, timeline } from './contextViewHarness'
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
resetTimelineDetailStores()
})
/** Two logged steps, an injection before each of them, one injection past the log tail, and one whose item is
* not served at all (the dropped-window arm). */
function injectTimeline(over: Partial<ContextTimeline> = {}): ContextTimeline {
return timeline({
requests: [
{ seq: 4, turn: 1, step: 1, time: T0 + 1000, system: 10, tools: 20, user: 10, inject: 20, assistant: 0, tool: 0, total: 60 },
{ seq: 8, turn: 1, step: 2, time: T0 + 2000, system: 10, tools: 20, user: 10, inject: 20, assistant: 0, tool: 0, total: 60 },
],
events: [
{ seq: 3, time: T0 + 500, kind: 'inject', form: 'notice', name: 'hook', tokens: 20, turn: 1, step: 1 },
{ seq: 5, time: T0 + 1500, kind: 'inject', form: 'context', name: 'dropped', tokens: 5 },
{ seq: 11, time: T0 + 3000, kind: 'inject', form: 'notice', name: 'late', tokens: 7 },
],
nodes: [
{ seq: 1, cat: 'user', tokens: 10, text: 'hello there', time: T0 },
{ seq: 3, cat: 'inject', tokens: 20, time: T0 + 500, text: 'the injected note' },
{ seq: 11, cat: 'inject', tokens: 7, time: T0 + 3000, text: 'the late note' },
],
...over,
})
}
/** The events card lists newest-first: pick the row whose label carries this injection's name. */
function injectLink(container: ParentNode, label: string): HTMLElement {
const row = queryAll(container, '.lc-event').find(r => text(r).includes(label))
assert.ok(row !== undefined, `no event row labelled ${label}`)
return query(row, '.lc-event-jump')
}
function mountInject(sessionId: string, data: ContextTimeline) {
const View = makeView(new TestClientCtx())
return mount(h(View, { sessionId, useProjection: projectionsFor(data) }))
}
describe('ContextView — injection rows reveal their item', () => {
test('a click opens the browser on the injection\'s own item at its entry step and scrolls the card in', async () => {
const scroller = document.createElement('div')
scroller.setAttribute('data-conversation-scroll', '')
scroller.style.overflowY = 'auto'
Object.defineProperty(scroller, 'scrollHeight', { value: 800 })
Object.defineProperty(scroller, 'clientHeight', { value: 300 })
// jsdom lays out nothing: fixed viewport tops make the reveal's rect delta deterministic (450 − 100).
const gBCR = vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (this: Element) {
const top = this.hasAttribute('data-conversation-scroll') ? 100
: this.classList.contains('lc-col-browser') ? 450 : 0
return { top } as DOMRect
})
try {
const View = makeView(new TestClientCtx())
const m = await mountInScroller(h(View, {
sessionId: 'sv-injectfocus',
useProjection: projectionsFor(injectTimeline()),
}), scroller)
assert.equal(queryAll(m.container, '.lc-br-elem-on').length, 0, 'nothing is open before the click')
await click(injectLink(m.container, DICT_EN['form.notice'] + ' · hook'))
assert.equal(query<HTMLSelectElement>(m.container, 'select.lc-br-pick').value, '4', 'the entry step is selected')
const open = queryAll(m.container, '.lc-br-elem-on')
assert.equal(open.length, 1)
assert.ok(text(open[0]).includes('the injected note'), 'the injection\'s own item is open')
assert.ok(queryAll(m.container, '.lc-br-cat-row')[3].className.includes('lc-br-cat-open'), 'the Inject category is open')
assert.equal(scroller.scrollTop, 350, 'the browser card scrolled to the scrollport top')
await m.unmount()
} finally {
gBCR.mockRestore()
}
})
test('an injection past the last request reveals on the live surface', async () => {
const m = await mountInject('sv-injectlive', injectTimeline())
await click(injectLink(m.container, DICT_EN['form.notice'] + ' · late'))
assert.equal(query<HTMLSelectElement>(m.container, 'select.lc-br-pick').value, 'live')
const open = queryAll(m.container, '.lc-br-elem-on')
assert.equal(open.length, 1)
assert.ok(text(open[0]).includes('the late note'))
await m.unmount()
})
test('an injection whose item is no longer served moves to its step, opening the category only', async () => {
const m = await mountInject('sv-injectdropped', injectTimeline())
await click(injectLink(m.container, DICT_EN['form.context'] + ' · dropped'))
assert.equal(query<HTMLSelectElement>(m.container, 'select.lc-br-pick').value, '8')
assert.equal(queryAll(m.container, '.lc-br-elem-on').length, 0, 'the item is not served, so no row can open')
assert.ok(queryAll(m.container, '.lc-br-cat-row')[3].className.includes('lc-br-cat-open'))
await m.unmount()
})
test('a compaction shadow reveals on the steps before its removal, and never after it', async () => {
const archived = (gone: number) => injectTimeline({
nodes: [{ seq: 1, cat: 'user', tokens: 10, text: 'hello there', time: T0 }],
archive: [{ seq: 3, cat: 'inject', tokens: 20, time: T0 + 500, text: 'the removed note', gone }],
})
// Removed at seq 6: the step-4 surface still held it, so the row opens there.
const m = await mountInject('sv-injectshadow', archived(6))
await click(injectLink(m.container, DICT_EN['form.notice'] + ' · hook'))
assert.equal(query<HTMLSelectElement>(m.container, 'select.lc-br-pick').value, '4')
const open = queryAll(m.container, '.lc-br-elem-on')
assert.equal(open.length, 1)
assert.ok(text(open[0]).includes('the removed note'))
await m.unmount()
// Removed at seq 4 — before any step dispatched it: nothing is left to reveal.
const gone = await mountInject('sv-injectgone', archived(4))
await click(injectLink(gone.container, DICT_EN['form.notice'] + ' · hook'))
assert.equal(query<HTMLSelectElement>(gone.container, 'select.lc-br-pick').value, 'live')
assert.equal(queryAll(gone.container, '.lc-br-elem-on').length, 0)
assert.ok(!queryAll(gone.container, '.lc-br-cat-row')[3].className.includes('lc-br-cat-open'))
await gone.unmount()
})
})