diff --git a/src/client/components/contextModal.tsx b/src/client/components/contextModal.tsx
index d6fb3f2..d5b1f62 100644
--- a/src/client/components/contextModal.tsx
+++ b/src/client/components/contextModal.tsx
@@ -16,9 +16,10 @@ import { contextPressureOf, headersOf, timelineOf } from '../services'
import type { ViewKit } from '../viewkit'
import { makeContextBrowser } from './browser'
import { makeCurrentComposition } from './currentComposition'
+import { makeErrorBoundary } from './errorBoundary'
import { makeLegend, makeStackedBar } from './stackedBar'
-import { React } from '../react'
+import { React, h } from '../react'
export interface ContextModalProps extends SessionStandardProps {
/** Bound selector hook over the per-session open flag (hooks compartment). */
@@ -36,8 +37,13 @@ export function makeContextModal(ctx: ClientCtx, kit: ViewKit): (props: ContextM
// while the browser shows the live step, and the tool chips jumping it).
const CurrentComposition = makeCurrentComposition(kit, StackedBar, Legend)
const ContextBrowser = makeContextBrowser(kit, StackedBar)
+ const ErrorBoundary = makeErrorBoundary(t)
- return function ContextModal(props: ContextModalProps): ReactNS.ReactElement | null {
+ // The body holds all hooks and data reads inside the error boundary (same
+ // arrangement as the Context tab): a corrupt projection value or framework
+ // surprise degrades to a styled error card inside the dialog instead of
+ // breaking the conversation input overlay around it.
+ 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'
@@ -133,4 +139,8 @@ export function makeContextModal(ctx: ClientCtx, kit: ViewKit): (props: ContextM
)
}
+
+ return function ContextModal(props: ContextModalProps): ReactNS.ReactElement | null {
+ return h(ErrorBoundary, null, h(ContextModalBody, props))
+ }
}
diff --git a/src/client/components/contextView.tsx b/src/client/components/contextView.tsx
index fb9b936..8044f72 100644
--- a/src/client/components/contextView.tsx
+++ b/src/client/components/contextView.tsx
@@ -26,7 +26,8 @@ import { makeStatsBoard } from './statsBoard'
import { makeLegend, makeStackedBar } from './stackedBar'
import { aggregateByTurn, attachMarkers, makeTrendChart } from './trendChart'
-import { React } from '../react'
+import { React, h } from '../react'
+import { makeErrorBoundary } from './errorBoundary'
// The context page scrolls inside the conversation's shared page scroller
// (`[data-conversation-scroll]`) — the same container the chat auto-scrolls
@@ -55,8 +56,15 @@ export function makeContextView(ctx: ClientCtx, kit: ViewKit): (props: ContextVi
const StatsBoard = makeStatsBoard(kit)
const PluginInfo = makePluginInfo(kit)
const ContextBrowser = makeContextBrowser(kit, StackedBar)
+ const ErrorBoundary = makeErrorBoundary(t)
- return function ContextView(props: ContextViewProps): ReactNS.ReactElement {
+ // The data-driven body lives inside the error boundary: any render error
+ // (a corrupt projection value that slips past the timelineOf shape guard,
+ // a framework surprise) degrades to a styled error card inside the tab
+ // instead of white-screening the whole conversation view. The boundary is
+ // a separate component with NO hooks of its own, so the body's hook order
+ // and the loading/data control flow stay exactly as they were.
+ function ContextViewBody(props: ContextViewProps): ReactNS.ReactElement {
const sessionId = props.sessionId
// The finished value the harness pushes for this session: the
// `contextTimeline` projection key (capability-absent until a value
@@ -340,4 +348,10 @@ export function makeContextView(ctx: ClientCtx, kit: ViewKit): (props: ContextVi
)
}
+
+ return function ContextView(props: ContextViewProps): ReactNS.ReactElement {
+ // The whole body — hooks, projection reads, derivation, and all child
+ // cards — renders under the boundary (see ContextViewBody above).
+ return h(ErrorBoundary, null, h(ContextViewBody, props))
+ }
}
diff --git a/src/client/components/errorBoundary.tsx b/src/client/components/errorBoundary.tsx
new file mode 100644
index 0000000..6049244
--- /dev/null
+++ b/src/client/components/errorBoundary.tsx
@@ -0,0 +1,52 @@
+/**
+ * ErrorBoundary — the tab's no-white-screen guarantee.
+ *
+ * A render error anywhere in the subtree (a foreign or corrupt projection
+ * value that slips past the shape guards in services.ts, a framework
+ * surprise, a future bug) would otherwise propagate into the harness's slot
+ * renderer and unmount the whole conversation view. This boundary catches
+ * render errors of its subtree and degrades to a styled error card with the
+ * offending message — the surrounding harness UI (chat, tabs, composer)
+ * keeps working as if nothing happened.
+ *
+ * A class component: the only React primitive that can catch render errors
+ * of a subtree (there is no hook-based boundary in React 18). The Retry
+ * button resets the boundary; if the underlying condition persists the
+ * boundary catches again, once a healthy value arrives the tab resumes.
+ */
+
+import type * as ReactNS from 'react'
+import type { Translate } from '../i18n'
+
+import { React } from '../react'
+
+export function makeErrorBoundary(t: Translate): ReactNS.ComponentType<{ children?: ReactNS.ReactNode }> {
+ return class ErrorBoundary extends React.Component<{ children?: ReactNS.ReactNode }, { error: Error | null }> {
+ constructor(props: { children?: ReactNS.ReactNode }) {
+ super(props)
+ this.state = { error: null }
+ }
+
+ static getDerivedStateFromError(error: unknown): { error: Error | null } {
+ return { error: error instanceof Error ? error : new Error(String(error)) }
+ }
+
+ render(): ReactNS.ReactNode {
+ const error = this.state.error
+ if (error === null) return this.props.children
+ return (
+
+
+ {t('error')}
+ {error.message}
+
+
+
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/client/components/statsBoard.tsx b/src/client/components/statsBoard.tsx
index 541d35d..1a332db 100644
--- a/src/client/components/statsBoard.tsx
+++ b/src/client/components/statsBoard.tsx
@@ -19,6 +19,7 @@ import type * as ReactNS from 'react'
import type { ContextEventRecord, RequestRecord, SessionCostUsage, TokenUsage } from '../../shared/types'
import { estimateSessionCost, formatCost, formatPriceRate, sessionPrices } from '../cost'
import type { CostCurrency } from '../cost'
+import { numOf } from '../services'
import type { ViewKit } from '../viewkit'
import { React } from '../react'
@@ -31,12 +32,16 @@ import { React } from '../react'
* round). Null when no input was billed. A tiny epsilon keeps a double stored
* a hair below a cent boundary (e.g. 80.00 as 79.9999999999…) from losing its
* last digit — with integer token counts no genuine value ever sits that close
- * to a boundary, so the epsilon can only absorb float noise.
+ * to a boundary, so the epsilon can only absorb float noise. Bucket reads go
+ * through numOf so a malformed payload degrades to a dash instead of NaN text.
*/
function cacheHitPercent(usage: TokenUsage): string | null {
- const billed = usage.uncachedInputTokens + usage.cacheReadTokens + usage.cacheWriteTokens
+ const uncached = numOf(usage.uncachedInputTokens)
+ const reads = numOf(usage.cacheReadTokens)
+ const writes = numOf(usage.cacheWriteTokens)
+ const billed = uncached + reads + writes
if (billed === 0) return null
- const hundredths = Math.trunc((usage.cacheReadTokens / billed) * 10000 + 1e-9)
+ const hundredths = Math.trunc((reads / billed) * 10000 + 1e-9)
return `${Math.floor(hundredths / 100)}.${String(hundredths % 100).padStart(2, '0')}`
}
diff --git a/src/client/cost.ts b/src/client/cost.ts
index e828f29..b441fbb 100644
--- a/src/client/cost.ts
+++ b/src/client/cost.ts
@@ -12,6 +12,7 @@
*/
import type { CostBucketTotals, SessionCostUsage } from '../shared/types'
+import { numOf } from './services'
/** Per-1M-token rates: cache-hit input, cache-miss input, output. */
export interface PriceTriple { hit: number; miss: number; out: number }
@@ -46,7 +47,7 @@ export function estimateSessionCost(usage: SessionCostUsage | null | undefined,
const b: CostBucketTotals | undefined = fam[period]
if (b === undefined) continue
const p: PriceTriple = PRICES[currency][family][period]
- total += (b.cacheRead * p.hit + (b.uncached + b.cacheWrite) * p.miss + b.output * p.out) / 1e6
+ total += (numOf(b.cacheRead) * p.hit + (numOf(b.uncached) + numOf(b.cacheWrite)) * p.miss + numOf(b.output) * p.out) / 1e6
any = true
}
}
diff --git a/src/client/format.ts b/src/client/format.ts
index 89005d5..39d35cb 100644
--- a/src/client/format.ts
+++ b/src/client/format.ts
@@ -13,5 +13,9 @@ export function fmt(n: number | null | undefined): string {
export function fmtTime(t: number): string {
// en-GB with a 24-hour clock renders the same local HH:MM:SS the manual
// zero-pad produced, without the hand-rolled p() helper.
- return new Date(t).toLocaleTimeString('en-GB', { hour12: false })
+ // An invalid timestamp (garbage from a corrupt payload) must show a dash,
+ // never throw — `toLocaleTimeString` raises RangeError on Invalid Date.
+ const d = new Date(t)
+ if (isNaN(d.getTime())) return '—'
+ return d.toLocaleTimeString('en-GB', { hour12: false })
}
diff --git a/src/client/i18n.ts b/src/client/i18n.ts
index 41df2d8..796a7c4 100644
--- a/src/client/i18n.ts
+++ b/src/client/i18n.ts
@@ -54,6 +54,7 @@ export const DICT_ZH: Record = {
'nodes.empty': '当前没有模型可见的消息',
'loading': '正在读取会话日志…',
'error': '上下文数据读取失败:',
+ 'error.retry': '重试',
'footer': '估算口径:与 dsh 内置 tokenMeter 相同的固定密度启发式(约 4 字符 ≈ 1 token);「实际」为供应商上报用量。',
'tip.step': '第 {t} 轮 · 第{s}步',
'tip.turn': '第 {t} 轮 · 共 {n} 步',
@@ -149,6 +150,7 @@ export const DICT_EN: Record = {
'nodes.empty': 'No model-visible messages right now',
'loading': 'Reading the session log…',
'error': 'Failed to read context data: ',
+ 'error.retry': 'Retry',
'footer': 'Estimate: same fixed-density heuristic as dsh’s built-in tokenMeter (~4 chars ≈ 1 token); “actual” is provider-reported usage.',
'tip.step': 'Turn {t} · Step {s}',
'tip.turn': 'Turn {t} · {n} steps',
diff --git a/src/client/services.ts b/src/client/services.ts
index 1a7a9c8..ef8f67f 100644
--- a/src/client/services.ts
+++ b/src/client/services.ts
@@ -11,7 +11,7 @@
*/
import type { Context } from '@deepseek-ai/cordis'
-import type { ContextHeaders, ContextPressure, ContextTimeline, TokenUsage } from '../shared/types'
+import type { ContextHeaders, ContextPressure, ContextTimeline, SessionCostUsage, TokenUsage } from '../shared/types'
export interface LocaleService {
register(ns: string, dicts: Record>): () => void
@@ -98,8 +98,84 @@ function asRecord(value: unknown): T | null {
return value as T
}
-/** Narrow a delivered projection value to the context timeline. */
-export const timelineOf = (value: unknown): ContextTimeline | null => asRecord(value)
+/**
+ * Safe finite-number read: a missing/non-numeric/NaN field degrades to 0
+ * instead of leaking into the UI as NaN percentages or broken arithmetic.
+ */
+export function numOf(value: unknown): number {
+ return typeof value === 'number' && Number.isFinite(value) ? value : 0
+}
+
+/** The usable (non-null object) entries of a delivered list; [] when the field is not a list. */
+function objectsOf(value: unknown): T[] {
+ if (!Array.isArray(value)) return []
+ return value.filter((v): v is T => v !== null && typeof v === 'object')
+}
+
+/**
+ * Narrow a delivered projection value to a RENDER-SAFE context timeline —
+ * the client's no-white-screen guarantee against backend/parse failures.
+ *
+ * A value that is not a record at all (capability absent, nothing delivered
+ * yet) stays `null` and callers show the loading screen. A record that fails
+ * the wire shape (corrupt checkpoint restore, a failed/older host payload,
+ * plugin drift) is SANITIZED instead of rejected: every collection becomes
+ * an array, non-object entries are dropped, `current` becomes a numeric
+ * breakdown, and wrong-typed scalars are dropped or zeroed — so the whole
+ * tab still renders with every usable piece of data instead of throwing
+ * during render and unmounting the conversation view.
+ */
+export function timelineOf(value: unknown): ContextTimeline | null {
+ const data = asRecord(value)
+ if (data === null) return null
+ const current = data.current
+ // The wire shape check for the cheap pass-through path: `current` must be
+ // a full numeric breakdown (the host always sends all seven fields), and
+ // every collection must be a real list. Anything else takes the slow path
+ // and is rebuilt into the safe shape below.
+ const numericBreakdown = current !== null && typeof current === 'object'
+ && ['system', 'tools', 'user', 'inject', 'assistant', 'tool', 'total']
+ .every(k => typeof (current as Record)[k] === 'number')
+ if (numericBreakdown
+ && Array.isArray(data.requests)
+ && Array.isArray(data.events)
+ && Array.isArray(data.nodes)
+ && Array.isArray(data.archive)
+ && Array.isArray(data.toolList)) {
+ // Well-formed: pass the delivered value through untouched (cheap, and
+ // reference-stable so plain re-renders stay zero-copy).
+ return data
+ }
+ // Malformed: rebuild with safe defaults, keeping every usable piece.
+ const safeCurrent: Record = current !== null && typeof current === 'object' ? current : {}
+ const cost = typeof data.cost === 'object' && data.cost !== null && !Array.isArray(data.cost)
+ ? data.cost as SessionCostUsage
+ : undefined
+ return {
+ ok: true,
+ ...(typeof data.model === 'string' ? { model: data.model } : {}),
+ ...(typeof data.provider === 'string' ? { provider: data.provider } : {}),
+ ...(typeof data.contextWindow === 'number' ? { contextWindow: data.contextWindow } : {}),
+ current: {
+ system: numOf(safeCurrent.system),
+ tools: numOf(safeCurrent.tools),
+ user: numOf(safeCurrent.user),
+ inject: numOf(safeCurrent.inject),
+ assistant: numOf(safeCurrent.assistant),
+ tool: numOf(safeCurrent.tool),
+ total: numOf(safeCurrent.total),
+ },
+ toolList: objectsOf(data.toolList),
+ requests: objectsOf(data.requests),
+ events: objectsOf(data.events),
+ nodes: objectsOf(data.nodes),
+ droppedNodes: numOf(data.droppedNodes),
+ archive: objectsOf(data.archive),
+ ...(cost !== undefined ? { cost } : {}),
+ ...(typeof data.surfaceFloor === 'number' ? { surfaceFloor: data.surfaceFloor } : {}),
+ ...(typeof data.archiveFloor === 'number' ? { archiveFloor: data.archiveFloor } : {}),
+ }
+}
/**
* Narrow a delivered projection value to the official token-meter
@@ -123,10 +199,22 @@ export const tokenUsageOf = (value: unknown): TokenUsage | null => asRecord(value)
- return headers !== null && Array.isArray(headers.headers) ? headers : null
+ if (headers === null || !Array.isArray(headers.headers)) return null
+ for (const h of headers.headers) {
+ if (h === null || typeof h !== 'object') return null
+ const entry = h as { tools?: unknown; system?: unknown }
+ if (!Array.isArray(entry.tools)) return null
+ if (entry.system !== undefined && typeof entry.system !== 'string') return null
+ }
+ return headers
}
// ---- /context command faces (framework `inputTriggers` service) ----
diff --git a/src/client/styles.ts b/src/client/styles.ts
index 9614da3..082d7b0 100644
--- a/src/client/styles.ts
+++ b/src/client/styles.ts
@@ -221,6 +221,13 @@ export const STYLES = [
'.lc-node-tokens { color: var(--dsw-alias-label-secondary); }',
'.lc-nodes-more { color: var(--dsw-alias-label-secondary); padding: 3px 0; }',
'.lc-empty { color: var(--dsw-alias-label-secondary); padding: 18px 0; text-align: center; }',
+ // Error-boundary fallback card: the tab degrades to this instead of
+ // white-screening; the message runs in mono on a padded chip, and Retry
+ // re-mounts the subtree once a healthy value arrives.
+ '.lc-error { display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 40px 16px; }',
+ '.lc-error-msg { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; color: var(--dsw-alias-state-error-primary); background: var(--dsw-alias-bg-layer-2); border: 1px solid var(--dsw-alias-border-l1); border-radius: 6px; padding: 4px 8px; max-width: 100%; overflow-wrap: anywhere; }',
+ '.lc-error-retry { border: 1px solid var(--dsw-alias-border-l1); background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-primary); font: inherit; font-size: 12px; padding: 4px 14px; border-radius: 6px; cursor: pointer; }',
+ '.lc-error-retry:hover { border-color: var(--dsw-alias-label-primary); }',
'.lc-foot { color: var(--dsw-alias-label-secondary); font-size: 12px; margin-top: 4px; }',
// ---- /context modal (centered dialog; escapes the composer anchor via fixed positioning) ----
'.lc-modal-backdrop { position: fixed; inset: 0; z-index: 200; background: rgba(0, 0, 0, 0.45); display: flex; align-items: center; justify-content: center; }',
diff --git a/tests/client.test.mjs b/tests/client.test.mjs
index 3747d31..1098aa3 100644
--- a/tests/client.test.mjs
+++ b/tests/client.test.mjs
@@ -31,6 +31,12 @@ const fakeReact = {
createElement: (...args) => ({ kind: 'element', args }),
useState: (init) => [init, () => {}],
useEffect: () => {},
+ // The ErrorBoundary extends React.Component; a minimal base keeps the
+ // class construction working (props assigned, state merge via setState).
+ Component: class Component {
+ constructor(props) { this.props = props }
+ setState(partial) { this.state = { ...this.state, ...partial } }
+ },
}
let handoff = null
@@ -225,6 +231,10 @@ const statefulReact = {
currentHooks[i] = { effect: fn }
return currentHooks[i]
},
+ Component: class Component {
+ constructor(props) { this.props = props }
+ setState(partial) { this.state = { ...this.state, ...partial } }
+ },
}
const requireStateful = (spec) => {
assert.ok(spec === 'react' || spec === '@deepseek-ai/dsh-client-ui-primitives',
@@ -234,7 +244,7 @@ const requireStateful = (spec) => {
const m2 = { exports: {} }
const pluginExports2 = factory(requireStateful, m2, globalThis.window, fakeDoc)
-const DICT_FOR_TEST = { 'tab': 'Context', 'loading': '…', 'error': 'x', 'detail.step': 'Turn {t} · Step {s}', 'gran.step': 'Step', 'gran.turn': 'Turn', 'detail.turn': 'Turn {t} · {n} steps', 'detail.lastStep': 'last step', 'overview.used': 'of context used', 'overview.free': 'Free window', 'events.at': 'Turn {t} · Step {s}', 'events.range': 'Turn {t} · Step {a}→{b}', 'events.rangeTo': 'Turn {a} · Step {as} → Turn {b} · Step {bs}', 'stats.recycleSub': '{c} compactions · {p} prunes', 'tip.step': 'Turn {t} · Step {s}', 'tip.turn': 'Turn {t} · {n} steps', 'tip.total': 'total ≈ {n}', 'tip.actual': ' (actual {n})', 'trend.title': 'History', 'trend.empty': 'empty trend', 'cmd.close': 'Close', 'cat.user': 'User', 'browser.live': 'Live (next request)', 'browser.liveNow': 'Live · next request', 'browser.items': '{n} items', 'browser.noContent': 'outside the loaded window', 'browser.loading': 'loading older history', 'browser.preview': 'preview', 'browser.noHeader': 'older plugin build',
+const DICT_FOR_TEST = { 'tab': 'Context', 'loading': '…', 'error': 'ERR:', 'error.retry': 'retry', 'detail.step': 'Turn {t} · Step {s}', 'gran.step': 'Step', 'gran.turn': 'Turn', 'detail.turn': 'Turn {t} · {n} steps', 'detail.lastStep': 'last step', 'overview.used': 'of context used', 'overview.free': 'Free window', 'events.at': 'Turn {t} · Step {s}', 'events.range': 'Turn {t} · Step {a}→{b}', 'events.rangeTo': 'Turn {a} · Step {as} → Turn {b} · Step {bs}', 'stats.recycleSub': '{c} compactions · {p} prunes', 'tip.step': 'Turn {t} · Step {s}', 'tip.turn': 'Turn {t} · {n} steps', 'tip.total': 'total ≈ {n}', 'tip.actual': ' (actual {n})', 'trend.title': 'History', 'trend.empty': 'empty trend', 'cmd.close': 'Close', 'cat.user': 'User', 'browser.live': 'Live (next request)', 'browser.liveNow': 'Live · next request', 'browser.items': '{n} items', 'browser.noContent': 'outside the loaded window', 'browser.loading': 'loading older history', 'browser.preview': 'preview', 'browser.noHeader': 'older plugin build',
'browser.deltaHint': 'vs previous turn', 'overview.compactReserve': 'compact reserve {pct}%', 'tool.desc': 'Description', 'tool.params': 'Parameters', 'tool.paramsEmpty': '(no parameters)', 'tool.jsonToggle': 'View Raw JSON', 'tool.jsonHide': 'Collapse', 'rich.raw': 'Raw', 'rich.md': 'Markdown', 'rich.toMd': 'View as Markdown', 'rich.toRaw': 'View Raw Text' }
let viewComponent = null
let modalComponent = null
@@ -310,11 +320,37 @@ const renderView = () => evaluate(viewComponent({
/** Invoke function-typed elements so hooks run and the tree materializes.
* Hooks are keyed by the component's fiber path (e.g. root/ContextView#0/
- * StackedBar#0), so distinct instances of the same component keep state. */
+ * StackedBar#0), so distinct instances of the same component keep state.
+ * Class components (the ErrorBoundary) are simulated with a React-like
+ * lifecycle: an instance is created from the prototype with props + empty
+ * state, render errors are caught through the static getDerivedStateFromError
+ * (the boundary protocol) and the fallback re-renders, and instances are
+ * registered so tests can inspect the caught error. */
+const classInstances = new Map()
function evaluate(node, path = '', fnIdx = 0) {
if (node === null || typeof node !== 'object') return node
if (node.kind === 'element') {
const [type, props, ...children] = node.args
+ if (typeof type === 'function' && type.prototype !== undefined && typeof type.prototype.render === 'function') {
+ const key = path + '/' + (type.name || 'anon') + '#' + fnIdx
+ // The fake createElement keeps children in args, not props; real React
+ // puts them on props.children (single child as-is, several as an
+ // array), which the class instance's render() reads. Constructing with
+ // `new` runs the real constructor, so props AND the initial state are
+ // exactly what React would set.
+ const childrenOf = children.length === 0 ? {} : children.length === 1 ? { children: children[0] } : { children }
+ const inst = new type(Object.assign({}, props || {}, childrenOf))
+ classInstances.set(key, inst)
+ try {
+ return evaluate(inst.render(), key)
+ } catch (err) {
+ if (typeof type.getDerivedStateFromError === 'function') {
+ inst.state = type.getDerivedStateFromError(err)
+ return evaluate(inst.render(), key)
+ }
+ throw err
+ }
+ }
if (typeof type === 'function') {
const key = path + '/' + (type.name || 'anon') + '#' + fnIdx
currentHooks = hookStates.get(key)
@@ -377,7 +413,9 @@ const snapshot = {
}
// Deliver the timeline like a session/projection frame: swap the holder the
// useProjection stub reads, then re-render the view.
-const ctxKey = [...hookStates.keys()].find(k => k.includes('ContextView'))
+// The ContextView fiber: the boundary wrapper owns an EMPTY hook slot array,
+// so the data-driven body's fiber is the one carrying the view hooks.
+const ctxKey = [...hookStates.keys()].find(k => k.includes('ContextView') && hookStates.get(k).length > 0)
assert.ok(ctxKey, 'ContextView fiber registered')
dataValue = snapshot
const tree = renderView()
@@ -1463,3 +1501,76 @@ modalOpen = false
assert.equal(renderModal(), null, 'modal closes again')
console.log('✔ modal render test passed (open/close, current overview, embedded context browser, localized chrome, deferred token consume on close)')
+
+// ---- backend-parse-failure resilience: a corrupt/foreign `contextTimeline`
+// value must never white-screen the tab. The projection is sanitized into a
+// render-safe shape — the tab shows the WHOLE UI with every usable piece —
+// and any residual render error is caught by the error boundary (a styled
+// error card, not an unmounted conversation view). ----
+
+// A non-record value (capability absent, nothing delivered yet): loading.
+dataValue = 'not-even-an-object'
+tr = renderView()
+assert.equal(textOf(byClass(tr, 'lc-empty')[0]), '…', 'a non-record value keeps the loading screen')
+
+// A garbage payload: wrong-typed scalars drop, non-list collections degrade
+// to [], non-object entries are dropped, and the breakdown zeroes out.
+dataValue = {
+ ok: false,
+ model: 42,
+ current: null,
+ requests: ['junk', { seq: 1, turn: 1, time: 10, system: 1, tools: 2, user: 3, inject: 0, assistant: 4, tool: 0, total: 10 }],
+ events: [null, { kind: 'inject', seq: 1, time: 5 }],
+ nodes: [null, { seq: 1, cat: 'user', tokens: 10, text: 'talk', time: NaN }],
+ archive: { bogus: true },
+ toolList: [null, { name: 'bash', tokens: 5 }],
+ droppedNodes: -3,
+ cost: ['not-a-record'],
+ surfaceFloor: 'x',
+}
+tr = renderView() // must not throw
+assert.equal(byClass(tr, 'lc-bar').length, 1, 'the usable request entry still renders a trend bar')
+assert.equal(byClass(tr, 'lc-stat-value').length, 7, 'stats board still renders')
+const statValsM = byClass(tr, 'lc-stat-value').map(n => n.args[2])
+assert.equal(statValsM[0], '1', 'turns count survives (non-array requests degraded, entries filtered)')
+assert.equal(statValsM[1], '1', 'steps count survives')
+assert.equal(statValsM[5], '—', 'cache hit degrades to a dash')
+assert.equal(statValsM[6], '—', 'cost degrades to a dash (non-record cost dropped)')
+assert.equal(byClass(tr, 'lc-event').length, 1, 'the usable event entry still shows')
+assert.equal(byClass(tr, 'lc-node').length, 1, 'the usable node entry still shows')
+assert.equal(textOf(byClass(tr, 'lc-node-time')[0]), '—', 'an invalid timestamp shows a dash instead of throwing')
+assert.equal(byClass(tr, 'lc-tool-chip').length, 1, 'the usable tool entry still shows')
+assert.equal(byClass(tr, 'lc-br-cat-row').length, 6, 'the context browser still renders all six sections')
+
+// The /context modal degrades identically: sanitized empty data renders the
+// dialog shell with full sections instead of throwing.
+const brokenModalTree = evaluate(modalComponent({
+ sessionId: 's1',
+ useProjection: (key) => (key === 'contextTimeline'
+ ? { current: 'x', requests: 5, nodes: null, toolList: 7, events: {}, archive: 'z' }
+ : undefined),
+ useContextModal: (sel) => sel(true),
+}))
+assert.equal(byClass(brokenModalTree, 'lc-modal-card').length, 1, 'the modal shell stays intact')
+assert.equal(byClass(brokenModalTree, 'lc-br-cat-row').length, 6, 'the modal degrades to full sections with sanitized data')
+
+// A projection reader that THROWS (a broker/framework failure) lands in the
+// error boundary's styled card — not a white screen — with the boundary
+// protocol recording the offending error.
+const boomTree = evaluate(viewComponent({
+ sessionId: 's1',
+ useProjection: () => { throw new Error('boom-parse') },
+}))
+assert.equal(byClass(boomTree, 'lc-error').length, 1, 'a throwing reader degrades to the error card')
+assert.match(textOf(byClass(boomTree, 'lc-error')[0]), /ERR:.*boom-parse/, 'the card carries the offending message')
+assert.equal(byClass(boomTree, 'lc-error-retry').length, 1, 'the card offers Retry')
+const boundaryKey = [...classInstances.keys()].find(k => k.includes('ErrorBoundary'))
+assert.ok(boundaryKey, 'boundary instance registered')
+assert.match(classInstances.get(boundaryKey).state.error.message, /boom-parse/, 'the boundary protocol recorded the error')
+
+// restore state for any later tests
+dataValue = snapshot
+tr = renderView()
+assert.equal(byClass(tr, 'lc-bar').length, 4, 'snapshot restored after the resilience tests')
+
+console.log('✔ backend-parse-failure resilience test passed (sanitized render of corrupt payloads, degraded modal, error boundary card)')