@assistant-ui/react 0.15.25 → 0.15.27
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/hooks/useToolCallElapsed.d.ts.map +1 -1
- package/dist/hooks/useToolCallElapsed.js +3 -2
- package/dist/hooks/useToolCallElapsed.js.map +1 -1
- package/dist/index.d.ts +31 -5
- package/dist/index.d.ts.map +1 -1
- 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/app-frame.d.ts.map +1 -1
- package/dist/mcp-apps/app-frame.js +58 -48
- package/dist/mcp-apps/app-frame.js.map +1 -1
- package/dist/mcp-apps/bridge.d.ts.map +1 -1
- package/dist/mcp-apps/bridge.js +28 -3
- package/dist/mcp-apps/bridge.js.map +1 -1
- package/dist/mcp-apps/csp.d.ts +4 -0
- package/dist/mcp-apps/csp.d.ts.map +1 -0
- package/dist/mcp-apps/csp.js +93 -0
- package/dist/mcp-apps/csp.js.map +1 -0
- package/dist/mcp-apps/index.d.ts +1 -1
- package/dist/mcp-apps/index.d.ts.map +1 -1
- package/dist/mcp-apps/types.d.ts +19 -1
- package/dist/mcp-apps/types.d.ts.map +1 -1
- package/dist/mcp-apps/types.js.map +1 -1
- package/dist/primitives/assistantModal/AssistantModalRoot.d.ts +1 -0
- package/dist/primitives/assistantModal/AssistantModalRoot.d.ts.map +1 -1
- package/dist/primitives/assistantModal/AssistantModalRoot.js.map +1 -1
- package/dist/primitives/attachment.d.ts +3 -1
- package/dist/primitives/attachment.d.ts.map +1 -1
- package/dist/primitives/attachment.js.map +1 -1
- package/dist/primitives/composer/ComposerAttachmentDropzone.d.ts.map +1 -1
- package/dist/primitives/composer/ComposerAttachmentDropzone.js +14 -12
- package/dist/primitives/composer/ComposerAttachmentDropzone.js.map +1 -1
- package/dist/primitives/composer/ComposerCancel.d.ts +5 -1
- package/dist/primitives/composer/ComposerCancel.d.ts.map +1 -1
- package/dist/primitives/composer/ComposerCancel.js +14 -1
- package/dist/primitives/composer/ComposerCancel.js.map +1 -1
- package/dist/primitives/composer/ComposerInput.d.ts +14 -0
- package/dist/primitives/composer/ComposerInput.d.ts.map +1 -1
- package/dist/primitives/composer/ComposerInput.js +4 -3
- package/dist/primitives/composer/ComposerInput.js.map +1 -1
- package/dist/primitives/composer/ComposerRoot.d.ts.map +1 -1
- package/dist/primitives/composer/ComposerRoot.js +24 -19
- package/dist/primitives/composer/ComposerRoot.js.map +1 -1
- package/dist/primitives/composer/composerInputSelector.d.ts +3 -0
- package/dist/primitives/composer/composerInputSelector.d.ts.map +1 -0
- package/dist/primitives/composer/composerInputSelector.js +7 -0
- package/dist/primitives/composer/composerInputSelector.js.map +1 -0
- package/dist/primitives/composer/useComposerCancelWithFocus.d.ts +2 -0
- package/dist/primitives/composer/useComposerCancelWithFocus.d.ts.map +1 -0
- package/dist/primitives/composer/useComposerCancelWithFocus.js +42 -0
- package/dist/primitives/composer/useComposerCancelWithFocus.js.map +1 -0
- package/dist/primitives/composer.d.ts +35 -14
- package/dist/primitives/composer.d.ts.map +1 -1
- package/dist/primitives/composer.js.map +1 -1
- package/dist/primitives/message/MessagePartsGrouped.d.ts +8 -1
- package/dist/primitives/message/MessagePartsGrouped.d.ts.map +1 -1
- package/dist/primitives/message/MessagePartsGrouped.js +58 -278
- package/dist/primitives/message/MessagePartsGrouped.js.map +1 -1
- package/dist/primitives/selectionToolbar/SelectionToolbarRoot.d.ts.map +1 -1
- package/dist/primitives/selectionToolbar/SelectionToolbarRoot.js +5 -2
- package/dist/primitives/selectionToolbar/SelectionToolbarRoot.js.map +1 -1
- package/dist/primitives/thread/ThreadViewport.d.ts.map +1 -1
- package/dist/primitives/thread/ThreadViewport.js +4 -3
- package/dist/primitives/thread/ThreadViewport.js.map +1 -1
- package/dist/primitives/thread/topAnchor/useTopAnchorReserve.js +2 -2
- package/dist/primitives/thread/topAnchor/useTopAnchorReserve.js.map +1 -1
- package/dist/primitives/thread/useThreadViewportAutoScroll.d.ts.map +1 -1
- package/dist/primitives/thread/useThreadViewportAutoScroll.js +90 -21
- package/dist/primitives/thread/useThreadViewportAutoScroll.js.map +1 -1
- package/dist/sandbox-host/SandboxHost.d.ts.map +1 -1
- package/dist/sandbox-host/SandboxHost.js +3 -2
- package/dist/sandbox-host/SandboxHost.js.map +1 -1
- package/dist/unstable/useComposerInput.d.ts +9 -4
- package/dist/unstable/useComposerInput.d.ts.map +1 -1
- package/dist/unstable/useComposerInput.js +6 -4
- package/dist/unstable/useComposerInput.js.map +1 -1
- package/dist/unstable/useComposerInputHistory.d.ts +3 -2
- package/dist/unstable/useComposerInputHistory.d.ts.map +1 -1
- package/dist/unstable/useComposerInputHistory.js +2 -2
- package/dist/unstable/useComposerInputHistory.js.map +1 -1
- package/dist/unstable/useLiveCompletionAdapter.d.ts +3 -2
- package/dist/unstable/useLiveCompletionAdapter.d.ts.map +1 -1
- package/dist/unstable/useLiveCompletionAdapter.js +8 -7
- package/dist/unstable/useLiveCompletionAdapter.js.map +1 -1
- package/dist/unstable/useMentionAdapter.d.ts +12 -3
- package/dist/unstable/useMentionAdapter.d.ts.map +1 -1
- package/dist/unstable/useMentionAdapter.js +2 -2
- package/dist/unstable/useMentionAdapter.js.map +1 -1
- package/dist/unstable/useMessageStallDetection.d.ts +4 -2
- package/dist/unstable/useMessageStallDetection.d.ts.map +1 -1
- package/dist/unstable/useMessageStallDetection.js +2 -2
- package/dist/unstable/useMessageStallDetection.js.map +1 -1
- package/dist/unstable/useSlashCommandAdapter.d.ts +5 -2
- package/dist/unstable/useSlashCommandAdapter.d.ts.map +1 -1
- package/dist/unstable/useSlashCommandAdapter.js +5 -4
- package/dist/unstable/useSlashCommandAdapter.js.map +1 -1
- package/dist/unstable/webmcp/useWebMcpProvider.d.ts +4 -0
- package/dist/unstable/webmcp/useWebMcpProvider.d.ts.map +1 -1
- package/dist/unstable/webmcp/useWebMcpProvider.js +2 -0
- package/dist/unstable/webmcp/useWebMcpProvider.js.map +1 -1
- package/dist/utils/getSelectionMessageId.d.ts.map +1 -1
- package/dist/utils/getSelectionMessageId.js +31 -8
- package/dist/utils/getSelectionMessageId.js.map +1 -1
- package/dist/utils/hooks/useOnResizeContent.d.ts.map +1 -1
- package/dist/utils/hooks/useOnResizeContent.js +2 -22
- package/dist/utils/hooks/useOnResizeContent.js.map +1 -1
- package/dist/utils/useIsomorphicLayoutEffect.d.ts +3 -0
- package/dist/utils/useIsomorphicLayoutEffect.d.ts.map +1 -0
- package/dist/utils/useIsomorphicLayoutEffect.js +6 -0
- package/dist/utils/useIsomorphicLayoutEffect.js.map +1 -0
- package/package.json +10 -10
- package/src/hooks/useToolCallElapsed.test.tsx +4 -2
- package/src/hooks/useToolCallElapsed.ts +3 -2
- package/src/index.ts +20 -2
- package/src/mcp-apps/McpAppRenderer.test.tsx +7 -2
- package/src/mcp-apps/McpAppRenderer.tsx +3 -9
- package/src/mcp-apps/app-frame.test.tsx +102 -6
- package/src/mcp-apps/app-frame.tsx +7 -10
- package/src/mcp-apps/bridge.test.ts +154 -0
- package/src/mcp-apps/bridge.ts +51 -9
- package/src/mcp-apps/csp.test.ts +112 -0
- package/src/mcp-apps/csp.ts +124 -0
- package/src/mcp-apps/index.ts +1 -0
- package/src/mcp-apps/types.ts +16 -1
- package/src/model-context/frame/AssistantFrame.test.ts +45 -0
- package/src/model-context/frame/SPEC_AssistantFrame.md +1 -0
- package/src/primitives/assistantModal/AssistantModalRoot.tsx +1 -0
- package/src/primitives/attachment.ts +4 -1
- package/src/primitives/composer/ComposerAttachmentDropzone.test.tsx +32 -1
- package/src/primitives/composer/ComposerAttachmentDropzone.tsx +14 -12
- package/src/primitives/composer/ComposerCancel.focus.test.tsx +307 -0
- package/src/primitives/composer/ComposerCancel.ts +8 -1
- package/src/primitives/composer/ComposerDisabledThread.test.tsx +121 -0
- package/src/primitives/composer/ComposerInput.tsx +19 -1
- package/src/primitives/composer/ComposerRoot.tsx +6 -5
- package/src/primitives/composer/composerInputSelector.ts +4 -0
- package/src/primitives/composer/useComposerCancelWithFocus.ts +57 -0
- package/src/primitives/composer.ts +56 -21
- package/src/primitives/message/MessagePartsGrouped.test.tsx +106 -2
- package/src/primitives/message/MessagePartsGrouped.tsx +36 -134
- package/src/primitives/selectionToolbar/SelectionToolbarRoot.test.tsx +20 -12
- package/src/primitives/selectionToolbar/SelectionToolbarRoot.tsx +6 -2
- package/src/primitives/thread/ThreadViewport.tsx +3 -3
- package/src/primitives/thread/topAnchor/useTopAnchorReserve.ts +2 -2
- package/src/primitives/thread/useThreadViewportAutoScroll.test.tsx +666 -63
- package/src/primitives/thread/useThreadViewportAutoScroll.ts +201 -73
- package/src/sandbox-host/SandboxHost.tsx +3 -8
- package/src/unstable/useComposerInput.ts +9 -4
- package/src/unstable/useComposerInputHistory.ts +3 -2
- package/src/unstable/useLiveCompletionAdapter.ts +9 -14
- package/src/unstable/useMentionAdapter.ts +12 -3
- package/src/unstable/useMessageStallDetection.ts +4 -2
- package/src/unstable/useSlashCommandAdapter.ts +8 -4
- package/src/unstable/webmcp/useWebMcpProvider.ts +4 -0
- package/src/utils/getSelectionMessageId.test.ts +231 -0
- package/src/utils/getSelectionMessageId.ts +45 -7
- package/src/utils/hooks/useOnResizeContent.ts +2 -29
- package/src/utils/useIsomorphicLayoutEffect.test.tsx +21 -0
- package/src/utils/useIsomorphicLayoutEffect.ts +9 -0
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
/** @vitest-environment jsdom */
|
|
2
|
+
import { act, fireEvent, render, screen } from "@testing-library/react";
|
|
3
|
+
import type { ComponentProps, PropsWithChildren, ReactNode } from "react";
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import {
|
|
6
|
+
AssistantRuntimeProvider,
|
|
7
|
+
useExternalStoreRuntime,
|
|
8
|
+
} from "@assistant-ui/core/react";
|
|
9
|
+
import type { ThreadMessageLike } from "@assistant-ui/core";
|
|
10
|
+
import { ComposerPrimitiveRoot } from "./ComposerRoot";
|
|
11
|
+
import { ComposerPrimitiveInput } from "./ComposerInput";
|
|
12
|
+
import { ComposerPrimitiveCancel } from "./ComposerCancel";
|
|
13
|
+
import { ThreadPrimitiveRoot } from "../thread/ThreadRoot";
|
|
14
|
+
import { ThreadPrimitiveViewportProvider } from "../../context/providers/ThreadViewportProvider";
|
|
15
|
+
import { ThreadPrimitiveMessages } from "../thread/ThreadMessages";
|
|
16
|
+
import { ActionBarPrimitiveEdit } from "../actionBar/ActionBarEdit";
|
|
17
|
+
|
|
18
|
+
const messages = [{ id: "user-message", text: "Original message" }];
|
|
19
|
+
const { cancelOverride } = vi.hoisted(() => ({ cancelOverride: vi.fn() }));
|
|
20
|
+
vi.mock("@assistant-ui/core/react", async (importOriginal) => {
|
|
21
|
+
const original =
|
|
22
|
+
await importOriginal<typeof import("@assistant-ui/core/react")>();
|
|
23
|
+
return {
|
|
24
|
+
...original,
|
|
25
|
+
useComposerCancel: () => {
|
|
26
|
+
const behavior = original.useComposerCancel();
|
|
27
|
+
return cancelOverride() ?? behavior;
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
});
|
|
31
|
+
const convertMessage = (
|
|
32
|
+
message: (typeof messages)[number],
|
|
33
|
+
): ThreadMessageLike => ({
|
|
34
|
+
id: message.id,
|
|
35
|
+
role: "user",
|
|
36
|
+
content: [{ type: "text", text: message.text }],
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const Provider = ({
|
|
40
|
+
children,
|
|
41
|
+
isRunning = false,
|
|
42
|
+
onCancel,
|
|
43
|
+
}: PropsWithChildren<{
|
|
44
|
+
isRunning?: boolean;
|
|
45
|
+
onCancel?: () => Promise<void>;
|
|
46
|
+
}>) => {
|
|
47
|
+
const runtime = useExternalStoreRuntime({
|
|
48
|
+
messages,
|
|
49
|
+
convertMessage,
|
|
50
|
+
onNew: async () => {},
|
|
51
|
+
onEdit: async () => {},
|
|
52
|
+
isRunning,
|
|
53
|
+
onCancel,
|
|
54
|
+
});
|
|
55
|
+
return (
|
|
56
|
+
<AssistantRuntimeProvider runtime={runtime}>
|
|
57
|
+
{children}
|
|
58
|
+
</AssistantRuntimeProvider>
|
|
59
|
+
);
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const Message = () => (
|
|
63
|
+
<ActionBarPrimitiveEdit>Edit message</ActionBarPrimitiveEdit>
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
type Options = {
|
|
67
|
+
nonForm?: boolean;
|
|
68
|
+
disabledMain?: boolean;
|
|
69
|
+
showMain?: boolean;
|
|
70
|
+
contentEditable?: boolean;
|
|
71
|
+
beforeMain?: ReactNode;
|
|
72
|
+
onCancelClick?: ComponentProps<typeof ComposerPrimitiveCancel>["onClick"];
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const setup = ({
|
|
76
|
+
nonForm,
|
|
77
|
+
disabledMain,
|
|
78
|
+
showMain = true,
|
|
79
|
+
contentEditable,
|
|
80
|
+
beforeMain,
|
|
81
|
+
onCancelClick,
|
|
82
|
+
}: Options = {}) => {
|
|
83
|
+
const EditComposer = () => (
|
|
84
|
+
<ComposerPrimitiveRoot render={nonForm ? <div /> : undefined}>
|
|
85
|
+
<ComposerPrimitiveInput aria-label="Edit composer" autoFocus />
|
|
86
|
+
<ComposerPrimitiveCancel onClick={onCancelClick}>
|
|
87
|
+
Cancel edit
|
|
88
|
+
</ComposerPrimitiveCancel>
|
|
89
|
+
</ComposerPrimitiveRoot>
|
|
90
|
+
);
|
|
91
|
+
render(
|
|
92
|
+
<Provider>
|
|
93
|
+
<input aria-label="Outside thread" />
|
|
94
|
+
<ThreadPrimitiveRoot>
|
|
95
|
+
<ThreadPrimitiveViewportProvider>
|
|
96
|
+
<ThreadPrimitiveMessages components={{ Message, EditComposer }} />
|
|
97
|
+
{beforeMain}
|
|
98
|
+
{showMain && (
|
|
99
|
+
<ComposerPrimitiveRoot render={nonForm ? <section /> : undefined}>
|
|
100
|
+
{contentEditable ? (
|
|
101
|
+
<div
|
|
102
|
+
role="textbox"
|
|
103
|
+
aria-label="Main composer"
|
|
104
|
+
contentEditable
|
|
105
|
+
/>
|
|
106
|
+
) : (
|
|
107
|
+
<ComposerPrimitiveInput
|
|
108
|
+
aria-label="Main composer"
|
|
109
|
+
disabled={disabledMain}
|
|
110
|
+
/>
|
|
111
|
+
)}
|
|
112
|
+
</ComposerPrimitiveRoot>
|
|
113
|
+
)}
|
|
114
|
+
</ThreadPrimitiveViewportProvider>
|
|
115
|
+
</ThreadPrimitiveRoot>
|
|
116
|
+
</Provider>,
|
|
117
|
+
);
|
|
118
|
+
fireEvent.click(screen.getByRole("button", { name: "Edit message" }));
|
|
119
|
+
const input = screen.getByRole("textbox", { name: "Edit composer" });
|
|
120
|
+
act(() => input.focus());
|
|
121
|
+
return { input, cancel: screen.getByRole("button", { name: "Cancel edit" }) };
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
afterEach(() => {
|
|
125
|
+
cancelOverride.mockReset();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe("edit composer cancellation focus", () => {
|
|
129
|
+
it.each(["Escape", "Cancel"])(
|
|
130
|
+
"returns focus to the main composer after %s",
|
|
131
|
+
(action) => {
|
|
132
|
+
const { input, cancel } = setup();
|
|
133
|
+
if (action === "Escape") {
|
|
134
|
+
fireEvent.keyDown(input, { key: "Escape" });
|
|
135
|
+
} else {
|
|
136
|
+
act(() => cancel.focus());
|
|
137
|
+
fireEvent.click(cancel);
|
|
138
|
+
}
|
|
139
|
+
expect(
|
|
140
|
+
screen.queryByRole("textbox", { name: "Edit composer" }),
|
|
141
|
+
).toBeNull();
|
|
142
|
+
expect(document.activeElement).toBe(
|
|
143
|
+
screen.getByRole("textbox", { name: "Main composer" }),
|
|
144
|
+
);
|
|
145
|
+
},
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
it("supports non-form composer roots and a contenteditable destination", () => {
|
|
149
|
+
const { input } = setup({ nonForm: true, contentEditable: true });
|
|
150
|
+
fireEvent.keyDown(input, { key: "Escape" });
|
|
151
|
+
expect(document.activeElement).toBe(
|
|
152
|
+
screen.getByRole("textbox", { name: "Main composer" }),
|
|
153
|
+
);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("does not focus a nested thread's composer", () => {
|
|
157
|
+
const { input } = setup({
|
|
158
|
+
beforeMain: (
|
|
159
|
+
<Provider>
|
|
160
|
+
<ThreadPrimitiveRoot>
|
|
161
|
+
<ComposerPrimitiveRoot>
|
|
162
|
+
<textarea aria-label="Nested composer" />
|
|
163
|
+
</ComposerPrimitiveRoot>
|
|
164
|
+
</ThreadPrimitiveRoot>
|
|
165
|
+
</Provider>
|
|
166
|
+
),
|
|
167
|
+
});
|
|
168
|
+
fireEvent.keyDown(input, { key: "Escape" });
|
|
169
|
+
expect(document.activeElement).toBe(
|
|
170
|
+
screen.getByRole("textbox", { name: "Main composer" }),
|
|
171
|
+
);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it.each(["hidden", "inert"] as const)(
|
|
175
|
+
"skips a %s main composer before the usable destination",
|
|
176
|
+
(attribute) => {
|
|
177
|
+
const { input } = setup({
|
|
178
|
+
beforeMain: (
|
|
179
|
+
<ComposerPrimitiveRoot {...{ [attribute]: true }}>
|
|
180
|
+
<textarea aria-label="Unavailable composer" />
|
|
181
|
+
</ComposerPrimitiveRoot>
|
|
182
|
+
),
|
|
183
|
+
});
|
|
184
|
+
fireEvent.keyDown(input, { key: "Escape" });
|
|
185
|
+
expect(document.activeElement).toBe(
|
|
186
|
+
screen.getByRole("textbox", { name: "Main composer" }),
|
|
187
|
+
);
|
|
188
|
+
},
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
it.each([{ showMain: false }, { disabledMain: true }])(
|
|
192
|
+
"cancels safely without a usable destination: %j",
|
|
193
|
+
(options) => {
|
|
194
|
+
const { input } = setup(options);
|
|
195
|
+
fireEvent.keyDown(input, { key: "Escape" });
|
|
196
|
+
expect(
|
|
197
|
+
screen.queryByRole("textbox", { name: "Edit composer" }),
|
|
198
|
+
).toBeNull();
|
|
199
|
+
expect(document.activeElement).toBe(document.body);
|
|
200
|
+
},
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
it("preserves focus deliberately moved by the consumer", () => {
|
|
204
|
+
const { cancel } = setup({
|
|
205
|
+
onCancelClick: () =>
|
|
206
|
+
screen.getByRole("textbox", { name: "Outside thread" }).focus(),
|
|
207
|
+
});
|
|
208
|
+
act(() => cancel.focus());
|
|
209
|
+
fireEvent.click(cancel);
|
|
210
|
+
expect(screen.queryByRole("textbox", { name: "Edit composer" })).toBeNull();
|
|
211
|
+
expect(document.activeElement).toBe(
|
|
212
|
+
screen.getByRole("textbox", { name: "Outside thread" }),
|
|
213
|
+
);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("restores focus when cancellation hides the connected edit composer", () => {
|
|
217
|
+
cancelOverride.mockReturnValue({
|
|
218
|
+
disabled: false,
|
|
219
|
+
cancel: () => {
|
|
220
|
+
const active = document.activeElement as HTMLElement;
|
|
221
|
+
active.closest<HTMLElement>('[data-aui-composer-type="edit"]')!.hidden =
|
|
222
|
+
true;
|
|
223
|
+
active.blur();
|
|
224
|
+
},
|
|
225
|
+
});
|
|
226
|
+
const { cancel } = setup();
|
|
227
|
+
act(() => cancel.focus());
|
|
228
|
+
fireEvent.click(cancel);
|
|
229
|
+
|
|
230
|
+
expect(cancel.isConnected).toBe(true);
|
|
231
|
+
expect(cancel.closest("[hidden]")).not.toBeNull();
|
|
232
|
+
expect(document.activeElement).toBe(
|
|
233
|
+
screen.getByRole("textbox", { name: "Main composer" }),
|
|
234
|
+
);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("does not cancel or restore focus when the consumer prevents the action", () => {
|
|
238
|
+
const { cancel } = setup({
|
|
239
|
+
onCancelClick: (event) => event.preventDefault(),
|
|
240
|
+
});
|
|
241
|
+
act(() => cancel.focus());
|
|
242
|
+
fireEvent.click(cancel);
|
|
243
|
+
expect(
|
|
244
|
+
screen.queryByRole("textbox", { name: "Edit composer" }),
|
|
245
|
+
).not.toBeNull();
|
|
246
|
+
expect(document.activeElement).toBe(cancel);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("leaves focus and editing unchanged when cancellation throws", () => {
|
|
250
|
+
const error = new Error("Cancellation failed");
|
|
251
|
+
const cancel = vi.fn(() => {
|
|
252
|
+
throw error;
|
|
253
|
+
});
|
|
254
|
+
cancelOverride.mockReturnValue({
|
|
255
|
+
disabled: false,
|
|
256
|
+
cancel,
|
|
257
|
+
});
|
|
258
|
+
const { cancel: button } = setup();
|
|
259
|
+
act(() => button.focus());
|
|
260
|
+
const onError = (event: ErrorEvent) => {
|
|
261
|
+
if (event.error === error) event.preventDefault();
|
|
262
|
+
};
|
|
263
|
+
window.addEventListener("error", onError);
|
|
264
|
+
try {
|
|
265
|
+
fireEvent.click(button);
|
|
266
|
+
expect(cancel).toHaveBeenCalledOnce();
|
|
267
|
+
expect(
|
|
268
|
+
screen.queryByRole("textbox", { name: "Edit composer" }),
|
|
269
|
+
).not.toBeNull();
|
|
270
|
+
expect(document.activeElement).toBe(button);
|
|
271
|
+
} finally {
|
|
272
|
+
window.removeEventListener("error", onError);
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it.each(["input", "button"])(
|
|
277
|
+
"does not restore focus after pointer cancellation with focus on the %s",
|
|
278
|
+
(source) => {
|
|
279
|
+
const { input, cancel } = setup();
|
|
280
|
+
act(() => (source === "input" ? input : cancel).focus());
|
|
281
|
+
fireEvent.click(cancel, { detail: 1 });
|
|
282
|
+
expect(
|
|
283
|
+
screen.queryByRole("textbox", { name: "Edit composer" }),
|
|
284
|
+
).toBeNull();
|
|
285
|
+
expect(document.activeElement).toBe(document.body);
|
|
286
|
+
},
|
|
287
|
+
);
|
|
288
|
+
|
|
289
|
+
it("does not move focus when cancelling a running thread", () => {
|
|
290
|
+
const onCancel = vi.fn(async () => {});
|
|
291
|
+
render(
|
|
292
|
+
<Provider isRunning onCancel={onCancel}>
|
|
293
|
+
<ThreadPrimitiveRoot>
|
|
294
|
+
<ComposerPrimitiveRoot>
|
|
295
|
+
<textarea aria-label="Main composer" />
|
|
296
|
+
<ComposerPrimitiveCancel>Stop run</ComposerPrimitiveCancel>
|
|
297
|
+
</ComposerPrimitiveRoot>
|
|
298
|
+
</ThreadPrimitiveRoot>
|
|
299
|
+
</Provider>,
|
|
300
|
+
);
|
|
301
|
+
const cancel = screen.getByRole("button", { name: "Stop run" });
|
|
302
|
+
act(() => cancel.focus());
|
|
303
|
+
fireEvent.click(cancel);
|
|
304
|
+
expect(onCancel).toHaveBeenCalledOnce();
|
|
305
|
+
expect(document.activeElement).toBe(cancel);
|
|
306
|
+
});
|
|
307
|
+
});
|
|
@@ -5,12 +5,16 @@ import {
|
|
|
5
5
|
type ActionButtonProps,
|
|
6
6
|
createActionButton,
|
|
7
7
|
} from "../../utils/createActionButton";
|
|
8
|
+
import type { MouseEvent } from "react";
|
|
8
9
|
import { useComposerCancel as useComposerCancelBehavior } from "@assistant-ui/core/react";
|
|
10
|
+
import { useComposerCancelWithFocus } from "./useComposerCancelWithFocus";
|
|
9
11
|
|
|
10
12
|
const useComposerCancel = () => {
|
|
11
13
|
const { disabled, cancel } = useComposerCancelBehavior();
|
|
14
|
+
const cancelWithFocus = useComposerCancelWithFocus(cancel);
|
|
12
15
|
if (disabled) return null;
|
|
13
|
-
return
|
|
16
|
+
return (event: MouseEvent<HTMLButtonElement>) =>
|
|
17
|
+
cancelWithFocus(event.detail === 0 ? event.currentTarget : null);
|
|
14
18
|
};
|
|
15
19
|
|
|
16
20
|
export namespace ComposerPrimitiveCancel {
|
|
@@ -27,6 +31,9 @@ export namespace ComposerPrimitiveCancel {
|
|
|
27
31
|
*
|
|
28
32
|
* This component automatically handles the cancel functionality and is disabled
|
|
29
33
|
* when canceling is not available.
|
|
34
|
+
* Keyboard or assistive-technology activation after editing returns focus to the
|
|
35
|
+
* main thread composer when available, unless a handler has moved focus elsewhere.
|
|
36
|
+
* Pointer activation preserves the existing focus behavior.
|
|
30
37
|
*
|
|
31
38
|
* @example
|
|
32
39
|
* ```tsx
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/** @vitest-environment jsdom */
|
|
2
|
+
import {
|
|
3
|
+
act,
|
|
4
|
+
cleanup,
|
|
5
|
+
fireEvent,
|
|
6
|
+
render,
|
|
7
|
+
screen,
|
|
8
|
+
} from "@testing-library/react";
|
|
9
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
10
|
+
import {
|
|
11
|
+
AssistantRuntimeProvider,
|
|
12
|
+
useExternalStoreRuntime,
|
|
13
|
+
} from "@assistant-ui/core/react";
|
|
14
|
+
import type { AttachmentAdapter, ThreadMessage } from "@assistant-ui/core";
|
|
15
|
+
import { ComposerPrimitiveAddAttachment } from "./ComposerAddAttachment";
|
|
16
|
+
import { ComposerPrimitiveInput } from "./ComposerInput";
|
|
17
|
+
import { ComposerPrimitiveRoot } from "./ComposerRoot";
|
|
18
|
+
import { ComposerPrimitiveSend } from "./ComposerSend";
|
|
19
|
+
import { ThreadPrimitiveViewportProvider } from "../../context/providers/ThreadViewportProvider";
|
|
20
|
+
|
|
21
|
+
const makeAttachmentAdapter = () => {
|
|
22
|
+
const add = vi.fn(async ({ file }: { file: File }) => ({
|
|
23
|
+
id: file.name,
|
|
24
|
+
type: "file" as const,
|
|
25
|
+
name: file.name,
|
|
26
|
+
contentType: file.type,
|
|
27
|
+
file,
|
|
28
|
+
status: {
|
|
29
|
+
type: "requires-action" as const,
|
|
30
|
+
reason: "composer-send" as const,
|
|
31
|
+
},
|
|
32
|
+
}));
|
|
33
|
+
const adapter: AttachmentAdapter = {
|
|
34
|
+
accept: "*",
|
|
35
|
+
add,
|
|
36
|
+
remove: async () => {},
|
|
37
|
+
send: async (attachment) => ({
|
|
38
|
+
...attachment,
|
|
39
|
+
status: { type: "complete" },
|
|
40
|
+
content: [{ type: "text", text: "file body" }],
|
|
41
|
+
}),
|
|
42
|
+
};
|
|
43
|
+
return { adapter, add };
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const App = ({
|
|
47
|
+
isDisabled,
|
|
48
|
+
onNew,
|
|
49
|
+
attachments,
|
|
50
|
+
}: {
|
|
51
|
+
isDisabled: boolean;
|
|
52
|
+
onNew: () => Promise<void>;
|
|
53
|
+
attachments: AttachmentAdapter;
|
|
54
|
+
}) => {
|
|
55
|
+
const runtime = useExternalStoreRuntime<ThreadMessage>({
|
|
56
|
+
messages: [],
|
|
57
|
+
isDisabled,
|
|
58
|
+
onNew,
|
|
59
|
+
adapters: { attachments },
|
|
60
|
+
});
|
|
61
|
+
return (
|
|
62
|
+
<AssistantRuntimeProvider runtime={runtime}>
|
|
63
|
+
<ThreadPrimitiveViewportProvider>
|
|
64
|
+
<ComposerPrimitiveRoot>
|
|
65
|
+
<ComposerPrimitiveInput aria-label="input" />
|
|
66
|
+
<ComposerPrimitiveAddAttachment>
|
|
67
|
+
attach
|
|
68
|
+
</ComposerPrimitiveAddAttachment>
|
|
69
|
+
<ComposerPrimitiveSend>send</ComposerPrimitiveSend>
|
|
70
|
+
</ComposerPrimitiveRoot>
|
|
71
|
+
</ThreadPrimitiveViewportProvider>
|
|
72
|
+
</AssistantRuntimeProvider>
|
|
73
|
+
);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const button = (name: string) =>
|
|
77
|
+
screen.getByRole("button", { name }) as HTMLButtonElement;
|
|
78
|
+
|
|
79
|
+
const settle = () =>
|
|
80
|
+
act(async () => {
|
|
81
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
afterEach(() => {
|
|
85
|
+
cleanup();
|
|
86
|
+
document.body.querySelectorAll('input[type="file"]').forEach((input) => {
|
|
87
|
+
input.remove();
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe("composer on a disabled thread", () => {
|
|
92
|
+
it("blocks sending a draft that was entered before the thread was disabled", async () => {
|
|
93
|
+
const onNew = vi.fn(async () => {});
|
|
94
|
+
const { adapter } = makeAttachmentAdapter();
|
|
95
|
+
const view = render(
|
|
96
|
+
<App isDisabled={false} onNew={onNew} attachments={adapter} />,
|
|
97
|
+
);
|
|
98
|
+
fireEvent.change(screen.getByRole("textbox", { name: "input" }), {
|
|
99
|
+
target: { value: "hello" },
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
view.rerender(<App isDisabled onNew={onNew} attachments={adapter} />);
|
|
103
|
+
await settle();
|
|
104
|
+
|
|
105
|
+
expect(button("send").disabled).toBe(true);
|
|
106
|
+
fireEvent.click(button("send"));
|
|
107
|
+
await settle();
|
|
108
|
+
expect(onNew).not.toHaveBeenCalled();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("disables attachment selection", () => {
|
|
112
|
+
const onNew = vi.fn(async () => {});
|
|
113
|
+
const { adapter, add } = makeAttachmentAdapter();
|
|
114
|
+
render(<App isDisabled onNew={onNew} attachments={adapter} />);
|
|
115
|
+
|
|
116
|
+
expect(button("attach").disabled).toBe(true);
|
|
117
|
+
fireEvent.click(button("attach"));
|
|
118
|
+
expect(document.body.querySelector('input[type="file"]')).toBeNull();
|
|
119
|
+
expect(add).not.toHaveBeenCalled();
|
|
120
|
+
});
|
|
121
|
+
});
|
|
@@ -27,6 +27,7 @@ import { useAui } from "@assistant-ui/store";
|
|
|
27
27
|
import { flushTapSync } from "@assistant-ui/tap";
|
|
28
28
|
import { useComposerInputPluginRegistryOptional } from "./ComposerInputPluginContext";
|
|
29
29
|
import { useComposerCompactContextOptional } from "./ComposerCompactContext";
|
|
30
|
+
import { useComposerCancelWithFocus } from "./useComposerCancelWithFocus";
|
|
30
31
|
import {
|
|
31
32
|
useComposerInputDisabled,
|
|
32
33
|
useComposerInputValue,
|
|
@@ -50,21 +51,32 @@ export namespace ComposerPrimitiveInput {
|
|
|
50
51
|
render?: ReactElement | undefined;
|
|
51
52
|
/**
|
|
52
53
|
* Whether to cancel message composition when Escape is pressed.
|
|
54
|
+
* After cancelling an edit, focus returns to the main thread composer when
|
|
55
|
+
* available, unless a handler has moved focus elsewhere.
|
|
53
56
|
* @default true
|
|
54
57
|
*/
|
|
55
58
|
cancelOnEscape?: boolean | undefined;
|
|
56
59
|
/**
|
|
57
60
|
* Whether to automatically focus the input when a new run starts.
|
|
61
|
+
*
|
|
62
|
+
* @deprecated Experimental since 2024-10-12. Not scheduled for removal; the API may change in any release.
|
|
63
|
+
*
|
|
58
64
|
* @default true
|
|
59
65
|
*/
|
|
60
66
|
unstable_focusOnRunStart?: boolean | undefined;
|
|
61
67
|
/**
|
|
62
68
|
* Whether to automatically focus the input when scrolling to bottom.
|
|
69
|
+
*
|
|
70
|
+
* @deprecated Experimental since 2024-10-12. Not scheduled for removal; the API may change in any release.
|
|
71
|
+
*
|
|
63
72
|
* @default true
|
|
64
73
|
*/
|
|
65
74
|
unstable_focusOnScrollToBottom?: boolean | undefined;
|
|
66
75
|
/**
|
|
67
76
|
* Whether to automatically focus the input when switching threads.
|
|
77
|
+
*
|
|
78
|
+
* @deprecated Experimental since 2024-10-12. Not scheduled for removal; the API may change in any release.
|
|
79
|
+
*
|
|
68
80
|
* @default true
|
|
69
81
|
*/
|
|
70
82
|
unstable_focusOnThreadSwitched?: boolean | undefined;
|
|
@@ -73,6 +85,9 @@ export namespace ComposerPrimitiveInput {
|
|
|
73
85
|
* instead of submitting, detected via
|
|
74
86
|
* `(pointer: coarse) and (not (any-pointer: fine))`. Only takes effect
|
|
75
87
|
* when `submitMode` resolves to `"enter"`.
|
|
88
|
+
*
|
|
89
|
+
* @deprecated Experimental since 2026-05-24. Not scheduled for removal; the API may change in any release.
|
|
90
|
+
*
|
|
76
91
|
* @default false
|
|
77
92
|
*/
|
|
78
93
|
unstable_insertNewlineOnTouchEnter?: boolean | undefined;
|
|
@@ -192,6 +207,9 @@ export const ComposerPrimitiveInput = forwardRef<
|
|
|
192
207
|
const value = useComposerInputValue();
|
|
193
208
|
const isDisabled = useComposerInputDisabled(disabledProp);
|
|
194
209
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
210
|
+
const cancel = useComposerCancelWithFocus(
|
|
211
|
+
useCallback(() => aui.composer.cancel(), [aui]),
|
|
212
|
+
);
|
|
195
213
|
const ref = useComposedRefs(forwardedRef, textareaRef);
|
|
196
214
|
// suppress text/cursor broadcasts during IME composition
|
|
197
215
|
const compositionRef = useRef(false);
|
|
@@ -214,7 +232,7 @@ export const ComposerPrimitiveInput = forwardRef<
|
|
|
214
232
|
|
|
215
233
|
const composer = aui.composer;
|
|
216
234
|
if (composer.getState().canCancel) {
|
|
217
|
-
|
|
235
|
+
cancel(textareaRef.current);
|
|
218
236
|
e.preventDefault();
|
|
219
237
|
}
|
|
220
238
|
});
|
|
@@ -14,11 +14,10 @@ import {
|
|
|
14
14
|
} from "react";
|
|
15
15
|
import { useComposerSend } from "./ComposerSend";
|
|
16
16
|
import { ComposerCompactContext } from "./ComposerCompactContext";
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
const COMPOSER_INPUT_SELECTOR = `textarea:not(:disabled), ${CONTENT_EDITABLE_SELECTOR}`;
|
|
17
|
+
import {
|
|
18
|
+
COMPOSER_INPUT_SELECTOR,
|
|
19
|
+
CONTENT_EDITABLE_SELECTOR,
|
|
20
|
+
} from "./composerInputSelector";
|
|
22
21
|
|
|
23
22
|
// Keeps tabindex="-1": roving-tabindex widgets click-focus items at -1.
|
|
24
23
|
const INTERACTIVE_ELEMENT_SELECTOR = [
|
|
@@ -82,6 +81,7 @@ export const ComposerPrimitiveRoot = forwardRef<
|
|
|
82
81
|
ComposerPrimitiveRoot.Props
|
|
83
82
|
>(({ onSubmit, onMouseDown, compact, ...rest }, forwardedRef) => {
|
|
84
83
|
const send = useComposerSend();
|
|
84
|
+
const composerType = useAuiState((s) => s.composer.type);
|
|
85
85
|
|
|
86
86
|
const [multiline, setMultiline] = useState(false);
|
|
87
87
|
const compactContext = useMemo(() => ({ setMultiline }), []);
|
|
@@ -126,6 +126,7 @@ export const ComposerPrimitiveRoot = forwardRef<
|
|
|
126
126
|
<ComposerCompactContext.Provider value={compact ? compactContext : null}>
|
|
127
127
|
<Primitive.form
|
|
128
128
|
{...rest}
|
|
129
|
+
data-aui-composer-type={composerType}
|
|
129
130
|
data-compact={isCompact ? "" : undefined}
|
|
130
131
|
ref={forwardedRef}
|
|
131
132
|
onSubmit={composeEventHandlers(onSubmit, handleSubmit)}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback } from "react";
|
|
4
|
+
import { useAui } from "@assistant-ui/store";
|
|
5
|
+
import { useThreadRootElementRef } from "../thread/ThreadRootElementContext";
|
|
6
|
+
import { COMPOSER_INPUT_SELECTOR } from "./composerInputSelector";
|
|
7
|
+
|
|
8
|
+
const COMPOSER_SELECTOR = "[data-aui-composer-type]";
|
|
9
|
+
const THREAD_SELECTOR = "[data-aui-thread-root]";
|
|
10
|
+
|
|
11
|
+
export const useComposerCancelWithFocus = (cancel: () => void) => {
|
|
12
|
+
const aui = useAui();
|
|
13
|
+
const threadRootRef = useThreadRootElementRef();
|
|
14
|
+
|
|
15
|
+
const cancelWithFocus = useCallback(
|
|
16
|
+
(source: HTMLElement | null) => {
|
|
17
|
+
const root = threadRootRef?.current;
|
|
18
|
+
const doc = source?.ownerDocument;
|
|
19
|
+
const active = doc?.activeElement;
|
|
20
|
+
const composer = source?.closest(COMPOSER_SELECTOR);
|
|
21
|
+
const shouldRestore =
|
|
22
|
+
aui.composer.getState().type === "edit" &&
|
|
23
|
+
root &&
|
|
24
|
+
active &&
|
|
25
|
+
composer?.getAttribute("data-aui-composer-type") === "edit" &&
|
|
26
|
+
composer.contains(active) &&
|
|
27
|
+
composer.closest(THREAD_SELECTOR) === root;
|
|
28
|
+
const candidates = shouldRestore
|
|
29
|
+
? Array.from(
|
|
30
|
+
root.querySelectorAll<HTMLElement>(COMPOSER_INPUT_SELECTOR),
|
|
31
|
+
).filter(
|
|
32
|
+
(input) =>
|
|
33
|
+
input.closest(THREAD_SELECTOR) === root &&
|
|
34
|
+
input
|
|
35
|
+
.closest(COMPOSER_SELECTOR)
|
|
36
|
+
?.getAttribute("data-aui-composer-type") === "thread",
|
|
37
|
+
)
|
|
38
|
+
: [];
|
|
39
|
+
|
|
40
|
+
cancel();
|
|
41
|
+
|
|
42
|
+
if (!doc || !active || !shouldRestore) return;
|
|
43
|
+
if (doc.activeElement !== active && doc.activeElement !== doc.body)
|
|
44
|
+
return;
|
|
45
|
+
|
|
46
|
+
for (const input of candidates) {
|
|
47
|
+
if (!input.isConnected || input.closest("[hidden], [inert]")) continue;
|
|
48
|
+
input.focus({ preventScroll: true });
|
|
49
|
+
if (doc.activeElement !== active && doc.activeElement !== doc.body)
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
[aui, cancel, threadRootRef],
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
return cancelWithFocus;
|
|
57
|
+
};
|