@molecule/app-react 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/LICENSE +115 -0
  2. package/README.md +2645 -0
  3. package/dist/agent-identity.d.ts +40 -0
  4. package/dist/agent-identity.d.ts.map +1 -0
  5. package/dist/agent-identity.js +31 -0
  6. package/dist/agent-identity.js.map +1 -0
  7. package/dist/contexts.d.ts +69 -0
  8. package/dist/contexts.d.ts.map +1 -0
  9. package/dist/contexts.js +70 -0
  10. package/dist/contexts.js.map +1 -0
  11. package/dist/hooks/index.d.ts +32 -0
  12. package/dist/hooks/index.d.ts.map +1 -0
  13. package/dist/hooks/index.js +32 -0
  14. package/dist/hooks/index.js.map +1 -0
  15. package/dist/hooks/useAIModels.d.ts +48 -0
  16. package/dist/hooks/useAIModels.d.ts.map +1 -0
  17. package/dist/hooks/useAIModels.js +145 -0
  18. package/dist/hooks/useAIModels.js.map +1 -0
  19. package/dist/hooks/useAsyncState.d.ts +41 -0
  20. package/dist/hooks/useAsyncState.d.ts.map +1 -0
  21. package/dist/hooks/useAsyncState.js +77 -0
  22. package/dist/hooks/useAsyncState.js.map +1 -0
  23. package/dist/hooks/useAuth.d.ts +43 -0
  24. package/dist/hooks/useAuth.d.ts.map +1 -0
  25. package/dist/hooks/useAuth.js +106 -0
  26. package/dist/hooks/useAuth.js.map +1 -0
  27. package/dist/hooks/useCapacitorApp.d.ts +35 -0
  28. package/dist/hooks/useCapacitorApp.d.ts.map +1 -0
  29. package/dist/hooks/useCapacitorApp.js +61 -0
  30. package/dist/hooks/useCapacitorApp.js.map +1 -0
  31. package/dist/hooks/useChangePassword.d.ts +34 -0
  32. package/dist/hooks/useChangePassword.d.ts.map +1 -0
  33. package/dist/hooks/useChangePassword.js +37 -0
  34. package/dist/hooks/useChangePassword.js.map +1 -0
  35. package/dist/hooks/useChat.d.ts +30 -0
  36. package/dist/hooks/useChat.d.ts.map +1 -0
  37. package/dist/hooks/useChat.js +1510 -0
  38. package/dist/hooks/useChat.js.map +1 -0
  39. package/dist/hooks/useDevice.d.ts +39 -0
  40. package/dist/hooks/useDevice.d.ts.map +1 -0
  41. package/dist/hooks/useDevice.js +41 -0
  42. package/dist/hooks/useDevice.js.map +1 -0
  43. package/dist/hooks/useEditor.d.ts +20 -0
  44. package/dist/hooks/useEditor.d.ts.map +1 -0
  45. package/dist/hooks/useEditor.js +98 -0
  46. package/dist/hooks/useEditor.js.map +1 -0
  47. package/dist/hooks/useForm.d.ts +87 -0
  48. package/dist/hooks/useForm.d.ts.map +1 -0
  49. package/dist/hooks/useForm.js +148 -0
  50. package/dist/hooks/useForm.js.map +1 -0
  51. package/dist/hooks/useHttp.d.ts +103 -0
  52. package/dist/hooks/useHttp.d.ts.map +1 -0
  53. package/dist/hooks/useHttp.js +161 -0
  54. package/dist/hooks/useHttp.js.map +1 -0
  55. package/dist/hooks/useLogger.d.ts +68 -0
  56. package/dist/hooks/useLogger.d.ts.map +1 -0
  57. package/dist/hooks/useLogger.js +95 -0
  58. package/dist/hooks/useLogger.js.map +1 -0
  59. package/dist/hooks/useLogin.d.ts +35 -0
  60. package/dist/hooks/useLogin.d.ts.map +1 -0
  61. package/dist/hooks/useLogin.js +37 -0
  62. package/dist/hooks/useLogin.js.map +1 -0
  63. package/dist/hooks/useOAuth.d.ts +83 -0
  64. package/dist/hooks/useOAuth.d.ts.map +1 -0
  65. package/dist/hooks/useOAuth.js +348 -0
  66. package/dist/hooks/useOAuth.js.map +1 -0
  67. package/dist/hooks/usePasswordReset.d.ts +43 -0
  68. package/dist/hooks/usePasswordReset.d.ts.map +1 -0
  69. package/dist/hooks/usePasswordReset.js +50 -0
  70. package/dist/hooks/usePasswordReset.js.map +1 -0
  71. package/dist/hooks/usePlatform.d.ts +38 -0
  72. package/dist/hooks/usePlatform.d.ts.map +1 -0
  73. package/dist/hooks/usePlatform.js +40 -0
  74. package/dist/hooks/usePlatform.js.map +1 -0
  75. package/dist/hooks/usePreview.d.ts +20 -0
  76. package/dist/hooks/usePreview.d.ts.map +1 -0
  77. package/dist/hooks/usePreview.js +62 -0
  78. package/dist/hooks/usePreview.js.map +1 -0
  79. package/dist/hooks/usePromise.d.ts +39 -0
  80. package/dist/hooks/usePromise.d.ts.map +1 -0
  81. package/dist/hooks/usePromise.js +92 -0
  82. package/dist/hooks/usePromise.js.map +1 -0
  83. package/dist/hooks/usePush.d.ts +50 -0
  84. package/dist/hooks/usePush.d.ts.map +1 -0
  85. package/dist/hooks/usePush.js +89 -0
  86. package/dist/hooks/usePush.js.map +1 -0
  87. package/dist/hooks/useRouter.d.ts +65 -0
  88. package/dist/hooks/useRouter.d.ts.map +1 -0
  89. package/dist/hooks/useRouter.js +120 -0
  90. package/dist/hooks/useRouter.js.map +1 -0
  91. package/dist/hooks/useSignup.d.ts +35 -0
  92. package/dist/hooks/useSignup.d.ts.map +1 -0
  93. package/dist/hooks/useSignup.js +37 -0
  94. package/dist/hooks/useSignup.js.map +1 -0
  95. package/dist/hooks/useStorage.d.ts +72 -0
  96. package/dist/hooks/useStorage.d.ts.map +1 -0
  97. package/dist/hooks/useStorage.js +144 -0
  98. package/dist/hooks/useStorage.js.map +1 -0
  99. package/dist/hooks/useStore.d.ts +51 -0
  100. package/dist/hooks/useStore.d.ts.map +1 -0
  101. package/dist/hooks/useStore.js +86 -0
  102. package/dist/hooks/useStore.js.map +1 -0
  103. package/dist/hooks/useTheme.d.ts +50 -0
  104. package/dist/hooks/useTheme.d.ts.map +1 -0
  105. package/dist/hooks/useTheme.js +135 -0
  106. package/dist/hooks/useTheme.js.map +1 -0
  107. package/dist/hooks/useTranslation.d.ts +69 -0
  108. package/dist/hooks/useTranslation.d.ts.map +1 -0
  109. package/dist/hooks/useTranslation.js +131 -0
  110. package/dist/hooks/useTranslation.js.map +1 -0
  111. package/dist/hooks/useVersion.d.ts +41 -0
  112. package/dist/hooks/useVersion.d.ts.map +1 -0
  113. package/dist/hooks/useVersion.js +73 -0
  114. package/dist/hooks/useVersion.js.map +1 -0
  115. package/dist/hooks/useWorkspace.d.ts +18 -0
  116. package/dist/hooks/useWorkspace.d.ts.map +1 -0
  117. package/dist/hooks/useWorkspace.js +67 -0
  118. package/dist/hooks/useWorkspace.js.map +1 -0
  119. package/dist/index.d.ts +80 -0
  120. package/dist/index.d.ts.map +1 -0
  121. package/dist/index.js +80 -0
  122. package/dist/index.js.map +1 -0
  123. package/dist/providers.d.ts +189 -0
  124. package/dist/providers.d.ts.map +1 -0
  125. package/dist/providers.js +248 -0
  126. package/dist/providers.js.map +1 -0
  127. package/dist/types.d.ts +381 -0
  128. package/dist/types.d.ts.map +1 -0
  129. package/dist/types.js +7 -0
  130. package/dist/types.js.map +1 -0
  131. package/package.json +82 -0
