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')}
+ )
) : (
+ // 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])
}