@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.
- package/LICENSE +115 -0
- package/README.md +2645 -0
- package/dist/agent-identity.d.ts +40 -0
- package/dist/agent-identity.d.ts.map +1 -0
- package/dist/agent-identity.js +31 -0
- package/dist/agent-identity.js.map +1 -0
- package/dist/contexts.d.ts +69 -0
- package/dist/contexts.d.ts.map +1 -0
- package/dist/contexts.js +70 -0
- package/dist/contexts.js.map +1 -0
- package/dist/hooks/index.d.ts +32 -0
- package/dist/hooks/index.d.ts.map +1 -0
- package/dist/hooks/index.js +32 -0
- package/dist/hooks/index.js.map +1 -0
- package/dist/hooks/useAIModels.d.ts +48 -0
- package/dist/hooks/useAIModels.d.ts.map +1 -0
- package/dist/hooks/useAIModels.js +145 -0
- package/dist/hooks/useAIModels.js.map +1 -0
- package/dist/hooks/useAsyncState.d.ts +41 -0
- package/dist/hooks/useAsyncState.d.ts.map +1 -0
- package/dist/hooks/useAsyncState.js +77 -0
- package/dist/hooks/useAsyncState.js.map +1 -0
- package/dist/hooks/useAuth.d.ts +43 -0
- package/dist/hooks/useAuth.d.ts.map +1 -0
- package/dist/hooks/useAuth.js +106 -0
- package/dist/hooks/useAuth.js.map +1 -0
- package/dist/hooks/useCapacitorApp.d.ts +35 -0
- package/dist/hooks/useCapacitorApp.d.ts.map +1 -0
- package/dist/hooks/useCapacitorApp.js +61 -0
- package/dist/hooks/useCapacitorApp.js.map +1 -0
- package/dist/hooks/useChangePassword.d.ts +34 -0
- package/dist/hooks/useChangePassword.d.ts.map +1 -0
- package/dist/hooks/useChangePassword.js +37 -0
- package/dist/hooks/useChangePassword.js.map +1 -0
- package/dist/hooks/useChat.d.ts +30 -0
- package/dist/hooks/useChat.d.ts.map +1 -0
- package/dist/hooks/useChat.js +1510 -0
- package/dist/hooks/useChat.js.map +1 -0
- package/dist/hooks/useDevice.d.ts +39 -0
- package/dist/hooks/useDevice.d.ts.map +1 -0
- package/dist/hooks/useDevice.js +41 -0
- package/dist/hooks/useDevice.js.map +1 -0
- package/dist/hooks/useEditor.d.ts +20 -0
- package/dist/hooks/useEditor.d.ts.map +1 -0
- package/dist/hooks/useEditor.js +98 -0
- package/dist/hooks/useEditor.js.map +1 -0
- package/dist/hooks/useForm.d.ts +87 -0
- package/dist/hooks/useForm.d.ts.map +1 -0
- package/dist/hooks/useForm.js +148 -0
- package/dist/hooks/useForm.js.map +1 -0
- package/dist/hooks/useHttp.d.ts +103 -0
- package/dist/hooks/useHttp.d.ts.map +1 -0
- package/dist/hooks/useHttp.js +161 -0
- package/dist/hooks/useHttp.js.map +1 -0
- package/dist/hooks/useLogger.d.ts +68 -0
- package/dist/hooks/useLogger.d.ts.map +1 -0
- package/dist/hooks/useLogger.js +95 -0
- package/dist/hooks/useLogger.js.map +1 -0
- package/dist/hooks/useLogin.d.ts +35 -0
- package/dist/hooks/useLogin.d.ts.map +1 -0
- package/dist/hooks/useLogin.js +37 -0
- package/dist/hooks/useLogin.js.map +1 -0
- package/dist/hooks/useOAuth.d.ts +83 -0
- package/dist/hooks/useOAuth.d.ts.map +1 -0
- package/dist/hooks/useOAuth.js +348 -0
- package/dist/hooks/useOAuth.js.map +1 -0
- package/dist/hooks/usePasswordReset.d.ts +43 -0
- package/dist/hooks/usePasswordReset.d.ts.map +1 -0
- package/dist/hooks/usePasswordReset.js +50 -0
- package/dist/hooks/usePasswordReset.js.map +1 -0
- package/dist/hooks/usePlatform.d.ts +38 -0
- package/dist/hooks/usePlatform.d.ts.map +1 -0
- package/dist/hooks/usePlatform.js +40 -0
- package/dist/hooks/usePlatform.js.map +1 -0
- package/dist/hooks/usePreview.d.ts +20 -0
- package/dist/hooks/usePreview.d.ts.map +1 -0
- package/dist/hooks/usePreview.js +62 -0
- package/dist/hooks/usePreview.js.map +1 -0
- package/dist/hooks/usePromise.d.ts +39 -0
- package/dist/hooks/usePromise.d.ts.map +1 -0
- package/dist/hooks/usePromise.js +92 -0
- package/dist/hooks/usePromise.js.map +1 -0
- package/dist/hooks/usePush.d.ts +50 -0
- package/dist/hooks/usePush.d.ts.map +1 -0
- package/dist/hooks/usePush.js +89 -0
- package/dist/hooks/usePush.js.map +1 -0
- package/dist/hooks/useRouter.d.ts +65 -0
- package/dist/hooks/useRouter.d.ts.map +1 -0
- package/dist/hooks/useRouter.js +120 -0
- package/dist/hooks/useRouter.js.map +1 -0
- package/dist/hooks/useSignup.d.ts +35 -0
- package/dist/hooks/useSignup.d.ts.map +1 -0
- package/dist/hooks/useSignup.js +37 -0
- package/dist/hooks/useSignup.js.map +1 -0
- package/dist/hooks/useStorage.d.ts +72 -0
- package/dist/hooks/useStorage.d.ts.map +1 -0
- package/dist/hooks/useStorage.js +144 -0
- package/dist/hooks/useStorage.js.map +1 -0
- package/dist/hooks/useStore.d.ts +51 -0
- package/dist/hooks/useStore.d.ts.map +1 -0
- package/dist/hooks/useStore.js +86 -0
- package/dist/hooks/useStore.js.map +1 -0
- package/dist/hooks/useTheme.d.ts +50 -0
- package/dist/hooks/useTheme.d.ts.map +1 -0
- package/dist/hooks/useTheme.js +135 -0
- package/dist/hooks/useTheme.js.map +1 -0
- package/dist/hooks/useTranslation.d.ts +69 -0
- package/dist/hooks/useTranslation.d.ts.map +1 -0
- package/dist/hooks/useTranslation.js +131 -0
- package/dist/hooks/useTranslation.js.map +1 -0
- package/dist/hooks/useVersion.d.ts +41 -0
- package/dist/hooks/useVersion.d.ts.map +1 -0
- package/dist/hooks/useVersion.js +73 -0
- package/dist/hooks/useVersion.js.map +1 -0
- package/dist/hooks/useWorkspace.d.ts +18 -0
- package/dist/hooks/useWorkspace.d.ts.map +1 -0
- package/dist/hooks/useWorkspace.js +67 -0
- package/dist/hooks/useWorkspace.js.map +1 -0
- package/dist/index.d.ts +80 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +80 -0
- package/dist/index.js.map +1 -0
- package/dist/providers.d.ts +189 -0
- package/dist/providers.d.ts.map +1 -0
- package/dist/providers.js +248 -0
- package/dist/providers.js.map +1 -0
- package/dist/types.d.ts +381 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +7 -0
- package/dist/types.js.map +1 -0
- 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
|