@@ -0,0 +1,1510 @@
1
+ /**
2
+ * React hook for AI chat provider.
3
+ *
4
+ * @module
5
+ */
6
+ import { useCallback, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore, } from 'react';
7
+ import { t } from '@molecule/app-i18n';
8
+ import { DEFAULT_AGENT_NAME } from '../agent-identity.js';
9
+ import { ChatContext } from '../contexts.js';
10
+ // ── Session persistence helpers ──────────────────────────────────────────────
11
+ // Persist the message queue and streaming state to sessionStorage so that
12
+ // queued messages survive a page refresh and interrupted streams auto-resume.
13
+ const STORAGE_PREFIX = 'mol-chat-';
14
+ /** Prefix used by auto-fix messages so we can identify them in the queue. */
15
+ const AUTOFIX_PREFIX = 'Fix these issues:';
16
+ /**
17
+ * Base backoff (in seconds) before the first auto-retry of a turn interrupted by
18
+ * a 5XX backend error. The wait doubles each attempt → 5s, 10s, 20s.
19
+ */
20
+ const RETRY_BASE_SECONDS = 5;
21
+ /**
22
+ * Maximum number of auto-retry attempts after a 5XX backend error before the
23
+ * error is surfaced to the user (no more retries).
24
+ */
25
+ const MAX_RETRY_ATTEMPTS = 3;
26
+ /**
27
+ * Persist the pending message queue for a project.
28
+ * @param projectId - The project identifier used as the storage key.
29
+ * @param queue - The message queue entries to persist.
30
+ */
31
+ function persistQueue(projectId, queue) {
32
+ try {
33
+ if (queue.length > 0) {
34
+ sessionStorage.setItem(`${STORAGE_PREFIX}queue-${projectId}`, JSON.stringify(queue));
35
+ }
36
+ else {
37
+ sessionStorage.removeItem(`${STORAGE_PREFIX}queue-${projectId}`);
38
+ }
39
+ }
40
+ catch (_error) {
41
+ // sessionStorage unavailable (SSR, private browsing quota exceeded) — safe to skip persistence
42
+ }
43
+ }
44
+ /**
45
+ * Load and clear the persisted queue for a project.
46
+ * @param projectId - The project identifier used as the storage key.
47
+ * @returns The previously persisted queue entries, or an empty array if none found.
48
+ */
49
+ function loadPersistedQueue(projectId) {
50
+ try {
51
+ const raw = sessionStorage.getItem(`${STORAGE_PREFIX}queue-${projectId}`);
52
+ if (raw) {
53
+ sessionStorage.removeItem(`${STORAGE_PREFIX}queue-${projectId}`);
54
+ return JSON.parse(raw);
55
+ }
56
+ }
57
+ catch (_error) {
58
+ // Ignore parse errors or unavailable storage — fall through to return []
59
+ }
60
+ return [];
61
+ }
62
+ /**
63
+ * Mark a project as actively streaming.
64
+ * @param projectId - The project identifier to flag as streaming.
65
+ */
66
+ function setStreamingFlag(projectId) {
67
+ try {
68
+ sessionStorage.setItem(`${STORAGE_PREFIX}streaming-${projectId}`, '1');
69
+ }
70
+ catch (_error) {
71
+ // sessionStorage unavailable — flag loss is safe; resume detection falls back gracefully
72
+ }
73
+ }
74
+ /**
75
+ * Clear the streaming flag for a project.
76
+ * @param projectId - The project identifier whose streaming flag should be cleared.
77
+ */
78
+ function clearStreamingFlag(projectId) {
79
+ try {
80
+ sessionStorage.removeItem(`${STORAGE_PREFIX}streaming-${projectId}`);
81
+ }
82
+ catch (_error) {
83
+ // sessionStorage unavailable — clearing the flag is best-effort, no functional impact
84
+ }
85
+ }
86
+ /**
87
+ * Check and clear the streaming flag — returns true if a stream was interrupted.
88
+ * @param projectId - The project identifier to check for an interrupted stream.
89
+ * @returns True if the streaming flag was set (indicating an interrupted stream), false otherwise.
90
+ */
91
+ function consumeStreamingFlag(projectId) {
92
+ try {
93
+ const val = sessionStorage.getItem(`${STORAGE_PREFIX}streaming-${projectId}`);
94
+ sessionStorage.removeItem(`${STORAGE_PREFIX}streaming-${projectId}`);
95
+ return val === '1';
96
+ }
97
+ catch (_error) {
98
+ // sessionStorage unavailable — treat as no interrupted stream
99
+ return false;
100
+ }
101
+ }
102
+ /** Stable empty array for the SSR/initial snapshot (useSyncExternalStore needs referential stability). */
103
+ const EMPTY_MESSAGES = [];
104
+ const messageStores = new Map();
105
+ /**
106
+ * Get (creating if needed) the live message store for a conversation key.
107
+ * @param key - The conversation/project storage key.
108
+ * @returns The store for that key.
109
+ */
110
+ function getMessageStore(key) {
111
+ let store = messageStores.get(key);
112
+ if (!store) {
113
+ store = {
114
+ messages: [],
115
+ streaming: false,
116
+ remoteStreaming: false,
117
+ stoppedByUser: false,
118
+ remotePollTimer: null,
119
+ listeners: new Set(),
120
+ generation: 0,
121
+ };
122
+ messageStores.set(key, store);
123
+ }
124
+ return store;
125
+ }
126
+ /**
127
+ * Notify all subscribers of a conversation's store that it changed.
128
+ * @param key - The conversation/project storage key.
129
+ */
130
+ function emitStore(key) {
131
+ const store = messageStores.get(key);
132
+ if (store)
133
+ for (const listener of store.listeners)
134
+ listener();
135
+ }
136
+ /**
137
+ * Replace or update a conversation's messages and notify subscribers. Safe to call
138
+ * at any time — including after the component that started the stream has unmounted.
139
+ * @param key - The conversation/project storage key.
140
+ * @param updater - The next messages array, or a function of the previous array.
141
+ */
142
+ function setStoreMessages(key, updater) {
143
+ const store = getMessageStore(key);
144
+ store.messages = typeof updater === 'function' ? updater(store.messages) : updater;
145
+ emitStore(key);
146
+ }
147
+ /**
148
+ * Set whether a conversation has an active stream, notifying subscribers on change.
149
+ * @param key - The conversation/project storage key.
150
+ * @param streaming - Whether a stream is currently active.
151
+ */
152
+ function setStoreStreaming(key, streaming) {
153
+ const store = getMessageStore(key);
154
+ if (store.streaming !== streaming) {
155
+ store.streaming = streaming;
156
+ emitStore(key);
157
+ }
158
+ }
159
+ /**
160
+ * Set whether a REMOTE backend turn (not owned by this client) is streaming,
161
+ * notifying subscribers on change.
162
+ * @param key - The conversation/project storage key.
163
+ * @param remoteStreaming - Whether a remote backend turn is currently live.
164
+ */
165
+ function setStoreRemoteStreaming(key, remoteStreaming) {
166
+ const store = getMessageStore(key);
167
+ if (store.remoteStreaming !== remoteStreaming) {
168
+ store.remoteStreaming = remoteStreaming;
169
+ emitStore(key);
170
+ }
171
+ }
172
+ /** Stop a store's remote-stream reconcile poll (if any) and clear the flag. */
173
+ function stopRemoteStreamPoll(key) {
174
+ const store = getMessageStore(key);
175
+ if (store.remotePollTimer !== null) {
176
+ clearTimeout(store.remotePollTimer);
177
+ store.remotePollTimer = null;
178
+ }
179
+ setStoreRemoteStreaming(key, false);
180
+ }
181
+ /** Interval between remote-stream reconcile polls. */
182
+ const REMOTE_STREAM_POLL_MS = 3000;
183
+ /** Upper bound on reconcile polls per remote turn (~5 min) so a poll can never leak. */
184
+ const REMOTE_STREAM_POLL_MAX = 100;
185
+ /**
186
+ * Confirm-and-track a remote backend turn. Called when a pushed (broadcast) chat
187
+ * event arrives for the open conversation while this client has no send of its
188
+ * own in flight — evidence that a backend turn is streaming somewhere else
189
+ * (another tab, a teammate, a server-side continuation). Rather than trusting a
190
+ * single sparse event (an own-echo card can arrive just after a local turn
191
+ * ends), the server's history `streaming` flag is polled: the remote flag turns
192
+ * on only once confirmed, stays on while the server reports streaming, and
193
+ * clears when the turn finishes. Single-flight per store; a local send taking
194
+ * over (store.streaming) ends the poll immediately.
195
+ *
196
+ * @param key - The conversation/project storage key.
197
+ * @param isServerStreaming - Reloads history and resolves whether the server
198
+ * reports an active stream for this conversation.
199
+ */
200
+ function startRemoteStreamPoll(key, isServerStreaming) {
201
+ const store = getMessageStore(key);
202
+ if (store.streaming || store.remotePollTimer !== null)
203
+ return;
204
+ const poll = async (iteration) => {
205
+ const s = getMessageStore(key);
206
+ // A local send now owns the streaming state, nobody is subscribed anymore,
207
+ // or the bound is hit — stop tracking.
208
+ if (s.streaming || s.listeners.size === 0 || iteration >= REMOTE_STREAM_POLL_MAX) {
209
+ s.remotePollTimer = null;
210
+ setStoreRemoteStreaming(key, false);
211
+ return;
212
+ }
213
+ let active;
214
+ try {
215
+ active = await isServerStreaming();
216
+ }
217
+ catch (_error) {
218
+ // History fetch failed (transient network) — keep the last known state and
219
+ // let the next poll reconcile; the iteration bound still guarantees an end.
220
+ active = s.remoteStreaming;
221
+ }
222
+ setStoreRemoteStreaming(key, active);
223
+ if (!active) {
224
+ s.remotePollTimer = null;
225
+ return;
226
+ }
227
+ s.remotePollTimer = setTimeout(() => void poll(iteration + 1), REMOTE_STREAM_POLL_MS);
228
+ };
229
+ // Mark the poll as started synchronously (single-flight), then confirm at once.
230
+ store.remotePollTimer = setTimeout(() => void poll(0), 0);
231
+ }
232
+ /**
233
+ * Test-only: clear all conversation stores. The store is module-level (it must
234
+ * outlive component mounts), so it persists across test cases — reset it in a
235
+ * `beforeEach` the same way tests clear `sessionStorage`.
236
+ */
237
+ export function resetChatStoresForTests() {
238
+ for (const store of messageStores.values()) {
239
+ if (store.remotePollTimer !== null)
240
+ clearTimeout(store.remotePollTimer);
241
+ }
242
+ messageStores.clear();
243
+ }
244
+ /**
245
+ * Access the chat provider from context.
246
+ * @returns The ChatProvider instance from the nearest ChatContext.
247
+ * @throws {Error} If called outside a ChatProvider.
248
+ */
249
+ export function useChatProvider() {
250
+ const provider = useContext(ChatContext);
251
+ if (!provider) {
252
+ throw new Error(t('react.error.useChatOutsideProvider', undefined, {
253
+ defaultValue: 'useChatProvider must be used within a ChatProvider',
254
+ }));
255
+ }
256
+ return provider;
257
+ }
258
+ /**
259
+ * Snapshot a streaming context's mutable state into a message-field update.
260
+ *
261
+ * @param ctx - The streaming context to snapshot.
262
+ * @returns Partial message fields to merge.
263
+ */
264
+ function buildCtxUpdate(ctx) {
265
+ return {
266
+ content: ctx.assistantText,
267
+ blocks: [...ctx.blocks],
268
+ toolCalls: [...ctx.toolCalls],
269
+ ...(ctx.loopLimitReached != null ? { loopLimitReached: ctx.loopLimitReached } : {}),
270
+ };
271
+ }
272
+ /**
273
+ * Derive a tool call's terminal status from its output. MUST match the http
274
+ * provider's loadHistory derivation so the live and reloaded tool cards agree
275
+ * (status is not persisted — both sides derive it from `output`).
276
+ *
277
+ * @param output - The tool call's output payload.
278
+ * @returns 'error' if the output looks like an error object, else 'done'.
279
+ */
280
+ function deriveToolStatus(output) {
281
+ return typeof output === 'object' && output !== null && 'error' in output ? 'error' : 'done';
282
+ }
283
+ /**
284
+ * Hook for AI chat with streaming support.
285
+ *
286
+ * Manages message state, sends messages to the backend, and handles
287
+ * SSE streaming responses.
288
+ *
289
+ * @param options - Chat configuration including endpoint URL, project ID, and whether to load history on mount.
290
+ * @returns Chat state and controls: messages, isLoading, error, sendMessage, abort, and clearHistory.
291
+ */
292
+ export function useChat(options) {
293
+ const provider = useChatProvider();
294
+ const { endpoint, projectId, agentName = DEFAULT_AGENT_NAME, loadOnMount = true, onFileChange, onModeChange, onConversationId, onStreamEvent, } = options;
295
+ // Project-scoped key for the message store + sessionStorage. The store is keyed
296
+ // by project (stable across the whole build flow — the conversation id is
297
+ // assigned mid-stream, so keying on it directly would swap the store out from
298
+ // under a live stream). Multi-conversation correctness is handled in the mount
299
+ // effect, which tracks the store's conversation id and resets only on a genuine
300
+ // switch (a DIFFERENT id), not when a new conversation first receives its id.
301
+ const storageKey = projectId ?? 'default';
302
+ // `messages` and `isLoading` are backed by the module-level conversation store
303
+ // (getMessageStore) rather than component state, so they survive remounts and a
304
+ // stream keeps filling them even if this component unmounts mid-turn. Every
305
+ // mounted useChat for the same project subscribes to the same store.
306
+ const subscribeStore = useCallback((onStoreChange) => {
307
+ const store = getMessageStore(storageKey);
308
+ store.listeners.add(onStoreChange);
309
+ return () => {
310
+ store.listeners.delete(onStoreChange);
311
+ };
312
+ }, [storageKey]);
313
+ const messages = useSyncExternalStore(subscribeStore, () => getMessageStore(storageKey).messages, () => EMPTY_MESSAGES);
314
+ const isLoading = useSyncExternalStore(subscribeStore, () => getMessageStore(storageKey).streaming, () => false);
315
+ const isRemoteStreaming = useSyncExternalStore(subscribeStore, () => getMessageStore(storageKey).remoteStreaming, () => false);
316
+ // The store generation this instance last aligned with (stamped by the mount
317
+ // effect and on each send/resume start). Null until first stamped — treated
318
+ // as current. An UNMOUNTED instance's stream callbacks keep this ref frozen
319
+ // at the generation their conversation was on, so once the user switches
320
+ // conversations (store.generation bumps) every late write from the old
321
+ // stream — text flushes, finalize, the trailing setIsLoading(false) — is
322
+ // dropped instead of bleeding into the new conversation's view.
323
+ const generationRef = useRef(null);
324
+ const isStaleGeneration = useCallback(() => generationRef.current !== null &&
325
+ getMessageStore(storageKey).generation !== generationRef.current, [storageKey]);
326
+ const setMessages = useCallback((updater) => {
327
+ if (isStaleGeneration())
328
+ return;
329
+ setStoreMessages(storageKey, updater);
330
+ }, [storageKey, isStaleGeneration]);
331
+ // Append an inline transcript CARD (model / mode / skills / custom notice) as a complete
332
+ // `role:'system'` card-message in the ONE message store — the same store as every other
333
+ // message, so cards interleave by timestamp and there is no separate card array. Used for
334
+ // BOTH this client's own `card` stream events (createMessageStream) AND a teammate's
335
+ // broadcast `card` (ChatPanel's pushed-event path, via the exposed handle). De-duped by the
336
+ // server-assigned id so an echo (own broadcast in a second tab) or a reload-then-broadcast
337
+ // race never doubles a card. A card is a finished item — no streaming/finalize.
338
+ const appendCardMessage = useCallback((id, timestamp, card) => {
339
+ setMessages((prev) => prev.some((m) => m.id === id)
340
+ ? prev
341
+ : [...prev, { id, role: 'system', content: '', timestamp, cardEvent: card }]);
342
+ }, [setMessages]);
343
+ const setIsLoading = useCallback((streaming) => {
344
+ if (isStaleGeneration())
345
+ return;
346
+ setStoreStreaming(storageKey, streaming);
347
+ }, [storageKey, isStaleGeneration]);
348
+ // Pushed (broadcast) chat event arrived for this conversation while no local
349
+ // send is in flight — confirm against the server's streaming flag and track
350
+ // the remote turn so the Stop control stays visible (see startRemoteStreamPoll).
351
+ const noteRemoteStreamEvent = useCallback(() => {
352
+ startRemoteStreamPoll(storageKey, async () => {
353
+ await provider.loadHistory({ endpoint, projectId });
354
+ return provider.isServerStreaming === true;
355
+ });
356
+ }, [provider, endpoint, projectId, storageKey]);
357
+ const [error, setError] = useState(null);
358
+ const [errorMeta, setErrorMeta] = useState(null);
359
+ // Active 5XX backoff-retry countdown, or null when none is pending. When a
360
+ // backend error is a server error (HTTP 5XX) the hook does NOT surface a
361
+ // terminal error — it shows this cancelable countdown and, when it elapses,
362
+ // auto-resumes the interrupted turn "where the user left off". `secondsRemaining`
363
+ // ticks down once per second; `attempt` is the 1-based retry number.
364
+ const [retryCountdown, setRetryCountdown] = useState(null);
365
+ const [mode, setMode] = useState('execute');
366
+ // Fast/priority speed tier — server-persisted per conversation, hydrated from
367
+ // the history load's `fastMode` meta field (same channel as `mode`).
368
+ const [fastMode, setFastMode] = useState(false);
369
+ // Transient label for a background phase (e.g. the verification pass) surfaced
370
+ // by `status` stream events. Shown in place of the spinner's rotating messages
371
+ // so the user sees the current step; cleared (null) by the server's status
372
+ // event when the phase ends, and defensively on send/stream-end.
373
+ const [streamingStatus, setStreamingStatus] = useState(null);
374
+ // Pace status labels. A small change's verification (type-check + lint over a
375
+ // couple of files) finishes in well under a second, so its step labels were
376
+ // emitted in a rapid burst — they flashed by unreadably, or React batched the
377
+ // burst down to just the final clear and the spinner showed no text at all.
378
+ // Queue them and surface each for a minimum dwell so the progression is legible
379
+ // regardless of how fast (or how batched) they arrive.
380
+ const STATUS_MIN_DWELL_MS = 650;
381
+ const statusQueueRef = useRef([]);
382
+ const statusTimerRef = useRef(null);
383
+ const pumpStatusRef = useRef(() => { });
384
+ pumpStatusRef.current = () => {
385
+ const next = statusQueueRef.current.shift();
386
+ if (next === undefined) {
387
+ statusTimerRef.current = null;
388
+ return;
389
+ }
390
+ setStreamingStatus(next);
391
+ // Non-null labels dwell so they're readable; a null (phase end) clears at once.
392
+ statusTimerRef.current = setTimeout(() => pumpStatusRef.current(), next === null ? 0 : STATUS_MIN_DWELL_MS);
393
+ };
394
+ const enqueueStatus = useCallback((label) => {
395
+ const q = statusQueueRef.current;
396
+ if (q.length > 0 && q[q.length - 1] === label)
397
+ return; // collapse consecutive dups
398
+ // Collapse a pending clear that's immediately superseded by a real label.
399
+ // runVerification emits a trailing `null` before the runtime-probe phase
400
+ // re-labels ("Checking the server responds", …); without this the queued
401
+ // null would still get its turn and blank the spinner for one dwell between
402
+ // phases. A standalone trailing null (genuine end of phase) is preserved.
403
+ if (label !== null && q.length > 0 && q[q.length - 1] === null)
404
+ q.pop();
405
+ q.push(label);
406
+ if (!statusTimerRef.current)
407
+ pumpStatusRef.current();
408
+ }, []);
409
+ const resetStatusQueue = useCallback(() => {
410
+ if (statusTimerRef.current) {
411
+ clearTimeout(statusTimerRef.current);
412
+ statusTimerRef.current = null;
413
+ }
414
+ statusQueueRef.current = [];
415
+ setStreamingStatus(null);
416
+ }, []);
417
+ useEffect(() => () => {
418
+ if (statusTimerRef.current)
419
+ clearTimeout(statusTimerRef.current);
420
+ }, []);
421
+ const mountedRef = useRef(true);
422
+ const idCounterRef = useRef(0);
423
+ // Capture loadOnMount at mount time — prevents mid-session flips
424
+ // (e.g. initialMessage consumed → loadOnMount becomes true → history
425
+ // load overwrites streaming messages).
426
+ const loadOnMountRef = useRef(loadOnMount);
427
+ // Queue for messages sent while a request is already in-flight
428
+ const sendingRef = useRef(false);
429
+ const pendingRef = useRef([]);
430
+ // Set when the user clicks Stop, so the post-send reconcile does NOT overwrite
431
+ // the locally-finalized streamed content with server history. The abort()
432
+ // callback already finalizes the streaming message (keeping every streamed
433
+ // char + tool card, flagged aborted); reloading history here could otherwise
434
+ // win a race against the server's own post-abort persist and momentarily wipe
435
+ // the streamed turn (C4 — "everything streamed disappeared, returned on refresh").
436
+ const userAbortedRef = useRef(false);
437
+ // Stable ref to the latest sendMessage so effects can call it without dep issues
438
+ const sendMessageRef = useRef(() => Promise.resolve());
439
+ // Stable ref to clearQueuedForFile so stream event handlers can call it
440
+ const clearQueuedForFileRef = useRef(() => { });
441
+ // Track page unload so the streaming flag isn't cleared during refresh.
442
+ // On refresh the browser aborts the in-flight fetch, which causes the
443
+ // sendMessage while-loop to exit and run cleanup — clearing the flag
444
+ // before the new page can read it. beforeunload fires synchronously
445
+ // before the abort microtask, so the ref is set in time.
446
+ const unloadingRef = useRef(false);
447
+ // ── Throttled flush for streaming message updates ─────────────────────────
448
+ // Instead of calling setMessages on every SSE event (130+ per response, each
449
+ // doing an O(n) array scan + React reconciliation), we accumulate the latest
450
+ // mutable state in a ref and flush to React on a 50ms throttle.
451
+ const flushTimerRef = useRef(null);
452
+ // Stores a function that, when called, performs the setMessages update for
453
+ // the current streaming message. Replaced each time a new stream starts.
454
+ const pendingFlushFnRef = useRef(null);
455
+ /**
456
+ * Build a flush-scheduling toolkit for a single streaming message.
457
+ *
458
+ * Returns `{ scheduleFlush, flushNow }` that share the same timer.
459
+ * - `scheduleFlush(buildUpdate)` — stores the update builder and schedules a
460
+ * 50ms debounced flush (no-op if one is already pending).
461
+ * - `flushNow(buildUpdate?)` — cancels any pending timer and flushes
462
+ * immediately. If `buildUpdate` is provided it replaces the stored one first.
463
+ *
464
+ * `buildUpdate` is a function `(msg: ChatMessage) => Partial<ChatMessage>`
465
+ * that returns the fields to merge into the streaming message.
466
+ */
467
+ const createFlushScheduler = useMemo(() => (targetId) => {
468
+ const scheduleFlush = (buildUpdate) => {
469
+ // Always store the latest builder so when the timer fires we use the
470
+ // most recent mutable state.
471
+ pendingFlushFnRef.current = () => {
472
+ setMessages((prev) => prev.map((m) => (m.id === targetId ? { ...m, ...buildUpdate(m) } : m)));
473
+ };
474
+ if (flushTimerRef.current === null) {
475
+ flushTimerRef.current = setTimeout(() => {
476
+ flushTimerRef.current = null;
477
+ pendingFlushFnRef.current?.();
478
+ pendingFlushFnRef.current = null;
479
+ }, 50);
480
+ }
481
+ };
482
+ const flushNow = (buildUpdate) => {
483
+ if (flushTimerRef.current !== null) {
484
+ clearTimeout(flushTimerRef.current);
485
+ flushTimerRef.current = null;
486
+ }
487
+ if (buildUpdate) {
488
+ pendingFlushFnRef.current = () => {
489
+ setMessages((prev) => prev.map((m) => (m.id === targetId ? { ...m, ...buildUpdate(m) } : m)));
490
+ };
491
+ }
492
+ pendingFlushFnRef.current?.();
493
+ pendingFlushFnRef.current = null;
494
+ };
495
+ return { scheduleFlush, flushNow };
496
+ }, []);
497
+ // Clean up flush timer on unmount
498
+ useEffect(() => {
499
+ return () => {
500
+ if (flushTimerRef.current !== null) {
501
+ clearTimeout(flushTimerRef.current);
502
+ flushTimerRef.current = null;
503
+ }
504
+ };
505
+ }, []);
506
+ useEffect(() => {
507
+ mountedRef.current = true;
508
+ const onBeforeUnload = () => {
509
+ unloadingRef.current = true;
510
+ // Kill the server-side stream on page refresh/close so it doesn't
511
+ // continue running in the background. Uses sendBeacon so it works
512
+ // even during unload.
513
+ if (sendingRef.current) {
514
+ const p = provider;
515
+ p.abortOnServer?.({ endpoint, projectId }, undefined);
516
+ }
517
+ };
518
+ window.addEventListener('beforeunload', onBeforeUnload);
519
+ return () => {
520
+ mountedRef.current = false;
521
+ window.removeEventListener('beforeunload', onBeforeUnload);
522
+ };
523
+ }, [provider, endpoint, projectId, storageKey]);
524
+ const config = {
525
+ endpoint,
526
+ projectId,
527
+ };
528
+ // Ref for the resume function so the mount effect can call it.
529
+ // Accepts the assistant message ID to resume into (avoids state timing issues).
530
+ const resumeStreamRef = useRef(() => Promise.resolve());
531
+ // ── Backoff auto-retry machinery ──────────────────────────────────────────
532
+ // When a stream `error` event reports an HTTP 5XX status OR a transport-layer
533
+ // drop (the connection died — server restart/crash, network blip — flagged
534
+ // `transport` by the provider), we don't surface a terminal error — we show a
535
+ // cancelable, once-per-second countdown and then either resume the turn
536
+ // "where the user left off" (provider.sendMessage('', { resume: true })) or,
537
+ // when the turn never started server-side, re-send the original message (see
538
+ // RetryTarget). 4XX, limit/quota, and signup-required errors never auto-retry.
539
+ // Bounded to MAX_RETRY_ATTEMPTS.
540
+ // Retries already performed for the CURRENT incident. Reset to 0 on a clean
541
+ // `done`, a new send, an abort, a clearHistory, or a cancel.
542
+ const retryAttemptRef = useRef(0);
543
+ // The live countdown interval handle (ticks once/second). Cleared on fire,
544
+ // cancel, reset, and unmount so no interval ever leaks.
545
+ const retryIntervalRef = useRef(null);
546
+ // Mirror of the countdown value driven by the interval, so the tick can decide
547
+ // when to fire WITHOUT scheduling a side effect inside a state updater.
548
+ const retrySecondsRef = useRef(0);
549
+ // What to do when the countdown elapses — resume the in-flight turn, or
550
+ // re-send a message the server never received (see RetryTarget).
551
+ const retryTargetRef = useRef(null);
552
+ // The original error message to surface if the user cancels the countdown or
553
+ // the retry budget is exhausted — so they always see WHY the turn failed.
554
+ const pendingRetryErrorRef = useRef(null);
555
+ /** Stop the countdown interval, if one is running. */
556
+ const clearRetryTimers = useCallback(() => {
557
+ if (retryIntervalRef.current !== null) {
558
+ clearInterval(retryIntervalRef.current);
559
+ retryIntervalRef.current = null;
560
+ }
561
+ }, []);
562
+ /**
563
+ * Clear any pending retry WITHOUT surfacing the error — used when the user
564
+ * moves on (sends a new message, aborts, or clears history) so a stale retry
565
+ * and its interval never leak into the next turn.
566
+ */
567
+ const resetRetry = useCallback(() => {
568
+ clearRetryTimers();
569
+ retryAttemptRef.current = 0;
570
+ retryTargetRef.current = null;
571
+ pendingRetryErrorRef.current = null;
572
+ setRetryCountdown(null);
573
+ }, [clearRetryTimers]);
574
+ /**
575
+ * Fire the scheduled retry: stop the countdown and either resume the
576
+ * interrupted turn "where the user left off" (resume:true) or re-send the
577
+ * original message when the turn never started server-side (see RetryTarget —
578
+ * resuming then would drop the never-persisted user message). The retry budget
579
+ * (retryAttemptRef) is intentionally NOT reset here — a retry that fails again
580
+ * re-arms the backoff up to the attempt cap.
581
+ */
582
+ const fireRetry = useCallback(() => {
583
+ clearRetryTimers();
584
+ setRetryCountdown(null);
585
+ const target = retryTargetRef.current;
586
+ retryTargetRef.current = null;
587
+ if (!target || !mountedRef.current)
588
+ return;
589
+ if (target.kind === 'resume') {
590
+ resumeStreamRef.current(target.id, target.content);
591
+ return;
592
+ }
593
+ // Re-send: drop the original optimistic bubble first so the re-send's own
594
+ // bubble doesn't duplicate it (same content, new id, re-stamped timestamp).
595
+ if (target.userMsgId) {
596
+ const uid = target.userMsgId;
597
+ setMessages((prev) => prev.filter((m) => m.id !== uid));
598
+ }
599
+ void sendMessageRef.current(target.message, target.attachments, target.options);
600
+ }, [clearRetryTimers]);
601
+ /**
602
+ * Schedule a cancelable, once-per-second countdown that auto-resumes the turn
603
+ * when it elapses. Backoff curve: 5s, 10s, 20s (exponential, base 5s) for
604
+ * attempts 1, 2, 3. Returns false (without scheduling) once the attempt cap is
605
+ * reached, so the caller surfaces the terminal error instead.
606
+ * @param target - What the retry should do — resume the interrupted turn, or
607
+ * re-send a message the server never received.
608
+ * @param message - The error to surface if the user cancels or the budget runs out.
609
+ * @returns True if a retry was scheduled; false if the budget is exhausted.
610
+ */
611
+ const scheduleRetry = useCallback((target, message) => {
612
+ if (retryAttemptRef.current >= MAX_RETRY_ATTEMPTS)
613
+ return false;
614
+ const attempt = retryAttemptRef.current + 1; // 1-based
615
+ retryAttemptRef.current = attempt;
616
+ retryTargetRef.current = target;
617
+ pendingRetryErrorRef.current = message;
618
+ const waitSeconds = RETRY_BASE_SECONDS * 2 ** (attempt - 1); // 5, 10, 20
619
+ retrySecondsRef.current = waitSeconds;
620
+ setRetryCountdown({ secondsRemaining: waitSeconds, attempt });
621
+ clearRetryTimers();
622
+ retryIntervalRef.current = setInterval(() => {
623
+ retrySecondsRef.current -= 1;
624
+ if (retrySecondsRef.current <= 0) {
625
+ fireRetry();
626
+ }
627
+ else {
628
+ setRetryCountdown({ secondsRemaining: retrySecondsRef.current, attempt });
629
+ }
630
+ }, 1000);
631
+ return true;
632
+ }, [clearRetryTimers, fireRetry]);
633
+ /**
634
+ * Decide how to handle a stream `error` event: auto-retry on a 5XX backend
635
+ * error OR a transport-layer drop (start the cancelable countdown + resume or
636
+ * re-send the turn), or surface a terminal error for everything else — a 4XX,
637
+ * a limit/quota gate, a signup-required error, a server-emitted error with no
638
+ * status, or an exhausted budget.
639
+ *
640
+ * A 5XX covers the server saying "try again" (overload shed, and the
641
+ * shutdown-drain handoff a deploying instance sends before closing its
642
+ * streams). `transport` covers the server never getting to say anything — the
643
+ * connection died (crash, kill, network blip); the provider flags those
644
+ * explicitly so a server-emitted terminal error (also status-less) is never
645
+ * mistaken for one. A user Stop never produces an error event (the provider
646
+ * swallows its own AbortError), but guard on userAbortedRef anyway.
647
+ *
648
+ * @param event - The error stream event (carries an optional HTTP `status`
649
+ * and the provider's `transport` flag).
650
+ * @param target - What a scheduled retry should do (resume vs re-send).
651
+ */
652
+ const handleStreamError = useCallback((event, target) => {
653
+ const isServerError = typeof event.status === 'number' && event.status >= 500 && event.status < 600;
654
+ const isTransportDrop = event.transport === true && !userAbortedRef.current;
655
+ // Only a 5XX or a transport drop, and never a limit/quota gate or a
656
+ // signup-required error.
657
+ const retryable = (isServerError || isTransportDrop) && !event.limitType && !event.requiresSignup;
658
+ if (retryable && scheduleRetry(target, event.message))
659
+ return;
660
+ // Not retryable (or the budget is spent) — surface the terminal error and
661
+ // reset the budget so a future, independent failure starts fresh.
662
+ retryAttemptRef.current = 0;
663
+ setError(event.message);
664
+ setErrorMeta(event.limitType
665
+ ? { limitType: event.limitType, requiresSignup: event.requiresSignup }
666
+ : null);
667
+ }, [scheduleRetry]);
668
+ /**
669
+ * Cancel a pending auto-retry (user-initiated) and surface the original error
670
+ * (via `error`) so the user sees why the turn failed.
671
+ */
672
+ const cancelRetry = useCallback(() => {
673
+ clearRetryTimers();
674
+ const message = pendingRetryErrorRef.current;
675
+ if (message !== null) {
676
+ setError(message);
677
+ setErrorMeta(null);
678
+ }
679
+ retryAttemptRef.current = 0;
680
+ retryTargetRef.current = null;
681
+ pendingRetryErrorRef.current = null;
682
+ setRetryCountdown(null);
683
+ }, [clearRetryTimers]);
684
+ // Clear the countdown interval on unmount so no interval ever leaks.
685
+ useEffect(() => clearRetryTimers, [clearRetryTimers]);
686
+ // Load history on mount and restore any persisted queue / interrupted stream
687
+ useEffect(() => {
688
+ // ── Conversation-switch detection — SYNCHRONOUS, before any async load ──
689
+ // A switch = the endpoint names a DIFFERENT conversation than the store
690
+ // currently holds. A brand-new conversation that just received its id
691
+ // (store id undefined → defined) is NOT a switch — adopt the id and keep
692
+ // the live messages, so the in-flight discovery/plan stream isn't wiped
693
+ // the moment the conversation is created server-side.
694
+ //
695
+ // On a genuine switch the PREVIOUS conversation's still-running stream must
696
+ // stop writing into this project-keyed store immediately — bumping the
697
+ // generation orphans its writes (see MessageStore.generation) — and the
698
+ // store's leftover transcript + streaming state belong to the old
699
+ // conversation, so clear them before the new history hydrates. Done
700
+ // synchronously (not inside the history .then()) so a slow or failed
701
+ // history fetch can't leave the old stream bleeding into the new view.
702
+ const syncStore = getMessageStore(storageKey);
703
+ const endpointConvId = endpoint.match(/conversationId=([^&]+)/)?.[1];
704
+ const isSwitch = !!endpointConvId && !!syncStore.conversationId && endpointConvId !== syncStore.conversationId;
705
+ if (endpointConvId)
706
+ syncStore.conversationId = endpointConvId;
707
+ if (isSwitch) {
708
+ syncStore.generation++;
709
+ // The old conversation's stop decision doesn't apply to the new one.
710
+ syncStore.stoppedByUser = false;
711
+ stopRemoteStreamPoll(storageKey);
712
+ setStoreStreaming(storageKey, false);
713
+ setStoreMessages(storageKey, []);
714
+ }
715
+ generationRef.current = syncStore.generation;
716
+ if (!loadOnMountRef.current)
717
+ return;
718
+ // Read persisted state synchronously before the async history fetch
719
+ const persistedQueue = loadPersistedQueue(storageKey);
720
+ const interrupted = consumeStreamingFlag(storageKey);
721
+ provider
722
+ .loadHistory(config)
723
+ .then((history) => {
724
+ if (!mountedRef.current)
725
+ return;
726
+ // Restore mode from server (persisted in conversation.aiContext.mode).
727
+ // Idempotent + cheap, so do it regardless of store state. The http bond
728
+ // surfaces app-specific GET fields generically via `lastMeta` (it no
729
+ // longer names the plan/execute vocabulary — molecule anti-pattern 14).
730
+ const serverMode = provider.lastMeta?.mode;
731
+ if (serverMode && serverMode !== 'execute') {
732
+ setMode(serverMode);
733
+ onModeChange?.(serverMode);
734
+ }
735
+ // Restore the conversation's fast-mode flag the same way (servers
736
+ // without the field leave the default `false`).
737
+ const serverFastMode = provider.lastMeta
738
+ ?.fastMode;
739
+ if (typeof serverFastMode === 'boolean')
740
+ setFastMode(serverFastMode);
741
+ // Multi-conversation handling: the switch itself was detected + applied
742
+ // SYNCHRONOUSLY at effect start (see above) — here we only hydrate.
743
+ //
744
+ // Same conversation with a live/populated store (e.g. the IDE ChatPanel
745
+ // mounting after the boot panel, or a re-render): the store is the source
746
+ // of truth — do NOT overwrite with server history (that would wipe
747
+ // in-flight streaming messages) and do NOT resume (the original stream is
748
+ // still writing to the store). Hydrate + resume only when the store is
749
+ // empty (first mount / after refresh) or on a real switch (whose store
750
+ // was already cleared synchronously).
751
+ // (On a switch the store was cleared synchronously — a populated store
752
+ // here means a NEW local send already started in the switched-to
753
+ // conversation while history was loading; don't clobber it either.)
754
+ if (syncStore.messages.length > 0 && (!isSwitch || syncStore.streaming))
755
+ return;
756
+ if (history.length > 0) {
757
+ setMessages(history);
758
+ }
759
+ // Also check the provider's streaming flag — the server tells us
760
+ // directly whether a stream is active, even if sessionStorage was lost
761
+ const serverStreaming = provider.isServerStreaming === true;
762
+ const shouldResume = (interrupted || serverStreaming) && history.length > 0;
763
+ if (shouldResume) {
764
+ // Stream was interrupted — resume into the last assistant message,
765
+ // or create a new placeholder if the last message is from the user
766
+ // (the server hadn't saved the assistant response yet).
767
+ const lastMsg = history[history.length - 1];
768
+ let resumeTarget;
769
+ if (lastMsg?.role === 'assistant') {
770
+ resumeTarget = { id: lastMsg.id, content: lastMsg.content };
771
+ }
772
+ else {
773
+ // No assistant message after the last user message — create one
774
+ const placeholderId = `assistant-${++idCounterRef.current}`;
775
+ const placeholder = {
776
+ id: placeholderId,
777
+ role: 'assistant',
778
+ content: '',
779
+ timestamp: Date.now(),
780
+ isStreaming: true,
781
+ blocks: [],
782
+ };
783
+ setMessages((prev) => [...prev, placeholder]);
784
+ resumeTarget = { id: placeholderId, content: '' };
785
+ }
786
+ pendingRef.current.push(...persistedQueue);
787
+ const target = resumeTarget;
788
+ setTimeout(() => {
789
+ if (mountedRef.current) {
790
+ resumeStreamRef.current(target.id, target.content);
791
+ }
792
+ }, 0);
793
+ }
794
+ else if (persistedQueue.length > 0) {
795
+ const [first, ...rest] = persistedQueue;
796
+ pendingRef.current.push(...rest);
797
+ setTimeout(() => {
798
+ if (mountedRef.current) {
799
+ sendMessageRef.current(first.message, first.attachments);
800
+ }
801
+ }, 0);
802
+ }
803
+ })
804
+ .catch(() => {
805
+ // History load failure is not critical
806
+ });
807
+ }, [endpoint]);
808
+ // Build a per-stream event handler that materializes the SAME per-message structure
809
+ // the server persists: each `message_start` finalizes the previous message and opens a
810
+ // new one (with the server's id + timestamp); every subsequent stream item routes into
811
+ // the current message. Shared by sendMessage AND resumeStream so the two paths cannot
812
+ // diverge (the historical #1 risk). Per-invocation concerns (the stall watchdog, the
813
+ // terminal-received flag) are injected via `deps`.
814
+ const createMessageStream = (deps) => {
815
+ let currentCtx = null;
816
+ // Whether ANY server event arrived on this stream (the server's first event
817
+ // is `conversation`, sent right after it persists the user message). Decides
818
+ // resume-vs-resend on error: seen → the turn started server-side → resume;
819
+ // not seen → nothing was persisted → re-send.
820
+ let sawServerEvent = false;
821
+ const finalizeCurrent = () => {
822
+ const ctx = currentCtx;
823
+ if (!ctx)
824
+ return;
825
+ currentCtx = null;
826
+ // A truly-empty turn (no text, no blocks, no tool calls, no loop-limit notice) is
827
+ // a ghost — drop it. The server filters the same empty message from persistence,
828
+ // so live === stored.
829
+ if (!ctx.assistantText &&
830
+ ctx.blocks.length === 0 &&
831
+ ctx.toolCalls.length === 0 &&
832
+ ctx.loopLimitReached == null) {
833
+ ctx.flushNow(); // cancel any pending flush bound to this id
834
+ setMessages((prev) => prev.filter((m) => m.id !== ctx.id));
835
+ return;
836
+ }
837
+ ctx.flushNow(() => ({ ...buildCtxUpdate(ctx), isStreaming: false }));
838
+ };
839
+ const startMessage = (id, timestamp) => {
840
+ // Flush + finalize the OUTGOING message BEFORE re-pointing the shared flush timer,
841
+ // so its last batched text isn't overwritten and lost at the boundary (EC-3).
842
+ finalizeCurrent();
843
+ const { scheduleFlush, flushNow } = createFlushScheduler(id);
844
+ currentCtx = { id, assistantText: '', blocks: [], toolCalls: [], scheduleFlush, flushNow };
845
+ setMessages((prev) => [
846
+ ...prev,
847
+ { id, role: 'assistant', content: '', timestamp, isStreaming: true, blocks: [] },
848
+ ]);
849
+ };
850
+ // The store generation this stream belongs to. If the user switches
851
+ // conversations mid-stream (new chat / picker), the mount effect bumps the
852
+ // store's generation and every remaining event of THIS stream is dropped
853
+ // wholesale — text, cards, mode flips, the conversation-id report, and the
854
+ // parent forward all belong to the old conversation's view. The turn still
855
+ // completes server-side; switching back reloads it from history.
856
+ const streamGeneration = getMessageStore(storageKey).generation;
857
+ const onEvent = (event) => {
858
+ if (getMessageStore(storageKey).generation !== streamGeneration)
859
+ return;
860
+ deps.resetStall();
861
+ // Any non-error event proves the server accepted the turn (see
862
+ // sawServerEvent) — an error can then safely retry as a resume.
863
+ if (event.type !== 'error')
864
+ sawServerEvent = true;
865
+ // Forward EVERY event to the parent (Workspace/ChatPanel) — it derives cards
866
+ // (model/mode), fires client_action, etc. Not gated on mountedRef: events write to
867
+ // the conversation store, which must keep filling across a boot→IDE remount.
868
+ onStreamEvent?.(event);
869
+ // ── Structural / ctx-independent events ──
870
+ switch (event.type) {
871
+ case 'message_start':
872
+ startMessage(event.id, event.timestamp);
873
+ return;
874
+ case 'card':
875
+ // A complete inline card (model / mode / skills / custom) — append it to the ONE
876
+ // message store as a card-message; it does NOT belong to the current streaming
877
+ // message (it interleaves by its own server timestamp). Recorded + persisted
878
+ // server-side, so this is byte-identical to what loadHistory returns on reload.
879
+ appendCardMessage(event.id, event.timestamp, event.card);
880
+ return;
881
+ case 'mode':
882
+ setMode(event.mode);
883
+ onModeChange?.(event.mode);
884
+ return;
885
+ case 'status':
886
+ enqueueStatus(event.label);
887
+ return;
888
+ case 'conversation':
889
+ onConversationId?.(event.id);
890
+ return;
891
+ case 'done':
892
+ deps.markTerminal();
893
+ // A clean finish ends the incident — reset the 5XX retry budget.
894
+ retryAttemptRef.current = 0;
895
+ finalizeCurrent();
896
+ return;
897
+ case 'error':
898
+ deps.markTerminal();
899
+ // A 5XX or transport drop auto-retries behind a countdown; anything
900
+ // else surfaces. Either way, finalize the partial current message.
901
+ // Retry shape: the turn started server-side (any event arrived) →
902
+ // resume it; it never started → re-send the original message.
903
+ handleStreamError(event, !sawServerEvent && deps.retrySend
904
+ ? { kind: 'resend', ...deps.retrySend }
905
+ : {
906
+ kind: 'resume',
907
+ id: currentCtx?.id ?? '',
908
+ content: currentCtx?.assistantText ?? '',
909
+ });
910
+ finalizeCurrent();
911
+ return;
912
+ }
913
+ // ── Content events — belong to the CURRENT message ──
914
+ const ctx = currentCtx;
915
+ if (!ctx)
916
+ return; // a content event before any message_start — ignore (shouldn't happen)
917
+ switch (event.type) {
918
+ case 'text': {
919
+ ctx.assistantText += event.content;
920
+ const last = ctx.blocks[ctx.blocks.length - 1];
921
+ if (last?.type === 'text')
922
+ last.content += event.content;
923
+ else
924
+ ctx.blocks.push({ type: 'text', content: event.content });
925
+ ctx.scheduleFlush(() => ({ content: ctx.assistantText, blocks: [...ctx.blocks] }));
926
+ break;
927
+ }
928
+ case 'thinking': {
929
+ const lastBlock = ctx.blocks[ctx.blocks.length - 1];
930
+ if (lastBlock?.type === 'thinking') {
931
+ lastBlock.content += event.content;
932
+ lastBlock.durationMs = Date.now() - (lastBlock._startedAt ?? Date.now());
933
+ }
934
+ else {
935
+ const now = Date.now();
936
+ ctx.blocks.push(Object.assign({ type: 'thinking', content: event.content, durationMs: 0 }, { _startedAt: now }));
937
+ }
938
+ ctx.scheduleFlush(() => ({ blocks: [...ctx.blocks] }));
939
+ break;
940
+ }
941
+ case 'tool_use': {
942
+ // tool_use_start may have already created the entry + block — fill in the
943
+ // final input rather than pushing a duplicate.
944
+ const existing = ctx.toolCalls.find((t) => t.id === event.id);
945
+ if (existing) {
946
+ existing.name = event.name;
947
+ existing.input = event.input;
948
+ existing.status = 'running';
949
+ }
950
+ else {
951
+ ctx.toolCalls.push({
952
+ id: event.id,
953
+ name: event.name,
954
+ input: event.input,
955
+ status: 'running',
956
+ });
957
+ ctx.blocks.push({ type: 'tool_call', id: event.id });
958
+ }
959
+ ctx.scheduleFlush(() => ({ toolCalls: [...ctx.toolCalls], blocks: [...ctx.blocks] }));
960
+ break;
961
+ }
962
+ case 'tool_use_start': {
963
+ if (!ctx.toolCalls.some((t) => t.id === event.id)) {
964
+ ctx.toolCalls.push({
965
+ id: event.id,
966
+ name: event.name,
967
+ input: undefined,
968
+ status: 'running',
969
+ streamInputChars: 0,
970
+ });
971
+ ctx.blocks.push({ type: 'tool_call', id: event.id });
972
+ }
973
+ ctx.scheduleFlush(() => ({ toolCalls: [...ctx.toolCalls], blocks: [...ctx.blocks] }));
974
+ break;
975
+ }
976
+ case 'tool_input_delta': {
977
+ const tc = ctx.toolCalls.find((t) => t.id === event.id);
978
+ if (tc) {
979
+ tc.streamInputChars = (tc.streamInputChars ?? 0) + event.chars;
980
+ if (event.partialInput) {
981
+ tc.input = {
982
+ ...tc.input,
983
+ ...event.partialInput,
984
+ };
985
+ }
986
+ }
987
+ ctx.scheduleFlush(() => ({ toolCalls: [...ctx.toolCalls] }));
988
+ break;
989
+ }
990
+ case 'tool_result': {
991
+ const tc = ctx.toolCalls.find((t) => t.id === event.id);
992
+ if (tc) {
993
+ tc.output = event.output;
994
+ tc.status = deriveToolStatus(event.output);
995
+ }
996
+ ctx.scheduleFlush(() => ({ toolCalls: [...ctx.toolCalls] }));
997
+ break;
998
+ }
999
+ case 'file_diff': {
1000
+ // Attach the diff snapshot to the matching write/edit tool in THIS message.
1001
+ const normalizePath = (p) => p.replace(/^\/workspace\//, '');
1002
+ const match = [...ctx.toolCalls]
1003
+ .reverse()
1004
+ .find((t) => (t.name === 'write_file' || t.name === 'edit_file') &&
1005
+ normalizePath(t.input?.path ?? '') ===
1006
+ normalizePath(event.path));
1007
+ if (match) {
1008
+ match.fileDiff = { original: event.oldContent ?? '', modified: event.newContent };
1009
+ ctx.scheduleFlush(() => ({ toolCalls: [...ctx.toolCalls] }));
1010
+ }
1011
+ clearQueuedForFileRef.current(event.path);
1012
+ onFileChange?.(event.path, event.newContent);
1013
+ break;
1014
+ }
1015
+ case 'commit_suggestion':
1016
+ ctx.scheduleFlush(() => ({
1017
+ commitSuggestion: { files: event.files, status: 'pending' },
1018
+ }));
1019
+ break;
1020
+ case 'verification_result':
1021
+ ctx.blocks.push({
1022
+ type: 'verification',
1023
+ status: event.status,
1024
+ ...(event.output ? { output: event.output } : {}),
1025
+ workspaces: event.workspaces,
1026
+ ...(event.categories ? { categories: event.categories } : {}),
1027
+ });
1028
+ ctx.scheduleFlush(() => ({ blocks: [...ctx.blocks] }));
1029
+ break;
1030
+ case 'resource_limit':
1031
+ ctx.blocks.push({
1032
+ type: 'resource_limit',
1033
+ resource: event.resource,
1034
+ message: event.message,
1035
+ });
1036
+ ctx.scheduleFlush(() => ({ blocks: [...ctx.blocks] }));
1037
+ break;
1038
+ case 'compaction':
1039
+ ctx.blocks.push({
1040
+ type: 'text',
1041
+ content: `**Context compacted** — ${event.compactedCount} older messages were summarized to free space.\n\n${event.summary}`,
1042
+ });
1043
+ ctx.scheduleFlush(() => ({ content: ctx.assistantText, blocks: [...ctx.blocks] }));
1044
+ break;
1045
+ case 'loop_limit_reached':
1046
+ ctx.loopLimitReached = event.maxLoops;
1047
+ ctx.scheduleFlush(() => ({ loopLimitReached: event.maxLoops }));
1048
+ break;
1049
+ default:
1050
+ break;
1051
+ }
1052
+ };
1053
+ return { onEvent, finalizeCurrent };
1054
+ };
1055
+ // Latest factory kept in a ref so the memoized sendMessage/resumeStream always use the
1056
+ // current props (matching the sendMessageRef/resumeStreamRef pattern below).
1057
+ const createMessageStreamRef = useRef(createMessageStream);
1058
+ createMessageStreamRef.current = createMessageStream;
1059
+ const sendMessage = useCallback(async (message, attachments, options) => {
1060
+ if (!mountedRef.current)
1061
+ return;
1062
+ // When suppressed (ask_user responses, the post-boot kickoff), the text is
1063
+ // still sent to the server but no local user-message bubble is appended —
1064
+ // the answer is reflected in the ask_user tool card, and the server marks
1065
+ // the persisted message hidden so it never reappears on refresh.
1066
+ const suppressUserMessage = options?.suppressUserMessage === true;
1067
+ // Auto-sent on the user's behalf (e.g. an auto-fix prompt): kept visible
1068
+ // but flagged so it renders in the distinct auto-sent style, not like a
1069
+ // typed user message.
1070
+ const automatic = options?.automatic === true;
1071
+ // A user Stop is a standing order: drop every AUTONOMOUS automatic send
1072
+ // (preview-health / preview-error / verification auto-fix dispatches) until
1073
+ // the user re-engages. Anything the user does themselves — typing a message,
1074
+ // answering ask_user, or an automatic send they explicitly requested
1075
+ // (userInitiated, e.g. the broken-preview overlay's "Fix with AI" button) —
1076
+ // IS that re-engagement: it clears the stop and proceeds. Without this, the
1077
+ // preview-health auto-fix restarted the executor seconds after a Stop (the
1078
+ // server's userStoppedAt gate is the durable backstop for other tabs).
1079
+ const store = getMessageStore(storageKey);
1080
+ // A send can only come from the LIVE instance — align with the store's
1081
+ // current generation so this turn's writes pass the stale-stream gate.
1082
+ generationRef.current = store.generation;
1083
+ if (store.stoppedByUser) {
1084
+ if (automatic && options?.userInitiated !== true)
1085
+ return;
1086
+ store.stoppedByUser = false;
1087
+ }
1088
+ // Resolve the pending ask_user card IN THE STORE: set the most-recent unanswered
1089
+ // ask_user tool call's output to this answer. The chosen option's checked state is
1090
+ // derived from a string `output`, so persisting it here keeps it checked across the
1091
+ // discovery→IDE remount (it previously lived only in the card's local React state and
1092
+ // vanished on remount). Idempotent; gated on the explicit askUserAnswer flag so the
1093
+ // suppressed post-boot kickoff never resolves a stale ask_user with its own text.
1094
+ if (options?.askUserAnswer) {
1095
+ setMessages((prev) => {
1096
+ for (let i = prev.length - 1; i >= 0; i--) {
1097
+ const tcs = prev[i].toolCalls;
1098
+ if (!tcs?.some((tc) => tc.name === 'ask_user' && typeof tc.output !== 'string')) {
1099
+ continue;
1100
+ }
1101
+ const copy = [...prev];
1102
+ copy[i] = {
1103
+ ...prev[i],
1104
+ toolCalls: tcs.map((tc) => tc.name === 'ask_user' && typeof tc.output !== 'string'
1105
+ ? { ...tc, output: message, status: 'done' }
1106
+ : tc),
1107
+ };
1108
+ return copy;
1109
+ }
1110
+ return prev;
1111
+ });
1112
+ }
1113
+ const userMsg = {
1114
+ id: `user-${++idCounterRef.current}`,
1115
+ role: 'user',
1116
+ content: message,
1117
+ timestamp: Date.now(),
1118
+ ...(automatic ? { automatic: true } : {}),
1119
+ ...(attachments?.length
1120
+ ? {
1121
+ attachments: attachments.map((a) => ({
1122
+ filename: a.filename,
1123
+ mediaType: a.mediaType,
1124
+ size: a.size,
1125
+ })),
1126
+ }
1127
+ : {}),
1128
+ };
1129
+ // If a request is already in-flight, mark the message as queued and defer sending
1130
+ if (sendingRef.current) {
1131
+ if (!suppressUserMessage) {
1132
+ userMsg.queued = true;
1133
+ setMessages((prev) => [...prev, userMsg]);
1134
+ }
1135
+ pendingRef.current.push({
1136
+ message,
1137
+ attachments,
1138
+ ...(automatic ? { automatic: true } : {}),
1139
+ ...(options?.userInitiated ? { userInitiated: true } : {}),
1140
+ ...(suppressUserMessage ? { suppressUserMessage } : { userMsgId: userMsg.id }),
1141
+ });
1142
+ persistQueue(storageKey, pendingRef.current);
1143
+ return;
1144
+ }
1145
+ if (!suppressUserMessage)
1146
+ setMessages((prev) => [...prev, userMsg]);
1147
+ // A fresh user-initiated send clears any prior stop so the reconcile path
1148
+ // works normally for this turn.
1149
+ userAbortedRef.current = false;
1150
+ sendingRef.current = true;
1151
+ setIsLoading(true);
1152
+ // This client now owns the streaming state — end any remote-turn tracking.
1153
+ stopRemoteStreamPoll(storageKey);
1154
+ setError(null);
1155
+ setErrorMeta(null);
1156
+ // A fresh user-initiated send abandons any pending 5XX auto-retry.
1157
+ resetRetry();
1158
+ resetStatusQueue();
1159
+ setStreamingFlag(storageKey);
1160
+ let current = {
1161
+ message,
1162
+ attachments,
1163
+ // Carry the optimistic bubble's id (queued entries already do) so a
1164
+ // never-started-turn auto-retry can replace it on re-send.
1165
+ ...(suppressUserMessage ? { suppressUserMessage: true } : { userMsgId: userMsg.id }),
1166
+ ...(automatic ? { automatic: true } : {}),
1167
+ ...(options?.userInitiated ? { userInitiated: true } : {}),
1168
+ };
1169
+ while (current) {
1170
+ if (!mountedRef.current)
1171
+ break;
1172
+ // Clear queued indicator now that this message is being sent. Re-stamp it to
1173
+ // the actual send time: while queued it pinned to the transcript bottom (see
1174
+ // timelineSortKey), and its queue-time timestamp predates everything that
1175
+ // streamed in since — keeping it would jump the message back UP on send.
1176
+ if (current.userMsgId) {
1177
+ const uid = current.userMsgId;
1178
+ setMessages((prev) => prev.map((m) => (m.id === uid ? { ...m, queued: false, timestamp: Date.now() } : m)));
1179
+ }
1180
+ const { message: currentMsg, attachments: currentAttachments } = current;
1181
+ // Liveness watchdog + terminal tracking — the server has its own stream
1182
+ // timeout, but if it goes silent WITHOUT closing the connection the client
1183
+ // would spin forever. Reset a generous watchdog on every event; if it fires,
1184
+ // abort. `receivedTerminal` distinguishes a clean done/error close from a
1185
+ // dropped stream (→ finalize + reconcile instead of an eternal spinner).
1186
+ let receivedTerminal = false;
1187
+ let stalled = false;
1188
+ const STALL_MS = 180_000;
1189
+ let stallTimer = null;
1190
+ const resetStall = () => {
1191
+ if (stallTimer)
1192
+ clearTimeout(stallTimer);
1193
+ stallTimer = setTimeout(() => {
1194
+ stalled = true;
1195
+ try {
1196
+ provider.abort();
1197
+ }
1198
+ catch (_error) {
1199
+ // provider.abort() may throw when no stream is active — safe to ignore
1200
+ }
1201
+ }, STALL_MS);
1202
+ };
1203
+ // Per-message stream handler: one message per server `message_start` (no upfront
1204
+ // placeholder); each item routes into the current message, so the live transcript
1205
+ // mirrors the persisted per-message structure exactly. Shared with resumeStream.
1206
+ const { onEvent, finalizeCurrent } = createMessageStreamRef.current({
1207
+ resetStall,
1208
+ markTerminal: () => {
1209
+ receivedTerminal = true;
1210
+ },
1211
+ // If the turn dies before ANY server event, the auto-retry re-sends
1212
+ // this exact message (resume would drop it — see RetryTarget).
1213
+ retrySend: {
1214
+ message: current.message,
1215
+ attachments: current.attachments,
1216
+ options: {
1217
+ ...(current.suppressUserMessage ? { suppressUserMessage: true } : {}),
1218
+ ...(current.automatic ? { automatic: true } : {}),
1219
+ ...(current.userInitiated ? { userInitiated: true } : {}),
1220
+ },
1221
+ userMsgId: current.userMsgId,
1222
+ },
1223
+ });
1224
+ resetStall();
1225
+ let resolvedCleanly = false;
1226
+ // Per-send config carries the intent flags to the server so it can tag
1227
+ // the persisted message (hidden / automatic). config holds the stable
1228
+ // endpoint + projectId; spread the per-message flags on top.
1229
+ const sendConfig = {
1230
+ ...config,
1231
+ ...(current.suppressUserMessage ? { suppressUserMessage: true } : {}),
1232
+ ...(current.automatic ? { automatic: true } : {}),
1233
+ ...(current.userInitiated ? { userInitiated: true } : {}),
1234
+ };
1235
+ try {
1236
+ await provider.sendMessage(currentMsg, sendConfig, onEvent, currentAttachments);
1237
+ resolvedCleanly = true;
1238
+ }
1239
+ catch (err) {
1240
+ if (mountedRef.current) {
1241
+ const msg = stalled
1242
+ ? t('chat.error.stalled', { agentName }, {
1243
+ defaultValue: '{{agentName}} stopped responding. It may still be finishing in the background — reload to see the latest, or send a new message.',
1244
+ })
1245
+ : err instanceof Error
1246
+ ? err.message
1247
+ : t('chat.error.sendFailed', undefined, { defaultValue: 'Failed to send message' });
1248
+ setError(msg);
1249
+ // Stream is over — finalize the current (partial) message.
1250
+ finalizeCurrent();
1251
+ }
1252
+ }
1253
+ finally {
1254
+ if (stallTimer)
1255
+ clearTimeout(stallTimer);
1256
+ }
1257
+ // Stream closed WITHOUT a terminal (done/error) event — e.g. the server
1258
+ // hit its own timeout and ended the connection without a final `done`,
1259
+ // or the connection dropped. Finalize so the spinner can't hang, then
1260
+ // reconcile with the server's persisted state: the turn has usually
1261
+ // completed (and persisted) server-side even though we lost the stream,
1262
+ // so reloading history surfaces the real result instead of a blank,
1263
+ // forever-spinning placeholder.
1264
+ //
1265
+ // EXCEPT on a user Stop (userAbortedRef): the abort() callback already
1266
+ // finalized this message with every streamed char + tool card kept. The
1267
+ // server is still persisting the partial turn, so a reload here can race
1268
+ // ahead of that write and momentarily replace the streamed content with
1269
+ // stale history (C4 — the bug where stopping wiped the chat until a
1270
+ // refresh). Trust the local finalization; a later manual reload reconciles.
1271
+ if (mountedRef.current && resolvedCleanly && !receivedTerminal && !userAbortedRef.current) {
1272
+ finalizeCurrent();
1273
+ try {
1274
+ const history = await provider.loadHistory(config);
1275
+ if (mountedRef.current && history.length > 0)
1276
+ setMessages(history);
1277
+ }
1278
+ catch (_error) {
1279
+ // Best-effort reconciliation — the finalized placeholder is already shown; dropping history here is safe
1280
+ }
1281
+ }
1282
+ // Drain the queue: send next pending message, clearing any prior error
1283
+ current = pendingRef.current.shift();
1284
+ if (current) {
1285
+ setError(null);
1286
+ setErrorMeta(null);
1287
+ persistQueue(storageKey, pendingRef.current);
1288
+ }
1289
+ }
1290
+ sendingRef.current = false;
1291
+ setIsLoading(false);
1292
+ // During page refresh the browser aborts the fetch which unblocks this
1293
+ // code path. Skip clearing so the streaming flag survives for resume.
1294
+ if (!unloadingRef.current) {
1295
+ clearStreamingFlag(storageKey);
1296
+ persistQueue(storageKey, []);
1297
+ }
1298
+ }, [provider, endpoint, agentName]);
1299
+ // Resume an interrupted stream after a page refresh. Two phases:
1300
+ // 1. Poll history until the server finishes the old request (lock clears)
1301
+ // 2. Send a resume request that continues the AI response with real streaming
1302
+ const resumeStream = useCallback(
1303
+ // `_existingContent` is no longer used (the resume no longer seeds/continues the
1304
+ // partial message — the server streams its continuation as new message(s)); kept in
1305
+ // the signature for the callers + ref type.
1306
+ async (resumeId, _existingContent) => {
1307
+ if (!mountedRef.current || sendingRef.current)
1308
+ return;
1309
+ // A resume can only come from the LIVE instance — align with the store's
1310
+ // current generation so this turn's writes pass the stale-stream gate.
1311
+ generationRef.current = getMessageStore(storageKey).generation;
1312
+ // Show the spinner on the last assistant message immediately
1313
+ setMessages((prev) => prev.map((m) => (m.id === resumeId ? { ...m, isStreaming: true } : m)));
1314
+ sendingRef.current = true;
1315
+ setIsLoading(true);
1316
+ // This client now owns the streaming state — end any remote-turn tracking.
1317
+ stopRemoteStreamPoll(storageKey);
1318
+ setError(null);
1319
+ setErrorMeta(null);
1320
+ resetStatusQueue();
1321
+ setStreamingFlag(storageKey);
1322
+ // ── Phase 1: wait for the server to finish the old request ────────
1323
+ const POLL_INTERVAL = 1000;
1324
+ const MAX_POLLS = 300;
1325
+ const streamingProvider = provider;
1326
+ for (let i = 0; i < MAX_POLLS; i++) {
1327
+ if (!mountedRef.current || !sendingRef.current)
1328
+ break;
1329
+ await new Promise((r) => setTimeout(r, POLL_INTERVAL));
1330
+ if (!mountedRef.current || !sendingRef.current)
1331
+ break;
1332
+ try {
1333
+ const history = await provider.loadHistory(config);
1334
+ if (!mountedRef.current)
1335
+ break;
1336
+ // Update displayed messages while keeping the spinner on the resumed message.
1337
+ if (history.length > 0) {
1338
+ setMessages(history.map((m) => (m.id === resumeId ? { ...m, isStreaming: true } : m)));
1339
+ }
1340
+ if (streamingProvider.isServerStreaming === false)
1341
+ break;
1342
+ }
1343
+ catch (_error) {
1344
+ // loadHistory failure during resume poll — keep polling until MAX_POLLS
1345
+ }
1346
+ }
1347
+ if (!mountedRef.current || !sendingRef.current) {
1348
+ sendingRef.current = false;
1349
+ setIsLoading(false);
1350
+ setMessages((prev) => prev.map((m) => (m.id === resumeId ? { ...m, isStreaming: false } : m)));
1351
+ return;
1352
+ }
1353
+ // ── Phase 2: send a resume request with full SSE streaming ────────
1354
+ // The resumed (partial) message is already persisted + shown. On resume the server
1355
+ // re-enters the agentic loop and streams its NEW iterations as NEW messages (each
1356
+ // with its own message_start) — so stop the resumed message's spinner and let the
1357
+ // shared per-message handler append the new messages after it. No content seeding
1358
+ // (that duplicated text before); new content becomes a new message.
1359
+ setMessages((prev) => prev.map((m) => (m.id === resumeId ? { ...m, isStreaming: false } : m)));
1360
+ const { onEvent, finalizeCurrent } = createMessageStreamRef.current({
1361
+ resetStall: () => { },
1362
+ markTerminal: () => { },
1363
+ });
1364
+ try {
1365
+ await provider.sendMessage('', { ...config, resume: true }, onEvent);
1366
+ }
1367
+ catch (err) {
1368
+ if (mountedRef.current) {
1369
+ const msg = err instanceof Error
1370
+ ? err.message
1371
+ : t('chat.error.sendFailed', undefined, { defaultValue: 'Failed to send message' });
1372
+ setError(msg);
1373
+ // Stream is over — finalize the current (partial) message.
1374
+ finalizeCurrent();
1375
+ }
1376
+ }
1377
+ // Drain any queued user messages
1378
+ let current = pendingRef.current.shift();
1379
+ while (current) {
1380
+ if (!mountedRef.current)
1381
+ break;
1382
+ setError(null);
1383
+ setErrorMeta(null);
1384
+ persistQueue(storageKey, pendingRef.current);
1385
+ sendingRef.current = false;
1386
+ await sendMessageRef.current(current.message, current.attachments);
1387
+ current = pendingRef.current.shift();
1388
+ }
1389
+ sendingRef.current = false;
1390
+ setIsLoading(false);
1391
+ if (!unloadingRef.current) {
1392
+ clearStreamingFlag(storageKey);
1393
+ persistQueue(storageKey, []);
1394
+ }
1395
+ }, [provider, endpoint]);
1396
+ // Keep refs in sync so the history-load effect can call the latest functions
1397
+ sendMessageRef.current = sendMessage;
1398
+ resumeStreamRef.current = resumeStream;
1399
+ const abort = useCallback(() => {
1400
+ // Mark this as a user Stop so the in-flight send's reconcile path leaves the
1401
+ // locally-finalized streamed content alone (see the reconcile guard above).
1402
+ userAbortedRef.current = true;
1403
+ // A stop is a user decision the platform must not overrule: suppress every
1404
+ // autonomous automatic send (auto-fix dispatches) until the user re-engages
1405
+ // (see the sendMessage guard), and end any remote-turn tracking — the server
1406
+ // stream is being killed below, so the Stop control can retire immediately.
1407
+ getMessageStore(storageKey).stoppedByUser = true;
1408
+ stopRemoteStreamPoll(storageKey);
1409
+ // Stop means the user took over — cancel any pending 5XX auto-retry + timers.
1410
+ resetRetry();
1411
+ try {
1412
+ provider.abort();
1413
+ }
1414
+ catch (_error) {
1415
+ // provider.abort() may throw when no stream is active — safe to ignore on user-initiated abort
1416
+ }
1417
+ // Also kill the server-side stream so it doesn't continue running. Flagged
1418
+ // userInitiated so the server records the durable stop marker that refuses
1419
+ // automatic follow-up turns from ANY client until the user sends again
1420
+ // (the page-unload beacon deliberately does NOT set this — a refresh is not
1421
+ // a stop, and the post-refresh auto-resume must stay possible).
1422
+ const p = provider;
1423
+ p.abortOnServer?.({ endpoint, projectId }, undefined, { userInitiated: true });
1424
+ // Apply the last throttled delta (the up-to-50ms of text/tool progress that
1425
+ // hadn't flushed yet) BEFORE finalizing, so Stop keeps every streamed char —
1426
+ // not just whatever landed in the previous flush window.
1427
+ if (flushTimerRef.current !== null) {
1428
+ clearTimeout(flushTimerRef.current);
1429
+ flushTimerRef.current = null;
1430
+ }
1431
+ pendingFlushFnRef.current?.();
1432
+ pendingFlushFnRef.current = null;
1433
+ pendingRef.current.length = 0;
1434
+ sendingRef.current = false;
1435
+ clearStreamingFlag(storageKey);
1436
+ persistQueue(storageKey, []);
1437
+ setIsLoading(false);
1438
+ setMessages((prev) => prev.map((m) => (m.isStreaming ? { ...m, isStreaming: false, aborted: true } : m)));
1439
+ }, [provider, endpoint, projectId, storageKey]);
1440
+ const clearHistory = useCallback(async () => {
1441
+ pendingRef.current.length = 0;
1442
+ // Drop any pending 5XX auto-retry — the conversation is being cleared.
1443
+ resetRetry();
1444
+ clearStreamingFlag(storageKey);
1445
+ persistQueue(storageKey, []);
1446
+ await provider.clearHistory(config);
1447
+ if (mountedRef.current) {
1448
+ setMessages([]);
1449
+ setError(null);
1450
+ setErrorMeta(null);
1451
+ }
1452
+ }, [provider, endpoint]);
1453
+ const editQueuedMessage = useCallback((msgId, newContent) => {
1454
+ const entry = pendingRef.current.find((e) => e.userMsgId === msgId);
1455
+ if (entry) {
1456
+ entry.message = newContent;
1457
+ persistQueue(storageKey, pendingRef.current);
1458
+ }
1459
+ setMessages((prev) => prev.map((m) => (m.id === msgId && m.queued ? { ...m, content: newContent } : m)));
1460
+ }, [storageKey]);
1461
+ const deleteQueuedMessage = useCallback((msgId) => {
1462
+ pendingRef.current = pendingRef.current.filter((e) => e.userMsgId !== msgId);
1463
+ persistQueue(storageKey, pendingRef.current);
1464
+ setMessages((prev) => prev.filter((m) => !(m.id === msgId && m.queued)));
1465
+ }, [storageKey]);
1466
+ /**
1467
+ * Remove queued auto-fix messages whose content references the given file path.
1468
+ * Called automatically when the AI writes to a file (file_diff event) and can
1469
+ * also be called externally when the user edits a file in the editor.
1470
+ */
1471
+ const clearQueuedForFile = useCallback((filePath) => {
1472
+ const norm = filePath.replace(/^\/workspace\//, '');
1473
+ const toRemove = pendingRef.current.filter((e) => e.message.startsWith(AUTOFIX_PREFIX) && e.message.includes(norm));
1474
+ if (toRemove.length === 0)
1475
+ return;
1476
+ const removeIds = new Set(toRemove.map((e) => e.userMsgId));
1477
+ pendingRef.current = pendingRef.current.filter((e) => !removeIds.has(e.userMsgId));
1478
+ persistQueue(storageKey, pendingRef.current);
1479
+ setMessages((prev) => prev.filter((m) => !(m.queued && removeIds.has(m.id))));
1480
+ }, [storageKey]);
1481
+ // Sync clearQueuedForFile ref so stream event handlers can call the latest version
1482
+ clearQueuedForFileRef.current = clearQueuedForFile;
1483
+ const exposedSetMode = useCallback((newMode) => {
1484
+ setMode(newMode);
1485
+ onModeChange?.(newMode);
1486
+ }, [onModeChange]);
1487
+ return {
1488
+ messages,
1489
+ isLoading,
1490
+ isRemoteStreaming,
1491
+ noteRemoteStreamEvent,
1492
+ error,
1493
+ errorMeta,
1494
+ mode,
1495
+ fastMode,
1496
+ streamingStatus,
1497
+ retryCountdown,
1498
+ setMode: exposedSetMode,
1499
+ setFastMode,
1500
+ sendMessage,
1501
+ abort,
1502
+ cancelRetry,
1503
+ clearHistory,
1504
+ editQueuedMessage,
1505
+ deleteQueuedMessage,
1506
+ clearQueuedForFile,
1507
+ appendCardMessage,
1508
+ };
1509
+ }
1510
+ //# sourceMappingURL=useChat.js.map