@assistant-ui/react 0.14.15 → 0.14.18
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/client/ExternalThread.d.ts +4 -3
- package/dist/client/ExternalThread.d.ts.map +1 -1
- package/dist/client/ExternalThread.js +46 -21
- package/dist/client/ExternalThread.js.map +1 -1
- package/dist/client/InMemoryThreadList.d.ts +1 -1
- package/dist/client/InMemoryThreadList.d.ts.map +1 -1
- package/dist/client/InMemoryThreadList.js +7 -5
- package/dist/client/InMemoryThreadList.js.map +1 -1
- package/dist/client/SingleThreadList.d.ts +1 -6
- package/dist/client/SingleThreadList.d.ts.map +1 -1
- package/dist/client/SingleThreadList.js +6 -4
- package/dist/client/SingleThreadList.js.map +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.js +3 -1
- package/dist/mcp-apps/McpAppRenderer.d.ts +2 -10
- package/dist/mcp-apps/McpAppRenderer.d.ts.map +1 -1
- package/dist/mcp-apps/McpAppRenderer.js +3 -2
- package/dist/mcp-apps/McpAppRenderer.js.map +1 -1
- package/dist/mcp-apps/McpAppsRemoteHost.d.ts +1 -8
- package/dist/mcp-apps/McpAppsRemoteHost.d.ts.map +1 -1
- package/dist/mcp-apps/McpAppsRemoteHost.js +3 -2
- package/dist/mcp-apps/McpAppsRemoteHost.js.map +1 -1
- package/dist/primitives/composer/ComposerInput.js +3 -3
- package/dist/primitives/composer/ComposerInput.js.map +1 -1
- package/dist/primitives/composer/trigger/TriggerPopoverResource.d.ts +2 -10
- package/dist/primitives/composer/trigger/TriggerPopoverResource.d.ts.map +1 -1
- package/dist/primitives/composer/trigger/TriggerPopoverResource.js +3 -2
- package/dist/primitives/composer/trigger/TriggerPopoverResource.js.map +1 -1
- package/dist/primitives/composer/trigger/triggerDetectionResource.d.ts +2 -6
- package/dist/primitives/composer/trigger/triggerDetectionResource.d.ts.map +1 -1
- package/dist/primitives/composer/trigger/triggerDetectionResource.js +3 -2
- package/dist/primitives/composer/trigger/triggerDetectionResource.js.map +1 -1
- package/dist/primitives/composer/trigger/triggerKeyboardResource.d.ts +2 -17
- package/dist/primitives/composer/trigger/triggerKeyboardResource.d.ts.map +1 -1
- package/dist/primitives/composer/trigger/triggerKeyboardResource.js +3 -2
- package/dist/primitives/composer/trigger/triggerKeyboardResource.js.map +1 -1
- package/dist/primitives/composer/trigger/triggerNavigationResource.d.ts +2 -10
- package/dist/primitives/composer/trigger/triggerNavigationResource.d.ts.map +1 -1
- package/dist/primitives/composer/trigger/triggerNavigationResource.js +3 -2
- package/dist/primitives/composer/trigger/triggerNavigationResource.js.map +1 -1
- package/dist/primitives/composer/trigger/triggerSelectionResource.d.ts +2 -10
- package/dist/primitives/composer/trigger/triggerSelectionResource.d.ts.map +1 -1
- package/dist/primitives/composer/trigger/triggerSelectionResource.js +3 -2
- package/dist/primitives/composer/trigger/triggerSelectionResource.js.map +1 -1
- package/dist/primitives/messagePart/MessagePartText.d.ts +5 -2
- package/dist/primitives/messagePart/MessagePartText.d.ts.map +1 -1
- package/dist/primitives/messagePart/MessagePartText.js.map +1 -1
- package/dist/primitives/reasoning/useScrollLock.js +11 -2
- package/dist/primitives/reasoning/useScrollLock.js.map +1 -1
- package/dist/primitives/thread/useThreadViewportAutoScroll.d.ts.map +1 -1
- package/dist/primitives/thread/useThreadViewportAutoScroll.js +5 -0
- package/dist/primitives/thread/useThreadViewportAutoScroll.js.map +1 -1
- package/dist/unstable/useComposerInputHistory.d.ts +30 -0
- package/dist/unstable/useComposerInputHistory.d.ts.map +1 -0
- package/dist/unstable/useComposerInputHistory.js +117 -0
- package/dist/unstable/useComposerInputHistory.js.map +1 -0
- package/dist/utils/smooth/useSmooth.d.ts +40 -2
- package/dist/utils/smooth/useSmooth.d.ts.map +1 -1
- package/dist/utils/smooth/useSmooth.js +48 -9
- package/dist/utils/smooth/useSmooth.js.map +1 -1
- package/package.json +31 -24
- package/src/client/ExternalThread.ts +70 -27
- package/src/client/InMemoryThreadList.ts +11 -7
- package/src/client/SingleThreadList.ts +29 -27
- package/src/index.ts +8 -0
- package/src/mcp-apps/McpAppRenderer.tsx +5 -3
- package/src/mcp-apps/McpAppsRemoteHost.ts +5 -3
- package/src/primitives/composer/ComposerInput.test.tsx +1 -1
- package/src/primitives/composer/ComposerInput.tsx +3 -3
- package/src/primitives/composer/trigger/TriggerPopoverResource.ts +5 -3
- package/src/primitives/composer/trigger/triggerDetectionResource.ts +21 -21
- package/src/primitives/composer/trigger/triggerKeyboardResource.test.ts +5 -4
- package/src/primitives/composer/trigger/triggerKeyboardResource.ts +99 -101
- package/src/primitives/composer/trigger/triggerNavigationResource.ts +92 -98
- package/src/primitives/composer/trigger/triggerSelectionResource.ts +76 -76
- package/src/primitives/messagePart/MessagePartText.tsx +3 -2
- package/src/primitives/reasoning/useScrollLock.ts +25 -2
- package/src/primitives/thread/useThreadViewportAutoScroll.ts +8 -0
- package/src/tests/external-thread-branches.test.tsx +160 -0
- package/src/tests/shouldContinue.test.ts +33 -0
- package/src/unstable/useComposerInputHistory.test.tsx +201 -0
- package/src/unstable/useComposerInputHistory.ts +160 -0
- package/src/utils/smooth/useSmooth.test.tsx +95 -0
- package/src/utils/smooth/useSmooth.ts +82 -10
|
@@ -33,91 +33,91 @@ export type TriggerSelectionResourceOutput = {
|
|
|
33
33
|
};
|
|
34
34
|
|
|
35
35
|
/** Owns composer text mutation + behavior dispatch on item selection. */
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
const selectItemOverrideRef = useRef<SelectItemOverride | null>(null);
|
|
36
|
+
const useTriggerSelectionResource = ({
|
|
37
|
+
behavior,
|
|
38
|
+
trigger,
|
|
39
|
+
aui,
|
|
40
|
+
triggerChar,
|
|
41
|
+
setCursorPosition,
|
|
42
|
+
onSelected,
|
|
43
|
+
}: {
|
|
44
|
+
behavior: TriggerBehavior | undefined;
|
|
45
|
+
trigger: DetectedTrigger | null;
|
|
46
|
+
aui: AssistantClient;
|
|
47
|
+
triggerChar: string;
|
|
48
|
+
setCursorPosition: (pos: number) => void;
|
|
49
|
+
/** Called after a successful selection so the parent can reset nav state. */
|
|
50
|
+
onSelected: () => void;
|
|
51
|
+
}): TriggerSelectionResourceOutput => {
|
|
52
|
+
// Select-item override: lets Lexical's DirectivePlugin intercept selection
|
|
53
|
+
// and drive its own node insertion.
|
|
54
|
+
const selectItemOverrideRef = useRef<SelectItemOverride | null>(null);
|
|
56
55
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
56
|
+
const registerSelectItemOverride = useEffectEvent(
|
|
57
|
+
(fn: SelectItemOverride) => {
|
|
58
|
+
selectItemOverrideRef.current = fn;
|
|
59
|
+
return () => {
|
|
60
|
+
if (selectItemOverrideRef.current === fn) {
|
|
61
|
+
selectItemOverrideRef.current = null;
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
},
|
|
65
|
+
);
|
|
67
66
|
|
|
68
|
-
|
|
69
|
-
|
|
67
|
+
const selectItem = useEffectEvent((item: Unstable_TriggerItem) => {
|
|
68
|
+
if (!trigger || !behavior) return;
|
|
70
69
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
70
|
+
if (selectItemOverrideRef.current?.(item)) {
|
|
71
|
+
onSelected();
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
75
74
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
75
|
+
const currentText = aui.composer().getState().text;
|
|
76
|
+
const before = currentText.slice(0, trigger.offset);
|
|
77
|
+
const after = currentText.slice(
|
|
78
|
+
trigger.offset + triggerChar.length + trigger.query.length,
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
const insertDirective = () => {
|
|
82
|
+
const directive = behavior.formatter.serialize(item);
|
|
83
|
+
aui
|
|
84
|
+
.composer()
|
|
85
|
+
.setText(
|
|
86
|
+
before + directive + (after.startsWith(" ") ? after : ` ${after}`),
|
|
87
|
+
);
|
|
88
|
+
};
|
|
81
89
|
|
|
82
|
-
|
|
83
|
-
|
|
90
|
+
if (behavior.kind === "directive") {
|
|
91
|
+
insertDirective();
|
|
92
|
+
behavior.onInserted?.(item);
|
|
93
|
+
} else {
|
|
94
|
+
if (behavior.removeOnExecute) {
|
|
84
95
|
aui
|
|
85
96
|
.composer()
|
|
86
|
-
.setText(
|
|
87
|
-
before + directive + (after.startsWith(" ") ? after : ` ${after}`),
|
|
88
|
-
);
|
|
89
|
-
};
|
|
90
|
-
|
|
91
|
-
if (behavior.kind === "directive") {
|
|
92
|
-
insertDirective();
|
|
93
|
-
behavior.onInserted?.(item);
|
|
97
|
+
.setText(before + (after.startsWith(" ") ? after.slice(1) : after));
|
|
94
98
|
} else {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
.composer()
|
|
98
|
-
.setText(before + (after.startsWith(" ") ? after.slice(1) : after));
|
|
99
|
-
} else {
|
|
100
|
-
// Leave directive chip in the composer as an audit trail
|
|
101
|
-
insertDirective();
|
|
102
|
-
}
|
|
103
|
-
behavior.onExecute(item);
|
|
99
|
+
// Leave directive chip in the composer as an audit trail
|
|
100
|
+
insertDirective();
|
|
104
101
|
}
|
|
102
|
+
behavior.onExecute(item);
|
|
103
|
+
}
|
|
105
104
|
|
|
106
|
-
|
|
107
|
-
|
|
105
|
+
onSelected();
|
|
106
|
+
});
|
|
108
107
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
108
|
+
const close = useEffectEvent(() => {
|
|
109
|
+
onSelected();
|
|
110
|
+
// Move cursor before the trigger so trigger detection deactivates
|
|
111
|
+
if (trigger) {
|
|
112
|
+
setCursorPosition(trigger.offset);
|
|
113
|
+
}
|
|
114
|
+
});
|
|
116
115
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
116
|
+
return {
|
|
117
|
+
selectItem,
|
|
118
|
+
close,
|
|
119
|
+
registerSelectItemOverride,
|
|
120
|
+
};
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
export const TriggerSelectionResource = resource(useTriggerSelectionResource);
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
type ElementType,
|
|
9
9
|
} from "react";
|
|
10
10
|
import { useMessagePartText } from "./useMessagePartText";
|
|
11
|
-
import { useSmooth } from "../../utils/smooth/useSmooth";
|
|
11
|
+
import { useSmooth, type SmoothOptions } from "../../utils/smooth/useSmooth";
|
|
12
12
|
|
|
13
13
|
export namespace MessagePartPrimitiveText {
|
|
14
14
|
export type Element = ComponentRef<typeof Primitive.span>;
|
|
@@ -19,9 +19,10 @@ export namespace MessagePartPrimitiveText {
|
|
|
19
19
|
/**
|
|
20
20
|
* Whether to enable smooth text streaming animation.
|
|
21
21
|
* When enabled, text appears with a typing effect as it streams in.
|
|
22
|
+
* Pass a `SmoothOptions` object to tune the reveal rate.
|
|
22
23
|
* @default true
|
|
23
24
|
*/
|
|
24
|
-
smooth?: boolean;
|
|
25
|
+
smooth?: boolean | SmoothOptions;
|
|
25
26
|
/**
|
|
26
27
|
* The HTML element or React component to render as.
|
|
27
28
|
* @default "span"
|
|
@@ -64,21 +64,44 @@ export const useScrollLock = <T extends HTMLElement = HTMLElement>(
|
|
|
64
64
|
const scrollPosition = scrollContainer.scrollTop;
|
|
65
65
|
const scrollbarWidth = scrollContainer.style.scrollbarWidth;
|
|
66
66
|
|
|
67
|
+
// Hiding the scrollbar collapses its gutter on classic scrollbars, which
|
|
68
|
+
// shifts centered content horizontally; compensate with padding on the
|
|
69
|
+
// side the scrollbar occupies (the left side in RTL).
|
|
70
|
+
const computed = getComputedStyle(scrollContainer);
|
|
71
|
+
const paddingSide =
|
|
72
|
+
computed.direction === "rtl" ? "paddingLeft" : "paddingRight";
|
|
73
|
+
const previousPadding = scrollContainer.style[paddingSide];
|
|
74
|
+
const scrollbarSize =
|
|
75
|
+
scrollContainer.offsetWidth -
|
|
76
|
+
scrollContainer.clientWidth -
|
|
77
|
+
parseFloat(computed.borderLeftWidth) -
|
|
78
|
+
parseFloat(computed.borderRightWidth);
|
|
79
|
+
|
|
67
80
|
scrollContainer.style.scrollbarWidth = "none";
|
|
81
|
+
if (scrollbarSize > 0) {
|
|
82
|
+
scrollContainer.style[paddingSide] = `${
|
|
83
|
+
parseFloat(computed[paddingSide]) + scrollbarSize
|
|
84
|
+
}px`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const restoreStyles = () => {
|
|
88
|
+
scrollContainer.style.scrollbarWidth = scrollbarWidth;
|
|
89
|
+
scrollContainer.style[paddingSide] = previousPadding;
|
|
90
|
+
};
|
|
68
91
|
|
|
69
92
|
const resetPosition = () => (scrollContainer.scrollTop = scrollPosition);
|
|
70
93
|
scrollContainer.addEventListener("scroll", resetPosition);
|
|
71
94
|
|
|
72
95
|
const timeoutId = setTimeout(() => {
|
|
73
96
|
scrollContainer.removeEventListener("scroll", resetPosition);
|
|
74
|
-
|
|
97
|
+
restoreStyles();
|
|
75
98
|
cleanupRef.current = null;
|
|
76
99
|
}, animationDuration);
|
|
77
100
|
|
|
78
101
|
cleanupRef.current = () => {
|
|
79
102
|
clearTimeout(timeoutId);
|
|
80
103
|
scrollContainer.removeEventListener("scroll", resetPosition);
|
|
81
|
-
|
|
104
|
+
restoreStyles();
|
|
82
105
|
};
|
|
83
106
|
}, [animationDuration, animatedElementRef]);
|
|
84
107
|
|
|
@@ -180,9 +180,17 @@ export const useThreadViewportAutoScroll = <TElement extends HTMLElement>({
|
|
|
180
180
|
});
|
|
181
181
|
|
|
182
182
|
const scrollRef = useManagedRef<HTMLElement>((el) => {
|
|
183
|
+
// A pointer gesture invalidates pending bottom-scroll intent; otherwise an
|
|
184
|
+
// intent kept alive by a non-overflowing thread (see handleScroll) hijacks
|
|
185
|
+
// the next content growth, e.g. expanding a collapsible tool call.
|
|
186
|
+
const cancelPendingScrollToBottom = () => {
|
|
187
|
+
scrollingToBottomBehaviorRef.current = null;
|
|
188
|
+
};
|
|
183
189
|
el.addEventListener("scroll", handleScroll);
|
|
190
|
+
el.addEventListener("pointerdown", cancelPendingScrollToBottom);
|
|
184
191
|
return () => {
|
|
185
192
|
el.removeEventListener("scroll", handleScroll);
|
|
193
|
+
el.removeEventListener("pointerdown", cancelPendingScrollToBottom);
|
|
186
194
|
};
|
|
187
195
|
});
|
|
188
196
|
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { render } from "@testing-library/react";
|
|
4
|
+
import type { FC } from "react";
|
|
5
|
+
import { describe, it, expect, vi } from "vitest";
|
|
6
|
+
import { useAui, AuiProvider } from "@assistant-ui/store";
|
|
7
|
+
import type {
|
|
8
|
+
ExternalThreadBranchAdapter,
|
|
9
|
+
ThreadMessage,
|
|
10
|
+
} from "@assistant-ui/core";
|
|
11
|
+
import {
|
|
12
|
+
ExternalThread,
|
|
13
|
+
type ExternalThreadProps,
|
|
14
|
+
} from "../client/ExternalThread";
|
|
15
|
+
|
|
16
|
+
const message = (id: string, role: "user" | "assistant"): ThreadMessage =>
|
|
17
|
+
({
|
|
18
|
+
id,
|
|
19
|
+
role,
|
|
20
|
+
content: [{ type: "text", text: `text of ${id}` }],
|
|
21
|
+
createdAt: new Date(1718000000000),
|
|
22
|
+
...(role === "assistant"
|
|
23
|
+
? { status: { type: "complete", reason: "stop" } }
|
|
24
|
+
: { attachments: [] }),
|
|
25
|
+
metadata: { custom: {} },
|
|
26
|
+
}) as ThreadMessage;
|
|
27
|
+
|
|
28
|
+
const renderThread = (props: ExternalThreadProps) => {
|
|
29
|
+
const captured: { aui?: ReturnType<typeof useAui> } = {};
|
|
30
|
+
const Capture: FC = () => {
|
|
31
|
+
captured.aui = useAui();
|
|
32
|
+
return null;
|
|
33
|
+
};
|
|
34
|
+
const App: FC<{ threadProps: ExternalThreadProps }> = ({ threadProps }) => {
|
|
35
|
+
const aui = useAui({ thread: ExternalThread(threadProps) });
|
|
36
|
+
return (
|
|
37
|
+
<AuiProvider value={aui}>
|
|
38
|
+
<Capture />
|
|
39
|
+
</AuiProvider>
|
|
40
|
+
);
|
|
41
|
+
};
|
|
42
|
+
const utils = render(<App threadProps={props} />);
|
|
43
|
+
return {
|
|
44
|
+
aui: () => captured.aui!,
|
|
45
|
+
rerender: (next: ExternalThreadProps) =>
|
|
46
|
+
utils.rerender(<App threadProps={next} />),
|
|
47
|
+
};
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const baseProps = (
|
|
51
|
+
branches?: ExternalThreadBranchAdapter,
|
|
52
|
+
): ExternalThreadProps => ({
|
|
53
|
+
messages: [message("u1", "user"), message("a2", "assistant")],
|
|
54
|
+
isRunning: false,
|
|
55
|
+
...(branches ? { branches } : {}),
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const adapterFor = (
|
|
59
|
+
ids: readonly string[],
|
|
60
|
+
switchToBranch = vi.fn(),
|
|
61
|
+
): ExternalThreadBranchAdapter => ({
|
|
62
|
+
getBranches: (messageId) => (ids.includes(messageId) ? ids : []),
|
|
63
|
+
switchToBranch,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("ExternalThread branches", () => {
|
|
67
|
+
it("defaults to single-branch state without an adapter", () => {
|
|
68
|
+
const { aui } = renderThread(baseProps());
|
|
69
|
+
const state = aui().thread().getState();
|
|
70
|
+
expect(state.messages[1]!.branchNumber).toBe(1);
|
|
71
|
+
expect(state.messages[1]!.branchCount).toBe(1);
|
|
72
|
+
expect(state.capabilities.switchToBranch).toBe(false);
|
|
73
|
+
expect(() =>
|
|
74
|
+
aui().thread().message({ index: 1 }).switchToBranch({ position: "next" }),
|
|
75
|
+
).not.toThrow();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("derives branchNumber and branchCount from the adapter", () => {
|
|
79
|
+
const { aui } = renderThread(baseProps(adapterFor(["a1", "a2", "a3"])));
|
|
80
|
+
const state = aui().thread().getState();
|
|
81
|
+
expect(state.messages[1]!.branchNumber).toBe(2);
|
|
82
|
+
expect(state.messages[1]!.branchCount).toBe(3);
|
|
83
|
+
expect(state.capabilities.switchToBranch).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("resolves previous and next to sibling ids and no-ops at the edges", () => {
|
|
87
|
+
const switchToBranch = vi.fn();
|
|
88
|
+
const { aui } = renderThread(
|
|
89
|
+
baseProps(adapterFor(["a1", "a2", "a3"], switchToBranch)),
|
|
90
|
+
);
|
|
91
|
+
const msg = () => aui().thread().message({ index: 1 });
|
|
92
|
+
|
|
93
|
+
msg().switchToBranch({ position: "previous" });
|
|
94
|
+
expect(switchToBranch).toHaveBeenLastCalledWith("a1");
|
|
95
|
+
msg().switchToBranch({ position: "next" });
|
|
96
|
+
expect(switchToBranch).toHaveBeenLastCalledWith("a3");
|
|
97
|
+
|
|
98
|
+
switchToBranch.mockClear();
|
|
99
|
+
const edge = renderThread({
|
|
100
|
+
messages: [message("u1", "user"), message("a1", "assistant")],
|
|
101
|
+
isRunning: false,
|
|
102
|
+
branches: adapterFor(["a1", "a2"], switchToBranch),
|
|
103
|
+
});
|
|
104
|
+
edge.aui().thread().message({ index: 1 }).switchToBranch({
|
|
105
|
+
position: "previous",
|
|
106
|
+
});
|
|
107
|
+
expect(switchToBranch).not.toHaveBeenCalled();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("forwards an explicit branchId unvalidated but ignores self-switches", () => {
|
|
111
|
+
const switchToBranch = vi.fn();
|
|
112
|
+
const { aui } = renderThread(
|
|
113
|
+
baseProps(adapterFor(["a1", "a2"], switchToBranch)),
|
|
114
|
+
);
|
|
115
|
+
const msg = () => aui().thread().message({ index: 1 });
|
|
116
|
+
|
|
117
|
+
msg().switchToBranch({ branchId: "not-in-the-list" });
|
|
118
|
+
expect(switchToBranch).toHaveBeenLastCalledWith("not-in-the-list");
|
|
119
|
+
|
|
120
|
+
switchToBranch.mockClear();
|
|
121
|
+
msg().switchToBranch({ branchId: "a2" });
|
|
122
|
+
expect(switchToBranch).not.toHaveBeenCalled();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("falls back to single-branch state when getBranches omits the own id", () => {
|
|
126
|
+
const switchToBranch = vi.fn();
|
|
127
|
+
const { aui } = renderThread(
|
|
128
|
+
baseProps({
|
|
129
|
+
getBranches: () => ["b1", "b2"],
|
|
130
|
+
switchToBranch,
|
|
131
|
+
}),
|
|
132
|
+
);
|
|
133
|
+
const state = aui().thread().getState();
|
|
134
|
+
expect(state.messages[1]!.branchNumber).toBe(1);
|
|
135
|
+
expect(state.messages[1]!.branchCount).toBe(1);
|
|
136
|
+
|
|
137
|
+
aui().thread().message({ index: 1 }).switchToBranch({ position: "next" });
|
|
138
|
+
expect(switchToBranch).not.toHaveBeenCalled();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("keeps message state identity across adapter recreation with equal values", () => {
|
|
142
|
+
const messages = [message("u1", "user"), message("a2", "assistant")];
|
|
143
|
+
const { aui, rerender } = renderThread({
|
|
144
|
+
messages,
|
|
145
|
+
isRunning: false,
|
|
146
|
+
branches: adapterFor(["a1", "a2", "a3"]),
|
|
147
|
+
});
|
|
148
|
+
const before = aui().thread().getState().messages[1];
|
|
149
|
+
|
|
150
|
+
rerender({
|
|
151
|
+
messages,
|
|
152
|
+
isRunning: false,
|
|
153
|
+
branches: adapterFor(["a1", "a2", "a3"]),
|
|
154
|
+
});
|
|
155
|
+
const after = aui().thread().getState().messages[1];
|
|
156
|
+
|
|
157
|
+
expect(after!.branchNumber).toBe(2);
|
|
158
|
+
expect(before!.parts[0]).toBe(after!.parts[0]);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
@@ -86,4 +86,37 @@ describe("shouldContinue", () => {
|
|
|
86
86
|
});
|
|
87
87
|
expect(shouldContinue(msg, undefined)).toBe(true);
|
|
88
88
|
});
|
|
89
|
+
|
|
90
|
+
it("returns false while a tool call has a pending approval", () => {
|
|
91
|
+
const msg = makeMessage({
|
|
92
|
+
status: { type: "requires-action", reason: "tool-calls" },
|
|
93
|
+
content: [{ ...toolCall("deploy"), approval: { id: "a1" } }],
|
|
94
|
+
});
|
|
95
|
+
expect(shouldContinue(msg, undefined)).toBe(false);
|
|
96
|
+
expect(shouldContinue(msg, ["human-approval"])).toBe(false);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("returns true when a decided approval has no result", () => {
|
|
100
|
+
const msg = makeMessage({
|
|
101
|
+
status: { type: "requires-action", reason: "tool-calls" },
|
|
102
|
+
content: [
|
|
103
|
+
{ ...toolCall("deploy"), approval: { id: "a1", approved: true } },
|
|
104
|
+
],
|
|
105
|
+
});
|
|
106
|
+
expect(shouldContinue(msg, undefined)).toBe(true);
|
|
107
|
+
expect(shouldContinue(msg, ["human-approval"])).toBe(true);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("exempts approval-gated tool calls from the human tool result requirement", () => {
|
|
111
|
+
const msg = makeMessage({
|
|
112
|
+
status: { type: "requires-action", reason: "tool-calls" },
|
|
113
|
+
content: [
|
|
114
|
+
{
|
|
115
|
+
...toolCall("human-approval"),
|
|
116
|
+
approval: { id: "a1", approved: true },
|
|
117
|
+
},
|
|
118
|
+
],
|
|
119
|
+
});
|
|
120
|
+
expect(shouldContinue(msg, ["human-approval"])).toBe(true);
|
|
121
|
+
});
|
|
89
122
|
});
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/** @vitest-environment jsdom */
|
|
2
|
+
import { createEvent, fireEvent, render, screen } from "@testing-library/react";
|
|
3
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
|
|
5
|
+
const fixture = {
|
|
6
|
+
messages: [] as { role: string; content: { type: string; text: string }[] }[],
|
|
7
|
+
composerType: "thread",
|
|
8
|
+
activeAria: null as object | null,
|
|
9
|
+
switchedToHandlers: [] as (() => void)[],
|
|
10
|
+
};
|
|
11
|
+
const setText = vi.fn();
|
|
12
|
+
|
|
13
|
+
vi.mock("@assistant-ui/store", () => ({
|
|
14
|
+
useAui: () => ({
|
|
15
|
+
composer: () => ({
|
|
16
|
+
getState: () => ({ type: fixture.composerType }),
|
|
17
|
+
setText,
|
|
18
|
+
}),
|
|
19
|
+
thread: () => ({ getState: () => ({ messages: fixture.messages }) }),
|
|
20
|
+
on: (_event: string, cb: () => void) => {
|
|
21
|
+
fixture.switchedToHandlers.push(cb);
|
|
22
|
+
return () => {};
|
|
23
|
+
},
|
|
24
|
+
}),
|
|
25
|
+
}));
|
|
26
|
+
vi.mock("@assistant-ui/tap", () => ({
|
|
27
|
+
flushTapSync: (fn: () => void) => fn(),
|
|
28
|
+
}));
|
|
29
|
+
vi.mock("../primitives/composer/trigger/TriggerPopoverRootContext", () => ({
|
|
30
|
+
useTriggerPopoverRootContextOptional: () => ({
|
|
31
|
+
getActiveAria: () => fixture.activeAria,
|
|
32
|
+
}),
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
|
|
36
|
+
cb(0);
|
|
37
|
+
return 0;
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
import { unstable_useComposerInputHistory } from "./useComposerInputHistory";
|
|
41
|
+
|
|
42
|
+
const user = (text: string) => ({
|
|
43
|
+
role: "user",
|
|
44
|
+
content: [{ type: "text", text }],
|
|
45
|
+
});
|
|
46
|
+
const assistant = (text: string) => ({
|
|
47
|
+
role: "assistant",
|
|
48
|
+
content: [{ type: "text", text }],
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
function Harness() {
|
|
52
|
+
const history = unstable_useComposerInputHistory();
|
|
53
|
+
return <textarea data-testid="input" {...history} />;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const setup = (value = "") => {
|
|
57
|
+
const utils = render(<Harness />);
|
|
58
|
+
const textarea = screen.getByTestId("input") as HTMLTextAreaElement;
|
|
59
|
+
textarea.value = value;
|
|
60
|
+
textarea.setSelectionRange(value.length, value.length);
|
|
61
|
+
return { ...utils, textarea };
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const arrow = (
|
|
65
|
+
textarea: HTMLTextAreaElement,
|
|
66
|
+
key: "ArrowUp" | "ArrowDown",
|
|
67
|
+
init: Record<string, unknown> = {},
|
|
68
|
+
) => fireEvent.keyDown(textarea, { key, ...init });
|
|
69
|
+
|
|
70
|
+
beforeEach(() => {
|
|
71
|
+
fixture.messages = [user("first"), assistant("reply"), user("second")];
|
|
72
|
+
fixture.composerType = "thread";
|
|
73
|
+
fixture.activeAria = null;
|
|
74
|
+
fixture.switchedToHandlers = [];
|
|
75
|
+
setText.mockClear();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("unstable_useComposerInputHistory", () => {
|
|
79
|
+
it("recalls the newest user message on ArrowUp in an empty composer", () => {
|
|
80
|
+
const { textarea } = setup("");
|
|
81
|
+
const notPrevented = arrow(textarea, "ArrowUp");
|
|
82
|
+
expect(notPrevented).toBe(false);
|
|
83
|
+
expect(setText).toHaveBeenCalledWith("second");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("steps to older entries and consumes the key at the oldest", () => {
|
|
87
|
+
const { textarea } = setup("");
|
|
88
|
+
arrow(textarea, "ArrowUp");
|
|
89
|
+
textarea.value = "second";
|
|
90
|
+
arrow(textarea, "ArrowUp");
|
|
91
|
+
expect(setText).toHaveBeenLastCalledWith("first");
|
|
92
|
+
|
|
93
|
+
textarea.value = "first";
|
|
94
|
+
const notPrevented = arrow(textarea, "ArrowUp");
|
|
95
|
+
expect(notPrevented).toBe(false);
|
|
96
|
+
expect(setText).toHaveBeenCalledTimes(2);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("restores the draft snapshot on ArrowDown past the newest", () => {
|
|
100
|
+
const { textarea } = setup(" ");
|
|
101
|
+
arrow(textarea, "ArrowUp");
|
|
102
|
+
textarea.value = "second";
|
|
103
|
+
arrow(textarea, "ArrowDown");
|
|
104
|
+
expect(setText).toHaveBeenLastCalledWith(" ");
|
|
105
|
+
|
|
106
|
+
textarea.value = " ";
|
|
107
|
+
const notPrevented = arrow(textarea, "ArrowDown");
|
|
108
|
+
expect(notPrevented).toBe(true);
|
|
109
|
+
expect(setText).toHaveBeenCalledTimes(2);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("ignores ArrowUp when the composer holds a typed draft", () => {
|
|
113
|
+
const { textarea } = setup("draft in progress");
|
|
114
|
+
const notPrevented = arrow(textarea, "ArrowUp");
|
|
115
|
+
expect(notPrevented).toBe(true);
|
|
116
|
+
expect(setText).not.toHaveBeenCalled();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("keeps native arrows when the caret is not on the first line", () => {
|
|
120
|
+
fixture.messages = [user("other"), user("line1\nline2")];
|
|
121
|
+
const { textarea } = setup("");
|
|
122
|
+
arrow(textarea, "ArrowUp");
|
|
123
|
+
expect(setText).toHaveBeenLastCalledWith("line1\nline2");
|
|
124
|
+
textarea.value = "line1\nline2";
|
|
125
|
+
textarea.setSelectionRange(8, 8);
|
|
126
|
+
const notPrevented = arrow(textarea, "ArrowUp");
|
|
127
|
+
expect(notPrevented).toBe(true);
|
|
128
|
+
expect(setText).toHaveBeenCalledTimes(1);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("yields to an open trigger popover", () => {
|
|
132
|
+
fixture.activeAria = {};
|
|
133
|
+
const { textarea } = setup("");
|
|
134
|
+
const notPrevented = arrow(textarea, "ArrowUp");
|
|
135
|
+
expect(notPrevented).toBe(true);
|
|
136
|
+
expect(setText).not.toHaveBeenCalled();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("ignores IME composition, modifiers, selections, and prevented events", () => {
|
|
140
|
+
const { textarea } = setup("");
|
|
141
|
+
fireEvent.keyDown(textarea, { key: "ArrowUp", isComposing: true });
|
|
142
|
+
arrow(textarea, "ArrowUp", { shiftKey: true });
|
|
143
|
+
arrow(textarea, "ArrowUp", { metaKey: true });
|
|
144
|
+
textarea.value = "ab";
|
|
145
|
+
textarea.setSelectionRange(0, 2);
|
|
146
|
+
arrow(textarea, "ArrowUp");
|
|
147
|
+
|
|
148
|
+
textarea.value = "";
|
|
149
|
+
textarea.setSelectionRange(0, 0);
|
|
150
|
+
const prevented = createEvent.keyDown(textarea, { key: "ArrowUp" });
|
|
151
|
+
prevented.preventDefault();
|
|
152
|
+
fireEvent(textarea, prevented);
|
|
153
|
+
expect(setText).not.toHaveBeenCalled();
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("derives the ring from trimmed user texts, newest first, deduped", () => {
|
|
157
|
+
fixture.messages = [
|
|
158
|
+
user("first"),
|
|
159
|
+
user("first"),
|
|
160
|
+
assistant("noise"),
|
|
161
|
+
user(" "),
|
|
162
|
+
user("second"),
|
|
163
|
+
];
|
|
164
|
+
const { textarea } = setup("");
|
|
165
|
+
arrow(textarea, "ArrowUp");
|
|
166
|
+
expect(setText).toHaveBeenLastCalledWith("second");
|
|
167
|
+
textarea.value = "second";
|
|
168
|
+
arrow(textarea, "ArrowUp");
|
|
169
|
+
expect(setText).toHaveBeenLastCalledWith("first");
|
|
170
|
+
textarea.value = "first";
|
|
171
|
+
arrow(textarea, "ArrowUp");
|
|
172
|
+
expect(setText).toHaveBeenCalledTimes(2);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("starts a fresh browse after the composer was cleared externally", () => {
|
|
176
|
+
const { textarea } = setup("");
|
|
177
|
+
arrow(textarea, "ArrowUp");
|
|
178
|
+
fixture.messages = [...fixture.messages, user("third")];
|
|
179
|
+
textarea.value = "";
|
|
180
|
+
arrow(textarea, "ArrowUp");
|
|
181
|
+
expect(setText).toHaveBeenLastCalledWith("third");
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("is inert on edit composers", () => {
|
|
185
|
+
fixture.composerType = "edit";
|
|
186
|
+
const { textarea } = setup("");
|
|
187
|
+
const notPrevented = arrow(textarea, "ArrowUp");
|
|
188
|
+
expect(notPrevented).toBe(true);
|
|
189
|
+
expect(setText).not.toHaveBeenCalled();
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("resets browsing when the thread switches", () => {
|
|
193
|
+
const { textarea } = setup("");
|
|
194
|
+
arrow(textarea, "ArrowUp");
|
|
195
|
+
fixture.switchedToHandlers.forEach((cb) => cb());
|
|
196
|
+
textarea.value = "second";
|
|
197
|
+
const notPrevented = arrow(textarea, "ArrowDown");
|
|
198
|
+
expect(notPrevented).toBe(true);
|
|
199
|
+
expect(setText).toHaveBeenCalledTimes(1);
|
|
200
|
+
});
|
|
201
|
+
});
|