@constructor-io/constructorio-ui-asa 1.7.1 → 1.8.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.
Files changed (76) hide show
  1. package/README.md +21 -17
  2. package/dist/constructorio-ui-asa-bundled.js +18 -18
  3. package/lib/cjs/components/Chat/AiMessage.js +2 -3
  4. package/lib/cjs/components/Chat/Chat.js +38 -10
  5. package/lib/cjs/components/CioAsa/CioAsa.js +6 -2
  6. package/lib/cjs/components/CioAsaProvider/CioAsaProvider.js +44 -3
  7. package/lib/cjs/components/ResultsBlock/ResultsBlock.js +0 -1
  8. package/lib/cjs/components/ResultsBlock/ResultsGroup.js +5 -5
  9. package/lib/cjs/hooks/agentStream.js +113 -0
  10. package/lib/cjs/hooks/asaStreamHandlers.js +2 -1
  11. package/lib/cjs/hooks/persistence/backgroundTurns.js +121 -0
  12. package/lib/cjs/hooks/persistence/useChatPersistence.js +485 -0
  13. package/lib/cjs/hooks/persistence/useForeignStream.js +34 -0
  14. package/lib/cjs/hooks/persistence/useThreadList.js +45 -0
  15. package/lib/cjs/hooks/persistence/useWriteQueue.js +17 -0
  16. package/lib/cjs/hooks/useAsaResults.js +93 -160
  17. package/lib/cjs/hooks/useAsaTracking.js +3 -3
  18. package/lib/cjs/hooks/useIsMounted.js +15 -0
  19. package/lib/cjs/hooks/useLatest.js +10 -0
  20. package/lib/cjs/hooks/useViewportTracking.js +4 -3
  21. package/lib/cjs/index.js +3 -1
  22. package/lib/cjs/utils/chatRecordValidation.js +54 -0
  23. package/lib/cjs/utils/chatSession.js +98 -0
  24. package/lib/cjs/utils/chatThreads.js +248 -0
  25. package/lib/cjs/utils/clientOptions.js +11 -0
  26. package/lib/cjs/utils/localStoragePersistence.js +391 -0
  27. package/lib/cjs/utils/translate.js +1 -0
  28. package/lib/cjs/version.js +1 -1
  29. package/lib/mjs/components/Chat/AiMessage.js +2 -3
  30. package/lib/mjs/components/Chat/Chat.js +36 -10
  31. package/lib/mjs/components/CioAsa/CioAsa.js +6 -2
  32. package/lib/mjs/components/CioAsaProvider/CioAsaProvider.js +44 -4
  33. package/lib/mjs/components/ResultsBlock/ResultsBlock.js +0 -1
  34. package/lib/mjs/components/ResultsBlock/ResultsGroup.js +2 -1
  35. package/lib/mjs/hooks/agentStream.js +106 -0
  36. package/lib/mjs/hooks/asaStreamHandlers.js +1 -1
  37. package/lib/mjs/hooks/persistence/backgroundTurns.js +120 -0
  38. package/lib/mjs/hooks/persistence/useChatPersistence.js +474 -0
  39. package/lib/mjs/hooks/persistence/useForeignStream.js +31 -0
  40. package/lib/mjs/hooks/persistence/useThreadList.js +41 -0
  41. package/lib/mjs/hooks/persistence/useWriteQueue.js +14 -0
  42. package/lib/mjs/hooks/useAsaResults.js +87 -157
  43. package/lib/mjs/hooks/useAsaTracking.js +2 -1
  44. package/lib/mjs/hooks/useIsMounted.js +12 -0
  45. package/lib/mjs/hooks/useLatest.js +7 -0
  46. package/lib/mjs/hooks/useViewportTracking.js +3 -3
  47. package/lib/mjs/index.js +1 -0
  48. package/lib/mjs/utils/chatRecordValidation.js +51 -0
  49. package/lib/mjs/utils/chatSession.js +86 -0
  50. package/lib/mjs/utils/chatThreads.js +219 -0
  51. package/lib/mjs/utils/clientOptions.js +7 -0
  52. package/lib/mjs/utils/localStoragePersistence.js +373 -0
  53. package/lib/mjs/utils/translate.js +1 -0
  54. package/lib/mjs/version.js +1 -1
  55. package/lib/types/components/Chat/Chat.d.ts +8 -2
  56. package/lib/types/components/CioAsa/CioAsa.d.ts +1 -1
  57. package/lib/types/components/ResultsBlock/ResultsBlock.d.ts +0 -1
  58. package/lib/types/hooks/agentStream.d.ts +48 -0
  59. package/lib/types/hooks/asaStreamHandlers.d.ts +1 -0
  60. package/lib/types/hooks/persistence/backgroundTurns.d.ts +43 -0
  61. package/lib/types/hooks/persistence/useChatPersistence.d.ts +34 -0
  62. package/lib/types/hooks/persistence/useForeignStream.d.ts +9 -0
  63. package/lib/types/hooks/persistence/useThreadList.d.ts +9 -0
  64. package/lib/types/hooks/persistence/useWriteQueue.d.ts +5 -0
  65. package/lib/types/hooks/useAsaTracking.d.ts +2 -0
  66. package/lib/types/hooks/useIsMounted.d.ts +3 -0
  67. package/lib/types/hooks/useLatest.d.ts +3 -0
  68. package/lib/types/index.d.ts +2 -1
  69. package/lib/types/types.d.ts +134 -2
  70. package/lib/types/utils/chatRecordValidation.d.ts +2 -0
  71. package/lib/types/utils/chatSession.d.ts +45 -0
  72. package/lib/types/utils/chatThreads.d.ts +63 -0
  73. package/lib/types/utils/clientOptions.d.ts +5 -0
  74. package/lib/types/utils/localStoragePersistence.d.ts +26 -0
  75. package/lib/types/version.d.ts +1 -1
  76. package/package.json +1 -1
