Files
dsh-context/src/client/components/contextModal.tsx
T
Bowen Liang 5cc028ff00 feat(client): replace history paging with seq-targeted content fetch
The Context browser joined full content against the chat conversation
window; when a surface node's seq sat outside it, the fallback paged
blindly backwards through session.loadOlder() (up to 20 pages of 50
messages prepended into the shared chat window) and finally dead-ended
on a static note.

sessions.history already supports seq-anchored pages cut on whole
append-origin message boundaries, so ONE call with beforeSeq: seq + 1
returns exactly the page holding the event whenever the durable log
still has it. The browser now:

- fetches on miss with that single anchored read and maps the raw page
  into the node shapes it already renders (new client/historyPage.ts:
  user messages, assistant blocks, tool results paired with their
  in-page call head, compaction checkpoints with their summary);
- merges fetched nodes under the zero-cost window join (join wins);
- reports honest states instead of one dead end: loading, absent from
  the session log (compacted or cleared), failed with a retry button;
- drops the sessions.provide contribution and the MAX_AUTO_PAGES
  machinery entirely - the chat scrollback is never mutated again.

Hosts without the connection api keep the static preview-plus-hint
degradation.
2026-08-26 03:34:24 +08:00

131 lines
5.7 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.
/**
* The /context command's centered dialog — the same data as the Context tab (the pushed `contextTimeline` projection) distilled to the
* current-composition overview and the shared Context browser; rendered from the `conversation.input.overlay` slot and opened/closed
* through the per-session modal store, so the trigger flips it and no message ever enters session history.
*/
import type * as ReactNS from 'react'
import { headlineOf } from '../headline'
import { modalStoreOf, takePendingConsume } from '../modalStore'
import type { ClientCtx, SessionStandardProps, SessionsFace } from '../services'
import { contextBreakdownOf, contextPressureOf, headersOf, timelineOf } from '../services'
import { makeContentFetcher } from '../historyPage'
import type { ViewKit } from '../viewkit'
import { makeContextBrowser } from './browser'
import { makeCurrentComposition } from './currentComposition'
import { makeErrorBoundary } from './errorBoundary'
import { makeLegend, makeStackedBar } from './stackedBar'
import { React, h } from '../react'
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 } = kit
const sessions = ctx.get('sessions') as SessionsFace | undefined
const StackedBar = makeStackedBar(kit)
const Legend = makeLegend(kit)
const CurrentComposition = makeCurrentComposition(kit, StackedBar, Legend)
const ContextBrowser = makeContextBrowser(kit, StackedBar)
const ErrorBoundary = makeErrorBoundary(t)
function ContextModalBody(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 pressure = typeof props.useProjection === 'function'
? contextPressureOf(props.useProjection('contextPressure'))
: null
const breakdown = typeof props.useProjection === 'function'
? contextBreakdownOf(props.useProjection('contextBreakdown'))
: null
const headers = typeof props.useProjection === 'function'
? headersOf(props.useProjection('contextHeaders'))
: null
const [hoverCat, setHoverCat] = React.useState<string | null>(null)
const [toolFocus, setToolFocus] = React.useState<{ tool?: string } | null>(null)
// Same targeted content fetch the Context tab wires (one seq-anchored history read per expanded row).
const fetchContent = React.useMemo(
() => (sessionId !== '' ? makeContentFetcher(ctx, sessionId) : undefined),
[ctx, sessionId],
)
const close = React.useCallback(() => {
if (sessionId === '') return
modalStoreOf(sessionId).set(false)
// Consume the `/context` token now (it stayed in the composer while the modal was open) via 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])
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 head = data !== null ? headlineOf(data, pressure, breakdown) : null
const subtitle = data !== null ? (data.model ? data.model : '') + (data.provider ? ' · ' + data.provider : '') : ''
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>
<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>
<CurrentComposition
head={head}
subtitle={subtitle}
hoverKey={hoverCat}
onHoverKey={setHoverCat}
tools={data.toolList}
onToolFocus={setToolFocus}
/>
<ContextBrowser
data={data}
headers={headers}
useSession={props.useSession}
fetchContent={fetchContent}
hoverKey={hoverCat}
onHoverKey={setHoverCat}
toolFocus={toolFocus}
onToolFocusHandled={() => { setToolFocus(null) }}
/>
</div>
)}
</div>
</div>
)
}
return function ContextModal(props: ContextModalProps): ReactNS.ReactElement | null {
return h(ErrorBoundary, null, h(ContextModalBody, props))
}
}