@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.
@@ -26,6 +26,213 @@ function cloneBlocks(blocks) {
26
26
  toolArgs: block.toolArgs ? { ...block.toolArgs } : block.toolArgs
27
27
  }));
28
28
  }
29
+ function updateDetachedSnapshotForEvent(streamContext, event) {
30
+ const snapshot = streamContext.detachedSnapshot;
31
+ if (!snapshot?.length) return false;
32
+ const assistantIndex = snapshot.findIndex((message) => message.id === streamContext.assistantMessageId);
33
+ if (assistantIndex === -1) return false;
34
+ const assistant = snapshot[assistantIndex];
35
+ switch (event.type) {
36
+ case "text": {
37
+ const rawContent = event.content;
38
+ if (!rawContent) return false;
39
+ const nextChunk = assistant.content ? rawContent : rawContent.trimStart();
40
+ if (!nextChunk) return false;
41
+ snapshot[assistantIndex] = {
42
+ ...assistant,
43
+ content: `${assistant.content}${nextChunk}`,
44
+ isStreaming: true,
45
+ isComplete: false
46
+ };
47
+ return true;
48
+ }
49
+ case "thinking": {
50
+ const rawThinking = event.content;
51
+ if (!rawThinking) return false;
52
+ const nextChunk = assistant.thinking ? rawThinking : rawThinking.trimStart();
53
+ if (!nextChunk) return false;
54
+ snapshot[assistantIndex] = {
55
+ ...assistant,
56
+ thinking: `${assistant.thinking ?? ""}${nextChunk}`,
57
+ currentThinking: `${assistant.currentThinking ?? ""}${nextChunk}`,
58
+ isStreaming: true,
59
+ isComplete: false,
60
+ isThinkingStreaming: true,
61
+ isThinkingPaused: false
62
+ };
63
+ return true;
64
+ }
65
+ case "tool_call": {
66
+ const tc = event.tool_call;
67
+ if (!tc) return false;
68
+ const toolName = tc.function?.name || tc.name || "";
69
+ const toolId = tc.id || "";
70
+ let args = {};
71
+ if (tc.args) {
72
+ args = tc.args;
73
+ } else if (tc.function?.arguments) {
74
+ try {
75
+ args = JSON.parse(tc.function.arguments);
76
+ } catch {
77
+ args = {};
78
+ }
79
+ }
80
+ const toolCalls = [...assistant.toolCalls || []];
81
+ toolCalls.push({
82
+ id: toolId,
83
+ name: toolName,
84
+ args,
85
+ status: "running"
86
+ });
87
+ const blocks = cloneBlocks(assistant.blocks) || [];
88
+ blocks.push({
89
+ type: "tool_call",
90
+ content: "",
91
+ timestamp: Date.now(),
92
+ toolName,
93
+ toolArgs: args,
94
+ toolCallId: toolId,
95
+ toolStatus: "running",
96
+ isIntermediate: true
97
+ });
98
+ snapshot[assistantIndex] = {
99
+ ...assistant,
100
+ toolCalls,
101
+ blocks,
102
+ isStreaming: true,
103
+ isComplete: false,
104
+ isThinkingStreaming: false,
105
+ isThinkingPaused: !!assistant.thinking,
106
+ isToolCallsStreaming: true
107
+ };
108
+ return true;
109
+ }
110
+ case "tool_args_chunk": {
111
+ const chunk = event.chunk;
112
+ if (!chunk) return false;
113
+ const toolCalls = [...assistant.toolCalls || []];
114
+ const toolCallIndex = findToolCallIndex(toolCalls, event.tool_call_id, event.tool_name);
115
+ if (toolCallIndex === -1) return false;
116
+ const existingRaw = toolCalls[toolCallIndex].argsRaw || "";
117
+ toolCalls[toolCallIndex] = {
118
+ ...toolCalls[toolCallIndex],
119
+ argsRaw: `${existingRaw}${chunk}`
120
+ };
121
+ const blocks = cloneBlocks(assistant.blocks) || [];
122
+ const blockIndex = blocks.findIndex(
123
+ (block) => block.type === "tool_call" && (block.toolCallId === event.tool_call_id || !event.tool_call_id && event.tool_name && block.toolName === event.tool_name)
124
+ );
125
+ if (blockIndex !== -1) {
126
+ blocks[blockIndex] = {
127
+ ...blocks[blockIndex],
128
+ toolArgsRaw: `${blocks[blockIndex].toolArgsRaw || ""}${chunk}`
129
+ };
130
+ }
131
+ snapshot[assistantIndex] = {
132
+ ...assistant,
133
+ toolCalls,
134
+ blocks
135
+ };
136
+ return true;
137
+ }
138
+ case "tool_args": {
139
+ const toolCalls = [...assistant.toolCalls || []];
140
+ const toolCallIndex = findToolCallIndex(toolCalls, event.tool_call_id, event.tool_name);
141
+ if (toolCallIndex === -1) return false;
142
+ toolCalls[toolCallIndex] = {
143
+ ...toolCalls[toolCallIndex],
144
+ args: event.args || {},
145
+ argsRaw: void 0
146
+ };
147
+ const blocks = cloneBlocks(assistant.blocks) || [];
148
+ const blockIndex = blocks.findIndex(
149
+ (block) => block.type === "tool_call" && (block.toolCallId === event.tool_call_id || !event.tool_call_id && event.tool_name && block.toolName === event.tool_name)
150
+ );
151
+ if (blockIndex !== -1) {
152
+ blocks[blockIndex] = {
153
+ ...blocks[blockIndex],
154
+ toolArgs: event.args || {},
155
+ toolArgsRaw: void 0
156
+ };
157
+ }
158
+ snapshot[assistantIndex] = {
159
+ ...assistant,
160
+ toolCalls,
161
+ blocks
162
+ };
163
+ return true;
164
+ }
165
+ case "tool_result": {
166
+ const toolCalls = (assistant.toolCalls || []).map((toolCall) => toolCall.id === event.tool_call_id ? { ...toolCall, status: "completed", result: event.result } : toolCall);
167
+ const hasRunning = toolCalls.some((toolCall) => toolCall.status === "running");
168
+ const blocks = cloneBlocks(assistant.blocks) || [];
169
+ const toolBlockIndex = blocks.findIndex(
170
+ (block) => block.type === "tool_call" && block.toolCallId === event.tool_call_id
171
+ );
172
+ if (toolBlockIndex !== -1) {
173
+ blocks[toolBlockIndex] = {
174
+ ...blocks[toolBlockIndex],
175
+ toolStatus: "completed"
176
+ };
177
+ }
178
+ blocks.push({
179
+ type: "tool_result",
180
+ content: typeof event.result === "string" ? event.result : JSON.stringify(event.result),
181
+ timestamp: Date.now(),
182
+ toolName: toolBlockIndex !== -1 ? blocks[toolBlockIndex].toolName : void 0,
183
+ toolCallId: event.tool_call_id,
184
+ isIntermediate: true
185
+ });
186
+ snapshot[assistantIndex] = {
187
+ ...assistant,
188
+ toolCalls,
189
+ blocks,
190
+ isToolCallsStreaming: hasRunning
191
+ };
192
+ return true;
193
+ }
194
+ case "done": {
195
+ snapshot[assistantIndex] = {
196
+ ...assistant,
197
+ thinking: assistant.thinking?.trimEnd(),
198
+ currentThinking: void 0,
199
+ isStreaming: false,
200
+ isComplete: true,
201
+ isThinkingStreaming: false,
202
+ isThinkingPaused: false,
203
+ isToolCallsStreaming: false
204
+ };
205
+ return true;
206
+ }
207
+ case "cancelled": {
208
+ snapshot[assistantIndex] = {
209
+ ...assistant,
210
+ thinking: assistant.thinking?.trimEnd(),
211
+ currentThinking: void 0,
212
+ isStreaming: false,
213
+ isComplete: true,
214
+ isThinkingStreaming: false,
215
+ isThinkingPaused: false,
216
+ isToolCallsStreaming: false
217
+ };
218
+ return true;
219
+ }
220
+ case "error": {
221
+ snapshot[assistantIndex] = {
222
+ ...assistant,
223
+ content: assistant.content || `Error: ${event.error}`,
224
+ isStreaming: false,
225
+ isComplete: true,
226
+ isThinkingStreaming: false,
227
+ isThinkingPaused: false,
228
+ isToolCallsStreaming: false
229
+ };
230
+ return true;
231
+ }
232
+ default:
233
+ return false;
234
+ }
235
+ }
29
236
  function cloneMessages(messages) {
30
237
  return messages.map((message) => ({
31
238
  ...message,
@@ -76,6 +283,8 @@ function useChat(options) {
76
283
  const pendingCancelFnRef = useRef(null);
77
284
  const suppressStreamRef = useRef(false);
78
285
  const activeStreamContextRef = useRef(null);
286
+ const streamContextsRef = useRef(/* @__PURE__ */ new Map());
287
+ const conversationStreamTokensRef = useRef(/* @__PURE__ */ new Map());
79
288
  const currentBlocksRef = useRef([]);
80
289
  const currentTextBlockIndexRef = useRef(-1);
81
290
  const needsContentClearRef = useRef(false);
@@ -99,6 +308,13 @@ function useChat(options) {
99
308
  return () => {
100
309
  isMountedRef.current = false;
101
310
  cancelRef.current?.();
311
+ streamContextsRef.current.forEach((context) => {
312
+ context.suppressed = true;
313
+ context.terminal = true;
314
+ context.cancel?.();
315
+ });
316
+ streamContextsRef.current.clear();
317
+ conversationStreamTokensRef.current.clear();
102
318
  if (typewriterIntervalRef.current) clearTimeout(typewriterIntervalRef.current);
103
319
  };
104
320
  }, []);
@@ -176,12 +392,89 @@ function useChat(options) {
176
392
  map.delete(oldestKey);
177
393
  }
178
394
  }, []);
395
+ const upsertConversationStreamToken = useCallback((id, token) => {
396
+ const map = conversationStreamTokensRef.current;
397
+ const existing = map.get(id);
398
+ if (existing) {
399
+ existing.delete(token);
400
+ existing.add(token);
401
+ return;
402
+ }
403
+ map.set(id, /* @__PURE__ */ new Set([token]));
404
+ }, []);
405
+ const removeConversationStreamToken = useCallback((id, token) => {
406
+ const map = conversationStreamTokensRef.current;
407
+ const tokens = map.get(id);
408
+ if (!tokens) return;
409
+ tokens.delete(token);
410
+ if (tokens.size === 0) {
411
+ map.delete(id);
412
+ }
413
+ }, []);
414
+ const bindStreamToConversation = useCallback((streamContext, id) => {
415
+ const nextConversationId = typeof id === "string" && id.trim().length > 0 ? id : null;
416
+ const previousConversationId = streamContext.conversationId;
417
+ if (previousConversationId && previousConversationId !== nextConversationId) {
418
+ removeConversationStreamToken(previousConversationId, streamContext.token);
419
+ }
420
+ streamContext.conversationId = nextConversationId;
421
+ if (nextConversationId) {
422
+ upsertConversationStreamToken(nextConversationId, streamContext.token);
423
+ }
424
+ }, [removeConversationStreamToken, upsertConversationStreamToken]);
425
+ const touchStreamContext = useCallback((streamContext) => {
426
+ streamContext.updatedAt = Date.now();
427
+ const id = streamContext.conversationId;
428
+ if (!id) return;
429
+ upsertConversationStreamToken(id, streamContext.token);
430
+ }, [upsertConversationStreamToken]);
431
+ const getLatestConversationStreamContext = useCallback((id) => {
432
+ if (!id) return null;
433
+ const tokenSet = conversationStreamTokensRef.current.get(id);
434
+ if (!tokenSet || tokenSet.size === 0) return null;
435
+ const tokens = Array.from(tokenSet.values()).reverse();
436
+ for (const token of tokens) {
437
+ const context = streamContextsRef.current.get(token);
438
+ if (!context || context.terminal) {
439
+ tokenSet.delete(token);
440
+ continue;
441
+ }
442
+ if (context.suppressed) {
443
+ continue;
444
+ }
445
+ return context;
446
+ }
447
+ if (tokenSet.size === 0) {
448
+ conversationStreamTokensRef.current.delete(id);
449
+ }
450
+ return null;
451
+ }, []);
452
+ const hasLiveConversationStream = useCallback((id) => {
453
+ return getLatestConversationStreamContext(id) !== null;
454
+ }, [getLatestConversationStreamContext]);
455
+ const finalizeStreamContext = useCallback((streamContext) => {
456
+ if (streamContext.terminal) {
457
+ return;
458
+ }
459
+ streamContext.terminal = true;
460
+ if (streamContext.conversationId) {
461
+ removeConversationStreamToken(streamContext.conversationId, streamContext.token);
462
+ }
463
+ streamContextsRef.current.delete(streamContext.token);
464
+ if (activeStreamContextRef.current?.token === streamContext.token) {
465
+ activeStreamContextRef.current = null;
466
+ }
467
+ }, [removeConversationStreamToken]);
179
468
  const detachActiveStream = useCallback((detachContext) => {
180
469
  const context = activeStreamContextRef.current;
181
- if (!context || context.detached) {
470
+ if (!context || context.detached || context.terminal) {
182
471
  return;
183
472
  }
184
- const detachedSnapshot = cloneMessages(normalizeConversationMessages(messagesRef.current));
473
+ const normalizedLiveMessages = cloneMessages(normalizeConversationMessages(messagesRef.current));
474
+ const liveSnapshotHasAssistant = normalizedLiveMessages.some(
475
+ (message) => message.id === context.assistantMessageId
476
+ );
477
+ const detachedSnapshot = liveSnapshotHasAssistant ? normalizedLiveMessages : context.initialSnapshot ? cloneMessages(context.initialSnapshot) : normalizedLiveMessages;
185
478
  const detachedAssistantMessageIndex = detachedSnapshot.findIndex(
186
479
  (message) => message.id === context.assistantMessageId
187
480
  );
@@ -207,7 +500,9 @@ function useChat(options) {
207
500
  setDetachedConversationSnapshot(context.conversationId, detachedSnapshot);
208
501
  }
209
502
  context.detached = true;
503
+ context.suppressed = false;
210
504
  context.detachContext = detachContext;
505
+ touchStreamContext(context);
211
506
  if (typewriterIntervalRef.current) {
212
507
  clearTimeout(typewriterIntervalRef.current);
213
508
  typewriterIntervalRef.current = null;
@@ -221,7 +516,7 @@ function useChat(options) {
221
516
  pendingInterruptStreamIdRef.current = null;
222
517
  clearPendingCancel();
223
518
  suppressStreamRef.current = false;
224
- }, [clearPendingCancel, resetStreamBuffers, setDetachedConversationSnapshot]);
519
+ }, [clearPendingCancel, resetStreamBuffers, setDetachedConversationSnapshot, touchStreamContext]);
225
520
  const refreshConversationIfVisible = useCallback(async (id) => {
226
521
  if (!id || conversationIdRef.current !== id) return false;
227
522
  try {
@@ -232,12 +527,13 @@ function useChat(options) {
232
527
  detachedConversationSnapshotsRef.current.delete(id);
233
528
  setMessages(normalizedMessages);
234
529
  setConversationTitle(detail.title ?? null);
530
+ setIsStreaming(hasLiveConversationStream(id) || normalizedMessages.some((message) => !!message.isStreaming));
235
531
  return true;
236
532
  } catch (refreshError) {
237
533
  console.warn("[useChat] Failed to refresh conversation after detached stream completion:", refreshError);
238
534
  return false;
239
535
  }
240
- }, [adapter]);
536
+ }, [adapter, hasLiveConversationStream]);
241
537
  const refreshConversationIfVisibleWithRetry = useCallback((id, retryDelaysMs) => {
242
538
  if (!id) return;
243
539
  void (async () => {
@@ -255,15 +551,49 @@ function useChat(options) {
255
551
  }
256
552
  })();
257
553
  }, [refreshConversationIfVisible]);
554
+ const syncDetachedSnapshotToVisibleConversation = useCallback((streamContext) => {
555
+ const id = streamContext.conversationId;
556
+ if (!id || conversationIdRef.current !== id) return;
557
+ const activeContext = activeStreamContextRef.current;
558
+ if (activeContext && !activeContext.detached && activeContext.conversationId === id) return;
559
+ if (!streamContext.detachedSnapshot?.length) return;
560
+ const nextMessages = cloneMessages(streamContext.detachedSnapshot);
561
+ setMessages(nextMessages);
562
+ setIsStreaming(
563
+ hasLiveConversationStream(id) || nextMessages.some((message) => message.isStreaming)
564
+ );
565
+ }, [hasLiveConversationStream]);
258
566
  const handleStreamEvent = useCallback((event, streamContext) => {
259
567
  if (!isMountedRef.current) return;
568
+ if (streamContext.terminal) return;
569
+ touchStreamContext(streamContext);
570
+ const isTerminalEvent = event.type === "done" || event.type === "cancelled" || event.type === "error";
571
+ if (streamContext.suppressed && event.type !== "start" && !isTerminalEvent) {
572
+ return;
573
+ }
260
574
  const isActiveStream = activeStreamContextRef.current?.token === streamContext.token && !streamContext.detached;
261
575
  if (!isActiveStream) {
576
+ if (event.type === "start" && event.conversationId) {
577
+ if (streamContext.conversationId && streamContext.conversationId !== event.conversationId) {
578
+ detachedConversationSnapshotsRef.current.delete(streamContext.conversationId);
579
+ }
580
+ bindStreamToConversation(streamContext, event.conversationId);
581
+ if (streamContext.detachedSnapshot?.length) {
582
+ setDetachedConversationSnapshot(event.conversationId, streamContext.detachedSnapshot);
583
+ }
584
+ }
585
+ const snapshotUpdated = updateDetachedSnapshotForEvent(streamContext, event);
586
+ if (snapshotUpdated && streamContext.conversationId && streamContext.detachedSnapshot?.length) {
587
+ setDetachedConversationSnapshot(streamContext.conversationId, streamContext.detachedSnapshot);
588
+ }
589
+ if (snapshotUpdated) {
590
+ syncDetachedSnapshotToVisibleConversation(streamContext);
591
+ }
262
592
  if (event.type === "done" && event.conversationId && !streamContext.didReportConversationChange) {
263
593
  if (streamContext.conversationId && streamContext.conversationId !== event.conversationId) {
264
594
  detachedConversationSnapshotsRef.current.delete(streamContext.conversationId);
265
595
  }
266
- streamContext.conversationId = event.conversationId;
596
+ bindStreamToConversation(streamContext, event.conversationId);
267
597
  streamContext.didReportConversationChange = true;
268
598
  if (streamContext.detachedSnapshot?.length) {
269
599
  setDetachedConversationSnapshot(event.conversationId, streamContext.detachedSnapshot);
@@ -276,17 +606,19 @@ function useChat(options) {
276
606
  });
277
607
  refreshConversationIfVisibleWithRetry(event.conversationId, [0, 300, 900]);
278
608
  } else if (event.type === "done" && event.conversationId && streamContext.didReportConversationChange) {
609
+ bindStreamToConversation(streamContext, event.conversationId);
279
610
  if (streamContext.detachedSnapshot?.length) {
280
611
  setDetachedConversationSnapshot(event.conversationId, streamContext.detachedSnapshot);
281
612
  }
282
613
  refreshConversationIfVisibleWithRetry(event.conversationId, [0, 300, 900]);
283
614
  }
284
- const isTerminalEvent = event.type === "done" || event.type === "cancelled" || event.type === "error";
285
615
  if (isTerminalEvent) {
286
616
  streamContext.detachedSnapshot = void 0;
287
- }
288
- if (isTerminalEvent && activeStreamContextRef.current?.token === streamContext.token) {
289
- activeStreamContextRef.current = null;
617
+ streamContext.initialSnapshot = void 0;
618
+ finalizeStreamContext(streamContext);
619
+ if (conversationIdRef.current === streamContext.conversationId) {
620
+ setIsStreaming(hasLiveConversationStream(streamContext.conversationId));
621
+ }
290
622
  }
291
623
  return;
292
624
  }
@@ -298,6 +630,9 @@ function useChat(options) {
298
630
  switch (event.type) {
299
631
  case "start": {
300
632
  currentRunIdRef.current = event.run_id;
633
+ if (event.conversationId) {
634
+ bindStreamToConversation(streamContext, event.conversationId);
635
+ }
301
636
  if (pendingCancelRef.current) {
302
637
  const pendingCancel = pendingCancelFnRef.current || cancelRef.current;
303
638
  if (pendingCancel) {
@@ -636,7 +971,7 @@ function useChat(options) {
636
971
  });
637
972
  resetStreamBuffers();
638
973
  if (event.conversationId) {
639
- streamContext.conversationId = event.conversationId;
974
+ bindStreamToConversation(streamContext, event.conversationId);
640
975
  streamContext.didReportConversationChange = true;
641
976
  setConversationId(event.conversationId);
642
977
  onConversationChange?.(event.conversationId, event.title, {
@@ -646,13 +981,13 @@ function useChat(options) {
646
981
  });
647
982
  }
648
983
  if (event.title) setConversationTitle(event.title);
649
- setIsStreaming(false);
650
984
  setIsLoading(false);
651
985
  currentRunIdRef.current = null;
652
986
  pendingInterruptStreamIdRef.current = null;
653
987
  suppressStreamRef.current = false;
654
988
  clearPendingCancel();
655
- activeStreamContextRef.current = null;
989
+ finalizeStreamContext(streamContext);
990
+ setIsStreaming(hasLiveConversationStream(conversationIdRef.current));
656
991
  break;
657
992
  }
658
993
  case "cancelled": {
@@ -683,14 +1018,14 @@ function useChat(options) {
683
1018
  return updated;
684
1019
  });
685
1020
  resetStreamBuffers();
686
- setIsStreaming(false);
687
1021
  setIsLoading(false);
688
1022
  setPendingInterrupt(null);
689
1023
  currentRunIdRef.current = null;
690
1024
  pendingInterruptStreamIdRef.current = null;
691
1025
  suppressStreamRef.current = false;
692
1026
  clearPendingCancel();
693
- activeStreamContextRef.current = null;
1027
+ finalizeStreamContext(streamContext);
1028
+ setIsStreaming(hasLiveConversationStream(conversationIdRef.current));
694
1029
  break;
695
1030
  }
696
1031
  case "error": {
@@ -713,13 +1048,13 @@ function useChat(options) {
713
1048
  resetStreamBuffers();
714
1049
  setError(event.error);
715
1050
  onError?.(new Error(event.error));
716
- setIsStreaming(false);
717
1051
  setIsLoading(false);
718
1052
  currentRunIdRef.current = null;
719
1053
  pendingInterruptStreamIdRef.current = null;
720
1054
  suppressStreamRef.current = false;
721
1055
  clearPendingCancel();
722
- activeStreamContextRef.current = null;
1056
+ finalizeStreamContext(streamContext);
1057
+ setIsStreaming(hasLiveConversationStream(conversationIdRef.current));
723
1058
  break;
724
1059
  }
725
1060
  case "interrupt": {
@@ -737,7 +1072,20 @@ function useChat(options) {
737
1072
  break;
738
1073
  }
739
1074
  }
740
- }, [clearPendingCancel, flushTypewriter, onConversationChange, onError, refreshConversationIfVisibleWithRetry, resetStreamBuffers, setDetachedConversationSnapshot]);
1075
+ }, [
1076
+ bindStreamToConversation,
1077
+ clearPendingCancel,
1078
+ finalizeStreamContext,
1079
+ flushTypewriter,
1080
+ hasLiveConversationStream,
1081
+ onConversationChange,
1082
+ onError,
1083
+ refreshConversationIfVisibleWithRetry,
1084
+ resetStreamBuffers,
1085
+ setDetachedConversationSnapshot,
1086
+ syncDetachedSnapshotToVisibleConversation,
1087
+ touchStreamContext
1088
+ ]);
741
1089
  const trySendMessage = useCallback(async (content, sendOptions) => {
742
1090
  const trimmedContent = content.trim();
743
1091
  const hasAttachedResources = (sendOptions?.attachedResources?.length ?? 0) > 0;
@@ -770,15 +1118,27 @@ function useChat(options) {
770
1118
  blocks: [],
771
1119
  isComplete: false
772
1120
  };
1121
+ const shouldRenderUserMessage = trimmedContent.length > 0 || hasAttachedResources;
1122
+ const initialSnapshot = cloneMessages(normalizeConversationMessages(
1123
+ shouldRenderUserMessage ? [...messagesRef.current, userMessage, assistantMessage] : [...messagesRef.current, assistantMessage]
1124
+ ));
773
1125
  const streamContext = {
774
1126
  token: crypto.randomUUID(),
775
1127
  assistantMessageId: assistantMessage.id,
776
1128
  conversationId: conversationIdRef.current,
777
1129
  detached: false,
1130
+ terminal: false,
1131
+ suppressed: false,
1132
+ updatedAt: Date.now(),
1133
+ pendingCancel: false,
1134
+ initialSnapshot,
778
1135
  didReportConversationChange: false
779
1136
  };
1137
+ streamContextsRef.current.set(streamContext.token, streamContext);
1138
+ if (streamContext.conversationId) {
1139
+ bindStreamToConversation(streamContext, streamContext.conversationId);
1140
+ }
780
1141
  activeStreamContextRef.current = streamContext;
781
- const shouldRenderUserMessage = trimmedContent.length > 0 || hasAttachedResources;
782
1142
  setMessages(
783
1143
  (prev) => shouldRenderUserMessage ? [...prev, userMessage, assistantMessage] : [...prev, assistantMessage]
784
1144
  );
@@ -807,6 +1167,11 @@ function useChat(options) {
807
1167
  sessionResources: sessionResourceIds.length ? sessionResourceIds : void 0
808
1168
  }
809
1169
  );
1170
+ streamContext.cancel = cancel;
1171
+ if (streamContext.pendingCancel) {
1172
+ streamContext.pendingCancel = false;
1173
+ cancel();
1174
+ }
810
1175
  cancelRef.current = cancel;
811
1176
  if (pendingCancelRef.current) {
812
1177
  pendingCancelFnRef.current = cancel;
@@ -835,56 +1200,132 @@ function useChat(options) {
835
1200
  pendingInterruptStreamIdRef.current = null;
836
1201
  setIsLoading(false);
837
1202
  setIsStreaming(false);
838
- if (activeStreamContextRef.current?.token === streamContext.token) {
839
- activeStreamContextRef.current = null;
840
- }
1203
+ finalizeStreamContext(streamContext);
841
1204
  return false;
842
1205
  }
843
- }, [adapter, clearPendingCancel, handleStreamEvent, onError, resetStreamBuffers, sessionResources]);
1206
+ }, [
1207
+ adapter,
1208
+ bindStreamToConversation,
1209
+ clearPendingCancel,
1210
+ finalizeStreamContext,
1211
+ handleStreamEvent,
1212
+ onError,
1213
+ resetStreamBuffers,
1214
+ sessionResources
1215
+ ]);
844
1216
  const sendMessage = useCallback(async (content, sendOptions) => {
845
1217
  await trySendMessage(content, sendOptions);
846
1218
  }, [trySendMessage]);
1219
+ const getVisibleStreamContext = useCallback(() => {
1220
+ const activeContext = activeStreamContextRef.current;
1221
+ if (activeContext && !activeContext.detached && !activeContext.terminal) {
1222
+ return activeContext;
1223
+ }
1224
+ return getLatestConversationStreamContext(conversationIdRef.current);
1225
+ }, [getLatestConversationStreamContext]);
847
1226
  const stopStreaming = useCallback(() => {
848
- pendingCancelRef.current = true;
849
- pendingCancelFnRef.current = cancelRef.current;
850
- suppressStreamRef.current = true;
851
- if (cancelRef.current) {
852
- cancelRef.current();
853
- clearPendingCancel();
854
- cancelRef.current = null;
1227
+ const targetContext = getVisibleStreamContext();
1228
+ if (!targetContext) {
1229
+ setIsStreaming(false);
1230
+ setIsLoading(false);
1231
+ return;
855
1232
  }
856
- flushTypewriter();
857
- setMessages((prev) => {
858
- const last = [...prev].reverse().find((m) => m.role === "assistant");
859
- if (!last?.isStreaming) return prev;
860
- return prev.map((m) => {
861
- if (m.id !== last.id) return m;
862
- return {
863
- ...m,
864
- content: fullContentRef.current || m.content,
865
- thinking: m.thinking?.trimEnd(),
1233
+ targetContext.suppressed = true;
1234
+ touchStreamContext(targetContext);
1235
+ const isActiveVisibleStream = activeStreamContextRef.current?.token === targetContext.token && !targetContext.detached;
1236
+ if (isActiveVisibleStream) {
1237
+ pendingCancelRef.current = true;
1238
+ pendingCancelFnRef.current = cancelRef.current;
1239
+ suppressStreamRef.current = true;
1240
+ if (cancelRef.current) {
1241
+ cancelRef.current();
1242
+ clearPendingCancel();
1243
+ cancelRef.current = null;
1244
+ }
1245
+ flushTypewriter();
1246
+ setMessages((prev) => {
1247
+ const last = [...prev].reverse().find((m) => m.role === "assistant");
1248
+ if (!last?.isStreaming) return prev;
1249
+ return prev.map((m) => {
1250
+ if (m.id !== last.id) return m;
1251
+ return {
1252
+ ...m,
1253
+ content: fullContentRef.current || m.content,
1254
+ thinking: m.thinking?.trimEnd(),
1255
+ currentThinking: void 0,
1256
+ isStreaming: false,
1257
+ isThinkingStreaming: false,
1258
+ isThinkingPaused: false,
1259
+ isToolCallsStreaming: false,
1260
+ isComplete: true,
1261
+ toolCalls: m.toolCalls?.map(
1262
+ (tc) => tc.status === "running" ? { ...tc, status: "cancelled" } : tc
1263
+ ),
1264
+ blocks: [...currentBlocksRef.current]
1265
+ };
1266
+ });
1267
+ });
1268
+ resetStreamBuffers();
1269
+ currentRunIdRef.current = null;
1270
+ pendingInterruptStreamIdRef.current = null;
1271
+ activeStreamContextRef.current = null;
1272
+ setIsStreaming(false);
1273
+ setIsLoading(false);
1274
+ return;
1275
+ }
1276
+ suppressStreamRef.current = false;
1277
+ const cancelStream = targetContext.cancel;
1278
+ if (cancelStream) {
1279
+ cancelStream();
1280
+ } else {
1281
+ targetContext.pendingCancel = true;
1282
+ }
1283
+ const detachedSnapshot = targetContext.detachedSnapshot;
1284
+ if (detachedSnapshot?.length) {
1285
+ const assistantIndex = detachedSnapshot.findIndex((message) => message.id === targetContext.assistantMessageId);
1286
+ if (assistantIndex !== -1) {
1287
+ const assistant = detachedSnapshot[assistantIndex];
1288
+ detachedSnapshot[assistantIndex] = {
1289
+ ...assistant,
1290
+ thinking: assistant.thinking?.trimEnd(),
866
1291
  currentThinking: void 0,
867
1292
  isStreaming: false,
1293
+ isComplete: true,
868
1294
  isThinkingStreaming: false,
869
1295
  isThinkingPaused: false,
870
1296
  isToolCallsStreaming: false,
871
- isComplete: true,
872
- toolCalls: m.toolCalls?.map(
873
- (tc) => tc.status === "running" ? { ...tc, status: "cancelled" } : tc
874
- ),
875
- blocks: [...currentBlocksRef.current]
1297
+ toolCalls: assistant.toolCalls?.map((tc) => tc.status === "running" ? { ...tc, status: "cancelled" } : tc)
876
1298
  };
877
- });
1299
+ }
1300
+ setDetachedConversationSnapshot(targetContext.conversationId, detachedSnapshot);
1301
+ if (targetContext.conversationId && targetContext.conversationId === conversationIdRef.current) {
1302
+ setMessages(cloneMessages(detachedSnapshot));
1303
+ }
1304
+ }
1305
+ setIsStreaming(hasLiveConversationStream(conversationIdRef.current));
1306
+ setIsLoading(false);
1307
+ }, [
1308
+ clearPendingCancel,
1309
+ flushTypewriter,
1310
+ getVisibleStreamContext,
1311
+ hasLiveConversationStream,
1312
+ resetStreamBuffers,
1313
+ setDetachedConversationSnapshot,
1314
+ touchStreamContext
1315
+ ]);
1316
+ const cancelAllStreamContexts = useCallback(() => {
1317
+ streamContextsRef.current.forEach((context) => {
1318
+ context.suppressed = true;
1319
+ context.terminal = true;
1320
+ context.cancel?.();
878
1321
  });
879
- resetStreamBuffers();
880
- currentRunIdRef.current = null;
881
- pendingInterruptStreamIdRef.current = null;
1322
+ streamContextsRef.current.clear();
1323
+ conversationStreamTokensRef.current.clear();
882
1324
  activeStreamContextRef.current = null;
883
- setIsStreaming(false);
884
- setIsLoading(false);
885
- }, [clearPendingCancel, flushTypewriter, resetStreamBuffers]);
1325
+ cancelRef.current = null;
1326
+ }, []);
886
1327
  const clearMessages = useCallback(() => {
887
- cancelRef.current?.();
1328
+ cancelAllStreamContexts();
888
1329
  if (typewriterIntervalRef.current) clearTimeout(typewriterIntervalRef.current);
889
1330
  setMessages([]);
890
1331
  setError(null);
@@ -893,18 +1334,17 @@ function useChat(options) {
893
1334
  resetStreamBuffers();
894
1335
  currentRunIdRef.current = null;
895
1336
  pendingInterruptStreamIdRef.current = null;
896
- activeStreamContextRef.current = null;
897
1337
  clearPendingCancel();
898
1338
  suppressStreamRef.current = false;
899
1339
  detachedConversationSnapshotsRef.current.clear();
900
- }, [clearPendingCancel, resetStreamBuffers]);
1340
+ }, [cancelAllStreamContexts, clearPendingCancel, resetStreamBuffers]);
901
1341
  const loadConversation = useCallback(async (id, optionsArg) => {
902
1342
  const cancelActiveStream = optionsArg?.cancelActiveStream ?? true;
903
- const activeStream = activeStreamContextRef.current;
904
- if (activeStream && !activeStream.detached) {
1343
+ const visibleStream = getVisibleStreamContext();
1344
+ if (visibleStream) {
905
1345
  if (cancelActiveStream) {
906
1346
  stopStreaming();
907
- } else {
1347
+ } else if (activeStreamContextRef.current && !activeStreamContextRef.current.detached) {
908
1348
  detachActiveStream(optionsArg?.detachContext);
909
1349
  }
910
1350
  }
@@ -923,6 +1363,9 @@ function useChat(options) {
923
1363
  refreshConversationIfVisibleWithRetry(id, [250, 800, 1800]);
924
1364
  }
925
1365
  setMessages(nextMessages);
1366
+ setIsStreaming(
1367
+ hasLiveConversationStream(detail.id) || nextMessages.some((message) => !!message.isStreaming)
1368
+ );
926
1369
  setConversationId(detail.id);
927
1370
  setConversationTitle(detail.title);
928
1371
  currentAgentIdRef.current = detail.agentId ?? currentAgentIdRef.current;
@@ -933,18 +1376,24 @@ function useChat(options) {
933
1376
  } finally {
934
1377
  if (isMountedRef.current) setIsLoading(false);
935
1378
  }
936
- }, [adapter, detachActiveStream, onError, refreshConversationIfVisibleWithRetry, stopStreaming]);
1379
+ }, [
1380
+ adapter,
1381
+ detachActiveStream,
1382
+ getVisibleStreamContext,
1383
+ hasLiveConversationStream,
1384
+ onError,
1385
+ refreshConversationIfVisibleWithRetry,
1386
+ stopStreaming
1387
+ ]);
937
1388
  const newConversation = useCallback((optionsArg) => {
938
1389
  const cancelActiveStream = optionsArg?.cancelActiveStream ?? true;
939
- const activeStream = activeStreamContextRef.current;
940
- if (activeStream && !activeStream.detached) {
1390
+ const visibleStream = getVisibleStreamContext();
1391
+ if (visibleStream) {
941
1392
  if (cancelActiveStream) {
942
1393
  stopStreaming();
943
- } else {
1394
+ } else if (activeStreamContextRef.current && !activeStreamContextRef.current.detached) {
944
1395
  detachActiveStream(optionsArg?.detachContext);
945
1396
  }
946
- } else if (cancelActiveStream) {
947
- cancelRef.current?.();
948
1397
  }
949
1398
  if (typewriterIntervalRef.current) clearTimeout(typewriterIntervalRef.current);
950
1399
  setMessages([]);
@@ -959,7 +1408,7 @@ function useChat(options) {
959
1408
  pendingInterruptStreamIdRef.current = null;
960
1409
  activeStreamContextRef.current = null;
961
1410
  clearPendingCancel();
962
- }, [clearPendingCancel, detachActiveStream, resetStreamBuffers, stopStreaming]);
1411
+ }, [clearPendingCancel, detachActiveStream, getVisibleStreamContext, resetStreamBuffers, stopStreaming]);
963
1412
  const sendHitlResponse = useCallback((response) => {
964
1413
  const runId = currentRunIdRef.current ?? void 0;
965
1414
  const streamId = pendingInterruptStreamIdRef.current ?? void 0;