diff options
| author | Adam Malczewski <[email protected]> | 2026-06-11 16:06:48 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-11 16:06:48 +0900 |
| commit | e45cab2a2d9d7bf5e48ace7111fd84b1b9bf2df3 (patch) | |
| tree | e9cd8665a3eea609ef1e027906be4abdfe67d876 /src/core/protocol/reducer.ts | |
| parent | b3f7ba523f644224364d155b575fa3f9f13c5eb9 (diff) | |
| download | dispatch-web-e45cab2a2d9d7bf5e48ace7111fd84b1b9bf2df3.tar.gz dispatch-web-e45cab2a2d9d7bf5e48ace7111fd84b1b9bf2df3.zip | |
feat(cache-warming,surfaces,metrics,markdown): conversation-scoped surfaces, cache warming + retention, markdown
Consumes the backend cache-warming + cache-rate handoffs end-to-end and adds supporting infra:
- protocol/transport: conversation-scoped surfaces (conversationId on subscribe/invoke/surface + staleness routing); store auto-subscribes the catalog with the focused conversation and re-scopes on switch.
- surface-host: generic Number field renderer + custom rendererId dispatch (graceful skip on unknown).
- cache-warming feature: enabled toggle, min+sec interval, AUTHORITATIVE countdown from the surface's cache-warming-timer nextWarmAt, manual Warm now (POST /chat/warm), lastWarmAt-keyed history, cache-retention stat, expectedCacheRate headline.
- metrics: cross-turn expected-cache (retention) derivation + bubble badge; cache-rate fix needs no code change (inputTokens now total).
- markdown feature: marked + marked-highlight + highlight.js + dompurify, rendered in ChatView.
- fixes (gemini review): {#key activeConversationId} remount of CacheWarmingView to stop history/feedback leaking across tabs; guard NaN interval inputs from committing 0.
- docs/contracts: regenerated transport/ui-contract mirrors; backend-handoff updated (CR-3 resolved).
Verified: svelte-check 0 errors, biome clean, 494 tests pass, vite build OK.
Diffstat (limited to 'src/core/protocol/reducer.ts')
| -rw-r--r-- | src/core/protocol/reducer.ts | 101 |
1 files changed, 81 insertions, 20 deletions
diff --git a/src/core/protocol/reducer.ts b/src/core/protocol/reducer.ts index 992a918..3d6b1c8 100644 --- a/src/core/protocol/reducer.ts +++ b/src/core/protocol/reducer.ts @@ -2,6 +2,7 @@ import type { InvokeMessage, SubscribeMessage, SurfaceServerMessage, + SurfaceSpec, UnsubscribeMessage, } from "@dispatch/ui-contract"; import type { ProtocolResult, ProtocolState } from "./types"; @@ -15,6 +16,31 @@ export function initialState(): ProtocolState { }; } +// ── Message builders (respect exactOptionalPropertyTypes: omit `conversationId` +// entirely for a global subscription rather than setting it to `undefined`). ── + +function subMsg(surfaceId: string, conversationId: string | undefined): SubscribeMessage { + return conversationId === undefined + ? { type: "subscribe", surfaceId } + : { type: "subscribe", surfaceId, conversationId }; +} + +function unsubMsg(surfaceId: string, conversationId: string | undefined): UnsubscribeMessage { + return conversationId === undefined + ? { type: "unsubscribe", surfaceId } + : { type: "unsubscribe", surfaceId, conversationId }; +} + +/** + * Is an inbound spec/update (which echoes `echoedId`) current for the + * subscription whose desired scope is `desiredId`? A scoped surface echoes its + * conversationId, so it must match the one we last subscribed with; a GLOBAL + * surface echoes nothing (`undefined`) and is always current. + */ +function isCurrent(desiredId: string | undefined, echoedId: string | undefined): boolean { + return echoedId === undefined || echoedId === desiredId; +} + /** Fold an inbound server message into the next protocol state. */ export function applyServerMessage(state: ProtocolState, msg: SurfaceServerMessage): ProtocolState { switch (msg.type) { @@ -22,18 +48,21 @@ export function applyServerMessage(state: ProtocolState, msg: SurfaceServerMessa return { ...state, catalog: msg.catalog }; case "surface": { - const surfaceId = msg.spec.id; - if (!state.subscriptions.has(surfaceId)) return state; + const sub = state.subscriptions.get(msg.spec.id); + if (sub === undefined) return state; + if (!isCurrent(sub.conversationId, msg.conversationId)) return state; const subs = new Map(state.subscriptions); - subs.set(surfaceId, msg.spec); + subs.set(msg.spec.id, { conversationId: sub.conversationId, spec: msg.spec }); return { ...state, subscriptions: subs }; } case "update": { - const surfaceId = msg.update.surfaceId; - if (!state.subscriptions.has(surfaceId)) return state; + const { surfaceId, spec, conversationId } = msg.update; + const sub = state.subscriptions.get(surfaceId); + if (sub === undefined) return state; + if (!isCurrent(sub.conversationId, conversationId)) return state; const subs = new Map(state.subscriptions); - subs.set(surfaceId, msg.update.spec); + subs.set(surfaceId, { conversationId: sub.conversationId, spec }); return { ...state, subscriptions: subs }; } @@ -43,40 +72,72 @@ export function applyServerMessage(state: ProtocolState, msg: SurfaceServerMessa } /** - * Subscribe to a surface. Idempotent: if already subscribed, returns the same - * state with no outgoing message. + * Subscribe to a surface for a given conversation (omit `conversationId` for a + * GLOBAL surface / when no conversation is focused). + * + * - Not yet subscribed → emits one `subscribe`. + * - Already subscribed with the SAME scope → idempotent no-op. + * - Already subscribed with a DIFFERENT conversation (a re-scope on conversation + * switch) → emits `unsubscribe` for the old pair then `subscribe` for the new + * one, retaining the previous spec until the new one arrives (no flicker). */ -export function subscribe(state: ProtocolState, surfaceId: string): ProtocolResult { - if (state.subscriptions.has(surfaceId)) { +export function subscribe( + state: ProtocolState, + surfaceId: string, + conversationId?: string, +): ProtocolResult { + const existing = state.subscriptions.get(surfaceId); + if (existing !== undefined && existing.conversationId === conversationId) { return { state, outgoing: [] }; } const subs = new Map(state.subscriptions); - subs.set(surfaceId, null); - const outgoing: SubscribeMessage = { type: "subscribe", surfaceId }; - return { state: { ...state, subscriptions: subs }, outgoing: [outgoing] }; + const outgoing: (SubscribeMessage | UnsubscribeMessage)[] = []; + const priorSpec: SurfaceSpec | null = existing?.spec ?? null; + if (existing !== undefined) { + outgoing.push(unsubMsg(surfaceId, existing.conversationId)); + } + subs.set(surfaceId, { conversationId, spec: priorSpec }); + outgoing.push(subMsg(surfaceId, conversationId)); + return { state: { ...state, subscriptions: subs }, outgoing }; } /** - * Unsubscribe from a surface. Drops the local spec and emits one unsubscribe. - * If not subscribed, returns the same state with no outgoing. + * Unsubscribe from a surface. Drops the local subscription and emits one + * `unsubscribe` (for the conversation pair it was subscribed under). No-op if + * not subscribed. */ export function unsubscribe(state: ProtocolState, surfaceId: string): ProtocolResult { - if (!state.subscriptions.has(surfaceId)) { + const existing = state.subscriptions.get(surfaceId); + if (existing === undefined) { return { state, outgoing: [] }; } const subs = new Map(state.subscriptions); subs.delete(surfaceId); - const outgoing: UnsubscribeMessage = { type: "unsubscribe", surfaceId }; - return { state: { ...state, subscriptions: subs }, outgoing: [outgoing] }; + return { + state: { ...state, subscriptions: subs }, + outgoing: [unsubMsg(surfaceId, existing.conversationId)], + }; } -/** Invoke a field's action on a surface. Emits an InvokeMessage; no state change. */ +/** + * Invoke a field's action on a surface. Emits an InvokeMessage (carrying + * `conversationId` for a scoped surface); no state change. + */ export function invoke( state: ProtocolState, surfaceId: string, actionId: string, payload?: unknown, + conversationId?: string, ): ProtocolResult { - const outgoing: InvokeMessage = { type: "invoke", surfaceId, actionId, payload }; + const outgoing: InvokeMessage = + conversationId === undefined + ? { type: "invoke", surfaceId, actionId, payload } + : { type: "invoke", surfaceId, actionId, payload, conversationId }; return { state, outgoing: [outgoing] }; } + +/** The current spec for a subscribed surface, or `null` if absent/unsubscribed. */ +export function getSurfaceSpec(state: ProtocolState, surfaceId: string): SurfaceSpec | null { + return state.subscriptions.get(surfaceId)?.spec ?? null; +} |
