@tai42/studio-sdk 5.0.0 → 6.0.0
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/hooks/useApi.d.ts +23 -4
- package/dist/hooks/useApi.d.ts.map +1 -1
- package/dist/hooks/useSse.d.ts +34 -2
- package/dist/hooks/useSse.d.ts.map +1 -1
- package/dist/hooks/useSse.js +252 -153
- package/dist/hooks/useSse.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/hooks/useSse.ts +310 -161
- package/src/index.ts +5 -1
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AACH,kFAAkF;AAClF,+EAA+E;AAC/E,4EAA4E;AAC5E,iFAAiF;AACjF,iFAAiF;AACjF,+EAA+E;AAC/E,qEAAqE;AACrE,OAAO,yBAAyB,CAAC;AACjC,OAAO,wBAAwB,CAAC;AAChC,OAAO,6BAA6B,CAAC;AAqBrC,OAAO,EAAE,yBAAyB,EAAE,qBAAqB,EAAE,MAAM,kBAAkB,CAAC;AAGpF,+EAA+E;AAC/E,OAAO,EAAE,WAAW,EAAE,MAAM,EAAE,MAAM,gBAAgB,CAAC;AAErD,OAAO,EAAE,oBAAoB,EAAE,iBAAiB,EAAE,MAAM,yBAAyB,CAAC;AAClF,OAAO,EAAE,YAAY,EAAE,OAAO,EAAE,MAAM,iBAAiB,CAAC;AAExD,OAAO,EACL,kBAAkB,EAClB,eAAe,EACf,WAAW,EACX,gBAAgB,EAChB,cAAc,EACd,WAAW,EACX,WAAW,GACZ,MAAM,yBAAyB,CAAC;AAEjC,OAAO,EACL,mBAAmB,EACnB,cAAc,EACd,aAAa,EACb,oBAAoB,GACrB,MAAM,wBAAwB,CAAC;AAEhC,OAAO,EACL,wBAAwB,EACxB,mBAAmB,EACnB,yBAAyB,EACzB,gBAAgB,GACjB,MAAM,6BAA6B,CAAC;AAKrC,OAAO,EAAE,aAAa,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAE3D,OAAO,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AAEtD,OAAO,EAAE,qBAAqB,EAAE,MAAM,gBAAgB,CAAC;
|
|
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": "
|
|
3
|
+
"version": "6.0.0",
|
|
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": "
|
|
93
|
+
"@tai42/api-client": "6.0.0"
|
|
94
94
|
},
|
|
95
95
|
"scripts": {
|
|
96
96
|
"build": "tsc -b tsconfig.build.json && node scripts/copy-assets.mjs",
|
package/src/hooks/useSse.ts
CHANGED
|
@@ -1,29 +1,50 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `
|
|
3
|
-
* `streamInteractions
|
|
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
|
-
*
|
|
6
|
-
* `interaction.answered` / `interaction.removed`
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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 —
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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
|
|
26
|
-
* `interaction_id`,
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
|
68
|
-
//
|
|
69
|
-
// body still backs off instead of tight-looping. A healthy
|
|
70
|
-
// near-instant while a sustained outage backs off, and the
|
|
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 —
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
// answered
|
|
80
|
-
// lazily on frame/reconnect ticks (the stream carries no
|
|
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
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
connected:
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
|
|
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
|
-
//
|
|
221
|
-
//
|
|
222
|
-
|
|
223
|
-
|
|
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
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
238
|
-
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
const publish = () => {
|
|
252
|
-
sweepAnswered();
|
|
253
|
-
setState((prev) => ({
|
|
397
|
+
const surfaceMalformed = (event: string) => {
|
|
398
|
+
setConnectionState((prev) => ({
|
|
254
399
|
...prev,
|
|
255
|
-
|
|
256
|
-
error: null,
|
|
400
|
+
error: new Error(`malformed interaction frame: ${event}`),
|
|
257
401
|
}));
|
|
258
402
|
};
|
|
259
403
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
|
|
311
|
-
|
|
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
|
-
//
|
|
321
|
-
//
|
|
322
|
-
|
|
323
|
-
//
|
|
324
|
-
//
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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
|
-
|
|
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
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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
|
-
|
|
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
|
-
|
|
516
|
+
setConnectionState((prev) => ({ ...prev, connected: false, disabled: true }));
|
|
389
517
|
return;
|
|
390
518
|
}
|
|
391
519
|
}
|
|
392
|
-
//
|
|
393
|
-
//
|
|
394
|
-
//
|
|
395
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|