@assistant-ui/react 0.12.14 → 0.12.15
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/dist/legacy-runtime/runtime-cores/assistant-transport/useToolInvocations.d.ts.map +1 -1
- package/dist/legacy-runtime/runtime-cores/assistant-transport/useToolInvocations.js +143 -38
- package/dist/legacy-runtime/runtime-cores/assistant-transport/useToolInvocations.js.map +1 -1
- package/dist/legacy-runtime/runtime-cores/external-store/external-message-converter.d.ts.map +1 -1
- package/dist/legacy-runtime/runtime-cores/external-store/external-message-converter.js +21 -9
- package/dist/legacy-runtime/runtime-cores/external-store/external-message-converter.js.map +1 -1
- package/dist/primitives/actionBar/ActionBarInteractionContext.d.ts +6 -0
- package/dist/primitives/actionBar/ActionBarInteractionContext.d.ts.map +1 -0
- package/dist/primitives/actionBar/ActionBarInteractionContext.js +5 -0
- package/dist/primitives/actionBar/ActionBarInteractionContext.js.map +1 -0
- package/dist/primitives/actionBar/ActionBarRoot.d.ts.map +1 -1
- package/dist/primitives/actionBar/ActionBarRoot.js +18 -4
- package/dist/primitives/actionBar/ActionBarRoot.js.map +1 -1
- package/dist/primitives/actionBar/useActionBarFloatStatus.d.ts +2 -1
- package/dist/primitives/actionBar/useActionBarFloatStatus.d.ts.map +1 -1
- package/dist/primitives/actionBar/useActionBarFloatStatus.js +3 -2
- package/dist/primitives/actionBar/useActionBarFloatStatus.js.map +1 -1
- package/dist/primitives/actionBarMore/ActionBarMoreRoot.d.ts.map +1 -1
- package/dist/primitives/actionBarMore/ActionBarMoreRoot.js +35 -2
- package/dist/primitives/actionBarMore/ActionBarMoreRoot.js.map +1 -1
- package/dist/utils/json/is-json-equal.d.ts +2 -0
- package/dist/utils/json/is-json-equal.d.ts.map +1 -0
- package/dist/utils/json/is-json-equal.js +31 -0
- package/dist/utils/json/is-json-equal.js.map +1 -0
- package/dist/utils/json/is-json.d.ts +1 -0
- package/dist/utils/json/is-json.d.ts.map +1 -1
- package/dist/utils/json/is-json.js +5 -3
- package/dist/utils/json/is-json.js.map +1 -1
- package/package.json +6 -6
- package/src/legacy-runtime/runtime-cores/assistant-transport/useToolInvocations.test.ts +225 -2
- package/src/legacy-runtime/runtime-cores/assistant-transport/useToolInvocations.ts +191 -50
- package/src/legacy-runtime/runtime-cores/external-store/external-message-converter.ts +28 -10
- package/src/primitives/actionBar/ActionBarInteractionContext.ts +13 -0
- package/src/primitives/actionBar/ActionBarRoot.tsx +38 -8
- package/src/primitives/actionBar/useActionBarFloatStatus.ts +4 -1
- package/src/primitives/actionBarMore/ActionBarMoreRoot.tsx +52 -2
- package/src/tests/external-message-converter.test.ts +80 -0
- package/src/utils/json/is-json-equal.ts +48 -0
- package/src/utils/json/is-json.ts +6 -3
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
AssistantMetaTransformStream,
|
|
15
15
|
type ReadonlyJSONValue,
|
|
16
16
|
} from "assistant-stream/utils";
|
|
17
|
+
import { isJSONValueEqual } from "../../../utils/json/is-json-equal";
|
|
17
18
|
|
|
18
19
|
const isArgsTextComplete = (argsText: string) => {
|
|
19
20
|
try {
|
|
@@ -24,6 +25,21 @@ const isArgsTextComplete = (argsText: string) => {
|
|
|
24
25
|
}
|
|
25
26
|
};
|
|
26
27
|
|
|
28
|
+
const parseArgsText = (argsText: string) => {
|
|
29
|
+
try {
|
|
30
|
+
return JSON.parse(argsText);
|
|
31
|
+
} catch {
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const isEquivalentCompleteArgsText = (previous: string, next: string) => {
|
|
37
|
+
const previousValue = parseArgsText(previous);
|
|
38
|
+
const nextValue = parseArgsText(next);
|
|
39
|
+
if (previousValue === undefined || nextValue === undefined) return false;
|
|
40
|
+
return isJSONValueEqual(previousValue, nextValue);
|
|
41
|
+
};
|
|
42
|
+
|
|
27
43
|
type UseToolInvocationsParams = {
|
|
28
44
|
state: AssistantTransportState;
|
|
29
45
|
getTools: () => Record<string, Tool> | undefined;
|
|
@@ -69,6 +85,7 @@ export function useToolInvocations({
|
|
|
69
85
|
|
|
70
86
|
const acRef = useRef<AbortController>(new AbortController());
|
|
71
87
|
const executingCountRef = useRef(0);
|
|
88
|
+
const startedExecutionToolCallIdsRef = useRef<Set<string>>(new Set());
|
|
72
89
|
const settledResolversRef = useRef<Array<() => void>>([]);
|
|
73
90
|
const toolCallIdAliasesRef = useRef<Map<string, string>>(new Map());
|
|
74
91
|
const ignoredResultToolCallIdsRef = useRef<Set<string>>(new Set());
|
|
@@ -151,6 +168,7 @@ export function useToolInvocations({
|
|
|
151
168
|
if (ignoredResultToolCallIdsRef.current.has(toolCallId)) {
|
|
152
169
|
return;
|
|
153
170
|
}
|
|
171
|
+
startedExecutionToolCallIdsRef.current.add(toolCallId);
|
|
154
172
|
const logicalToolCallId = getLogicalToolCallId(toolCallId);
|
|
155
173
|
executingCountRef.current++;
|
|
156
174
|
setToolStatuses((prev) => ({
|
|
@@ -159,7 +177,19 @@ export function useToolInvocations({
|
|
|
159
177
|
}));
|
|
160
178
|
},
|
|
161
179
|
onExecutionEnd: (toolCallId: string) => {
|
|
180
|
+
const wasStarted =
|
|
181
|
+
startedExecutionToolCallIdsRef.current.delete(toolCallId);
|
|
162
182
|
if (ignoredResultToolCallIdsRef.current.has(toolCallId)) {
|
|
183
|
+
if (wasStarted) {
|
|
184
|
+
executingCountRef.current--;
|
|
185
|
+
if (executingCountRef.current === 0) {
|
|
186
|
+
settledResolversRef.current.forEach((resolve) => resolve());
|
|
187
|
+
settledResolversRef.current = [];
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (!wasStarted) {
|
|
163
193
|
return;
|
|
164
194
|
}
|
|
165
195
|
const logicalToolCallId = getLogicalToolCallId(toolCallId);
|
|
@@ -244,6 +274,64 @@ export function useToolInvocations({
|
|
|
244
274
|
return setToolState(toolCallId, { ...state, ...patch });
|
|
245
275
|
};
|
|
246
276
|
|
|
277
|
+
const hasExecutableTool = (toolName: string) => {
|
|
278
|
+
const tool = getTools()?.[toolName];
|
|
279
|
+
return tool?.execute !== undefined || tool?.streamCall !== undefined;
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
const shouldCloseArgsStream = ({
|
|
283
|
+
toolName,
|
|
284
|
+
argsText,
|
|
285
|
+
hasResult,
|
|
286
|
+
}: {
|
|
287
|
+
toolName: string;
|
|
288
|
+
argsText: string;
|
|
289
|
+
hasResult: boolean;
|
|
290
|
+
}) => {
|
|
291
|
+
if (hasResult) return true;
|
|
292
|
+
if (!hasExecutableTool(toolName)) {
|
|
293
|
+
// Non-executable tools can emit parseable snapshots mid-stream.
|
|
294
|
+
// Wait until the run settles before closing the args stream.
|
|
295
|
+
return !state.isRunning && isArgsTextComplete(argsText);
|
|
296
|
+
}
|
|
297
|
+
return isArgsTextComplete(argsText);
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
const restartToolArgsStream = ({
|
|
301
|
+
toolCallId,
|
|
302
|
+
toolName,
|
|
303
|
+
state,
|
|
304
|
+
}: {
|
|
305
|
+
toolCallId: string;
|
|
306
|
+
toolName: string;
|
|
307
|
+
state: ToolState;
|
|
308
|
+
}) => {
|
|
309
|
+
ignoredResultToolCallIdsRef.current.add(state.streamToolCallId);
|
|
310
|
+
state.controller.argsText.close();
|
|
311
|
+
|
|
312
|
+
const streamToolCallId = `${toolCallId}:rewrite:${rewriteCounterRef.current++}`;
|
|
313
|
+
toolCallIdAliasesRef.current.set(streamToolCallId, toolCallId);
|
|
314
|
+
const toolCallController = controller.addToolCallPart({
|
|
315
|
+
toolName,
|
|
316
|
+
toolCallId: streamToolCallId,
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
if (process.env.NODE_ENV !== "production") {
|
|
320
|
+
console.warn("started replacement stream tool call", {
|
|
321
|
+
toolCallId,
|
|
322
|
+
streamToolCallId,
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return setToolState(toolCallId, {
|
|
327
|
+
...createToolState({
|
|
328
|
+
controller: toolCallController,
|
|
329
|
+
streamToolCallId,
|
|
330
|
+
}),
|
|
331
|
+
hasResult: state.hasResult,
|
|
332
|
+
});
|
|
333
|
+
};
|
|
334
|
+
|
|
247
335
|
const processMessages = (
|
|
248
336
|
messages: readonly (typeof state.messages)[number][],
|
|
249
337
|
) => {
|
|
@@ -276,89 +364,141 @@ export function useToolInvocations({
|
|
|
276
364
|
}
|
|
277
365
|
|
|
278
366
|
if (content.argsText !== lastState.argsText) {
|
|
367
|
+
let shouldWriteArgsText = true;
|
|
368
|
+
|
|
279
369
|
if (lastState.argsComplete) {
|
|
280
|
-
if (
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
370
|
+
if (
|
|
371
|
+
isEquivalentCompleteArgsText(
|
|
372
|
+
lastState.argsText,
|
|
373
|
+
content.argsText,
|
|
374
|
+
)
|
|
375
|
+
) {
|
|
376
|
+
lastState = patchToolState(content.toolCallId, lastState, {
|
|
377
|
+
argsText: content.argsText,
|
|
378
|
+
});
|
|
379
|
+
shouldWriteArgsText = false;
|
|
288
380
|
}
|
|
289
|
-
|
|
290
|
-
if (
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
) {
|
|
298
|
-
lastState.controller.argsText.close();
|
|
299
|
-
patchToolState(content.toolCallId, lastState, {
|
|
300
|
-
argsText: content.argsText,
|
|
301
|
-
argsComplete: true,
|
|
302
|
-
});
|
|
303
|
-
return; // Continue to next content part
|
|
304
|
-
}
|
|
381
|
+
|
|
382
|
+
if (shouldWriteArgsText) {
|
|
383
|
+
const canRestartClosedArgsStream =
|
|
384
|
+
!lastState.hasResult &&
|
|
385
|
+
!startedExecutionToolCallIdsRef.current.has(
|
|
386
|
+
lastState.streamToolCallId,
|
|
387
|
+
);
|
|
388
|
+
|
|
305
389
|
if (process.env.NODE_ENV !== "production") {
|
|
306
390
|
console.warn(
|
|
307
|
-
|
|
391
|
+
canRestartClosedArgsStream
|
|
392
|
+
? "argsText updated after controller was closed, restarting tool args stream:"
|
|
393
|
+
: "argsText updated after controller was closed:",
|
|
308
394
|
{
|
|
309
395
|
previous: lastState.argsText,
|
|
310
396
|
next: content.argsText,
|
|
311
|
-
toolCallId: content.toolCallId,
|
|
312
397
|
},
|
|
313
398
|
);
|
|
314
399
|
}
|
|
315
400
|
|
|
316
|
-
|
|
317
|
-
lastState
|
|
318
|
-
|
|
319
|
-
|
|
401
|
+
if (!canRestartClosedArgsStream) {
|
|
402
|
+
lastState = patchToolState(
|
|
403
|
+
content.toolCallId,
|
|
404
|
+
lastState,
|
|
405
|
+
{
|
|
406
|
+
argsText: content.argsText,
|
|
407
|
+
},
|
|
408
|
+
);
|
|
409
|
+
shouldWriteArgsText = false;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
320
412
|
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
content.toolCallId,
|
|
325
|
-
);
|
|
326
|
-
const toolCallController = controller.addToolCallPart({
|
|
413
|
+
if (shouldWriteArgsText) {
|
|
414
|
+
lastState = restartToolArgsStream({
|
|
415
|
+
toolCallId: content.toolCallId,
|
|
327
416
|
toolName: content.toolName,
|
|
328
|
-
|
|
417
|
+
state: lastState,
|
|
329
418
|
});
|
|
419
|
+
}
|
|
420
|
+
} else if (!content.argsText.startsWith(lastState.argsText)) {
|
|
421
|
+
// Check if this is key reordering (both are complete JSON)
|
|
422
|
+
// This happens when transitioning from streaming to complete state
|
|
423
|
+
// and the provider returns keys in a different order
|
|
424
|
+
if (
|
|
425
|
+
isArgsTextComplete(lastState.argsText) &&
|
|
426
|
+
isArgsTextComplete(content.argsText) &&
|
|
427
|
+
isEquivalentCompleteArgsText(
|
|
428
|
+
lastState.argsText,
|
|
429
|
+
content.argsText,
|
|
430
|
+
)
|
|
431
|
+
) {
|
|
432
|
+
const shouldClose = shouldCloseArgsStream({
|
|
433
|
+
toolName: content.toolName,
|
|
434
|
+
argsText: content.argsText,
|
|
435
|
+
hasResult: content.result !== undefined,
|
|
436
|
+
});
|
|
437
|
+
if (shouldClose) {
|
|
438
|
+
lastState.controller.argsText.close();
|
|
439
|
+
}
|
|
440
|
+
lastState = patchToolState(content.toolCallId, lastState, {
|
|
441
|
+
argsText: content.argsText,
|
|
442
|
+
argsComplete: shouldClose,
|
|
443
|
+
});
|
|
444
|
+
shouldWriteArgsText = false;
|
|
445
|
+
}
|
|
446
|
+
if (shouldWriteArgsText) {
|
|
330
447
|
if (process.env.NODE_ENV !== "production") {
|
|
331
|
-
console.warn(
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
448
|
+
console.warn(
|
|
449
|
+
"argsText rewrote previous snapshot, restarting tool args stream:",
|
|
450
|
+
{
|
|
451
|
+
previous: lastState.argsText,
|
|
452
|
+
next: content.argsText,
|
|
453
|
+
toolCallId: content.toolCallId,
|
|
454
|
+
},
|
|
455
|
+
);
|
|
335
456
|
}
|
|
336
|
-
lastState =
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
}),
|
|
341
|
-
hasResult: lastState.hasResult,
|
|
457
|
+
lastState = restartToolArgsStream({
|
|
458
|
+
toolCallId: content.toolCallId,
|
|
459
|
+
toolName: content.toolName,
|
|
460
|
+
state: lastState,
|
|
342
461
|
});
|
|
343
462
|
}
|
|
463
|
+
}
|
|
344
464
|
|
|
465
|
+
if (shouldWriteArgsText) {
|
|
345
466
|
const argsTextDelta = content.argsText.slice(
|
|
346
467
|
lastState.argsText.length,
|
|
347
468
|
);
|
|
348
469
|
lastState.controller.argsText.append(argsTextDelta);
|
|
349
470
|
|
|
350
|
-
const shouldClose =
|
|
471
|
+
const shouldClose = shouldCloseArgsStream({
|
|
472
|
+
toolName: content.toolName,
|
|
473
|
+
argsText: content.argsText,
|
|
474
|
+
hasResult: content.result !== undefined,
|
|
475
|
+
});
|
|
351
476
|
if (shouldClose) {
|
|
352
477
|
lastState.controller.argsText.close();
|
|
353
478
|
}
|
|
354
479
|
|
|
355
|
-
patchToolState(content.toolCallId, lastState, {
|
|
480
|
+
lastState = patchToolState(content.toolCallId, lastState, {
|
|
356
481
|
argsText: content.argsText,
|
|
357
482
|
argsComplete: shouldClose,
|
|
358
483
|
});
|
|
359
484
|
}
|
|
360
485
|
}
|
|
361
486
|
|
|
487
|
+
if (!lastState.argsComplete) {
|
|
488
|
+
const shouldClose = shouldCloseArgsStream({
|
|
489
|
+
toolName: content.toolName,
|
|
490
|
+
argsText: content.argsText,
|
|
491
|
+
hasResult: content.result !== undefined,
|
|
492
|
+
});
|
|
493
|
+
if (shouldClose) {
|
|
494
|
+
lastState.controller.argsText.close();
|
|
495
|
+
lastState = patchToolState(content.toolCallId, lastState, {
|
|
496
|
+
argsText: content.argsText,
|
|
497
|
+
argsComplete: true,
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
362
502
|
if (content.result !== undefined && !lastState.hasResult) {
|
|
363
503
|
patchToolState(content.toolCallId, lastState, {
|
|
364
504
|
hasResult: true,
|
|
@@ -390,7 +530,7 @@ export function useToolInvocations({
|
|
|
390
530
|
if (isInitialState.current) {
|
|
391
531
|
isInitialState.current = false;
|
|
392
532
|
}
|
|
393
|
-
}, [state, controller]);
|
|
533
|
+
}, [state, controller, getTools]);
|
|
394
534
|
|
|
395
535
|
const abort = (): Promise<void> => {
|
|
396
536
|
humanInputRef.current.forEach(({ reject }) => {
|
|
@@ -414,6 +554,7 @@ export function useToolInvocations({
|
|
|
414
554
|
reset: () => {
|
|
415
555
|
isInitialState.current = true;
|
|
416
556
|
void abort().finally(() => {
|
|
557
|
+
startedExecutionToolCallIdsRef.current.clear();
|
|
417
558
|
toolCallIdAliasesRef.current.clear();
|
|
418
559
|
ignoredResultToolCallIdsRef.current.clear();
|
|
419
560
|
rewriteCounterRef.current = 0;
|
|
@@ -62,6 +62,13 @@ type Mutable<T> = {
|
|
|
62
62
|
-readonly [P in keyof T]: T[P];
|
|
63
63
|
};
|
|
64
64
|
|
|
65
|
+
const mergeInnerMessages = (existing: object, incoming: object) => ({
|
|
66
|
+
[symbolInnerMessage]: [
|
|
67
|
+
...((existing as any)[symbolInnerMessage] ?? []),
|
|
68
|
+
...((incoming as any)[symbolInnerMessage] ?? []),
|
|
69
|
+
],
|
|
70
|
+
});
|
|
71
|
+
|
|
65
72
|
const joinExternalMessages = (
|
|
66
73
|
messages: readonly useExternalMessageConverter.Message[],
|
|
67
74
|
): ThreadMessageLike => {
|
|
@@ -77,6 +84,8 @@ const joinExternalMessages = (
|
|
|
77
84
|
const toolCallIdx = assistantMessage.content.findIndex(
|
|
78
85
|
(c) => c.type === "tool-call" && c.toolCallId === output.toolCallId,
|
|
79
86
|
);
|
|
87
|
+
// Ignore orphaned tool results so one bad tool message does not
|
|
88
|
+
// prevent rendering the rest of the conversation.
|
|
80
89
|
if (toolCallIdx !== -1) {
|
|
81
90
|
const toolCall = assistantMessage.content[
|
|
82
91
|
toolCallIdx
|
|
@@ -100,10 +109,6 @@ const joinExternalMessages = (
|
|
|
100
109
|
isError: output.isError,
|
|
101
110
|
messages: output.messages,
|
|
102
111
|
};
|
|
103
|
-
} else {
|
|
104
|
-
throw new Error(
|
|
105
|
-
`Tool call ${output.toolCallId} ${output.toolName} not found in assistant message`,
|
|
106
|
-
);
|
|
107
112
|
}
|
|
108
113
|
} else {
|
|
109
114
|
const role = output.role;
|
|
@@ -180,6 +185,24 @@ const joinExternalMessages = (
|
|
|
180
185
|
|
|
181
186
|
// Add content parts, merging reasoning parts with same parentId
|
|
182
187
|
for (const part of content) {
|
|
188
|
+
if (part.type === "tool-call") {
|
|
189
|
+
const existingIdx = assistantMessage.content.findIndex(
|
|
190
|
+
(c) =>
|
|
191
|
+
c.type === "tool-call" && c.toolCallId === part.toolCallId,
|
|
192
|
+
);
|
|
193
|
+
if (existingIdx !== -1) {
|
|
194
|
+
const existing = assistantMessage.content[
|
|
195
|
+
existingIdx
|
|
196
|
+
] as typeof part;
|
|
197
|
+
assistantMessage.content[existingIdx] = {
|
|
198
|
+
...existing,
|
|
199
|
+
...part,
|
|
200
|
+
...mergeInnerMessages(existing, part),
|
|
201
|
+
};
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
183
206
|
if (
|
|
184
207
|
part.type === "reasoning" &&
|
|
185
208
|
"parentId" in part &&
|
|
@@ -198,12 +221,7 @@ const joinExternalMessages = (
|
|
|
198
221
|
assistantMessage.content[existingIdx] = {
|
|
199
222
|
...existing,
|
|
200
223
|
text: `${existing.text}\n\n${part.text}`,
|
|
201
|
-
...
|
|
202
|
-
[symbolInnerMessage]: [
|
|
203
|
-
...((existing as any)[symbolInnerMessage] ?? []),
|
|
204
|
-
...((part as any)[symbolInnerMessage] ?? []),
|
|
205
|
-
],
|
|
206
|
-
},
|
|
224
|
+
...mergeInnerMessages(existing, part),
|
|
207
225
|
};
|
|
208
226
|
continue;
|
|
209
227
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { createContext, useContext } from "react";
|
|
4
|
+
|
|
5
|
+
export type ActionBarInteractionContextValue = {
|
|
6
|
+
acquireInteractionLock: () => () => void;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export const ActionBarInteractionContext =
|
|
10
|
+
createContext<ActionBarInteractionContextValue | null>(null);
|
|
11
|
+
|
|
12
|
+
export const useActionBarInteractionContext = () =>
|
|
13
|
+
useContext(ActionBarInteractionContext);
|
|
@@ -1,11 +1,19 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { Primitive } from "@radix-ui/react-primitive";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
type ComponentRef,
|
|
6
|
+
forwardRef,
|
|
7
|
+
ComponentPropsWithoutRef,
|
|
8
|
+
useCallback,
|
|
9
|
+
useMemo,
|
|
10
|
+
useState,
|
|
11
|
+
} from "react";
|
|
5
12
|
import {
|
|
6
13
|
useActionBarFloatStatus,
|
|
7
14
|
HideAndFloatStatus,
|
|
8
15
|
} from "./useActionBarFloatStatus";
|
|
16
|
+
import { ActionBarInteractionContext } from "./ActionBarInteractionContext";
|
|
9
17
|
|
|
10
18
|
type PrimitiveDivProps = ComponentPropsWithoutRef<typeof Primitive.div>;
|
|
11
19
|
|
|
@@ -60,22 +68,44 @@ export const ActionBarPrimitiveRoot = forwardRef<
|
|
|
60
68
|
ActionBarPrimitiveRoot.Element,
|
|
61
69
|
ActionBarPrimitiveRoot.Props
|
|
62
70
|
>(({ hideWhenRunning, autohide, autohideFloat, ...rest }, ref) => {
|
|
71
|
+
const [interactionCount, setInteractionCount] = useState(0);
|
|
72
|
+
|
|
73
|
+
const acquireInteractionLock = useCallback(() => {
|
|
74
|
+
let released = false;
|
|
75
|
+
|
|
76
|
+
setInteractionCount((count) => count + 1);
|
|
77
|
+
|
|
78
|
+
return () => {
|
|
79
|
+
if (released) return;
|
|
80
|
+
released = true;
|
|
81
|
+
setInteractionCount((count) => Math.max(0, count - 1));
|
|
82
|
+
};
|
|
83
|
+
}, []);
|
|
84
|
+
|
|
85
|
+
const interactionContext = useMemo(
|
|
86
|
+
() => ({ acquireInteractionLock }),
|
|
87
|
+
[acquireInteractionLock],
|
|
88
|
+
);
|
|
89
|
+
|
|
63
90
|
const hideAndfloatStatus = useActionBarFloatStatus({
|
|
64
91
|
hideWhenRunning,
|
|
65
92
|
autohide,
|
|
66
93
|
autohideFloat,
|
|
94
|
+
forceVisible: interactionCount > 0,
|
|
67
95
|
});
|
|
68
96
|
|
|
69
97
|
if (hideAndfloatStatus === HideAndFloatStatus.Hidden) return null;
|
|
70
98
|
|
|
71
99
|
return (
|
|
72
|
-
<
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
100
|
+
<ActionBarInteractionContext.Provider value={interactionContext}>
|
|
101
|
+
<Primitive.div
|
|
102
|
+
{...(hideAndfloatStatus === HideAndFloatStatus.Floating
|
|
103
|
+
? { "data-floating": "true" }
|
|
104
|
+
: null)}
|
|
105
|
+
{...rest}
|
|
106
|
+
ref={ref}
|
|
107
|
+
/>
|
|
108
|
+
</ActionBarInteractionContext.Provider>
|
|
79
109
|
);
|
|
80
110
|
});
|
|
81
111
|
|
|
@@ -12,24 +12,27 @@ export type UseActionBarFloatStatusProps = {
|
|
|
12
12
|
hideWhenRunning?: boolean | undefined;
|
|
13
13
|
autohide?: "always" | "not-last" | "never" | undefined;
|
|
14
14
|
autohideFloat?: "always" | "single-branch" | "never" | undefined;
|
|
15
|
+
forceVisible?: boolean | undefined;
|
|
15
16
|
};
|
|
16
17
|
|
|
17
18
|
export const useActionBarFloatStatus = ({
|
|
18
19
|
hideWhenRunning,
|
|
19
20
|
autohide,
|
|
20
21
|
autohideFloat,
|
|
22
|
+
forceVisible,
|
|
21
23
|
}: UseActionBarFloatStatusProps) => {
|
|
22
24
|
return useAuiState((s) => {
|
|
23
25
|
if (hideWhenRunning && s.thread.isRunning) return HideAndFloatStatus.Hidden;
|
|
24
26
|
|
|
25
27
|
const autohideEnabled =
|
|
26
28
|
autohide === "always" || (autohide === "not-last" && !s.message.isLast);
|
|
29
|
+
const isVisibleByInteraction = forceVisible || s.message.isHovering;
|
|
27
30
|
|
|
28
31
|
// normal status
|
|
29
32
|
if (!autohideEnabled) return HideAndFloatStatus.Normal;
|
|
30
33
|
|
|
31
34
|
// hidden status
|
|
32
|
-
if (!
|
|
35
|
+
if (!isVisibleByInteraction) return HideAndFloatStatus.Hidden;
|
|
33
36
|
|
|
34
37
|
// floating status
|
|
35
38
|
if (
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { FC } from "react";
|
|
3
|
+
import { FC, useCallback, useEffect, useRef } from "react";
|
|
4
4
|
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
|
|
5
5
|
import { ScopedProps, useDropdownMenuScope } from "./scope";
|
|
6
|
+
import { useActionBarInteractionContext } from "../actionBar/ActionBarInteractionContext";
|
|
6
7
|
|
|
7
8
|
export namespace ActionBarMorePrimitiveRoot {
|
|
8
9
|
export type Props = DropdownMenuPrimitive.DropdownMenuProps;
|
|
@@ -12,11 +13,60 @@ export const ActionBarMorePrimitiveRoot: FC<
|
|
|
12
13
|
ActionBarMorePrimitiveRoot.Props
|
|
13
14
|
> = ({
|
|
14
15
|
__scopeActionBarMore,
|
|
16
|
+
open,
|
|
17
|
+
onOpenChange,
|
|
15
18
|
...rest
|
|
16
19
|
}: ScopedProps<ActionBarMorePrimitiveRoot.Props>) => {
|
|
17
20
|
const scope = useDropdownMenuScope(__scopeActionBarMore);
|
|
21
|
+
const actionBarInteraction = useActionBarInteractionContext();
|
|
22
|
+
const releaseInteractionLockRef = useRef<(() => void) | null>(null);
|
|
23
|
+
const isControlled = open !== undefined;
|
|
18
24
|
|
|
19
|
-
|
|
25
|
+
const setInteractionOpen = useCallback(
|
|
26
|
+
(nextOpen: boolean) => {
|
|
27
|
+
if (nextOpen) {
|
|
28
|
+
if (releaseInteractionLockRef.current) return;
|
|
29
|
+
releaseInteractionLockRef.current =
|
|
30
|
+
actionBarInteraction?.acquireInteractionLock() ?? null;
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
releaseInteractionLockRef.current?.();
|
|
35
|
+
releaseInteractionLockRef.current = null;
|
|
36
|
+
},
|
|
37
|
+
[actionBarInteraction],
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
const handleOpenChange = useCallback(
|
|
41
|
+
(nextOpen: boolean) => {
|
|
42
|
+
if (!isControlled) {
|
|
43
|
+
setInteractionOpen(nextOpen);
|
|
44
|
+
}
|
|
45
|
+
onOpenChange?.(nextOpen);
|
|
46
|
+
},
|
|
47
|
+
[isControlled, setInteractionOpen, onOpenChange],
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
if (!isControlled) return;
|
|
52
|
+
setInteractionOpen(Boolean(open));
|
|
53
|
+
}, [isControlled, open, setInteractionOpen]);
|
|
54
|
+
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
return () => {
|
|
57
|
+
releaseInteractionLockRef.current?.();
|
|
58
|
+
releaseInteractionLockRef.current = null;
|
|
59
|
+
};
|
|
60
|
+
}, []);
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<DropdownMenuPrimitive.Root
|
|
64
|
+
{...scope}
|
|
65
|
+
{...rest}
|
|
66
|
+
{...(open !== undefined ? { open } : null)}
|
|
67
|
+
onOpenChange={handleOpenChange}
|
|
68
|
+
/>
|
|
69
|
+
);
|
|
20
70
|
};
|
|
21
71
|
|
|
22
72
|
ActionBarMorePrimitiveRoot.displayName = "ActionBarMorePrimitive.Root";
|
|
@@ -158,6 +158,86 @@ describe("convertExternalMessages", () => {
|
|
|
158
158
|
expect(toolCallParts).toHaveLength(1);
|
|
159
159
|
expect((toolCallParts[0] as any).result).toEqual({ data: "result" });
|
|
160
160
|
});
|
|
161
|
+
|
|
162
|
+
it("should merge duplicate tool calls by toolCallId across assistant messages", () => {
|
|
163
|
+
const messages = [
|
|
164
|
+
{
|
|
165
|
+
id: "msg1",
|
|
166
|
+
role: "assistant" as const,
|
|
167
|
+
content: [
|
|
168
|
+
{
|
|
169
|
+
type: "tool-call" as const,
|
|
170
|
+
toolCallId: "tc1",
|
|
171
|
+
toolName: "search",
|
|
172
|
+
args: { query: "old" },
|
|
173
|
+
argsText: '{"query":"old"',
|
|
174
|
+
},
|
|
175
|
+
],
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
id: "msg2",
|
|
179
|
+
role: "assistant" as const,
|
|
180
|
+
content: [
|
|
181
|
+
{
|
|
182
|
+
type: "tool-call" as const,
|
|
183
|
+
toolCallId: "tc1",
|
|
184
|
+
toolName: "search",
|
|
185
|
+
args: { query: "new" },
|
|
186
|
+
argsText: '{"query":"new"}',
|
|
187
|
+
},
|
|
188
|
+
],
|
|
189
|
+
},
|
|
190
|
+
];
|
|
191
|
+
|
|
192
|
+
const callback: useExternalMessageConverter.Callback<
|
|
193
|
+
(typeof messages)[number]
|
|
194
|
+
> = (msg) => msg;
|
|
195
|
+
|
|
196
|
+
const result = convertExternalMessages(messages, callback, false, {});
|
|
197
|
+
|
|
198
|
+
expect(result).toHaveLength(1);
|
|
199
|
+
expect(result[0]!.role).toBe("assistant");
|
|
200
|
+
const toolCallParts = result[0]!.content.filter(
|
|
201
|
+
(p) => p.type === "tool-call",
|
|
202
|
+
);
|
|
203
|
+
expect(toolCallParts).toHaveLength(1);
|
|
204
|
+
expect((toolCallParts[0] as any).args).toEqual({ query: "new" });
|
|
205
|
+
expect((toolCallParts[0] as any).argsText).toBe('{"query":"new"}');
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("should ignore orphaned tool results without throwing", () => {
|
|
209
|
+
const messages = [
|
|
210
|
+
{
|
|
211
|
+
id: "msg1",
|
|
212
|
+
role: "assistant" as const,
|
|
213
|
+
content: "First response",
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
role: "tool" as const,
|
|
217
|
+
toolCallId: "missing-tool-call",
|
|
218
|
+
toolName: "search",
|
|
219
|
+
result: { data: "orphan result" },
|
|
220
|
+
},
|
|
221
|
+
{
|
|
222
|
+
id: "msg2",
|
|
223
|
+
role: "assistant" as const,
|
|
224
|
+
content: "Second response",
|
|
225
|
+
},
|
|
226
|
+
];
|
|
227
|
+
|
|
228
|
+
const callback: useExternalMessageConverter.Callback<
|
|
229
|
+
(typeof messages)[number]
|
|
230
|
+
> = (msg) => msg;
|
|
231
|
+
|
|
232
|
+
const result = convertExternalMessages(messages, callback, false, {});
|
|
233
|
+
expect(result).toHaveLength(1);
|
|
234
|
+
expect(result[0]!.role).toBe("assistant");
|
|
235
|
+
|
|
236
|
+
const textParts = result[0]!.content.filter((p) => p.type === "text");
|
|
237
|
+
expect(textParts).toHaveLength(2);
|
|
238
|
+
expect((textParts[0] as any).text).toBe("First response");
|
|
239
|
+
expect((textParts[1] as any).text).toBe("Second response");
|
|
240
|
+
});
|
|
161
241
|
});
|
|
162
242
|
|
|
163
243
|
describe("synthetic error message", () => {
|