@@ -0,0 +1,485 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const tslib_1 = require("tslib");
4
+ const react_1 = require("react");
5
+ const chatThreads_1 = require("../../utils/chatThreads");
6
+ const chatSession_1 = require("../../utils/chatSession");
7
+ const useIsMounted_1 = tslib_1.__importDefault(require("../useIsMounted"));
8
+ const useLatest_1 = tslib_1.__importDefault(require("../useLatest"));
9
+ const useWriteQueue_1 = tslib_1.__importDefault(require("./useWriteQueue"));
10
+ const useThreadList_1 = tslib_1.__importDefault(require("./useThreadList"));
11
+ const useForeignStream_1 = tslib_1.__importDefault(require("./useForeignStream"));
12
+ const backgroundTurns_1 = require("./backgroundTurns");
13
+ const hasUnsavedWork = (session) => session.isStreaming || session.dirty || session.orphanIds.length > 0;
14
+ /** Restores, saves and syncs the conversation with `store`; inert when `store` is undefined. */
15
+ function useChatPersistence(params) {
16
+ const { store, scope, index, session, initialThreadId, messages, setMessages, isStreaming, setIsStreaming, turnRef, cancelStream, } = params;
17
+ if (store)
18
+ (0, chatThreads_1.getTabId)();
19
+ const [isHydrating, setIsHydrating] = (0, react_1.useState)(Boolean(store));
20
+ const [activeThreadId, setActiveThreadId] = (0, react_1.useState)(null);
21
+ const storeRef = (0, useLatest_1.default)(store);
22
+ const messagesRef = (0, useLatest_1.default)(messages);
23
+ const mountedRef = (0, useIsMounted_1.default)();
24
+ const { enqueue: enqueueWrite, reset: resetWrites } = (0, useWriteQueue_1.default)();
25
+ // Async writes not yet committed; the page may go away before a queued one gets its lock.
26
+ const pendingWritesRef = (0, react_1.useRef)(0);
27
+ // Another tab changed the thread while this one streamed; looked at again once the turn is saved.
28
+ const missedSyncRef = (0, react_1.useRef)(false);
29
+ const { threads, setThreads, refreshThreads, invalidate: invalidateThreads, } = (0, useThreadList_1.default)(storeRef);
30
+ const { foreignInFlight, setForeignInFlight, watch: watchForeign, stop: stopForeign, clearTimer: clearForeignTimer, } = (0, useForeignStream_1.default)(session);
31
+ // Reset during render so the previous store's conversation never paints under the new one.
32
+ // A login keeps the guest conversation on screen instead and moves it into the shopper's store.
33
+ const [rendered, setRendered] = (0, react_1.useState)({ store, scope, index });
34
+ const carryOverFromRef = (0, react_1.useRef)(undefined);
35
+ // Every guest thread moves into the shopper's store on login, so none is left for the next guest.
36
+ const migrateFromRef = (0, react_1.useRef)(undefined);
37
+ const settleIntoRef = (0, react_1.useRef)(undefined);
38
+ if (rendered.store !== store) {
39
+ const login = rendered.store &&
40
+ store &&
41
+ rendered.index === index &&
42
+ rendered.scope === 'guest' &&
43
+ scope === 'user';
44
+ const carryOver = login && messages.length > 0;
45
+ setRendered({ store, scope, index });
46
+ setThreads([]);
47
+ if (login)
48
+ migrateFromRef.current = rendered.store;
49
+ if (carryOver) {
50
+ carryOverFromRef.current = rendered.store;
51
+ }
52
+ else {
53
+ // An unfinished turn still belongs to the previous store; the effect below writes it there.
54
+ if (rendered.store && messages.length > 0 && hasUnsavedWork(session)) {
55
+ settleIntoRef.current = { store: rendered.store, messages };
56
+ }
57
+ setMessages([]);
58
+ setActiveThreadId(null);
59
+ setForeignInFlight(false);
60
+ setIsStreaming(false);
61
+ setIsHydrating(Boolean(store));
62
+ }
63
+ }
64
+ /** Puts a stored conversation on screen and makes it the one this tab continues. */
65
+ const showStoredChat = (0, react_1.useCallback)((chat) => {
66
+ (0, chatSession_1.adoptStoredChat)(session, chat);
67
+ setActiveThreadId(chat.threadId);
68
+ const remainingMs = (0, chatThreads_1.foreignStreamRemainingMs)(chat);
69
+ if (remainingMs === 0) {
70
+ stopForeign();
71
+ setMessages((0, chatThreads_1.normalizeHydratedMessages)(chat.messages));
72
+ // Write the settled snapshot back so the list stops reporting it in flight.
73
+ if ((0, chatThreads_1.isInFlight)(chat.messages))
74
+ session.dirty = true;
75
+ return;
76
+ }
77
+ setMessages(chat.messages);
78
+ watchForeign(remainingMs, () => {
79
+ if (!mountedRef.current || session.storageThreadId !== chat.threadId)
80
+ return;
81
+ if (session.isStreaming)
82
+ return;
83
+ session.dirty = true;
84
+ setMessages((prev) => (0, chatThreads_1.normalizeHydratedMessages)(prev));
85
+ });
86
+ }, [session, stopForeign, watchForeign, setMessages, mountedRef]);
87
+ /** Writes the conversation as it is now; `settle` finishes an in-flight answer, `sync` skips awaiting. */
88
+ const persistNow = (0, react_1.useCallback)((mode) => {
89
+ const { current } = storeRef;
90
+ if (!current || messagesRef.current.length === 0)
91
+ return undefined;
92
+ const toSave = (mode === null || mode === void 0 ? void 0 : mode.settle)
93
+ ? (0, chatThreads_1.normalizeHydratedMessages)(messagesRef.current)
94
+ : messagesRef.current;
95
+ const { snapshot, staleIds } = (0, chatSession_1.prepareSnapshot)(session, toSave);
96
+ if ((mode === null || mode === void 0 ? void 0 : mode.sync) && current.saveThreadSync) {
97
+ current.saveThreadSync(snapshot, staleIds);
98
+ return undefined;
99
+ }
100
+ if (mountedRef.current)
101
+ setActiveThreadId(snapshot.threadId);
102
+ pendingWritesRef.current += 1;
103
+ return enqueueWrite(() => tslib_1.__awaiter(this, void 0, void 0, function* () {
104
+ const { saved, leftover } = yield (0, chatThreads_1.saveThreadAndRetireStale)(current, snapshot, staleIds).finally(() => {
105
+ pendingWritesRef.current -= 1;
106
+ });
107
+ // Leftovers of a conversation the user has since left stay stored: they may be its only copy.
108
+ const sameConversation = storeRef.current === current && session.storageThreadId === snapshot.threadId;
109
+ if (!sameConversation)
110
+ return;
111
+ if (leftover.length > 0) {
112
+ session.orphanIds = Array.from(new Set([...session.orphanIds, ...leftover]));
113
+ }
114
+ // Not stored and no newer snapshot on its way: pagehide or the next save must write it.
115
+ if (!saved && session.lastSyncedAt === snapshot.updatedAt)
116
+ session.dirty = true;
117
+ }));
118
+ }, [session, storeRef, messagesRef, enqueueWrite, mountedRef]);
119
+ const onStreamStart = (0, react_1.useCallback)(() => {
120
+ var _a;
121
+ (_a = persistNow()) === null || _a === void 0 ? void 0 : _a.then(refreshThreads);
122
+ }, [persistNow, refreshThreads]);
123
+ /** Where this tab's answer goes while its conversation is on screen. */
124
+ const screenOwner = (0, react_1.useCallback)((turn) => ({
125
+ setMessages,
126
+ onStart: (threadId) => {
127
+ if (threadId)
128
+ session.serverThreadId = threadId;
129
+ onStreamStart();
130
+ },
131
+ onDone: () => {
132
+ // A turn cancelled by a reset, or handed to the background, is accounted for elsewhere.
133
+ if (turnRef.current !== turn)
134
+ return;
135
+ turnRef.current = null;
136
+ session.dirty = true;
137
+ session.isStreaming = false;
138
+ setIsStreaming(false);
139
+ },
140
+ }), [session, setMessages, onStreamStart, turnRef, setIsStreaming]);
141
+ /** Lets this tab's answer finish in the background, so leaving its conversation does not stop it. */
142
+ const detachTurn = (0, react_1.useCallback)(() => {
143
+ const turn = turnRef.current;
144
+ const { current } = storeRef;
145
+ const shown = messagesRef.current;
146
+ if (!turn || !current || !session.isStreaming)
147
+ return false;
148
+ if (!shown.some((m) => m.id === turn.assistantId))
149
+ return false;
150
+ turnRef.current = null;
151
+ (0, backgroundTurns_1.continueInBackground)(turn, {
152
+ store: current,
153
+ session: (0, chatSession_1.forkSession)(session),
154
+ messages: shown.map((m) => (m.id === turn.assistantId ? turn.assistant : m)),
155
+ after: enqueueWrite(() => tslib_1.__awaiter(this, void 0, void 0, function* () { })),
156
+ });
157
+ session.isStreaming = false;
158
+ return true;
159
+ }, [session, storeRef, messagesRef, turnRef, enqueueWrite]);
160
+ /** Shows a conversation whose answer went on in the background; an unfinished one streams on here. */
161
+ const showBackgroundTurn = (0, react_1.useCallback)((bg) => {
162
+ stopForeign();
163
+ (0, chatSession_1.adoptSession)(session, bg.session, bg.messages);
164
+ enqueueWrite(() => bg.writes);
165
+ setActiveThreadId(session.storageThreadId);
166
+ setMessages(bg.messages);
167
+ if (!bg.streaming) {
168
+ if (!bg.saved)
169
+ session.dirty = true;
170
+ return;
171
+ }
172
+ turnRef.current = bg.turn;
173
+ session.isStreaming = true;
174
+ setIsStreaming(true);
175
+ }, [session, stopForeign, enqueueWrite, setMessages, turnRef, setIsStreaming]);
176
+ const settleAndPersist = (0, react_1.useCallback)(() => {
177
+ if (hasUnsavedWork(session))
178
+ return persistNow({ settle: true });
179
+ return undefined;
180
+ }, [session, persistNow]);
181
+ /** Write the settled conversation without awaiting anything: the page is going away. */
182
+ const flushBeforeUnload = (0, react_1.useCallback)(() => {
183
+ var _a;
184
+ if (!hasUnsavedWork(session) && pendingWritesRef.current === 0)
185
+ return;
186
+ if ((_a = storeRef.current) === null || _a === void 0 ? void 0 : _a.saveThreadSync)
187
+ session.dirty = false;
188
+ persistNow({ settle: true, sync: true });
189
+ }, [session, storeRef, persistNow]);
190
+ /** Forget the conversation on screen; returns every id it is stored under. */
191
+ const forgetConversation = (0, react_1.useCallback)(() => {
192
+ stopForeign();
193
+ cancelStream();
194
+ const { orphanIds } = session;
195
+ const storedId = (0, chatSession_1.resetConversation)(session);
196
+ session.interacted = true;
197
+ setActiveThreadId(null);
198
+ setMessages([]);
199
+ setIsStreaming(false);
200
+ return storedId ? [storedId, ...orphanIds] : orphanIds;
201
+ }, [session, stopForeign, cancelStream, setMessages, setIsStreaming]);
202
+ // Restore on mount, and again from scratch whenever the store changes. Compared by store, not
203
+ // by a first-run flag, so StrictMode re-running the mount effect is not taken for a switch.
204
+ const effectStoreRef = (0, react_1.useRef)(null);
205
+ (0, react_1.useEffect)(() => {
206
+ var _a, _b;
207
+ const previous = effectStoreRef.current;
208
+ effectStoreRef.current = { store };
209
+ const carryOverFrom = carryOverFromRef.current;
210
+ carryOverFromRef.current = undefined;
211
+ const migrateFrom = migrateFromRef.current;
212
+ migrateFromRef.current = undefined;
213
+ // Behind the guest store's pending writes, so none of them can recreate a moved record.
214
+ const migrate = (onScreen) => migrateFrom && store
215
+ ? enqueueWrite(() => (0, backgroundTurns_1.moveBackgroundTurns)(migrateFrom, store)
216
+ .then(() => (0, chatThreads_1.moveThreads)(migrateFrom, store, onScreen))
217
+ .catch(() => { }))
218
+ : undefined;
219
+ if (carryOverFrom && store && migrateFrom) {
220
+ // A login mid-conversation: the guest conversation continues as the shopper's own.
221
+ stopForeign();
222
+ invalidateThreads();
223
+ const onScreen = {
224
+ threadIds: [session.storageThreadId, ...session.orphanIds].filter((id) => id !== null),
225
+ firstMessageId: (_a = messagesRef.current[0]) === null || _a === void 0 ? void 0 : _a.id,
226
+ };
227
+ const guestWritesDone = enqueueWrite(() => tslib_1.__awaiter(this, void 0, void 0, function* () { }));
228
+ resetWrites();
229
+ session.orphanIds = [];
230
+ session.lastSyncedAt = 0;
231
+ session.loadRequest += 1;
232
+ session.interacted = true;
233
+ // Saved into the shopper's store first: the guest copies are deleted only once it is there.
234
+ session.dirty = session.isStreaming;
235
+ const saved = persistNow();
236
+ Promise.all([guestWritesDone, saved, (0, backgroundTurns_1.moveBackgroundTurns)(migrateFrom, store)])
237
+ .then(() => (0, chatThreads_1.moveThreads)(migrateFrom, store, onScreen))
238
+ .catch(() => { })
239
+ .then(refreshThreads);
240
+ setIsHydrating(false);
241
+ return undefined;
242
+ }
243
+ if (previous && previous.store !== store) {
244
+ // The store changed (e.g. a logout): start over instead of saving the old conversation into it.
245
+ clearForeignTimer();
246
+ cancelStream();
247
+ const settleInto = settleIntoRef.current;
248
+ settleIntoRef.current = undefined;
249
+ if (settleInto) {
250
+ const { snapshot, staleIds } = (0, chatSession_1.prepareSnapshot)(session, (0, chatThreads_1.normalizeHydratedMessages)(settleInto.messages));
251
+ const { store: left } = settleInto;
252
+ // Written at once when the store can: a logout often navigates away before a queued write runs.
253
+ if (left.saveThreadSync) {
254
+ try {
255
+ left.saveThreadSync(snapshot, staleIds);
256
+ }
257
+ catch (_c) {
258
+ /* ignore */
259
+ }
260
+ }
261
+ else {
262
+ enqueueWrite(() => (0, chatThreads_1.saveThreadAndRetireStale)(left, snapshot, staleIds).then(() => { }));
263
+ }
264
+ }
265
+ (_b = migrate()) === null || _b === void 0 ? void 0 : _b.then(refreshThreads);
266
+ (0, chatSession_1.resetConversation)(session);
267
+ session.interacted = false;
268
+ invalidateThreads();
269
+ resetWrites();
270
+ }
271
+ if (!store) {
272
+ setIsHydrating(false);
273
+ return undefined;
274
+ }
275
+ setIsHydrating(true);
276
+ let cancelled = false;
277
+ (() => tslib_1.__awaiter(this, void 0, void 0, function* () {
278
+ var _d;
279
+ try {
280
+ const summaries = yield store.listThreads();
281
+ if (cancelled)
282
+ return;
283
+ if (session.interacted) {
284
+ refreshThreads();
285
+ return;
286
+ }
287
+ setThreads(summaries);
288
+ const targetId = initialThreadId !== null && initialThreadId !== void 0 ? initialThreadId : (_d = summaries[0]) === null || _d === void 0 ? void 0 : _d.threadId;
289
+ const live = targetId ? (0, backgroundTurns_1.resumeBackgroundTurn)(store, targetId, screenOwner) : null;
290
+ if (live) {
291
+ showBackgroundTurn(live);
292
+ return;
293
+ }
294
+ const chat = targetId ? yield store.getThread(targetId) : null;
295
+ if (cancelled || session.interacted)
296
+ return;
297
+ if (!chat) {
298
+ if (targetId && (0, chatThreads_1.isLocalThreadId)(targetId))
299
+ session.serverThreadId = null;
300
+ return;
301
+ }
302
+ showStoredChat(chat);
303
+ }
304
+ catch (_e) {
305
+ /* storage unavailable: start empty */
306
+ }
307
+ finally {
308
+ if (!cancelled)
309
+ setIsHydrating(false);
310
+ }
311
+ }))();
312
+ return () => {
313
+ cancelled = true;
314
+ clearForeignTimer();
315
+ };
316
+ // eslint-disable-next-line react-hooks/exhaustive-deps
317
+ }, [store]);
318
+ // Leaving the page or unmounting mid-answer keeps the turn, settled.
319
+ (0, react_1.useEffect)(() => {
320
+ if (!store || typeof window === 'undefined')
321
+ return undefined;
322
+ window.addEventListener('pagehide', flushBeforeUnload);
323
+ return () => window.removeEventListener('pagehide', flushBeforeUnload);
324
+ }, [store, flushBeforeUnload]);
325
+ (0, react_1.useEffect)(() => () => {
326
+ if (!detachTurn())
327
+ settleAndPersist();
328
+ }, [detachTurn, settleAndPersist]);
329
+ (0, react_1.useEffect)(() => (0, backgroundTurns_1.subscribeBackgroundTurns)(refreshThreads), [refreshThreads]);
330
+ // Follow changes another tab made to the list or to the thread on screen.
331
+ const syncFromStore = (0, react_1.useCallback)(() => tslib_1.__awaiter(this, void 0, void 0, function* () {
332
+ var _a;
333
+ const { current } = storeRef;
334
+ if (!current)
335
+ return;
336
+ refreshThreads();
337
+ const activeId = session.storageThreadId;
338
+ if (!activeId)
339
+ return;
340
+ const stillActive = () => mountedRef.current && session.storageThreadId === activeId;
341
+ try {
342
+ const chat = yield current.getThread(activeId);
343
+ if (!stillActive())
344
+ return;
345
+ if (!chat) {
346
+ const rekeyed = (0, chatThreads_1.isLocalThreadId)(activeId)
347
+ ? yield (0, chatThreads_1.findRekeyedThread)(current, (_a = messagesRef.current[0]) === null || _a === void 0 ? void 0 : _a.id)
348
+ : null;
349
+ if (!stillActive())
350
+ return;
351
+ if (rekeyed) {
352
+ showStoredChat(rekeyed);
353
+ return;
354
+ }
355
+ // Evicted or expired, not deleted: keep it; writing back here would ping-pong with the other tab.
356
+ const deleted = current.isThreadDeleted ? yield current.isThreadDeleted(activeId) : true;
357
+ if (!stillActive())
358
+ return;
359
+ if (deleted)
360
+ forgetConversation();
361
+ else
362
+ session.dirty = true;
363
+ return;
364
+ }
365
+ // Unsaved local work first; the save's completion looks at the store again.
366
+ if (session.isStreaming || session.dirty || pendingWritesRef.current > 0) {
367
+ missedSyncRef.current = true;
368
+ return;
369
+ }
370
+ if (chat.updatedAt > session.lastSyncedAt) {
371
+ showStoredChat(chat);
372
+ return;
373
+ }
374
+ // Turns another tab merged in while this one streamed: add them, and store the full view.
375
+ const shown = new Set(messagesRef.current.map((m) => m.id));
376
+ if (chat.messages.some((m) => !shown.has(m.id) && !(0, chatSession_1.isOwnMessage)(session, m.id))) {
377
+ session.dirty = true;
378
+ setMessages((prev) => (0, chatThreads_1.mergeMessages)(chat.messages, prev));
379
+ }
380
+ }
381
+ catch (_b) {
382
+ /* ignore */
383
+ }
384
+ }), [
385
+ session,
386
+ storeRef,
387
+ messagesRef,
388
+ refreshThreads,
389
+ showStoredChat,
390
+ forgetConversation,
391
+ mountedRef,
392
+ setMessages,
393
+ ]);
394
+ // Save once a turn has settled.
395
+ (0, react_1.useEffect)(() => {
396
+ var _a;
397
+ if (!storeRef.current || messages.length === 0)
398
+ return;
399
+ if (isStreaming || !session.dirty)
400
+ return;
401
+ session.dirty = false;
402
+ (_a = persistNow()) === null || _a === void 0 ? void 0 : _a.then(() => {
403
+ if (!missedSyncRef.current) {
404
+ refreshThreads();
405
+ return;
406
+ }
407
+ missedSyncRef.current = false;
408
+ syncFromStore();
409
+ });
410
+ }, [messages, isStreaming, session, storeRef, persistNow, refreshThreads, syncFromStore]);
411
+ (0, react_1.useEffect)(() => {
412
+ if (!(store === null || store === void 0 ? void 0 : store.subscribe))
413
+ return undefined;
414
+ return store.subscribe(() => {
415
+ syncFromStore();
416
+ });
417
+ }, [store, syncFromStore]);
418
+ /** The user is sending a message: nothing still loading may land on top of it. */
419
+ const beginTurn = (0, react_1.useCallback)(() => {
420
+ session.interacted = true;
421
+ session.loadRequest += 1;
422
+ setIsHydrating(false);
423
+ }, [session]);
424
+ const clearHistory = (0, react_1.useCallback)(() => {
425
+ const storedIds = forgetConversation();
426
+ const { current } = storeRef;
427
+ if (storedIds.length === 0 || !current)
428
+ return;
429
+ // Queued behind pending saves so one of them cannot recreate the deleted thread.
430
+ enqueueWrite(() => Promise.all(storedIds.map((id) => current.deleteThread(id).catch(() => { }))).then(() => { })).then(refreshThreads);
431
+ }, [forgetConversation, storeRef, enqueueWrite, refreshThreads]);
432
+ const leaveConversation = (0, react_1.useCallback)(() => {
433
+ var _a;
434
+ if (!detachTurn())
435
+ (_a = settleAndPersist()) === null || _a === void 0 ? void 0 : _a.then(refreshThreads);
436
+ forgetConversation();
437
+ }, [detachTurn, settleAndPersist, refreshThreads, forgetConversation]);
438
+ const newThread = leaveConversation;
439
+ const switchThread = (0, react_1.useCallback)((threadId) => tslib_1.__awaiter(this, void 0, void 0, function* () {
440
+ const { current } = storeRef;
441
+ if (!current || threadId === session.storageThreadId)
442
+ return;
443
+ leaveConversation();
444
+ const live = (0, backgroundTurns_1.resumeBackgroundTurn)(current, threadId, screenOwner);
445
+ if (live) {
446
+ showBackgroundTurn(live);
447
+ return;
448
+ }
449
+ const request = session.loadRequest;
450
+ const stillWanted = () => mountedRef.current && request === session.loadRequest;
451
+ setIsHydrating(true);
452
+ try {
453
+ const chat = yield current.getThread(threadId);
454
+ if (stillWanted() && chat)
455
+ showStoredChat(chat);
456
+ }
457
+ catch (_c) {
458
+ /* keep the empty conversation */
459
+ }
460
+ finally {
461
+ if (stillWanted())
462
+ setIsHydrating(false);
463
+ }
464
+ }), [
465
+ session,
466
+ storeRef,
467
+ leaveConversation,
468
+ screenOwner,
469
+ showBackgroundTurn,
470
+ showStoredChat,
471
+ mountedRef,
472
+ ]);
473
+ return {
474
+ isHydrating,
475
+ threads,
476
+ activeThreadId,
477
+ foreignInFlight,
478
+ beginTurn,
479
+ screenOwner,
480
+ clearHistory,
481
+ newThread,
482
+ switchThread,
483
+ };
484
+ }
485
+ exports.default = useChatPersistence;
@@ -0,0 +1,34 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const react_1 = require("react");
4
+ /** An answer streaming in another tab: blocks sending and settles after the grace period. */
5
+ function useForeignStream(session) {
6
+ const [foreignInFlight, setForeignInFlight] = (0, react_1.useState)(false);
7
+ const timerRef = (0, react_1.useRef)(null);
8
+ const setForeign = (0, react_1.useCallback)((value) => {
9
+ session.foreignInFlight = value;
10
+ setForeignInFlight(value);
11
+ }, [session]);
12
+ const clearTimer = (0, react_1.useCallback)(() => {
13
+ if (timerRef.current)
14
+ clearTimeout(timerRef.current);
15
+ timerRef.current = null;
16
+ }, []);
17
+ /** Show the thread as streaming elsewhere, then run `onExpire` unless cleared before. */
18
+ const watch = (0, react_1.useCallback)((remainingMs, onExpire) => {
19
+ clearTimer();
20
+ setForeign(true);
21
+ timerRef.current = setTimeout(() => {
22
+ timerRef.current = null;
23
+ setForeign(false);
24
+ onExpire();
25
+ }, remainingMs);
26
+ }, [clearTimer, setForeign]);
27
+ const stop = (0, react_1.useCallback)(() => {
28
+ clearTimer();
29
+ setForeign(false);
30
+ }, [clearTimer, setForeign]);
31
+ (0, react_1.useEffect)(() => clearTimer, [clearTimer]);
32
+ return { foreignInFlight, setForeignInFlight, watch, stop, clearTimer };
33
+ }
34
+ exports.default = useForeignStream;
@@ -0,0 +1,45 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const tslib_1 = require("tslib");
4
+ const react_1 = require("react");
5
+ const useIsMounted_1 = tslib_1.__importDefault(require("../useIsMounted"));
6
+ /** Whether two thread lists describe the same state, so an unchanged re-read can be dropped. */
7
+ function sameThreads(a, b) {
8
+ return (a.length === b.length &&
9
+ a.every((t, i) => {
10
+ const other = b[i];
11
+ return (t.threadId === other.threadId &&
12
+ t.updatedAt === other.updatedAt &&
13
+ t.createdAt === other.createdAt &&
14
+ t.inFlight === other.inFlight &&
15
+ t.title === other.title);
16
+ }));
17
+ }
18
+ /** The stored thread list, re-read on demand; only the latest read may land. */
19
+ function useThreadList(storeRef) {
20
+ const [threads, setThreads] = (0, react_1.useState)([]);
21
+ const requestRef = (0, react_1.useRef)(0);
22
+ const mountedRef = (0, useIsMounted_1.default)();
23
+ /** Drop the result of any read still in flight. */
24
+ const invalidate = (0, react_1.useCallback)(() => {
25
+ requestRef.current += 1;
26
+ }, []);
27
+ const refreshThreads = (0, react_1.useCallback)(() => tslib_1.__awaiter(this, void 0, void 0, function* () {
28
+ const store = storeRef.current;
29
+ if (!store)
30
+ return;
31
+ requestRef.current += 1;
32
+ const request = requestRef.current;
33
+ try {
34
+ const list = yield store.listThreads();
35
+ if (!mountedRef.current || request !== requestRef.current)
36
+ return;
37
+ setThreads((prev) => (sameThreads(prev, list) ? prev : list));
38
+ }
39
+ catch (_a) {
40
+ /* ignore */
41
+ }
42
+ }), [storeRef, mountedRef]);
43
+ return { threads, setThreads, refreshThreads, invalidate };
44
+ }
45
+ exports.default = useThreadList;
@@ -0,0 +1,17 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const react_1 = require("react");
4
+ /** Runs storage writes one after another, in the order they were issued. */
5
+ function useWriteQueue() {
6
+ const chainRef = (0, react_1.useRef)(Promise.resolve());
7
+ const enqueue = (0, react_1.useCallback)((run) => {
8
+ chainRef.current = chainRef.current.then(run, run);
9
+ return chainRef.current;
10
+ }, []);
11
+ /** Forget pending writes, so a slow one cannot delay writes into a different store. */
12
+ const reset = (0, react_1.useCallback)(() => {
13
+ chainRef.current = Promise.resolve();
14
+ }, []);
15
+ return { enqueue, reset };
16
+ }
17
+ exports.default = useWriteQueue;