perf(context): cut context-tab render, hover and DNA costs (#167)
* perf(context): cut context-tab render, hover and DNA costs P0 — interaction/caching: - isolate pointer hovers: chart-local hover stays immediate, cross-card previews trail (140ms; 220ms in DNA mode) so sweeps cannot stack full-view renders; - cache pure projections (assemble, DNA/file derivations) across hover/remount; - remove forced layout from the flow/timing read paths, gate the hidden sidebar copy, memoize picker options and tool tallies. P1 — windowing/lazy rendering: - window trend bars, file-activity rows, timing-strip segments (dense gradient) and the browser DNA feed; off-screen rows use content-visibility; - lazy-mount the browser card and the below-fold event/file row behind height-matched skeletons, mounted on an idle slice / near the viewport; - window DNA bands on long total-mode logs, tighten the DNA window, sample per-bar gradient stops, and keep DNA+delta eager so its churn axis stays exact; - force-mount + reveal for stats file-pill jumps into the lazy row. Rendering-only by construction: axes, tooltips, ordering, DNA hit-testing and every readout still come from the full model; scrolling only changes which rows are in the DOM. Measured on a 987-step session: mount 986-1251ms -> ~510ms, first paint 480-592ms -> ~80ms, zero long tasks; tab DOM 15,929 -> 3,226 (below-fold mounted ~4.7k); hover 30 -> 0 long tasks; DNA toggle 875 -> ~230ms. Tests: new window/lazy/DNA/cache specs; lint, typecheck and 138-file 100% per-file coverage green. * fix(context): second-review follow-ups - keep DNA bands fresh per header epoch, and force eager DNA+delta on long logs so per-key churn axes stay exact; - correct windowed spacer heights for the container flex gap: the file list and the DNA feed now reproduce the full scroll height exactly; - re-translate memoized picker/tooltip labels when the locale changes; - re-observe lazy mounts across detach/reattach (a collapsed sidebar copy) via a callback ref, keep forced mounts for stats jumps, and clear the chart's local hover/DNA state on mouse leave; - shrink the window for a mount that already has DNA on, and add the async subagent count to the flow-measurement deps; - repair the skeleton background rule and restore the accidentally globalized .lc-heat-ctl .lc-card-sub selector.
This commit is contained in:
1 parent
2ee5c96c9d
commit
a694adfb34
32 files changed
+2142
-405
No files matched your search
@@ -1,9 +1,9 @@
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactElement, type ReactNode } from 'react'
|
||||
import { assembleCached } from './derivedCache'
|
||||
import { UNKNOWN_TOOL_SOURCE, type Category, type ContextHeaders, type ContextTimeline, type HeaderRecord, type HeaderTool, type RequestRecord, type SurfaceNode, type SystemPromptNode } from '../../shared/types'
|
||||
import { assemble } from '../assemble'
|
||||
import type { Assembled } from '../assemble'
|
||||
import { CATS, CAT_COLOR, partsOf } from '../categories'
|
||||
import { dnaBaseLabel, dnaOf } from '../dna'
|
||||
import { useRowWindow } from './useRowWindow'
|
||||
import type { DnaItem } from '../dna'
|
||||
import type { ContentFetcher, ConversationNodeLike, HeaderFetcher } from '../services'
|
||||
import type { ContextSettings, DefaultDeltaBase, DefaultToolSort } from '../settings'
|
||||
@@ -23,10 +23,16 @@ import { TextSection, schemaTextOf, RowToolbar, ToolSchema, toolErrOf } from './
|
||||
import { revealRowInCard } from '../revealScroll'
|
||||
import { NodeContent, byCatOf, nodeNameOf, msgKindsOf, reasoningTextOf, ROW_KINDS } from './browserNodes'
|
||||
import type { RowKind } from './browserNodes'
|
||||
import { DNA_MIN_BAND, countOf, lastOfTurn, stepStampOf } from './browserHelpers'
|
||||
import type { CatFocus } from './browserHelpers'
|
||||
export type { CatFocus } from './browserHelpers'
|
||||
export { stepStampOf } from './browserHelpers'
|
||||
import { MapIcon, makeBrowserTreemap } from './browserTreemap'
|
||||
import type { MapBlock } from '../treemap'
|
||||
|
||||
export interface ContextBrowserProps {
|
||||
/** Active UI locale; the picker labels are translated eagerly, so a switch must rebuild them. */
|
||||
locale?: string
|
||||
data: ContextTimeline
|
||||
headers: ContextHeaders | null
|
||||
/** Resolved by the caller so the `useChat` seat's hook order lives in exactly one place. */
|
||||
@@ -58,41 +64,6 @@ export interface ContextBrowserProps {
|
||||
}
|
||||
|
||||
|
||||
export interface CatFocus {
|
||||
cat: string
|
||||
kind?: RowKind
|
||||
}
|
||||
|
||||
function countOf(asm: Assembled, byCat: Partial<Record<Category, SurfaceNode[]>>, c: string): number {
|
||||
if (c === 'system') return asm.system !== null ? 1 : 0
|
||||
if (c === 'tools') return asm.header !== null ? asm.header.tools.length : 0
|
||||
return byCat[c as Category]?.length ?? 0
|
||||
}
|
||||
|
||||
function lastOfTurn(requests: RequestRecord[], turn: number): RequestRecord | null {
|
||||
for (let i = requests.length - 1; i >= 0; i--) if ((requests[i].turn ?? 0) === turn) return requests[i]
|
||||
return null
|
||||
}
|
||||
|
||||
/** The turn/step marking where a surface item entered the context: the FIRST request logged after the item's seq is
|
||||
* the first that carried it (assemble's inclusion rule is `n.seq < R.seq`); no following request stamps null. */
|
||||
export function stepStampOf(requests: RequestRecord[]): (itemSeq: number) => { turn: number; step: number } | null {
|
||||
// Sort a copy: the wire arrives in append order, but the derivation must not depend on it (restored rows can re-order).
|
||||
const sorted = requests.slice().sort((a, b) => a.seq - b.seq)
|
||||
return (itemSeq) => {
|
||||
for (const r of sorted) {
|
||||
if (r.seq > itemSeq) {
|
||||
return typeof r.turn === 'number' && typeof r.step === 'number' ? { turn: r.turn, step: r.step } : null
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Bands keep at least this share of the occupied region, so a tiny item stays a hoverable/clickable filament
|
||||
* instead of a sub-pixel sliver; tooltips still report true shares. */
|
||||
const DNA_MIN_BAND = 0.35
|
||||
|
||||
export function makeContextBrowser(
|
||||
kit: ViewKit,
|
||||
StackedBar: (props: StackedBarProps) => ReactElement,
|
||||
@@ -171,6 +142,11 @@ export function makeContextBrowser(
|
||||
setOpenElem(null)
|
||||
}, [pinSeq, onOpenCat])
|
||||
const rootRef = useRef<HTMLDivElement | null>(null)
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null)
|
||||
// The DNA flat feed can carry thousands of sub-row-sized items; the rows region is a fixed-pitch scroller, so
|
||||
// only the visible window is rendered once the card is wide enough for single-line rows.
|
||||
const [flatWide, setFlatWide] = useState(false)
|
||||
const [flatReady, setFlatReady] = useState(false)
|
||||
const focusScrollRef = useRef(false)
|
||||
const nodeFocus = props.nodeFocus
|
||||
useEffect(() => {
|
||||
@@ -206,6 +182,15 @@ export function makeContextBrowser(
|
||||
|
||||
const requests = data.requests
|
||||
const stepsOf = useMemo(() => turnStepsOf(requests), [requests])
|
||||
// One <option> per retained request: rebuilt only when the records or locale inputs change, never on hover.
|
||||
const pickOptions = useMemo(
|
||||
() => requests.slice().reverse().map(r => (
|
||||
<option key={r.seq} value={String(r.seq)}>
|
||||
{t('detail.step', { t: r.turn ?? 0, s: r.step ?? 0, n: stepsOf(r.turn) })}
|
||||
</option>
|
||||
)),
|
||||
[requests, stepsOf, t, props.locale],
|
||||
)
|
||||
const stampOf = useMemo(() => stepStampOf(requests), [requests])
|
||||
// The live tail — items no logged request carries yet — rides the NEXT request, following the harness's step loop
|
||||
// (`core/agent-loop`'s agent.ts): a newest user message opens a new turn, a text-only final reply ends the turn,
|
||||
@@ -246,7 +231,9 @@ export function makeContextBrowser(
|
||||
const linkKey = linked && props.hoverKey !== null && props.hoverKey !== 'free'
|
||||
? props.hoverKey
|
||||
: null
|
||||
const view = assemble(data, headers, seq)
|
||||
// assemble() is a pure walk over the served projection; memoize it per (projection, step) so a hover
|
||||
// preview only pays for the steps it actually shows.
|
||||
const view = useMemo(() => assembleCached(data, headers, seq), [data, headers, seq])
|
||||
|
||||
/** The tools group's element keys: the group key itself or any exact tool schema key. */
|
||||
const toolsElemOpen = (): boolean => openElem === 'tools' || (openElem !== null && openElem.startsWith('tool:'))
|
||||
@@ -292,7 +279,10 @@ export function makeContextBrowser(
|
||||
? deltaBase === 'step' ? lastReq : lastReq === null ? null : turnBaseOf(lastReq)
|
||||
: deltaBase === 'turn' ? turnBaseOf(req) : prevRecordOf(req)
|
||||
const zeroBase = refReq === null && lastReq !== null
|
||||
const prevView = refReq !== null ? assemble(data, headers, refReq.seq) : null
|
||||
const prevView = useMemo(
|
||||
() => (refReq !== null ? assembleCached(data, headers, refReq.seq) : null),
|
||||
[data, headers, refReq],
|
||||
)
|
||||
const prevByCat = prevView !== null ? byCatOf(prevView) : null
|
||||
|
||||
const byCat = byCatOf(view)
|
||||
@@ -766,20 +756,38 @@ export function makeContextBrowser(
|
||||
<RowToolbar value={rowQuery} placeholder={t('browser.search.flat')} onChange={setRowQuery} />
|
||||
)
|
||||
|
||||
const flatRows = (): ReactElement => {
|
||||
/* v8 ignore start -- jsdom has no layout/ResizeObserver: the wide gate and the first-paint skeleton are live-only. */
|
||||
useEffect(() => {
|
||||
if (!dna) {
|
||||
setFlatReady(false)
|
||||
return
|
||||
}
|
||||
const el = scrollRef.current
|
||||
if (el === null) return
|
||||
const sync = (): void => { setFlatWide(el.clientWidth >= 380) }
|
||||
sync()
|
||||
setFlatReady(true)
|
||||
if (typeof ResizeObserver !== 'function') return
|
||||
const observer = new ResizeObserver(sync)
|
||||
observer.observe(el)
|
||||
return () => { observer.disconnect() }
|
||||
}, [dna])
|
||||
/* v8 ignore stop */
|
||||
|
||||
interface FlatEntry {
|
||||
r: NodeRow
|
||||
pos: number
|
||||
}
|
||||
/** Only the visible feed slice becomes React elements: a long session carries over a thousand rows, and
|
||||
* building them all just to render ~30 was the DNA toggle's dominant cost. */
|
||||
const flatRows = (): { entries: FlatEntry[]; tail: ReactElement[]; empty: boolean } => {
|
||||
const q = rowQuery.trim().toLowerCase()
|
||||
const rows: ReactElement[] = []
|
||||
// Positions count ascending in reading order; reversing the array must not renumber them.
|
||||
const flow = view.nodes.map((n, i) => ({ r: nodeRowOf(n), pos: i + 1 })).reverse()
|
||||
for (const { r, pos } of flow) {
|
||||
const entries: FlatEntry[] = []
|
||||
for (let i = view.nodes.length - 1; i >= 0; i--) {
|
||||
const r = nodeRowOf(view.nodes[i])
|
||||
if (q !== '' && !rowMatches(r, q)) continue
|
||||
rows.push(messageRow(r, (
|
||||
<>
|
||||
{catChip(r.n.cat)}
|
||||
<span className="lc-br-pos">#{fmt(pos)}</span>
|
||||
{rowTagNode(r.tag, r.tagNames)}
|
||||
</>
|
||||
), (over) => { setRowDnaKey(over ? 'n' + String(r.n.seq) : null) }))
|
||||
entries.push({ r, pos: i + 1 })
|
||||
}
|
||||
// An empty q matches everything (String.includes('') is true), so the lens needs no short-circuit arms.
|
||||
// A header-less surface (no epoch served) renders message bands only, so its feed carries no header rows.
|
||||
@@ -788,15 +796,16 @@ export function makeContextBrowser(
|
||||
const sys = view.system
|
||||
const sysText = sys !== null ? headerContent.get(sys.seq)?.system ?? '' : ''
|
||||
const sysHit = sys !== null && (systemPreviewOf(sys).toLowerCase().includes(q) || sysText.toLowerCase().includes(q))
|
||||
if (view.header !== null && toolsHit) rows.push(toolsGroupRow(view.header))
|
||||
if (sys !== null && sysHit) rows.push(systemRow(sys))
|
||||
return (
|
||||
<div className="lc-br-flat">
|
||||
{rows}
|
||||
{rows.length === 0 ? <div className="lc-br-note">{t('browser.rowNoMatch')}</div> : null}
|
||||
</div>
|
||||
)
|
||||
const tail: ReactElement[] = []
|
||||
if (view.header !== null && toolsHit) tail.push(toolsGroupRow(view.header))
|
||||
if (sys !== null && sysHit) tail.push(systemRow(sys))
|
||||
return { entries, tail, empty: entries.length === 0 && tail.length === 0 }
|
||||
}
|
||||
const flatRowsModel = dna && flatReady ? flatRows() : null
|
||||
// 28px row + 2px flat gap: the pad formula reproduces the full feed height while only the viewport renders.
|
||||
const flatRange = useRowWindow(scrollRef, flatRowsModel?.entries.length ?? 0, 30, dna && flatReady && flatWide && openElem === null)
|
||||
// A non-DNA render never shows this model; the local keeps the JSX branch-free (the DNA branch owns the rows).
|
||||
const flat = flatRowsModel ?? { entries: [] as FlatEntry[], tail: [] as ReactElement[], empty: true }
|
||||
|
||||
return (
|
||||
<div className="lc-card lc-br" ref={rootRef}>
|
||||
@@ -834,11 +843,7 @@ export function makeContextBrowser(
|
||||
onChange={(e) => { pick(e.target.value) }}
|
||||
>
|
||||
<option value="live">{t('browser.live')}</option>
|
||||
{requests.slice().reverse().map(r => (
|
||||
<option key={r.seq} value={String(r.seq)}>
|
||||
{t('detail.step', { t: r.turn ?? 0, s: r.step ?? 0, n: stepsOf(r.turn) })}
|
||||
</option>
|
||||
))}
|
||||
{pickOptions}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -913,9 +918,29 @@ export function makeContextBrowser(
|
||||
)
|
||||
: null}
|
||||
|
||||
<div className="lc-br-scroll">
|
||||
<div className="lc-br-scroll" ref={scrollRef}>
|
||||
{dna
|
||||
? flatRows()
|
||||
? (!flatReady
|
||||
/* v8 ignore next 2 -- first paint only; jsdom flushes effect work before assertions. */
|
||||
? <div className="lc-br-flat" style={{ minHeight: 320 }} aria-hidden="true" />
|
||||
: (
|
||||
<div className="lc-br-flat">
|
||||
{flatRange.from > 0 ? <div className="lc-row-pad" style={{ height: Math.max(0, flatRange.from * 30 - 2) }} aria-hidden="true" /> : null}
|
||||
{flat.entries.slice(flatRange.from, flatRange.to + 1).map(({ r, pos }) => messageRow(r, (
|
||||
<>
|
||||
{catChip(r.n.cat)}
|
||||
<span className="lc-br-pos">#{fmt(pos)}</span>
|
||||
{rowTagNode(r.tag, r.tagNames)}
|
||||
</>
|
||||
), (over) => { setRowDnaKey(over ? 'n' + String(r.n.seq) : null) }))}
|
||||
{flatRange.to < flat.entries.length - 1
|
||||
? <div className="lc-row-pad" style={{ height: Math.max(0, (flat.entries.length - 1 - flatRange.to) * 30 - 2) }} aria-hidden="true" />
|
||||
: null}
|
||||
{/* Header groups are the feed's tail: render them AFTER the bottom pad so they stay at the end. */}
|
||||
{flat.tail}
|
||||
{flat.empty ? <div className="lc-br-note">{t('browser.rowNoMatch')}</div> : null}
|
||||
</div>
|
||||
))
|
||||
: (
|
||||
<div className="lc-br-cats">
|
||||
{CATS.map((c) => {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { Assembled } from '../assemble'
|
||||
import type { Category, RequestRecord, SurfaceNode } from '../../shared/types'
|
||||
import type { RowKind } from './browserNodes'
|
||||
|
||||
export interface CatFocus {
|
||||
cat: string
|
||||
kind?: RowKind
|
||||
}
|
||||
|
||||
export function countOf(asm: Assembled, byCat: Partial<Record<Category, SurfaceNode[]>>, c: string): number {
|
||||
if (c === 'system') return asm.system !== null ? 1 : 0
|
||||
if (c === 'tools') return asm.header !== null ? asm.header.tools.length : 0
|
||||
return byCat[c as Category]?.length ?? 0
|
||||
}
|
||||
|
||||
export function lastOfTurn(requests: RequestRecord[], turn: number): RequestRecord | null {
|
||||
for (let i = requests.length - 1; i >= 0; i--) if ((requests[i].turn ?? 0) === turn) return requests[i]
|
||||
return null
|
||||
}
|
||||
|
||||
/** The turn/step marking where a surface item entered the context: the FIRST request logged after the item's seq is
|
||||
* the first that carried it (assemble's inclusion rule is `n.seq < R.seq`); no following request stamps null. */
|
||||
export function stepStampOf(requests: RequestRecord[]): (itemSeq: number) => { turn: number; step: number } | null {
|
||||
// Sort a copy: the wire arrives in append order, but the derivation must not depend on it (restored rows can re-order).
|
||||
const sorted = requests.slice().sort((a, b) => a.seq - b.seq)
|
||||
return (itemSeq) => {
|
||||
for (const r of sorted) {
|
||||
if (r.seq > itemSeq) {
|
||||
return typeof r.turn === 'number' && typeof r.step === 'number' ? { turn: r.turn, step: r.step } : null
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Bands keep at least this share of the occupied region, so a tiny item stays a hoverable/clickable filament
|
||||
* instead of a sub-pixel sliver; tooltips still report true shares. */
|
||||
export const DNA_MIN_BAND = 0.35
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createElement as h, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactElement, type ReactNode } from 'react'
|
||||
import { createElement as h, startTransition, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactElement, type ReactNode } from 'react'
|
||||
import type { Category, ContextEventRecord, RequestRecord, SurfaceNode } from '../../shared/types'
|
||||
import { CATS } from '../categories'
|
||||
import { briefNodes, briefOf } from '../brief'
|
||||
@@ -35,8 +35,10 @@ import { makeStatsTokens } from './statsTokens'
|
||||
import { makeLegend, makeStackedBar } from './stackedBar'
|
||||
import { aggregateByTurn, attachMarkers, jumpTargetOf, makeTrendChart, turnStepsOf } from './trendChart'
|
||||
import { makeTrendOverview } from './trendOverview'
|
||||
import { assemble } from '../assemble'
|
||||
import { cachedByData, assembleCached } from './derivedCache'
|
||||
import { useLazyMount } from './useLazyMount'
|
||||
import { trendBandsOf } from '../dna'
|
||||
import type { TrendBand } from '../dna'
|
||||
|
||||
import { activateViewTab, openContextSidebar, subscribeContextFocus, takeContextFocus } from '../viewFocus'
|
||||
import { revealInScrollParent } from '../revealScroll'
|
||||
@@ -47,6 +49,11 @@ import { makeErrorBoundary } from './errorBoundary'
|
||||
const viewScroll = new Map<string, number>()
|
||||
|
||||
const EVENT_KINDS = ['inject', 'compaction', 'prune', 'model', 'mode'] as const
|
||||
/** Ordinary sweeps coalesce on this trailing delay; DNA bars are heavier and wait longer for the pointer to rest. */
|
||||
const HOVER_DELAY_MS = 140
|
||||
const DNA_HOVER_DELAY = 220
|
||||
/** Above this many retained steps the DNA bands are built lazily per rendered window. */
|
||||
const DNA_LAZY_MIN = 512
|
||||
|
||||
/** Scroll a landed-on card to the top of the page's scrollport: the stats jumps and the events card's injection
|
||||
* rows land on the browser, the stats file pills on the File Activity card, and no landing may stay below the fold. */
|
||||
@@ -160,6 +167,71 @@ export function makeContextView(
|
||||
const [jumpSeq, setJumpSeq] = useState<number | null>(null)
|
||||
const [hoverCat, setHoverCat] = useState<string | null>(null)
|
||||
const [focusCat, setFocusCat] = useState<string | null>(null)
|
||||
// Sidebar-hosted copies stay mounted while the sidebar is collapsed (the dock only hides them): track real
|
||||
// visibility so a hidden copy stops rendering its cards and the page never pays for two full context views.
|
||||
const [sidebarVisible, setSidebarVisible] = useState(true)
|
||||
// Pointer-driven hovers are transient: they must never block the pointer. The chart keeps its own immediate
|
||||
// hover state, while the rest of the view (browser preview, file activity) follows in a transition so a
|
||||
// pointer sweep cannot stack synchronous full-view renders.
|
||||
const hoverTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const hoverPending = useRef<number | null>(null)
|
||||
const hoverAt = useRef(0)
|
||||
const clearHoverTimer = useCallback((): void => {
|
||||
if (hoverTimer.current !== null) {
|
||||
clearTimeout(hoverTimer.current)
|
||||
hoverTimer.current = null
|
||||
}
|
||||
}, [])
|
||||
useEffect(() => clearHoverTimer, [clearHoverTimer])
|
||||
const handleHover = useCallback((seq: number | null): void => {
|
||||
hoverPending.current = seq
|
||||
if (seq === null) {
|
||||
clearHoverTimer()
|
||||
hoverAt.current = 0
|
||||
setHoveredSeq(null)
|
||||
return
|
||||
}
|
||||
const now = performance.now()
|
||||
// First entry (or a deliberate slow move) previews immediately; a pointer sweep keeps only the trailing
|
||||
// value, so the browser preview cannot force a layout per pointer event while the chart stays instant.
|
||||
// DNA bars carry thousands of bands: previewing every bar enter made each sweep cost a full view render,
|
||||
// so DNA mode only follows once the pointer rests (the chart's own hover stays instant).
|
||||
if (!dna && now - hoverAt.current > HOVER_DELAY_MS) {
|
||||
clearHoverTimer()
|
||||
hoverAt.current = now
|
||||
setHoveredSeq(seq)
|
||||
return
|
||||
}
|
||||
hoverAt.current = now
|
||||
clearHoverTimer()
|
||||
hoverTimer.current = setTimeout(() => {
|
||||
hoverTimer.current = null
|
||||
const trailing = seq
|
||||
startTransition(() => {
|
||||
/* v8 ignore next 2 -- the timer is cleared by a reset; the guard only covers a transition already
|
||||
queued behind that reset, which jsdom's synchronous act cannot interleave. */
|
||||
if (hoverPending.current === trailing) setHoveredSeq(trailing)
|
||||
})
|
||||
}, dna ? DNA_HOVER_DELAY : HOVER_DELAY_MS)
|
||||
}, [clearHoverTimer, dna])
|
||||
const handleHoverTurn = useCallback((turn: number | null): void => {
|
||||
startTransition(() => { setHoverTurn(turn) })
|
||||
}, [])
|
||||
const handleHoverKey = useCallback((key: string | null): void => {
|
||||
startTransition(() => { setHoverCat(key) })
|
||||
}, [])
|
||||
/* v8 ignore start -- jsdom has neither IntersectionObserver nor layout: the gate is verified in the live page. */
|
||||
useEffect(() => {
|
||||
if (!inSidebar || typeof IntersectionObserver !== 'function') return
|
||||
const el = rootRef.current
|
||||
if (el === null) return
|
||||
const observer = new IntersectionObserver(([entry]) => {
|
||||
setSidebarVisible(entry.isIntersecting)
|
||||
}, { threshold: 0 })
|
||||
observer.observe(el)
|
||||
return () => { observer.disconnect() }
|
||||
}, [inSidebar, data])
|
||||
/* v8 ignore stop */
|
||||
const [pickedKinds, setPickedKinds] = useState<string[]>([...EVENT_KINDS])
|
||||
const toggleKind = (k: string) => {
|
||||
setPickedKinds((p) => {
|
||||
@@ -204,11 +276,18 @@ export function makeContextView(
|
||||
const [filterFocus, setFilterFocus] = useState<FileFilter | null>(null)
|
||||
const clearFilterFocus = useCallback(() => { setFilterFocus(null) }, [])
|
||||
|
||||
// The browser column's height cap (browserCapOf), re-measured per render and followed by a
|
||||
// ResizeObserver: pane drags and card reflows change no prop.
|
||||
// Below-the-fold cards: keep the first commit to the cards the reader actually sees, then mount the event/file
|
||||
// row in an idle slice once it nears the conversation viewport. The two skeleton cards reserve the row height.
|
||||
const [belowFoldRef, belowFoldMounted] = useLazyMount(filterFocus !== null)
|
||||
// The browser card is visible but heavy (assembled rows for the current step): paint its card-shaped skeleton
|
||||
// in the first commit, then fill it in the next idle slice so the open's commit stays small.
|
||||
const [browserRef, browserMounted] = useLazyMount()
|
||||
|
||||
// The browser column's height cap (browserCapOf), re-measured for new data and ResizeObserver ticks:
|
||||
// pane drags and card reflows change no prop.
|
||||
const leftColRef = useRef<HTMLDivElement | null>(null)
|
||||
const [browserCap, setBrowserCap] = useState<number | null>(null)
|
||||
const [, setCapTick] = useState(0)
|
||||
const [capTick, setCapTick] = useState(0)
|
||||
useLayoutEffect(() => {
|
||||
const col = leftColRef.current
|
||||
/* v8 ignore next 1 -- the loading frame renders no left column; every carded render attaches it. */
|
||||
@@ -216,7 +295,9 @@ export function makeContextView(
|
||||
// The column renders exactly the composition + trend cards.
|
||||
const next = browserCapOf([...col.children].map(el => el.getBoundingClientRect()))
|
||||
setBrowserCap(prev => (prev === next ? prev : next))
|
||||
})
|
||||
// Hover/selection commits change no card geometry: only a new projection or a ResizeObserver tick
|
||||
// needs a fresh measurement, so a pointer sweep never flushes layout here.
|
||||
}, [data, capTick])
|
||||
/* v8 ignore start -- jsdom has neither ResizeObserver nor layout: the cap's math is pinned by browserCapOf's unit tests. */
|
||||
useEffect(() => {
|
||||
const col = leftColRef.current
|
||||
@@ -231,8 +312,9 @@ export function makeContextView(
|
||||
// a file kind on the File Activity card's matching chip.
|
||||
const onJump = useCallback((jump: StatsJump): void => {
|
||||
if (jump === 'read' || jump === 'write' || jump === 'search' || jump === 'image') {
|
||||
// The below-fold row may still be lazy: setting the focus forces its mount, and the effect below
|
||||
// reveals the card once it exists.
|
||||
setFilterFocus(jump)
|
||||
revealCard(rootRef.current, '.lc-fa-card')
|
||||
return
|
||||
}
|
||||
setCatFocus(jump === 'skills'
|
||||
@@ -294,14 +376,45 @@ export function makeContextView(
|
||||
// re-renders never reassemble.
|
||||
/* v8 ignore next 1 -- a missing projection returns the loading screen before the trend card
|
||||
(and its DNA toggle) renders, so `dna && data === null` cannot occur. */
|
||||
// A long log carries thousands of requests: assembling every step's context up front blocked the DNA toggle.
|
||||
// Small logs and delta mode keep the eager array; large total-mode logs hand the chart a per-seq accessor so only
|
||||
// the rendered window assembles. Delta needs every bar's per-key change: net totals cannot bound key churn.
|
||||
/* v8 ignore next 1 -- the sidebar-visible operand is only reachable in a live dock (jsdom has no IO);
|
||||
the hidden-copy path is verified on the live page and every main-host test covers the short-circuit. */
|
||||
const dnaAllowed = dna && data !== null && (!inSidebar || sidebarVisible)
|
||||
const dnaEager = dnaAllowed && (displayRequests.length <= DNA_LAZY_MIN || trendMode === 'delta')
|
||||
// Bands depend on the header epoch as well as the timeline; the composite owner keeps a late epoch from
|
||||
// reusing bands assembled without its system prompt/tool schemas.
|
||||
const dnaCacheOwner = useMemo(() => ({ data, headers }), [data, headers])
|
||||
const dnaBands = useMemo(
|
||||
() => (dna && data !== null ? displayRequests.map(req => trendBandsOf(assemble(data, headers, req.seq))) : null),
|
||||
[dna, data, headers, displayRequests],
|
||||
() => (dnaEager
|
||||
? cachedByData(dnaCacheOwner, `dna:${granularity}`, () => displayRequests.map(req => trendBandsOf(assembleCached(data, headers, req.seq))))
|
||||
: null),
|
||||
[dnaEager, dnaCacheOwner, data, headers, displayRequests, granularity],
|
||||
)
|
||||
const dnaAt = useMemo(() => {
|
||||
if (!dnaAllowed || displayRequests.length <= DNA_LAZY_MIN || trendMode === 'delta') return null
|
||||
const bandsBySeq = new Map<number, TrendBand[]>()
|
||||
return (seq: number): TrendBand[] => {
|
||||
let bands = bandsBySeq.get(seq)
|
||||
/* v8 ignore next 1 -- the chart's own per-seq cache makes a repeated lookup here defensive only. */
|
||||
if (bands === undefined) {
|
||||
bands = trendBandsOf(assembleCached(data, headers, seq))
|
||||
bandsBySeq.set(seq, bands)
|
||||
}
|
||||
return bands
|
||||
}
|
||||
}, [dnaAllowed, data, headers, displayRequests.length, trendMode])
|
||||
const revealBand = useCallback((seq: number, band: { key: string; cat: Category | 'system' | 'tools' }): void => {
|
||||
setNodeFocus({ step: seq, key: band.key, cat: band.cat })
|
||||
}, [])
|
||||
|
||||
// A stats file-pill jump can target the lazy below-fold row: reveal it once the forced mount lands.
|
||||
useEffect(() => {
|
||||
if (filterFocus === null || !belowFoldMounted) return
|
||||
revealCard(rootRef.current, '.lc-fa-card')
|
||||
}, [filterFocus, belowFoldMounted])
|
||||
|
||||
// Chat → Context jump, leg 1: pick up the relay's request once per mount and on every later record (the sidebar
|
||||
// landing keeps this view mounted).
|
||||
useEffect(() => {
|
||||
@@ -331,7 +444,7 @@ export function makeContextView(
|
||||
if (anchor !== null) revealInScrollParent(anchor)
|
||||
}, [jumpSeq, data, requests, detailReady])
|
||||
|
||||
const briefList = useMemo(() => (data ? briefNodes(data) : []), [data])
|
||||
const briefList = useMemo(() => (data ? cachedByData(data, 'briefList', () => briefNodes(data)) : []), [data])
|
||||
const convNodes = conversationNodesOf(props)
|
||||
const bySeq = useMemo(() => {
|
||||
const m = new Map<number, ConversationNodeLike>()
|
||||
@@ -363,14 +476,18 @@ export function makeContextView(
|
||||
const convOf = useCallback((seq: number): ConversationNodeLike | undefined => bySeq.get(seq), [bySeq])
|
||||
|
||||
// Memoized on the walk's inputs only: hover/select renders elsewhere leave every input reference untouched, so the walk is skipped.
|
||||
// The live chat-node map moves independently of the projection, so it joins the cache owner.
|
||||
const fileCacheOwner = useMemo(() => ({ data, bySeq }), [data, bySeq])
|
||||
const fileActivity = useMemo(
|
||||
() => {
|
||||
if (data !== null && data.fileOps !== undefined) {
|
||||
return activityOfOps(data.fileOps, data.archive, filesBefore)
|
||||
}
|
||||
return activityOf(briefList, convOf, filesBefore)
|
||||
if (data === null) return activityOf(briefList, convOf, filesBefore)
|
||||
return cachedByData(fileCacheOwner, `files:${String(filesBefore)}`, () => (
|
||||
data.fileOps !== undefined
|
||||
? activityOfOps(data.fileOps, data.archive, filesBefore)
|
||||
: activityOf(briefList, convOf, filesBefore)
|
||||
))
|
||||
},
|
||||
[data, briefList, convOf, filesBefore],
|
||||
[fileCacheOwner, data, briefList, convOf, filesBefore],
|
||||
)
|
||||
// The stats card's pill tallies; the null bound means the WHOLE session.
|
||||
const ioTotals = useMemo(
|
||||
@@ -382,7 +499,7 @@ export function makeContextView(
|
||||
},
|
||||
[data, briefList, convOf],
|
||||
)
|
||||
const toolTally = useMemo(() => (data ? toolTallyOf(data.nodes) : []), [data])
|
||||
const toolTally = useMemo(() => (data ? cachedByData(data, 'toolTally', () => toolTallyOf(data.nodes)) : []), [data])
|
||||
// Read per render off the observable snapshot, so the next projection push re-renders with the current workspace root.
|
||||
const workspace = typeof ctx.get === 'function' ? workspaceOf(ctx, typeof sessionId === 'string' ? sessionId : undefined) : undefined
|
||||
// The capability is an async RPC answer, so file names stay inert until the probe settles.
|
||||
@@ -435,12 +552,13 @@ export function makeContextView(
|
||||
// (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) {
|
||||
if (data === null) return new Map<number, SurfaceNode>()
|
||||
return cachedByData(data, 'nodeOfSeq', () => {
|
||||
const m = new Map<number, SurfaceNode>()
|
||||
for (const n of data.nodes) m.set(n.seq, n)
|
||||
for (const n of data.archive) m.set(n.seq, n)
|
||||
}
|
||||
return m
|
||||
return m
|
||||
})
|
||||
}, [data])
|
||||
const focusInject = useCallback((ev: ContextEventRecord): void => {
|
||||
const node = nodeOfSeq.get(ev.seq)
|
||||
@@ -493,12 +611,15 @@ export function makeContextView(
|
||||
const subtitle = (data.model ?? '') + (data.provider ? ' · ' + data.provider : '')
|
||||
const gate = unsupportedOf(data.unsupported)
|
||||
|
||||
/* v8 ignore next 2 -- only a real dock collapse hides a sidebar copy; jsdom has no IntersectionObserver. */
|
||||
if (inSidebar && !sidebarVisible) return <div className="lc-root" ref={rootRef} />
|
||||
|
||||
const compositionCard = (
|
||||
<CurrentComposition
|
||||
head={head}
|
||||
subtitle={subtitle}
|
||||
hoverKey={hoverCat}
|
||||
onHoverKey={setHoverCat}
|
||||
onHoverKey={handleHoverKey}
|
||||
/>
|
||||
)
|
||||
// Both trend faces share the records + hover/select flow; only the overview's turn click pins instead of
|
||||
@@ -519,8 +640,8 @@ export function makeContextView(
|
||||
focusCat,
|
||||
durationCurve,
|
||||
onSelect: setSelectedSeq,
|
||||
onHover: setHoveredSeq,
|
||||
onHoverTurn: setHoverTurn,
|
||||
onHover: handleHover,
|
||||
onHoverTurn: handleHoverTurn,
|
||||
}
|
||||
const trendCard = (
|
||||
<div className="lc-card">
|
||||
@@ -619,6 +740,7 @@ export function makeContextView(
|
||||
focusTurn={focusTurn}
|
||||
adaptive={adaptive}
|
||||
dna={dnaBands}
|
||||
dnaAt={dnaAt}
|
||||
onPickBand={revealBand}
|
||||
onPickTurn={(turn) => { setGranularity('turn'); setFocusTurn(turn) }}
|
||||
onFocusTurnHandled={() => { setFocusTurn(null) }}
|
||||
@@ -636,7 +758,7 @@ export function makeContextView(
|
||||
stepsOf={stepsOf}
|
||||
onLocate={locateNode}
|
||||
hoverKey={trendHoverCat}
|
||||
onHoverKey={setHoverCat}
|
||||
onHoverKey={handleHoverKey}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -652,7 +774,7 @@ export function makeContextView(
|
||||
previewSeq={hoveredSeq}
|
||||
pinSeq={pinnedReq !== null ? pinnedReq.seq : null}
|
||||
hoverKey={hoverCat}
|
||||
onHoverKey={setHoverCat}
|
||||
onHoverKey={handleHoverKey}
|
||||
onOpenCat={setFocusCat}
|
||||
nodeFocus={nodeFocus}
|
||||
onNodeFocusHandled={clearNodeFocus}
|
||||
@@ -665,6 +787,7 @@ export function makeContextView(
|
||||
// The DNA switch is shared with the trend card: both toggles move as one.
|
||||
dna={dna}
|
||||
onDnaChange={setDna}
|
||||
locale={activeLocale}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -682,7 +805,7 @@ export function makeContextView(
|
||||
)}
|
||||
<div className="lc-cols lc-head">
|
||||
<StatsTokens usage={usage} current={data.current} breakdown={breakdown} />
|
||||
<StatsTiming timing={data.timing ?? null} spans={data.spans} />
|
||||
<StatsTiming timing={data.timing ?? null} spans={data.spans} locale={activeLocale} />
|
||||
</div>
|
||||
|
||||
<div className="lc-cols lc-cols-main">
|
||||
@@ -691,38 +814,44 @@ export function makeContextView(
|
||||
/context modal draws its own stack and fills its own card. The DNA feed lays out every
|
||||
message, so the card's rows region scrolls inside the capped card. */}
|
||||
<div
|
||||
ref={browserRef}
|
||||
className="lc-col lc-col-browser flex-1 min-w-[min(360px,100%)]"
|
||||
style={browserCap !== null ? { maxHeight: browserCap } : undefined}
|
||||
>{browserCard}</div>
|
||||
>{browserMounted ? browserCard : <div className="lc-card lc-deferred-skeleton" style={{ height: '100%', minHeight: 320 }} aria-hidden="true" />}</div>
|
||||
</div>
|
||||
|
||||
<div className="lc-cols">
|
||||
<div className="lc-card lc-col flex-1 min-w-[min(360px,100%)]">
|
||||
<div className="lc-card-title">
|
||||
<span className="lc-card-title-text">{t('events.title')}</span>
|
||||
<div className="lc-kinds @max-[380px]/lc-card:flex-wrap">
|
||||
{EVENT_KINDS.map((k) => {
|
||||
const n = kindCounts[k]
|
||||
return (
|
||||
<button
|
||||
key={k}
|
||||
data-kind={k}
|
||||
className={'lc-gran-btn' + (pickedKinds.includes(k) ? ' lc-gran-on lc-kind-' + k : '')}
|
||||
onClick={() => { toggleKind(k) }}
|
||||
>
|
||||
{t('kind.' + k)}
|
||||
{n !== undefined ? <span className="lc-kind-n">{kit.fmt(n)}</span> : null}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
<div className="lc-cols" ref={belowFoldRef}>
|
||||
{belowFoldMounted ? (<>
|
||||
<div className="lc-card lc-col flex-1 min-w-[min(360px,100%)]">
|
||||
<div className="lc-card-title">
|
||||
<span className="lc-card-title-text">{t('events.title')}</span>
|
||||
<div className="lc-kinds @max-[380px]/lc-card:flex-wrap">
|
||||
{EVENT_KINDS.map((k) => {
|
||||
const n = kindCounts[k]
|
||||
return (
|
||||
<button
|
||||
key={k}
|
||||
data-kind={k}
|
||||
className={'lc-gran-btn' + (pickedKinds.includes(k) ? ' lc-gran-on lc-kind-' + k : '')}
|
||||
onClick={() => { toggleKind(k) }}
|
||||
>
|
||||
{t('kind.' + k)}
|
||||
{n !== undefined ? <span className="lc-kind-n">{kit.fmt(n)}</span> : null}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<EventList events={shownEvents} state={source.detailState} onRetry={source.retryDetail} onInjectFocus={focusInject} />
|
||||
</div>
|
||||
<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}
|
||||
filterFocus={filterFocus} onFilterFocusHandled={clearFilterFocus}
|
||||
state={source.detailState} onRetry={source.retryDetail} />
|
||||
<FileCard activity={fileActivity} scope={fileScope} workspace={workspace}
|
||||
onPreview={previewFile} onOpen={fileOpener} onLocate={locateFileOp}
|
||||
filterFocus={filterFocus} onFilterFocusHandled={clearFilterFocus}
|
||||
state={source.detailState} onRetry={source.retryDetail} />
|
||||
</>) : (<>
|
||||
<div className="lc-card lc-col flex-1 min-w-[min(360px,100%)] lc-deferred-skeleton" style={{ minHeight: 450 }} aria-hidden="true" />
|
||||
<div className="lc-card lc-col flex-1 min-w-[min(360px,100%)] lc-deferred-skeleton" style={{ minHeight: 450 }} aria-hidden="true" />
|
||||
</>)}
|
||||
</div>
|
||||
|
||||
{/* The card's standalone home is the Fleet tab; the right Sidebar's panel is too narrow to split, so it keeps the card inline. */}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { assemble } from '../assemble'
|
||||
import type { Assembled } from '../assemble'
|
||||
import type { ContextHeaders, ContextTimeline } from '../../shared/types'
|
||||
|
||||
/** Per-owner derived values. Keyed weakly by the served projection (or a composite owner) so a superseded cache
|
||||
* can be collected without a manual eviction pass. The detail store keeps at most a handful of payloads alive. */
|
||||
const derived = new WeakMap<object, Map<string, unknown>>()
|
||||
|
||||
/**
|
||||
* Reuse a pure derivation across component remounts (tab switches, sidebar reopen) while the same served
|
||||
* projection object is current. The value must be a pure function of the projection and `key`.
|
||||
*/
|
||||
export function cachedByData<T>(owner: object, key: string, compute: () => T): T {
|
||||
let values = derived.get(owner)
|
||||
if (values === undefined) {
|
||||
values = new Map()
|
||||
derived.set(owner, values)
|
||||
}
|
||||
const hit = values.get(key)
|
||||
if (hit !== undefined) return hit as T
|
||||
const value = compute()
|
||||
values.set(key, value)
|
||||
return value
|
||||
}
|
||||
|
||||
/** assemble() is a pure walk over the projection + header epoch; hover previews and remounts revisit the same steps. */
|
||||
const NO_HEADERS = {}
|
||||
const assembled = new WeakMap<ContextTimeline, WeakMap<object, Map<number | null, Assembled>>>()
|
||||
|
||||
export function assembleCached(data: ContextTimeline, headers: ContextHeaders | null, seq: number | null): Assembled {
|
||||
let byHeaders = assembled.get(data)
|
||||
if (byHeaders === undefined) {
|
||||
byHeaders = new WeakMap()
|
||||
assembled.set(data, byHeaders)
|
||||
}
|
||||
const headerKey = headers ?? NO_HEADERS
|
||||
let bySeq = byHeaders.get(headerKey)
|
||||
if (bySeq === undefined) {
|
||||
bySeq = new Map()
|
||||
byHeaders.set(headerKey, bySeq)
|
||||
}
|
||||
const hit = bySeq.get(seq)
|
||||
if (hit !== undefined) return hit
|
||||
const value = assemble(data, headers, seq)
|
||||
bySeq.set(seq, value)
|
||||
return value
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { memo, useEffect, useState, type ChangeEvent, type ComponentType, type MouseEvent, type ReactElement } from 'react'
|
||||
import { memo, useEffect, useRef, useState, type ChangeEvent, type ComponentType, type MouseEvent, type ReactElement } from 'react'
|
||||
import { absPathOf, baseNameOf, displayPathOf, glyphOf } from '../fileActivity'
|
||||
import type { FileActivity, FileEntry, FileOp, FileOpKind } from '../fileActivity'
|
||||
import type { ContextSettings, DefaultFileSort } from '../settings'
|
||||
import type { DetailState } from '../timelineSource'
|
||||
import type { ViewKit } from '../viewkit'
|
||||
import { makeDetailNote } from './detailNote'
|
||||
import { useRowWindow } from './useRowWindow'
|
||||
|
||||
export type FileFilter = 'all' | FileOpKind | 'image'
|
||||
|
||||
@@ -55,6 +56,14 @@ export function makeFileCard(kit: ViewKit, settings: ContextSettings): Component
|
||||
const [sort, setSort] = useState<DefaultFileSort>(() => settings.defaultFileSort())
|
||||
const [query, setQuery] = useState('')
|
||||
const [openPath, setOpenPath] = useState<string | null>(null)
|
||||
// The list lane is a fixed-height virtual scroller while no row is expanded and the card is wide enough for
|
||||
// single-line rows (narrow cards wrap, so their heights are not uniform and the list renders in full).
|
||||
const listRef = useRef<HTMLDivElement | null>(null)
|
||||
const [listWide, setListWide] = useState(false)
|
||||
// Row pitch (height + any gap) is measured from the full list before windowing: rows wrap on narrow cards, so a
|
||||
// hard-coded height would break the scroll geometry there (the gate below keeps narrow cards unwindowed).
|
||||
const [rowPitch, setRowPitch] = useState(0)
|
||||
const [rowGap, setRowGap] = useState(0)
|
||||
|
||||
// A stats-board jump re-points the chip band: the card drops its own lens and open row, so the landing is the
|
||||
// frontier the click promised. The request is handed back so an unchanged kind can fire again.
|
||||
@@ -90,6 +99,34 @@ export function makeFileCard(kit: ViewKit, settings: ContextSettings): Component
|
||||
? byName(a, b)
|
||||
: (a.path < b.path ? -1 : 1))
|
||||
|
||||
useEffect(() => {
|
||||
const el = listRef.current
|
||||
if (el === null) {
|
||||
setListWide(false)
|
||||
return
|
||||
}
|
||||
const sync = (): void => { setListWide(el.clientWidth >= 380) }
|
||||
sync()
|
||||
if (typeof ResizeObserver !== 'function') return
|
||||
/* v8 ignore start -- jsdom has neither a layout nor ResizeObserver; the live page covers a pane resize. */
|
||||
const observer = new ResizeObserver(sync)
|
||||
observer.observe(el)
|
||||
return () => { observer.disconnect() }
|
||||
/* v8 ignore stop */
|
||||
}, [shown.length > 0])
|
||||
useEffect(() => {
|
||||
const el = listRef.current
|
||||
if (el === null) return
|
||||
const items = el.querySelectorAll<HTMLElement>('.lc-fa-item')
|
||||
if (items.length < 2) return
|
||||
const first = items[0].getBoundingClientRect()
|
||||
const pitch = items[1].getBoundingClientRect().top - first.top
|
||||
if (Number.isFinite(pitch) && pitch > 4) {
|
||||
setRowPitch(Math.round(pitch))
|
||||
setRowGap(Math.max(0, Math.round(pitch - first.height)))
|
||||
}
|
||||
}, [shown.length, listWide, filter, query, sort, openPath])
|
||||
const rowRange = useRowWindow(listRef, shown.length, rowPitch, listWide && openPath === null && rowPitch > 0)
|
||||
const chips: { key: FileFilter; files: number; ops: number }[] = [
|
||||
{
|
||||
key: 'all',
|
||||
@@ -192,8 +229,9 @@ export function makeFileCard(kit: ViewKit, settings: ContextSettings): Component
|
||||
) : shown.length === 0 ? (
|
||||
<div className="lc-empty lc-lane">{t(activity.entries.length === 0 ? 'files.empty' : 'files.noMatch')}</div>
|
||||
) : (
|
||||
<div className="lc-fa-list">
|
||||
{shown.map((e) => {
|
||||
<div className="lc-fa-list" ref={listRef}>
|
||||
{rowRange.from > 0 ? <div className="lc-row-pad" style={{ height: Math.max(0, rowRange.from * rowPitch - rowGap) }} aria-hidden="true" /> : null}
|
||||
{shown.slice(rowRange.from, rowRange.to + 1).map((e) => {
|
||||
const open = openPath === e.path
|
||||
const display = displayOf(e)
|
||||
const trimmed = display.endsWith('/') ? display.slice(0, -1) : display
|
||||
@@ -284,6 +322,7 @@ export function makeFileCard(kit: ViewKit, settings: ContextSettings): Component
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{rowRange.to < shown.length - 1 ? <div className="lc-row-pad" style={{ height: Math.max(0, (shown.length - 1 - rowRange.to) * rowPitch - rowGap) }} aria-hidden="true" /> : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -181,11 +181,17 @@ export function makeStackedBar(kit: ViewKit): (props: StackedBarProps) => ReactE
|
||||
// keeps that loopless. Rects, not clientWidth: the latter truncates to a whole pixel, and the residue lands
|
||||
// straight back on the overhang. A pane drag that changes no prop leaves the shift as last measured.
|
||||
useLayoutEffect(() => {
|
||||
// No visible tip → no measurement. Chart hover commits re-render this bar with no tip; reading layout
|
||||
// here would flush the whole page's layout for nothing.
|
||||
if (tip === null) {
|
||||
setTipShift(prev => (prev === 0 ? prev : 0))
|
||||
return
|
||||
}
|
||||
/* v8 ignore next -- unreachable: the wrap commits its ref before layout effects run. */
|
||||
const wrapW = wrapRef.current === null ? 0 : wrapRef.current.getBoundingClientRect().width
|
||||
const tipW = tipRef.current === null ? 0 : tipRef.current.getBoundingClientRect().width
|
||||
setTipShift((prev) => {
|
||||
if (tip === null || wrapW <= 0 || tipW <= 0) return prev === 0 ? prev : 0
|
||||
if (wrapW <= 0 || tipW <= 0) return prev === 0 ? prev : 0
|
||||
const center = tip.leftPct / 100 * wrapW
|
||||
const leftOver = tipW / 2 - center
|
||||
const rightOver = center + tipW / 2 - wrapW
|
||||
|
||||
@@ -351,12 +351,12 @@ export function makeStatsContext(
|
||||
...notes(subDeepseek),
|
||||
subUnpriced ? <span key="unavailable">{t('stats.costUnavailable')}</span> : null,
|
||||
]
|
||||
// A no-deps layout effect re-measures after every render (a same-string bail-out keeps it loopless); the
|
||||
// Re-measures when the values that move the flow change (the same-string bail-out keeps it loopless); the
|
||||
// ResizeObserver tick covers pane drags, which change no prop.
|
||||
const flowRef = useRef<HTMLDivElement | null>(null)
|
||||
const nodeEls = useRef(new Map<FlowNodeKey, HTMLDivElement>())
|
||||
const [links, setLinks] = useState<FlowLink[]>([])
|
||||
const [, setResizeTick] = useState(0)
|
||||
const [resizeTick, setResizeTick] = useState(0)
|
||||
const nodeRef = (key: FlowNodeKey) => (el: HTMLDivElement | null): void => {
|
||||
if (el === null) nodeEls.current.delete(key)
|
||||
else nodeEls.current.set(key, el)
|
||||
@@ -377,7 +377,13 @@ export function makeStatsContext(
|
||||
}
|
||||
const next = measureFlow(boxes, root.clientWidth > FLOW_FOLD_PX)
|
||||
setLinks(prev => (prev.length === next.length && prev.every((l, i) => l.d === next[i].d && l.color === next[i].color) ? prev : next))
|
||||
})
|
||||
// Chart hover commits never move a flow node: only these inputs (or a ResizeObserver tick) can.
|
||||
}, [
|
||||
props.counts, props.humanInputs, props.answers, props.toolCalls, props.files, props.tools,
|
||||
props.cost, props.locale, props.sessionId, props.onTeamClick, sub.count,
|
||||
// A pane drag only ticks this state; the async subagent usage lands later and moves the flow's text widths.
|
||||
resizeTick, subUsage, book,
|
||||
])
|
||||
/* v8 ignore start -- jsdom has neither ResizeObserver nor layout: tests pin the geometry through measureFlow's unit tests. */
|
||||
useEffect(() => {
|
||||
const root = flowRef.current
|
||||
|
||||
@@ -55,10 +55,11 @@ export function makeStatsTiming(kit: ViewKit, Donut: (props: DonutProps) => Reac
|
||||
* slices). Absent on the overview's cross-session aggregate card and on
|
||||
* rows folded before the collection existed — the card then shows no strip. */
|
||||
spans?: TimingSpan[]
|
||||
locale?: string
|
||||
}) => ReactElement {
|
||||
const { t, fmt, fmtDuration, fmtShare } = kit
|
||||
const TimingStrip = makeTimingStrip(kit)
|
||||
return function StatsTiming(props: { timing: TimingTotals | null; spans?: TimingSpan[] }): ReactElement {
|
||||
return function StatsTiming(props: { timing: TimingTotals | null; spans?: TimingSpan[]; locale?: string }): ReactElement {
|
||||
// The legend row ↔ donut segment hover link (shared key, set from either side).
|
||||
const [hoverKey, setHoverKey] = useState<string | null>(null)
|
||||
const timing = props.timing
|
||||
@@ -201,7 +202,7 @@ export function makeStatsTiming(kit: ViewKit, Donut: (props: DonutProps) => Reac
|
||||
every span of its KIND plus the donut arc and the legend row,
|
||||
and a legend/donut hover lights the strip's matching spans. */}
|
||||
{props.spans !== undefined && props.spans.length > 0
|
||||
? <TimingStrip spans={props.spans} hoverKey={hoverKey} onHoverKey={setHoverKey} />
|
||||
? <TimingStrip spans={props.spans} hoverKey={hoverKey} onHoverKey={setHoverKey} locale={props.locale} />
|
||||
: null}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
* capped at eight stagger slots, so a packed strip already settled as a sweep.
|
||||
*/
|
||||
|
||||
import { useState, type ReactElement } from 'react'
|
||||
import { useMemo, useRef, useState, type ReactElement } from 'react'
|
||||
import type { TimingSpan } from '../../shared/types'
|
||||
import type { ViewKit } from '../viewkit'
|
||||
|
||||
@@ -48,6 +48,46 @@ const KIND_LABEL: Record<TimingSpan['kind'], string> = {
|
||||
/** The axis's fixed tick fractions: zero, the three quartiles, and the full span. */
|
||||
const TICK_FRACS: readonly number[] = [0, 0.25, 0.5, 0.75, 1]
|
||||
|
||||
/** Above this many bands the strip paints two gradient layers instead of one absolutely-positioned div per band
|
||||
* (a long session packs thousands of mostly sub-pixel bands; the full band model still drives hit-testing). */
|
||||
export const TIMING_DOM_MAX = 600
|
||||
|
||||
/** The band under a percentage position: the last band whose left edge has been reached. */
|
||||
export function timingBandAt(bands: readonly { leftPct: number; widthPct: number }[], pct: number): number {
|
||||
let lo = 0
|
||||
let hi = bands.length - 1
|
||||
let hit = 0
|
||||
while (lo <= hi) {
|
||||
const mid = (lo + hi) >>> 1
|
||||
if (bands[mid].leftPct <= pct) {
|
||||
hit = mid
|
||||
lo = mid + 1
|
||||
} else {
|
||||
hi = mid - 1
|
||||
}
|
||||
}
|
||||
return hit
|
||||
}
|
||||
|
||||
/** Hard-stop gradient for a band model: contiguous same-colour runs coalesce into one stop pair. */
|
||||
export function timingGradientOf(
|
||||
bands: readonly { kind: TimingSpan['kind']; leftPct: number; widthPct: number }[],
|
||||
only: TimingSpan['kind'] | null,
|
||||
): string {
|
||||
const runs: { color: string; start: number; end: number }[] = []
|
||||
for (const band of bands) {
|
||||
const color = only === null || band.kind === only ? TIMING_COLOR[band.kind] : 'transparent'
|
||||
const start = band.leftPct
|
||||
const end = band.leftPct + band.widthPct
|
||||
const last = runs.length > 0 ? runs[runs.length - 1] : null
|
||||
if (last !== null && last.color === color) last.end = end
|
||||
else runs.push({ color, start, end })
|
||||
}
|
||||
return 'linear-gradient(to right, ' + runs
|
||||
.map(r => `${r.color} ${r.start.toFixed(4)}% ${r.end.toFixed(4)}%`)
|
||||
.join(', ') + ')'
|
||||
}
|
||||
|
||||
/** One positioned band of the strip, precomputed from a sanitized span. */
|
||||
interface StripBand {
|
||||
key: number
|
||||
@@ -65,41 +105,70 @@ export function makeTimingStrip(kit: ViewKit): (props: {
|
||||
hoverKey?: string | null
|
||||
/** Hover relay; absent renders the bands inert. */
|
||||
onHoverKey?: (key: string | null) => void
|
||||
locale?: string
|
||||
}) => ReactElement | null {
|
||||
const { t, fmtDuration, fmtTime } = kit
|
||||
return function TimingStrip(props: {
|
||||
spans: TimingSpan[]
|
||||
hoverKey?: string | null
|
||||
onHoverKey?: (key: string | null) => void
|
||||
/** Active UI locale: tooltip labels are translated eagerly, so a switch must rebuild the band model. */
|
||||
locale?: string
|
||||
}): ReactElement | null {
|
||||
// The strip's own pointer hover: the floating tip answers this alone.
|
||||
const [tipKey, setTipKey] = useState<number | null>(null)
|
||||
// Sanitize the untrusted collection: an unknown kind or an unusable/
|
||||
// inverted instant drops the span whole instead of poisoning the axis.
|
||||
const valid = props.spans.filter(s =>
|
||||
Object.hasOwn(TIMING_COLOR, s.kind)
|
||||
&& Number.isFinite(s.start)
|
||||
&& Number.isFinite(s.end)
|
||||
&& s.end > s.start)
|
||||
if (valid.length === 0) return null
|
||||
// The axis domain: 0 → the session's cumulative ACTIVE time (the spans
|
||||
// packed gapless in log order — idle time between them takes no track).
|
||||
let total = 0
|
||||
for (const s of valid) total += s.end - s.start
|
||||
let cursor = 0
|
||||
const bands: StripBand[] = valid.map((s, i) => {
|
||||
const leftPct = cursor / total * 100
|
||||
cursor += s.end - s.start
|
||||
return {
|
||||
key: i,
|
||||
kind: s.kind,
|
||||
leftPct,
|
||||
widthPct: (s.end - s.start) / total * 100,
|
||||
tip: `${t(KIND_LABEL[s.kind])} ${fmtDuration(s.end - s.start)} · ${fmtTime(s.start)}`,
|
||||
}
|
||||
})
|
||||
const model = useMemo(() => {
|
||||
const valid = props.spans.filter(s =>
|
||||
Object.hasOwn(TIMING_COLOR, s.kind)
|
||||
&& Number.isFinite(s.start)
|
||||
&& Number.isFinite(s.end)
|
||||
&& s.end > s.start)
|
||||
if (valid.length === 0) return null
|
||||
// The axis domain: 0 → the session's cumulative ACTIVE time (the spans
|
||||
// packed gapless in log order — idle time between them takes no track).
|
||||
let total = 0
|
||||
for (const s of valid) total += s.end - s.start
|
||||
let cursor = 0
|
||||
const bands: StripBand[] = valid.map((s, i) => {
|
||||
const leftPct = cursor / total * 100
|
||||
cursor += s.end - s.start
|
||||
return {
|
||||
key: i,
|
||||
kind: s.kind,
|
||||
leftPct,
|
||||
widthPct: (s.end - s.start) / total * 100,
|
||||
tip: `${t(KIND_LABEL[s.kind])} ${fmtDuration(s.end - s.start)} · ${fmtTime(s.start)}`,
|
||||
}
|
||||
})
|
||||
return { bands, total }
|
||||
}, [props.spans, t, fmtDuration, fmtTime, props.locale])
|
||||
const dense = model !== null && model.bands.length > TIMING_DOM_MAX
|
||||
const hoverKey = props.hoverKey
|
||||
const baseGradient = useMemo(
|
||||
() => (model !== null && dense ? timingGradientOf(model.bands, null) : ''),
|
||||
[dense, model],
|
||||
)
|
||||
const hotGradient = useMemo(
|
||||
() => (model !== null && dense && hoverKey !== null && hoverKey !== undefined
|
||||
? timingGradientOf(model.bands, hoverKey as TimingSpan['kind'])
|
||||
: ''),
|
||||
[dense, model, hoverKey],
|
||||
)
|
||||
const barRef = useRef<HTMLDivElement | null>(null)
|
||||
if (model === null) return null
|
||||
const bands = model.bands
|
||||
const total = model.total
|
||||
const hovering = hoverKey !== null && hoverKey !== undefined && bands.some(b => b.kind === hoverKey)
|
||||
const onDenseMove = (e: { clientX: number; currentTarget: HTMLDivElement }): void => {
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
if (rect.width <= 0) return
|
||||
const pct = Math.min(100, Math.max(0, (e.clientX - rect.left) / rect.width * 100))
|
||||
const band = bands[timingBandAt(bands, pct)]
|
||||
setTipKey(band.key)
|
||||
props.onHoverKey?.(band.kind)
|
||||
}
|
||||
// The tip centers on its band's rendered span, pinned 10% off both edges so a hairline band's bubble stays inside the card.
|
||||
let tip: { text: string; leftPct: number } | null = null
|
||||
for (const b of bands) {
|
||||
@@ -112,7 +181,9 @@ export function makeTimingStrip(kit: ViewKit): (props: {
|
||||
<div className="lc-tstrip" role="img" aria-label={t('timing.strip')}>
|
||||
<div className="lc-tstrip-wrap">
|
||||
<div
|
||||
ref={barRef}
|
||||
className={'lc-tstrip-bar' + (hovering ? ' lc-tstrip-dim' : '')}
|
||||
onMouseMove={dense ? onDenseMove : undefined}
|
||||
onMouseLeave={() => {
|
||||
setTipKey(null)
|
||||
props.onHoverKey?.(null)
|
||||
@@ -120,9 +191,18 @@ export function makeTimingStrip(kit: ViewKit): (props: {
|
||||
>
|
||||
{/* The bands ride ONE animated wrapper: the strip wipes open left to
|
||||
right on its single composited transform instead of one animation
|
||||
per band (see the module header). */}
|
||||
per band (see the module header). Dense strips paint two gradient
|
||||
layers instead of thousands of sub-pixel divs; the band model above
|
||||
still answers the exact span under the pointer. */}
|
||||
<div className="lc-tstrip-fill animate-lc-stacked-in motion-reduce:animate-none">
|
||||
{bands.map(b => (
|
||||
{dense ? (
|
||||
<>
|
||||
<div className="lc-tstrip-gradient lc-tstrip-gradient-base" style={{ background: baseGradient }} />
|
||||
{hovering && hotGradient !== '' ? (
|
||||
<div className="lc-tstrip-gradient lc-tstrip-gradient-hot" style={{ background: hotGradient }} />
|
||||
) : null}
|
||||
</>
|
||||
) : bands.map(b => (
|
||||
<div
|
||||
key={b.key}
|
||||
className={'lc-tstrip-seg' + (hoverKey === b.kind ? ' lc-tstrip-seg-on' : '')}
|
||||
|
||||
@@ -1,17 +1,30 @@
|
||||
import { Fragment, memo, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type ReactElement, type UIEvent } from 'react'
|
||||
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type ReactElement, type UIEvent } from 'react'
|
||||
import type { Category, ContextEventRecord, RequestRecord } from '../../shared/types'
|
||||
import { CATS } from '../categories'
|
||||
import { deltaBandsOf, dnaBaseLabel } from '../dna'
|
||||
import type { DnaDelta, TrendBand } from '../dna'
|
||||
import { containHorizontalOverscroll } from '../overscroll'
|
||||
import type { ViewKit } from '../viewkit'
|
||||
import { DnaBar, DnaDeltaBar, DnaHighlights, RISE_CLASS, STAGGER_CAP } from './trendDnaBars'
|
||||
import { DurationCurve } from './trendDurationCurve'
|
||||
import { BAR_GAP, BAR_W, TREND_WINDOW_MIN, WINDOW_HYSTERESIS_BARS, WINDOW_OVERSCAN_BARS, initialBarWindow, nextBarWindow, windowPads, windowRangeFor } from './trendWindow'
|
||||
import type { BarWindow } from './trendWindow'
|
||||
export {
|
||||
BAR_GAP, BAR_W, TREND_WINDOW_MIN, WINDOW_HYSTERESIS_BARS, WINDOW_OVERSCAN_BARS,
|
||||
initialBarWindow, nextBarWindow, windowPads, windowRangeFor,
|
||||
}
|
||||
export type { BarWindow }
|
||||
|
||||
export const RISE_CLASS = ' animate-lc-bar-in motion-reduce:animate-none'
|
||||
export { RISE_CLASS }
|
||||
|
||||
/** Only the NEWEST `RISE_CAP` columns play the grow-in: the chart mounts anchored to the newest bars, and animating
|
||||
* everything left of the window layerizes the whole chart and stalls the card's open. */
|
||||
export const RISE_CAP = 200
|
||||
|
||||
/** DNA bars carry one long inline gradient each: a tighter window bounds the style recalc that dominates the toggle. */
|
||||
const DNA_WINDOW_OVERSCAN = 8
|
||||
const DNA_WINDOW_HYSTERESIS = 2
|
||||
|
||||
export interface TrendChartProps {
|
||||
requests: RequestRecord[]
|
||||
markers: (ContextEventRecord | undefined)[]
|
||||
@@ -29,6 +42,8 @@ export interface TrendChartProps {
|
||||
/** DNA mode: per bar, its assembled context decomposed into one band per item in read order (dna.ts), aligned
|
||||
* with `requests` by index. */
|
||||
dna?: TrendBand[][] | null
|
||||
/** Lazy per-seq bands for long logs: the chart calls this only for the bars it renders (and their delta base). */
|
||||
dnaAt?: ((seq: number) => TrendBand[]) | null
|
||||
onPickBand?: (seq: number, band: { key: string; cat: Category | 'system' | 'tools' }) => void
|
||||
onSelect: (seq: number | null) => void
|
||||
onHover: (seq: number | null) => void
|
||||
@@ -212,10 +227,6 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactE
|
||||
const Q3_TOP = 41
|
||||
const Q1_TOP = 97
|
||||
const fmtSigned = (v: number): string => (v > 0 ? '+' : '') + fmt(v)
|
||||
// Constant bar width (the turn strip below mirrors the same column grid): dense histories scroll instead of compressing.
|
||||
const BAR_W = 14
|
||||
const BAR_GAP = 2
|
||||
const STAGGER_CAP = 20
|
||||
const STEP_FLAG_EVERY = 5
|
||||
// Labels render at natural width and overflow their block: the pair-gap sizing below shrinks ALL of them uniformly, and
|
||||
// updateTurnLabels' measured chain is the last-resort guard. OVERHANG bounds a label's reach; GAP is the box gap.
|
||||
@@ -258,150 +269,7 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactE
|
||||
onHover: (seq: number | null) => void
|
||||
}
|
||||
|
||||
/** DNA bar interior: ONE gradient div, same-color bands coalesced (long runs would bloat the style string) and
|
||||
* zero-token bands skipped. */
|
||||
interface DnaBarProps {
|
||||
bands: TrendBand[]
|
||||
total: number
|
||||
maxTotal: number
|
||||
enterIndex: number
|
||||
rise: boolean
|
||||
seq: number
|
||||
onHit: (key: string | null) => void
|
||||
onPick?: (seq: number, band: TrendBand) => void
|
||||
}
|
||||
|
||||
const DnaBar = function DnaBar(props: DnaBarProps): ReactElement | null {
|
||||
const visible: TrendBand[] = []
|
||||
for (const b of props.bands) if (b.tokens > 0) visible.push(b)
|
||||
if (visible.length === 0) return null
|
||||
// Shared per-token pixel scale (CHART_H/maxTotal), not per-bar percentages: the same band's stops are
|
||||
// identical numbers in every bar, so the browser rasterizes common strands identically (per-bar %
|
||||
// stops quantized against each bar's own total and made sub-pixel strands flip visible/merged).
|
||||
const pxPerToken = CHART_H / props.maxTotal
|
||||
const runs: { color: string; from: number; to: number }[] = []
|
||||
for (const b of visible) {
|
||||
const from = Math.round(b.off * pxPerToken * 100) / 100
|
||||
const to = Math.round((b.off + b.tokens) * pxPerToken * 100) / 100
|
||||
const last = runs.length > 0 ? runs[runs.length - 1] : null
|
||||
if (last !== null && last.color === b.color && last.to === from) last.to = to
|
||||
else runs.push({ color: b.color, from, to })
|
||||
}
|
||||
// A hit always exists (the first visible band starts at off 0; the fraction clamps), so the type carries no null.
|
||||
const hitAt = (e: { clientY: number; currentTarget: HTMLDivElement }): TrendBand => {
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
const frac = rect.height > 0 ? Math.min(1, Math.max(0, 1 - (e.clientY - rect.top) / rect.height)) : 0
|
||||
const pos = frac * props.total
|
||||
let hit = visible[0]
|
||||
for (const b of props.bands) {
|
||||
if (b.off > pos) break
|
||||
if (b.tokens > 0) hit = b
|
||||
}
|
||||
return hit
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={'lc-bar-dna' + (props.rise ? RISE_CLASS : '')}
|
||||
style={{
|
||||
height: `${Math.max(1, Math.round(props.total * pxPerToken))}px`,
|
||||
background: 'linear-gradient(to top, ' + runs.map(r => `${r.color} ${r.from}px, ${r.color} ${r.to}px`).join(', ') + ')',
|
||||
'--lc-i': Math.min(props.enterIndex, STAGGER_CAP),
|
||||
} as CSSProperties}
|
||||
onMouseMove={(e) => { props.onHit(hitAt(e).key) }}
|
||||
onMouseLeave={() => { props.onHit(null) }}
|
||||
onClick={(e) => {
|
||||
if (props.onPick !== undefined) props.onPick(props.seq, hitAt(e))
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/** DNA+delta bar interior: ONE gradient div spans both arms — the down arm's bands in REVERSE read order, then the up arm. */
|
||||
interface DnaDeltaBarProps {
|
||||
d: DnaDelta
|
||||
scale: number
|
||||
zeroBottom: number
|
||||
enterIndex: number
|
||||
rise: boolean
|
||||
seq: number
|
||||
onHit: (key: string | null) => void
|
||||
onPick?: (seq: number, band: { key: string; cat: Category | 'system' | 'tools' }) => void
|
||||
}
|
||||
|
||||
const DnaDeltaBar = function DnaDeltaBar(props: DnaDeltaBarProps): ReactElement | null {
|
||||
let upSum = 0
|
||||
for (const b of props.d.up) upSum += b.tokens
|
||||
let downSum = 0
|
||||
for (const b of props.d.down) downSum -= b.tokens
|
||||
const gradSpan = upSum + downSum
|
||||
if (gradSpan === 0) return null
|
||||
const runs: { color: string; from: number; to: number }[] = []
|
||||
const pushRun = (color: string, mag: number, at: number): number => {
|
||||
const from = Math.round(at / gradSpan * 10000) / 100
|
||||
const to = Math.round((at + mag) / gradSpan * 10000) / 100
|
||||
const last = runs.length > 0 ? runs[runs.length - 1] : null
|
||||
if (last !== null && last.color === color && last.to === from) last.to = to
|
||||
else runs.push({ color, from, to })
|
||||
return at + mag
|
||||
}
|
||||
let acc = 0
|
||||
for (let i = props.d.down.length - 1; i >= 0; i--) acc = pushRun(props.d.down[i].color, -props.d.down[i].tokens, acc)
|
||||
for (const b of props.d.up) acc = pushRun(b.color, b.tokens, acc)
|
||||
const height = Math.max(1, Math.round(gradSpan * props.scale))
|
||||
const hitAt = (e: { clientY: number; currentTarget: HTMLDivElement }): { key: string; cat: Category | 'system' | 'tools' } => {
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
const frac = rect.height > 0 ? Math.min(1, Math.max(0, 1 - (e.clientY - rect.top) / rect.height)) : 0
|
||||
const pos = frac * gradSpan - downSum
|
||||
const useUp = pos >= 0 && props.d.up.length > 0
|
||||
const arm = useUp ? props.d.up : props.d.down
|
||||
const p = useUp ? pos : -pos
|
||||
let hit = arm[0]
|
||||
for (const b of arm) {
|
||||
if (b.off > p) break
|
||||
hit = b
|
||||
}
|
||||
return hit
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={'lc-bar-dna' + (props.rise ? RISE_CLASS : '')}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: `${Math.round(props.zeroBottom - downSum * props.scale)}px`,
|
||||
height: `${height}px`,
|
||||
background: 'linear-gradient(to top, ' + runs.map(r => `${r.color} ${r.from}%, ${r.color} ${r.to}%`).join(', ') + ')',
|
||||
// The entrance rise grows from the zero line, not an arm's end.
|
||||
transformOrigin: `50% ${Math.round(downSum / gradSpan * 10000) / 100}%`,
|
||||
'--lc-i': Math.min(props.enterIndex, STAGGER_CAP),
|
||||
} as CSSProperties}
|
||||
onMouseMove={(e) => { props.onHit(hitAt(e).key) }}
|
||||
onMouseLeave={() => { props.onHit(null) }}
|
||||
onClick={(e) => { if (props.onPick !== undefined) props.onPick(props.seq, hitAt(e)) }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/** Cross-bar lifetime highlight: a flex row mirroring the bar grid, one slice per bar still holding the hovered item; memoized. */
|
||||
interface DnaHighlightsProps {
|
||||
slices: ({ bottom: number; height: number } | null)[] | null
|
||||
}
|
||||
|
||||
const DnaHighlights = memo(function DnaHighlights(props: DnaHighlightsProps): ReactElement | null {
|
||||
if (props.slices === null) return null
|
||||
return (
|
||||
<div className="lc-dna-hl">
|
||||
{props.slices.map((s, i) => (
|
||||
<span key={i} className="lc-dna-cell">
|
||||
{s !== null ? (
|
||||
<span className="lc-dna-slice" style={{ bottom: `${s.bottom}px`, height: `${s.height}px` }} />
|
||||
) : null}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
// Memoized so a hover/selection change re-renders only the bars whose flags flipped (the retained log renders in full);
|
||||
// Memoized so a hover/selection change re-renders only the bars whose flags flipped (the window keeps bar props stable);
|
||||
// `req`/`marker` keep stable identities because the parent memoizes its aggregation, so the default shallow compare suffices.
|
||||
const ChartBar = memo(function ChartBar(props: ChartBarProps): ReactElement {
|
||||
const { req, marker } = props
|
||||
@@ -445,6 +313,7 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactE
|
||||
/>
|
||||
) : (
|
||||
<DnaBar
|
||||
chartHeight={CHART_H}
|
||||
bands={props.dna}
|
||||
total={req.total}
|
||||
maxTotal={props.maxTotal}
|
||||
@@ -487,24 +356,53 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactE
|
||||
})
|
||||
|
||||
return function TrendChart(props: TrendChartProps): ReactElement {
|
||||
const dnaBands = props.dna ?? null
|
||||
const dnaOn = dnaBands !== null
|
||||
const dnaArray = props.dna ?? null
|
||||
const dnaAt = props.dnaAt ?? null
|
||||
const dnaOn = dnaArray !== null || dnaAt !== null
|
||||
const delta = props.mode === 'delta'
|
||||
const dnaDeltaOn = dnaOn && delta
|
||||
const durationOn = props.durationCurve === true
|
||||
const [dnaHit, setDnaHit] = useState<string | null>(null)
|
||||
// Each bar's bands paired against the previous bar's by key (dna.ts), aligned with `requests` by index; the
|
||||
// first bar carries no change.
|
||||
const dnaDeltas = useMemo(
|
||||
() => (dnaDeltaOn ? dnaBands.map((bands, i) => deltaBandsOf(bands, i > 0 ? dnaBands[i - 1] : null)) : null),
|
||||
[dnaDeltaOn, dnaBands],
|
||||
)
|
||||
// Pointer hover is local to the chart: the tip, outlines and DNA slices must track the pointer without waiting
|
||||
// for the parent's transition, and a sweep must not re-render the whole context view synchronously.
|
||||
const [localHover, setLocalHover] = useState<number | null>(null)
|
||||
const hoverSeq = localHover ?? props.hoveredSeq
|
||||
const handleBarHover = useCallback((seq: number | null): void => {
|
||||
setLocalHover(seq)
|
||||
props.onHover(seq)
|
||||
}, [props.onHover])
|
||||
// An externally driven hover (test harness, a future link from another card) wins over the pointer's own
|
||||
// last position; the pointer's value is kept only while the prop still names the same bar.
|
||||
useEffect(() => {
|
||||
setLocalHover(prev => (prev === null || props.hoveredSeq === prev ? prev : null))
|
||||
}, [props.hoveredSeq])
|
||||
// The DNA faces decompose the full context, so the browser's category focus would plot tool-only
|
||||
// bar totals against full-context band offsets — floating every lifetime slice above half-height bars.
|
||||
const { requests, focus } = useMemo(
|
||||
() => preprocessTrend(props.requests, delta, dnaOn ? null : props.focusCat),
|
||||
[props.requests, delta, props.focusCat, dnaOn],
|
||||
)
|
||||
// Per-render caches keyed by the active DNA source: a lazy source assembles a step only when a bar (or its
|
||||
// delta base) is actually rendered, and the window keeps those lookups bounded.
|
||||
const bandStore = useMemo(() => ({
|
||||
bands: new Map<number, TrendBand[]>(),
|
||||
deltas: new Map<number, DnaDelta>(),
|
||||
}), [dnaAt, dnaArray])
|
||||
const bandAt = useCallback((idx: number): TrendBand[] => {
|
||||
const seq = requests[idx].seq
|
||||
const hit = bandStore.bands.get(seq)
|
||||
if (hit !== undefined) return hit
|
||||
const bands = dnaAt !== null ? dnaAt(seq) : (dnaArray as TrendBand[][])[idx]
|
||||
bandStore.bands.set(seq, bands)
|
||||
return bands
|
||||
}, [bandStore, requests, dnaAt, dnaArray])
|
||||
const deltaAt = useCallback((idx: number): DnaDelta => {
|
||||
const hit = bandStore.deltas.get(idx)
|
||||
if (hit !== undefined) return hit
|
||||
const d = deltaBandsOf(bandAt(idx), idx > 0 ? bandAt(idx - 1) : null)
|
||||
bandStore.deltas.set(idx, d)
|
||||
return d
|
||||
}, [bandStore, bandAt])
|
||||
const markers = props.markers
|
||||
// The maxima over the bars on screen; null until the first measure, which falls back to the whole-log scale, so the
|
||||
// first paint never draws an empty axis and scrolling inside an unchanged window re-renders nothing.
|
||||
@@ -532,11 +430,12 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactE
|
||||
// (so a toggle-on never re-measures).
|
||||
const m = req.activeMs ?? 0
|
||||
if (m > activeMs) activeMs = m
|
||||
if (dnaDeltaOn && dnaDeltas !== null) {
|
||||
if (dnaDeltaOn) {
|
||||
let bu = 0
|
||||
let bd = 0
|
||||
for (const b of dnaDeltas[i].up) bu += b.tokens
|
||||
for (const b of dnaDeltas[i].down) bd -= b.tokens
|
||||
const d = deltaAt(i)
|
||||
for (const b of d.up) bu += b.tokens
|
||||
for (const b of d.down) bd -= b.tokens
|
||||
if (bu > up) up = bu
|
||||
if (bd > down) down = bd
|
||||
} else if (delta) {
|
||||
@@ -561,8 +460,11 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactE
|
||||
let maxTotal = 1
|
||||
let maxUp = 0
|
||||
let maxDown = 0
|
||||
if (dnaDeltaOn && dnaDeltas !== null) {
|
||||
for (const d of dnaDeltas) {
|
||||
if (dnaDeltaOn) {
|
||||
// Net totals cannot bound churn (keys grow and shrink against each other), so delta axes walk the per-key
|
||||
// bands. The context view hands delta mode its eager array, because this walk needs the full model.
|
||||
for (let i = 0; i < requests.length; i++) {
|
||||
const d = deltaAt(i)
|
||||
let up = 0
|
||||
let down = 0
|
||||
for (const b of d.up) up += b.tokens
|
||||
@@ -594,15 +496,16 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactE
|
||||
if (adaptive && visMax !== null) maxActiveMs = visMax.activeMs
|
||||
}
|
||||
// A bar without `activeMs` breaks the run instead of faking a value; an isolated single point needs a dot —
|
||||
// a one-point polyline renders invisible.
|
||||
const durationRuns: string[] = []
|
||||
const durationDots: [number, number][] = []
|
||||
if (durationOn) {
|
||||
// a one-point polyline renders invisible. Memoized: a window shift must not rebuild the full-history path.
|
||||
const durationGeometry = useMemo(() => {
|
||||
const runs: string[] = []
|
||||
const dots: [number, number][] = []
|
||||
if (!durationOn) return { runs, dots }
|
||||
const scale = CHART_H / Math.max(1, maxActiveMs)
|
||||
let run: [number, number][] = []
|
||||
const flushRun = (): void => {
|
||||
if (run.length >= 2) durationRuns.push(run.map(p => `${p[0]},${p[1]}`).join(' '))
|
||||
else if (run.length === 1) durationDots.push(run[0])
|
||||
if (run.length >= 2) runs.push(run.map(p => `${p[0]},${p[1]}`).join(' '))
|
||||
else if (run.length === 1) dots.push(run[0])
|
||||
run = []
|
||||
}
|
||||
for (let i = 0; i < requests.length; i++) {
|
||||
@@ -614,7 +517,8 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactE
|
||||
run.push([i * (BAR_W + BAR_GAP) + BAR_W / 2, Math.round((CHART_H - m * scale) * 100) / 100])
|
||||
}
|
||||
flushRun()
|
||||
}
|
||||
return { runs, dots }
|
||||
}, [requests, durationOn, maxActiveMs])
|
||||
// The zero line splits the bar area PROPORTIONALLY to the larger side, so px-per-token is identical above and below it.
|
||||
const span = Math.max(1, maxUp + maxDown)
|
||||
const deltaScale = CHART_H / span
|
||||
@@ -676,6 +580,60 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactE
|
||||
// synchronous layout flush of whatever that commit just dirtied. The commit effect, scroll handler and resize observer refresh them.
|
||||
const cwRef = useRef(0)
|
||||
const slRef = useRef(0)
|
||||
// Only large histories window their DOM: the rendered bars are always a contiguous slice of `requests`, and
|
||||
// every readout (axis, tip, DNA, delta, turn strip) still comes from the full model.
|
||||
const windowed = requests.length > TREND_WINDOW_MIN
|
||||
const windowOverscan = dnaOn ? DNA_WINDOW_OVERSCAN : WINDOW_OVERSCAN_BARS
|
||||
const windowHysteresis = dnaOn ? DNA_WINDOW_HYSTERESIS : WINDOW_HYSTERESIS_BARS
|
||||
const [barWindow, setBarWindow] = useState<BarWindow>(() => initialBarWindow(requests.length))
|
||||
const barWindowRef = useRef(barWindow)
|
||||
useLayoutEffect(() => { barWindowRef.current = barWindow }, [barWindow])
|
||||
const winRafRef = useRef<number | null>(null)
|
||||
const syncWindow = (el: HTMLDivElement): void => {
|
||||
if (requests.length <= TREND_WINDOW_MIN) return
|
||||
const desired = windowRangeFor(el.scrollLeft, el.clientWidth, requests.length, windowOverscan)
|
||||
const prev = barWindowRef.current
|
||||
const next = nextBarWindow(prev, desired, requests.length, windowHysteresis)
|
||||
if (next === prev) return
|
||||
barWindowRef.current = next
|
||||
setBarWindow(next)
|
||||
}
|
||||
const syncWindowRef = useRef(syncWindow)
|
||||
useLayoutEffect(() => { syncWindowRef.current = syncWindow })
|
||||
// Scroll frames coalesce into one rAF: the range shifts in overscan-sized steps, never per pixel.
|
||||
const scheduleWindow = (el: HTMLDivElement): void => {
|
||||
if (winRafRef.current !== null) return
|
||||
winRafRef.current = requestAnimationFrame(() => {
|
||||
winRafRef.current = null
|
||||
syncWindowRef.current(el)
|
||||
})
|
||||
}
|
||||
useEffect(() => () => {
|
||||
if (winRafRef.current !== null) cancelAnimationFrame(winRafRef.current)
|
||||
}, [])
|
||||
const prevDnaOn = useRef<boolean | null>(null)
|
||||
useLayoutEffect(() => {
|
||||
const first = prevDnaOn.current === null
|
||||
const changed = prevDnaOn.current !== dnaOn
|
||||
prevDnaOn.current = dnaOn
|
||||
const el = scrollRef.current
|
||||
// A first mount with DNA on still needs the tight window; a first mount without it keeps the anchored one.
|
||||
if (el === null || requests.length <= TREND_WINDOW_MIN || (first && !dnaOn) || (!first && !changed)) return
|
||||
// DNA bars are expensive, so a DNA mount/toggle re-windows immediately instead of waiting for a scroll to
|
||||
// leave the (much wider) ordinary window; leaving DNA restores the ordinary range so no blank tray remains.
|
||||
const overscan = dnaOn ? DNA_WINDOW_OVERSCAN : WINDOW_OVERSCAN_BARS
|
||||
const hysteresis = dnaOn ? DNA_WINDOW_HYSTERESIS : WINDOW_HYSTERESIS_BARS
|
||||
const desired = windowRangeFor(el.scrollLeft, el.clientWidth, requests.length, overscan)
|
||||
const next = {
|
||||
from: Math.max(0, desired.from - hysteresis),
|
||||
to: Math.min(requests.length - 1, desired.to + hysteresis),
|
||||
}
|
||||
if (next.from !== barWindowRef.current.from || next.to !== barWindowRef.current.to) {
|
||||
barWindowRef.current = next
|
||||
setBarWindow(next)
|
||||
}
|
||||
// Mode flips only: scroll-driven resizing stays with syncWindow.
|
||||
}, [dnaOn])
|
||||
/** Center each turn label within its block's visible slice, then thin colliding labels; reads batch before writes. */
|
||||
const updateTurnLabels = (el: HTMLDivElement): void => {
|
||||
const labels = el.querySelectorAll<HTMLElement>('.lc-turn-label')
|
||||
@@ -742,6 +700,7 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactE
|
||||
updateTurnLabels(el)
|
||||
syncTip(el)
|
||||
measureVisible(el)
|
||||
syncWindowRef.current(el)
|
||||
// A DNA↔stacked or total↔delta switch changes what the window maximum means, so re-measure rather than ride a stale adaptive scale.
|
||||
}, [props.granularity, props.focusTurn, requests, adaptive, dnaDeltaOn])
|
||||
|
||||
@@ -756,7 +715,7 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactE
|
||||
attaches refs before layout effects run; el is never null here. */
|
||||
if (el === null) return
|
||||
if (typeof ResizeObserver !== 'function') return
|
||||
const observer = new ResizeObserver(() => { cwRef.current = el.clientWidth; measureRef.current(el) })
|
||||
const observer = new ResizeObserver(() => { cwRef.current = el.clientWidth; measureRef.current(el); syncWindowRef.current(el) })
|
||||
observer.observe(el)
|
||||
return () => { observer.disconnect() }
|
||||
}, [])
|
||||
@@ -769,41 +728,64 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactE
|
||||
return containHorizontalOverscroll(el)
|
||||
}, [])
|
||||
|
||||
const shownFrom = windowed ? Math.min(Math.max(barWindow.from, 0), Math.max(0, requests.length - 1)) : 0
|
||||
const shownTo = windowed
|
||||
? Math.max(shownFrom, Math.min(barWindow.to, requests.length - 1))
|
||||
: Math.max(0, requests.length - 1)
|
||||
const pads = windowPads(shownFrom, shownTo, requests.length)
|
||||
const shownBars = requests.slice(shownFrom, shownTo + 1)
|
||||
|
||||
const stepsOf = useMemo(() => turnStepsOf(props.requests), [props.requests])
|
||||
const hoveredIdx = props.hoveredSeq !== null ? requests.findIndex(r => r.seq === props.hoveredSeq) : -1
|
||||
const hoveredIdx = hoverSeq !== null ? requests.findIndex(r => r.seq === hoverSeq) : -1
|
||||
const hoveredReq = hoveredIdx >= 0 ? requests[hoveredIdx] : null
|
||||
const hoveredBands = dnaOn && !dnaDeltaOn && hoveredIdx >= 0 ? dnaBands[hoveredIdx] : null
|
||||
const hoveredBands = dnaOn && !dnaDeltaOn && hoveredIdx >= 0 ? bandAt(hoveredIdx) : null
|
||||
const hitBand = hoveredBands !== null && dnaHit !== null
|
||||
? hoveredBands.find(b => b.key === dnaHit && b.tokens > 0) ?? null
|
||||
: null
|
||||
const hoveredDelta = dnaDeltaOn && hoveredIdx >= 0 && dnaDeltas !== null
|
||||
? dnaDeltas[hoveredIdx].up.find(b => b.key === dnaHit) ?? dnaDeltas[hoveredIdx].down.find(b => b.key === dnaHit) ?? null
|
||||
const hoveredDelta = dnaDeltaOn && hoveredIdx >= 0
|
||||
? (() => {
|
||||
const d = deltaAt(hoveredIdx)
|
||||
return d.up.find(b => b.key === dnaHit) ?? d.down.find(b => b.key === dnaHit) ?? null
|
||||
})()
|
||||
: null
|
||||
const dnaSlices = useMemo(() => {
|
||||
if (!dnaOn || dnaHit === null) return null
|
||||
const slices: ({ bottom: number; height: number } | null)[] = []
|
||||
if (dnaDeltaOn && dnaDeltas !== null) {
|
||||
for (const d of dnaDeltas) {
|
||||
const hit = d.up.find(b => b.key === dnaHit) ?? d.down.find(b => b.key === dnaHit) ?? null
|
||||
if (hit === null) { slices.push(null); continue }
|
||||
// Up-arm slice rises from the zero line, down-arm slice hangs below it (`off` is the cumulative magnitude from the line).
|
||||
const mag = Math.abs(hit.tokens)
|
||||
const edge = hit.tokens > 0
|
||||
? downPx + hit.off * deltaScale
|
||||
: downPx - (hit.off + mag) * deltaScale
|
||||
slices.push({ bottom: Math.round(edge), height: Math.max(1, Math.round(mag * deltaScale)) })
|
||||
}
|
||||
} else {
|
||||
for (const bands of dnaBands) {
|
||||
const hit = bands.find(b => b.key === dnaHit && b.tokens > 0) ?? null
|
||||
slices.push(hit === null ? null : {
|
||||
bottom: Math.round(hit.off / maxTotal * CHART_H),
|
||||
height: Math.max(1, Math.round(hit.tokens / maxTotal * CHART_H)),
|
||||
})
|
||||
// Per-window band-key index: a DNA hover sweeps one key across bars, so build each bar's key→slice map once
|
||||
// (window changes only) instead of walking ~1,000 bands per bar on every pointer move.
|
||||
type Slice = { bottom: number; height: number }
|
||||
const dnaSliceIndex = useMemo(() => {
|
||||
if (!dnaOn) return null
|
||||
const index = new Map<number, Map<string, Slice>>()
|
||||
for (let i = shownFrom; i <= shownTo; i++) {
|
||||
const byKey = new Map<string, Slice>()
|
||||
if (dnaDeltaOn) {
|
||||
const d = deltaAt(i)
|
||||
for (const b of d.up) {
|
||||
const edge = downPx + b.off * deltaScale
|
||||
byKey.set(b.key, { bottom: Math.round(edge), height: Math.max(1, Math.round(b.tokens * deltaScale)) })
|
||||
}
|
||||
for (const b of d.down) {
|
||||
const mag = Math.abs(b.tokens)
|
||||
const edge = downPx - (b.off + mag) * deltaScale
|
||||
byKey.set(b.key, { bottom: Math.round(edge), height: Math.max(1, Math.round(mag * deltaScale)) })
|
||||
}
|
||||
} else {
|
||||
for (const b of bandAt(i)) {
|
||||
if (b.tokens <= 0) continue
|
||||
byKey.set(b.key, {
|
||||
bottom: Math.round(b.off / maxTotal * CHART_H),
|
||||
height: Math.max(1, Math.round(b.tokens / maxTotal * CHART_H)),
|
||||
})
|
||||
}
|
||||
}
|
||||
index.set(i, byKey)
|
||||
}
|
||||
return index
|
||||
}, [dnaOn, dnaDeltaOn, downPx, deltaScale, maxTotal, shownFrom, shownTo, bandAt, deltaAt])
|
||||
const dnaSlices = useMemo(() => {
|
||||
if (dnaSliceIndex === null || dnaHit === null) return null
|
||||
const slices: (Slice | null)[] = Array.from({ length: requests.length }, () => null)
|
||||
for (let i = shownFrom; i <= shownTo; i++) slices[i] = dnaSliceIndex.get(i)?.get(dnaHit) ?? null
|
||||
return slices
|
||||
}, [dnaOn, dnaHit, dnaDeltaOn, dnaDeltas, dnaBands, downPx, deltaScale, maxTotal])
|
||||
}, [dnaSliceIndex, dnaHit, shownFrom, shownTo, requests.length])
|
||||
const tipRowsOf = (req: RequestRecord): string[] => {
|
||||
// DNA mode: the metric row names the hovered item; a hover on the bar's padding falls through to the mode's own row.
|
||||
const band = hitBand ?? hoveredDelta
|
||||
@@ -845,7 +827,8 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactE
|
||||
if (scrollRef.current === null) return
|
||||
tipColRef.current = hoveredIdx >= 0 ? hoveredIdx * (BAR_W + BAR_GAP) + BAR_W / 2 : 0
|
||||
syncTip(scrollRef.current)
|
||||
})
|
||||
// Only a hovered-bar change needs a fresh pass; scroll/other commits keep the cached placement.
|
||||
}, [hoveredIdx])
|
||||
|
||||
return (
|
||||
<div className="lc-chartrow">
|
||||
@@ -885,14 +868,16 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactE
|
||||
updateTurnLabels(e.currentTarget)
|
||||
syncTip(e.currentTarget)
|
||||
measureVisible(e.currentTarget)
|
||||
scheduleWindow(e.currentTarget)
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="lc-chart"
|
||||
data-lc-bars={requests.length}
|
||||
// The shared category hover rides a plain attribute: the CSS lights that key's segment in every bar,
|
||||
// so the memoized bars never re-render on a cross-card hover.
|
||||
data-catdim={props.hoverCat ?? undefined}
|
||||
onMouseLeave={() => { props.onHover(null); setDnaHit(null) }}
|
||||
onMouseLeave={() => { props.onHover(null); setLocalHover(null); setDnaHit(null) }}
|
||||
>
|
||||
<div className="lc-grid lc-grid-top" />
|
||||
{/* Dashed guides aligning the bars with the axis quarter marks; a delta mark that yielded to the
|
||||
@@ -901,58 +886,50 @@ export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactE
|
||||
{(!delta || q1Clear) ? <div className="lc-grid lc-grid-q1" /> : null}
|
||||
{!delta ? <div className="lc-grid lc-grid-mid" /> : null}
|
||||
<div className="lc-grid lc-grid-zero" style={delta ? { top: `${18 + upPx}px` } : undefined} />
|
||||
{requests.map((req, i) => (
|
||||
<ChartBar
|
||||
// Granularity belongs in the key: a turn aggregate IS its last step's record, so a switch would
|
||||
// reuse the DOM node and never replay its rise.
|
||||
key={`${req.seq}:${props.granularity}`}
|
||||
req={req}
|
||||
marker={markers[i]}
|
||||
selected={props.selectedSeq === req.seq}
|
||||
hovered={props.hoveredSeq === req.seq}
|
||||
inTurn={props.activeTurn !== null && (req.turn ?? 0) === props.activeTurn}
|
||||
// Turn bars skip the flag: the turn strip below already numbers that grid.
|
||||
flag={props.granularity === 'step' && (i + 1) % STEP_FLAG_EVERY === 0 ? i + 1 : null}
|
||||
maxTotal={maxTotal}
|
||||
upPx={delta ? upPx : undefined}
|
||||
downPx={delta ? downPx : undefined}
|
||||
deltaScale={delta ? deltaScale : undefined}
|
||||
// Counted from the window's edge, so the rising columns cascade left to right even when the log runs far past it.
|
||||
enterIndex={Math.max(0, i - riseFrom)}
|
||||
rise={i >= riseFrom}
|
||||
dna={dnaOn ? dnaBands[i] : null}
|
||||
dnaDelta={dnaDeltaOn && dnaDeltas !== null ? dnaDeltas[i] : null}
|
||||
dnaScale={dnaDeltaOn ? deltaScale : undefined}
|
||||
zeroBottom={dnaDeltaOn ? downPx : undefined}
|
||||
onDnaHit={setDnaHit}
|
||||
onPickBand={props.onPickBand}
|
||||
onSelect={props.onSelect}
|
||||
onHover={props.onHover}
|
||||
/>
|
||||
))}
|
||||
{dnaOn ? <DnaHighlights slices={dnaSlices} /> : null}
|
||||
{pads.left > 0 ? <div className="lc-bar-pad" style={{ width: `${pads.left}px`, flex: 'none' }} aria-hidden="true" /> : null}
|
||||
{shownBars.map((req, k) => {
|
||||
const i = shownFrom + k
|
||||
return (
|
||||
<ChartBar
|
||||
// Granularity belongs in the key: a turn aggregate IS its last step's record, so a switch would
|
||||
// reuse the DOM node and never replay its rise.
|
||||
key={`${req.seq}:${props.granularity}`}
|
||||
req={req}
|
||||
marker={markers[i]}
|
||||
selected={props.selectedSeq === req.seq}
|
||||
hovered={hoverSeq === req.seq}
|
||||
inTurn={props.activeTurn !== null && (req.turn ?? 0) === props.activeTurn}
|
||||
// Turn bars skip the flag: the turn strip below already numbers that grid.
|
||||
flag={props.granularity === 'step' && (i + 1) % STEP_FLAG_EVERY === 0 ? i + 1 : null}
|
||||
maxTotal={maxTotal}
|
||||
upPx={delta ? upPx : undefined}
|
||||
downPx={delta ? downPx : undefined}
|
||||
deltaScale={delta ? deltaScale : undefined}
|
||||
// Counted from the window's edge, so the rising columns cascade left to right even when the log runs far past it.
|
||||
enterIndex={Math.max(0, i - riseFrom)}
|
||||
rise={i >= riseFrom}
|
||||
dna={dnaOn ? bandAt(i) : null}
|
||||
dnaDelta={dnaDeltaOn ? deltaAt(i) : null}
|
||||
dnaScale={dnaDeltaOn ? deltaScale : undefined}
|
||||
zeroBottom={dnaDeltaOn ? downPx : undefined}
|
||||
onDnaHit={setDnaHit}
|
||||
onPickBand={props.onPickBand}
|
||||
onSelect={props.onSelect}
|
||||
onHover={handleBarHover}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{pads.right > 0 ? <div className="lc-bar-pad" style={{ width: `${pads.right}px`, flex: 'none' }} aria-hidden="true" /> : null}
|
||||
{dnaOn ? <DnaHighlights slices={dnaSlices} from={shownFrom} to={shownTo} pads={pads} /> : null}
|
||||
{/* One svg riding the scrolling content, so it scrolls with the bars; z-index parity with the DNA highlight but
|
||||
later in DOM order, so the curve reads above the slices. Each segment paints twice (a card-bg knockout underlay). */}
|
||||
{durationOn && (durationRuns.length > 0 || durationDots.length > 0) ? (
|
||||
<svg
|
||||
className="lc-duration"
|
||||
{durationOn && (durationGeometry.runs.length > 0 || durationGeometry.dots.length > 0) ? (
|
||||
<DurationCurve
|
||||
width={requests.length * (BAR_W + BAR_GAP) - BAR_GAP}
|
||||
height={CHART_H}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{durationRuns.map((pts, i) => (
|
||||
<Fragment key={i}>
|
||||
<polyline className="lc-dur-halo" points={pts} />
|
||||
<polyline points={pts} />
|
||||
</Fragment>
|
||||
))}
|
||||
{durationDots.map((p, i) => (
|
||||
<Fragment key={i}>
|
||||
<circle className="lc-dur-halo" cx={p[0]} cy={p[1]} r={2} />
|
||||
<circle cx={p[0]} cy={p[1]} r={1.2} />
|
||||
</Fragment>
|
||||
))}
|
||||
</svg>
|
||||
runs={durationGeometry.runs}
|
||||
dots={durationGeometry.dots}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="lc-turns" style={labelFont !== '' ? { fontSize: labelFont } : undefined} onMouseLeave={() => { props.onHoverTurn(null) }}>
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import { memo, type CSSProperties, type ReactElement } from 'react'
|
||||
import type { Category } from '../../shared/types'
|
||||
import type { DnaDelta, TrendBand } from '../dna'
|
||||
|
||||
export const RISE_CLASS = ' animate-lc-bar-in motion-reduce:animate-none'
|
||||
|
||||
export const STAGGER_CAP = 20
|
||||
const DNA_MAX_STOPS = 64
|
||||
|
||||
/** A 14px-wide DNA bar cannot show one stop per band, and thousands of stops bloat every inline style. Sample the
|
||||
* runs down to a small fixed set: visually indistinguishable at this width, and the tooltip still reads the full model. */
|
||||
export function capDnaRuns(
|
||||
runs: { color: string; from: number; to: number }[],
|
||||
maxStops: number,
|
||||
): { color: string; from: number; to: number }[] {
|
||||
if (runs.length <= maxStops) return runs
|
||||
const end = runs[runs.length - 1].to
|
||||
const out: { color: string; from: number; to: number }[] = []
|
||||
let j = 0
|
||||
for (let k = 0; k < maxStops; k++) {
|
||||
const from = end * k / maxStops
|
||||
const to = end * (k + 1) / maxStops
|
||||
const mid = (from + to) / 2
|
||||
while (j < runs.length - 1 && runs[j].to <= mid) j++
|
||||
const color = runs[j].color
|
||||
const last = out.length > 0 ? out[out.length - 1] : null
|
||||
if (last !== null && last.color === color) last.to = to
|
||||
else out.push({ color, from, to })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
|
||||
|
||||
/** DNA bar interior: ONE gradient div, same-color bands coalesced (long runs would bloat the style string) and
|
||||
* zero-token bands skipped. */
|
||||
interface DnaBarProps {
|
||||
bands: TrendBand[]
|
||||
total: number
|
||||
maxTotal: number
|
||||
chartHeight: number
|
||||
enterIndex: number
|
||||
rise: boolean
|
||||
seq: number
|
||||
onHit: (key: string | null) => void
|
||||
onPick?: (seq: number, band: TrendBand) => void
|
||||
}
|
||||
|
||||
export const DnaBar = function DnaBar(props: DnaBarProps): ReactElement | null {
|
||||
const visible: TrendBand[] = []
|
||||
for (const b of props.bands) if (b.tokens > 0) visible.push(b)
|
||||
if (visible.length === 0) return null
|
||||
// Shared per-token pixel scale (chartHeight/maxTotal), not per-bar percentages: the same band's stops are
|
||||
// identical numbers in every bar, so the browser rasterizes common strands identically (per-bar %
|
||||
// stops quantized against each bar's own total and made sub-pixel strands flip visible/merged).
|
||||
const pxPerToken = props.chartHeight / props.maxTotal
|
||||
const runs: { color: string; from: number; to: number }[] = []
|
||||
for (const b of visible) {
|
||||
const from = Math.round(b.off * pxPerToken * 100) / 100
|
||||
const to = Math.round((b.off + b.tokens) * pxPerToken * 100) / 100
|
||||
const last = runs.length > 0 ? runs[runs.length - 1] : null
|
||||
if (last !== null && last.color === b.color && last.to === from) last.to = to
|
||||
else runs.push({ color: b.color, from, to })
|
||||
}
|
||||
const stops = capDnaRuns(runs, DNA_MAX_STOPS)
|
||||
// A hit always exists (the first visible band starts at off 0; the fraction clamps), so the type carries no null.
|
||||
const hitAt = (e: { clientY: number; currentTarget: HTMLDivElement }): TrendBand => {
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
const frac = rect.height > 0 ? Math.min(1, Math.max(0, 1 - (e.clientY - rect.top) / rect.height)) : 0
|
||||
const pos = frac * props.total
|
||||
let hit = visible[0]
|
||||
for (const b of props.bands) {
|
||||
if (b.off > pos) break
|
||||
if (b.tokens > 0) hit = b
|
||||
}
|
||||
return hit
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={'lc-bar-dna' + (props.rise ? RISE_CLASS : '')}
|
||||
style={{
|
||||
height: `${Math.max(1, Math.round(props.total * pxPerToken))}px`,
|
||||
background: 'linear-gradient(to top, ' + stops.map(r => `${r.color} ${r.from}px, ${r.color} ${r.to}px`).join(', ') + ')',
|
||||
'--lc-i': Math.min(props.enterIndex, STAGGER_CAP),
|
||||
} as CSSProperties}
|
||||
onMouseMove={(e) => { props.onHit(hitAt(e).key) }}
|
||||
onMouseLeave={() => { props.onHit(null) }}
|
||||
onClick={(e) => {
|
||||
if (props.onPick !== undefined) props.onPick(props.seq, hitAt(e))
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
/** DNA+delta bar interior: ONE gradient div spans both arms — the down arm's bands in REVERSE read order, then the up arm. */
|
||||
interface DnaDeltaBarProps {
|
||||
d: DnaDelta
|
||||
scale: number
|
||||
zeroBottom: number
|
||||
enterIndex: number
|
||||
rise: boolean
|
||||
seq: number
|
||||
onHit: (key: string | null) => void
|
||||
onPick?: (seq: number, band: { key: string; cat: Category | 'system' | 'tools' }) => void
|
||||
}
|
||||
|
||||
export const DnaDeltaBar = function DnaDeltaBar(props: DnaDeltaBarProps): ReactElement | null {
|
||||
let upSum = 0
|
||||
for (const b of props.d.up) upSum += b.tokens
|
||||
let downSum = 0
|
||||
for (const b of props.d.down) downSum -= b.tokens
|
||||
const gradSpan = upSum + downSum
|
||||
if (gradSpan === 0) return null
|
||||
const runs: { color: string; from: number; to: number }[] = []
|
||||
const pushRun = (color: string, mag: number, at: number): number => {
|
||||
const from = Math.round(at / gradSpan * 10000) / 100
|
||||
const to = Math.round((at + mag) / gradSpan * 10000) / 100
|
||||
const last = runs.length > 0 ? runs[runs.length - 1] : null
|
||||
if (last !== null && last.color === color && last.to === from) last.to = to
|
||||
else runs.push({ color, from, to })
|
||||
return at + mag
|
||||
}
|
||||
let acc = 0
|
||||
for (let i = props.d.down.length - 1; i >= 0; i--) acc = pushRun(props.d.down[i].color, -props.d.down[i].tokens, acc)
|
||||
for (const b of props.d.up) acc = pushRun(b.color, b.tokens, acc)
|
||||
const stops = capDnaRuns(runs, DNA_MAX_STOPS)
|
||||
const height = Math.max(1, Math.round(gradSpan * props.scale))
|
||||
const hitAt = (e: { clientY: number; currentTarget: HTMLDivElement }): { key: string; cat: Category | 'system' | 'tools' } => {
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
const frac = rect.height > 0 ? Math.min(1, Math.max(0, 1 - (e.clientY - rect.top) / rect.height)) : 0
|
||||
const pos = frac * gradSpan - downSum
|
||||
const useUp = pos >= 0 && props.d.up.length > 0
|
||||
const arm = useUp ? props.d.up : props.d.down
|
||||
const p = useUp ? pos : -pos
|
||||
let hit = arm[0]
|
||||
for (const b of arm) {
|
||||
if (b.off > p) break
|
||||
hit = b
|
||||
}
|
||||
return hit
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={'lc-bar-dna' + (props.rise ? RISE_CLASS : '')}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: `${Math.round(props.zeroBottom - downSum * props.scale)}px`,
|
||||
height: `${height}px`,
|
||||
background: 'linear-gradient(to top, ' + stops.map(r => `${r.color} ${r.from}%, ${r.color} ${r.to}%`).join(', ') + ')',
|
||||
// The entrance rise grows from the zero line, not an arm's end.
|
||||
transformOrigin: `50% ${Math.round(downSum / gradSpan * 10000) / 100}%`,
|
||||
'--lc-i': Math.min(props.enterIndex, STAGGER_CAP),
|
||||
} as CSSProperties}
|
||||
onMouseMove={(e) => { props.onHit(hitAt(e).key) }}
|
||||
onMouseLeave={() => { props.onHit(null) }}
|
||||
onClick={(e) => { if (props.onPick !== undefined) props.onPick(props.seq, hitAt(e)) }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
/** Cross-bar lifetime highlight: a flex row mirroring the bar grid, one slice per bar still holding the hovered item; memoized. */
|
||||
interface DnaHighlightsProps {
|
||||
slices: ({ bottom: number; height: number } | null)[] | null
|
||||
from: number
|
||||
to: number
|
||||
pads: { left: number; right: number }
|
||||
}
|
||||
|
||||
export const DnaHighlights = memo(function DnaHighlights(props: DnaHighlightsProps): ReactElement | null {
|
||||
if (props.slices === null) return null
|
||||
return (
|
||||
<div className="lc-dna-hl">
|
||||
{props.pads.left > 0 ? <span className="lc-dna-cell" style={{ width: `${props.pads.left}px` }} aria-hidden="true" /> : null}
|
||||
{props.slices.slice(props.from, props.to + 1).map((s, k) => (
|
||||
<span key={props.from + k} className="lc-dna-cell">
|
||||
{s !== null ? (
|
||||
<span className="lc-dna-slice" style={{ bottom: `${s.bottom}px`, height: `${s.height}px` }} />
|
||||
) : null}
|
||||
</span>
|
||||
))}
|
||||
{props.pads.right > 0 ? <span className="lc-dna-cell" style={{ width: `${props.pads.right}px` }} aria-hidden="true" /> : null}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Fragment, memo, type ReactElement } from 'react'
|
||||
|
||||
interface DurationCurveProps {
|
||||
width: number
|
||||
height: number
|
||||
runs: string[]
|
||||
dots: [number, number][]
|
||||
}
|
||||
|
||||
export const DurationCurve = memo(function DurationCurve(props: DurationCurveProps): ReactElement {
|
||||
return (
|
||||
<svg className="lc-duration" width={props.width} height={props.height} aria-hidden="true">
|
||||
{props.runs.map((pts, i) => (
|
||||
<Fragment key={i}>
|
||||
<polyline className="lc-dur-halo" points={pts} />
|
||||
<polyline points={pts} />
|
||||
</Fragment>
|
||||
))}
|
||||
{props.dots.map((p, i) => (
|
||||
<Fragment key={i}>
|
||||
<circle className="lc-dur-halo" cx={p[0]} cy={p[1]} r={2} />
|
||||
<circle cx={p[0]} cy={p[1]} r={1.2} />
|
||||
</Fragment>
|
||||
))}
|
||||
</svg>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
/** One bar column: 14px bar + 2px gap. The turn strip and the windowing math share this grid. */
|
||||
export const BAR_W = 14
|
||||
export const BAR_GAP = 2
|
||||
/** Under this many columns every bar renders; above it the chart renders only the visible window + overscan. */
|
||||
export const TREND_WINDOW_MIN = 240
|
||||
export const WINDOW_OVERSCAN_BARS = 36
|
||||
export const WINDOW_HYSTERESIS_BARS = 8
|
||||
|
||||
export interface BarWindow {
|
||||
from: number
|
||||
to: number
|
||||
}
|
||||
|
||||
/** A large history mounts anchored to its newest bars (the layout effect then scrolls there). */
|
||||
export function initialBarWindow(n: number, overscan: number = WINDOW_OVERSCAN_BARS): BarWindow {
|
||||
return n <= TREND_WINDOW_MIN ? { from: 0, to: Math.max(0, n - 1) } : { from: Math.max(0, n - 1 - 4 * overscan), to: n - 1 }
|
||||
}
|
||||
|
||||
/** The range that must exist for the current scroll position, expressed in bar indices. */
|
||||
export function windowRangeFor(scrollLeft: number, clientWidth: number, n: number, overscan: number = WINDOW_OVERSCAN_BARS): BarWindow {
|
||||
if (n <= TREND_WINDOW_MIN) return { from: 0, to: Math.max(0, n - 1) }
|
||||
const pitch = BAR_W + BAR_GAP
|
||||
const from = Math.min(n - 1, Math.max(0, Math.floor(scrollLeft / pitch) - overscan))
|
||||
const to = Math.min(n - 1, Math.ceil((scrollLeft + clientWidth) / pitch) + overscan)
|
||||
return { from, to: Math.max(from, to) }
|
||||
}
|
||||
|
||||
/** Keep the rendered range until the scroll position leaves its overscan; then re-fill around the new position. */
|
||||
export function nextBarWindow(prev: BarWindow, desired: BarWindow, n: number, hysteresis: number = WINDOW_HYSTERESIS_BARS): BarWindow {
|
||||
if (n <= TREND_WINDOW_MIN) return { from: 0, to: Math.max(0, n - 1) }
|
||||
if (desired.from >= prev.from && desired.to <= prev.to) return prev
|
||||
return {
|
||||
from: Math.max(0, desired.from - hysteresis),
|
||||
to: Math.min(n - 1, desired.to + hysteresis),
|
||||
}
|
||||
}
|
||||
|
||||
/** Flex-row padding that reproduces the skipped columns exactly (one 2px gap joins each side to the rendered bars). */
|
||||
export function windowPads(from: number, to: number, n: number): { left: number; right: number } {
|
||||
const pitch = BAR_W + BAR_GAP
|
||||
return {
|
||||
left: from > 0 ? from * pitch - BAR_GAP : 0,
|
||||
right: to < n - 1 ? (n - 1 - to) * pitch - BAR_GAP : 0,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
/** Give the idle slice (or the timeout fallback) room to mount the deferred block without blocking a scroll frame. */
|
||||
const IDLE_TIMEOUT_MS = 200
|
||||
|
||||
/**
|
||||
* Mount a below-the-fold block only once its placeholder nears the conversation viewport (or, without
|
||||
* IntersectionObserver, immediately): the first commit then stays small and the deferred cards mount in an
|
||||
* idle slice instead of stretching the open's long task. The placeholder keeps the row's measured height.
|
||||
*/
|
||||
export function useLazyMount(force = false): [(el: HTMLElement | null) => void, boolean] {
|
||||
const [node, setNode] = useState<HTMLElement | null>(null)
|
||||
const ref = useCallback((el: HTMLElement | null) => { setNode(el) }, [])
|
||||
const [mounted, setMounted] = useState(() => force || typeof IntersectionObserver !== 'function')
|
||||
useEffect(() => {
|
||||
if (mounted) return
|
||||
if (force) {
|
||||
setMounted(true)
|
||||
return
|
||||
}
|
||||
if (node === null) return
|
||||
/* v8 ignore start -- jsdom has neither IntersectionObserver nor layout: without it the block mounts at once. */
|
||||
let idle = 0
|
||||
const reveal = (): void => {
|
||||
if (typeof requestIdleCallback === 'function') {
|
||||
idle = requestIdleCallback(() => { setMounted(true) }, { timeout: IDLE_TIMEOUT_MS })
|
||||
} else {
|
||||
idle = window.setTimeout(() => { setMounted(true) }, 0)
|
||||
}
|
||||
}
|
||||
const root = node.closest('[data-conversation-scroll]')
|
||||
const observer = new IntersectionObserver(([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
observer.disconnect()
|
||||
reveal()
|
||||
}
|
||||
}, { root: root instanceof Element ? root : null, rootMargin: '200px' })
|
||||
observer.observe(node)
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
if (idle !== 0) {
|
||||
if (typeof cancelIdleCallback === 'function') cancelIdleCallback(idle)
|
||||
else clearTimeout(idle)
|
||||
}
|
||||
}
|
||||
/* v8 ignore stop */
|
||||
}, [node, mounted, force])
|
||||
return [ref, mounted]
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useEffect, useState, type RefObject } from 'react'
|
||||
|
||||
/** Extra rows rendered above and below the viewport so a fling never exposes blank space. */
|
||||
export const ROW_WINDOW_OVERSCAN = 8
|
||||
|
||||
export interface RowRange {
|
||||
from: number
|
||||
to: number
|
||||
}
|
||||
|
||||
/** The row slice that covers a fixed-row-height scroll viewport. Pure, so the card tests pin the geometry. */
|
||||
export function rowWindowFor(
|
||||
scrollTop: number,
|
||||
viewport: number,
|
||||
rowHeight: number,
|
||||
count: number,
|
||||
overscan: number = ROW_WINDOW_OVERSCAN,
|
||||
): RowRange {
|
||||
if (count <= 0 || rowHeight <= 0 || viewport <= 0) return { from: 0, to: Math.max(0, count - 1) }
|
||||
const from = Math.min(count - 1, Math.max(0, Math.floor(scrollTop / rowHeight) - overscan))
|
||||
const to = Math.min(count - 1, Math.ceil((scrollTop + viewport) / rowHeight) + overscan)
|
||||
return { from, to: Math.max(from, to) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Render only the rows a fixed-row-height scroller shows: the list keeps its full height through caller-owned
|
||||
* spacers, every readout stays in the model, and the window only shifts when a scroll crosses a row boundary.
|
||||
* Disabled (or unmeasured) lists render in full, so tests and narrow wrapped layouts never change.
|
||||
*/
|
||||
export function useRowWindow(
|
||||
ref: RefObject<HTMLElement | null>,
|
||||
count: number,
|
||||
rowHeight: number,
|
||||
enabled: boolean,
|
||||
overscan: number = ROW_WINDOW_OVERSCAN,
|
||||
): RowRange {
|
||||
const full: RowRange = { from: 0, to: Math.max(0, count - 1) }
|
||||
const [range, setRange] = useState<RowRange>(full)
|
||||
useEffect(() => {
|
||||
const el = ref.current
|
||||
if (el === null || !enabled || el.clientHeight <= 0) {
|
||||
setRange(current => (current.from === 0 && current.to === Math.max(0, count - 1) ? current : full))
|
||||
return
|
||||
}
|
||||
let frame = 0
|
||||
const update = (): void => {
|
||||
frame = 0
|
||||
const next = rowWindowFor(el.scrollTop, el.clientHeight, rowHeight, count, overscan)
|
||||
setRange(current => (current.from === next.from && current.to === next.to ? current : next))
|
||||
}
|
||||
const schedule = (): void => {
|
||||
if (frame === 0) frame = requestAnimationFrame(update)
|
||||
}
|
||||
el.addEventListener('scroll', schedule, { passive: true })
|
||||
/* v8 ignore start -- jsdom has neither a layout nor ResizeObserver; the live page covers a pane resize. */
|
||||
const observer = typeof ResizeObserver === 'function' ? new ResizeObserver(schedule) : null
|
||||
observer?.observe(el)
|
||||
/* v8 ignore stop */
|
||||
update()
|
||||
return () => {
|
||||
el.removeEventListener('scroll', schedule)
|
||||
if (frame !== 0) cancelAnimationFrame(frame)
|
||||
/* v8 ignore next -- the observer only exists where ResizeObserver is available (not jsdom). */
|
||||
observer?.disconnect()
|
||||
}
|
||||
}, [ref, count, rowHeight, enabled, overscan, full.from, full.to])
|
||||
return enabled ? range : full
|
||||
}
|
||||
@@ -24,6 +24,7 @@
|
||||
.lc-trend-ctl, .lc-heat-ctl { margin-left: auto; display: flex; gap: 6px; align-items: center; }
|
||||
.lc-trend-ctl .lc-gran, .lc-heat-ctl .lc-gran { margin-left: 0; }
|
||||
.lc-heat-ctl .lc-card-sub { margin-left: 0; }
|
||||
.lc-card.lc-deferred-skeleton { background: var(--dsw-alias-bg-layer-2); }
|
||||
.lc-trend-mods { margin-left: 0; flex: none; }
|
||||
.lc-gran-btn { border: 0; background: transparent; color: var(--dsw-alias-label-secondary); font-size: 11px; line-height: 1; padding: 3px 8px; border-radius: 5px; cursor: pointer; font-family: inherit; transition: color var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out); }
|
||||
.lc-gran-btn:hover { color: var(--dsw-alias-label-primary); }
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.lc-event { display: flex; align-items: center; gap: 8px; padding: 3px 0; }
|
||||
.lc-event { content-visibility: auto; contain-intrinsic-size: auto 25px; display: flex; align-items: center; gap: 8px; padding: 3px 0; }
|
||||
.lc-event-icon { width: 18px; text-align: center; color: var(--dsw-alias-state-warn-primary); }
|
||||
.lc-event-icon.lc-event-inject { color: var(--color-purple-500); }
|
||||
.lc-event-icon.lc-event-model { color: var(--dsw-alias-brand-primary); }
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
.lc-fa-meta-tip { top: calc(100% + 6px); left: 0; max-width: 260px; white-space: normal; line-height: 1.5; }
|
||||
.lc-fa-item { border: 1px solid transparent; border-radius: 8px; transition: background-color var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out), border-color var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out); }
|
||||
.lc-fa-item-on { background: var(--dsw-alias-bg-layer-2); border-color: var(--dsw-alias-border-l2); }
|
||||
.lc-fa-row { display: flex; align-items: center; gap: 7px; width: 100%; border: 0; background: transparent; color: var(--dsw-alias-label-primary); font: inherit; font-size: 12px; padding: 6px 8px; cursor: pointer; text-align: left; border-radius: 7px; transition: background-color var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out); }
|
||||
.lc-row-pad { flex: none; }
|
||||
.lc-fa-row { content-visibility: auto; contain-intrinsic-size: auto 27px; display: flex; align-items: center; gap: 7px; width: 100%; border: 0; background: transparent; color: var(--dsw-alias-label-primary); font: inherit; font-size: 12px; padding: 6px 8px; cursor: pointer; text-align: left; border-radius: 7px; transition: background-color var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out); }
|
||||
.lc-fa-form { flex: none; width: 20px; height: 14px; display: flex; align-items: center; justify-content: center; font-size: 11px; opacity: 0.85; }
|
||||
.lc-fa-lang { display: inline-flex; align-items: center; justify-content: center; min-width: 16px; height: 13px; padding: 0 2px; border-radius: 4px; font-size: 8px; font-weight: 700; line-height: 1; }
|
||||
.lc-fa-row .lc-br-chev { margin-right: -2px; }
|
||||
|
||||
@@ -129,6 +129,10 @@ a.lc-flow-head:hover .lc-flow-label { color: var(--dsw-alias-label-primary); }
|
||||
.lc-tstrip-seg-on { filter: brightness(1.18); }
|
||||
.lc-tstrip-dim .lc-tstrip-seg { opacity: 0.35; }
|
||||
.lc-tstrip-dim .lc-tstrip-seg-on { opacity: 1; }
|
||||
/* Dense mode: one base gradient + one kind-highlight gradient instead of thousands of sub-pixel divs. */
|
||||
.lc-tstrip-gradient { position: absolute; inset: 0; pointer-events: none; transition: opacity var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out); }
|
||||
.lc-tstrip-gradient-base { transition: opacity var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out); }
|
||||
.lc-tstrip-dim .lc-tstrip-gradient-base { opacity: 0.35; }
|
||||
/* The ends pin outward — first hugs the left edge, last the right — so width pressure squeezes the interior labels instead of the endpoints. */
|
||||
.lc-tstrip-axis { position: relative; height: 13px; margin-top: 5px; font-size: 10px; line-height: 13px; color: var(--dsw-alias-label-secondary); font-variant-numeric: tabular-nums; }
|
||||
.lc-tstrip-tick { position: absolute; transform: translateX(-50%); white-space: nowrap; }
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { createElement as h } from 'react'
|
||||
import { afterAll, beforeAll, describe, test, vi } from 'vitest'
|
||||
import type { ContextHeaders } from '../../../src/shared/types'
|
||||
import { mount, query, queryAll, surfaceNode, until } from '../helpers/kit'
|
||||
import { Browser, props, tl } from './browserHarness'
|
||||
|
||||
let originals: [string, PropertyDescriptor | undefined][] = []
|
||||
|
||||
/** jsdom lays nothing out: give the feed scroller a 500×400 box so the browser's windowing path runs for real. */
|
||||
beforeAll(() => {
|
||||
originals = (['clientWidth', 'clientHeight'] as const)
|
||||
.map(name => [name, Object.getOwnPropertyDescriptor(HTMLElement.prototype, name)] as [string, PropertyDescriptor | undefined])
|
||||
Object.defineProperty(HTMLElement.prototype, 'clientWidth', {
|
||||
configurable: true,
|
||||
get(this: HTMLElement): number {
|
||||
if (this.classList.contains('lc-br-scroll')) return 500
|
||||
const own = Object.getOwnPropertyDescriptor(Element.prototype, 'clientWidth')?.get
|
||||
return own === undefined ? 0 : own.call(this)
|
||||
},
|
||||
})
|
||||
Object.defineProperty(HTMLElement.prototype, 'clientHeight', {
|
||||
configurable: true,
|
||||
get(this: HTMLElement): number {
|
||||
if (this.classList.contains('lc-br-scroll')) return 400
|
||||
const own = Object.getOwnPropertyDescriptor(Element.prototype, 'clientHeight')?.get
|
||||
return own === undefined ? 0 : own.call(this)
|
||||
},
|
||||
})
|
||||
vi.stubGlobal('ResizeObserver', class {
|
||||
observe = (): void => {}
|
||||
disconnect = (): void => {}
|
||||
})
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllGlobals()
|
||||
for (const [name, desc] of originals) {
|
||||
if (desc === undefined) delete (HTMLElement.prototype as unknown as Record<string, unknown>)[name]
|
||||
else Object.defineProperty(HTMLElement.prototype, name, desc)
|
||||
}
|
||||
})
|
||||
|
||||
const NODES = Array.from({ length: 200 }, (_, i) => surfaceNode({
|
||||
seq: i + 1, tokens: 10, time: 1_000 + i, cat: 'tool', tool: 'bash',
|
||||
}))
|
||||
|
||||
const HEADERS: ContextHeaders = {
|
||||
headers: [{ seq: 0, time: 0, systemTokens: 100, tools: [{ name: 'bash', tokens: 50 }] }],
|
||||
}
|
||||
|
||||
function data() {
|
||||
return tl({
|
||||
current: { system: 0, tools: 0, user: 0, inject: 0, skill: 0, assistant: 0, tool: 2_000, total: 2_000 },
|
||||
requests: [],
|
||||
nodes: NODES,
|
||||
})
|
||||
}
|
||||
|
||||
describe('ContextBrowser DNA feed windowing', () => {
|
||||
test('renders only the measured window with full-height pads and follows the scroll', async () => {
|
||||
const m = await mount(h(Browser, props({ data: data(), headers: HEADERS, dna: true, onDnaChange: () => {} })))
|
||||
try {
|
||||
const rows = queryAll(m.container, '.lc-br-flat > .lc-br-elem')
|
||||
assert.ok(rows.length < 200, `windowed rows (${rows.length})`)
|
||||
assert.ok(rows.length > 10, 'a window wide enough to scroll through')
|
||||
const pads = queryAll<HTMLElement>(m.container, '.lc-row-pad')
|
||||
assert.equal(pads.length, 1)
|
||||
assert.ok(Number.parseFloat(pads[0].style.height) > 0)
|
||||
// The composition bar still carries every item: only the feed's DOM is windowed.
|
||||
assert.ok(queryAll(m.container, '.lc-stacked-seg').length >= 200, 'composition bar keeps every item')
|
||||
const scroller = query<HTMLElement>(m.container, '.lc-br-scroll')
|
||||
scroller.scrollTop = 600
|
||||
await new Promise(resolve => setTimeout(resolve, 30))
|
||||
scroller.dispatchEvent(new Event('scroll'))
|
||||
await until(() => queryAll<HTMLElement>(m.container, '.lc-row-pad').length === 2, 'a top pad appears after scrolling')
|
||||
const shifted = queryAll<HTMLElement>(m.container, '.lc-br-flat > .lc-br-elem').length
|
||||
assert.ok(shifted < 200)
|
||||
// Header/tool groups are the feed's tail: after the bottom pad, so they stay at the feed end.
|
||||
const children = [...query<HTMLElement>(m.container, '.lc-br-flat').children] as HTMLElement[]
|
||||
const lastPad = children.map((el, i) => el.classList.contains('lc-row-pad') ? i : -1).filter(i => i >= 0).at(-1)
|
||||
const tail = children.at(-1)
|
||||
assert.ok(lastPad !== undefined && tail !== undefined
|
||||
&& tail.classList.contains('lc-br-elem') && children.length - 1 > lastPad, 'tail rows follow the bottom pad')
|
||||
} finally {
|
||||
await m.unmount()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createElement as h } from 'react'
|
||||
import assert from 'node:assert/strict'
|
||||
import { describe, test } from 'vitest'
|
||||
import { describe, test, vi } from 'vitest'
|
||||
import { DICT_EN } from '../../../src/client/i18n'
|
||||
import type { UseChatLike } from '../../../src/client/services'
|
||||
import type { ContextTimeline } from '../../../src/shared/types'
|
||||
@@ -9,6 +9,9 @@ import { click, flush, hover, mount, query, queryAll, text, unhover } from '../h
|
||||
import { buttonByText, makeView, projectionsFor, T0, timeline } from './contextViewHarness'
|
||||
|
||||
describe('ContextView — file activity card', () => {
|
||||
// Chart hover previews the browser/file activity after a short rest; a rapid sweep keeps only the trailing
|
||||
// value (contextView's hover debounce), so tests wait for the trailing update before asserting.
|
||||
const rest = (): Promise<void> => new Promise(resolve => setTimeout(resolve, 200))
|
||||
/** Six steps with two file ops: a live read (seq 3) and an edit (seq 5) compacted away before seq 6 dispatched. */
|
||||
function fileTimeline(): ContextTimeline {
|
||||
const req = (seq: number, time: number, extra: Record<string, unknown> = {}) =>
|
||||
@@ -64,6 +67,7 @@ describe('ContextView — file activity card', () => {
|
||||
|
||||
// Hovering the FIRST bar bounds the fold at the second request: the edit drops out.
|
||||
await hover(query(m.container, '.lc-bar[data-seq="2"]'))
|
||||
await rest()
|
||||
assert.ok(text(card).includes('Turn 1 · Step 1'))
|
||||
assert.equal(queryAll(card, '.lc-fa-meta-delta').length, 0)
|
||||
assert.equal(queryAll(card, '.lc-fa-row').length, 1)
|
||||
@@ -71,6 +75,7 @@ describe('ContextView — file activity card', () => {
|
||||
|
||||
// The turn-less mid step falls back to zero labels and still bounds at the next request.
|
||||
await hover(query(m.container, '.lc-bar[data-seq="6"]'))
|
||||
await rest()
|
||||
assert.ok(text(card).includes('Turn 0 · Step 0'))
|
||||
assert.ok(text(card).includes('+1'))
|
||||
|
||||
@@ -79,16 +84,45 @@ describe('ContextView — file activity card', () => {
|
||||
await m.unmount()
|
||||
})
|
||||
|
||||
test('a rapid second hover keeps only the trailing preview', async () => {
|
||||
const { m, card } = await mountFiles('sv-files-trailing')
|
||||
// Freeze the hover clock across both moves: the first previews immediately, the second can only trail.
|
||||
const frozen = performance.now()
|
||||
const clock = vi.spyOn(performance, 'now').mockReturnValue(frozen)
|
||||
await hover(query(m.container, '.lc-bar[data-seq="2"]'))
|
||||
await hover(query(m.container, '.lc-bar[data-seq="4"]'))
|
||||
await hover(query(m.container, '.lc-bar[data-seq="6"]'))
|
||||
clock.mockRestore()
|
||||
await rest()
|
||||
assert.ok(text(card).includes('Turn 0 · Step 0'))
|
||||
await m.unmount()
|
||||
})
|
||||
|
||||
test('DNA mode waits for the pointer to rest before updating the preview', async () => {
|
||||
const { m, card } = await mountFiles('sv-files-dna-hover')
|
||||
await click(buttonByText(m.container, DICT_EN['trend.dna']))
|
||||
await hover(query(m.container, '.lc-bar[data-seq="2"]'))
|
||||
await new Promise(resolve => setTimeout(resolve, 80))
|
||||
// The chart lights its own bar immediately; the cross-card preview stays on the latest scope.
|
||||
assert.ok(text(card).includes(DICT_EN['files.scopeLatest']))
|
||||
await new Promise(resolve => setTimeout(resolve, 300))
|
||||
assert.ok(text(card).includes('Turn 1 · Step 1'))
|
||||
await m.unmount()
|
||||
})
|
||||
|
||||
test('turn aggregates label the scope; op clicks reveal the result in the browser', async () => {
|
||||
const { m, card } = await mountFiles('sv-files-locate')
|
||||
const pick = query<HTMLSelectElement>(m.container, 'select.lc-br-pick')
|
||||
|
||||
await click(buttonByText(m.container, DICT_EN['gran.turn']))
|
||||
await hover(query(m.container, '.lc-bar[data-seq="4"]'))
|
||||
await rest()
|
||||
assert.ok(text(card).includes(DICT_EN['detail.turn'].replace('{t}', '1').replace('{n}', '2')))
|
||||
await hover(query(m.container, '.lc-bar[data-seq="7"]'))
|
||||
await rest()
|
||||
assert.ok(text(card).includes(DICT_EN['detail.turn'].replace('{t}', '0').replace('{n}', '2')))
|
||||
await hover(query(m.container, '.lc-bar[data-seq="8"]'))
|
||||
await rest()
|
||||
assert.ok(text(card).includes('Turn 2 · Step 1'))
|
||||
await unhover(query(m.container, '.lc-chart'))
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { act, createElement as h } from 'react'
|
||||
import { describe, test } from 'vitest'
|
||||
import type { SurfaceNode } from '../../../src/shared/types'
|
||||
import type { UseChatLike } from '../../../src/client/services'
|
||||
import { TestClientCtx } from '../helpers/harness'
|
||||
import { flush, makeKit, mount, queryAll } from '../helpers/kit'
|
||||
import { buttonByText, makeView, projectionsFor, T0, timeline } from './contextViewHarness'
|
||||
|
||||
const kit = makeKit()
|
||||
|
||||
function bigTimeline() {
|
||||
const requests = Array.from({ length: 600 }, (_, i) => ({
|
||||
time: T0 + i * 1_000,
|
||||
seq: i + 1,
|
||||
turn: 1 + Math.floor(i / 100),
|
||||
step: i % 100,
|
||||
system: 10,
|
||||
tools: 20,
|
||||
user: 5,
|
||||
inject: 0,
|
||||
assistant: 15,
|
||||
tool: 5,
|
||||
total: 55,
|
||||
activeMs: 1_000 + i,
|
||||
}))
|
||||
const nodes: SurfaceNode[] = Array.from({ length: 200 }, (_, i) => ({
|
||||
seq: i + 1, cat: 'assistant', tokens: 10, text: `reply ${i}`,
|
||||
})) as SurfaceNode[]
|
||||
return timeline({ requests, nodes })
|
||||
}
|
||||
|
||||
describe('ContextView lazy DNA bands', () => {
|
||||
test('a long log builds DNA bands only for the rendered window', async () => {
|
||||
const View = makeView(new TestClientCtx())
|
||||
const m = await mount(h(View, {
|
||||
sessionId: 'sv-lazy-dna',
|
||||
useProjection: projectionsFor(bigTimeline()),
|
||||
useChat: (selector => selector({ legacy: { nodes: [] } })) as UseChatLike,
|
||||
}))
|
||||
try {
|
||||
await act(async () => { buttonByText(m.container, kit.t('trend.dna')).click() })
|
||||
await flush()
|
||||
const dnaBars = queryAll(m.container, '.lc-bar-dna')
|
||||
assert.ok(dnaBars.length > 0, 'DNA bars render from the lazy accessor')
|
||||
assert.ok(dnaBars.length < 600, `only the window is assembled (${dnaBars.length})`)
|
||||
assert.equal(queryAll(m.container, '.lc-cat-seg').length, 0)
|
||||
} finally {
|
||||
await m.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
test('delta mode on a long log keeps every band so its axis stays exact', async () => {
|
||||
const View = makeView(new TestClientCtx())
|
||||
const m = await mount(h(View, {
|
||||
sessionId: 'sv-lazy-dna-delta',
|
||||
useProjection: projectionsFor(bigTimeline()),
|
||||
useChat: (selector => selector({ legacy: { nodes: [] } })) as UseChatLike,
|
||||
}))
|
||||
try {
|
||||
await act(async () => { buttonByText(m.container, kit.t('trend.dna')).click() })
|
||||
await act(async () => { buttonByText(m.container, kit.t('gran.delta')).click() })
|
||||
await flush()
|
||||
// Eager bands plus delta mode still render the windowed DNA bars (correct axis needs the full model).
|
||||
assert.ok(queryAll(m.container, '.lc-bar-dna').length > 0)
|
||||
} finally {
|
||||
await m.unmount()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,73 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { act, createElement as h } from 'react'
|
||||
import { afterEach, describe, test, vi } from 'vitest'
|
||||
import type { UseChatLike } from '../../../src/client/services'
|
||||
import { TestClientCtx } from '../helpers/harness'
|
||||
import { click, flush, mount, queryAll, text } from '../helpers/kit'
|
||||
import { DICT_EN } from '../../../src/client/i18n'
|
||||
import { makeView, projectionsFor, timeline } from './contextViewHarness'
|
||||
|
||||
type ObserverCallback = IntersectionObserverCallback
|
||||
|
||||
function stubIntersection(): { callbacks: ObserverCallback[]; restore: () => void } {
|
||||
const callbacks: ObserverCallback[] = []
|
||||
class FakeObserver {
|
||||
constructor(callback: ObserverCallback) { callbacks.push(callback) }
|
||||
observe = (): void => {}
|
||||
disconnect = (): void => {}
|
||||
}
|
||||
vi.stubGlobal('IntersectionObserver', FakeObserver)
|
||||
return { callbacks, restore: () => { vi.unstubAllGlobals() } }
|
||||
}
|
||||
|
||||
describe('ContextView below-fold lazy mount', () => {
|
||||
afterEach(() => { vi.unstubAllGlobals() })
|
||||
|
||||
test('keeps two skeletons until the row nears the viewport, then mounts both cards', async () => {
|
||||
const observer = stubIntersection()
|
||||
const idle: (() => void)[] = []
|
||||
vi.stubGlobal('requestIdleCallback', (callback: () => void) => { idle.push(callback); return 1 })
|
||||
vi.stubGlobal('cancelIdleCallback', () => {})
|
||||
const View = makeView(new TestClientCtx())
|
||||
const m = await mount(h(View, {
|
||||
sessionId: 'sv-lazy',
|
||||
useProjection: projectionsFor(timeline({
|
||||
events: [{ seq: 1, time: 1, kind: 'inject', tokens: 1 }],
|
||||
fileOps: [{ seq: 2, path: '/src/a.ts', kind: 'read', tool: 'read', err: false, added: 0, removed: 0 }],
|
||||
})),
|
||||
useChat: (selector => selector({ legacy: { nodes: [] } })) as UseChatLike,
|
||||
}))
|
||||
try {
|
||||
// The visible browser card and the below-fold event/file row both hold cards-shaped skeletons.
|
||||
assert.equal(queryAll(m.container, '.lc-deferred-skeleton').length, 3)
|
||||
assert.equal(queryAll(m.container, '.lc-fa-card').length, 0)
|
||||
await act(async () => {
|
||||
for (const callback of observer.callbacks) callback([{ isIntersecting: true } as IntersectionObserverEntry], {} as IntersectionObserver)
|
||||
})
|
||||
assert.equal(idle.length, 2)
|
||||
await act(async () => { for (const run of idle) run() })
|
||||
await flush()
|
||||
assert.equal(queryAll(m.container, '.lc-deferred-skeleton').length, 0)
|
||||
assert.equal(queryAll(m.container, '.lc-fa-card').length, 1)
|
||||
|
||||
// A stats file-pill jump forces the lazy row: the card exists and the jump can land on it.
|
||||
await m.unmount()
|
||||
const second = await mount(h(View, {
|
||||
sessionId: 'sv-lazy-jump',
|
||||
useProjection: projectionsFor(timeline({
|
||||
fileOps: [{ seq: 2, path: '/src/a.ts', kind: 'read', tool: 'read', err: false, added: 0, removed: 0 }],
|
||||
})),
|
||||
useChat: (selector => selector({ legacy: { nodes: [] } })) as UseChatLike,
|
||||
}))
|
||||
const pill = queryAll(second.container, '.lc-flow-pill').find(el => text(el).startsWith(DICT_EN['files.kind.read']))
|
||||
assert.ok(pill !== undefined)
|
||||
await click(pill)
|
||||
await flush()
|
||||
assert.equal(queryAll(second.container, '.lc-fa-card').length, 1, 'the jump mounts the lazy card')
|
||||
await second.unmount()
|
||||
} finally {
|
||||
await m.unmount()
|
||||
observer.restore()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { describe, test } from 'vitest'
|
||||
import { assembleCached, cachedByData } from '../../../src/client/components/derivedCache'
|
||||
import type { ContextHeaders } from '../../../src/shared/types'
|
||||
import { timeline } from './contextViewHarness'
|
||||
|
||||
const headers: ContextHeaders = {
|
||||
headers: [{ seq: 1, time: 1, systemTokens: 0, tools: [] }],
|
||||
}
|
||||
|
||||
describe('derived cache', () => {
|
||||
test('cachedByData reuses a value per projection and key', () => {
|
||||
const data = timeline()
|
||||
let calls = 0
|
||||
const compute = () => { calls++; return { calls } }
|
||||
const first = cachedByData(data, 'k', compute)
|
||||
const again = cachedByData(data, 'k', compute)
|
||||
assert.equal(first, again)
|
||||
assert.equal(calls, 1)
|
||||
const other = cachedByData(data, 'other', compute)
|
||||
assert.notEqual(first, other)
|
||||
assert.equal(calls, 2)
|
||||
const otherData = timeline()
|
||||
assert.notEqual(cachedByData(otherData, 'k', compute), first)
|
||||
assert.equal(calls, 3)
|
||||
})
|
||||
|
||||
test('assembleCached reuses one assembled view per projection, headers and step', () => {
|
||||
const data = timeline()
|
||||
const live = assembleCached(data, null, null)
|
||||
assert.equal(assembleCached(data, null, null), live)
|
||||
const withHeaders = assembleCached(data, headers, null)
|
||||
assert.notEqual(withHeaders, live)
|
||||
assert.equal(assembleCached(data, headers, null), withHeaders)
|
||||
const otherHeaders: ContextHeaders = { headers: [] }
|
||||
assert.notEqual(assembleCached(data, otherHeaders, null), withHeaders)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,128 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { createElement as h } from 'react'
|
||||
import { afterAll, beforeAll, describe, test, vi } from 'vitest'
|
||||
import { makeFileCard } from '../../../src/client/components/fileCard'
|
||||
import type { FileActivity, FileEntry, FileOp } from '../../../src/client/fileActivity'
|
||||
import { createContextSettings } from '../../../src/client/settings'
|
||||
import { makeKit, mount, query, queryAll, until } from '../helpers/kit'
|
||||
|
||||
const kit = makeKit()
|
||||
const FileCard = makeFileCard(kit, createContextSettings())
|
||||
|
||||
const PITCH = 41
|
||||
const GAP = 2
|
||||
const T0 = 1700000000000
|
||||
let originals: [string, PropertyDescriptor | undefined][] = []
|
||||
|
||||
/** jsdom lays nothing out: give the list a 500×320 box and the rows a fixed 41px pitch so the card's own
|
||||
* windowing path (wide + unexpanded) runs for real: measure → window → scroll. */
|
||||
beforeAll(() => {
|
||||
// A no-op observer covers the card's ResizeObserver path; the list is measured on mount anyway.
|
||||
vi.stubGlobal('ResizeObserver', class {
|
||||
observe = (): void => {}
|
||||
disconnect = (): void => {}
|
||||
})
|
||||
originals = (['clientWidth', 'clientHeight', 'getBoundingClientRect'] as const)
|
||||
.map(name => [name, Object.getOwnPropertyDescriptor(HTMLElement.prototype, name)] as [string, PropertyDescriptor | undefined])
|
||||
Object.defineProperty(HTMLElement.prototype, 'clientWidth', {
|
||||
configurable: true,
|
||||
get(this: HTMLElement): number {
|
||||
if (this.classList.contains('lc-fa-list')) return 500
|
||||
const own = Object.getOwnPropertyDescriptor(Element.prototype, 'clientWidth')?.get
|
||||
return own === undefined ? 0 : own.call(this)
|
||||
},
|
||||
})
|
||||
Object.defineProperty(HTMLElement.prototype, 'clientHeight', {
|
||||
configurable: true,
|
||||
get(this: HTMLElement): number {
|
||||
if (this.classList.contains('lc-fa-list')) return 320
|
||||
const own = Object.getOwnPropertyDescriptor(Element.prototype, 'clientHeight')?.get
|
||||
return own === undefined ? 0 : own.call(this)
|
||||
},
|
||||
})
|
||||
const rect = Object.getOwnPropertyDescriptor(Element.prototype, 'getBoundingClientRect')!.value as () => DOMRect
|
||||
Object.defineProperty(HTMLElement.prototype, 'getBoundingClientRect', {
|
||||
configurable: true,
|
||||
value(this: HTMLElement): DOMRect {
|
||||
if (this.classList.contains('lc-fa-list')) {
|
||||
return { left: 0, top: 0, width: 500, height: 320, right: 500, bottom: 320, x: 0, y: 0, toJSON: () => ({}) } as DOMRect
|
||||
}
|
||||
if (this.classList.contains('lc-fa-item')) {
|
||||
const items = [...(this.parentElement?.querySelectorAll('.lc-fa-item') ?? [])]
|
||||
const i = items.indexOf(this)
|
||||
return {
|
||||
left: 0, top: i * PITCH, width: 500, height: PITCH - GAP,
|
||||
right: 500, bottom: i * PITCH + PITCH - GAP, x: 0, y: i * PITCH, toJSON: () => ({}),
|
||||
} as DOMRect
|
||||
}
|
||||
return rect.call(this) as DOMRect
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllGlobals()
|
||||
for (const [name, desc] of originals) {
|
||||
if (desc === undefined) delete (HTMLElement.prototype as unknown as Record<string, unknown>)[name]
|
||||
else Object.defineProperty(HTMLElement.prototype, name, desc)
|
||||
}
|
||||
})
|
||||
|
||||
function entry(index: number): FileEntry {
|
||||
const op: FileOp = { seq: index, kind: 'read', tool: 'read', time: T0 + index, err: false, added: 0, removed: 0, path: '' }
|
||||
return { path: `/src/file-${String(index).padStart(3, '0')}.ts`, form: 'text', reads: 1, writes: 0, searches: 0, added: 0, removed: 0, errs: 0, ops: [op] }
|
||||
}
|
||||
|
||||
function activityOf(count: number): FileActivity {
|
||||
return {
|
||||
entries: Array.from({ length: count }, (_, i) => entry(i)),
|
||||
totals: {
|
||||
read: { files: count, ops: count }, write: { files: 0, ops: 0 }, search: { files: 0, ops: 0 }, image: { files: 0, ops: 0 },
|
||||
added: 0, removed: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const props = { activity: activityOf(60), scope: 'scope', state: 'ready' as const, onRetry: undefined }
|
||||
|
||||
describe('FileCard row windowing', () => {
|
||||
test('renders a measured window with full-height pads and follows the scroll', async () => {
|
||||
const m = await mount(h(FileCard, props))
|
||||
try {
|
||||
const list = query<HTMLElement>(m.container, '.lc-fa-list')
|
||||
await until(() => queryAll(m.container, '.lc-fa-item').length < 60, 'the list windows after measuring')
|
||||
const rendered = queryAll(m.container, '.lc-fa-item').length
|
||||
assert.ok(rendered < 60 && rendered > 8)
|
||||
const pads = queryAll<HTMLElement>(m.container, '.lc-row-pad')
|
||||
assert.equal(pads.length, 1)
|
||||
assert.ok(Number.parseFloat(pads[0].style.height) > 0)
|
||||
assert.equal(list.clientHeight, 320) // the list keeps its box; the full height lives in the pads
|
||||
list.scrollTop = 900
|
||||
await new Promise(resolve => setTimeout(resolve, 40))
|
||||
list.dispatchEvent(new Event('scroll'))
|
||||
await until(() => queryAll<HTMLElement>(m.container, '.lc-row-pad').length === 2, 'a top pad appears after scrolling')
|
||||
const afterScroll = queryAll(m.container, '.lc-fa-item').length
|
||||
assert.ok(afterScroll < 60 && afterScroll >= 8)
|
||||
// Pads reproduce the skipped rows' pitch, letting the flex gap supply one slot between pad and row.
|
||||
const [topPad, bottomPad] = queryAll<HTMLElement>(m.container, '.lc-row-pad')
|
||||
assert.equal(topPad.style.height, `${13 * PITCH - GAP}px`)
|
||||
assert.equal(bottomPad.style.height, `${21 * PITCH - GAP}px`)
|
||||
} finally {
|
||||
await m.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
test('an expanded row falls back to the full list, then windows again when closed', async () => {
|
||||
const m = await mount(h(FileCard, props))
|
||||
try {
|
||||
await until(() => queryAll(m.container, '.lc-fa-item').length < 60, 'windowed')
|
||||
const first = query<HTMLElement>(m.container, '.lc-fa-row')
|
||||
first.click()
|
||||
await until(() => queryAll(m.container, '.lc-fa-item').length === 60, 'expanded row shows the full list')
|
||||
first.click()
|
||||
await until(() => queryAll(m.container, '.lc-fa-item').length < 60, 'closing the row re-windows')
|
||||
} finally {
|
||||
await m.unmount()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -220,6 +220,37 @@ describe('StackedBar hover link', () => {
|
||||
await m.unmount()
|
||||
})
|
||||
|
||||
test('a tip whose boxes collapse to zero width clears a previous shift', async () => {
|
||||
const props = { parts: [part('system', 100, 120), part('user', 900)], max: 2000, hoverKey: 'system' }
|
||||
const m = await mount(h(StackedBar, props))
|
||||
const wrap = query(m.container, '.lc-stacked-wrap')
|
||||
const tip = query(m.container, '.lc-bar-tip')
|
||||
const pin = (el: Element, width: number): void => {
|
||||
Object.defineProperty(el, 'getBoundingClientRect', {
|
||||
configurable: true,
|
||||
value: () => ({ left: 0, top: 0, width, height: 0, right: width, bottom: 0 }),
|
||||
})
|
||||
}
|
||||
pin(wrap, 400)
|
||||
pin(tip, 300)
|
||||
await m.update(h(StackedBar, props))
|
||||
assert.equal(tip.style.transform, 'translateX(calc(-50% + 140px))')
|
||||
pin(wrap, 0)
|
||||
pin(tip, 0)
|
||||
await m.update(h(StackedBar, props))
|
||||
assert.equal(tip.style.transform, '')
|
||||
await m.unmount()
|
||||
})
|
||||
|
||||
test('a tip whose boxes lay out at zero width keeps the bare anchor', async () => {
|
||||
// jsdom reports zero rects: the measure pass must bail out without shifting the bubble.
|
||||
const m = await mount(h(StackedBar, { parts: [part('system', 100, 120), part('user', 900)], max: 2000, hoverKey: 'system' }))
|
||||
const tip = query(m.container, '.lc-bar-tip')
|
||||
await m.update(h(StackedBar, { parts: [part('system', 100, 120), part('user', 900)], max: 2000, hoverKey: 'system' }))
|
||||
assert.equal(tip.style.transform, '')
|
||||
await m.unmount()
|
||||
})
|
||||
|
||||
test('hover without onHoverKey is a no-op and never throws', async () => {
|
||||
const m = await mount(h(StackedBar, { parts: [part('user', 100)], max: 200 }))
|
||||
await hover(query(m.container, '.lc-stacked-seg'))
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { createElement as h } from 'react'
|
||||
import { act, createElement as h } from 'react'
|
||||
import assert from 'node:assert/strict'
|
||||
import { describe, test } from 'vitest'
|
||||
import type { TimingSpan } from '../../../src/shared/types'
|
||||
import { makeTimingStrip } from '../../../src/client/components/timingStrip'
|
||||
import { makeKit, mount, query, queryAll, hover, unhover } from '../helpers/kit'
|
||||
import { makeTimingStrip, timingGradientOf } from '../../../src/client/components/timingStrip'
|
||||
import { flush, makeKit, mount, query, queryAll, hover, unhover } from '../helpers/kit'
|
||||
|
||||
const kit = makeKit()
|
||||
const kitZh = makeKit('zh')
|
||||
@@ -111,6 +111,62 @@ describe('TimingStrip', () => {
|
||||
await m.unmount()
|
||||
})
|
||||
|
||||
test('a packed strip paints gradient layers and hit-tests the full band model', async () => {
|
||||
const spans: TimingSpan[] = []
|
||||
for (let i = 0; i < 700; i++) spans.push({ kind: i % 2 === 0 ? 'ttft' : 'tools', start: i * 1_000, end: i * 1_000 + 500 })
|
||||
let relayed: string | null = 'unset'
|
||||
const m = await mount(h(Strip, { spans, onHoverKey: (k) => { relayed = k } }))
|
||||
// Thousands of sub-pixel divs would be pointless: the dense strip paints two gradients instead.
|
||||
assert.equal(segsOf(m.container).length, 0)
|
||||
const bar = query(m.container, '.lc-tstrip-bar')
|
||||
// A zero-width box cannot answer a hit and keeps the tip closed.
|
||||
await act(async () => { bar.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, clientX: 10 })) })
|
||||
await flush()
|
||||
assert.equal(queryAll(m.container, '.lc-bar-tip-on').length, 0)
|
||||
Object.defineProperty(bar, 'getBoundingClientRect', {
|
||||
configurable: true,
|
||||
value: () => ({ left: 0, top: 0, width: 1_000, height: 12, right: 1_000, bottom: 12 }),
|
||||
})
|
||||
await act(async () => { bar.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, clientX: 500 })) })
|
||||
await flush()
|
||||
// 700 equal 0.5s bands: half-way is band 350 (even → ttft).
|
||||
assert.equal(relayed, 'ttft')
|
||||
const tip = query(m.container, '.lc-bar-tip')
|
||||
assert.ok(tip.className.includes('lc-bar-tip-on'))
|
||||
assert.ok(tip.textContent?.startsWith('TTFT 500ms'))
|
||||
assert.ok(tip.textContent?.includes(timeOf(350_000)))
|
||||
// Past the right edge clamps to the last band.
|
||||
await act(async () => { bar.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, clientX: 1_500 })) })
|
||||
await flush()
|
||||
assert.equal(relayed, 'tools')
|
||||
await unhover(bar)
|
||||
assert.equal(relayed, null)
|
||||
await m.unmount()
|
||||
})
|
||||
|
||||
test('gradient stops coalesce contiguous same-kind runs', () => {
|
||||
const bands = [
|
||||
{ kind: 'tools' as const, leftPct: 0, widthPct: 10 },
|
||||
{ kind: 'tools' as const, leftPct: 10, widthPct: 10 },
|
||||
{ kind: 'ttft' as const, leftPct: 20, widthPct: 10 },
|
||||
]
|
||||
const base = timingGradientOf(bands, null)
|
||||
assert.equal((base.match(/--color-teal-500/g) || []).length, 1)
|
||||
assert.ok(base.includes('var(--color-teal-500) 0.0000% 20.0000%'))
|
||||
const hot = timingGradientOf(bands, 'tools')
|
||||
assert.ok(hot.includes('var(--color-teal-500) 0.0000% 20.0000%, transparent 20.0000% 30.0000%'))
|
||||
})
|
||||
|
||||
test('a dense hover lights its kind through the hot layer', async () => {
|
||||
const spans: TimingSpan[] = []
|
||||
for (let i = 0; i < 700; i++) spans.push({ kind: i % 2 === 0 ? 'ttft' : 'tools', start: i * 1_000, end: i * 1_000 + 500 })
|
||||
const m = await mount(h(Strip, { spans, hoverKey: 'ttft' }))
|
||||
assert.ok(query(m.container, '.lc-tstrip-bar').className.includes('lc-tstrip-dim'))
|
||||
const hot = query<HTMLElement>(m.container, '.lc-tstrip-gradient-hot')
|
||||
assert.ok(hot.style.background.includes('--color-blue-500'))
|
||||
await m.unmount()
|
||||
})
|
||||
|
||||
test('a hover key naming no painted kind leaves the strip at rest', async () => {
|
||||
for (const hoverKey of ['toolarg', 'zz']) {
|
||||
const m = await mount(h(Strip, { spans: SPANS, hoverKey }))
|
||||
|
||||
@@ -31,7 +31,9 @@ beforeAll(() => {
|
||||
configurable: true,
|
||||
get(this: LayoutEl): number {
|
||||
if (this.classList && this.classList.contains('lc-chart-scroll')) {
|
||||
return Math.max(this.clientWidth, this.querySelectorAll('.lc-bar').length * BAR_CELL)
|
||||
const total = Number(this.querySelector('.lc-chart')?.getAttribute('data-lc-bars') ?? '')
|
||||
const columns = Number.isFinite(total) && total > 0 ? total : this.querySelectorAll('.lc-bar').length
|
||||
return Math.max(this.clientWidth, columns * BAR_CELL)
|
||||
}
|
||||
return this.__scrollW ?? 0
|
||||
},
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { act, createElement as h } from 'react'
|
||||
import { describe, test } from 'vitest'
|
||||
import type { TrendBand } from '../../../src/client/dna'
|
||||
import { flush, mount, queryAll } from '../helpers/kit'
|
||||
import { capDnaRuns } from '../../../src/client/components/trendDnaBars'
|
||||
import { TrendChart, bars, propsOf, req, viewport } from './trendChartHarness'
|
||||
|
||||
const bandOf = (seq: number): TrendBand[] => [{ key: 'a', cat: 'system', tokens: 10 + (seq % 7), off: 0, color: '#8b5cf6' }]
|
||||
|
||||
function requestList(n: number) {
|
||||
return Array.from({ length: n }, (_, i) => req(i + 1, {
|
||||
turn: 1 + Math.floor(i / 100),
|
||||
step: i % 100,
|
||||
system: 10 + i % 7,
|
||||
tools: 20,
|
||||
user: 5,
|
||||
inject: 0,
|
||||
assistant: 15,
|
||||
tool: 5,
|
||||
total: 55 + i % 7,
|
||||
activeMs: 1_000 + i,
|
||||
}))
|
||||
}
|
||||
|
||||
function lazyBands() {
|
||||
const calls = new Map<number, number>()
|
||||
const at = (seq: number): TrendBand[] => {
|
||||
calls.set(seq, (calls.get(seq) ?? 0) + 1)
|
||||
return bandOf(seq)
|
||||
}
|
||||
return { calls, at }
|
||||
}
|
||||
|
||||
describe('capDnaRuns', () => {
|
||||
test('passes short runs through and samples dense ones onto a fixed stop count', () => {
|
||||
const short = [{ color: '#a', from: 0, to: 50 }, { color: '#b', from: 50, to: 100 }]
|
||||
assert.equal(capDnaRuns(short, 96), short)
|
||||
const dense = Array.from({ length: 400 }, (_, i) => ({
|
||||
color: i % 2 === 0 ? '#a' : '#b',
|
||||
from: i / 4,
|
||||
to: (i + 1) / 4,
|
||||
}))
|
||||
const capped = capDnaRuns(dense, 96)
|
||||
assert.ok(capped.length <= 96)
|
||||
assert.equal(capped[0].from, 0)
|
||||
assert.equal(capped[capped.length - 1].to, dense[dense.length - 1].to)
|
||||
// Adjacent samples of the same colour coalesce into one run.
|
||||
assert.ok(capped.every((run, i) => i === 0 || capped[i - 1].color !== run.color))
|
||||
const mono = Array.from({ length: 400 }, (_, i) => ({ color: '#a', from: i / 4, to: (i + 1) / 4 }))
|
||||
assert.equal(capDnaRuns(mono, 96).length, 1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('TrendChart lazy DNA bands', () => {
|
||||
test('a lazy source assembles only the rendered window and hit-tests its bands', async () => {
|
||||
const reqs = requestList(600)
|
||||
const lazy = lazyBands()
|
||||
viewport.clientW = 400
|
||||
const m = await mount(h(TrendChart, propsOf(reqs, { dnaAt: lazy.at })))
|
||||
try {
|
||||
const dnaBars = queryAll(m.container, '.lc-bar-dna')
|
||||
assert.ok(dnaBars.length > 0 && dnaBars.length < reqs.length, `windowed DNA bars (${dnaBars.length})`)
|
||||
assert.ok(lazy.calls.size < reqs.length, `only visible steps assembled (${lazy.calls.size})`)
|
||||
// Hovering a DNA bar resolves the band under the pointer and paints the lifetime slice across the window.
|
||||
await act(async () => { dnaBars[0].dispatchEvent(new MouseEvent('mousemove', { bubbles: true, clientY: 10 })) })
|
||||
await flush()
|
||||
assert.ok(queryAll(m.container, '.lc-dna-slice').length > 0)
|
||||
} finally {
|
||||
await m.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
test('entering DNA tightens the window and leaving it restores the ordinary range', async () => {
|
||||
const reqs = requestList(600)
|
||||
const lazy = lazyBands()
|
||||
viewport.clientW = 400
|
||||
const m = await mount(h(TrendChart, propsOf(reqs, { dna: null })))
|
||||
try {
|
||||
const plain = queryAll(m.container, '.lc-bar').length
|
||||
await m.update(h(TrendChart, propsOf(reqs, { dnaAt: lazy.at })))
|
||||
const dna = queryAll(m.container, '.lc-bar').length
|
||||
assert.ok(dna > 0 && dna < plain, `DNA window is tighter (${dna} < ${plain})`)
|
||||
await m.update(h(TrendChart, propsOf(reqs, { dna: null })))
|
||||
assert.ok(queryAll(m.container, '.lc-bar').length > dna, 'leaving DNA expands the window again')
|
||||
} finally {
|
||||
await m.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
test('delta mode reads every per-key band so a lazy source keeps the eager axis', async () => {
|
||||
const reqs = requestList(600)
|
||||
const lazy = lazyBands()
|
||||
viewport.clientW = 400
|
||||
const eager = reqs.map((_, i) => bandOf(i + 1))
|
||||
const eagerView = await mount(h(TrendChart, propsOf(reqs, { dna: eager, mode: 'delta' })))
|
||||
const axisOf = (el: HTMLElement): (string | null)[] => ['.lc-axis-top', '.lc-axis-q3', '.lc-axis-mid', '.lc-axis-q1']
|
||||
.map(sel => el.querySelector(sel)?.textContent ?? null)
|
||||
const eagerAxis = axisOf(eagerView.container)
|
||||
await eagerView.unmount()
|
||||
const m = await mount(h(TrendChart, propsOf(reqs, { dnaAt: lazy.at, mode: 'delta' })))
|
||||
try {
|
||||
assert.ok(queryAll(m.container, '.lc-bar-dna').length > 0)
|
||||
assert.deepEqual(axisOf(m.container), eagerAxis, 'lazy delta axes match the eager per-band maxima')
|
||||
// Exact churn bounds require every bar's key changes: no totals-based shortcut.
|
||||
assert.ok(lazy.calls.size <= reqs.length * 2, `bounded per-seq lookups (${lazy.calls.size})`)
|
||||
assert.ok(bars(m.container).length > 0)
|
||||
} finally {
|
||||
await m.unmount()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,104 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { act, createElement as h } from 'react'
|
||||
import { describe, test } from 'vitest'
|
||||
import {
|
||||
BAR_GAP, BAR_W, initialBarWindow, nextBarWindow, TREND_WINDOW_MIN, WINDOW_HYSTERESIS_BARS, WINDOW_OVERSCAN_BARS,
|
||||
windowPads, windowRangeFor,
|
||||
} from '../../../src/client/components/trendChart'
|
||||
import { flush, mount, query, queryAll, until } from '../helpers/kit'
|
||||
import { TrendChart, bars, propsOf, req, scrollTo, viewport } from './trendChartHarness'
|
||||
import type { LayoutEl } from './trendChartHarness'
|
||||
|
||||
const PITCH = BAR_W + BAR_GAP
|
||||
|
||||
describe('trend chart windowing', () => {
|
||||
test('keep small and empty histories whole', () => {
|
||||
assert.deepEqual(windowRangeFor(0, 400, 0), { from: 0, to: 0 })
|
||||
assert.deepEqual(windowRangeFor(3200, 400, 120), { from: 0, to: 119 })
|
||||
assert.deepEqual(nextBarWindow({ from: 0, to: 10 }, { from: 50, to: 60 }, 100), { from: 0, to: 99 })
|
||||
assert.deepEqual(windowPads(0, 119, 120), { left: 0, right: 0 })
|
||||
assert.deepEqual(initialBarWindow(120), { from: 0, to: 119 })
|
||||
assert.equal(initialBarWindow(1_000).to, 999)
|
||||
assert.ok(1_000 > TREND_WINDOW_MIN)
|
||||
})
|
||||
|
||||
test('fill an overscanned range around the scroll position', () => {
|
||||
const n = 1_000
|
||||
const range = windowRangeFor(8000, 400, n)
|
||||
assert.equal(range.from, Math.max(0, Math.floor(8000 / PITCH) - WINDOW_OVERSCAN_BARS))
|
||||
assert.equal(range.to, Math.min(n - 1, Math.ceil(8400 / PITCH) + WINDOW_OVERSCAN_BARS))
|
||||
assert.ok(range.from > 0 && range.to < n - 1)
|
||||
// Near both edges the range clamps into the history.
|
||||
assert.equal(windowRangeFor(0, 400, n).from, 0)
|
||||
assert.equal(windowRangeFor(999_999, 400, n).to, n - 1)
|
||||
})
|
||||
|
||||
test('keep a covering window and refill after leaving it', () => {
|
||||
const prev = { from: 100, to: 300 }
|
||||
assert.equal(nextBarWindow(prev, { from: 120, to: 280 }, 1_000), prev)
|
||||
const moved = nextBarWindow(prev, { from: 400, to: 500 }, 1_000)
|
||||
assert.deepEqual(moved, { from: 400 - WINDOW_HYSTERESIS_BARS, to: 500 + WINDOW_HYSTERESIS_BARS })
|
||||
const clamped = nextBarWindow({ from: 0, to: 100 }, { from: 900, to: 999 }, 1_000)
|
||||
assert.deepEqual(clamped, { from: 900 - WINDOW_HYSTERESIS_BARS, to: 999 })
|
||||
})
|
||||
|
||||
test('padding reproduces the full column width exactly', () => {
|
||||
const n = 1_000
|
||||
for (const [from, to] of [[0, 0], [0, 799], [200, 799], [950, 999], [400, 500]] as const) {
|
||||
const pads = windowPads(from, to, n)
|
||||
const rendered = to - from + 1
|
||||
const items = rendered + (pads.left > 0 ? 1 : 0) + (pads.right > 0 ? 1 : 0)
|
||||
const width = rendered * BAR_W + pads.left + pads.right + Math.max(0, items - 1) * BAR_GAP
|
||||
assert.equal(width, n * PITCH - BAR_GAP, `columns ${from}..${to}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('DNA highlights are windowed with the same padding', async () => {
|
||||
const list = Array.from({ length: 400 }, (_, i) => req(i + 1))
|
||||
const dna = list.map(() => [{ key: 'sys', cat: 'system' as const, tokens: 10, off: 0, color: '#8b5cf6' }])
|
||||
viewport.clientW = 400
|
||||
const m = await mount(h(TrendChart, propsOf(list, { dna })))
|
||||
try {
|
||||
const scroller = query<LayoutEl>(m.container, '.lc-chart-scroll')
|
||||
await scrollTo(scroller, 3_200)
|
||||
await until(() => queryAll(m.container, '.lc-bar-pad').length === 2, 'window lands mid-history with both pads')
|
||||
const dnaBar = query(m.container, '.lc-bar-dna')
|
||||
await act(async () => { dnaBar.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, clientY: 10 })) })
|
||||
await flush()
|
||||
const highlight = query(m.container, '.lc-dna-hl')
|
||||
assert.ok(highlight !== null)
|
||||
assert.equal(queryAll(highlight, '.lc-dna-cell').length, queryAll(m.container, '.lc-bar').length + 2)
|
||||
} finally {
|
||||
await m.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
test('a large history mounts on its newest bars and follows a scroll', async () => {
|
||||
const list = Array.from({ length: 400 }, (_, i) => req(i + 1))
|
||||
viewport.clientW = 400
|
||||
const m = await mount(h(TrendChart, propsOf(list)))
|
||||
try {
|
||||
const scroller = query<LayoutEl>(m.container, '.lc-chart-scroll')
|
||||
const chart = query(m.container, '.lc-chart')
|
||||
assert.equal(chart.getAttribute('data-lc-bars'), '400')
|
||||
const initial = bars(m.container)
|
||||
assert.ok(initial.length < list.length, 'a window, not all 400 bars')
|
||||
assert.ok(initial.length > 50, 'a window wide enough to scroll through')
|
||||
assert.equal(initial[initial.length - 1].getAttribute('data-seq'), String(list[list.length - 1].seq))
|
||||
assert.ok(scroller.__scrollL !== undefined && scroller.__scrollL > 0, 'anchored to the newest bars')
|
||||
assert.ok(query(m.container, '.lc-bar-pad') !== null, 'a pad keeps the full content width')
|
||||
|
||||
await scrollTo(scroller, 0)
|
||||
await until(
|
||||
() => bars(m.container)[0]?.getAttribute('data-seq') === String(list[0].seq),
|
||||
'window follows the scroll to the oldest bars',
|
||||
)
|
||||
assert.equal(bars(m.container)[0].getAttribute('data-seq'), String(list[0].seq))
|
||||
// The full readout remains: axis, turn strip and hover model still see 400 columns.
|
||||
assert.equal(query(m.container, '.lc-turns').children.length, 1)
|
||||
} finally {
|
||||
await m.unmount()
|
||||
viewport.clientW = 400
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,133 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { createElement as h } from 'react'
|
||||
import { act } from 'react'
|
||||
import { describe, test, vi } from 'vitest'
|
||||
import { useLazyMount } from '../../../src/client/components/useLazyMount'
|
||||
import { mount, query } from '../helpers/kit'
|
||||
|
||||
function Probe(props: { attachRef?: boolean; wrap?: boolean; force?: boolean }) {
|
||||
const [ref, mounted] = useLazyMount(props.force === true)
|
||||
const body = mounted ? h('span', { 'data-mounted': 'yes' }) : h('span', { 'data-mounted': 'no' })
|
||||
const inner = h('div', { ...(props.attachRef === false ? {} : { ref }), 'data-probe': '' }, body)
|
||||
return props.wrap === true
|
||||
? h('div', { 'data-conversation-scroll': '' }, inner)
|
||||
: inner
|
||||
}
|
||||
|
||||
interface ObserverRecord { callback: IntersectionObserverCallback; disconnected: number }
|
||||
|
||||
function stubObserver(): { records: ObserverRecord[]; restore: () => void } {
|
||||
const records: ObserverRecord[] = []
|
||||
class FakeObserver {
|
||||
private readonly record: ObserverRecord
|
||||
constructor(callback: IntersectionObserverCallback) {
|
||||
this.record = { callback, disconnected: 0 }
|
||||
records.push(this.record)
|
||||
}
|
||||
observe = (): void => {}
|
||||
disconnect = (): void => { this.record.disconnected++ }
|
||||
}
|
||||
vi.stubGlobal('IntersectionObserver', FakeObserver)
|
||||
return { records, restore: () => { vi.unstubAllGlobals() } }
|
||||
}
|
||||
|
||||
describe('useLazyMount', () => {
|
||||
test('mounts immediately when IntersectionObserver is unavailable', async () => {
|
||||
const m = await mount(h(Probe, {}))
|
||||
assert.equal(query(m.container, '[data-mounted]')?.getAttribute('data-mounted'), 'yes')
|
||||
await m.unmount()
|
||||
})
|
||||
|
||||
test('re-observes a reattached placeholder (a hidden sidebar copy comes back)', async () => {
|
||||
const observer = stubObserver()
|
||||
vi.stubGlobal('requestIdleCallback', undefined)
|
||||
try {
|
||||
const m = await mount(h(Probe, { attachRef: false }))
|
||||
assert.equal(observer.records.length, 0)
|
||||
await m.update(h(Probe, { attachRef: true }))
|
||||
assert.equal(observer.records.length, 1, 'the first attach observes')
|
||||
await m.update(h(Probe, { attachRef: false }))
|
||||
await m.update(h(Probe, { attachRef: true }))
|
||||
assert.equal(observer.records.length, 2, 'a reattach observes again')
|
||||
await m.unmount()
|
||||
} finally {
|
||||
observer.restore()
|
||||
vi.unstubAllGlobals()
|
||||
}
|
||||
})
|
||||
|
||||
test('force mounts without waiting for the observer (a pending jump)', async () => {
|
||||
const observer = stubObserver()
|
||||
try {
|
||||
const m = await mount(h(Probe, { force: true }))
|
||||
assert.equal(query(m.container, '[data-mounted]')?.getAttribute('data-mounted'), 'yes')
|
||||
await m.unmount()
|
||||
assert.equal(observer.records.length, 0)
|
||||
} finally {
|
||||
observer.restore()
|
||||
vi.unstubAllGlobals()
|
||||
}
|
||||
})
|
||||
|
||||
test('keeps the skeleton until the block nears the viewport, then mounts in an idle slice', async () => {
|
||||
const observer = stubObserver()
|
||||
const idle: (() => void)[] = []
|
||||
vi.stubGlobal('requestIdleCallback', (callback: () => void) => { idle.push(callback); return 7 })
|
||||
const cancel = vi.fn()
|
||||
vi.stubGlobal('cancelIdleCallback', cancel)
|
||||
try {
|
||||
const m = await mount(h(Probe, {}))
|
||||
assert.equal(query(m.container, '[data-mounted]')?.getAttribute('data-mounted'), 'no')
|
||||
assert.equal(observer.records.length, 1)
|
||||
const entry = { isIntersecting: false } as IntersectionObserverEntry
|
||||
await act(async () => { observer.records[0].callback([entry], {} as IntersectionObserver) })
|
||||
assert.equal(query(m.container, '[data-mounted]')?.getAttribute('data-mounted'), 'no')
|
||||
await act(async () => {
|
||||
observer.records[0].callback([{ isIntersecting: true } as IntersectionObserverEntry], {} as IntersectionObserver)
|
||||
})
|
||||
assert.equal(idle.length, 1)
|
||||
await act(async () => { idle[0]() })
|
||||
assert.equal(query(m.container, '[data-mounted]')?.getAttribute('data-mounted'), 'yes')
|
||||
// A second intersection after mounting must not schedule another mount.
|
||||
await act(async () => {
|
||||
observer.records[0].callback([{ isIntersecting: true } as IntersectionObserverEntry], {} as IntersectionObserver)
|
||||
})
|
||||
await m.unmount()
|
||||
} finally {
|
||||
observer.restore()
|
||||
vi.unstubAllGlobals()
|
||||
}
|
||||
})
|
||||
|
||||
test('falls back to a timeout without requestIdleCallback and cancels it on unmount', async () => {
|
||||
const observer = stubObserver()
|
||||
vi.stubGlobal('requestIdleCallback', undefined)
|
||||
try {
|
||||
const m = await mount(h(Probe, {}))
|
||||
await act(async () => {
|
||||
observer.records[0].callback([{ isIntersecting: true } as IntersectionObserverEntry], {} as IntersectionObserver)
|
||||
})
|
||||
// Unmount before the timeout fires: the cleanup clears it (no state update on an unmounted tree).
|
||||
await m.unmount()
|
||||
assert.ok(observer.records[0].disconnected > 0)
|
||||
} finally {
|
||||
observer.restore()
|
||||
vi.unstubAllGlobals()
|
||||
}
|
||||
})
|
||||
|
||||
test('resolves a conversation scroll parent as the observer root and tolerates a missing ref', async () => {
|
||||
const observer = stubObserver()
|
||||
try {
|
||||
const m = await mount(h(Probe, { wrap: true }))
|
||||
assert.equal(observer.records.length, 1)
|
||||
await m.unmount()
|
||||
const missing = await mount(h(Probe, { attachRef: false }))
|
||||
assert.equal(query(missing.container, '[data-mounted]')?.getAttribute('data-mounted'), 'no')
|
||||
await missing.unmount()
|
||||
} finally {
|
||||
observer.restore()
|
||||
vi.unstubAllGlobals()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,137 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { createElement as h, useRef } from 'react'
|
||||
import { act } from 'react'
|
||||
import { describe, test, vi } from 'vitest'
|
||||
import { ROW_WINDOW_OVERSCAN, rowWindowFor, useRowWindow } from '../../../src/client/components/useRowWindow'
|
||||
import { mount, query, until } from '../helpers/kit'
|
||||
|
||||
describe('rowWindowFor', () => {
|
||||
test('covers the viewport with an overscan on both sides', () => {
|
||||
assert.deepEqual(rowWindowFor(0, 100, 20, 50), { from: 0, to: 5 + ROW_WINDOW_OVERSCAN })
|
||||
assert.deepEqual(rowWindowFor(200, 100, 20, 50), { from: 10 - ROW_WINDOW_OVERSCAN, to: 15 + ROW_WINDOW_OVERSCAN })
|
||||
assert.deepEqual(rowWindowFor(10_000, 100, 20, 50), { from: 49, to: 49 })
|
||||
})
|
||||
|
||||
test('falls back to the whole list when unmeasured or empty', () => {
|
||||
assert.deepEqual(rowWindowFor(0, 0, 20, 30), { from: 0, to: 29 })
|
||||
assert.deepEqual(rowWindowFor(0, 100, 0, 30), { from: 0, to: 29 })
|
||||
assert.deepEqual(rowWindowFor(0, 100, 20, 0), { from: 0, to: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
function Probe(props: { count: number; enabled: boolean; attachRef?: boolean }) {
|
||||
const ref = useRef<HTMLDivElement | null>(null)
|
||||
const range = useRowWindow(ref, props.count, 20, props.enabled)
|
||||
return h('div', {
|
||||
...(props.attachRef === false ? {} : { ref }),
|
||||
'data-from': range.from,
|
||||
'data-to': range.to,
|
||||
})
|
||||
}
|
||||
|
||||
function NoRefProbe(props: { count: number }) {
|
||||
const range = useRowWindow(useRef<HTMLDivElement | null>(null), props.count, 20, true)
|
||||
return h('div', { 'data-from': range.from, 'data-to': range.to })
|
||||
}
|
||||
|
||||
describe('useRowWindow', () => {
|
||||
test('renders in full until the scroller is measured, then follows scroll', async () => {
|
||||
const m = await mount(h(Probe, { count: 50, enabled: true }))
|
||||
try {
|
||||
const el = query<HTMLElement>(m.container, 'div')
|
||||
assert.equal(el.dataset.from, '0')
|
||||
assert.equal(el.dataset.to, '49')
|
||||
Object.defineProperty(el, 'clientHeight', { configurable: true, value: 100 })
|
||||
// The effect re-reads the box when the list changes.
|
||||
await m.update(h(Probe, { count: 51, enabled: true }))
|
||||
await until(() => query<HTMLElement>(m.container, 'div').dataset.to !== '50', 'window measures the viewport')
|
||||
assert.equal(el.dataset.from, '0')
|
||||
assert.equal(el.dataset.to, String(5 + ROW_WINDOW_OVERSCAN))
|
||||
el.scrollTop = 200
|
||||
await act(async () => { el.dispatchEvent(new Event('scroll')) })
|
||||
await until(() => query<HTMLElement>(m.container, 'div').dataset.from !== '0', 'window follows the scroll')
|
||||
assert.equal(el.dataset.from, String(10 - ROW_WINDOW_OVERSCAN))
|
||||
} finally {
|
||||
await m.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
test('stays full when disabled, unmeasured, or the ref is not attached', async () => {
|
||||
const cases = [
|
||||
h(Probe, { count: 40, enabled: false }),
|
||||
h(Probe, { count: 40, enabled: true, attachRef: false }),
|
||||
h(NoRefProbe, { count: 40 }),
|
||||
]
|
||||
for (const el of cases) {
|
||||
const m = await mount(el)
|
||||
const node = query<HTMLElement>(m.container, 'div')
|
||||
assert.equal(node.dataset.from, '0')
|
||||
assert.equal(node.dataset.to, '39')
|
||||
await m.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
test('coalesces two scroll events into one frame and applies the latest offset', async () => {
|
||||
const frames: FrameRequestCallback[] = []
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { frames.push(callback); return 1 })
|
||||
vi.stubGlobal('cancelAnimationFrame', () => {})
|
||||
try {
|
||||
const m = await mount(h(Probe, { count: 50, enabled: true }))
|
||||
const el = query<HTMLElement>(m.container, 'div')
|
||||
Object.defineProperty(el, 'clientHeight', { configurable: true, value: 100 })
|
||||
await m.update(h(Probe, { count: 51, enabled: true }))
|
||||
await until(() => query<HTMLElement>(m.container, 'div').dataset.to !== '50', 'window measured')
|
||||
await act(async () => {
|
||||
el.scrollTop = 100
|
||||
el.dispatchEvent(new Event('scroll'))
|
||||
el.scrollTop = 200
|
||||
el.dispatchEvent(new Event('scroll'))
|
||||
})
|
||||
assert.equal(frames.length, 1, 'one frame serves both scroll events')
|
||||
await act(async () => { frames[0](0) })
|
||||
await until(() => query<HTMLElement>(m.container, 'div').dataset.from !== '0', 'latest offset applied')
|
||||
assert.equal(el.dataset.from, String(10 - ROW_WINDOW_OVERSCAN))
|
||||
await m.unmount()
|
||||
} finally {
|
||||
vi.unstubAllGlobals()
|
||||
}
|
||||
})
|
||||
|
||||
test('observes pane resizes when ResizeObserver exists', async () => {
|
||||
const disconnect = vi.fn()
|
||||
class FakeObserver {
|
||||
constructor(private readonly cb: () => void) {}
|
||||
observe = (): void => { this.cb() }
|
||||
disconnect = disconnect
|
||||
}
|
||||
vi.stubGlobal('ResizeObserver', FakeObserver)
|
||||
try {
|
||||
const m = await mount(h(Probe, { count: 20, enabled: true }))
|
||||
const el = query<HTMLElement>(m.container, 'div')
|
||||
Object.defineProperty(el, 'clientHeight', { configurable: true, value: 100 })
|
||||
await m.update(h(Probe, { count: 21, enabled: true }))
|
||||
await m.unmount()
|
||||
assert.ok(disconnect.mock.calls.length > 0)
|
||||
} finally {
|
||||
vi.unstubAllGlobals()
|
||||
}
|
||||
})
|
||||
|
||||
test('cancels a pending animation frame on unmount', async () => {
|
||||
const cancel = vi.fn()
|
||||
vi.stubGlobal('requestAnimationFrame', () => 7)
|
||||
vi.stubGlobal('cancelAnimationFrame', cancel)
|
||||
try {
|
||||
const m = await mount(h(Probe, { count: 50, enabled: true }))
|
||||
const el = query<HTMLElement>(m.container, 'div')
|
||||
Object.defineProperty(el, 'clientHeight', { configurable: true, value: 100 })
|
||||
await m.update(h(Probe, { count: 51, enabled: true }))
|
||||
await act(async () => { el.dispatchEvent(new Event('scroll')) })
|
||||
await m.unmount()
|
||||
assert.ok(cancel.mock.calls.length > 0)
|
||||
} finally {
|
||||
vi.unstubAllGlobals()
|
||||
}
|
||||
})
|
||||
|
||||
})
|
||||
Reference in new issue
Block a user