/** * ContextView — the root component of the Context tab: renders the * `contextTimeline` session projection delivered by the framework (finished * value, pushed by the Host half) and composes the stats board, composition * bar, history chart + detail, events and message columns. * * JSX functional component. All data comes through the framework standard * kit (`useProjection`); the component never calls any RPC and holds no * cache — the harness owns the projection pipeline end to end. */ import type * as ReactNS from 'react' import type { ContextEventRecord, RequestRecord } from '../../shared/types' import { headlineOf } from '../headline' import type { LocaleService, SessionStandardProps } from '../services' import { timelineOf } from '../services' import type { ClientCtx } from '../services' import type { ViewKit } from '../viewkit' import { makeEventList } from './events' import { makeNodeList } from './nodes' import { makeRequestDetail } from './requestDetail' import { makeStatsBoard } from './statsBoard' import { makeLegend, makeStackedBar } from './stackedBar' import { aggregateByTurn, attachMarkers, makeTrendChart } from './trendChart' import { React } from '../react' // The context page scrolls inside the conversation's shared page scroller // (`[data-conversation-scroll]`) — the same container the chat auto-scrolls // to the bottom on every visit. Without a position ledger of its own, a // context mount inherits wherever the chat left that container, so the tab // opens at the bottom and stays locked there (each chat visit re-scrolls it). // Mirror the chat's chatScroll pattern: remember where the reader left each // session's context page (module-level, so it survives tab remounts) and // restore it once the content renders — first visits start at the top. const viewScroll = new Map() export type ContextViewProps = SessionStandardProps export function makeContextView(ctx: ClientCtx, kit: ViewKit): (props: ContextViewProps) => ReactNS.ReactElement { const { t, tr, fmt, fmtTime, catLabel } = kit const StackedBar = makeStackedBar(kit) const Legend = makeLegend(kit) const TrendChart = makeTrendChart(kit) const RequestDetail = makeRequestDetail(kit, StackedBar) const EventList = makeEventList(kit) const NodeList = makeNodeList(kit) const StatsBoard = makeStatsBoard(kit) return function ContextView(props: ContextViewProps): ReactNS.ReactElement { const sessionId = props.sessionId // The finished value the harness pushes for this session: the // `contextTimeline` projection key (capability-absent until a value // arrives -> loading screen, mirroring the old first-poll wait). const data = typeof props.useProjection === 'function' ? timelineOf(props.useProjection('contextTimeline')) : null const [selectedSeq, setSelectedSeq] = React.useState(null) const [hoveredSeq, setHoveredSeq] = React.useState(null) const [hoverTurn, setHoverTurn] = React.useState(null) const [tick, setTick] = React.useState(0) const [granularity, setGranularity] = React.useState<'step' | 'turn'>('step') // Shared hover link between the composition bar and its legend below. const [hoverCat, setHoverCat] = React.useState(null) // ---- page-scroller ownership (see `viewScroll` above) ---- const rootRef = React.useRef(null) const scrollerRef = React.useRef(null) // The session whose position was already applied this mount: re-renders // must never re-apply, or they would yank the reader's scroll. const restoredRef = React.useRef(null) // Restore this session's saved position (or the top on a first visit) as // soon as the content renders — a layout effect, so the reader never sees // the chat's bottom-anchored position flash in first. React.useLayoutEffect(() => { if (typeof sessionId !== 'string' || sessionId === '' || data === null) return if (restoredRef.current === sessionId) return restoredRef.current = sessionId 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 where the reader left this session's context page. Runs on unmount // (tab switch) and on session change — a layout-effect cleanup, so it // fires before the incoming view's own layout effects re-scroll the // shared container. React.useLayoutEffect(() => { return () => { if (typeof sessionId !== 'string' || sessionId === '') return const scroller = scrollerRef.current if (scroller === null) return viewScroll.set(sessionId, scroller.scrollTop) } }, [sessionId]) // Re-render on locale switch. React.useEffect(() => { const localeSvc = ctx.get('locale') as LocaleService | undefined if (!localeSvc) return undefined return localeSvc.subscribe(() => setTick(x => x + 1)) }, []) void tick if (!data) { return
{t('loading')}
} const requests = data.requests || [] const events = data.events || [] const nodes = data.nodes || [] // Display granularity: one bar per step (default) or one bar per turn // (each turn shown by its LAST step's record). const displayRequests = granularity === 'turn' ? aggregateByTurn(requests) : requests // Boundary events attach to the first request after them; the same // attachment drives the ✂ marker above the bar and the detail chip. const markers = attachMarkers(displayRequests, events) const markerOf = (req: RequestRecord): ContextEventRecord | undefined => { const i = displayRequests.indexOf(req) return i >= 0 ? markers[i] : undefined } // The detail below follows the pointer: hover previews a bar, a pinned // click takes over when the pointer leaves, and both fall back to the // newest request. The active turn (for strip/bar highlighting) follows // the turn strip hover, or the hovered bar's turn. let pinnedReq: RequestRecord | null = null for (const req of displayRequests) if (req.seq === selectedSeq) pinnedReq = req let activeReq: RequestRecord | null = null if (hoveredSeq !== null) { for (const req of displayRequests) if (req.seq === hoveredSeq) activeReq = req } if (activeReq === null) activeReq = pinnedReq if (activeReq === null && displayRequests.length > 0) activeReq = displayRequests[displayRequests.length - 1] // The turn highlight is hover-only: the turn strip hover wins, then the // hovered bar's turn (no fallback — a pinned or default selection must // not keep 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 } } // Provider-anchored CURRENT occupancy, matching the official chat ring // (contextPressure.projectedTokens): the newest usage sample (input + // cache) carried forward by the heuristic surface movement since it was // taken. The provider's tokenizer counts the real billed tokens, which // the fixed 4-chars/token heuristic can undercount by ~10-15% on // CJK-heavy sessions — so this is the headline, and the heuristic // composition below is anchored to it (proportions stay heuristic). // Shared with the /context popup (headline.ts). const head = headlineOf(data) return (
{/* ---- session context stats (over the retained window) ---- */} {/* ---- overview ---- */}
{t('overview.title')} {(data.model ? data.model : '') + (data.provider ? ' · ' + data.provider : '')}
{fmt(head.tokens)} {head.window ? ' / ' + fmt(head.window) + ' ' + tr('overview.ofWindow', { p: head.pct ?? 0 }) : ' ' + t('overview.estimate')} {!head.estimated ? {t('overview.splitEst')} : null}
{(data.toolList && data.toolList.length > 0) ? (
{t('tools.top')} {data.toolList.slice().sort((a, b) => b.tokens - a.tokens).slice(0, 5).map(tool => { return {tool.name + ' ' + fmt(tool.tokens)} })} {data.toolList.length > 5 ? {' ' + tr('tools.more', { n: data.toolList.length })} : null}
) : null}
{/* ---- trend ---- */}
{t('trend.title')} {t('trend.hint')}
{displayRequests.length === 0 ?
{t('trend.empty')}
: (
)}
{/* ---- events + messages ---- */}
{t('events.title')}
{t('nodes.title')} {t('nodes.hint')}
{t('footer')}
) } }