@tai42/studio-sdk 5.0.0 → 6.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tai42/studio-sdk",
3
- "version": "5.0.0",
3
+ "version": "6.0.1",
4
4
  "description": "The Studio plugin contract, shared UI components, and hooks that every feature builds on.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "tai42 <oss@tai42.ai>",
@@ -90,7 +90,7 @@
90
90
  "@radix-ui/react-select": "^2.3.2",
91
91
  "@radix-ui/react-tabs": "^1.1.16",
92
92
  "@radix-ui/react-tooltip": "^1.2.11",
93
- "@tai42/api-client": "5.0.0"
93
+ "@tai42/api-client": "6.0.1"
94
94
  },
95
95
  "scripts": {
96
96
  "build": "tsc -b tsconfig.build.json && node scripts/copy-assets.mjs",
@@ -1,29 +1,50 @@
1
1
  /**
2
- * `useSse` — the live interactions stream, implemented over the api-client's
3
- * `streamInteractions` (fetch + ReadableStream, never EventSource).
2
+ * `useInteractionsStream` — the live interactions inbox, a TAIL-ONLY SSE stream
3
+ * (via the api-client's `streamInteractions`: fetch + ReadableStream, never
4
+ * EventSource) laid over a PAGED base the caller supplies.
4
5
  *
5
- * SSE event contract: events `interaction.add` /
6
- * `interaction.answered` / `interaction.removed` / `interaction.backlog_done`.
7
- * The `add` frame (backlog on connect + live tail) carries the full question;
8
- * `answered` / `removed` frames carry only ids. On (re)connect the server
9
- * replays the current PENDING set as the backlog (answered items are NOT in it);
10
- * this hook treats that backlog as authoritative for the PENDING set only — a
11
- * still-pending entry not re-seen by `interaction.backlog_done` is dropped
12
- * (removed while the client was disconnected), while a locally-ANSWERED entry is
13
- * exempt from that reconcile so it survives reconnect (reconcile-deleting it
14
- * would vanish an answered card on the next reconnect, since the pending-only
15
- * backlog never replays it).
6
+ * The stream carries only the live tail: events `interaction.add` /
7
+ * `interaction.answered` / `interaction.removed` (no backlog, no
8
+ * `interaction.backlog_done`). The PENDING SET is not replayed on the stream — it
9
+ * is the `seed` the caller passes from the paged `GET /api/interactions` door; this
10
+ * hook overlays the live deltas onto it. The hook calls `onResync` on every
11
+ * (re)connect ONLY, so the caller refetches that base to catch up anything missed
12
+ * while disconnected; a live delta never triggers a refetch — the overlay updates
13
+ * the list and the derived `count` in place.
14
+ *
15
+ * The published list is `merge(seed, live)`: every seeded pending item, plus any
16
+ * item added on the tail since connect, minus any removed on the tail, with the
17
+ * client `answered` flag set on an item the tail reported answered.
18
+ *
19
+ * The published `count` is the live PENDING tally, derived from the same overlay
20
+ * without a refetch, reconciled by EPOCH so a resync never double-counts. Each
21
+ * (re)connect refetches the base under a fresh epoch and, once that refetch LANDS
22
+ * fresh, records it as `countEpoch` (a failed refetch does not advance it, so
23
+ * prior-epoch deltas keep applying against the consistent prior total rather than
24
+ * being discarded against a stale one); every overlay add and every terminal
25
+ * (answered or removed) entry is stamped with the epoch current when its frame
26
+ * arrived. The count
27
+ * is the seed `total` plus live adds not in the seed AND stamped at or after
28
+ * `countEpoch`, minus one for every terminal id stamped at or after `countEpoch`
29
+ * (deduped per id) — a terminal or an add already folded into the refetched `total`
30
+ * carries an older stamp and is not applied again. The single residual: a frame
31
+ * arriving while a refetch is in flight may or may not be reflected in that refetch's
32
+ * `total`, and is reconciled on the next resync.
16
33
  *
17
34
  * ANSWERED-CARD LIFECYCLE: the server emits no removal for an answered card —
18
- * only `interaction.answered`, never `interaction.removed` for it — so answered
19
- * cards would otherwise pile up unbounded in the always-mounted badge/inbox. This
20
- * hook AGES them out itself: an answered card is dropped once it is older than
21
- * `ANSWERED_RETENTION_MS`, with `ANSWERED_CAP` as a hard newest-N flood backstop.
22
- * Both are swept lazily on frame/reconnect ticks (the stream carries no idle
23
- * heartbeat frame), never a per-item timer.
35
+ * only `interaction.answered`, never `interaction.removed` for it — and the paged
36
+ * base never carries answered items. On its answered frame a card is promoted into
37
+ * the overlay's adds (a seed-origin card too), so it lingers uniformly whether it
38
+ * began as a live add or a seed item, even after a refetch drops it from the base.
39
+ * This hook AGES it out itself: an answered card is dropped `ANSWERED_RETENTION_MS`
40
+ * after its answered frame (tombstoned so a still-stale seed cannot resurrect it),
41
+ * with `ANSWERED_CAP` as a hard newest-N flood backstop. Both are swept lazily on
42
+ * frame/reconnect ticks (the stream carries no idle heartbeat frame), never a
43
+ * per-item timer.
24
44
  *
25
- * `add` frames are at-least-once, so entries are de-duplicated by
26
- * `interaction_id`, and a redelivered `add` preserves the client `answered` flag.
45
+ * `add` / `answered` frames are at-least-once; entries are keyed by
46
+ * `interaction_id`, a redelivered `add` overwrites in place, and a redelivered
47
+ * `answered` keeps the original stamp so the retention window is not extended.
27
48
  *
28
49
  * The `Interaction` type is imported type-only (no runtime dep on api-client);
29
50
  * frames are validated defensively and a malformed frame is surfaced as an error,
@@ -40,18 +61,43 @@ import { isFeatureDisabled } from '../feature-disabled';
40
61
  /** A live interaction plus the client-maintained `answered` flag. */
41
62
  export type StreamInteraction = Interaction & { readonly answered: boolean };
42
63
 
43
- /**
44
- * The internal map value: a `StreamInteraction` plus the wall-clock ms of its
45
- * answered frame (`null` while pending), read only by the client-side aging
46
- * sweep. `answeredAt` is stripped before the interaction is published — it is not
47
- * part of the public `StreamInteraction` contract.
48
- */
49
- type TrackedInteraction = StreamInteraction & { readonly answeredAt: number | null };
64
+ /** The inputs the caller drives the tail-only stream with. */
65
+ export interface InteractionsStreamOptions {
66
+ /**
67
+ * The pending set from the paged `GET /api/interactions` door — the base the
68
+ * live tail overlays. A stable reference between refetches (memoize it upstream);
69
+ * a new reference re-merges but does NOT reopen the stream.
70
+ */
71
+ readonly seed: readonly Interaction[];
72
+ /**
73
+ * The door's authoritative pending TOTAL for the current `seed` (the paged
74
+ * response's `total`). The published `count` derives the live pending tally from
75
+ * this plus the overlay delta, so the badge stays live without a refetch.
76
+ */
77
+ readonly total: number;
78
+ /**
79
+ * Refetch `seed`. Called on every (re)connect ONLY — the tail carries no backlog,
80
+ * so this reconciles anything missed while disconnected. A live delta never calls
81
+ * it: the overlay updates the list and the derived `count` in place. MUST resolve
82
+ * `true` only when the refetch LANDED a fresh `total`, and `false` when it failed:
83
+ * the hook advances `countEpoch` only on `true`, so terminals and adds already
84
+ * folded into the fresh `total` are not applied to the count again, while after a
85
+ * failed refetch the prior-epoch deltas keep applying against the unchanged prior
86
+ * total instead of being discarded against a stale one.
87
+ */
88
+ readonly onResync: () => boolean | Promise<boolean>;
89
+ }
50
90
 
51
91
  export interface InteractionsStreamState {
52
92
  readonly interactions: StreamInteraction[];
93
+ /**
94
+ * The live pending count: the seed `total` adjusted by the overlay delta stamped at
95
+ * or after the last landed resync's epoch (adds not in the seed, minus one per
96
+ * terminal id). Stays live without a refetch; a delta stamped before `countEpoch` is
97
+ * already reflected in `total` and not applied again.
98
+ */
99
+ readonly count: number;
53
100
  readonly connected: boolean;
54
- readonly backlogLoaded: boolean;
55
101
  readonly error: Error | null;
56
102
  /**
57
103
  * The interactions store is not configured on this deployment: the stream
@@ -64,21 +110,22 @@ export interface InteractionsStreamState {
64
110
 
65
111
  // Reconnect backoff: capped exponential with full jitter. The delay for attempt
66
112
  // n is a random value in [0, min(CAP, BASE * 2**n)); the attempt counter resets
67
- // only when a connection is proven healthy (`interaction.backlog_done`), not when
68
- // it merely opens — so a flapping server that accepts the request then drops the
69
- // body still backs off instead of tight-looping. A healthy tail-reconnect is
70
- // near-instant while a sustained outage backs off, and the jitter spreads many
71
- // clients apart rather than retrying in lockstep.
113
+ // only when a connection PROVES HEALTHY — it stayed open at least
114
+ // `HEALTHY_CONNECTION_MS` — not merely when it opens, so a server that accepts the
115
+ // request then drops the body still backs off instead of tight-looping. A healthy
116
+ // tail-reconnect is near-instant while a sustained outage backs off, and the
117
+ // jitter spreads many clients apart rather than retrying in lockstep.
72
118
  const RECONNECT_BASE_MS = 1500;
73
119
  const RECONNECT_CAP_MS = 30000;
120
+ const HEALTHY_CONNECTION_MS = RECONNECT_BASE_MS;
74
121
 
75
122
  // The server emits no removal for an answered card — only `interaction.answered`,
76
- // never `interaction.removed` for it — so answered cards would otherwise pile up
77
- // unbounded in the always-mounted badge/inbox. The client ages them out: drop an
78
- // answered card ANSWERED_RETENTION_MS after its answered frame (a "recently
79
- // answered" window), keeping at most ANSWERED_CAP as a hard flood backstop. Swept
80
- // lazily on frame/reconnect ticks (the stream carries no idle heartbeat frame),
81
- // never a per-item timer.
123
+ // never `interaction.removed` for it — and the paged base never carries answered
124
+ // items, so answered cards would otherwise pile up unbounded in the always-mounted
125
+ // badge/inbox. The client ages them out: drop an answered card
126
+ // ANSWERED_RETENTION_MS after its answered frame, keeping at most ANSWERED_CAP as a
127
+ // hard flood backstop. Swept lazily on frame/reconnect ticks (the stream carries no
128
+ // idle heartbeat frame), never a per-item timer.
82
129
  const ANSWERED_RETENTION_MS = 10 * 60 * 1000;
83
130
  const ANSWERED_CAP = 200;
84
131
 
@@ -199,116 +246,186 @@ function parseId(data: string): string | null {
199
246
  return typeof id === 'string' ? id : null;
200
247
  }
201
248
 
202
- export function useInteractionsStream(): InteractionsStreamState {
249
+ /**
250
+ * The live overlay a connection accumulates over the paged base: the tail's own
251
+ * adds (kept whether or not the base already lists them, so a fresher add wins),
252
+ * the answered stamps (wall-clock ms, for the aging sweep), and the tombstones for
253
+ * ids removed on the tail OR aged out of the answered window (a tombstone stops a
254
+ * still-stale seed from re-showing a gone card; ids are never reused). `addEpoch`
255
+ * and `goneEpoch` carry the resync epoch current when an add / a first terminal frame
256
+ * arrived, so the count applies an add (or subtracts a terminal) only while it is not
257
+ * yet folded into the refetched `total`; the merge and the aging sweep ignore them.
258
+ */
259
+ interface LiveOverlay {
260
+ readonly adds: Map<string, Interaction>;
261
+ readonly addEpoch: Map<string, number>;
262
+ readonly answeredAt: Map<string, number>;
263
+ readonly removed: Set<string>;
264
+ readonly goneEpoch: Map<string, number>;
265
+ }
266
+
267
+ function emptyOverlay(): LiveOverlay {
268
+ return {
269
+ adds: new Map(),
270
+ addEpoch: new Map(),
271
+ answeredAt: new Map(),
272
+ removed: new Set(),
273
+ goneEpoch: new Map(),
274
+ };
275
+ }
276
+
277
+ /** Fold the paged base and the live overlay into the published, ordered list. */
278
+ function merge(seed: readonly Interaction[], live: LiveOverlay): StreamInteraction[] {
279
+ const out = new Map<string, StreamInteraction>();
280
+ for (const item of seed) {
281
+ if (live.removed.has(item.interaction_id)) continue;
282
+ out.set(item.interaction_id, { ...item, answered: live.answeredAt.has(item.interaction_id) });
283
+ }
284
+ for (const [id, item] of live.adds) {
285
+ if (live.removed.has(id)) continue;
286
+ out.set(id, { ...item, answered: live.answeredAt.has(id) });
287
+ }
288
+ return Array.from(out.values());
289
+ }
290
+
291
+ /**
292
+ * The live pending tally: the door's `total` for the seed, plus live adds whose id
293
+ * is absent from the seed AND stamped at or after `countEpoch` (a new question not
294
+ * yet folded into `total`), minus one for every terminal id — answered OR removed —
295
+ * stamped at or after `countEpoch`, deduped per id so an id counts once however many
296
+ * terminal frames it drew. `countEpoch` is the epoch of the last resync whose refetch
297
+ * LANDED (a failed refetch never advances it): a delta stamped before it is already
298
+ * reflected in the fresh `total`, so it is NOT applied again — this is what stops a
299
+ * resync from re-subtracting a terminal it already dropped from `total`, while a
300
+ * failed refetch keeps the prior-epoch deltas applying against the prior total. Every
301
+ * terminal frame a client receives refers to a
302
+ * question inside its own `total` (the stream and the list door apply the same
303
+ * audience filter). For honest server data this never goes negative, so no clamp is
304
+ * applied. The single residual: a frame arriving while a refetch is in flight may or
305
+ * may not be reflected in that refetch's `total`, and is reconciled on the next resync.
306
+ */
307
+ function pendingCount(
308
+ seed: readonly Interaction[],
309
+ total: number,
310
+ live: LiveOverlay,
311
+ countEpoch: number,
312
+ ): number {
313
+ const seedIds = new Set(seed.map((item) => item.interaction_id));
314
+ let count = total;
315
+ for (const id of live.adds.keys()) {
316
+ if (!seedIds.has(id) && (live.addEpoch.get(id) ?? 0) >= countEpoch) count += 1;
317
+ }
318
+ const gone = new Set<string>([...live.answeredAt.keys(), ...live.removed]);
319
+ for (const id of gone) {
320
+ if ((live.goneEpoch.get(id) ?? 0) >= countEpoch) count -= 1;
321
+ }
322
+ return count;
323
+ }
324
+
325
+ export function useInteractionsStream(options: InteractionsStreamOptions): InteractionsStreamState {
326
+ const { seed, total, onResync } = options;
203
327
  const api = useApi();
204
328
  const onUnauthorized = useOnUnauthorized();
205
- const [state, setState] = useState<InteractionsStreamState>({
206
- interactions: [],
207
- connected: false,
208
- backlogLoaded: false,
209
- error: null,
210
- disabled: false,
211
- });
212
- // Source of truth: a dedupe map keyed by interaction_id, rebuilt into the
213
- // ordered array on every change.
214
- const mapRef = useRef<Map<string, TrackedInteraction>>(new Map());
329
+
330
+ const [connectionState, setConnectionState] = useState<{
331
+ connected: boolean;
332
+ error: Error | null;
333
+ disabled: boolean;
334
+ }>({ connected: false, error: null, disabled: false });
335
+
336
+ // The live overlay is a ref (mutated in place by the stream loop); the published
337
+ // list is `merge(seed, overlay)` held in state, recomputed on every overlay
338
+ // change (the stream loop) and every seed change (a refetch of the paged base).
339
+ const overlayRef = useRef<LiveOverlay>(emptyOverlay());
340
+ const [interactions, setInteractions] = useState<StreamInteraction[]>(() =>
341
+ merge(seed, overlayRef.current),
342
+ );
343
+
344
+ // Resync epochs. `epochRef` increments on each (re)connect and stamps every frame
345
+ // that arrives on that connection; `countEpoch` is set to the connect's epoch only
346
+ // once its refetch landed fresh (onResync resolved true) — a failed refetch leaves
347
+ // it unchanged so prior-epoch deltas keep applying against the prior total.
348
+ // `pendingCount` applies only overlay entries stamped at or after `countEpoch`, so a
349
+ // resync never re-subtracts a terminal it already dropped from the fresh `total`.
350
+ // `countEpoch` is STATE, not a ref: advancing it must republish the derived count
351
+ // even when the reconnect carries no frame after the refetch. Both reset with the
352
+ // overlay on remount.
353
+ const epochRef = useRef(0);
354
+ const [countEpoch, setCountEpoch] = useState(0);
355
+
356
+ // The stream effect must not reopen when `seed`/`onResync` change, so they are
357
+ // read through refs the render keeps current.
358
+ const seedRef = useRef(seed);
359
+ seedRef.current = seed;
360
+ const onResyncRef = useRef(onResync);
361
+ onResyncRef.current = onResync;
362
+
363
+ // A new paged base (initial load or a refetch) re-merges over the live overlay.
364
+ useEffect(() => {
365
+ setInteractions(merge(seed, overlayRef.current));
366
+ }, [seed]);
215
367
 
216
368
  useEffect(() => {
217
369
  const controller = new AbortController();
218
370
  const aborted = () => controller.signal.aborted;
371
+ const overlay = overlayRef.current;
372
+ let reconnectAttempt = 0;
219
373
 
220
- // The published list carries the public shape only — the internal
221
- // `answeredAt` aging stamp is stripped here.
222
- const toPublicList = (): StreamInteraction[] =>
223
- Array.from(mapRef.current.values(), ({ answeredAt: _answeredAt, ...rest }) => rest);
224
-
225
- // Age answered cards out (the server sends no removal for them): drop any past
226
- // the retention window, then — as a flood backstop — the oldest beyond the
227
- // count cap. Pending items (`answeredAt === null`) are never touched. Called on
228
- // every frame/reconnect tick so the always-mounted badge cannot grow unbounded.
374
+ // Age answered cards out (the server sends no removal for them): tombstone any
375
+ // past the retention window, then — as a flood backstop — the oldest beyond the
376
+ // count cap. A tombstone (not a bare delete) so a still-stale seed cannot
377
+ // resurrect a card the client has retired.
229
378
  const sweepAnswered = () => {
230
379
  const now = Date.now();
231
- const answered: { id: string; answeredAt: number }[] = [];
232
- for (const entry of mapRef.current.values()) {
233
- if (entry.answeredAt !== null) {
234
- answered.push({ id: entry.interaction_id, answeredAt: entry.answeredAt });
235
- }
380
+ const retire = (id: string) => {
381
+ overlay.answeredAt.delete(id);
382
+ overlay.adds.delete(id);
383
+ overlay.addEpoch.delete(id);
384
+ overlay.removed.add(id);
385
+ // The id keeps its original `goneEpoch` (stamped on its answered frame) so
386
+ // moving answered→removed never re-dates it into a fresher resync.
387
+ };
388
+ for (const [id, at] of overlay.answeredAt) {
389
+ if (now - at >= ANSWERED_RETENTION_MS) retire(id);
236
390
  }
237
- for (const { id, answeredAt } of answered) {
238
- if (now - answeredAt >= ANSWERED_RETENTION_MS) mapRef.current.delete(id);
239
- }
240
- const live = answered.filter(({ id }) => mapRef.current.has(id));
241
- if (live.length > ANSWERED_CAP) {
242
- live.sort((a, b) => a.answeredAt - b.answeredAt); // oldest first
243
- for (const { id } of live.slice(0, live.length - ANSWERED_CAP)) {
244
- mapRef.current.delete(id);
245
- }
391
+ if (overlay.answeredAt.size > ANSWERED_CAP) {
392
+ const oldest = [...overlay.answeredAt.entries()].sort((a, b) => a[1] - b[1]);
393
+ for (const [id] of oldest.slice(0, oldest.length - ANSWERED_CAP)) retire(id);
246
394
  }
247
395
  };
248
396
 
249
- // A successful data frame rebuilds the list and clears any transient
250
- // frame-parse error — one bad frame on a healthy stream must not stick.
251
- const publish = () => {
252
- sweepAnswered();
253
- setState((prev) => ({
397
+ const surfaceMalformed = (event: string) => {
398
+ setConnectionState((prev) => ({
254
399
  ...prev,
255
- interactions: toPublicList(),
256
- error: null,
400
+ error: new Error(`malformed interaction frame: ${event}`),
257
401
  }));
258
402
  };
259
403
 
260
- const surfaceMalformed = (event: string) => {
261
- setState((prev) => ({ ...prev, error: new Error(`malformed interaction frame: ${event}`) }));
262
- };
263
-
264
- // Per-connection reconcile state: the replayed backlog is the PENDING set
265
- // only, so a still-pending id not re-seen by `interaction.backlog_done` is
266
- // dropped (removed while disconnected); a locally-answered id is exempt — it
267
- // survives reconnect and is aged out client-side (see `sweepAnswered`).
268
- let reconciling = false;
269
- let seenInBacklog = new Set<string>();
270
- // Consecutive failed reconnect attempts. Reset only when a connection proves
271
- // healthy (`interaction.backlog_done`), NOT merely when it opens — a server
272
- // that accepts the request then drops the body would otherwise reset every
273
- // time and never back off.
274
- let reconnectAttempt = 0;
275
- const beginConnection = () => {
276
- reconciling = true;
277
- seenInBacklog = new Set();
404
+ // A successful data frame sweeps, republishes the merged list, and clears any
405
+ // transient frame-parse error — one bad frame on a healthy stream must not
406
+ // stick. It does NOT refetch: the overlay already updated the list and the
407
+ // derived count, and `onResync` fires on (re)connect only.
408
+ const commit = () => {
409
+ if (aborted()) return;
410
+ sweepAnswered();
411
+ setConnectionState((prev) => (prev.error === null ? prev : { ...prev, error: null }));
412
+ setInteractions(merge(seedRef.current, overlay));
278
413
  };
279
414
 
280
415
  const applyFrame = (event: string, data: string) => {
281
- if (event === 'interaction.backlog_done') {
282
- if (reconciling) {
283
- // The pending-only backlog is authoritative for pending items alone:
284
- // drop a still-pending id it did not replay (removed while
285
- // disconnected), but NEVER an answered id — that is terminal and the
286
- // backlog never carries it, so reconcile-deleting it would vanish an
287
- // answered card on every reconnect.
288
- for (const [id, item] of mapRef.current) {
289
- if (!seenInBacklog.has(id) && !item.answered) mapRef.current.delete(id);
290
- }
291
- reconciling = false;
292
- }
293
- // A completed backlog is proof the connection is healthy — clear the
294
- // reconnect backoff so the next drop reconnects promptly.
295
- reconnectAttempt = 0;
296
- sweepAnswered();
297
- setState((prev) => ({
298
- ...prev,
299
- interactions: toPublicList(),
300
- backlogLoaded: true,
301
- }));
302
- return;
303
- }
304
416
  if (event === 'interaction.removed') {
305
417
  const id = parseId(data);
306
418
  if (id === null) {
307
419
  surfaceMalformed(event);
308
420
  return;
309
421
  }
310
- mapRef.current.delete(id);
311
- publish();
422
+ overlay.adds.delete(id);
423
+ overlay.addEpoch.delete(id);
424
+ overlay.answeredAt.delete(id);
425
+ overlay.removed.add(id);
426
+ // Stamp the terminal epoch on the FIRST terminal frame for this id only.
427
+ if (!overlay.goneEpoch.has(id)) overlay.goneEpoch.set(id, epochRef.current);
428
+ commit();
312
429
  return;
313
430
  }
314
431
  if (event === 'interaction.answered') {
@@ -317,52 +434,63 @@ export function useInteractionsStream(): InteractionsStreamState {
317
434
  surfaceMalformed(event);
318
435
  return;
319
436
  }
320
- // The answered frame carries only ids — flip the existing item's flag,
321
- // keeping its question/format. An answered event for an item we never saw
322
- // has nothing to show. Stamp the answered time on the FIRST answered frame
323
- // only; a redelivered answered keeps the original stamp so the retention
324
- // window is not extended.
325
- const existing = mapRef.current.get(id);
326
- if (existing) {
327
- const answeredAt = existing.answered ? existing.answeredAt : Date.now();
328
- mapRef.current.set(id, { ...existing, answered: true, answeredAt });
437
+ // Stamp the answered time on the FIRST answered frame only; a redelivered
438
+ // answered keeps the original stamp so the retention window is not extended.
439
+ if (!overlay.answeredAt.has(id)) overlay.answeredAt.set(id, Date.now());
440
+ // Stamp the terminal epoch on the FIRST terminal frame for this id only, so a
441
+ // later terminal never re-dates it into a fresher resync.
442
+ if (!overlay.goneEpoch.has(id)) overlay.goneEpoch.set(id, epochRef.current);
443
+ // Promote a seed-origin card into the overlay so it lingers uniformly with a
444
+ // live-add card: the paged base never carries answered items, so a refetch
445
+ // would otherwise drop it before its retention window elapses.
446
+ if (!overlay.adds.has(id)) {
447
+ const seeded = seedRef.current.find((item) => item.interaction_id === id);
448
+ if (seeded !== undefined) {
449
+ overlay.adds.set(id, seeded);
450
+ overlay.addEpoch.set(id, epochRef.current);
451
+ }
329
452
  }
330
- publish();
453
+ commit();
331
454
  return;
332
455
  }
333
- // interaction.add — the full question.
456
+ // interaction.add — the full question. Ids are never reused, so a live add
457
+ // simply overwrites any prior copy in place.
334
458
  const interaction = parseAddFrame(data);
335
459
  if (interaction === null) {
336
460
  surfaceMalformed(event);
337
461
  return;
338
462
  }
339
- if (reconciling) seenInBacklog.add(interaction.interaction_id);
340
- // Dedupe by id; a redelivered add preserves the client answered flag (and
341
- // its aging stamp) so a prior `answered` is not undone by an at-least-once
342
- // replay. A fresh (pending) add carries no stamp until its answered frame.
343
- const existing = mapRef.current.get(interaction.interaction_id);
344
- const answered = existing?.answered ?? false;
345
- mapRef.current.set(interaction.interaction_id, {
346
- ...interaction,
347
- answered,
348
- answeredAt: answered ? (existing?.answeredAt ?? null) : null,
349
- });
350
- publish();
463
+ overlay.adds.set(interaction.interaction_id, interaction);
464
+ overlay.addEpoch.set(interaction.interaction_id, epochRef.current);
465
+ commit();
351
466
  };
352
467
 
353
468
  // The abort signal is the single cancellation source of truth: the cleanup
354
469
  // aborts it, and every loop guard reads it fresh.
355
470
  const run = async () => {
356
471
  while (!aborted()) {
472
+ const openedAt = Date.now();
357
473
  try {
358
474
  const frames = await api.streamInteractions(controller.signal);
359
475
  if (aborted()) return;
360
- beginConnection();
361
- // Clear `disabled` on a fresh connect: the terminal-501 branch sets it and
362
- // returns, so within a single mount this only ever writes false-over-false —
363
- // but should the effect re-run and a later stream connect, the hook must not
364
- // carry a stale OFF flag. Correct-by-construction, not load-bearing today.
365
- setState((prev) => ({ ...prev, connected: true, error: null, disabled: false }));
476
+ setConnectionState((prev) => ({
477
+ ...prev,
478
+ connected: true,
479
+ error: null,
480
+ disabled: false,
481
+ }));
482
+ // Tail-only: the pending base is not on the stream. Refetch it on every
483
+ // (re)connect under a fresh epoch so anything removed or added while
484
+ // disconnected is reconciled by the fresh page; record that epoch as
485
+ // `countEpoch` only once the refetch LANDED (onResync resolved true), so the
486
+ // count applies only deltas not yet folded into the fresh `total`. A failed
487
+ // refetch (false) leaves `countEpoch` unchanged, so prior-epoch deltas keep
488
+ // applying against the consistent prior total instead of being discarded
489
+ // against a stale one. Every frame on this connection is stamped with `epoch`.
490
+ const epoch = (epochRef.current += 1);
491
+ const landed = await onResyncRef.current();
492
+ if (aborted()) return;
493
+ if (landed) setCountEpoch(epoch);
366
494
  for await (const frame of frames) {
367
495
  if (aborted()) return;
368
496
  applyFrame(frame.event, frame.data);
@@ -370,7 +498,7 @@ export function useInteractionsStream(): InteractionsStreamState {
370
498
  } catch (err) {
371
499
  if (aborted()) return;
372
500
  const error = err instanceof Error ? err : new Error(String(err));
373
- setState((prev) => ({ ...prev, connected: false, error }));
501
+ setConnectionState((prev) => ({ ...prev, connected: false, error }));
374
502
  if (error.name === 'ApiUnauthorizedError') {
375
503
  // A 401 means the stored credential is dead — reconnecting would just
376
504
  // replay the same bad key every delay forever. Treat it as terminal
@@ -385,16 +513,20 @@ export function useInteractionsStream(): InteractionsStreamState {
385
513
  // every backoff forever (this stream is always-mounted via the shell
386
514
  // badge). Stop, and flag the OFF state so the badge disappears and the
387
515
  // page renders the muted "not configured" note instead of a red error.
388
- setState((prev) => ({ ...prev, connected: false, disabled: true }));
516
+ setConnectionState((prev) => ({ ...prev, connected: false, disabled: true }));
389
517
  return;
390
518
  }
391
519
  }
392
- // Stream ended or errored → wait, then reconnect (server replays backlog).
393
- // Guard before the setState, like every other setState in run(): a
394
- // microtask scheduled after the `for await` must not write state after
395
- // the effect cleanup has aborted (and unmounted) the hook.
520
+ // A connection that stayed open at least HEALTHY_CONNECTION_MS proved
521
+ // healthy — clear the reconnect backoff so the next drop reconnects
522
+ // promptly; an immediate accept-then-drop flap does not reset it.
523
+ if (Date.now() - openedAt >= HEALTHY_CONNECTION_MS) reconnectAttempt = 0;
524
+ // Stream ended or errored → wait, then reconnect. Guard before the
525
+ // setState, like every other setState in run(): a microtask scheduled after
526
+ // the `for await` must not write state after the effect cleanup has aborted
527
+ // (and unmounted) the hook.
396
528
  if (aborted()) return;
397
- setState((prev) => ({ ...prev, connected: false }));
529
+ setConnectionState((prev) => ({ ...prev, connected: false }));
398
530
  const ceiling = Math.min(RECONNECT_CAP_MS, RECONNECT_BASE_MS * 2 ** reconnectAttempt);
399
531
  const delay = Math.random() * ceiling;
400
532
  reconnectAttempt += 1;
@@ -417,8 +549,25 @@ export function useInteractionsStream(): InteractionsStreamState {
417
549
  void run();
418
550
  return () => {
419
551
  controller.abort();
552
+ // A fresh mount starts from an empty overlay at epoch 0: the effect owns the
553
+ // connection and its live deltas, so a re-run must not inherit the prior run's
554
+ // tail or its epoch stamps.
555
+ overlayRef.current = emptyOverlay();
556
+ epochRef.current = 0;
557
+ setCountEpoch(0);
420
558
  };
421
559
  }, [api, onUnauthorized]);
422
560
 
423
- return state;
561
+ // Derived live, not stored: every overlay mutation republishes `interactions` (a
562
+ // setState), and every seed/total change and every `countEpoch` advance is itself a
563
+ // render, so this recomputes from the current overlay ref on the same render.
564
+ const count = pendingCount(seed, total, overlayRef.current, countEpoch);
565
+
566
+ return {
567
+ interactions,
568
+ count,
569
+ connected: connectionState.connected,
570
+ error: connectionState.error,
571
+ disabled: connectionState.disabled,
572
+ };
424
573
  }