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:
1 parent
0e81996a41
commit
f8ac2f45cb
2 files changed
+96
-22
No files matched your search
+64
-15
@@ -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
@@ -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); }',
|
||||
|
||||
Reference in new issue
Block a user