diff --git a/src/client/components/contextView.tsx b/src/client/components/contextView.tsx index 585d22d..e0fc338 100644 --- a/src/client/components/contextView.tsx +++ b/src/client/components/contextView.tsx @@ -43,6 +43,15 @@ const viewScroll = new Map() const EVENT_KINDS = ['inject', 'compaction', 'prune', 'model', 'mode'] as const +/** Scroll the Context Browser card to the top of the page's scrollport: the stats figures and the events card's + * injection rows both jump into it, and neither may land below the fold. */ +function revealBrowser(root: HTMLElement | null): void { + /* v8 ignore start -- every render path attaches the root and draws the browser card, so the target always resolves. */ + const browser = root?.querySelector('.lc-col-browser') ?? null + if (browser !== null) revealInScrollParent(browser) + /* v8 ignore stop */ +} + /** The trend card's reserved body: the plot lane plus an empty detail panel wearing the panel's OWN classes, so the * pending and loaded states are measured by the same CSS and cannot drift. Without it the first step's arrival grew * the card by its whole height and shoved every card below it down the page. */ @@ -166,11 +175,7 @@ export function makeContextView( const clearCatFocus = useCallback(() => { setCatFocus(null) }, []) const onFigureClick = useCallback((figure: 'skills' | 'answers'): void => { setCatFocus(figure === 'skills' ? { cat: 'skill' } : { cat: 'assistant', kind: 'answer' }) - /** v8 ignore start -- the stats card and the browser card render in the - * same view, so the target always resolves here; the guard stays for defensive parity with revealAgents. */ - const browser = rootRef.current?.querySelector('.lc-col-browser') ?? null - if (browser !== null) revealInScrollParent(browser) - /* v8 ignore stop */ + revealBrowser(rootRef.current) }, []) // Restore the saved position in a layout effect, so the chat's bottom-anchored position never flashes in first. @@ -350,6 +355,27 @@ export function makeContextView( setNodeFocus({ step, key: 'n' + String(node.seq), cat: node.cat }) }, [activeReq, activeIdx, displayRequests]) + // An injection event carries the seq of the very surface node it added (fold.ts), so its row can reveal that node + // at the step that first carried it. Resolution comes from the served node: its `gone` stamp bounds the reveal + // (a removed item shows only on the steps before its removal), and its category is the fold's own verdict — skill + // machinery lands in `skill`, which the event's `sub` does not cover (the catalog digest rides an untagged event). + const nodeOfSeq = useMemo(() => { + const m = new Map() + if (data !== null) { + for (const n of data.nodes) m.set(n.seq, n) + for (const n of data.archive) m.set(n.seq, n) + } + return m + }, [data]) + const focusInject = useCallback((ev: ContextEventRecord): void => { + const node = nodeOfSeq.get(ev.seq) + const step = locateStepOf(requests, ev.seq, node?.gone) + // Removed before any step dispatched it: there is nothing left to show. + if (step === null) return + setNodeFocus({ step, key: 'n' + String(ev.seq), cat: node?.cat ?? 'inject' }) + revealBrowser(rootRef.current) + }, [requests, nodeOfSeq]) + if (!data) { return (
@@ -567,7 +593,7 @@ export function makeContextView( })}
- + void + /** Reveal an injection's own context item in the Context Browser: its label becomes a link (hover underline). + * Absent — every row keeps a plain label. */ + onInjectFocus?: (ev: ContextEventRecord) => void } export function makeEventText(t: Translate): { @@ -72,6 +75,8 @@ function syncTitles(root: HTMLElement): void { export function makeEventList(kit: ViewKit): (props: EventListProps) => ReactElement { const { t, fmt, fmtTime, eventLabel, eventAt } = kit const DetailNote = makeDetailNote(kit) + // The lane both row shapes share; its 92px basis reservation covers icon + gap + the widest kind chip in both locales. + const labelClass = 'lc-event-label flex-1 @max-[380px]/lc-card:basis-[calc(100%-92px)]' return function EventList(props: EventListProps): ReactElement { // Hooks stay unconditional (Rules of Hooks): events going empty -> // non-empty in one mounted instance must not grow the hook count — an @@ -111,15 +116,20 @@ export function makeEventList(kit: ViewKit): (props: EventListProps) => ReactEle : EVENT_ICONS[ev.kind] || '•' // Key on the durable seq alone: the list renders newest-first, so a fresh event lands at index 0 // and an index-bearing key would shift EVERY existing row's key — a full-list remount on every push. - // Narrow cards (phone panes): the single-line row would crush the label and push the meta - // tail into a scrollbar, so the row wraps — the label's near-full-width basis keeps line 1 - // to icon + kind + label, and the meta tail folds onto line 2. The 92px basis reservation - // covers icon + gap + the widest kind chip (both locales) + gap. + // Narrow cards (phone panes): the row wraps, folding the meta tail onto line 2 instead of crushing + // the label into a scrollbar. + // An injection's label doubles as the link into the Context Browser (the row's own context item); + // every other kind keeps a plain label. + const reveal = ev.kind === 'inject' ? props.onInjectFocus : undefined return (
{glyph} {t('kind.' + ev.kind)} - {label} + {reveal !== undefined ? ( + + ) : ( + {label} + )} {at !== null ? {at} : null} {ev.tokens ? ( diff --git a/src/client/fileActivity.ts b/src/client/fileActivity.ts index c0efdac..a9074cc 100644 --- a/src/client/fileActivity.ts +++ b/src/client/fileActivity.ts @@ -373,9 +373,12 @@ export function activityOf( return aggregateOps(ops, null) } -/** The browser step whose surface SHOWS an op's result node: the first request dispatched after it while it was still alive. */ +/** The browser step whose surface SHOWS an op's result node: the first request dispatched after it while it was still + * alive. Sorted here because the wire's append order is not guaranteed (restored rows can re-order) — the same rule + * the browser's own entry stamps read. */ export function locateStepOf(requests: RequestRecord[], seq: number, gone: number | undefined): number | 'live' | null { - for (const r of requests) { + const sorted = requests.slice().sort((a, b) => a.seq - b.seq) + for (const r of sorted) { if (r.seq > seq && (gone === undefined || gone > r.seq)) return r.seq } return gone === undefined ? 'live' : null diff --git a/src/client/styles/events.css b/src/client/styles/events.css index d113a69..aee0b03 100644 --- a/src/client/styles/events.css +++ b/src/client/styles/events.css @@ -12,6 +12,10 @@ .lc-kind-model { background: color-mix(in srgb, var(--dsw-alias-brand-primary) 15%, transparent); color: var(--dsw-alias-brand-primary); } .lc-kind-mode { background: color-mix(in srgb, var(--dsw-alias-label-secondary) 15%, transparent); color: var(--dsw-alias-label-secondary); } .lc-event-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +/** An injection's label is the link into the Context Browser: reset the UA button skin onto the label lane and + * underline on hover as the affordance. The truncated label's native title fallback stays its own text (syncTitles). */ +.lc-event-jump { border: 0; background: transparent; color: inherit; font: inherit; text-align: left; padding: 0; cursor: pointer; } +.lc-event-jump:hover { text-decoration: underline; } .lc-event-at { flex: none; color: var(--dsw-alias-label-secondary); font-size: 11px; white-space: nowrap; } .lc-event-tokens { color: var(--dsw-alias-state-success-primary); font-weight: 600; white-space: nowrap; } .lc-event-tokens.lc-up { color: var(--dsw-alias-state-warn-primary); } diff --git a/tests/client/components/contextView.injectFocus.spec.ts b/tests/client/components/contextView.injectFocus.spec.ts new file mode 100644 index 0000000..aa13d90 --- /dev/null +++ b/tests/client/components/contextView.injectFocus.spec.ts @@ -0,0 +1,130 @@ +// 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 { + 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(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(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(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(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(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() + }) +}) diff --git a/tests/client/components/events.spec.ts b/tests/client/components/events.spec.ts index 1331978..d73afef 100644 --- a/tests/client/components/events.spec.ts +++ b/tests/client/components/events.spec.ts @@ -189,6 +189,31 @@ describe('EventList', () => { assert.ok(text(m.container).includes('No context events yet')) await m.unmount() }) + + test('an injection label links into the browser; other kinds and a handler-less list keep plain labels', async () => { + const events = [ + ev({ seq: 1, kind: 'inject', form: 'notice', name: 'hook' }), + ev({ seq: 2, kind: 'prune' }), + ] + const focused: ContextEventRecord[] = [] + const m = await mount(h(EventList, { events, onInjectFocus: e => { focused.push(e) } })) + const rows = queryAll(m.container, '.lc-event') + // Newest first: the prune row leads, and only the injection becomes a link. + assert.equal(rows[0].querySelector('.lc-event-jump'), null) + assert.equal(query(rows[0], '.lc-event-label').tagName, 'SPAN') + const link = query(rows[1], '.lc-event-jump') + assert.equal(link.tagName, 'BUTTON') + assert.equal(link.textContent, 'Notice · hook') + await click(link) + assert.deepEqual(focused.map(e => e.seq), [1]) + await m.unmount() + + // No handler (a view with no browser to open): every label stays a plain span. + const plain = await mount(h(EventList, { events })) + assert.equal(queryAll(plain.container, '.lc-event-jump').length, 0) + assert.equal(queryAll(plain.container, '.lc-event-label').length, 2) + await plain.unmount() + }) }) describe('EventList — the split generation detail states', () => { diff --git a/tests/client/fileActivity.spec.ts b/tests/client/fileActivity.spec.ts index bf4cdfc..38c83f2 100644 --- a/tests/client/fileActivity.spec.ts +++ b/tests/client/fileActivity.spec.ts @@ -301,6 +301,10 @@ describe('locateStepOf', () => { assert.equal(locateStepOf(requests, 35, undefined), 'live') assert.equal(locateStepOf([], 1, undefined), 'live') }) + + test('a re-ordered wire still resolves the first request logged after the node', () => { + assert.equal(locateStepOf([{ seq: 30 }, { seq: 10 }, { seq: 20 }] as RequestRecord[], 5, undefined), 10) + }) }) /** One settled nested sub-call block, as the conversation join delivers it. */