diff --git a/src/client/components/contextModal.tsx b/src/client/components/contextModal.tsx index 3a9b332..39683a5 100644 --- a/src/client/components/contextModal.tsx +++ b/src/client/components/contextModal.tsx @@ -16,6 +16,7 @@ import { useTimelineSource } from '../timelineSource' import type { ViewKit } from '../viewkit' import { makeContextBrowser } from './browser' import { makeCurrentComposition } from './currentComposition' +import { makeDetailNote } from './detailNote' import { makeErrorBoundary } from './errorBoundary' import { useEscapeClose } from './escapeClose' import { makeLegend, makeStackedBar } from './stackedBar' @@ -35,6 +36,7 @@ export function makeContextModal( const Legend = makeLegend(kit) const CurrentComposition = makeCurrentComposition(kit, StackedBar, Legend) const ContextBrowser = makeContextBrowser(kit, StackedBar, settings) + const DetailNote = makeDetailNote(kit) const ErrorBoundary = makeErrorBoundary(t) function ContextModalBody(props: ContextModalProps): ReactElement | null { @@ -125,7 +127,11 @@ export function makeContextModal( {data === null || head === null ? ( -
{t('loading')}
+ source.detailState === 'failed' ? ( + + ) : ( +
{t('loading')}
+ ) ) : (
{t('loading')}
+ // No renderable value: the cold read is still in flight (the loading + // screen) or settled without one — the retryable failure note, never a + // spinner that never resolves. + return ( +
+ {source.detailState === 'failed' + ? + :
{t('loading')}
} +
+ ) } const markerOf = (req: RequestRecord): ContextEventRecord | undefined => { diff --git a/src/client/timelineSource.ts b/src/client/timelineSource.ts index 28574ac..a7377d2 100644 --- a/src/client/timelineSource.ts +++ b/src/client/timelineSource.ts @@ -24,6 +24,15 @@ * stops the trailing until the head moves again. With no detail at all, * failure surfaces as a retryable note on the detail cards instead of an * empty chart. + * + * The cold start: a session can sit with NO pushed `contextTimeline` value + * at all — the enable recomposition raced its open baseline (and the + * projection store clears a key its baseline omits), or its checkpoint row + * is missing or version-stale on a cold-observed session. Waiting for a + * push that never comes would stall the view on "loading" forever, so the + * source opens the detail channel on its own (rev 0): the route folds the + * durable log on demand and serves the slim head beside the collections, + * and the next pushed head takes the channel back per its own revision. */ import { useEffect, useMemo, useSyncExternalStore } from 'react' @@ -283,38 +292,63 @@ export function useTimelineSource(ctx: ClientCtx, props: SessionStandardProps): // inline generation — `headRev` null means no detail channel work at all. const headRev = head !== null && typeof head.detailRev === 'number' ? head.detailRev : null const slim = headRev !== null + // The cold start arms whenever no value has been pushed: the channel is + // opened by the source itself (rev 0) instead of waiting on a push that + // may never come (the enable recomposition raced the baseline, a cleared + // or stale checkpoint row). The store is per session, so the cold read's + // result carries straight into the pushed generation's later refetches. + const cold = head === null const store = useMemo( - () => (slim ? detailStoreOf(ctx, sessionId) : null), - [ctx, sessionId, slim], + () => (slim || cold ? detailStoreOf(ctx, sessionId) : null), + [ctx, sessionId, slim, cold], ) const snap = useSyncExternalStore( store !== null ? store.subscribe : noopSubscribe, store !== null ? store.getSnapshot : () => EMPTY_SNAP, ) useEffect(() => { - if (store !== null && headRev !== null) store.request(headRev) + // The pushed head's revision drives the trailing refetch; the cold start + // requests rev 0 — acceptance is rev-safe, and a head that arrives later + // trails per its own revision. + if (store !== null) store.request(headRev ?? 0) }, [store, headRev]) return useMemo(() => { - if (head === null) return { data: null, detailState: 'loading', retryDetail: noopRetry } - if (!slim || store === null) return { data: head, detailState: 'legacy', retryDetail: noopRetry } const detail = snap.detail - const data: ContextTimeline = detail === null - ? head - : { - ...head, - requests: detail.requests, - events: detail.events, - nodes: detail.nodes, - droppedNodes: detail.droppedNodes, - archive: detail.archive, - // Slim heads serve no floors; the detail's pair lands whole here. - ...(detail.surfaceFloor !== undefined ? { surfaceFloor: detail.surfaceFloor } : {}), - ...(detail.archiveFloor !== undefined ? { archiveFloor: detail.archiveFloor } : {}), - ...(detail.fileOps !== undefined ? { fileOps: detail.fileOps } : {}), - ...(detail.fileOpsFloor !== undefined ? { fileOpsFloor: detail.fileOpsFloor } : {}), - } - const detailState: DetailState = detail !== null ? 'ready' : snap.failed ? 'failed' : 'loading' - return { data, detailState, retryDetail: store.retry } + // A head-with-detail can only exist through a live channel store — the + // fallback covers TS's narrowing loss, never a real state. + const retry = store !== null ? store.retry : noopRetry + // The inline generation passes through untouched — no store exists, the + // collections never ride the channel. + if (head !== null && !slim) return { data: head, detailState: 'legacy', retryDetail: noopRetry } + // The render head: the pushed value, or — while nothing has been pushed — + // the detail read's own slim head. + const base = head !== null ? head : detail !== null ? detail.head ?? null : null + if (base === null) { + // Nothing renderable: the cold read still pending, or settled without + // a usable value (transport failure, absence, a headless payload) — + // the retryable failure, never a spinner that never resolves. + const failed = store !== null && (snap.failed || detail !== null) + return { data: null, detailState: failed ? 'failed' : 'loading', retryDetail: retry } + } + if (detail === null) { + // The head's own counters render while the first read is in flight (or + // once it settled without data — the failure note rides beside). + return { data: base, detailState: snap.failed ? 'failed' : 'loading', retryDetail: retry } + } + const data: ContextTimeline = { + ...base, + requests: detail.requests, + events: detail.events, + nodes: detail.nodes, + droppedNodes: detail.droppedNodes, + archive: detail.archive, + // Slim heads serve no floors; the detail's pair lands whole here. + ...(detail.surfaceFloor !== undefined ? { surfaceFloor: detail.surfaceFloor } : {}), + ...(detail.archiveFloor !== undefined ? { archiveFloor: detail.archiveFloor } : {}), + ...(detail.fileOps !== undefined ? { fileOps: detail.fileOps } : {}), + ...(detail.fileOpsFloor !== undefined ? { fileOpsFloor: detail.fileOpsFloor } : {}), + } + return { data, detailState: 'ready', retryDetail: retry } }, [head, slim, store, snap]) }