Files
dsh-context/src/client/components/contextView.tsx
T
bowenliang123 bf4cdd1e58 feat: add /context slash command with a centered context modal
Typing /context in the composer opens a centered dialog with the current
composition (provider-anchored headline, stacked bar, legend) and the
last-10-turn trend chart — the same pushed contextTimeline projection the
tab renders, no RPC, no polling.

The command is the plugin's own '/' trigger source, fully client-side:
no host descriptor, no session log records, nothing model-visible. The
composer token is consumed only when the modal closes (deferred
slash/input-consume-token with span / bare-token guards, fail-soft if the
draft changed meanwhile). Esc, backdrop click, and the close button all
close it and restore composer focus.

Also extracts the provider-anchored headline derivation shared by the tab
and the modal (headline.ts).
2026-08-16 22:26:46 +08:00

258 lines
12 KiB
TypeScript

/**
* 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<string, number>()
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<number | null>(null)
const [hoveredSeq, setHoveredSeq] = React.useState<number | null>(null)
const [hoverTurn, setHoverTurn] = React.useState<number | null>(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<string | null>(null)
// ---- page-scroller ownership (see `viewScroll` above) ----
const rootRef = React.useRef<HTMLDivElement | null>(null)
const scrollerRef = React.useRef<HTMLElement | null>(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<string | null>(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 <div className="lc-root" ref={rootRef}><div className="lc-empty">{t('loading')}</div></div>
}
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 (
<div className="lc-root" ref={rootRef}>
{/* ---- session context stats (over the retained window) ---- */}
<StatsBoard requests={requests} events={events} />
{/* ---- overview ---- */}
<div className="lc-card">
<div className="lc-card-title">
{t('overview.title')}
<span className="lc-card-sub">
{(data.model ? data.model : '') + (data.provider ? ' · ' + data.provider : '')}
</span>
</div>
<div className="lc-overview-num">
<b>{fmt(head.tokens)}</b>
<span>
{head.window
? ' / ' + fmt(head.window) + ' ' + tr('overview.ofWindow', { p: head.pct ?? 0 })
: ' ' + t('overview.estimate')}
</span>
{!head.estimated ? <span className="lc-card-sub">{t('overview.splitEst')}</span> : null}
</div>
<StackedBar parts={head.parts} height={16} max={head.window} hoverKey={hoverCat} onHoverKey={setHoverCat} />
<Legend parts={head.parts} hoverKey={hoverCat} onHoverKey={setHoverCat} />
{(data.toolList && data.toolList.length > 0) ? (
<div className="lc-tools">
{t('tools.top')}
{data.toolList.slice().sort((a, b) => b.tokens - a.tokens).slice(0, 5).map(tool => {
return <span key={tool.name} className="lc-tool-chip">{tool.name + ' ' + fmt(tool.tokens)}</span>
})}
{data.toolList.length > 5
? <span className="lc-card-sub">{' ' + tr('tools.more', { n: data.toolList.length })}</span>
: null}
</div>
) : null}
</div>
{/* ---- trend ---- */}
<div className="lc-card">
<div className="lc-card-title">
{t('trend.title')}
<span className="lc-card-sub">{t('trend.hint')}</span>
<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>
{displayRequests.length === 0
? <div className="lc-empty">{t('trend.empty')}</div>
: (
<div>
<TrendChart
// Remount per session: switching sessions re-anchors the chart
// at the newest bars instead of inheriting stale scroll state.
key={sessionId}
// The host caps the log at 160 requests; render them ALL so
// earlier turns/steps stay reachable via horizontal scroll.
requests={displayRequests}
markers={markers}
selectedSeq={pinnedReq ? pinnedReq.seq : null}
hoveredSeq={hoveredSeq}
activeTurn={activeTurn}
granularity={granularity}
onSelect={setSelectedSeq}
onHover={setHoveredSeq}
onHoverTurn={setHoverTurn}
/>
<RequestDetail request={activeReq} marker={activeReq !== null ? markerOf(activeReq) : undefined} />
</div>
)}
</div>
{/* ---- events + messages ---- */}
<div className="lc-cols">
<div className="lc-card lc-col">
<div className="lc-card-title">{t('events.title')}</div>
<EventList events={events} />
</div>
<div className="lc-card lc-col">
<div className="lc-card-title">
{t('nodes.title')}
<span className="lc-card-sub">{t('nodes.hint')}</span>
</div>
<NodeList nodes={nodes} dropped={data.droppedNodes || 0} />
</div>
</div>
<div className="lc-foot">{t('footer')}</div>
</div>
)
}
}