The 20 hover/focus rules that are leaf styles (no state class re-asserts them) become hover:/focus:/group-hover: variants on the elements' own class lists, and their sheet rules are deleted: row and button hover tints, retry/close/gate brightness, input focus borders, underline hints, the three styled-tip reveals (named group/tip), the plugin-info row underlines (group/pi), and the agent halo's hover/focus wash (group/agent — the React hover state keeps driving the inspector; lc-agent-hover stays as a test anchor). The hover/lit(-on) cascade pyramids stay hand-written in the sheets — the gran/rich-seg/rich-copy/fa-chip toggles and the settings card's open state need ordered overrides over their own hover rules, several of those state classes double as test anchors and DOM query hooks, and moving them would mean a chip factory for zero visual change. Transitions, animations and all harness --dsw-* tokens stay in sheets. Behavior notes: variant utilities carry (0,2,0) specificity, so they keep beating the sheet base rules exactly as the old :hover rules did; Tailwind media-guards hover behind (hover: hover), so touch devices no longer get sticky hover styling (dsh web is desktop-first).
155 lines
7.8 KiB
TypeScript
155 lines
7.8 KiB
TypeScript
/**
|
||
* 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 { createElement as h, useCallback, useLayoutEffect, useMemo, useRef, useState, type ReactElement } from 'react'
|
||
import { measureDock } from '../dockMeasure'
|
||
import { headlineOf } from '../headline'
|
||
import { modalStoreOf, takePendingConsume } from '../modalStore'
|
||
import type { ClientCtx, SessionStandardProps, SessionsFace } from '../services'
|
||
import { contextBreakdownOf, contextPressureOf, conversationNodesOf, headersOf, imageLoaderOf, projectionOf } from '../services'
|
||
import { makeContentFetcher, makeHeaderFetcher, useHistoryFace } from '../historyPage'
|
||
import { useTimelineSource } from '../timelineSource'
|
||
import type { ViewKit } from '../viewkit'
|
||
import { makeContextBrowser } from './browser'
|
||
import { makeCurrentComposition } from './currentComposition'
|
||
import { makeErrorBoundary } from './errorBoundary'
|
||
import { useEscapeClose } from './escapeClose'
|
||
import { makeLegend, makeStackedBar } from './stackedBar'
|
||
|
||
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) => ReactElement | null {
|
||
const { t } = kit
|
||
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): ReactElement | null {
|
||
const sessionId = typeof props.sessionId === 'string' ? props.sessionId : ''
|
||
const open = typeof props.useContextModal === 'function' ? props.useContextModal(s => s) : false
|
||
// The timeline source (timelineSource.ts) — shares the tab's per-session
|
||
// detail store, so an open tab's detail serves the modal with no refetch.
|
||
const source = useTimelineSource(ctx, props)
|
||
const data = source.data
|
||
const pressure = projectionOf(props, 'contextPressure', contextPressureOf)
|
||
const breakdown = projectionOf(props, 'contextBreakdown', contextBreakdownOf)
|
||
const headers = projectionOf(props, 'contextHeaders', headersOf)
|
||
// Conversation-window join for the browser (the seat is a real hook —
|
||
// read unconditionally here, before the closed early return, so the hook
|
||
// order stays stable across open/close).
|
||
const convNodes = conversationNodesOf(props)
|
||
const [hoverCat, setHoverCat] = useState<string | null>(null)
|
||
// Dock the mask beside the shell sidebar: 0 until the frame measure lands
|
||
// (the layout effect below resolves it before first paint).
|
||
const [dockLeft, setDockLeft] = useState(0)
|
||
const backdropRef = useRef<HTMLDivElement | null>(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. Same parity as the Context tab.
|
||
const loadImage = useMemo(
|
||
() => imageLoaderOf(ctx, sessionId !== '' ? 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 overlay 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()
|
||
|
||
// Same targeted content fetch the Context tab wires (one seq-anchored history read per expanded row), plus the on-demand header
|
||
// epoch content read for the browser's system/tools sections.
|
||
const fetchContent = useMemo(
|
||
() => (sessionId !== '' && historyFace !== undefined ? makeContentFetcher(sessionId) : undefined),
|
||
[sessionId, historyFace],
|
||
)
|
||
const fetchHeader = useMemo(
|
||
() => (sessionId !== '' && historyFace !== undefined ? makeHeaderFetcher(sessionId) : undefined),
|
||
[sessionId, historyFace],
|
||
)
|
||
|
||
const close = 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. The sessions service is read at CLOSE time:
|
||
// capturing it at apply would race the finer module composition (`ctx.get` is the inject-free reflect read — undefined, never a
|
||
// throw, when the service is not composed).
|
||
const guard = takePendingConsume(sessionId)
|
||
const sessions = ctx.get('sessions') as SessionsFace | undefined
|
||
if (guard === undefined || sessions === undefined) return
|
||
const scope = sessions.scope(sessionId)
|
||
if (scope !== undefined) scope.bail(scope, 'slash/input-consume-token', { guard })
|
||
}, [ctx, sessionId])
|
||
|
||
// Capture-phase Escape close + focus restore (the shared overlay contract).
|
||
useEscapeClose(open, close)
|
||
|
||
// Dock the mask to the main column: measure the sidebar track once before
|
||
// first paint, then follow the frame's inline template while open (sidebar
|
||
// drags, collapse toggles and narrow-viewport re-solves all rewrite it).
|
||
// An unresolved frame keeps the full-viewport mask.
|
||
useLayoutEffect(() => {
|
||
if (!open) return undefined
|
||
const dock = measureDock(backdropRef.current)
|
||
setDockLeft(dock.left)
|
||
if (dock.frame === null) return undefined
|
||
const observer = new MutationObserver(() => {
|
||
setDockLeft(measureDock(backdropRef.current).left)
|
||
})
|
||
observer.observe(dock.frame, { attributes: true, attributeFilter: ['style'] })
|
||
return () => { observer.disconnect() }
|
||
}, [open])
|
||
|
||
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 ref={backdropRef} className="lc-modal-backdrop" style={{ left: dockLeft }} 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 hover:text-(--dsw-alias-label-primary) hover:bg-(--dsw-alias-bg-layer-2)" 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}
|
||
/>
|
||
<ContextBrowser
|
||
data={data}
|
||
headers={headers}
|
||
convNodes={convNodes}
|
||
fetchContent={fetchContent}
|
||
fetchHeader={fetchHeader}
|
||
loadImage={loadImage}
|
||
hoverKey={hoverCat}
|
||
onHoverKey={setHoverCat}
|
||
detailState={source.detailState}
|
||
onDetailRetry={source.retryDetail}
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return function ContextModal(props: ContextModalProps): ReactElement | null {
|
||
return h(ErrorBoundary, null, h(ContextModalBody, props))
|
||
}
|
||
}
|