fix(browser): cold-start the timeline source when no projection value is pushed

A session can sit with no pushed contextTimeline value at all — the enable
recomposition raced its open baseline (the projection store clears a key the
baseline omits), or its checkpoint row is missing or version-stale on a
cold-observed session. The old code returned a loading surface in that case
with no request and no failure path, so the Context tab and /context modal
stalled on "Reading the session log…" until a refresh.

The source now opens the detail channel itself (rev 0): the detail route
folds the durable log on demand and serves the slim head beside the
collections, the next pushed head takes the channel back per its own
revision, and a read that settles without data surfaces the retryable
failure note instead of an eternal spinner.
This commit is contained in:
bowenliang123 committed 2026-09-29 17:10:11 +08:00
1 parent 9ec53289ba
commit 864aa2603c
3 files changed
+73 -24

No files matched your search

+7 -1
View File
@@ -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(
</div>
{data === null || head === null ? (
<div className="lc-empty">{t('loading')}</div>
source.detailState === 'failed' ? (
<DetailNote state="failed" onRetry={source.retryDetail} />
) : (
<div className="lc-empty">{t('loading')}</div>
)
) : (
<div>
<CurrentComposition
+10 -1
View File
@@ -362,7 +362,16 @@ export function makeContextView(
}, [activeReq, activeIdx, displayRequests])
if (!data) {
return <div className="lc-root" ref={rootRef}><div className="lc-empty">{t('loading')}</div></div>
// 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 (
<div className="lc-root" ref={rootRef}>
{source.detailState === 'failed'
? <DetailNote state="failed" onRetry={source.retryDetail} />
: <div className="lc-empty">{t('loading')}</div>}
</div>
)
}
const markerOf = (req: RequestRecord): ContextEventRecord | undefined => {
+56 -22
View File
@@ -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<TimelineSource>(() => {
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])
}