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.
This commit is contained in:
1 parent
f1505420a5
commit
3aed76b493
7 files changed
+215
-13
No files matched your search
@@ -43,6 +43,15 @@ const viewScroll = new Map<string, number>()
|
||||
|
||||
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<number, SurfaceNode>()
|
||||
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 (
|
||||
<div className="lc-root" ref={rootRef}>
|
||||
@@ -567,7 +593,7 @@ export function makeContextView(
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<EventList events={shownEvents} state={source.detailState} onRetry={source.retryDetail} />
|
||||
<EventList events={shownEvents} state={source.detailState} onRetry={source.retryDetail} onInjectFocus={focusInject} />
|
||||
</div>
|
||||
<FileCard activity={fileActivity} scope={fileScope} workspace={workspace}
|
||||
onPreview={previewFile} onOpen={fileOpener} onLocate={locateFileOp}
|
||||
|
||||
@@ -18,6 +18,9 @@ export interface EventListProps {
|
||||
* retry button — an un-fetched list is not an empty one. */
|
||||
state?: DetailState
|
||||
onRetry?: () => 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 (
|
||||
<div key={ev.seq} className="lc-event @max-[380px]/lc-card:flex-wrap">
|
||||
<span className={'lc-event-icon lc-event-' + ev.kind}>{glyph}</span>
|
||||
<span className={'lc-kind lc-kind-' + ev.kind}>{t('kind.' + ev.kind)}</span>
|
||||
<span className="lc-event-label flex-1 @max-[380px]/lc-card:basis-[calc(100%-92px)]">{label}</span>
|
||||
{reveal !== undefined ? (
|
||||
<button type="button" className={labelClass + ' lc-event-jump'} onClick={() => { reveal(ev) }}>{label}</button>
|
||||
) : (
|
||||
<span className={labelClass}>{label}</span>
|
||||
)}
|
||||
{at !== null ? <span className="lc-event-at">{at}</span> : null}
|
||||
{ev.tokens ? (
|
||||
<span className={'lc-event-tokens' + (ev.kind === 'inject' ? ' lc-up' : ' lc-down')}>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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> = {}): 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()
|
||||
})
|
||||
})
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in new issue
Block a user