feat(ui): hover a history bar to preview its breakdown below

Hovering a trend bar links the detail panel to that request immediately
(hover wins over the pinned selection, which takes over again when the
pointer leaves; both fall back to the newest request). The hovered bar
gets a dashed outline, and the chart clears the preview on mouse leave.
This commit is contained in:
bowenliang123 committed 2026-08-14 19:24:28 +08:00
1 parent 0e81996a41
commit f8ac2f45cb
2 files changed
+96 -22

No files matched your search

+64 -15
View File
@@ -123,11 +123,12 @@ const statefulReact = {
createElement: (...args) => ({ kind: 'element', args }),
useState(init) {
const i = hookCursor++
if (currentHooks[i] === undefined) {
const set = (v) => { currentHooks[i][0] = typeof v === 'function' ? v(currentHooks[i][0]) : v }
currentHooks[i] = [typeof init === 'function' ? init() : init, set]
const slots = currentHooks // captured by the setter: stable per fiber
if (slots[i] === undefined) {
const set = (v) => { slots[i][0] = typeof v === 'function' ? v(slots[i][0]) : v }
slots[i] = [typeof init === 'function' ? init() : init, set]
}
return currentHooks[i]
return slots[i]
},
useEffect: () => {},
useRef: (init) => ({ current: init }),
@@ -139,7 +140,7 @@ const requireStateful = (spec) => {
const m2 = { exports: {} }
const pluginExports2 = factory(requireStateful, m2, globalThis.window, fakeDoc)
const DICT_FOR_TEST = { 'tab': 'Context', 'loading': '…', 'error': 'x' }
const DICT_FOR_TEST = { 'tab': 'Context', 'loading': '…', 'error': 'x', 'detail.step': 'T{t} · step {s}' }
let viewComponent = null
const fakeCtx2 = {
get: () => undefined,
@@ -161,23 +162,37 @@ const fakeCtx2 = {
pluginExports2.apply(fakeCtx2)
assert.ok(viewComponent !== null, 'view component captured')
/** Invoke function-typed elements so hooks run and the tree materializes. */
function evaluate(node) {
/** Invoke function-typed elements so hooks run and the tree materializes.
* Hooks are keyed by the component's fiber path (e.g. root/ContextView#0/
* StackedBar#0), so distinct instances of the same component keep state. */
function evaluate(node, path = '', fnIdx = 0) {
if (node === null || typeof node !== 'object') return node
if (node.kind === 'element') {
const [type, props, ...children] = node.args
if (typeof type === 'function') {
currentHooks = hookStates.get(type)
const key = path + '/' + (type.name || 'anon') + '#' + fnIdx
currentHooks = hookStates.get(key)
if (currentHooks === undefined) {
currentHooks = []
hookStates.set(type, currentHooks)
hookStates.set(key, currentHooks)
}
hookCursor = 0
return evaluate(type(props))
return evaluate(type(props), key)
}
return { kind: 'element', args: [type, props, ...children.map(evaluate)] }
const kids = []
let f = 0
const walkChildren = (c) => {
if (Array.isArray(c)) { for (const x of c) walkChildren(x); return }
if (c !== null && typeof c === 'object' && c.kind === 'element' && typeof c.args[0] === 'function') {
kids.push(evaluate(c, path, f++))
} else {
kids.push(evaluate(c, path, f))
}
}
for (const c of children) walkChildren(c)
return { kind: 'element', args: [type, props, ...kids] }
}
if (Array.isArray(node)) return node.map(evaluate)
if (Array.isArray(node)) return node.map(n => evaluate(n, path, fnIdx))
return node
}
@@ -212,8 +227,9 @@ const snapshot = {
events: [], nodes: [], droppedNodes: 0,
}
// setData: slot 0 of the ContextView fiber holds the data state.
const contextViewFn = viewComponent({ sessionId: 's1' }).args[0]
hookStates.get(contextViewFn)[0][1](snapshot)
const ctxKey = [...hookStates.keys()].find(k => k.includes('ContextView'))
assert.ok(ctxKey, 'ContextView fiber registered')
hookStates.get(ctxKey)[0][1](snapshot)
const tree = evaluate(viewComponent({ sessionId: 's1' }))
const bars = byClass(tree, 'lc-bar')
@@ -227,4 +243,37 @@ assert.equal(turnWidths[1], '14px', 'T2 tick spans one column')
assert.ok(byClass(tree, 'lc-chart-scroll').length === 1, 'scroll container present')
assert.ok(byClass(tree, 'lc-turns').length === 1, 'turn tick row present')
console.log('✔ chart render test passed (fixed-width bars, scroll container, turn ranges)')
// ---- hover linking: hovering a trend bar updates the detail below ----
const ctxSlots = hookStates.get(ctxKey) // data(0) error(1) selected(2) hovered(3) tick(4)
const renderView = () => evaluate(viewComponent({ sessionId: 's1' }))
function textOf(node) {
if (typeof node === 'string') return node
if (node === null || node === undefined || typeof node !== 'object') return ''
if (node.kind === 'element') return node.args.slice(2).map(textOf).join('')
if (Array.isArray(node)) return node.map(textOf).join('')
return ''
}
const detailStep = (tr) => {
const head = byClass(tr, 'lc-detail-head')[0]
return head === undefined ? '' : textOf(head).trim()
}
let tr = renderView()
assert.match(detailStep(tr), /T3/, 'detail defaults to the newest request (T3)')
assert.equal(byClass(tr, 'lc-bar-hovered').length, 0, 'no hovered bar initially')
ctxSlots[3][1](3) // setHoveredSeq(seq 3, turn 2)
tr = renderView()
assert.match(detailStep(tr), /T2/, 'hovering a bar links the detail below to it')
const hovered = byClass(tr, 'lc-bar-hovered')
assert.equal(hovered.length, 1, 'exactly one hovered bar')
assert.equal(hovered[0].args[1].key, 3, 'hovered bar is seq 3')
const bar3 = byClass(tr, 'lc-bar').find(b => b.args[1].key === 3)
assert.equal(typeof bar3.args[1].onMouseEnter, 'function', 'bars carry onMouseEnter')
assert.equal(typeof bar3.args[1].onClick, 'function', 'bars carry onClick')
ctxSlots[3][1](null) // leave the plot
tr = renderView()
assert.match(detailStep(tr), /T3/, 'leaving the plot reverts the detail to the newest request')
console.log('✔ chart render test passed (fixed-width bars, scroll container, turn ranges, hover linking)')
+32 -7
View File
@@ -237,7 +237,9 @@ interface TrendChartProps {
requests: RequestRecord[]
events: ContextEventRecord[]
selectedSeq: number | null
hoveredSeq: number | null
onSelect: (seq: number | null) => void
onHover: (seq: number | null) => void
}
interface RequestDetailProps { request: RequestRecord | null }
interface EventListProps { events: ContextEventRecord[] }
@@ -371,20 +373,26 @@ function makeView(ctx: ClientCtx, t: Translate): (props: ContextViewProps) => Re
h('span', { className: 'lc-axis-mid' }, fmt(Math.round(maxTotal / 2))),
h('span', { className: 'lc-axis-bot' }, '0')),
h('div', { className: 'lc-chart-scroll', ref: scrollRef },
h('div', { className: 'lc-chart' },
// Hovering a bar previews it in the detail below; leaving the plot
// clears the preview (a pinned selection, if any, takes over again).
h('div', { className: 'lc-chart', onMouseLeave: () => { props.onHover(null) } },
h('div', { className: 'lc-grid lc-grid-top' }),
h('div', { className: 'lc-grid lc-grid-mid' }),
requests.map((req, i) => {
const selected = props.selectedSeq === req.seq
const hovered = props.hoveredSeq === req.seq
const tip = tr('tip.step', { t: req.turn ?? 0, s: req.step ?? 0 }) + ' · ' + fmtTime(req.time) + '\n'
+ tr('tip.total', { n: fmt(req.total) })
+ (req.prompt !== undefined ? tr('tip.actual', { n: fmt(req.prompt) }) : '') + '\n'
+ CATS.map(c => catLabel(c.key) + ' ' + fmt(req[c.key] || 0)).join(' / ')
return h('div', {
key: req.seq,
className: 'lc-bar' + (selected ? ' lc-bar-selected' : ''),
className: 'lc-bar'
+ (selected ? ' lc-bar-selected' : '')
+ (hovered ? ' lc-bar-hovered' : ''),
title: tip,
onClick: () => { props.onSelect(selected ? null : req.seq) },
onMouseEnter: () => { props.onHover(req.seq) },
},
markers[i] ? h('span', { className: 'lc-bar-marker', title: eventLabel(markers[i]) }, '✂') : null,
h('div', { className: 'lc-bar-stack' },
@@ -473,6 +481,7 @@ function makeView(ctx: ClientCtx, t: Translate): (props: ContextViewProps) => Re
const [data, setData] = React.useState<Snapshot | null>(null)
const [error, setError] = React.useState<string | null>(null)
const [selectedSeq, setSelectedSeq] = React.useState<number | null>(null)
const [hoveredSeq, setHoveredSeq] = React.useState<number | null>(null)
const [tick, setTick] = React.useState(0)
React.useEffect(() => {
@@ -514,9 +523,17 @@ function makeView(ctx: ClientCtx, t: Translate): (props: ContextViewProps) => Re
const events = data.events || []
const nodes = data.nodes || []
let selReq: RequestRecord | null = null
for (const req of requests) if (req.seq === selectedSeq) selReq = req
if (!selReq && requests.length > 0) selReq = requests[requests.length - 1]
// The detail below follows the pointer: hover previews a bar, a pinned
// click takes over when the pointer leaves, and both fall back to the
// newest request.
let pinnedReq: RequestRecord | null = null
for (const req of requests) if (req.seq === selectedSeq) pinnedReq = req
let activeReq: RequestRecord | null = null
if (hoveredSeq !== null) {
for (const req of requests) if (req.seq === hoveredSeq) activeReq = req
}
if (activeReq === null) activeReq = pinnedReq
if (activeReq === null && requests.length > 0) activeReq = requests[requests.length - 1]
const windowPct = data.contextWindow ? Math.min(100, Math.round(current.total / data.contextWindow * 100)) : null
@@ -550,8 +567,15 @@ function makeView(ctx: ClientCtx, t: Translate): (props: ContextViewProps) => Re
requests.length === 0
? h('div', { className: 'lc-empty' }, t('trend.empty'))
: h('div', null,
h(TrendChart, { requests: requests.slice(-80), events, selectedSeq: selReq ? selReq.seq : null, onSelect: setSelectedSeq }),
h(RequestDetail, { request: selReq }))),
h(TrendChart, {
requests: requests.slice(-80),
events,
selectedSeq: pinnedReq ? pinnedReq.seq : null,
hoveredSeq,
onSelect: setSelectedSeq,
onHover: setHoveredSeq,
}),
h(RequestDetail, { request: activeReq }))),
// ---- events + messages ----
h('div', { className: 'lc-cols' },
@@ -603,6 +627,7 @@ const STYLES = [
'.lc-bar { position: relative; width: 14px; flex: none; height: 100%; display: flex; align-items: flex-end; cursor: pointer; border-radius: 2px; }',
'.lc-bar:hover { background: var(--dsw-alias-bg-layer-2); }',
'.lc-bar-selected { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: 1px; }',
'.lc-bar-hovered { outline: 1px dashed var(--dsw-alias-brand-primary); outline-offset: 1px; }',
'.lc-bar-stack { display: flex; flex-direction: column-reverse; width: 100%; }',
'.lc-bar-stack > div { width: 100%; }',
'.lc-bar-marker { position: absolute; top: -16px; left: 50%; transform: translateX(-50%); font-size: 11px; color: var(--dsw-alias-state-warn-primary); }',