Files
dsh-context/src/client/components/contextView.tsx
T
Bowen Liang 8d2ddeff55 feat(client): move the context-event tallies onto the events card's kind filters
The Context Stats card's inject/compaction/prune cells duplicated what the
events card already tracks. Drop the three cells (the grid falls to five:
turns, steps, tool calls, cache hit, cost) and show each tally inside the
matching kind-filter button instead, sourced from the same precomputed
counts the stats card used.
2026-09-12 00:35:30 +08:00

589 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Context tab root: renders the `contextTimeline` projection and composes stats, composition, history, events and messages.
* The data plane is the harness's projection pipeline end to end; the one addition is the split generation's on-demand
* detail read (timelineSource.ts) — the pushed wire value is the slim head, and the heavy collections arrive from the
* host's detail endpoint only while the tab (or modal) is open.
*/
import { createElement as h, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactElement } from 'react'
import type { ContextEventRecord, RequestRecord, SurfaceNode } from '../../shared/types'
import { briefNodes, briefOf } from '../brief'
import { headlineOf } from '../headline'
import type { ContextViewProps } from '../services'
import { contextBreakdownOf, contextPressureOf, conversationNodesOf, headersOf, imageLoaderOf, numOf, projectionOf, tokenUsageOf, unsupportedOf } from '../services'
import type { ClientCtx, ConversationNodeLike } from '../services'
import { makeContentFetcher, makeHeaderFetcher, useHistoryFace } from '../historyPage'
import { useTimelineSource } from '../timelineSource'
import { makeDetailNote } from './detailNote'
import { canOpenPathsOf, openPathVia, openResourceVia, workspaceOf } from '../services'
import { activityOf, activityOfOps, locateStepOf, previewAddressOf } from '../fileActivity'
import type { FileEntry, FileOp } from '../fileActivity'
import type { ContextSettings } from '../settings'
import type { ViewKit } from '../viewkit'
import { makeContextBrowser } from './browser'
import { makeAgentGraph } from './agentGraph'
import { makeDonut } from './donut'
import { makeCurrentComposition } from './currentComposition'
import { makeEventList } from './events'
import { makeFileCard } from './fileCard'
import { makePluginInfo } from './pluginInfo'
import { makeUpgradeGate } from './upgradeGate'
import { makeRequestDetail } from './requestDetail'
import { countsOfRecords, makeStatsContext } from './statsContext'
import { makeStatsTiming } from './statsTiming'
import { makeStatsTokens } from './statsTokens'
import { makeLegend, makeStackedBar } from './stackedBar'
import { aggregateByTurn, attachMarkers, jumpTargetOf, makeTrendChart } from './trendChart'
import { takeContextFocus } from '../viewFocus'
import { makeErrorBoundary } from './errorBoundary'
// The context page scrolls inside the conversation's shared `[data-conversation-scroll]` container, which the chat bottom-anchors — mirror
// the chat's chatScroll pattern: a module-level per-session position ledger (survives tab remounts), restored once content renders; first
// visits start at the top.
const viewScroll = new Map<string, number>()
const EVENT_KINDS = ['inject', 'compaction', 'prune', 'model', 'mode'] as const
export function makeContextView(
ctx: ClientCtx,
kit: ViewKit,
settings: ContextSettings,
): (props: ContextViewProps) => ReactElement {
const { t } = kit
const StackedBar = makeStackedBar(kit)
const Legend = makeLegend(kit)
const CurrentComposition = makeCurrentComposition(kit, StackedBar, Legend)
const TrendChart = makeTrendChart(kit)
const RequestDetail = makeRequestDetail(kit, StackedBar)
const EventList = makeEventList(kit)
const FileCard = makeFileCard(kit, settings)
const Donut = makeDonut(kit)
const StatsContext = makeStatsContext(kit)
const StatsTiming = makeStatsTiming(kit, Donut)
const StatsTokens = makeStatsTokens(kit, Donut)
const PluginInfo = makePluginInfo(kit)
const UpgradeGate = makeUpgradeGate(kit)
const DetailNote = makeDetailNote(kit)
const ContextBrowser = makeContextBrowser(kit, StackedBar)
const AgentGraph = makeAgentGraph(ctx, kit)
const ErrorBoundary = makeErrorBoundary(t)
// The body renders under the error boundary: a corrupt projection value (past the timelineOf shape guard) degrades to a styled error
// card, not a white screen; the boundary itself has NO hooks, so the body's hook order and loading/data flow stay unchanged.
function ContextViewBody(props: ContextViewProps): ReactElement {
const sessionId = props.sessionId
// The right Sidebar's panel is a narrow column: it drops the two head cards
// that only pay off on the full-width tab (context stats, plugin info).
const inSidebar = props.host === 'sidebar'
// The timeline source (timelineSource.ts): the pushed value on the inline
// generation, or the slim head merged with the on-demand detail on the
// split generation. `detailState`/`retryDetail` drive the detail cards'
// loading/failed notes.
const source = useTimelineSource(ctx, props)
const data = source.data
// Official token-meter `contextPressure` projection — the same key the chat's context ring reads; token-meter owns estimation, the Host
// no longer mirrors it. Absent → derived fallback.
const pressure = projectionOf(props, 'contextPressure', contextPressureOf)
// Official token-meter `tokenUsage` projection — the same data the chat stats line below the input box reads for its '缓存命中' figure, so
// the stats board's cache-hit cell reuses it verbatim; absent → the cell drops to a dash instead of estimating.
const usage = projectionOf(props, 'tokenUsage', tokenUsageOf)
// Official token-meter `contextBreakdown` projection — the exact rows the chat ring's click-open panel shows, so the overview legend
// reads identically by construction; absent → the fold's own same-estimator sums inside headlineOf.
const breakdown = projectionOf(props, 'contextBreakdown', contextBreakdownOf)
// `contextHeaders` companion projection (full system prompt + tool schemas) for the Context browser; absent key = older Host half →
// those sections degrade to tokens-only with a note.
const headers = projectionOf(props, 'contextHeaders', headersOf)
const [selectedSeq, setSelectedSeq] = useState<number | null>(null)
const [hoveredSeq, setHoveredSeq] = useState<number | null>(null)
const [hoverTurn, setHoverTurn] = useState<number | null>(null)
// Mount-time default from the plugin settings card; in-chart toggling stays mount-local and never writes back.
const [granularity, setGranularity] = useState<'step' | 'turn'>(() => settings.defaultGranularity())
// 'total' plots each request's cumulative composition, 'delta' its incremental change vs the previous one;
// like granularity, the default is read at mount and in-chart toggling never writes back.
const [trendMode, setTrendMode] = useState<'total' | 'delta'>(() => settings.defaultTrendMode())
// Adaptive scale (the title-adjacent toggle): the trend bars rescale to the visible window; like the two
// toggles above, mount-local and never written back.
const [adaptive, setAdaptive] = useState(false)
// Strip-clicked turn: chart switches to turn granularity and scroll-centers that turn's bar, then clears via onFocusTurnHandled.
const [focusTurn, setFocusTurn] = useState<number | null>(null)
// Chat → Context jump: the assistant-action relay's one-shot request, held until the projection data is in, then resolved into a
// turn-level pin below (mirrors the strip-click's consume-once focus flow).
const [jumpSeq, setJumpSeq] = useState<number | null>(null)
const [hoverCat, setHoverCat] = useState<string | null>(null)
// The browser's open category: the trend card focuses its bars on it (collapsing the category restores all).
const [focusCat, setFocusCat] = useState<string | null>(null)
const [pickedKinds, setPickedKinds] = useState<string[]>([...EVENT_KINDS])
const toggleKind = (k: string) => {
setPickedKinds((p) => {
if (p.length === EVENT_KINDS.length) return [k]
if (!p.includes(k)) return [...p, k]
return p.length === 1 ? [...EVENT_KINDS] : p.filter(x => x !== k)
})
}
// Step-brief → browser reveal bridge: one-shot focus request consumed by the Context browser.
const [nodeFocus, setNodeFocus] = useState<{ step: number | 'live'; seq: number; cat: SurfaceNode['cat'] } | null>(null)
const clearNodeFocus = useCallback(() => { setNodeFocus(null) }, [])
// Session-authorized durable-image loader for the browser's attachment cards, resolved through the harness `uiConversation` service
// (`imageUrl`); absent service/session degrades the cards to metadata-only, never an error.
const loadImage = useMemo(
() => imageLoaderOf(ctx, typeof sessionId === 'string' ? sessionId : undefined),
[ctx, sessionId],
)
// The gateway page face, as a React seat: the first render can race the
// declared inject (a watch rebuild remounts this view before the fiber
// re-fires), and both fetchers below must rebuild — not stick to the
// static degradation — when the face lands or is revoked.
const historyFace = useHistoryFace()
// Targeted full-content fetch for browser nodes outside the conversation window (one seq-anchored history read per expanded row);
// absent face/session degrades to the static hint — never an error.
const fetchContent = useMemo(
() => (typeof sessionId === 'string' && sessionId !== '' && historyFace !== undefined
? makeContentFetcher(sessionId)
: undefined),
[sessionId, historyFace],
)
// On-demand header epoch content (system prompt text, tool schemas) for the Context browser — one seq-anchored history read per
// opened epoch; the `contextHeaders` projection carries metadata only. Same degradation shape as fetchContent.
const fetchHeader = useMemo(
() => (typeof sessionId === 'string' && sessionId !== '' && historyFace !== undefined
? makeHeaderFetcher(sessionId)
: undefined),
[sessionId, historyFace],
)
const rootRef = useRef<HTMLDivElement | null>(null)
const scrollerRef = useRef<HTMLElement | null>(null)
// The session whose position was already applied this mount — re-applying on re-renders would yank the reader's scroll.
const restoredRef = useRef<string | null>(null)
// Restore the saved position (or the top on first visit) in a layout effect, so the chat's bottom-anchored position never flashes in
// first.
useLayoutEffect(() => {
if (typeof sessionId !== 'string' || sessionId === '' || data === null) return
if (restoredRef.current === sessionId) return
restoredRef.current = sessionId
/* v8 ignore next 3 -- a layout effect body only runs while mounted and
both render paths attach rootRef, so the null arm cannot fire. */
const scroller = rootRef.current !== null
? rootRef.current.closest('[data-conversation-scroll]')
: null
if (scroller === null) return
scrollerRef.current = scroller as HTMLElement
scroller.scrollTop = viewScroll.get(sessionId) ?? 0
}, [sessionId, data])
// Save the position on unmount/session change — a layout-effect cleanup, so it fires before the incoming view's own layout effects
// re-scroll the shared container.
useLayoutEffect(() => {
return () => {
if (typeof sessionId !== 'string' || sessionId === '') return
const scroller = scrollerRef.current
if (scroller === null) return
viewScroll.set(sessionId, scroller.scrollTop)
}
}, [sessionId])
// No locale subscription here: the harness slot outlet subscribes the
// LocaleFace revision and re-renders every entry on a locale switch, and
// the kit's bound `t` reads the active locale at call time.
// Hooks stay unconditional (Rules of Hooks): the projection value can arrive AFTER a loading first render, and an early return above
// these useMemos would grow the hook count between renders (React #310); fall back to empty collections and keep the loading return
// below the last hook.
const requests = data ? data.requests : []
const events = data ? data.events : []
// The stats board's count figures — the split head's precomputed tallies, or
// the collections' own derivation on the inline generation. Shared by the
// stats card's shape cells and the events card's kind-filter counts.
const counts = data?.counts ?? countsOfRecords(requests, events)
// The per-kind tallies that ride the filter buttons: only the three priced
// kinds the host counts (model/mode switches carry no tally, hence undefined).
const kindCounts: Record<string, number | undefined> = { inject: counts.injects, compaction: counts.compactions, prune: counts.prunes }
const shownEvents = pickedKinds.length === EVENT_KINDS.length ? events : events.filter(e => pickedKinds.includes(e.kind))
// Per-step bars, or one per turn (each turn's LAST step's record); memoized so hover-driven re-renders keep bar props identity-stable —
// the chart's memoized bars then skip reconciliation (turn-mode aggregation allocates).
const displayRequests = useMemo(
() => (granularity === 'turn' ? aggregateByTurn(requests) : requests),
[requests, granularity],
)
const markers = useMemo(() => attachMarkers(displayRequests, events), [displayRequests, events])
// Chat → Context jump, leg 1: pick up the assistant-action relay's request for this session (once per mount).
useEffect(() => {
if (typeof sessionId !== 'string' || sessionId === '') return
const seq = takeContextFocus(sessionId)
if (seq !== null) setJumpSeq(seq)
}, [sessionId])
// Leg 2: the action row belongs to the reply that CLOSED a turn, so the jump is turn-level — flip the chart to turn bars, pin that
// turn's aggregate (the relayed seq is the turn's last request, exactly the aggregate's record), and center it: the same flow as a
// turn-strip click. An aged-out turn clamps to the oldest retained bar. No page-scroller anchor → the reset degrades quietly.
// The split generation waits for the detail read first: the relayed seq resolves against the served request records, so the
// one-shot request must not be consumed while the collections are still pending (it re-fires when they land).
const detailReady = source.detailState === 'ready' || source.detailState === 'legacy'
useEffect(() => {
if (jumpSeq === null || data === null || !detailReady) return
setJumpSeq(null)
const target = jumpTargetOf(aggregateByTurn(requests), jumpSeq)
if (target === null) return
setGranularity('turn')
setSelectedSeq(target.seq)
setFocusTurn(target.turn ?? 0)
// The restore layout effect resolved the shared scroller on this same data render (layout effects precede this one).
if (scrollerRef.current !== null) scrollerRef.current.scrollTop = 0
}, [jumpSeq, data, requests, detailReady])
// Step-brief raw material: every served node seq-sorted (live tail + archive), and the conversation-snapshot
// join the brief uses for call-argument enrichment (same join the Context browser builds).
const briefList = useMemo(() => (data ? briefNodes(data) : []), [data])
// The conversation-window join, from the `useChat` seat. The seat is a
// real hook — invoked unconditionally per render (stable order);
// undefined join = render without it, never an error.
const convNodes = conversationNodesOf(props)
const bySeq = useMemo(() => {
const m = new Map<number, ConversationNodeLike>()
for (const n of convNodes ?? []) m.set(n.seq, n)
return m
}, [convNodes])
// Active bar / pin lookup — derived BEFORE the loading-return so the hooks below stay unconditional (React #310).
let pinnedIdx = -1
for (let i = 0; i < displayRequests.length; i++) if (displayRequests[i].seq === selectedSeq) pinnedIdx = i
const pinnedReq = pinnedIdx >= 0 ? displayRequests[pinnedIdx] : null
let activeIdx = -1
if (hoveredSeq !== null) {
for (let i = 0; i < displayRequests.length; i++) if (displayRequests[i].seq === hoveredSeq) { activeIdx = i; break }
}
if (activeIdx < 0) activeIdx = pinnedIdx
if (activeIdx < 0 && displayRequests.length > 0) activeIdx = displayRequests.length - 1
const activeReq = activeIdx >= 0 ? displayRequests[activeIdx] : null
// File activity follows the same active bar: the EXCLUSIVE upper bound is the next RAW request's seq, so the picked step's own
// tool calls (results land before the next request) count too; the latest bar's null bound serves everything.
let filesBefore: number | null = null
if (activeReq !== null) {
// The active bar's seq always exists in the raw list (turn aggregates keep their last step's record).
const ri = requests.findIndex(r => r.seq === activeReq.seq)
filesBefore = ri + 1 < requests.length ? requests[ri + 1].seq : null
}
// The active bar's semantic identity ("what this step was about"); pure derivation over the served nodes, null when nothing is known.
const brief = useMemo(
() => (activeReq !== null ? briefOf(briefList, displayRequests, activeIdx) : null),
[activeReq, briefList, displayRequests, activeIdx],
)
const convOf = useCallback((seq: number): ConversationNodeLike | undefined => bySeq.get(seq), [bySeq])
// File activity follows the same active bar: the op-log generation reads the fold-derived records (full
// session-log coverage); older hosts keep the conversation-window derivation. Recomputed when the brief
// source, the conversation join, or the upper bound moves — hover/select elsewhere (composition chips,
// kind filters) leaves every input reference untouched, so the walk is skipped on those renders.
const fileActivity = useMemo(
() => {
if (data !== null && data.fileOps !== undefined) {
return activityOfOps(data.fileOps, data.archive, filesBefore)
}
return activityOf(briefList, convOf, filesBefore)
},
[data, briefList, convOf, filesBefore],
)
// The session's workspace root — './'-relative row paths when known; read per
// render (an observable snapshot), so the next projection push re-renders with it.
const workspace = typeof ctx.get === 'function' ? workspaceOf(ctx, typeof sessionId === 'string' ? sessionId : undefined) : undefined
// The system-opener affordance rides the same ctx; both resolve before the early return.
// The capability is an RPC answer now (the synchronous host-description fact is gone),
// so it lands in state once the probe settles — inert file names until then; the
// effect's cleanup drops an answer that arrives after the view unmounted.
const [canOpenPaths, setCanOpenPaths] = useState(false)
useEffect(() => {
let live = true
void canOpenPathsOf(ctx).then((can) => { if (live) setCanOpenPaths(can) })
return () => { live = false }
}, [ctx])
const fileOpener = useMemo(
() => (canOpenPaths ? openPathVia(ctx) : undefined),
[canOpenPaths, ctx],
)
// The right-Sidebar preview opener (the same optional column the Context
// tab itself registers into): resolved off `ctx` at mount, the face
// re-proved per call, so an HMR reload's revocation never leaves a stale
// opener. Absent face = no `onPreview` at all, and the card keeps its
// system-open affordance untouched.
const previewOpener = useMemo(() => openResourceVia(ctx), [ctx])
const sessionKey = typeof sessionId === 'string' ? sessionId : undefined
const previewFile = useMemo(
() => previewOpener === undefined
? undefined
: (entry: FileEntry): boolean => {
const address = previewAddressOf(entry.path, entry.form, entry.pattern, sessionKey, workspace)
return address !== undefined && previewOpener(address)
},
[previewOpener, sessionKey, workspace],
)
const locateFileOp = useCallback((op: FileOp): void => {
// A nested Code-Mode op has no surface row of its own — it reveals on
// its parent run_code result (whose removal stamp the op carries).
const seq = op.parent ?? op.seq
const step = locateStepOf(requests, seq, op.gone)
if (step === null) return
setNodeFocus({ step, seq, cat: 'tool' })
}, [requests])
// A brief row's reveal target: inputs/opener live in the picked step's OWN assembled surface; the response node (seq === the
// request's) first appears in the NEXT step's surface — or the live surface when the last bar is picked.
const locateNode = useCallback((node: SurfaceNode, isResponse: boolean): void => {
/* v8 ignore next 1 -- locateNode is only wired to brief rows, and
brief !== null guarantees activeReq !== null in the same closure. */
if (activeReq === null) return
const next = isResponse && activeIdx + 1 < displayRequests.length ? displayRequests[activeIdx + 1] : null
const step: number | 'live' = isResponse ? (next !== null ? next.seq : 'live') : activeReq.seq
setNodeFocus({ step, seq: node.seq, cat: node.cat })
}, [activeReq, activeIdx, displayRequests])
if (!data) {
return <div className="lc-root" ref={rootRef}><div className="lc-empty">{t('loading')}</div></div>
}
const markerOf = (req: RequestRecord): ContextEventRecord | undefined => {
const i = displayRequests.indexOf(req)
/* v8 ignore next 1 -- the only caller passes displayRequests[activeIdx],
an element of the very array indexOf scans. */
return i >= 0 ? markers[i] : undefined
}
// Provider-anchored CURRENT occupancy (contextPressure.projectedTokens): the headline, because the fixed 4-chars/token heuristic
// undercounts CJK by ~10–15% — proportions stay heuristic but are anchored to the real billed total. Shared with the /context popup
// (headline.ts); composition rides the official `contextBreakdown` rows.
const head = headlineOf(data, pressure, breakdown)
let fileScope = t('files.scopeLatest')
if (activeReq !== null && filesBefore !== null) {
fileScope = activeReq.stepCount !== undefined && activeReq.stepCount > 1
? t('detail.turn', { t: activeReq.turn ?? 0, n: activeReq.stepCount })
: t('detail.step', { t: activeReq.turn ?? 0, s: activeReq.step ?? 0 })
}
// Turn highlight is hover-only: the turn strip hover wins, then the hovered bar's turn — no fallback, so a pinned or default selection
// never keeps a turn glowing.
let activeTurn: number | null = hoverTurn
if (activeTurn === null && hoveredSeq !== null) {
for (const req of displayRequests) if (req.seq === hoveredSeq) { activeTurn = req.turn ?? null; break }
}
// The trend card mirrors the SAME shared category hover the browser's live link uses: the overview's 'free' key drops
// (no segment in the chart or the detail bar), and every bar + the detail bar light that category's segment.
const trendHoverCat = hoverCat !== null && hoverCat !== 'free' ? hoverCat : null
// The cost cell prices in the active locale (zh → CNY, else USD), read at render time — the locale subscription above already
// re-renders on a switch; older hosts without getLocale fall back to USD.
const localeSvc = ctx.get('locale')
const activeLocale = localeSvc !== undefined && typeof localeSvc.getLocale === 'function'
? localeSvc.getLocale().active
: 'en'
const subtitle = (data.model ?? '') + (data.provider ? ' · ' + data.provider : '')
// The host's baseline gate (host/fallback.ts): the cards below render
// the fallback's zeroed data while the modal names both versions and
// urges the upgrade. Absent record = a supported harness, no gate.
const gate = unsupportedOf(data.unsupported)
// The three main-row cards, built once and laid out by the shared
// arrangement below (every host uses the same row).
const compositionCard = (
<CurrentComposition
head={head}
subtitle={subtitle}
hoverKey={hoverCat}
onHoverKey={setHoverCat}
/>
)
const trendCard = (
<div className="lc-card">
<div className="lc-card-title">
<span className="lc-card-title-text">{t('trend.title')}</span>
{/* The adaptive switch rides the title's right (the browser card's DNA toggle idiom): bars rescale
to the bars currently on screen instead of the whole retained log. */}
<span className="lc-gran lc-trend-adaptive" role="group" title={t('trend.adaptiveHint')}>
<button
type="button"
className={'lc-gran-btn' + (adaptive ? ' lc-gran-on' : '')}
onClick={() => { setAdaptive(on => !on) }}
>{t('trend.adaptive')}</button>
</span>
{focusCat !== null
? <span className="lc-card-sub">{t('trend.focus', { cat: kit.catLabel(focusCat) })}</span>
: null}
<div className="lc-trend-ctl">
<div className="lc-gran">
<button
className={'lc-gran-btn' + (granularity === 'step' ? ' lc-gran-on' : '')}
onClick={() => { setGranularity('step') }}
>{t('gran.step')}</button>
<button
className={'lc-gran-btn' + (granularity === 'turn' ? ' lc-gran-on' : '')}
onClick={() => { setGranularity('turn') }}
>{t('gran.turn')}</button>
</div>
<div className="lc-gran" title={t('gran.modeHint')}>
<button
className={'lc-gran-btn' + (trendMode === 'total' ? ' lc-gran-on' : '')}
onClick={() => { setTrendMode('total') }}
>{t('gran.total')}</button>
<button
className={'lc-gran-btn' + (trendMode === 'delta' ? ' lc-gran-on' : '')}
onClick={() => { setTrendMode('delta') }}
>{t('gran.delta')}</button>
</div>
</div>
</div>
{displayRequests.length === 0
// Split generation, first detail read pending or failed: say
// so instead of claiming the session has no history.
? (detailReady
? <div className="lc-empty">{t('trend.empty')}</div>
: <DetailNote state={source.detailState === 'failed' ? 'failed' : 'loading'} onRetry={source.retryDetail} />)
: (
<div>
<TrendChart
// Remount per session: switching sessions re-anchors the chart at the newest bars instead of inheriting stale scroll
// state.
key={sessionId}
// Render ALL retained requests (bounded by the host's maxKeptTurns/maxRequestSteps config) so earlier turns/steps
// stay reachable via horizontal scroll.
requests={displayRequests}
markers={markers}
selectedSeq={pinnedReq ? pinnedReq.seq : null}
hoveredSeq={hoveredSeq}
activeTurn={activeTurn}
granularity={granularity}
mode={trendMode}
focusTurn={focusTurn}
hoverCat={trendHoverCat}
focusCat={focusCat}
adaptive={adaptive}
onSelect={setSelectedSeq}
onHover={setHoveredSeq}
onHoverTurn={setHoverTurn}
onPickTurn={(turn) => { setGranularity('turn'); setFocusTurn(turn) }}
onFocusTurnHandled={() => { setFocusTurn(null) }}
/>
<RequestDetail
request={activeReq}
// Delta mode pairs the detail with the SAME previous record the chart diffs against (first bar: null).
prev={trendMode === 'delta' && activeIdx >= 0 ? (activeIdx > 0 ? displayRequests[activeIdx - 1] : null) : undefined}
/* v8 ignore next 1 -- RequestDetail renders only when
displayRequests.length > 0, which forces activeReq
non-null via the activeIdx fallback above. */
marker={activeReq !== null ? markerOf(activeReq) : undefined}
brief={brief}
convOf={convOf}
onLocate={locateNode}
hoverKey={trendHoverCat}
/>
</div>
)}
</div>
)
const browserCard = (
<ContextBrowser
data={data}
headers={headers}
convNodes={convNodes}
fetchContent={fetchContent}
fetchHeader={fetchHeader}
previewSeq={hoveredSeq}
pinSeq={pinnedReq !== null ? pinnedReq.seq : null}
hoverKey={hoverCat}
onHoverKey={setHoverCat}
onOpenCat={setFocusCat}
nodeFocus={nodeFocus}
onNodeFocusHandled={clearNodeFocus}
loadImage={loadImage}
detailState={source.detailState}
onDetailRetry={source.retryDetail}
/>
)
return (
<div className="lc-root" ref={rootRef}>
{/* The head band splits into two rows: the session's shape beside the
plugin card, then the two donut cards together. The sidebar panel
drops the first row (context stats / plugin info pay off only on
the full-width tab) and keeps the donut row, whose container query
stacks the pair in the narrow pane. */}
{inSidebar ? null : (
<div className="lc-cols lc-head">
<StatsContext counts={counts} toolCalls={data.toolCalls} usage={usage}
cost={data.cost} locale={activeLocale} />
<PluginInfo />
</div>
)}
<div className="lc-cols lc-head">
<StatsTokens usage={usage} current={data.current} breakdown={breakdown} />
<StatsTiming timing={data.timing ?? null} />
</div>
{/* One arrangement for every host: composition over trend in the left
column, the browser beside them and stretched to the pair's height.
The sidebar panel's column simply folds to one at the shared 360px
floor instead of splitting the three cards across two ragged rows. */}
<div className="lc-cols lc-cols-main">
<div className="lc-col">{compositionCard}{trendCard}</div>
{/* `lc-col-browser` stretches the browser card to the left column's
height; the /context modal, which draws its own stack, must stay
content-sized. */}
<div className="lc-col lc-col-browser">{browserCard}</div>
</div>
<div className="lc-cols">
<div className="lc-card lc-col">
<div className="lc-card-title">
<span className="lc-card-title-text">{t('events.title')}</span>
<div className="lc-kinds">
{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} />
</div>
<FileCard activity={fileActivity} scope={fileScope} workspace={workspace}
onPreview={previewFile} onOpen={fileOpener} onLocate={locateFileOp}
state={source.detailState} onRetry={source.retryDetail} />
</div>
<AgentGraph
sessionId={typeof sessionId === 'string' ? sessionId : undefined}
self={{
head,
billed: usage !== null ? numOf(usage.uncachedInputTokens) + numOf(usage.outputTokens)
+ numOf(usage.cacheReadTokens) + numOf(usage.cacheWriteTokens) : null,
requests: requests.length,
}}
/>
<div className="lc-foot">{t('footer')}</div>
{gate !== null && (
<UpgradeGate sessionId={sessionId} current={gate.current} minimum={gate.minimum} />
)}
</div>
)
}
return function ContextView(props: ContextViewProps): ReactElement {
return h(ErrorBoundary, null, h(ContextViewBody, props))
}
}