@molecule/app-react 1.6.6 → 1.7.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.
@@ -73,7 +73,7 @@ function persistQueue(projectId, queue) {
73
73
  }
74
74
  }
75
75
  /**
76
- * Load and clear the persisted queue for a project.
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
- setMessages((prev) => (prev.some((m) => m.id === message.id) ? prev : [...prev, message]));
517
- }, [setMessages]);
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 history = await provider.loadHistory({ endpoint, projectId });
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 [backgroundTasks, setBackgroundTasks] = useState([]);
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 = useRef([]);
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
- const history = await provider.loadHistory({ endpoint, projectId });
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: drop the original optimistic bubble first so the re-send's own
975
- // bubble doesn't duplicate it (same content, new id, re-stamped timestamp).
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, target.options);
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
- pendingRef.current.push(...persistedQueue);
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 (persistedQueue.length > 0) {
1196
- const [first, ...rest] = persistedQueue;
1197
- pendingRef.current.push(...rest);
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' && store.streaming)
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: `user-${++idCounterRef.current}`,
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
- pendingRef.current.push({
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
- ...(suppressUserMessage ? { suppressUserMessage } : { userMsgId: userMsg.id }),
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
- ...(suppressUserMessage ? { suppressUserMessage: true } : { userMsgId: userMsg.id }),
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
- // Drain the queue: send next pending message, clearing any prior error
2079
- current = pendingRef.current.shift();
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
- // Drain any queued user messages
2247
- let current = pendingRef.current.shift();
2248
- while (current) {
2249
- if (!mountedRef.current)
2250
- break;
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
- await sendMessageRef.current(current.message, current.attachments);
2258
- current = pendingRef.current.shift();
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.length = 0;
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
- pendingRef.current = pendingRef.current.filter((e) => e.userMsgId !== msgId);
2363
- persistQueue(storageKey, pendingRef.current);
2364
- setMessages((prev) => prev.filter((m) => !(m.id === msgId && m.queued)));
2365
- }, [storageKey]);
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 removeIds = new Set(toRemove.map((e) => e.userMsgId));
2377
- pendingRef.current = pendingRef.current.filter((e) => !removeIds.has(e.userMsgId));
2378
- persistQueue(storageKey, pendingRef.current);
2379
- setMessages((prev) => prev.filter((m) => !(m.queued && removeIds.has(m.id))));
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) => {