@alia.onl/sdk 22.0.1 → 22.1.1
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/CHANGELOG.md +18 -0
- package/package.json +1 -1
- package/src/components/AliaChatContent.tsx +8 -0
- package/src/components/AliaChatSheet.tsx +15 -15
- package/src/runtime/chat-turn-control.ts +74 -0
- package/src/runtime/index.ts +2 -0
- package/src/runtime/turn-preparation.ts +21 -0
- package/src/runtime/use-conversation-stream.ts +79 -29
- package/src/runtime/use-conversation-thread.ts +2 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# @alia.onl/sdk
|
|
2
2
|
|
|
3
|
+
## 22.1.1
|
|
4
|
+
|
|
5
|
+
- A delayed Stop acknowledgement cannot idle a newer turn, replace its sidebar
|
|
6
|
+
preview, or notify a different conversation. Thread/account changes and unmounts
|
|
7
|
+
invalidate old completion callbacks while the original acknowledgement drains.
|
|
8
|
+
- Keep repeated Stop requests deduplicated during that drain; errors from an old
|
|
9
|
+
Stop cannot appear in a replacement thread. Public APIs and peer ranges are unchanged.
|
|
10
|
+
|
|
11
|
+
## 22.1.0
|
|
12
|
+
|
|
13
|
+
- Stop explicitly cancels the authenticated foreground turn across API replicas;
|
|
14
|
+
closing a sheet or losing its transport still allows background completion.
|
|
15
|
+
- Export `createChatTurnControl` / `AliaChatTurnControl` from `@alia.onl/sdk/runtime`
|
|
16
|
+
for custom Stop controls, with account-change fences and early-turn ids.
|
|
17
|
+
- Requires the explicit Stop API receiver to be deployed before adoption; see
|
|
18
|
+
`docs/explicit-chat-stop.md` in the repository.
|
|
19
|
+
|
|
20
|
+
|
|
3
21
|
## 22.0.1
|
|
4
22
|
|
|
5
23
|
Remove the Bloom peer upper bound to follow the first-party package policy.
|
package/package.json
CHANGED
|
@@ -265,6 +265,8 @@ export interface AliaChatContentProps {
|
|
|
265
265
|
presentedProgress?: SharedValue<number>;
|
|
266
266
|
/** Internal sheet lifecycle bridge: receives the live request canceller. */
|
|
267
267
|
onStopReady?: (stop: (() => void) | null) => void;
|
|
268
|
+
/** Detach a closed view while its durable answer continues. Never invokes explicit Stop. */
|
|
269
|
+
onDetachReady?: (detach: (() => void) | null) => void;
|
|
268
270
|
}
|
|
269
271
|
|
|
270
272
|
/** No live conversation while the thread looks at a moment of its past, as ONE array. */
|
|
@@ -319,6 +321,7 @@ function AliaConversationView({
|
|
|
319
321
|
initialTurn,
|
|
320
322
|
presentedProgress,
|
|
321
323
|
onStopReady,
|
|
324
|
+
onDetachReady,
|
|
322
325
|
}: ConversationViewProps) {
|
|
323
326
|
const ownFlights = useAvatarFlights();
|
|
324
327
|
const flights = sharedFlights ?? ownFlights;
|
|
@@ -359,6 +362,7 @@ function AliaConversationView({
|
|
|
359
362
|
error,
|
|
360
363
|
clearError,
|
|
361
364
|
stopGeneration: stop,
|
|
365
|
+
detachGeneration: detach,
|
|
362
366
|
startOver,
|
|
363
367
|
clearConversation,
|
|
364
368
|
suggestedNewConversation,
|
|
@@ -384,6 +388,10 @@ function AliaConversationView({
|
|
|
384
388
|
onStopReady?.(stop);
|
|
385
389
|
return () => onStopReady?.(null);
|
|
386
390
|
}, [onStopReady, stop]);
|
|
391
|
+
useEffect(() => {
|
|
392
|
+
onDetachReady?.(detach);
|
|
393
|
+
return () => onDetachReady?.(null);
|
|
394
|
+
}, [onDetachReady, detach]);
|
|
387
395
|
|
|
388
396
|
// ── What the person does: write, send, rewrite, call ──
|
|
389
397
|
// An agent's starters are its welcome suggestions, unless the host brought its own.
|
|
@@ -151,9 +151,9 @@ const AliaChatSheetBody = forwardRef<AliaChatSheetRef, AliaChatSheetProps>(
|
|
|
151
151
|
const conversationProgress = useSharedValue(0);
|
|
152
152
|
const mainRef = useRef<BottomSheetRef>(null);
|
|
153
153
|
const conversationRef = useRef<BottomSheetRef>(null);
|
|
154
|
-
//
|
|
155
|
-
const
|
|
156
|
-
const
|
|
154
|
+
// Closing detaches this sheet's reader; only its explicit Stop cancels the generation.
|
|
155
|
+
const mainDetachRef = useRef<(() => void) | null>(null);
|
|
156
|
+
const conversationDetachRef = useRef<(() => void) | null>(null);
|
|
157
157
|
|
|
158
158
|
// The conversation sheet, stacked over the main one while a chat is open: begun by a first
|
|
159
159
|
// turn, or a stored one picked up again. The key makes every opening a fresh chat.
|
|
@@ -163,11 +163,11 @@ const AliaChatSheetBody = forwardRef<AliaChatSheetRef, AliaChatSheetProps>(
|
|
|
163
163
|
conversationId?: string;
|
|
164
164
|
} | null>(null);
|
|
165
165
|
|
|
166
|
-
const
|
|
167
|
-
|
|
166
|
+
const registerMainDetach = useCallback((stop: (() => void) | null) => {
|
|
167
|
+
mainDetachRef.current = stop;
|
|
168
168
|
}, []);
|
|
169
|
-
const
|
|
170
|
-
|
|
169
|
+
const registerConversationDetach = useCallback((stop: (() => void) | null) => {
|
|
170
|
+
conversationDetachRef.current = stop;
|
|
171
171
|
}, []);
|
|
172
172
|
|
|
173
173
|
const startConversation = useCallback((turn: AliaTurn) => {
|
|
@@ -186,11 +186,11 @@ const AliaChatSheetBody = forwardRef<AliaChatSheetRef, AliaChatSheetProps>(
|
|
|
186
186
|
|
|
187
187
|
// Back, and the menu's Clear: close the conversation, back to the main screen underneath.
|
|
188
188
|
const closeConversation = useCallback(() => {
|
|
189
|
-
|
|
189
|
+
conversationDetachRef.current?.();
|
|
190
190
|
conversationRef.current?.dismiss();
|
|
191
191
|
}, []);
|
|
192
192
|
const onConversationDismissAttempt = useCallback(() => {
|
|
193
|
-
|
|
193
|
+
conversationDetachRef.current?.();
|
|
194
194
|
return true;
|
|
195
195
|
}, []);
|
|
196
196
|
|
|
@@ -214,8 +214,8 @@ const AliaChatSheetBody = forwardRef<AliaChatSheetRef, AliaChatSheetProps>(
|
|
|
214
214
|
);
|
|
215
215
|
|
|
216
216
|
const handleDismiss = useCallback(() => {
|
|
217
|
-
|
|
218
|
-
|
|
217
|
+
conversationDetachRef.current?.();
|
|
218
|
+
mainDetachRef.current?.();
|
|
219
219
|
conversationRef.current?.dismiss();
|
|
220
220
|
mainRef.current?.dismiss();
|
|
221
221
|
closeThought();
|
|
@@ -239,8 +239,8 @@ const AliaChatSheetBody = forwardRef<AliaChatSheetRef, AliaChatSheetProps>(
|
|
|
239
239
|
|
|
240
240
|
// Closing the main sheet (a drag, backdrop press, Back or Escape) closes what is stacked on it.
|
|
241
241
|
const onMainDismissAttempt = useCallback(() => {
|
|
242
|
-
|
|
243
|
-
|
|
242
|
+
mainDetachRef.current?.();
|
|
243
|
+
conversationDetachRef.current?.();
|
|
244
244
|
conversationRef.current?.dismiss();
|
|
245
245
|
closeThought();
|
|
246
246
|
return true;
|
|
@@ -324,7 +324,7 @@ const AliaChatSheetBody = forwardRef<AliaChatSheetRef, AliaChatSheetProps>(
|
|
|
324
324
|
) : undefined
|
|
325
325
|
}
|
|
326
326
|
presentedProgress={mainProgress}
|
|
327
|
-
|
|
327
|
+
onDetachReady={registerMainDetach}
|
|
328
328
|
onStart={startConversation}
|
|
329
329
|
/>
|
|
330
330
|
</BottomSheet>
|
|
@@ -347,7 +347,7 @@ const AliaChatSheetBody = forwardRef<AliaChatSheetRef, AliaChatSheetProps>(
|
|
|
347
347
|
conversationId={conversation.conversationId}
|
|
348
348
|
onConversationChange={followConversation}
|
|
349
349
|
presentedProgress={conversationProgress}
|
|
350
|
-
|
|
350
|
+
onDetachReady={registerConversationDetach}
|
|
351
351
|
chrome={conversationChrome}
|
|
352
352
|
/>
|
|
353
353
|
</BottomSheet>
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { OxyServices } from '@oxy.so/core';
|
|
2
|
+
import { createRandomUuid } from './random-uuid';
|
|
3
|
+
import { awaitTurnPreparation } from './turn-preparation';
|
|
4
|
+
|
|
5
|
+
type LinkedClient = ReturnType<OxyServices['createLinkedClient']>;
|
|
6
|
+
|
|
7
|
+
/** One client turn, created before any asynchronous preparation or metadata. */
|
|
8
|
+
export function createChatTurnControl(
|
|
9
|
+
linked: LinkedClient,
|
|
10
|
+
headers: Promise<Record<string, string>>,
|
|
11
|
+
conversationId: string | undefined,
|
|
12
|
+
controller: AbortController,
|
|
13
|
+
) {
|
|
14
|
+
// A host can reject before Stop is pressed. Observe that rejection immediately;
|
|
15
|
+
// awaiting the original promise below still reports the failure to Stop.
|
|
16
|
+
void headers.catch(() => undefined);
|
|
17
|
+
const id = createRandomUuid();
|
|
18
|
+
let stopping: Promise<void> | undefined;
|
|
19
|
+
let ended = false;
|
|
20
|
+
const lifetime = new AbortController();
|
|
21
|
+
const epoch = linked.client.getSessionEpoch();
|
|
22
|
+
const current = () => linked.client.getSessionEpoch() === epoch;
|
|
23
|
+
const unsubscribe = linked.client.addTokenChangeListener(() => {
|
|
24
|
+
if (!current()) {
|
|
25
|
+
const reason = new Error('The conversation account changed');
|
|
26
|
+
lifetime.abort(reason);
|
|
27
|
+
controller.abort(reason);
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
return {
|
|
31
|
+
id,
|
|
32
|
+
/** Only a deliberate Stop invokes this. View teardown merely aborts its transport. */
|
|
33
|
+
stop(): Promise<void> {
|
|
34
|
+
if (stopping) return stopping;
|
|
35
|
+
if (ended || controller.signal.aborted) return Promise.resolve();
|
|
36
|
+
stopping = (async () => {
|
|
37
|
+
const signal = AbortSignal.any([lifetime.signal, AbortSignal.timeout(10_000)]);
|
|
38
|
+
const assertionHeaders = await awaitTurnPreparation(headers, signal);
|
|
39
|
+
signal.throwIfAborted();
|
|
40
|
+
controller.signal.throwIfAborted();
|
|
41
|
+
if (!current()) throw new Error('The conversation account changed');
|
|
42
|
+
const response = await linked.client.requestAuthenticatedResponse({
|
|
43
|
+
url: `/v1/chat/turns/${id}/cancel`,
|
|
44
|
+
method: 'POST',
|
|
45
|
+
headers: { ...assertionHeaders, 'Content-Type': 'application/json' },
|
|
46
|
+
body: JSON.stringify({ ...(conversationId ? { conversationId } : {}) }),
|
|
47
|
+
// The cancelled chat signal must NOT cancel its own control request.
|
|
48
|
+
signal,
|
|
49
|
+
});
|
|
50
|
+
if (!response.ok) {
|
|
51
|
+
await response.body?.cancel();
|
|
52
|
+
throw new Error('Generation could not be stopped. Try again.');
|
|
53
|
+
}
|
|
54
|
+
await response.body?.cancel();
|
|
55
|
+
controller.abort();
|
|
56
|
+
})().catch((error) => {
|
|
57
|
+
stopping = undefined;
|
|
58
|
+
throw error;
|
|
59
|
+
});
|
|
60
|
+
return stopping;
|
|
61
|
+
},
|
|
62
|
+
async finish() {
|
|
63
|
+
ended = true;
|
|
64
|
+
// The stream can close before the replica answering Stop sends its acknowledgement.
|
|
65
|
+
await stopping?.catch(() => undefined);
|
|
66
|
+
lifetime.abort();
|
|
67
|
+
unsubscribe();
|
|
68
|
+
linked.dispose();
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Public lifecycle handle for hosts that render their own Stop control. */
|
|
74
|
+
export type AliaChatTurnControl = ReturnType<typeof createChatTurnControl>;
|
package/src/runtime/index.ts
CHANGED
|
@@ -112,3 +112,5 @@ export type { BuiltMessageContent, DroppedAttachment, UploadDocument } from './a
|
|
|
112
112
|
|
|
113
113
|
// The conversation as data (also the leaf entry `@alia.onl/sdk/model`).
|
|
114
114
|
export * from './model';
|
|
115
|
+
|
|
116
|
+
export { createChatTurnControl, type AliaChatTurnControl } from './chat-turn-control';
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** Fence asynchronous host preparation without waiting for an uncooperative host. */
|
|
2
|
+
export function awaitTurnPreparation<T>(pending: Promise<T>, signal: AbortSignal): Promise<T> {
|
|
3
|
+
return new Promise<T>((resolve, reject) => {
|
|
4
|
+
const aborted = () => {
|
|
5
|
+
signal.removeEventListener('abort', aborted);
|
|
6
|
+
reject(signal.reason);
|
|
7
|
+
};
|
|
8
|
+
pending.then(
|
|
9
|
+
(value) => {
|
|
10
|
+
signal.removeEventListener('abort', aborted);
|
|
11
|
+
resolve(value);
|
|
12
|
+
},
|
|
13
|
+
(error) => {
|
|
14
|
+
signal.removeEventListener('abort', aborted);
|
|
15
|
+
reject(error);
|
|
16
|
+
},
|
|
17
|
+
);
|
|
18
|
+
if (signal.aborted) aborted();
|
|
19
|
+
else signal.addEventListener('abort', aborted, { once: true });
|
|
20
|
+
});
|
|
21
|
+
}
|
|
@@ -15,6 +15,8 @@ import {
|
|
|
15
15
|
import { agentMessageId, buildOutboundMessages, isAgentMessageOf } from './message-history';
|
|
16
16
|
import { aliaQueryKeys } from './query-keys';
|
|
17
17
|
import { createRandomUuid } from './random-uuid';
|
|
18
|
+
import { createChatTurnControl } from './chat-turn-control';
|
|
19
|
+
import { awaitTurnPreparation } from './turn-preparation';
|
|
18
20
|
import { type AliaHostTools, HostOutcomeQueue, hostRequest } from './host-tools';
|
|
19
21
|
import { API_ROUTES } from './routes';
|
|
20
22
|
import { createSseFrameReader } from './sse-frame-reader';
|
|
@@ -213,6 +215,9 @@ export function useConversationStream({
|
|
|
213
215
|
const appAssertionHeaders = useAppAssertionHeaders();
|
|
214
216
|
const queryClient = useQueryClient();
|
|
215
217
|
const abortControllerRef = useRef<AbortController | null>(null);
|
|
218
|
+
const turnControlRef = useRef<ReturnType<typeof createChatTurnControl> | null>(null);
|
|
219
|
+
// A Stop acknowledgement can outlive its stream, thread, or the next send.
|
|
220
|
+
const turnGenerationRef = useRef(0);
|
|
216
221
|
|
|
217
222
|
// Batching refs: accumulate streaming text and flush at ~20fps instead of per-chunk
|
|
218
223
|
const pendingContentRef = useRef('');
|
|
@@ -276,6 +281,8 @@ export function useConversationStream({
|
|
|
276
281
|
mountedRef.current = true;
|
|
277
282
|
return () => {
|
|
278
283
|
mountedRef.current = false;
|
|
284
|
+
turnGenerationRef.current++;
|
|
285
|
+
turnControlRef.current = null;
|
|
279
286
|
flushPendingUpdates();
|
|
280
287
|
if (flushTimerRef.current) {
|
|
281
288
|
clearTimeout(flushTimerRef.current);
|
|
@@ -321,6 +328,8 @@ export function useConversationStream({
|
|
|
321
328
|
const was = conversationRef.current;
|
|
322
329
|
conversationRef.current = conversationId;
|
|
323
330
|
if (was !== undefined && was !== conversationId) {
|
|
331
|
+
turnGenerationRef.current++;
|
|
332
|
+
turnControlRef.current = null;
|
|
324
333
|
hostOutcomesRef.current.clear();
|
|
325
334
|
// The reply still streaming belongs to the thread that was left: cut it, drop what it had
|
|
326
335
|
// batched (it would land on this thread's last message) and let the screen be idle.
|
|
@@ -347,6 +356,8 @@ export function useConversationStream({
|
|
|
347
356
|
const was = accountRef.current;
|
|
348
357
|
accountRef.current = accountId;
|
|
349
358
|
if (was === null || was === accountId) return;
|
|
359
|
+
turnGenerationRef.current++;
|
|
360
|
+
turnControlRef.current = null;
|
|
350
361
|
abortControllerRef.current?.abort();
|
|
351
362
|
abortControllerRef.current = null;
|
|
352
363
|
dropPendingUpdates();
|
|
@@ -364,6 +375,7 @@ export function useConversationStream({
|
|
|
364
375
|
|
|
365
376
|
const append = useCallback(
|
|
366
377
|
async (message: Omit<Message, 'id'>, options?: SendOptions): Promise<SendOutcome> => {
|
|
378
|
+
const turnGeneration = ++turnGenerationRef.current;
|
|
367
379
|
setIsLoading(true);
|
|
368
380
|
setError(null);
|
|
369
381
|
// A new send answers the old failure, whether it is the retry or a fresh
|
|
@@ -615,6 +627,7 @@ export function useConversationStream({
|
|
|
615
627
|
/** The request's own controller: `finally` asks it whether the person stopped the turn. */
|
|
616
628
|
let controller: AbortController | null = null;
|
|
617
629
|
let linked: ReturnType<typeof oxyServices.createLinkedClient> | null = null;
|
|
630
|
+
let turnControl: ReturnType<typeof createChatTurnControl> | null = null;
|
|
618
631
|
/** Cut THIS turn's stream. Never the ref's: by now it may hold a later turn's controller. */
|
|
619
632
|
const abortThisTurn = (): void => {
|
|
620
633
|
controller?.abort();
|
|
@@ -626,9 +639,17 @@ export function useConversationStream({
|
|
|
626
639
|
// conversation change reach a turn that is still collecting device info.
|
|
627
640
|
controller = new AbortController();
|
|
628
641
|
abortControllerRef.current = controller;
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
642
|
+
linked = oxyServices.createLinkedClient({ baseURL: apiUrl });
|
|
643
|
+
const assertion = appAssertionHeaders();
|
|
644
|
+
turnControl = createChatTurnControl(linked, assertion, conversationId, controller);
|
|
645
|
+
turnControlRef.current = turnControl;
|
|
646
|
+
|
|
647
|
+
// Attach both rejection handlers immediately; a slow host must not leave
|
|
648
|
+
// an already rejected surface assertion unobserved or defeat detachment.
|
|
649
|
+
const [appAssertion, deviceInfo] = await awaitTurnPreparation(
|
|
650
|
+
Promise.all([assertion, host.deviceInfo ? host.deviceInfo() : undefined]),
|
|
651
|
+
controller.signal,
|
|
652
|
+
);
|
|
632
653
|
if (controller.signal.aborted) return 'aborted';
|
|
633
654
|
|
|
634
655
|
/**
|
|
@@ -646,8 +667,7 @@ export function useConversationStream({
|
|
|
646
667
|
// Authentication, the preflight refresh and the single 401 replay belong
|
|
647
668
|
// to the linked Oxy client; this layer never reads or writes a bearer.
|
|
648
669
|
// Which Oxy app this turn is in (ADR 0021): an agent Oxy confines to an app opens only there.
|
|
649
|
-
|
|
650
|
-
linked = oxyServices.createLinkedClient({ baseURL: apiUrl });
|
|
670
|
+
if (controller.signal.aborted) return 'aborted';
|
|
651
671
|
const response = await linked.client.requestAuthenticatedResponse({
|
|
652
672
|
method: 'POST',
|
|
653
673
|
url: API_ROUTES.chat,
|
|
@@ -658,6 +678,7 @@ export function useConversationStream({
|
|
|
658
678
|
...(deviceInfo === undefined ? {} : { 'X-Device-Info': JSON.stringify(deviceInfo) }),
|
|
659
679
|
},
|
|
660
680
|
body: JSON.stringify({
|
|
681
|
+
turnId: turnControl.id,
|
|
661
682
|
messages: messagesToSend,
|
|
662
683
|
// The reply is stored under the id it is drawn under here, so a vote
|
|
663
684
|
// or a read-aloud on it needs no reload to find it.
|
|
@@ -688,6 +709,12 @@ export function useConversationStream({
|
|
|
688
709
|
const lockedFeature = lockedFeatureOf(
|
|
689
710
|
errorData?.error && typeof errorData.error === 'object' ? errorData.error : null,
|
|
690
711
|
);
|
|
712
|
+
if (errorCode(errorData?.error) === 'turn_cancelled') {
|
|
713
|
+
flushPendingUpdates();
|
|
714
|
+
settleAssistant('cancelled');
|
|
715
|
+
abortThisTurn();
|
|
716
|
+
return 'aborted';
|
|
717
|
+
}
|
|
691
718
|
if (lockedFeature !== null) {
|
|
692
719
|
host.onFeatureLocked?.(lockedFeature);
|
|
693
720
|
return rollback();
|
|
@@ -1037,6 +1064,13 @@ export function useConversationStream({
|
|
|
1037
1064
|
// Handle structured error events sent via SSE
|
|
1038
1065
|
if (parsed.error) {
|
|
1039
1066
|
const err = parsed.error;
|
|
1067
|
+
if (errorCode(err) === 'turn_cancelled') {
|
|
1068
|
+
flushPendingUpdates();
|
|
1069
|
+
settleAssistant('cancelled');
|
|
1070
|
+
abortThisTurn();
|
|
1071
|
+
reader.cancel().catch(() => {});
|
|
1072
|
+
return 'aborted';
|
|
1073
|
+
}
|
|
1040
1074
|
// An agent's feature gate refused the turn before anything was spent: the host
|
|
1041
1075
|
// shows its own upsell, and the person's words go back to the composer.
|
|
1042
1076
|
const lockedFeature = lockedFeatureOf(err);
|
|
@@ -1351,31 +1385,31 @@ export function useConversationStream({
|
|
|
1351
1385
|
setError(finalError);
|
|
1352
1386
|
return keepFailedTurn(true, finalError.message);
|
|
1353
1387
|
} finally {
|
|
1354
|
-
//
|
|
1355
|
-
|
|
1356
|
-
if (
|
|
1357
|
-
|
|
1358
|
-
flushTimerRef.current
|
|
1388
|
+
// Batches belong to the current turn. An older finally must not flush or clear
|
|
1389
|
+
// the next turn's shared timer, even when its controller ref has been cleared.
|
|
1390
|
+
if (turnGenerationRef.current === turnGeneration) {
|
|
1391
|
+
flushPendingUpdates();
|
|
1392
|
+
if (flushTimerRef.current) {
|
|
1393
|
+
clearTimeout(flushTimerRef.current);
|
|
1394
|
+
flushTimerRef.current = null;
|
|
1395
|
+
}
|
|
1359
1396
|
}
|
|
1360
|
-
//
|
|
1361
|
-
//
|
|
1362
|
-
const ownsTurn =
|
|
1363
|
-
abortControllerRef.current === null || abortControllerRef.current === controller;
|
|
1364
|
-
if (abortControllerRef.current === controller) abortControllerRef.current = null;
|
|
1365
|
-
linked?.dispose();
|
|
1366
|
-
// A turn that is still streaming here ended without an error path
|
|
1367
|
-
// settling it: on its own, or because `stop()` aborted the controller.
|
|
1368
|
-
// (The error paths abort it too, but they have settled it first.)
|
|
1397
|
+
// Settle only this turn's message ids, including when a new send has already
|
|
1398
|
+
// started in the same conversation. Error paths may have settled them first.
|
|
1369
1399
|
settleAssistant(controller?.signal.aborted === true ? 'cancelled' : 'completed');
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1400
|
+
// Retain this control while its ACK drains so repeated Stop remains deduplicated.
|
|
1401
|
+
// Local transport cleanup is required even after the thread has been replaced.
|
|
1402
|
+
if (turnControl) await turnControl.finish();
|
|
1403
|
+
else linked?.dispose();
|
|
1404
|
+
if (abortControllerRef.current === controller) abortControllerRef.current = null;
|
|
1405
|
+
if (turnControlRef.current === turnControl) turnControlRef.current = null;
|
|
1406
|
+
// Recheck after the await: another turn, thread, account or mount may now own UI.
|
|
1407
|
+
if (mountedRef.current && turnGenerationRef.current === turnGeneration) {
|
|
1408
|
+
setIsLoading(false);
|
|
1409
|
+
// The sidebar preview and completion notification belong to this visible turn.
|
|
1410
|
+
if (replyText !== '') host.onTurnPreview?.(agentId, replyText);
|
|
1411
|
+
host.onTurnEnd?.();
|
|
1412
|
+
}
|
|
1379
1413
|
}
|
|
1380
1414
|
},
|
|
1381
1415
|
[
|
|
@@ -1397,7 +1431,7 @@ export function useConversationStream({
|
|
|
1397
1431
|
],
|
|
1398
1432
|
);
|
|
1399
1433
|
|
|
1400
|
-
const
|
|
1434
|
+
const detach = useCallback(() => {
|
|
1401
1435
|
if (abortControllerRef.current) {
|
|
1402
1436
|
abortControllerRef.current.abort();
|
|
1403
1437
|
abortControllerRef.current = null;
|
|
@@ -1405,6 +1439,21 @@ export function useConversationStream({
|
|
|
1405
1439
|
setIsLoading(false);
|
|
1406
1440
|
}, []);
|
|
1407
1441
|
|
|
1442
|
+
const stop = useCallback(() => {
|
|
1443
|
+
const turn = turnControlRef.current;
|
|
1444
|
+
const turnGeneration = turnGenerationRef.current;
|
|
1445
|
+
if (!turn) return;
|
|
1446
|
+
void turn.stop().catch((error: unknown) => {
|
|
1447
|
+
if (
|
|
1448
|
+
turnControlRef.current === turn &&
|
|
1449
|
+
turnGenerationRef.current === turnGeneration &&
|
|
1450
|
+
mountedRef.current
|
|
1451
|
+
) {
|
|
1452
|
+
setError(error instanceof Error ? error : new Error('Generation could not be stopped.'));
|
|
1453
|
+
}
|
|
1454
|
+
});
|
|
1455
|
+
}, []);
|
|
1456
|
+
|
|
1408
1457
|
const clearError = useCallback(() => setError(null), []);
|
|
1409
1458
|
|
|
1410
1459
|
/**
|
|
@@ -1451,6 +1500,7 @@ export function useConversationStream({
|
|
|
1451
1500
|
error,
|
|
1452
1501
|
append,
|
|
1453
1502
|
stop,
|
|
1503
|
+
detach,
|
|
1454
1504
|
setMessages: setMessagesAndRef,
|
|
1455
1505
|
conversationTitle,
|
|
1456
1506
|
clearError,
|
|
@@ -134,6 +134,7 @@ export function useConversationThread({
|
|
|
134
134
|
clearError,
|
|
135
135
|
setMessages,
|
|
136
136
|
stop,
|
|
137
|
+
detach,
|
|
137
138
|
suggestedNewConversation,
|
|
138
139
|
dismissSuggestedNewConversation,
|
|
139
140
|
agentRun,
|
|
@@ -547,6 +548,7 @@ export function useConversationThread({
|
|
|
547
548
|
promptOf,
|
|
548
549
|
turnOptionsOf,
|
|
549
550
|
stopGeneration,
|
|
551
|
+
detachGeneration: detach,
|
|
550
552
|
clearConversation,
|
|
551
553
|
clearError,
|
|
552
554
|
setMessages,
|