@yushaw/sanqian-chat 0.2.44 → 0.3.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.
@@ -114,6 +114,213 @@ function cloneBlocks(blocks) {
114
114
  toolArgs: block.toolArgs ? { ...block.toolArgs } : block.toolArgs
115
115
  }));
116
116
  }
117
+ function updateDetachedSnapshotForEvent(streamContext, event) {
118
+ const snapshot = streamContext.detachedSnapshot;
119
+ if (!snapshot?.length) return false;
120
+ const assistantIndex = snapshot.findIndex((message) => message.id === streamContext.assistantMessageId);
121
+ if (assistantIndex === -1) return false;
122
+ const assistant = snapshot[assistantIndex];
123
+ switch (event.type) {
124
+ case "text": {
125
+ const rawContent = event.content;
126
+ if (!rawContent) return false;
127
+ const nextChunk = assistant.content ? rawContent : rawContent.trimStart();
128
+ if (!nextChunk) return false;
129
+ snapshot[assistantIndex] = {
130
+ ...assistant,
131
+ content: `${assistant.content}${nextChunk}`,
132
+ isStreaming: true,
133
+ isComplete: false
134
+ };
135
+ return true;
136
+ }
137
+ case "thinking": {
138
+ const rawThinking = event.content;
139
+ if (!rawThinking) return false;
140
+ const nextChunk = assistant.thinking ? rawThinking : rawThinking.trimStart();
141
+ if (!nextChunk) return false;
142
+ snapshot[assistantIndex] = {
143
+ ...assistant,
144
+ thinking: `${assistant.thinking ?? ""}${nextChunk}`,
145
+ currentThinking: `${assistant.currentThinking ?? ""}${nextChunk}`,
146
+ isStreaming: true,
147
+ isComplete: false,
148
+ isThinkingStreaming: true,
149
+ isThinkingPaused: false
150
+ };
151
+ return true;
152
+ }
153
+ case "tool_call": {
154
+ const tc = event.tool_call;
155
+ if (!tc) return false;
156
+ const toolName = tc.function?.name || tc.name || "";
157
+ const toolId = tc.id || "";
158
+ let args = {};
159
+ if (tc.args) {
160
+ args = tc.args;
161
+ } else if (tc.function?.arguments) {
162
+ try {
163
+ args = JSON.parse(tc.function.arguments);
164
+ } catch {
165
+ args = {};
166
+ }
167
+ }
168
+ const toolCalls = [...assistant.toolCalls || []];
169
+ toolCalls.push({
170
+ id: toolId,
171
+ name: toolName,
172
+ args,
173
+ status: "running"
174
+ });
175
+ const blocks = cloneBlocks(assistant.blocks) || [];
176
+ blocks.push({
177
+ type: "tool_call",
178
+ content: "",
179
+ timestamp: Date.now(),
180
+ toolName,
181
+ toolArgs: args,
182
+ toolCallId: toolId,
183
+ toolStatus: "running",
184
+ isIntermediate: true
185
+ });
186
+ snapshot[assistantIndex] = {
187
+ ...assistant,
188
+ toolCalls,
189
+ blocks,
190
+ isStreaming: true,
191
+ isComplete: false,
192
+ isThinkingStreaming: false,
193
+ isThinkingPaused: !!assistant.thinking,
194
+ isToolCallsStreaming: true
195
+ };
196
+ return true;
197
+ }
198
+ case "tool_args_chunk": {
199
+ const chunk = event.chunk;
200
+ if (!chunk) return false;
201
+ const toolCalls = [...assistant.toolCalls || []];
202
+ const toolCallIndex = findToolCallIndex(toolCalls, event.tool_call_id, event.tool_name);
203
+ if (toolCallIndex === -1) return false;
204
+ const existingRaw = toolCalls[toolCallIndex].argsRaw || "";
205
+ toolCalls[toolCallIndex] = {
206
+ ...toolCalls[toolCallIndex],
207
+ argsRaw: `${existingRaw}${chunk}`
208
+ };
209
+ const blocks = cloneBlocks(assistant.blocks) || [];
210
+ const blockIndex = blocks.findIndex(
211
+ (block) => block.type === "tool_call" && (block.toolCallId === event.tool_call_id || !event.tool_call_id && event.tool_name && block.toolName === event.tool_name)
212
+ );
213
+ if (blockIndex !== -1) {
214
+ blocks[blockIndex] = {
215
+ ...blocks[blockIndex],
216
+ toolArgsRaw: `${blocks[blockIndex].toolArgsRaw || ""}${chunk}`
217
+ };
218
+ }
219
+ snapshot[assistantIndex] = {
220
+ ...assistant,
221
+ toolCalls,
222
+ blocks
223
+ };
224
+ return true;
225
+ }
226
+ case "tool_args": {
227
+ const toolCalls = [...assistant.toolCalls || []];
228
+ const toolCallIndex = findToolCallIndex(toolCalls, event.tool_call_id, event.tool_name);
229
+ if (toolCallIndex === -1) return false;
230
+ toolCalls[toolCallIndex] = {
231
+ ...toolCalls[toolCallIndex],
232
+ args: event.args || {},
233
+ argsRaw: void 0
234
+ };
235
+ const blocks = cloneBlocks(assistant.blocks) || [];
236
+ const blockIndex = blocks.findIndex(
237
+ (block) => block.type === "tool_call" && (block.toolCallId === event.tool_call_id || !event.tool_call_id && event.tool_name && block.toolName === event.tool_name)
238
+ );
239
+ if (blockIndex !== -1) {
240
+ blocks[blockIndex] = {
241
+ ...blocks[blockIndex],
242
+ toolArgs: event.args || {},
243
+ toolArgsRaw: void 0
244
+ };
245
+ }
246
+ snapshot[assistantIndex] = {
247
+ ...assistant,
248
+ toolCalls,
249
+ blocks
250
+ };
251
+ return true;
252
+ }
253
+ case "tool_result": {
254
+ const toolCalls = (assistant.toolCalls || []).map((toolCall) => toolCall.id === event.tool_call_id ? { ...toolCall, status: "completed", result: event.result } : toolCall);
255
+ const hasRunning = toolCalls.some((toolCall) => toolCall.status === "running");
256
+ const blocks = cloneBlocks(assistant.blocks) || [];
257
+ const toolBlockIndex = blocks.findIndex(
258
+ (block) => block.type === "tool_call" && block.toolCallId === event.tool_call_id
259
+ );
260
+ if (toolBlockIndex !== -1) {
261
+ blocks[toolBlockIndex] = {
262
+ ...blocks[toolBlockIndex],
263
+ toolStatus: "completed"
264
+ };
265
+ }
266
+ blocks.push({
267
+ type: "tool_result",
268
+ content: typeof event.result === "string" ? event.result : JSON.stringify(event.result),
269
+ timestamp: Date.now(),
270
+ toolName: toolBlockIndex !== -1 ? blocks[toolBlockIndex].toolName : void 0,
271
+ toolCallId: event.tool_call_id,
272
+ isIntermediate: true
273
+ });
274
+ snapshot[assistantIndex] = {
275
+ ...assistant,
276
+ toolCalls,
277
+ blocks,
278
+ isToolCallsStreaming: hasRunning
279
+ };
280
+ return true;
281
+ }
282
+ case "done": {
283
+ snapshot[assistantIndex] = {
284
+ ...assistant,
285
+ thinking: assistant.thinking?.trimEnd(),
286
+ currentThinking: void 0,
287
+ isStreaming: false,
288
+ isComplete: true,
289
+ isThinkingStreaming: false,
290
+ isThinkingPaused: false,
291
+ isToolCallsStreaming: false
292
+ };
293
+ return true;
294
+ }
295
+ case "cancelled": {
296
+ snapshot[assistantIndex] = {
297
+ ...assistant,
298
+ thinking: assistant.thinking?.trimEnd(),
299
+ currentThinking: void 0,
300
+ isStreaming: false,
301
+ isComplete: true,
302
+ isThinkingStreaming: false,
303
+ isThinkingPaused: false,
304
+ isToolCallsStreaming: false
305
+ };
306
+ return true;
307
+ }
308
+ case "error": {
309
+ snapshot[assistantIndex] = {
310
+ ...assistant,
311
+ content: assistant.content || `Error: ${event.error}`,
312
+ isStreaming: false,
313
+ isComplete: true,
314
+ isThinkingStreaming: false,
315
+ isThinkingPaused: false,
316
+ isToolCallsStreaming: false
317
+ };
318
+ return true;
319
+ }
320
+ default:
321
+ return false;
322
+ }
323
+ }
117
324
  function cloneMessages(messages) {
118
325
  return messages.map((message) => ({
119
326
  ...message,
@@ -164,6 +371,8 @@ function useChat(options) {
164
371
  const pendingCancelFnRef = (0, import_react.useRef)(null);
165
372
  const suppressStreamRef = (0, import_react.useRef)(false);
166
373
  const activeStreamContextRef = (0, import_react.useRef)(null);
374
+ const streamContextsRef = (0, import_react.useRef)(/* @__PURE__ */ new Map());
375
+ const conversationStreamTokensRef = (0, import_react.useRef)(/* @__PURE__ */ new Map());
167
376
  const currentBlocksRef = (0, import_react.useRef)([]);
168
377
  const currentTextBlockIndexRef = (0, import_react.useRef)(-1);
169
378
  const needsContentClearRef = (0, import_react.useRef)(false);
@@ -187,6 +396,13 @@ function useChat(options) {
187
396
  return () => {
188
397
  isMountedRef.current = false;
189
398
  cancelRef.current?.();
399
+ streamContextsRef.current.forEach((context) => {
400
+ context.suppressed = true;
401
+ context.terminal = true;
402
+ context.cancel?.();
403
+ });
404
+ streamContextsRef.current.clear();
405
+ conversationStreamTokensRef.current.clear();
190
406
  if (typewriterIntervalRef.current) clearTimeout(typewriterIntervalRef.current);
191
407
  };
192
408
  }, []);
@@ -264,12 +480,89 @@ function useChat(options) {
264
480
  map.delete(oldestKey);
265
481
  }
266
482
  }, []);
483
+ const upsertConversationStreamToken = (0, import_react.useCallback)((id, token) => {
484
+ const map = conversationStreamTokensRef.current;
485
+ const existing = map.get(id);
486
+ if (existing) {
487
+ existing.delete(token);
488
+ existing.add(token);
489
+ return;
490
+ }
491
+ map.set(id, /* @__PURE__ */ new Set([token]));
492
+ }, []);
493
+ const removeConversationStreamToken = (0, import_react.useCallback)((id, token) => {
494
+ const map = conversationStreamTokensRef.current;
495
+ const tokens = map.get(id);
496
+ if (!tokens) return;
497
+ tokens.delete(token);
498
+ if (tokens.size === 0) {
499
+ map.delete(id);
500
+ }
501
+ }, []);
502
+ const bindStreamToConversation = (0, import_react.useCallback)((streamContext, id) => {
503
+ const nextConversationId = typeof id === "string" && id.trim().length > 0 ? id : null;
504
+ const previousConversationId = streamContext.conversationId;
505
+ if (previousConversationId && previousConversationId !== nextConversationId) {
506
+ removeConversationStreamToken(previousConversationId, streamContext.token);
507
+ }
508
+ streamContext.conversationId = nextConversationId;
509
+ if (nextConversationId) {
510
+ upsertConversationStreamToken(nextConversationId, streamContext.token);
511
+ }
512
+ }, [removeConversationStreamToken, upsertConversationStreamToken]);
513
+ const touchStreamContext = (0, import_react.useCallback)((streamContext) => {
514
+ streamContext.updatedAt = Date.now();
515
+ const id = streamContext.conversationId;
516
+ if (!id) return;
517
+ upsertConversationStreamToken(id, streamContext.token);
518
+ }, [upsertConversationStreamToken]);
519
+ const getLatestConversationStreamContext = (0, import_react.useCallback)((id) => {
520
+ if (!id) return null;
521
+ const tokenSet = conversationStreamTokensRef.current.get(id);
522
+ if (!tokenSet || tokenSet.size === 0) return null;
523
+ const tokens = Array.from(tokenSet.values()).reverse();
524
+ for (const token of tokens) {
525
+ const context = streamContextsRef.current.get(token);
526
+ if (!context || context.terminal) {
527
+ tokenSet.delete(token);
528
+ continue;
529
+ }
530
+ if (context.suppressed) {
531
+ continue;
532
+ }
533
+ return context;
534
+ }
535
+ if (tokenSet.size === 0) {
536
+ conversationStreamTokensRef.current.delete(id);
537
+ }
538
+ return null;
539
+ }, []);
540
+ const hasLiveConversationStream = (0, import_react.useCallback)((id) => {
541
+ return getLatestConversationStreamContext(id) !== null;
542
+ }, [getLatestConversationStreamContext]);
543
+ const finalizeStreamContext = (0, import_react.useCallback)((streamContext) => {
544
+ if (streamContext.terminal) {
545
+ return;
546
+ }
547
+ streamContext.terminal = true;
548
+ if (streamContext.conversationId) {
549
+ removeConversationStreamToken(streamContext.conversationId, streamContext.token);
550
+ }
551
+ streamContextsRef.current.delete(streamContext.token);
552
+ if (activeStreamContextRef.current?.token === streamContext.token) {
553
+ activeStreamContextRef.current = null;
554
+ }
555
+ }, [removeConversationStreamToken]);
267
556
  const detachActiveStream = (0, import_react.useCallback)((detachContext) => {
268
557
  const context = activeStreamContextRef.current;
269
- if (!context || context.detached) {
558
+ if (!context || context.detached || context.terminal) {
270
559
  return;
271
560
  }
272
- const detachedSnapshot = cloneMessages(normalizeConversationMessages(messagesRef.current));
561
+ const normalizedLiveMessages = cloneMessages(normalizeConversationMessages(messagesRef.current));
562
+ const liveSnapshotHasAssistant = normalizedLiveMessages.some(
563
+ (message) => message.id === context.assistantMessageId
564
+ );
565
+ const detachedSnapshot = liveSnapshotHasAssistant ? normalizedLiveMessages : context.initialSnapshot ? cloneMessages(context.initialSnapshot) : normalizedLiveMessages;
273
566
  const detachedAssistantMessageIndex = detachedSnapshot.findIndex(
274
567
  (message) => message.id === context.assistantMessageId
275
568
  );
@@ -295,7 +588,9 @@ function useChat(options) {
295
588
  setDetachedConversationSnapshot(context.conversationId, detachedSnapshot);
296
589
  }
297
590
  context.detached = true;
591
+ context.suppressed = false;
298
592
  context.detachContext = detachContext;
593
+ touchStreamContext(context);
299
594
  if (typewriterIntervalRef.current) {
300
595
  clearTimeout(typewriterIntervalRef.current);
301
596
  typewriterIntervalRef.current = null;
@@ -309,7 +604,7 @@ function useChat(options) {
309
604
  pendingInterruptStreamIdRef.current = null;
310
605
  clearPendingCancel();
311
606
  suppressStreamRef.current = false;
312
- }, [clearPendingCancel, resetStreamBuffers, setDetachedConversationSnapshot]);
607
+ }, [clearPendingCancel, resetStreamBuffers, setDetachedConversationSnapshot, touchStreamContext]);
313
608
  const refreshConversationIfVisible = (0, import_react.useCallback)(async (id) => {
314
609
  if (!id || conversationIdRef.current !== id) return false;
315
610
  try {
@@ -320,12 +615,13 @@ function useChat(options) {
320
615
  detachedConversationSnapshotsRef.current.delete(id);
321
616
  setMessages(normalizedMessages);
322
617
  setConversationTitle(detail.title ?? null);
618
+ setIsStreaming(hasLiveConversationStream(id) || normalizedMessages.some((message) => !!message.isStreaming));
323
619
  return true;
324
620
  } catch (refreshError) {
325
621
  console.warn("[useChat] Failed to refresh conversation after detached stream completion:", refreshError);
326
622
  return false;
327
623
  }
328
- }, [adapter]);
624
+ }, [adapter, hasLiveConversationStream]);
329
625
  const refreshConversationIfVisibleWithRetry = (0, import_react.useCallback)((id, retryDelaysMs) => {
330
626
  if (!id) return;
331
627
  void (async () => {
@@ -343,15 +639,49 @@ function useChat(options) {
343
639
  }
344
640
  })();
345
641
  }, [refreshConversationIfVisible]);
642
+ const syncDetachedSnapshotToVisibleConversation = (0, import_react.useCallback)((streamContext) => {
643
+ const id = streamContext.conversationId;
644
+ if (!id || conversationIdRef.current !== id) return;
645
+ const activeContext = activeStreamContextRef.current;
646
+ if (activeContext && !activeContext.detached && activeContext.conversationId === id) return;
647
+ if (!streamContext.detachedSnapshot?.length) return;
648
+ const nextMessages = cloneMessages(streamContext.detachedSnapshot);
649
+ setMessages(nextMessages);
650
+ setIsStreaming(
651
+ hasLiveConversationStream(id) || nextMessages.some((message) => message.isStreaming)
652
+ );
653
+ }, [hasLiveConversationStream]);
346
654
  const handleStreamEvent = (0, import_react.useCallback)((event, streamContext) => {
347
655
  if (!isMountedRef.current) return;
656
+ if (streamContext.terminal) return;
657
+ touchStreamContext(streamContext);
658
+ const isTerminalEvent = event.type === "done" || event.type === "cancelled" || event.type === "error";
659
+ if (streamContext.suppressed && event.type !== "start" && !isTerminalEvent) {
660
+ return;
661
+ }
348
662
  const isActiveStream = activeStreamContextRef.current?.token === streamContext.token && !streamContext.detached;
349
663
  if (!isActiveStream) {
664
+ if (event.type === "start" && event.conversationId) {
665
+ if (streamContext.conversationId && streamContext.conversationId !== event.conversationId) {
666
+ detachedConversationSnapshotsRef.current.delete(streamContext.conversationId);
667
+ }
668
+ bindStreamToConversation(streamContext, event.conversationId);
669
+ if (streamContext.detachedSnapshot?.length) {
670
+ setDetachedConversationSnapshot(event.conversationId, streamContext.detachedSnapshot);
671
+ }
672
+ }
673
+ const snapshotUpdated = updateDetachedSnapshotForEvent(streamContext, event);
674
+ if (snapshotUpdated && streamContext.conversationId && streamContext.detachedSnapshot?.length) {
675
+ setDetachedConversationSnapshot(streamContext.conversationId, streamContext.detachedSnapshot);
676
+ }
677
+ if (snapshotUpdated) {
678
+ syncDetachedSnapshotToVisibleConversation(streamContext);
679
+ }
350
680
  if (event.type === "done" && event.conversationId && !streamContext.didReportConversationChange) {
351
681
  if (streamContext.conversationId && streamContext.conversationId !== event.conversationId) {
352
682
  detachedConversationSnapshotsRef.current.delete(streamContext.conversationId);
353
683
  }
354
- streamContext.conversationId = event.conversationId;
684
+ bindStreamToConversation(streamContext, event.conversationId);
355
685
  streamContext.didReportConversationChange = true;
356
686
  if (streamContext.detachedSnapshot?.length) {
357
687
  setDetachedConversationSnapshot(event.conversationId, streamContext.detachedSnapshot);
@@ -364,17 +694,19 @@ function useChat(options) {
364
694
  });
365
695
  refreshConversationIfVisibleWithRetry(event.conversationId, [0, 300, 900]);
366
696
  } else if (event.type === "done" && event.conversationId && streamContext.didReportConversationChange) {
697
+ bindStreamToConversation(streamContext, event.conversationId);
367
698
  if (streamContext.detachedSnapshot?.length) {
368
699
  setDetachedConversationSnapshot(event.conversationId, streamContext.detachedSnapshot);
369
700
  }
370
701
  refreshConversationIfVisibleWithRetry(event.conversationId, [0, 300, 900]);
371
702
  }
372
- const isTerminalEvent = event.type === "done" || event.type === "cancelled" || event.type === "error";
373
703
  if (isTerminalEvent) {
374
704
  streamContext.detachedSnapshot = void 0;
375
- }
376
- if (isTerminalEvent && activeStreamContextRef.current?.token === streamContext.token) {
377
- activeStreamContextRef.current = null;
705
+ streamContext.initialSnapshot = void 0;
706
+ finalizeStreamContext(streamContext);
707
+ if (conversationIdRef.current === streamContext.conversationId) {
708
+ setIsStreaming(hasLiveConversationStream(streamContext.conversationId));
709
+ }
378
710
  }
379
711
  return;
380
712
  }
@@ -386,6 +718,9 @@ function useChat(options) {
386
718
  switch (event.type) {
387
719
  case "start": {
388
720
  currentRunIdRef.current = event.run_id;
721
+ if (event.conversationId) {
722
+ bindStreamToConversation(streamContext, event.conversationId);
723
+ }
389
724
  if (pendingCancelRef.current) {
390
725
  const pendingCancel = pendingCancelFnRef.current || cancelRef.current;
391
726
  if (pendingCancel) {
@@ -724,7 +1059,7 @@ function useChat(options) {
724
1059
  });
725
1060
  resetStreamBuffers();
726
1061
  if (event.conversationId) {
727
- streamContext.conversationId = event.conversationId;
1062
+ bindStreamToConversation(streamContext, event.conversationId);
728
1063
  streamContext.didReportConversationChange = true;
729
1064
  setConversationId(event.conversationId);
730
1065
  onConversationChange?.(event.conversationId, event.title, {
@@ -734,13 +1069,13 @@ function useChat(options) {
734
1069
  });
735
1070
  }
736
1071
  if (event.title) setConversationTitle(event.title);
737
- setIsStreaming(false);
738
1072
  setIsLoading(false);
739
1073
  currentRunIdRef.current = null;
740
1074
  pendingInterruptStreamIdRef.current = null;
741
1075
  suppressStreamRef.current = false;
742
1076
  clearPendingCancel();
743
- activeStreamContextRef.current = null;
1077
+ finalizeStreamContext(streamContext);
1078
+ setIsStreaming(hasLiveConversationStream(conversationIdRef.current));
744
1079
  break;
745
1080
  }
746
1081
  case "cancelled": {
@@ -771,14 +1106,14 @@ function useChat(options) {
771
1106
  return updated;
772
1107
  });
773
1108
  resetStreamBuffers();
774
- setIsStreaming(false);
775
1109
  setIsLoading(false);
776
1110
  setPendingInterrupt(null);
777
1111
  currentRunIdRef.current = null;
778
1112
  pendingInterruptStreamIdRef.current = null;
779
1113
  suppressStreamRef.current = false;
780
1114
  clearPendingCancel();
781
- activeStreamContextRef.current = null;
1115
+ finalizeStreamContext(streamContext);
1116
+ setIsStreaming(hasLiveConversationStream(conversationIdRef.current));
782
1117
  break;
783
1118
  }
784
1119
  case "error": {
@@ -801,13 +1136,13 @@ function useChat(options) {
801
1136
  resetStreamBuffers();
802
1137
  setError(event.error);
803
1138
  onError?.(new Error(event.error));
804
- setIsStreaming(false);
805
1139
  setIsLoading(false);
806
1140
  currentRunIdRef.current = null;
807
1141
  pendingInterruptStreamIdRef.current = null;
808
1142
  suppressStreamRef.current = false;
809
1143
  clearPendingCancel();
810
- activeStreamContextRef.current = null;
1144
+ finalizeStreamContext(streamContext);
1145
+ setIsStreaming(hasLiveConversationStream(conversationIdRef.current));
811
1146
  break;
812
1147
  }
813
1148
  case "interrupt": {
@@ -825,7 +1160,20 @@ function useChat(options) {
825
1160
  break;
826
1161
  }
827
1162
  }
828
- }, [clearPendingCancel, flushTypewriter, onConversationChange, onError, refreshConversationIfVisibleWithRetry, resetStreamBuffers, setDetachedConversationSnapshot]);
1163
+ }, [
1164
+ bindStreamToConversation,
1165
+ clearPendingCancel,
1166
+ finalizeStreamContext,
1167
+ flushTypewriter,
1168
+ hasLiveConversationStream,
1169
+ onConversationChange,
1170
+ onError,
1171
+ refreshConversationIfVisibleWithRetry,
1172
+ resetStreamBuffers,
1173
+ setDetachedConversationSnapshot,
1174
+ syncDetachedSnapshotToVisibleConversation,
1175
+ touchStreamContext
1176
+ ]);
829
1177
  const trySendMessage = (0, import_react.useCallback)(async (content, sendOptions) => {
830
1178
  const trimmedContent = content.trim();
831
1179
  const hasAttachedResources = (sendOptions?.attachedResources?.length ?? 0) > 0;
@@ -858,15 +1206,27 @@ function useChat(options) {
858
1206
  blocks: [],
859
1207
  isComplete: false
860
1208
  };
1209
+ const shouldRenderUserMessage = trimmedContent.length > 0 || hasAttachedResources;
1210
+ const initialSnapshot = cloneMessages(normalizeConversationMessages(
1211
+ shouldRenderUserMessage ? [...messagesRef.current, userMessage, assistantMessage] : [...messagesRef.current, assistantMessage]
1212
+ ));
861
1213
  const streamContext = {
862
1214
  token: crypto.randomUUID(),
863
1215
  assistantMessageId: assistantMessage.id,
864
1216
  conversationId: conversationIdRef.current,
865
1217
  detached: false,
1218
+ terminal: false,
1219
+ suppressed: false,
1220
+ updatedAt: Date.now(),
1221
+ pendingCancel: false,
1222
+ initialSnapshot,
866
1223
  didReportConversationChange: false
867
1224
  };
1225
+ streamContextsRef.current.set(streamContext.token, streamContext);
1226
+ if (streamContext.conversationId) {
1227
+ bindStreamToConversation(streamContext, streamContext.conversationId);
1228
+ }
868
1229
  activeStreamContextRef.current = streamContext;
869
- const shouldRenderUserMessage = trimmedContent.length > 0 || hasAttachedResources;
870
1230
  setMessages(
871
1231
  (prev) => shouldRenderUserMessage ? [...prev, userMessage, assistantMessage] : [...prev, assistantMessage]
872
1232
  );
@@ -895,6 +1255,11 @@ function useChat(options) {
895
1255
  sessionResources: sessionResourceIds.length ? sessionResourceIds : void 0
896
1256
  }
897
1257
  );
1258
+ streamContext.cancel = cancel;
1259
+ if (streamContext.pendingCancel) {
1260
+ streamContext.pendingCancel = false;
1261
+ cancel();
1262
+ }
898
1263
  cancelRef.current = cancel;
899
1264
  if (pendingCancelRef.current) {
900
1265
  pendingCancelFnRef.current = cancel;
@@ -923,56 +1288,132 @@ function useChat(options) {
923
1288
  pendingInterruptStreamIdRef.current = null;
924
1289
  setIsLoading(false);
925
1290
  setIsStreaming(false);
926
- if (activeStreamContextRef.current?.token === streamContext.token) {
927
- activeStreamContextRef.current = null;
928
- }
1291
+ finalizeStreamContext(streamContext);
929
1292
  return false;
930
1293
  }
931
- }, [adapter, clearPendingCancel, handleStreamEvent, onError, resetStreamBuffers, sessionResources]);
1294
+ }, [
1295
+ adapter,
1296
+ bindStreamToConversation,
1297
+ clearPendingCancel,
1298
+ finalizeStreamContext,
1299
+ handleStreamEvent,
1300
+ onError,
1301
+ resetStreamBuffers,
1302
+ sessionResources
1303
+ ]);
932
1304
  const sendMessage = (0, import_react.useCallback)(async (content, sendOptions) => {
933
1305
  await trySendMessage(content, sendOptions);
934
1306
  }, [trySendMessage]);
1307
+ const getVisibleStreamContext = (0, import_react.useCallback)(() => {
1308
+ const activeContext = activeStreamContextRef.current;
1309
+ if (activeContext && !activeContext.detached && !activeContext.terminal) {
1310
+ return activeContext;
1311
+ }
1312
+ return getLatestConversationStreamContext(conversationIdRef.current);
1313
+ }, [getLatestConversationStreamContext]);
935
1314
  const stopStreaming = (0, import_react.useCallback)(() => {
936
- pendingCancelRef.current = true;
937
- pendingCancelFnRef.current = cancelRef.current;
938
- suppressStreamRef.current = true;
939
- if (cancelRef.current) {
940
- cancelRef.current();
941
- clearPendingCancel();
942
- cancelRef.current = null;
1315
+ const targetContext = getVisibleStreamContext();
1316
+ if (!targetContext) {
1317
+ setIsStreaming(false);
1318
+ setIsLoading(false);
1319
+ return;
943
1320
  }
944
- flushTypewriter();
945
- setMessages((prev) => {
946
- const last = [...prev].reverse().find((m) => m.role === "assistant");
947
- if (!last?.isStreaming) return prev;
948
- return prev.map((m) => {
949
- if (m.id !== last.id) return m;
950
- return {
951
- ...m,
952
- content: fullContentRef.current || m.content,
953
- thinking: m.thinking?.trimEnd(),
1321
+ targetContext.suppressed = true;
1322
+ touchStreamContext(targetContext);
1323
+ const isActiveVisibleStream = activeStreamContextRef.current?.token === targetContext.token && !targetContext.detached;
1324
+ if (isActiveVisibleStream) {
1325
+ pendingCancelRef.current = true;
1326
+ pendingCancelFnRef.current = cancelRef.current;
1327
+ suppressStreamRef.current = true;
1328
+ if (cancelRef.current) {
1329
+ cancelRef.current();
1330
+ clearPendingCancel();
1331
+ cancelRef.current = null;
1332
+ }
1333
+ flushTypewriter();
1334
+ setMessages((prev) => {
1335
+ const last = [...prev].reverse().find((m) => m.role === "assistant");
1336
+ if (!last?.isStreaming) return prev;
1337
+ return prev.map((m) => {
1338
+ if (m.id !== last.id) return m;
1339
+ return {
1340
+ ...m,
1341
+ content: fullContentRef.current || m.content,
1342
+ thinking: m.thinking?.trimEnd(),
1343
+ currentThinking: void 0,
1344
+ isStreaming: false,
1345
+ isThinkingStreaming: false,
1346
+ isThinkingPaused: false,
1347
+ isToolCallsStreaming: false,
1348
+ isComplete: true,
1349
+ toolCalls: m.toolCalls?.map(
1350
+ (tc) => tc.status === "running" ? { ...tc, status: "cancelled" } : tc
1351
+ ),
1352
+ blocks: [...currentBlocksRef.current]
1353
+ };
1354
+ });
1355
+ });
1356
+ resetStreamBuffers();
1357
+ currentRunIdRef.current = null;
1358
+ pendingInterruptStreamIdRef.current = null;
1359
+ activeStreamContextRef.current = null;
1360
+ setIsStreaming(false);
1361
+ setIsLoading(false);
1362
+ return;
1363
+ }
1364
+ suppressStreamRef.current = false;
1365
+ const cancelStream = targetContext.cancel;
1366
+ if (cancelStream) {
1367
+ cancelStream();
1368
+ } else {
1369
+ targetContext.pendingCancel = true;
1370
+ }
1371
+ const detachedSnapshot = targetContext.detachedSnapshot;
1372
+ if (detachedSnapshot?.length) {
1373
+ const assistantIndex = detachedSnapshot.findIndex((message) => message.id === targetContext.assistantMessageId);
1374
+ if (assistantIndex !== -1) {
1375
+ const assistant = detachedSnapshot[assistantIndex];
1376
+ detachedSnapshot[assistantIndex] = {
1377
+ ...assistant,
1378
+ thinking: assistant.thinking?.trimEnd(),
954
1379
  currentThinking: void 0,
955
1380
  isStreaming: false,
1381
+ isComplete: true,
956
1382
  isThinkingStreaming: false,
957
1383
  isThinkingPaused: false,
958
1384
  isToolCallsStreaming: false,
959
- isComplete: true,
960
- toolCalls: m.toolCalls?.map(
961
- (tc) => tc.status === "running" ? { ...tc, status: "cancelled" } : tc
962
- ),
963
- blocks: [...currentBlocksRef.current]
1385
+ toolCalls: assistant.toolCalls?.map((tc) => tc.status === "running" ? { ...tc, status: "cancelled" } : tc)
964
1386
  };
965
- });
1387
+ }
1388
+ setDetachedConversationSnapshot(targetContext.conversationId, detachedSnapshot);
1389
+ if (targetContext.conversationId && targetContext.conversationId === conversationIdRef.current) {
1390
+ setMessages(cloneMessages(detachedSnapshot));
1391
+ }
1392
+ }
1393
+ setIsStreaming(hasLiveConversationStream(conversationIdRef.current));
1394
+ setIsLoading(false);
1395
+ }, [
1396
+ clearPendingCancel,
1397
+ flushTypewriter,
1398
+ getVisibleStreamContext,
1399
+ hasLiveConversationStream,
1400
+ resetStreamBuffers,
1401
+ setDetachedConversationSnapshot,
1402
+ touchStreamContext
1403
+ ]);
1404
+ const cancelAllStreamContexts = (0, import_react.useCallback)(() => {
1405
+ streamContextsRef.current.forEach((context) => {
1406
+ context.suppressed = true;
1407
+ context.terminal = true;
1408
+ context.cancel?.();
966
1409
  });
967
- resetStreamBuffers();
968
- currentRunIdRef.current = null;
969
- pendingInterruptStreamIdRef.current = null;
1410
+ streamContextsRef.current.clear();
1411
+ conversationStreamTokensRef.current.clear();
970
1412
  activeStreamContextRef.current = null;
971
- setIsStreaming(false);
972
- setIsLoading(false);
973
- }, [clearPendingCancel, flushTypewriter, resetStreamBuffers]);
1413
+ cancelRef.current = null;
1414
+ }, []);
974
1415
  const clearMessages = (0, import_react.useCallback)(() => {
975
- cancelRef.current?.();
1416
+ cancelAllStreamContexts();
976
1417
  if (typewriterIntervalRef.current) clearTimeout(typewriterIntervalRef.current);
977
1418
  setMessages([]);
978
1419
  setError(null);
@@ -981,18 +1422,17 @@ function useChat(options) {
981
1422
  resetStreamBuffers();
982
1423
  currentRunIdRef.current = null;
983
1424
  pendingInterruptStreamIdRef.current = null;
984
- activeStreamContextRef.current = null;
985
1425
  clearPendingCancel();
986
1426
  suppressStreamRef.current = false;
987
1427
  detachedConversationSnapshotsRef.current.clear();
988
- }, [clearPendingCancel, resetStreamBuffers]);
1428
+ }, [cancelAllStreamContexts, clearPendingCancel, resetStreamBuffers]);
989
1429
  const loadConversation = (0, import_react.useCallback)(async (id, optionsArg) => {
990
1430
  const cancelActiveStream = optionsArg?.cancelActiveStream ?? true;
991
- const activeStream = activeStreamContextRef.current;
992
- if (activeStream && !activeStream.detached) {
1431
+ const visibleStream = getVisibleStreamContext();
1432
+ if (visibleStream) {
993
1433
  if (cancelActiveStream) {
994
1434
  stopStreaming();
995
- } else {
1435
+ } else if (activeStreamContextRef.current && !activeStreamContextRef.current.detached) {
996
1436
  detachActiveStream(optionsArg?.detachContext);
997
1437
  }
998
1438
  }
@@ -1011,6 +1451,9 @@ function useChat(options) {
1011
1451
  refreshConversationIfVisibleWithRetry(id, [250, 800, 1800]);
1012
1452
  }
1013
1453
  setMessages(nextMessages);
1454
+ setIsStreaming(
1455
+ hasLiveConversationStream(detail.id) || nextMessages.some((message) => !!message.isStreaming)
1456
+ );
1014
1457
  setConversationId(detail.id);
1015
1458
  setConversationTitle(detail.title);
1016
1459
  currentAgentIdRef.current = detail.agentId ?? currentAgentIdRef.current;
@@ -1021,18 +1464,24 @@ function useChat(options) {
1021
1464
  } finally {
1022
1465
  if (isMountedRef.current) setIsLoading(false);
1023
1466
  }
1024
- }, [adapter, detachActiveStream, onError, refreshConversationIfVisibleWithRetry, stopStreaming]);
1467
+ }, [
1468
+ adapter,
1469
+ detachActiveStream,
1470
+ getVisibleStreamContext,
1471
+ hasLiveConversationStream,
1472
+ onError,
1473
+ refreshConversationIfVisibleWithRetry,
1474
+ stopStreaming
1475
+ ]);
1025
1476
  const newConversation = (0, import_react.useCallback)((optionsArg) => {
1026
1477
  const cancelActiveStream = optionsArg?.cancelActiveStream ?? true;
1027
- const activeStream = activeStreamContextRef.current;
1028
- if (activeStream && !activeStream.detached) {
1478
+ const visibleStream = getVisibleStreamContext();
1479
+ if (visibleStream) {
1029
1480
  if (cancelActiveStream) {
1030
1481
  stopStreaming();
1031
- } else {
1482
+ } else if (activeStreamContextRef.current && !activeStreamContextRef.current.detached) {
1032
1483
  detachActiveStream(optionsArg?.detachContext);
1033
1484
  }
1034
- } else if (cancelActiveStream) {
1035
- cancelRef.current?.();
1036
1485
  }
1037
1486
  if (typewriterIntervalRef.current) clearTimeout(typewriterIntervalRef.current);
1038
1487
  setMessages([]);
@@ -1047,7 +1496,7 @@ function useChat(options) {
1047
1496
  pendingInterruptStreamIdRef.current = null;
1048
1497
  activeStreamContextRef.current = null;
1049
1498
  clearPendingCancel();
1050
- }, [clearPendingCancel, detachActiveStream, resetStreamBuffers, stopStreaming]);
1499
+ }, [clearPendingCancel, detachActiveStream, getVisibleStreamContext, resetStreamBuffers, stopStreaming]);
1051
1500
  const sendHitlResponse = (0, import_react.useCallback)((response) => {
1052
1501
  const runId = currentRunIdRef.current ?? void 0;
1053
1502
  const streamId = pendingInterruptStreamIdRef.current ?? void 0;