@molecule/app-react 1.6.7 → 1.7.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/README.md +31 -10
- package/dist/hooks/useAuth.d.ts +15 -3
- package/dist/hooks/useAuth.d.ts.map +1 -1
- package/dist/hooks/useAuth.js +12 -0
- package/dist/hooks/useAuth.js.map +1 -1
- package/dist/hooks/useChat.d.ts.map +1 -1
- package/dist/hooks/useChat.js +390 -65
- package/dist/hooks/useChat.js.map +1 -1
- package/dist/hooks/useForm.d.ts +3 -1
- package/dist/hooks/useForm.d.ts.map +1 -1
- package/dist/hooks/useForm.js +35 -39
- package/dist/hooks/useForm.js.map +1 -1
- package/dist/hooks/useSignup.d.ts +9 -4
- package/dist/hooks/useSignup.d.ts.map +1 -1
- package/dist/hooks/useSignup.js.map +1 -1
- package/dist/types.d.ts +12 -3
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
package/dist/hooks/useChat.js
CHANGED
|
@@ -73,7 +73,7 @@ function persistQueue(projectId, queue) {
|
|
|
73
73
|
}
|
|
74
74
|
}
|
|
75
75
|
/**
|
|
76
|
-
* Load
|
|
76
|
+
* Load the persisted queue for a project without discarding unacknowledged work.
|
|
77
77
|
* @param projectId - The project identifier used as the storage key.
|
|
78
78
|
* @returns The previously persisted queue entries, or an empty array if none found.
|
|
79
79
|
*/
|
|
@@ -81,7 +81,6 @@ function loadPersistedQueue(projectId) {
|
|
|
81
81
|
try {
|
|
82
82
|
const raw = sessionStorage.getItem(`${STORAGE_PREFIX}queue-${projectId}`);
|
|
83
83
|
if (raw) {
|
|
84
|
-
sessionStorage.removeItem(`${STORAGE_PREFIX}queue-${projectId}`);
|
|
85
84
|
return JSON.parse(raw);
|
|
86
85
|
}
|
|
87
86
|
}
|
|
@@ -132,6 +131,7 @@ function consumeStreamingFlag(projectId) {
|
|
|
132
131
|
}
|
|
133
132
|
/** Stable empty array for the SSR/initial snapshot (useSyncExternalStore needs referential stability). */
|
|
134
133
|
const EMPTY_MESSAGES = [];
|
|
134
|
+
const EMPTY_TASKS = [];
|
|
135
135
|
const messageStores = new Map();
|
|
136
136
|
/**
|
|
137
137
|
* Get (creating if needed) the live message store for a conversation key.
|
|
@@ -143,9 +143,13 @@ function getMessageStore(key) {
|
|
|
143
143
|
if (!store) {
|
|
144
144
|
store = {
|
|
145
145
|
messages: [],
|
|
146
|
+
backgroundTasks: [],
|
|
147
|
+
pending: { current: [] },
|
|
148
|
+
deliveredQueueIds: new Set(),
|
|
146
149
|
streaming: false,
|
|
147
150
|
remoteStreaming: false,
|
|
148
151
|
stoppedByUser: false,
|
|
152
|
+
userIntentRevision: 0,
|
|
149
153
|
remotePollTimer: null,
|
|
150
154
|
listeners: new Set(),
|
|
151
155
|
generation: 0,
|
|
@@ -175,6 +179,74 @@ function setStoreMessages(key, updater) {
|
|
|
175
179
|
store.messages = typeof updater === 'function' ? updater(store.messages) : updater;
|
|
176
180
|
emitStore(key);
|
|
177
181
|
}
|
|
182
|
+
/** Update conversation-level work independently of any assistant message. */
|
|
183
|
+
function mergeBackgroundTasks(key, tasks) {
|
|
184
|
+
const store = getMessageStore(key);
|
|
185
|
+
const next = new Map(store.backgroundTasks.map((task) => [task.id, task]));
|
|
186
|
+
for (const task of tasks) {
|
|
187
|
+
const previous = next.get(task.id);
|
|
188
|
+
if (previous && previous.status !== 'running' && task.status === 'running')
|
|
189
|
+
continue;
|
|
190
|
+
next.set(task.id, {
|
|
191
|
+
...task,
|
|
192
|
+
...(previous?.outputChars != null
|
|
193
|
+
? { outputChars: Math.max(previous.outputChars, task.outputChars ?? 0) }
|
|
194
|
+
: {}),
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
store.backgroundTasks = [...next.values()];
|
|
198
|
+
emitStore(key);
|
|
199
|
+
}
|
|
200
|
+
/** Recover durable queue handles; the server keeps full attachment payloads behind each UUID. */
|
|
201
|
+
function restoreQueuedMessages(key, history, acceptedBeforeLoad = new Set()) {
|
|
202
|
+
const pending = getMessageStore(key).pending;
|
|
203
|
+
const queuedIds = new Set(history.filter((message) => message.queued).map((message) => message.id));
|
|
204
|
+
const removed = new Set([...acceptedBeforeLoad].filter((id) => !queuedIds.has(id)));
|
|
205
|
+
for (const id of removed)
|
|
206
|
+
getMessageStore(key).deliveredQueueIds.add(id);
|
|
207
|
+
pending.current = pending.current.filter((entry) => !entry.userMsgId || !removed.has(entry.userMsgId));
|
|
208
|
+
if (removed.size > 0)
|
|
209
|
+
setStoreMessages(key, (messages) => messages.filter((message) => !message.queued || !removed.has(message.id)));
|
|
210
|
+
for (const message of history) {
|
|
211
|
+
if (message.role !== 'user')
|
|
212
|
+
continue;
|
|
213
|
+
const existing = pending.current.find((entry) => entry.userMsgId === message.id);
|
|
214
|
+
if (message.queued) {
|
|
215
|
+
if (getMessageStore(key).deliveredQueueIds.has(message.id))
|
|
216
|
+
continue;
|
|
217
|
+
if (existing)
|
|
218
|
+
existing.accepted = true;
|
|
219
|
+
if (!existing)
|
|
220
|
+
pending.current.push({
|
|
221
|
+
message: message.content,
|
|
222
|
+
userMsgId: message.id,
|
|
223
|
+
durable: true,
|
|
224
|
+
accepted: true,
|
|
225
|
+
...(message.automatic ? { automatic: true } : {}),
|
|
226
|
+
...(message.viaDictation ? { viaDictation: true } : {}),
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
else {
|
|
230
|
+
pending.current = pending.current.filter((entry) => entry.userMsgId !== message.id);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
persistQueue(key, pending.current);
|
|
234
|
+
return history.filter((message) => !message.queued || !getMessageStore(key).deliveredQueueIds.has(message.id));
|
|
235
|
+
}
|
|
236
|
+
/** Only acceptance known before a history request can be authoritatively reconciled by it. */
|
|
237
|
+
function acceptedQueueIds(key) {
|
|
238
|
+
return new Set(getMessageStore(key)
|
|
239
|
+
.pending.current.filter((entry) => entry.accepted && entry.userMsgId != null)
|
|
240
|
+
.map((entry) => entry.userMsgId));
|
|
241
|
+
}
|
|
242
|
+
/** Remove a queue item in every mounted view and reject delayed acceptance snapshots. */
|
|
243
|
+
function removeQueuedMessage(key, messageId) {
|
|
244
|
+
const store = getMessageStore(key);
|
|
245
|
+
store.pending.current = store.pending.current.filter((entry) => entry.userMsgId !== messageId);
|
|
246
|
+
store.deliveredQueueIds.add(messageId);
|
|
247
|
+
persistQueue(key, store.pending.current);
|
|
248
|
+
setStoreMessages(key, (messages) => messages.filter((message) => message.id !== messageId || !message.queued));
|
|
249
|
+
}
|
|
178
250
|
/**
|
|
179
251
|
* Set whether a conversation has an active stream, notifying subscribers on change.
|
|
180
252
|
* @param key - The conversation/project storage key.
|
|
@@ -295,6 +367,10 @@ export function convergeHistory(prev, history) {
|
|
|
295
367
|
const local = prevById.get(h.id);
|
|
296
368
|
if (!local)
|
|
297
369
|
return h;
|
|
370
|
+
if (local.queued)
|
|
371
|
+
return h;
|
|
372
|
+
if (h.queued)
|
|
373
|
+
return local;
|
|
298
374
|
// Keep the live copy when it is at least as far along; carry its streaming
|
|
299
375
|
// flag either way so an in-progress message keeps its spinner across merges.
|
|
300
376
|
const winner = messageProgress(local) >= messageProgress(h) ? local : h;
|
|
@@ -480,6 +556,7 @@ export function useChat(options) {
|
|
|
480
556
|
const messages = useSyncExternalStore(subscribeStore, () => getMessageStore(storageKey).messages, () => EMPTY_MESSAGES);
|
|
481
557
|
const isLoading = useSyncExternalStore(subscribeStore, () => getMessageStore(storageKey).streaming, () => false);
|
|
482
558
|
const isRemoteStreaming = useSyncExternalStore(subscribeStore, () => getMessageStore(storageKey).remoteStreaming, () => false);
|
|
559
|
+
const backgroundTasks = useSyncExternalStore(subscribeStore, () => getMessageStore(storageKey).backgroundTasks, () => EMPTY_TASKS);
|
|
483
560
|
// The store generation this instance last aligned with (stamped by the mount
|
|
484
561
|
// effect and on each send/resume start). Null until first stamped — treated
|
|
485
562
|
// as current. An UNMOUNTED instance's stream callbacks keep this ref frozen
|
|
@@ -513,8 +590,50 @@ export function useChat(options) {
|
|
|
513
590
|
// echo (own broadcast in a second tab) or a reload-then-broadcast race never doubles
|
|
514
591
|
// it. A complete message is a finished item — no streaming/finalize.
|
|
515
592
|
const appendCompleteMessage = useCallback((message) => {
|
|
516
|
-
|
|
517
|
-
|
|
593
|
+
const store = getMessageStore(storageKey);
|
|
594
|
+
const pending = store.pending;
|
|
595
|
+
const existingMessage = store.messages.find((item) => item.id === message.id);
|
|
596
|
+
if (message.queued &&
|
|
597
|
+
(store.deliveredQueueIds.has(message.id) || (existingMessage && !existingMessage.queued)))
|
|
598
|
+
return;
|
|
599
|
+
if (!message.queued) {
|
|
600
|
+
if (message.hidden || pending.current.some((entry) => entry.userMsgId === message.id))
|
|
601
|
+
store.deliveredQueueIds.add(message.id);
|
|
602
|
+
pending.current = pending.current.filter((entry) => entry.userMsgId !== message.id);
|
|
603
|
+
}
|
|
604
|
+
else {
|
|
605
|
+
const entry = pending.current.find((item) => item.userMsgId === message.id);
|
|
606
|
+
if (entry) {
|
|
607
|
+
entry.accepted = true;
|
|
608
|
+
entry.message = message.content;
|
|
609
|
+
}
|
|
610
|
+
else if (provider.queueMessage)
|
|
611
|
+
pending.current.push({
|
|
612
|
+
userMsgId: message.id,
|
|
613
|
+
message: message.content,
|
|
614
|
+
durable: true,
|
|
615
|
+
accepted: true,
|
|
616
|
+
...(message.hidden ? { suppressUserMessage: true } : {}),
|
|
617
|
+
...(message.automatic ? { automatic: true } : {}),
|
|
618
|
+
...(message.viaDictation ? { viaDictation: true } : {}),
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
persistQueue(storageKey, pending.current);
|
|
622
|
+
if (message.hidden) {
|
|
623
|
+
if (!message.queued)
|
|
624
|
+
setMessages((prev) => prev.filter((item) => item.id !== message.id || !item.queued));
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
setMessages((prev) => {
|
|
628
|
+
const existing = prev.find((item) => item.id === message.id);
|
|
629
|
+
if (!existing)
|
|
630
|
+
return [...prev, message];
|
|
631
|
+
// A delayed acceptance response cannot re-queue a message already delivered.
|
|
632
|
+
if (!existing.queued)
|
|
633
|
+
return prev;
|
|
634
|
+
return prev.map((item) => (item.id === message.id ? message : item));
|
|
635
|
+
});
|
|
636
|
+
}, [provider, setMessages, storageKey]);
|
|
518
637
|
const setIsLoading = useCallback((streaming) => {
|
|
519
638
|
if (isStaleGeneration())
|
|
520
639
|
return;
|
|
@@ -524,8 +643,17 @@ export function useChat(options) {
|
|
|
524
643
|
// send is in flight — confirm against the server's streaming flag and track
|
|
525
644
|
// the remote turn so the Stop control stays visible (see startRemoteStreamPoll).
|
|
526
645
|
const noteRemoteStreamEvent = useCallback(() => {
|
|
646
|
+
const generation = getMessageStore(storageKey).generation;
|
|
527
647
|
startRemoteStreamPoll(storageKey, async () => {
|
|
528
|
-
const
|
|
648
|
+
const accepted = acceptedQueueIds(storageKey);
|
|
649
|
+
const intentRevision = getMessageStore(storageKey).userIntentRevision;
|
|
650
|
+
let history = await provider.loadHistory({ endpoint, projectId });
|
|
651
|
+
if (getMessageStore(storageKey).generation === generation) {
|
|
652
|
+
restoreBackgroundTasks();
|
|
653
|
+
restoreUserStop(intentRevision);
|
|
654
|
+
if (provider.queueMessage)
|
|
655
|
+
history = restoreQueuedMessages(storageKey, history, accepted);
|
|
656
|
+
}
|
|
529
657
|
return {
|
|
530
658
|
streaming: provider.isServerStreaming === true,
|
|
531
659
|
history,
|
|
@@ -575,7 +703,23 @@ export function useChat(options) {
|
|
|
575
703
|
// than per-message on purpose: one starts under one assistant message and
|
|
576
704
|
// finishes minutes later under another, so a message-scoped block could only
|
|
577
705
|
// ever show half of it.
|
|
578
|
-
const
|
|
706
|
+
const restoreBackgroundTasks = () => {
|
|
707
|
+
const tasks = provider.lastMeta
|
|
708
|
+
?.backgroundTasks;
|
|
709
|
+
if (Array.isArray(tasks))
|
|
710
|
+
mergeBackgroundTasks(storageKey, tasks);
|
|
711
|
+
};
|
|
712
|
+
const restoreUserStop = (intentRevision) => {
|
|
713
|
+
const store = getMessageStore(storageKey);
|
|
714
|
+
const stoppedAt = provider.lastMeta?.userStoppedAt;
|
|
715
|
+
// Missing metadata must never clear a local Stop. A history request begun
|
|
716
|
+
// before the user's next send must likewise never reinstate an old Stop.
|
|
717
|
+
if (store.userIntentRevision === intentRevision &&
|
|
718
|
+
!store.streaming &&
|
|
719
|
+
typeof stoppedAt === 'string' &&
|
|
720
|
+
stoppedAt.length > 0)
|
|
721
|
+
store.stoppedByUser = true;
|
|
722
|
+
};
|
|
579
723
|
// Pace status labels. A small change's verification (type-check + lint over a
|
|
580
724
|
// couple of files) finishes in well under a second, so its step labels were
|
|
581
725
|
// emitted in a rapid burst — they flashed by unreadably, or React batched the
|
|
@@ -631,7 +775,7 @@ export function useChat(options) {
|
|
|
631
775
|
const loadOnMountRef = useRef(loadOnMount);
|
|
632
776
|
// Queue for messages sent while a request is already in-flight
|
|
633
777
|
const sendingRef = useRef(false);
|
|
634
|
-
const pendingRef =
|
|
778
|
+
const pendingRef = useMemo(() => getMessageStore(storageKey).pending, [storageKey]);
|
|
635
779
|
// Set when the user clicks Stop, so the post-send reconcile does NOT overwrite
|
|
636
780
|
// the locally-finalized streamed content with server history. The abort()
|
|
637
781
|
// callback already finalizes the streaming message (keeping every streamed
|
|
@@ -758,8 +902,17 @@ export function useChat(options) {
|
|
|
758
902
|
// — a teammate's /teamsay note against a backgrounded tab or a slept laptop —
|
|
759
903
|
// is otherwise lost until the next lifecycle event happens to fire.
|
|
760
904
|
const reconcileHistory = useCallback(async () => {
|
|
905
|
+
const generation = getMessageStore(storageKey).generation;
|
|
906
|
+
const intentRevision = getMessageStore(storageKey).userIntentRevision;
|
|
907
|
+
const accepted = acceptedQueueIds(storageKey);
|
|
761
908
|
try {
|
|
762
|
-
|
|
909
|
+
let history = await provider.loadHistory({ endpoint, projectId });
|
|
910
|
+
if (getMessageStore(storageKey).generation !== generation)
|
|
911
|
+
return;
|
|
912
|
+
restoreBackgroundTasks();
|
|
913
|
+
restoreUserStop(intentRevision);
|
|
914
|
+
if (provider.queueMessage)
|
|
915
|
+
history = restoreQueuedMessages(storageKey, history, accepted);
|
|
763
916
|
if (!mountedRef.current || sendingRef.current)
|
|
764
917
|
return;
|
|
765
918
|
applyServerMode();
|
|
@@ -795,7 +948,7 @@ export function useChat(options) {
|
|
|
795
948
|
noteRemoteStreamEvent();
|
|
796
949
|
return;
|
|
797
950
|
}
|
|
798
|
-
if (serverStreaming && history.length > 0) {
|
|
951
|
+
if (serverStreaming && history.length > 0 && !store.stoppedByUser) {
|
|
799
952
|
const last = history[history.length - 1];
|
|
800
953
|
if (last.role === 'assistant') {
|
|
801
954
|
void resumeStreamRef.current(last.id, last.content);
|
|
@@ -971,13 +1124,16 @@ export function useChat(options) {
|
|
|
971
1124
|
resumeStreamRef.current(target.id, target.content);
|
|
972
1125
|
return;
|
|
973
1126
|
}
|
|
974
|
-
// Re-send
|
|
975
|
-
//
|
|
1127
|
+
// Re-send under the original id: an acceptance whose response was lost must
|
|
1128
|
+
// not create a second server message. Refresh the optimistic bubble in place.
|
|
976
1129
|
if (target.userMsgId) {
|
|
977
1130
|
const uid = target.userMsgId;
|
|
978
1131
|
setMessages((prev) => prev.filter((m) => m.id !== uid));
|
|
979
1132
|
}
|
|
980
|
-
void sendMessageRef.current(target.message, target.attachments,
|
|
1133
|
+
void sendMessageRef.current(target.message, target.attachments, {
|
|
1134
|
+
...target.options,
|
|
1135
|
+
...(target.userMsgId ? { messageId: target.userMsgId } : {}),
|
|
1136
|
+
});
|
|
981
1137
|
}, [clearRetryTimers]);
|
|
982
1138
|
/**
|
|
983
1139
|
* Schedule a cancelable, once-per-second countdown that auto-resumes the turn
|
|
@@ -1100,18 +1256,34 @@ export function useChat(options) {
|
|
|
1100
1256
|
stopRemoteStreamPoll(storageKey);
|
|
1101
1257
|
setStoreStreaming(storageKey, false);
|
|
1102
1258
|
setStoreMessages(storageKey, []);
|
|
1259
|
+
syncStore.backgroundTasks = [];
|
|
1260
|
+
syncStore.pending.current = [];
|
|
1261
|
+
syncStore.deliveredQueueIds.clear();
|
|
1262
|
+
persistQueue(storageKey, []);
|
|
1263
|
+
emitStore(storageKey);
|
|
1103
1264
|
}
|
|
1104
1265
|
generationRef.current = syncStore.generation;
|
|
1266
|
+
const loadGeneration = syncStore.generation;
|
|
1267
|
+
const intentRevision = syncStore.userIntentRevision;
|
|
1105
1268
|
if (!loadOnMountRef.current)
|
|
1106
1269
|
return;
|
|
1107
1270
|
// Read persisted state synchronously before the async history fetch
|
|
1108
1271
|
const persistedQueue = loadPersistedQueue(storageKey);
|
|
1109
1272
|
const interrupted = consumeStreamingFlag(storageKey);
|
|
1273
|
+
const accepted = acceptedQueueIds(storageKey);
|
|
1110
1274
|
provider
|
|
1111
1275
|
.loadHistory(config)
|
|
1112
1276
|
.then((history) => {
|
|
1113
|
-
if (!mountedRef.current)
|
|
1277
|
+
if (!mountedRef.current || syncStore.generation !== loadGeneration)
|
|
1114
1278
|
return;
|
|
1279
|
+
restoreBackgroundTasks();
|
|
1280
|
+
restoreUserStop(intentRevision);
|
|
1281
|
+
if (provider.queueMessage)
|
|
1282
|
+
history = restoreQueuedMessages(storageKey, history, accepted);
|
|
1283
|
+
// A server-accepted item absent from fresh history was consumed/deleted.
|
|
1284
|
+
// Unacknowledged submissions still need their idempotent retry.
|
|
1285
|
+
const queuedIds = new Set(history.filter((item) => item.queued).map((item) => item.id));
|
|
1286
|
+
const restoreQueue = persistedQueue.filter((entry) => !entry.accepted || (entry.userMsgId != null && queuedIds.has(entry.userMsgId)));
|
|
1115
1287
|
// Restore mode from server (persisted in conversation.aiContext.mode).
|
|
1116
1288
|
// Idempotent + cheap, so do it regardless of store state. The http bond
|
|
1117
1289
|
// surfaces app-specific GET fields generically via `lastMeta` (it no
|
|
@@ -1160,7 +1332,7 @@ export function useChat(options) {
|
|
|
1160
1332
|
}
|
|
1161
1333
|
return;
|
|
1162
1334
|
}
|
|
1163
|
-
const shouldResume = (interrupted || serverStreaming) && history.length > 0;
|
|
1335
|
+
const shouldResume = !syncStore.stoppedByUser && (interrupted || serverStreaming) && history.length > 0;
|
|
1164
1336
|
if (shouldResume) {
|
|
1165
1337
|
// Stream was interrupted — resume into the last assistant message,
|
|
1166
1338
|
// or create a new placeholder if the last message is from the user
|
|
@@ -1184,24 +1356,42 @@ export function useChat(options) {
|
|
|
1184
1356
|
setMessages((prev) => [...prev, placeholder]);
|
|
1185
1357
|
resumeTarget = { id: placeholderId, content: '' };
|
|
1186
1358
|
}
|
|
1187
|
-
|
|
1359
|
+
for (const entry of restoreQueue) {
|
|
1360
|
+
if (!entry.userMsgId ||
|
|
1361
|
+
!pendingRef.current.some((item) => item.userMsgId === entry.userMsgId))
|
|
1362
|
+
pendingRef.current.push(entry);
|
|
1363
|
+
}
|
|
1188
1364
|
const target = resumeTarget;
|
|
1189
1365
|
setTimeout(() => {
|
|
1190
|
-
if (mountedRef.current
|
|
1366
|
+
if (mountedRef.current &&
|
|
1367
|
+
syncStore.generation === loadGeneration &&
|
|
1368
|
+
!syncStore.stoppedByUser) {
|
|
1191
1369
|
resumeStreamRef.current(target.id, target.content);
|
|
1192
1370
|
}
|
|
1193
1371
|
}, 0);
|
|
1194
1372
|
}
|
|
1195
|
-
else if (
|
|
1196
|
-
const
|
|
1197
|
-
|
|
1373
|
+
else if (restoreQueue.length > 0 || pendingRef.current.length > 0) {
|
|
1374
|
+
for (const entry of restoreQueue) {
|
|
1375
|
+
if (!entry.userMsgId ||
|
|
1376
|
+
!pendingRef.current.some((item) => item.userMsgId === entry.userMsgId))
|
|
1377
|
+
pendingRef.current.push(entry);
|
|
1378
|
+
}
|
|
1379
|
+
persistQueue(storageKey, pendingRef.current);
|
|
1380
|
+
if (syncStore.stoppedByUser)
|
|
1381
|
+
return;
|
|
1382
|
+
const first = pendingRef.current[0];
|
|
1383
|
+
if (!first.durable)
|
|
1384
|
+
pendingRef.current.shift();
|
|
1198
1385
|
setTimeout(() => {
|
|
1199
|
-
if (mountedRef.current
|
|
1386
|
+
if (mountedRef.current &&
|
|
1387
|
+
syncStore.generation === loadGeneration &&
|
|
1388
|
+
!syncStore.stoppedByUser) {
|
|
1200
1389
|
// Re-send with the entry's own intent flags — a queue persisted
|
|
1201
1390
|
// before a refresh must re-send as what it was (an auto-sent
|
|
1202
1391
|
// fix stays automatic, a dictated message keeps its flag), not
|
|
1203
1392
|
// degraded to a plain typed message.
|
|
1204
1393
|
sendMessageRef.current(first.message, first.attachments, {
|
|
1394
|
+
...(first.userMsgId ? { messageId: first.userMsgId } : {}),
|
|
1205
1395
|
...(first.suppressUserMessage ? { suppressUserMessage: true } : {}),
|
|
1206
1396
|
...(first.automatic ? { automatic: true } : {}),
|
|
1207
1397
|
...(first.userInitiated ? { userInitiated: true } : {}),
|
|
@@ -1334,15 +1524,6 @@ export function useChat(options) {
|
|
|
1334
1524
|
});
|
|
1335
1525
|
ctx.scheduleFlush(() => ({ blocks: [...ctx.blocks] }));
|
|
1336
1526
|
break;
|
|
1337
|
-
case 'background_task': {
|
|
1338
|
-
const incoming = event.task;
|
|
1339
|
-
setBackgroundTasks((prev) => {
|
|
1340
|
-
const next = prev.filter((t) => t.id !== incoming.id);
|
|
1341
|
-
next.push(incoming);
|
|
1342
|
-
return next;
|
|
1343
|
-
});
|
|
1344
|
-
break;
|
|
1345
|
-
}
|
|
1346
1527
|
case 'resource_limit':
|
|
1347
1528
|
ctx.blocks.push({
|
|
1348
1529
|
type: 'resource_limit',
|
|
@@ -1496,6 +1677,14 @@ export function useChat(options) {
|
|
|
1496
1677
|
onStreamEvent?.(event);
|
|
1497
1678
|
// ── Structural / ctx-independent events ──
|
|
1498
1679
|
switch (event.type) {
|
|
1680
|
+
case 'queue_removed':
|
|
1681
|
+
removeQueuedMessage(storageKey, event.messageId);
|
|
1682
|
+
return;
|
|
1683
|
+
case 'background_task':
|
|
1684
|
+
mergeBackgroundTasks(storageKey, [event.task]);
|
|
1685
|
+
return;
|
|
1686
|
+
case 'context_usage':
|
|
1687
|
+
return;
|
|
1499
1688
|
case 'message_start':
|
|
1500
1689
|
// A status line shown before this message (e.g. "The AI provider
|
|
1501
1690
|
// dropped the reply, so Synthase is resuming it…") has done its job
|
|
@@ -1632,6 +1821,16 @@ export function useChat(options) {
|
|
|
1632
1821
|
finalizeRemoteStreamRef.current = finalizeRemoteStream;
|
|
1633
1822
|
const applyRemoteEventImpl = (event) => {
|
|
1634
1823
|
const store = getMessageStore(storageKey);
|
|
1824
|
+
if (event.type === 'queue_removed') {
|
|
1825
|
+
removeQueuedMessage(storageKey, event.messageId);
|
|
1826
|
+
return;
|
|
1827
|
+
}
|
|
1828
|
+
if (event.type === 'background_task') {
|
|
1829
|
+
mergeBackgroundTasks(storageKey, [event.task]);
|
|
1830
|
+
return;
|
|
1831
|
+
}
|
|
1832
|
+
if (event.type === 'context_usage')
|
|
1833
|
+
return;
|
|
1635
1834
|
// Complete, id-deduped items apply even while a local stream runs: the
|
|
1636
1835
|
// sender's own SSE copy wins the dedupe, and a teammate's team note (or a
|
|
1637
1836
|
// shared setting card) must land mid-turn too.
|
|
@@ -1642,7 +1841,10 @@ export function useChat(options) {
|
|
|
1642
1841
|
if (event.type === 'message') {
|
|
1643
1842
|
// A broadcast user message: the sender's tab already shows its optimistic
|
|
1644
1843
|
// bubble (different local id) — never double it while its send is live.
|
|
1645
|
-
if (event.message.role === 'user' &&
|
|
1844
|
+
if (event.message.role === 'user' &&
|
|
1845
|
+
store.streaming &&
|
|
1846
|
+
!provider.queueMessage &&
|
|
1847
|
+
!store.messages.some((message) => message.id === event.message.id && message.queued))
|
|
1646
1848
|
return;
|
|
1647
1849
|
appendCompleteMessage(event.message);
|
|
1648
1850
|
return;
|
|
@@ -1716,6 +1918,21 @@ export function useChat(options) {
|
|
|
1716
1918
|
finalizeRemoteStreamRef.current();
|
|
1717
1919
|
resetStatusQueue();
|
|
1718
1920
|
setStoreRemoteStreaming(storageKey, false);
|
|
1921
|
+
if (event.type === 'done' && mountedRef.current && !readOnly && !store.stoppedByUser) {
|
|
1922
|
+
const next = pendingRef.current[0];
|
|
1923
|
+
if (next) {
|
|
1924
|
+
if (!next.durable)
|
|
1925
|
+
pendingRef.current.shift();
|
|
1926
|
+
persistQueue(storageKey, pendingRef.current);
|
|
1927
|
+
void sendMessageRef.current(next.message, next.attachments, {
|
|
1928
|
+
messageId: next.userMsgId,
|
|
1929
|
+
suppressUserMessage: next.suppressUserMessage,
|
|
1930
|
+
automatic: next.automatic,
|
|
1931
|
+
userInitiated: next.userInitiated,
|
|
1932
|
+
viaDictation: next.viaDictation,
|
|
1933
|
+
});
|
|
1934
|
+
}
|
|
1935
|
+
}
|
|
1719
1936
|
return;
|
|
1720
1937
|
default:
|
|
1721
1938
|
break;
|
|
@@ -1843,6 +2060,8 @@ export function useChat(options) {
|
|
|
1843
2060
|
return;
|
|
1844
2061
|
store.stoppedByUser = false;
|
|
1845
2062
|
}
|
|
2063
|
+
if (!automatic || options?.userInitiated)
|
|
2064
|
+
store.userIntentRevision++;
|
|
1846
2065
|
// Resolve the pending ask_user card IN THE STORE: set the most-recent unanswered
|
|
1847
2066
|
// ask_user tool call's output to this answer. The chosen option's checked state is
|
|
1848
2067
|
// derived from a string `output`, so persisting it here keeps it checked across the
|
|
@@ -1869,7 +2088,7 @@ export function useChat(options) {
|
|
|
1869
2088
|
});
|
|
1870
2089
|
}
|
|
1871
2090
|
const userMsg = {
|
|
1872
|
-
id:
|
|
2091
|
+
id: options?.messageId ?? crypto.randomUUID(),
|
|
1873
2092
|
role: 'user',
|
|
1874
2093
|
content: message,
|
|
1875
2094
|
timestamp: Date.now(),
|
|
@@ -1886,24 +2105,64 @@ export function useChat(options) {
|
|
|
1886
2105
|
: {}),
|
|
1887
2106
|
};
|
|
1888
2107
|
// If a request is already in-flight, mark the message as queued and defer sending
|
|
1889
|
-
if (sendingRef.current) {
|
|
2108
|
+
if (!options?.messageId && (sendingRef.current || store.streaming || store.remoteStreaming)) {
|
|
1890
2109
|
if (!suppressUserMessage) {
|
|
1891
2110
|
userMsg.queued = true;
|
|
1892
2111
|
setMessages((prev) => [...prev, userMsg]);
|
|
1893
2112
|
}
|
|
1894
|
-
|
|
2113
|
+
const queued = {
|
|
1895
2114
|
message,
|
|
1896
2115
|
attachments,
|
|
1897
2116
|
...(automatic ? { automatic: true } : {}),
|
|
1898
2117
|
...(options?.userInitiated ? { userInitiated: true } : {}),
|
|
1899
2118
|
...(viaDictation ? { viaDictation: true } : {}),
|
|
1900
|
-
|
|
1901
|
-
|
|
2119
|
+
userMsgId: userMsg.id,
|
|
2120
|
+
...(suppressUserMessage ? { suppressUserMessage } : {}),
|
|
2121
|
+
...(provider.queueMessage ? { durable: true } : {}),
|
|
2122
|
+
};
|
|
2123
|
+
pendingRef.current.push(queued);
|
|
1902
2124
|
persistQueue(storageKey, pendingRef.current);
|
|
2125
|
+
if (provider.queueMessage) {
|
|
2126
|
+
const queueGeneration = store.generation;
|
|
2127
|
+
try {
|
|
2128
|
+
await provider.queueMessage(message, {
|
|
2129
|
+
...config,
|
|
2130
|
+
messageId: userMsg.id,
|
|
2131
|
+
suppressUserMessage,
|
|
2132
|
+
automatic,
|
|
2133
|
+
userInitiated: options?.userInitiated,
|
|
2134
|
+
viaDictation,
|
|
2135
|
+
}, (event) => {
|
|
2136
|
+
if (getMessageStore(storageKey).generation !== queueGeneration)
|
|
2137
|
+
return;
|
|
2138
|
+
if (event.type === 'message') {
|
|
2139
|
+
const latest = pendingRef.current.find((entry) => entry.userMsgId === event.message.id);
|
|
2140
|
+
// The initial acceptance can arrive after an acknowledged edit.
|
|
2141
|
+
const accepted = event.message.queued && latest?.accepted
|
|
2142
|
+
? { ...event.message, content: latest.message }
|
|
2143
|
+
: event.message;
|
|
2144
|
+
appendCompleteMessage(accepted);
|
|
2145
|
+
}
|
|
2146
|
+
else if (event.type === 'card') {
|
|
2147
|
+
appendCardMessage(event.id, event.timestamp, event.card);
|
|
2148
|
+
}
|
|
2149
|
+
else if (event.type === 'error')
|
|
2150
|
+
setError(event.message);
|
|
2151
|
+
}, attachments, userMsg.id);
|
|
2152
|
+
}
|
|
2153
|
+
catch (error) {
|
|
2154
|
+
if (getMessageStore(storageKey).generation === queueGeneration)
|
|
2155
|
+
setError(error instanceof Error
|
|
2156
|
+
? error.message
|
|
2157
|
+
: t('chat.error.sendFailed', undefined, {
|
|
2158
|
+
defaultValue: 'Failed to send message',
|
|
2159
|
+
}));
|
|
2160
|
+
}
|
|
2161
|
+
}
|
|
1903
2162
|
return;
|
|
1904
2163
|
}
|
|
1905
2164
|
if (!suppressUserMessage)
|
|
1906
|
-
setMessages((prev) => [...prev, userMsg]);
|
|
2165
|
+
setMessages((prev) => prev.some((item) => item.id === userMsg.id) ? prev : [...prev, userMsg]);
|
|
1907
2166
|
// A fresh user-initiated send clears any prior stop so the reconcile path
|
|
1908
2167
|
// works normally for this turn.
|
|
1909
2168
|
userAbortedRef.current = false;
|
|
@@ -1925,7 +2184,11 @@ export function useChat(options) {
|
|
|
1925
2184
|
attachments,
|
|
1926
2185
|
// Carry the optimistic bubble's id (queued entries already do) so a
|
|
1927
2186
|
// never-started-turn auto-retry can replace it on re-send.
|
|
1928
|
-
|
|
2187
|
+
userMsgId: userMsg.id,
|
|
2188
|
+
...(suppressUserMessage ? { suppressUserMessage: true } : {}),
|
|
2189
|
+
...(pendingRef.current.find((entry) => entry.userMsgId === userMsg.id)?.durable
|
|
2190
|
+
? { durable: true }
|
|
2191
|
+
: {}),
|
|
1929
2192
|
...(automatic ? { automatic: true } : {}),
|
|
1930
2193
|
...(options?.userInitiated ? { userInitiated: true } : {}),
|
|
1931
2194
|
...(viaDictation ? { viaDictation: true } : {}),
|
|
@@ -1935,6 +2198,7 @@ export function useChat(options) {
|
|
|
1935
2198
|
// hands off to the resume flow (which polls history live until the server
|
|
1936
2199
|
// finishes) instead of leaving the chat frozen mid-turn.
|
|
1937
2200
|
let resumeAfterLoop = null;
|
|
2201
|
+
const sendGeneration = store.generation;
|
|
1938
2202
|
while (current) {
|
|
1939
2203
|
if (!mountedRef.current)
|
|
1940
2204
|
break;
|
|
@@ -1943,7 +2207,7 @@ export function useChat(options) {
|
|
|
1943
2207
|
// the actual send time: while queued it pinned to the transcript bottom (see
|
|
1944
2208
|
// timelineSortKey), and its queue-time timestamp predates everything that
|
|
1945
2209
|
// streamed in since — keeping it would jump the message back UP on send.
|
|
1946
|
-
if (current.userMsgId) {
|
|
2210
|
+
if (current.userMsgId && !current.durable) {
|
|
1947
2211
|
const uid = current.userMsgId;
|
|
1948
2212
|
setMessages((prev) => prev.map((m) => (m.id === uid ? { ...m, queued: false, timestamp: Date.now() } : m)));
|
|
1949
2213
|
}
|
|
@@ -2000,6 +2264,7 @@ export function useChat(options) {
|
|
|
2000
2264
|
// endpoint + projectId; spread the per-message flags on top.
|
|
2001
2265
|
const sendConfig = {
|
|
2002
2266
|
...config,
|
|
2267
|
+
...(current.userMsgId ? { messageId: current.userMsgId } : {}),
|
|
2003
2268
|
...(current.suppressUserMessage ? { suppressUserMessage: true } : {}),
|
|
2004
2269
|
...(current.automatic ? { automatic: true } : {}),
|
|
2005
2270
|
...(current.userInitiated ? { userInitiated: true } : {}),
|
|
@@ -2075,8 +2340,17 @@ export function useChat(options) {
|
|
|
2075
2340
|
// Best-effort reconciliation — the finalized placeholder is already shown; dropping history here is safe
|
|
2076
2341
|
}
|
|
2077
2342
|
}
|
|
2078
|
-
|
|
2079
|
-
|
|
2343
|
+
if (getMessageStore(storageKey).generation !== sendGeneration)
|
|
2344
|
+
return;
|
|
2345
|
+
if (userAbortedRef.current || getMessageStore(storageKey).stoppedByUser)
|
|
2346
|
+
break;
|
|
2347
|
+
// A durable entry stays editable until delivery; failed sends keep it queued.
|
|
2348
|
+
if (current.durable &&
|
|
2349
|
+
pendingRef.current.some((entry) => entry.userMsgId === current.userMsgId))
|
|
2350
|
+
break;
|
|
2351
|
+
current = pendingRef.current[0];
|
|
2352
|
+
if (current && !current.durable)
|
|
2353
|
+
pendingRef.current.shift();
|
|
2080
2354
|
if (current) {
|
|
2081
2355
|
setError(null);
|
|
2082
2356
|
setErrorMeta(null);
|
|
@@ -2089,7 +2363,7 @@ export function useChat(options) {
|
|
|
2089
2363
|
// code path. Skip clearing so the streaming flag survives for resume.
|
|
2090
2364
|
if (!unloadingRef.current) {
|
|
2091
2365
|
clearStreamingFlag(storageKey);
|
|
2092
|
-
persistQueue(storageKey,
|
|
2366
|
+
persistQueue(storageKey, pendingRef.current);
|
|
2093
2367
|
}
|
|
2094
2368
|
// Dropped-stream tail: the server was still streaming this turn when the
|
|
2095
2369
|
// connection died with no terminal event (see resumeAfterLoop above).
|
|
@@ -2130,11 +2404,12 @@ export function useChat(options) {
|
|
|
2130
2404
|
// partial message — the server streams its continuation as new message(s)); kept in
|
|
2131
2405
|
// the signature for the callers + ref type.
|
|
2132
2406
|
async (resumeId, _existingContent) => {
|
|
2133
|
-
if (!mountedRef.current || sendingRef.current)
|
|
2407
|
+
if (!mountedRef.current || sendingRef.current || getMessageStore(storageKey).stoppedByUser)
|
|
2134
2408
|
return;
|
|
2135
2409
|
// A resume can only come from the LIVE instance — align with the store's
|
|
2136
2410
|
// current generation so this turn's writes pass the stale-stream gate.
|
|
2137
2411
|
generationRef.current = getMessageStore(storageKey).generation;
|
|
2412
|
+
const resumeGeneration = generationRef.current;
|
|
2138
2413
|
// Show the spinner on the last assistant message immediately
|
|
2139
2414
|
setMessages((prev) => prev.map((m) => (m.id === resumeId ? { ...m, isStreaming: true } : m)));
|
|
2140
2415
|
sendingRef.current = true;
|
|
@@ -2243,25 +2518,36 @@ export function useChat(options) {
|
|
|
2243
2518
|
// Best-effort — the page-lifecycle reconcile or a manual reload recovers.
|
|
2244
2519
|
}
|
|
2245
2520
|
}
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2521
|
+
if (getMessageStore(storageKey).generation !== resumeGeneration)
|
|
2522
|
+
return;
|
|
2523
|
+
// The regular send path drains the remaining queue and retains durable IDs.
|
|
2524
|
+
const current = pendingRef.current[0];
|
|
2525
|
+
if (current &&
|
|
2526
|
+
mountedRef.current &&
|
|
2527
|
+
!userAbortedRef.current &&
|
|
2528
|
+
!getMessageStore(storageKey).stoppedByUser) {
|
|
2529
|
+
if (!current.durable)
|
|
2530
|
+
pendingRef.current.shift();
|
|
2251
2531
|
// A queued send takes over the conversation — don't also re-resume.
|
|
2252
2532
|
resumeAgain = null;
|
|
2253
2533
|
setError(null);
|
|
2254
2534
|
setErrorMeta(null);
|
|
2255
2535
|
persistQueue(storageKey, pendingRef.current);
|
|
2256
2536
|
sendingRef.current = false;
|
|
2257
|
-
|
|
2258
|
-
|
|
2537
|
+
setIsLoading(false);
|
|
2538
|
+
await sendMessageRef.current(current.message, current.attachments, {
|
|
2539
|
+
messageId: current.userMsgId,
|
|
2540
|
+
suppressUserMessage: current.suppressUserMessage,
|
|
2541
|
+
automatic: current.automatic,
|
|
2542
|
+
userInitiated: current.userInitiated,
|
|
2543
|
+
viaDictation: current.viaDictation,
|
|
2544
|
+
});
|
|
2259
2545
|
}
|
|
2260
2546
|
sendingRef.current = false;
|
|
2261
2547
|
setIsLoading(false);
|
|
2262
2548
|
if (!unloadingRef.current) {
|
|
2263
2549
|
clearStreamingFlag(storageKey);
|
|
2264
|
-
persistQueue(storageKey,
|
|
2550
|
+
persistQueue(storageKey, pendingRef.current);
|
|
2265
2551
|
}
|
|
2266
2552
|
// Dropped again while the server still streams — schedule another resume
|
|
2267
2553
|
// after this one fully unwinds (each cycle requires a fresh real drop, so
|
|
@@ -2302,6 +2588,7 @@ export function useChat(options) {
|
|
|
2302
2588
|
// (see the sendMessage guard), and end any remote-turn tracking — the server
|
|
2303
2589
|
// stream is being killed below, so the Stop control can retire immediately.
|
|
2304
2590
|
getMessageStore(storageKey).stoppedByUser = true;
|
|
2591
|
+
getMessageStore(storageKey).userIntentRevision++;
|
|
2305
2592
|
stopRemoteStreamPoll(storageKey);
|
|
2306
2593
|
// Drop any remote accumulator outright — the blanket un-stream map below
|
|
2307
2594
|
// already marks its message aborted.
|
|
@@ -2330,39 +2617,78 @@ export function useChat(options) {
|
|
|
2330
2617
|
}
|
|
2331
2618
|
pendingFlushFnRef.current?.();
|
|
2332
2619
|
pendingFlushFnRef.current = null;
|
|
2333
|
-
pendingRef.current
|
|
2620
|
+
pendingRef.current = pendingRef.current.filter((entry) => entry.durable);
|
|
2334
2621
|
sendingRef.current = false;
|
|
2335
2622
|
clearStreamingFlag(storageKey);
|
|
2336
|
-
persistQueue(storageKey,
|
|
2623
|
+
persistQueue(storageKey, pendingRef.current);
|
|
2337
2624
|
setIsLoading(false);
|
|
2338
2625
|
setMessages((prev) => prev.map((m) => (m.isStreaming ? { ...m, isStreaming: false, aborted: true } : m)));
|
|
2339
2626
|
}, [provider, endpoint, projectId, storageKey]);
|
|
2340
2627
|
const clearHistory = useCallback(async () => {
|
|
2628
|
+
const clearGeneration = getMessageStore(storageKey).generation;
|
|
2341
2629
|
pendingRef.current.length = 0;
|
|
2342
2630
|
// Drop any pending 5XX auto-retry — the conversation is being cleared.
|
|
2343
2631
|
resetRetry();
|
|
2344
2632
|
clearStreamingFlag(storageKey);
|
|
2345
2633
|
persistQueue(storageKey, []);
|
|
2346
2634
|
await provider.clearHistory(config);
|
|
2347
|
-
if (mountedRef.current) {
|
|
2635
|
+
if (mountedRef.current && getMessageStore(storageKey).generation === clearGeneration) {
|
|
2348
2636
|
setMessages([]);
|
|
2637
|
+
getMessageStore(storageKey).backgroundTasks = [];
|
|
2638
|
+
getMessageStore(storageKey).deliveredQueueIds.clear();
|
|
2639
|
+
emitStore(storageKey);
|
|
2349
2640
|
setError(null);
|
|
2350
2641
|
setErrorMeta(null);
|
|
2351
2642
|
}
|
|
2352
2643
|
}, [provider, endpoint]);
|
|
2353
|
-
const editQueuedMessage = useCallback((msgId, newContent) => {
|
|
2644
|
+
const editQueuedMessage = useCallback(async (msgId, newContent) => {
|
|
2354
2645
|
const entry = pendingRef.current.find((e) => e.userMsgId === msgId);
|
|
2646
|
+
const generation = getMessageStore(storageKey).generation;
|
|
2647
|
+
if (entry?.durable) {
|
|
2648
|
+
if (!provider.editQueuedMessage)
|
|
2649
|
+
return;
|
|
2650
|
+
try {
|
|
2651
|
+
await provider.editQueuedMessage(msgId, newContent, config);
|
|
2652
|
+
}
|
|
2653
|
+
catch (error) {
|
|
2654
|
+
setError(error instanceof Error
|
|
2655
|
+
? error.message
|
|
2656
|
+
: t('chat.error.sendFailed', undefined, { defaultValue: 'Failed to send message' }));
|
|
2657
|
+
return;
|
|
2658
|
+
}
|
|
2659
|
+
if (getMessageStore(storageKey).generation !== generation)
|
|
2660
|
+
return;
|
|
2661
|
+
if (!pendingRef.current.some((item) => item.userMsgId === msgId))
|
|
2662
|
+
return;
|
|
2663
|
+
}
|
|
2355
2664
|
if (entry) {
|
|
2356
2665
|
entry.message = newContent;
|
|
2666
|
+
if (entry.durable)
|
|
2667
|
+
entry.accepted = true;
|
|
2357
2668
|
persistQueue(storageKey, pendingRef.current);
|
|
2358
2669
|
}
|
|
2359
2670
|
setMessages((prev) => prev.map((m) => (m.id === msgId && m.queued ? { ...m, content: newContent } : m)));
|
|
2360
|
-
}, [storageKey]);
|
|
2361
|
-
const deleteQueuedMessage = useCallback((msgId) => {
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2671
|
+
}, [provider, endpoint, storageKey]);
|
|
2672
|
+
const deleteQueuedMessage = useCallback(async (msgId) => {
|
|
2673
|
+
const entry = pendingRef.current.find((item) => item.userMsgId === msgId);
|
|
2674
|
+
const generation = getMessageStore(storageKey).generation;
|
|
2675
|
+
if (entry?.durable) {
|
|
2676
|
+
if (!provider.deleteQueuedMessage)
|
|
2677
|
+
return;
|
|
2678
|
+
try {
|
|
2679
|
+
await provider.deleteQueuedMessage(msgId, config);
|
|
2680
|
+
}
|
|
2681
|
+
catch (error) {
|
|
2682
|
+
setError(error instanceof Error
|
|
2683
|
+
? error.message
|
|
2684
|
+
: t('chat.error.sendFailed', undefined, { defaultValue: 'Failed to send message' }));
|
|
2685
|
+
return;
|
|
2686
|
+
}
|
|
2687
|
+
if (getMessageStore(storageKey).generation !== generation)
|
|
2688
|
+
return;
|
|
2689
|
+
}
|
|
2690
|
+
removeQueuedMessage(storageKey, msgId);
|
|
2691
|
+
}, [provider, endpoint, storageKey]);
|
|
2366
2692
|
/**
|
|
2367
2693
|
* Remove queued auto-fix messages whose content references the given file path.
|
|
2368
2694
|
* Called automatically when the AI writes to a file (file_diff event) and can
|
|
@@ -2373,11 +2699,10 @@ export function useChat(options) {
|
|
|
2373
2699
|
const toRemove = pendingRef.current.filter((e) => e.message.startsWith(AUTOFIX_PREFIX) && e.message.includes(norm));
|
|
2374
2700
|
if (toRemove.length === 0)
|
|
2375
2701
|
return;
|
|
2376
|
-
const
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
}, [storageKey]);
|
|
2702
|
+
for (const entry of toRemove)
|
|
2703
|
+
if (entry.userMsgId)
|
|
2704
|
+
void deleteQueuedMessage(entry.userMsgId);
|
|
2705
|
+
}, [deleteQueuedMessage]);
|
|
2381
2706
|
// Sync clearQueuedForFile ref so stream event handlers can call the latest version
|
|
2382
2707
|
clearQueuedForFileRef.current = clearQueuedForFile;
|
|
2383
2708
|
const exposedSetMode = useCallback((newMode) => {
|