Files
dsh-context/src/client/components/contextModal.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

158 lines
6.9 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.
/**
* ContextModal — the /context command's centered dialog: the same data as
* the Context tab (the pushed `contextTimeline` projection) distilled to
* the overview (headline + composition bar + legend) and the last-10-turn
* trend chart. Rendered from the `conversation.input.overlay` slot; opens
* and closes through the per-session modal store (the trigger source flips
* it, so no message ever enters the session history).
*/
import type * as ReactNS from 'react'
import type { ContextEventRecord, RequestRecord } from '../../shared/types'
import { headlineOf } from '../headline'
import { modalStoreOf, takePendingConsume } from '../modalStore'
import type { ClientCtx, SessionStandardProps, SessionsFace } from '../services'
import { timelineOf } from '../services'
import type { ViewKit } from '../viewkit'
import { makeRequestDetail } from './requestDetail'
import { makeLegend, makeStackedBar } from './stackedBar'
import { aggregateByTurn, attachMarkers, makeTrendChart } from './trendChart'
import { React } from '../react'
/** How many of the most recent turns the modal's trend chart shows. */
const TREND_TURNS = 10
export interface ContextModalProps extends SessionStandardProps {
/** Bound selector hook over the per-session open flag (hooks compartment). */
useContextModal?: (sel: (open: boolean) => boolean) => boolean
}
export function makeContextModal(ctx: ClientCtx, kit: ViewKit): (props: ContextModalProps) => ReactNS.ReactElement | null {
const { t, tr, fmt } = kit
const sessions = ctx.get('sessions') as SessionsFace | undefined
const StackedBar = makeStackedBar(kit)
const Legend = makeLegend(kit)
const TrendChart = makeTrendChart(kit)
const RequestDetail = makeRequestDetail(kit, StackedBar)
return function ContextModal(props: ContextModalProps): ReactNS.ReactElement | null {
const sessionId = typeof props.sessionId === 'string' ? props.sessionId : ''
const open = typeof props.useContextModal === 'function' ? props.useContextModal(s => s) : false
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 [hoverCat, setHoverCat] = React.useState<string | null>(null)
const close = React.useCallback(() => {
if (sessionId === '') return
modalStoreOf(sessionId).set(false)
// Deferred token consumption: the `/context` token stayed in the
// composer while the modal was open; consume it now through the scoped
// input event (a stale guard — the user typed meanwhile — fails soft
// inside the shell and leaves the draft untouched).
const guard = takePendingConsume(sessionId)
if (guard === undefined || sessions === undefined) return
const scope = sessions.scope(sessionId)
if (scope !== undefined) scope.bail(scope, 'slash/input-consume-token', { guard })
}, [sessionId])
// Escape closes; on open the current focus (the composer) is captured
// and restored on close, so typing resumes exactly where it stopped.
React.useEffect(() => {
if (!open) return undefined
const previous = document.activeElement instanceof HTMLElement ? document.activeElement : null
const onKey = (ev: KeyboardEvent) => {
if (ev.key !== 'Escape') return
ev.preventDefault()
ev.stopPropagation()
close()
}
window.addEventListener('keydown', onKey, true)
return () => {
window.removeEventListener('keydown', onKey, true)
if (previous !== null && document.contains(previous)) previous.focus()
}
}, [open, close])
if (!open) return null
const requests = data !== null ? data.requests || [] : []
const events = data !== null ? data.events || [] : []
// One bar per turn (each turn shown by its last step), newest 10 only.
const turns = aggregateByTurn(requests).slice(-TREND_TURNS)
const markers = attachMarkers(turns, events)
let pinnedReq: RequestRecord | null = null
for (const req of turns) if (req.seq === selectedSeq) pinnedReq = req
let activeReq: RequestRecord | null = null
if (hoveredSeq !== null) {
for (const req of turns) if (req.seq === hoveredSeq) activeReq = req
}
if (activeReq === null) activeReq = pinnedReq
if (activeReq === null && turns.length > 0) activeReq = turns[turns.length - 1]
const markerOf = (req: RequestRecord): ContextEventRecord | undefined => {
const i = turns.indexOf(req)
return i >= 0 ? markers[i] : undefined
}
const head = data !== null ? headlineOf(data) : null
return (
<div className="lc-modal-backdrop" onClick={close}>
<div className="lc-modal-card" onClick={ev => { ev.stopPropagation() }}>
<div className="lc-modal-head">
<span className="lc-modal-title">{t('tab')}</span>
{data !== null && (data.model || data.provider) ? (
<span className="lc-card-sub">
{(data.model ? data.model : '') + (data.provider ? ' · ' + data.provider : '')}
</span>
) : null}
<button className="lc-modal-close" aria-label={t('cmd.close')} onClick={close}>×</button>
</div>
{data === null || head === null ? (
<div className="lc-empty">{t('loading')}</div>
) : (
<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} />
<div className="lc-card-title lc-modal-trend">{t('trend.title')}</div>
{turns.length === 0 ? (
<div className="lc-empty">{t('trend.empty')}</div>
) : (
<div>
<TrendChart
requests={turns}
markers={markers}
selectedSeq={pinnedReq !== null ? pinnedReq.seq : null}
hoveredSeq={hoveredSeq}
activeTurn={null}
granularity="turn"
onSelect={setSelectedSeq}
onHover={setHoveredSeq}
onHoverTurn={() => {}}
/>
<RequestDetail request={activeReq} marker={activeReq !== null ? markerOf(activeReq) : undefined} />
</div>
)}
</div>
)}
</div>
</div>
)
}
}