@assistant-ui/react-native 0.1.26 → 0.1.28

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.
Files changed (39) hide show
  1. package/dist/index.d.ts +3 -3
  2. package/dist/index.js +3 -3
  3. package/dist/primitives/composer/ComposerInput.d.ts.map +1 -1
  4. package/dist/primitives/composer/ComposerInput.js +8 -0
  5. package/dist/primitives/composer/ComposerInput.js.map +1 -1
  6. package/dist/primitives/thread.d.ts +3 -3
  7. package/dist/primitives/thread.js +4 -3
  8. package/dist/primitives/thread.js.map +1 -1
  9. package/package.json +11 -7
  10. package/src/index.ts +29 -0
  11. package/src/primitives/attachment/AttachmentName.test.tsx +55 -0
  12. package/src/primitives/attachment/AttachmentRemove.test.tsx +81 -0
  13. package/src/primitives/attachment/AttachmentThumb.test.tsx +75 -0
  14. package/src/primitives/branchPicker/BranchPickerCount.test.tsx +55 -0
  15. package/src/primitives/branchPicker/BranchPickerNext.test.tsx +97 -0
  16. package/src/primitives/branchPicker/BranchPickerNumber.test.tsx +56 -0
  17. package/src/primitives/branchPicker/BranchPickerPrevious.test.tsx +100 -0
  18. package/src/primitives/chainOfThought/ChainOfThoughtAccordionTrigger.test.tsx +85 -0
  19. package/src/primitives/composer/ComposerAddAttachment.test.tsx +136 -0
  20. package/src/primitives/composer/ComposerCancel.test.tsx +116 -0
  21. package/src/primitives/composer/ComposerInput.test.tsx +253 -0
  22. package/src/primitives/composer/ComposerInput.tsx +12 -0
  23. package/src/primitives/composer/ComposerSend.test.tsx +116 -0
  24. package/src/primitives/error/ErrorMessage.test.tsx +64 -0
  25. package/src/primitives/error/ErrorRoot.test.tsx +65 -0
  26. package/src/primitives/message/MessageContent.test.tsx +278 -0
  27. package/src/primitives/message/MessageIf.test.tsx +156 -0
  28. package/src/primitives/suggestion/SuggestionDescription.test.tsx +61 -0
  29. package/src/primitives/suggestion/SuggestionTitle.test.tsx +61 -0
  30. package/src/primitives/suggestion/SuggestionTrigger.test.tsx +144 -0
  31. package/src/primitives/thread/ThreadEmpty.test.tsx +56 -0
  32. package/src/primitives/thread/ThreadIf.test.tsx +121 -0
  33. package/src/primitives/thread/ThreadMessages.test.tsx +192 -0
  34. package/src/primitives/thread.ts +1 -0
  35. package/src/primitives/threadList/ThreadListNew.test.tsx +72 -0
  36. package/src/primitives/threadListItem/ThreadListItemArchive.test.tsx +72 -0
  37. package/src/primitives/threadListItem/ThreadListItemDelete.test.tsx +72 -0
  38. package/src/primitives/threadListItem/ThreadListItemTrigger.test.tsx +72 -0
  39. package/src/primitives/threadListItem/ThreadListItemUnarchive.test.tsx +72 -0
@@ -0,0 +1,278 @@
1
+ import { act, type ReactElement } from "react";
2
+ import { createRoot, type Root } from "react-dom/client";
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
+ import { MessageContent } from "./MessageContent";
5
+
6
+ type AnyPart = { type: string; [key: string]: unknown };
7
+
8
+ const h = vi.hoisted(() => ({
9
+ addToolResult: vi.fn(),
10
+ resumeToolCall: vi.fn(),
11
+ respondToToolApproval: vi.fn(),
12
+ state: {
13
+ message: { content: [] as AnyPart[] },
14
+ tools: { tools: {} as Record<string, unknown> },
15
+ dataRenderers: { renderers: {} as Record<string, unknown> },
16
+ },
17
+ }));
18
+
19
+ vi.mock("@assistant-ui/store", () => {
20
+ const aui = {
21
+ message: () => ({
22
+ part: ({ index }: { index: number }) => ({
23
+ addToolResult: (...args: unknown[]) => h.addToolResult(index, ...args),
24
+ resumeToolCall: (...args: unknown[]) =>
25
+ h.resumeToolCall(index, ...args),
26
+ respondToToolApproval: (...args: unknown[]) =>
27
+ h.respondToToolApproval(index, ...args),
28
+ }),
29
+ }),
30
+ };
31
+ return {
32
+ useAui: () => aui,
33
+ useAuiState: <T,>(selector: (s: typeof h.state) => T) => selector(h.state),
34
+ };
35
+ });
36
+
37
+ (globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
38
+
39
+ describe("MessageContent", () => {
40
+ let container: HTMLDivElement;
41
+ let root: Root;
42
+
43
+ beforeEach(() => {
44
+ h.addToolResult.mockReset();
45
+ h.resumeToolCall.mockReset();
46
+ h.respondToToolApproval.mockReset();
47
+ h.state.message.content = [];
48
+ h.state.tools.tools = {};
49
+ h.state.dataRenderers.renderers = {};
50
+
51
+ container = document.createElement("div");
52
+ document.body.appendChild(container);
53
+ root = createRoot(container);
54
+ });
55
+
56
+ afterEach(async () => {
57
+ await act(async () => {
58
+ root.unmount();
59
+ });
60
+ container.remove();
61
+ });
62
+
63
+ const mount = async (
64
+ props: Partial<Parameters<typeof MessageContent>[0]> = {},
65
+ ) => {
66
+ await act(async () => {
67
+ root.render(<MessageContent {...props} />);
68
+ });
69
+ };
70
+
71
+ it("renders a text part through the default text renderer", async () => {
72
+ h.state.message.content = [{ type: "text", text: "hello world" }];
73
+ await mount();
74
+ expect(container.textContent).toContain("hello world");
75
+ });
76
+
77
+ it("prefers a provided renderText over the default renderer", async () => {
78
+ h.state.message.content = [{ type: "text", text: "raw" }];
79
+ const renderText = vi.fn(
80
+ ({ part, index }): ReactElement => (
81
+ <span data-testid={`text-${index}`}>custom:{part.text}</span>
82
+ ),
83
+ );
84
+ await mount({ renderText });
85
+
86
+ expect(renderText).toHaveBeenCalledTimes(1);
87
+ expect(renderText).toHaveBeenCalledWith({
88
+ part: { type: "text", text: "raw" },
89
+ index: 0,
90
+ });
91
+ const el = container.querySelector('[data-testid="text-0"]');
92
+ expect(el?.textContent).toBe("custom:raw");
93
+ expect(container.innerHTML).not.toContain(">raw<");
94
+ });
95
+
96
+ it("renders nothing for an unknown part type", async () => {
97
+ h.state.message.content = [{ type: "mystery" }];
98
+ await mount();
99
+ expect(container.textContent).toBe("");
100
+ });
101
+
102
+ it("renders null for optional parts when no renderer is provided", async () => {
103
+ h.state.message.content = [
104
+ { type: "image", image: "x" },
105
+ { type: "reasoning", text: "r" },
106
+ { type: "source", sourceType: "url", id: "1", url: "u" },
107
+ { type: "file", filename: "f" },
108
+ ];
109
+ await mount();
110
+ expect(container.textContent).toBe("");
111
+ });
112
+
113
+ it("renders optional parts via their provided renderers", async () => {
114
+ h.state.message.content = [
115
+ { type: "image", image: "x" },
116
+ { type: "reasoning", text: "r" },
117
+ { type: "source", sourceType: "url", id: "1", url: "u" },
118
+ { type: "file", filename: "f" },
119
+ ];
120
+ const renderImage = vi.fn(
121
+ ({ index }): ReactElement => <span>image-{index}</span>,
122
+ );
123
+ const renderReasoning = vi.fn(
124
+ ({ index }): ReactElement => <span>reasoning-{index}</span>,
125
+ );
126
+ const renderSource = vi.fn(
127
+ ({ index }): ReactElement => <span>source-{index}</span>,
128
+ );
129
+ const renderFile = vi.fn(
130
+ ({ index }): ReactElement => <span>file-{index}</span>,
131
+ );
132
+ await mount({ renderImage, renderReasoning, renderSource, renderFile });
133
+
134
+ expect(container.textContent).toBe("image-0reasoning-1source-2file-3");
135
+ expect(renderImage).toHaveBeenCalledWith({
136
+ part: h.state.message.content[0],
137
+ index: 0,
138
+ });
139
+ expect(renderFile).toHaveBeenCalledWith({
140
+ part: h.state.message.content[3],
141
+ index: 3,
142
+ });
143
+ });
144
+
145
+ describe("tool-call parts", () => {
146
+ it("renders a registered tool renderer with part methods wired in", async () => {
147
+ h.state.message.content = [
148
+ { type: "tool-call", toolName: "search", toolCallId: "c1" },
149
+ ];
150
+ const ToolRender = vi.fn((props: Record<string, unknown>) => {
151
+ (props.addResult as () => void)();
152
+ (props.resume as () => void)();
153
+ (props.respondToApproval as () => void)();
154
+ return <span data-testid="tool">tool:{String(props.toolName)}</span>;
155
+ });
156
+ h.state.tools.tools = { search: ToolRender };
157
+
158
+ await mount();
159
+
160
+ const el = container.querySelector('[data-testid="tool"]');
161
+ expect(el?.textContent).toBe("tool:search");
162
+ expect(h.addToolResult).toHaveBeenCalledWith(0);
163
+ expect(h.resumeToolCall).toHaveBeenCalledWith(0);
164
+ expect(h.respondToToolApproval).toHaveBeenCalledWith(0);
165
+ });
166
+
167
+ it("picks the first renderer when the registry holds an array", async () => {
168
+ h.state.message.content = [
169
+ { type: "tool-call", toolName: "search", toolCallId: "c1" },
170
+ ];
171
+ const First = vi.fn(() => <span data-testid="first">first</span>);
172
+ const Second = vi.fn(() => <span>second</span>);
173
+ h.state.tools.tools = { search: [First, Second] };
174
+
175
+ await mount();
176
+
177
+ expect(container.querySelector('[data-testid="first"]')).not.toBeNull();
178
+ expect(First).toHaveBeenCalledTimes(1);
179
+ expect(Second).not.toHaveBeenCalled();
180
+ });
181
+
182
+ it("falls back to renderToolCall when no renderer is registered", async () => {
183
+ h.state.message.content = [
184
+ { type: "tool-call", toolName: "search", toolCallId: "c1" },
185
+ ];
186
+ const renderToolCall = vi.fn(
187
+ ({ part, index }): ReactElement => (
188
+ <span data-testid="fallback">
189
+ fallback:{String(part.toolName)}:{index}
190
+ </span>
191
+ ),
192
+ );
193
+ await mount({ renderToolCall });
194
+
195
+ const el = container.querySelector('[data-testid="fallback"]');
196
+ expect(el?.textContent).toBe("fallback:search:0");
197
+ expect(renderToolCall.mock.calls[0]![0]).toEqual({
198
+ part: h.state.message.content[0],
199
+ index: 0,
200
+ });
201
+ });
202
+
203
+ it("renders null when no renderer is registered and no fallback is given", async () => {
204
+ h.state.message.content = [
205
+ { type: "tool-call", toolName: "search", toolCallId: "c1" },
206
+ ];
207
+ await mount();
208
+ expect(container.textContent).toBe("");
209
+ });
210
+ });
211
+
212
+ describe("data parts", () => {
213
+ it("renders a registered data renderer", async () => {
214
+ h.state.message.content = [
215
+ { type: "data", name: "chart", data: { a: 1 } },
216
+ ];
217
+ const DataRender = vi.fn((props: Record<string, unknown>) => (
218
+ <span data-testid="data">data:{String(props.name)}</span>
219
+ ));
220
+ h.state.dataRenderers.renderers = { chart: DataRender };
221
+
222
+ await mount();
223
+
224
+ const el = container.querySelector('[data-testid="data"]');
225
+ expect(el?.textContent).toBe("data:chart");
226
+ });
227
+
228
+ it("picks the first data renderer when the registry holds an array", async () => {
229
+ h.state.message.content = [{ type: "data", name: "chart", data: {} }];
230
+ const First = vi.fn(() => <span data-testid="dfirst">first</span>);
231
+ const Second = vi.fn(() => <span>second</span>);
232
+ h.state.dataRenderers.renderers = { chart: [First, Second] };
233
+
234
+ await mount();
235
+
236
+ expect(container.querySelector('[data-testid="dfirst"]')).not.toBeNull();
237
+ expect(Second).not.toHaveBeenCalled();
238
+ });
239
+
240
+ it("falls back to renderData when no renderer is registered", async () => {
241
+ h.state.message.content = [{ type: "data", name: "chart", data: {} }];
242
+ const renderData = vi.fn(
243
+ ({ part, index }): ReactElement => (
244
+ <span data-testid="dfallback">
245
+ fallback:{String(part.name)}:{index}
246
+ </span>
247
+ ),
248
+ );
249
+ await mount({ renderData });
250
+
251
+ const el = container.querySelector('[data-testid="dfallback"]');
252
+ expect(el?.textContent).toBe("fallback:chart:0");
253
+ });
254
+
255
+ it("renders null when no data renderer is registered and no fallback is given", async () => {
256
+ h.state.message.content = [{ type: "data", name: "chart", data: {} }];
257
+ await mount();
258
+ expect(container.textContent).toBe("");
259
+ });
260
+ });
261
+
262
+ it("dispatches a mixed content array in order", async () => {
263
+ h.state.message.content = [
264
+ { type: "text", text: "A" },
265
+ { type: "tool-call", toolName: "t", toolCallId: "c" },
266
+ { type: "data", name: "d", data: {} },
267
+ ];
268
+ h.state.tools.tools = {
269
+ t: () => <span>[tool]</span>,
270
+ };
271
+ h.state.dataRenderers.renderers = {
272
+ d: () => <span>[data]</span>,
273
+ };
274
+ await mount();
275
+
276
+ expect(container.textContent).toBe("A[tool][data]");
277
+ });
278
+ });
@@ -0,0 +1,156 @@
1
+ import { act } from "react";
2
+ import { createRoot, type Root } from "react-dom/client";
3
+ import { Text } from "react-native";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import { MessageIf } from "./MessageIf";
6
+
7
+ const h = vi.hoisted(() => ({
8
+ message: {
9
+ role: "assistant" as "user" | "assistant" | "system",
10
+ status: { type: "complete" as "running" | "complete" | "incomplete" },
11
+ isLast: false,
12
+ },
13
+ }));
14
+
15
+ vi.mock("@assistant-ui/store", () => ({
16
+ useAuiState: <T,>(selector: (s: { message: typeof h.message }) => T) =>
17
+ selector({ message: h.message }),
18
+ }));
19
+
20
+ (globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
21
+
22
+ describe("MessageIf", () => {
23
+ let container: HTMLDivElement;
24
+ let root: Root;
25
+
26
+ beforeEach(() => {
27
+ h.message.role = "assistant";
28
+ h.message.status = { type: "complete" };
29
+ h.message.isLast = false;
30
+
31
+ container = document.createElement("div");
32
+ document.body.appendChild(container);
33
+ root = createRoot(container);
34
+ });
35
+
36
+ afterEach(async () => {
37
+ await act(async () => {
38
+ root.unmount();
39
+ });
40
+ container.remove();
41
+ });
42
+
43
+ const mount = async (
44
+ props: Partial<Parameters<typeof MessageIf>[0]> = {},
45
+ ) => {
46
+ await act(async () => {
47
+ root.render(
48
+ <MessageIf {...props}>
49
+ <Text testID="child">visible</Text>
50
+ </MessageIf>,
51
+ );
52
+ });
53
+ return container.querySelector('[data-testid="child"]');
54
+ };
55
+
56
+ it("renders children when no guard is set", async () => {
57
+ expect(await mount()).not.toBeNull();
58
+ });
59
+
60
+ describe("user guard", () => {
61
+ it("renders children when user:true matches a user message", async () => {
62
+ h.message.role = "user";
63
+ expect(await mount({ user: true })).not.toBeNull();
64
+ });
65
+
66
+ it("hides children when user:true but the message is from the assistant", async () => {
67
+ h.message.role = "assistant";
68
+ expect(await mount({ user: true })).toBeNull();
69
+ });
70
+
71
+ it("renders children when user:false matches a non-user message", async () => {
72
+ h.message.role = "assistant";
73
+ expect(await mount({ user: false })).not.toBeNull();
74
+ });
75
+
76
+ it("hides children when user:false but the message is from the user", async () => {
77
+ h.message.role = "user";
78
+ expect(await mount({ user: false })).toBeNull();
79
+ });
80
+ });
81
+
82
+ describe("assistant guard", () => {
83
+ it("renders children when assistant:true matches an assistant message", async () => {
84
+ h.message.role = "assistant";
85
+ expect(await mount({ assistant: true })).not.toBeNull();
86
+ });
87
+
88
+ it("hides children when assistant:true but the message is from the user", async () => {
89
+ h.message.role = "user";
90
+ expect(await mount({ assistant: true })).toBeNull();
91
+ });
92
+
93
+ it("renders children when assistant:false matches a non-assistant message", async () => {
94
+ h.message.role = "user";
95
+ expect(await mount({ assistant: false })).not.toBeNull();
96
+ });
97
+ });
98
+
99
+ describe("running guard", () => {
100
+ it("renders children when an assistant message is running", async () => {
101
+ h.message.role = "assistant";
102
+ h.message.status = { type: "running" };
103
+ expect(await mount({ running: true })).not.toBeNull();
104
+ });
105
+
106
+ it("hides children when running:true but the assistant message is complete", async () => {
107
+ h.message.role = "assistant";
108
+ h.message.status = { type: "complete" };
109
+ expect(await mount({ running: true })).toBeNull();
110
+ });
111
+
112
+ it("treats a running user message as not running", async () => {
113
+ h.message.role = "user";
114
+ h.message.status = { type: "running" };
115
+ expect(await mount({ running: true })).toBeNull();
116
+ expect(await mount({ running: false })).not.toBeNull();
117
+ });
118
+ });
119
+
120
+ describe("last guard", () => {
121
+ it("renders children when last:true matches the last message", async () => {
122
+ h.message.isLast = true;
123
+ expect(await mount({ last: true })).not.toBeNull();
124
+ });
125
+
126
+ it("hides children when last:true but the message is not last", async () => {
127
+ h.message.isLast = false;
128
+ expect(await mount({ last: true })).toBeNull();
129
+ });
130
+
131
+ it("renders children when last:false matches a non-last message", async () => {
132
+ h.message.isLast = false;
133
+ expect(await mount({ last: false })).not.toBeNull();
134
+ });
135
+ });
136
+
137
+ describe("combined guards", () => {
138
+ it("renders children only when assistant and last both match", async () => {
139
+ h.message.role = "assistant";
140
+ h.message.isLast = true;
141
+ expect(await mount({ assistant: true, last: true })).not.toBeNull();
142
+ });
143
+
144
+ it("hides children when assistant matches but last does not", async () => {
145
+ h.message.role = "assistant";
146
+ h.message.isLast = false;
147
+ expect(await mount({ assistant: true, last: true })).toBeNull();
148
+ });
149
+
150
+ it("hides children when last matches but assistant does not", async () => {
151
+ h.message.role = "user";
152
+ h.message.isLast = true;
153
+ expect(await mount({ assistant: true, last: true })).toBeNull();
154
+ });
155
+ });
156
+ });
@@ -0,0 +1,61 @@
1
+ import { act } from "react";
2
+ import { createRoot, type Root } from "react-dom/client";
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
+ import { SuggestionDescription } from "./SuggestionDescription";
5
+
6
+ const h = vi.hoisted(() => ({
7
+ suggestion: { label: "" as string | undefined },
8
+ }));
9
+
10
+ vi.mock("@assistant-ui/store", () => ({
11
+ useAuiState: <T,>(selector: (s: { suggestion: typeof h.suggestion }) => T) =>
12
+ selector({ suggestion: h.suggestion }),
13
+ }));
14
+
15
+ (globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
16
+
17
+ describe("SuggestionDescription", () => {
18
+ let container: HTMLDivElement;
19
+ let root: Root;
20
+
21
+ beforeEach(() => {
22
+ h.suggestion.label = "";
23
+ container = document.createElement("div");
24
+ document.body.appendChild(container);
25
+ root = createRoot(container);
26
+ });
27
+
28
+ afterEach(async () => {
29
+ await act(async () => {
30
+ root.unmount();
31
+ });
32
+ container.remove();
33
+ });
34
+
35
+ const mount = async (
36
+ props: Partial<Parameters<typeof SuggestionDescription>[0]> = {},
37
+ ) => {
38
+ await act(async () => {
39
+ root.render(<SuggestionDescription testID="t" {...props} />);
40
+ });
41
+ return container.querySelector('[data-testid="t"]') as HTMLElement;
42
+ };
43
+
44
+ it("renders the store suggestion label", async () => {
45
+ h.suggestion.label = "Generate a quick summary";
46
+ const el = await mount();
47
+ expect(el.textContent).toBe("Generate a quick summary");
48
+ });
49
+
50
+ it("renders explicit children over the store label", async () => {
51
+ h.suggestion.label = "Generate a quick summary";
52
+ const el = await mount({ children: "Custom label" });
53
+ expect(el.textContent).toBe("Custom label");
54
+ });
55
+
56
+ it("falls back to the store label when children is undefined", async () => {
57
+ h.suggestion.label = "Store label";
58
+ const el = await mount({ children: undefined });
59
+ expect(el.textContent).toBe("Store label");
60
+ });
61
+ });
@@ -0,0 +1,61 @@
1
+ import { act } from "react";
2
+ import { createRoot, type Root } from "react-dom/client";
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
+ import { SuggestionTitle } from "./SuggestionTitle";
5
+
6
+ const h = vi.hoisted(() => ({
7
+ suggestion: { title: "" as string | undefined },
8
+ }));
9
+
10
+ vi.mock("@assistant-ui/store", () => ({
11
+ useAuiState: <T,>(selector: (s: { suggestion: typeof h.suggestion }) => T) =>
12
+ selector({ suggestion: h.suggestion }),
13
+ }));
14
+
15
+ (globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
16
+
17
+ describe("SuggestionTitle", () => {
18
+ let container: HTMLDivElement;
19
+ let root: Root;
20
+
21
+ beforeEach(() => {
22
+ h.suggestion.title = "";
23
+ container = document.createElement("div");
24
+ document.body.appendChild(container);
25
+ root = createRoot(container);
26
+ });
27
+
28
+ afterEach(async () => {
29
+ await act(async () => {
30
+ root.unmount();
31
+ });
32
+ container.remove();
33
+ });
34
+
35
+ const mount = async (
36
+ props: Partial<Parameters<typeof SuggestionTitle>[0]> = {},
37
+ ) => {
38
+ await act(async () => {
39
+ root.render(<SuggestionTitle testID="t" {...props} />);
40
+ });
41
+ return container.querySelector('[data-testid="t"]') as HTMLElement;
42
+ };
43
+
44
+ it("renders the store suggestion title", async () => {
45
+ h.suggestion.title = "Summarize this";
46
+ const el = await mount();
47
+ expect(el.textContent).toBe("Summarize this");
48
+ });
49
+
50
+ it("renders explicit children over the store title", async () => {
51
+ h.suggestion.title = "Summarize this";
52
+ const el = await mount({ children: "Custom title" });
53
+ expect(el.textContent).toBe("Custom title");
54
+ });
55
+
56
+ it("falls back to the store title when children is undefined", async () => {
57
+ h.suggestion.title = "Store title";
58
+ const el = await mount({ children: undefined });
59
+ expect(el.textContent).toBe("Store title");
60
+ });
61
+ });
@@ -0,0 +1,144 @@
1
+ import { act } from "react";
2
+ import { createRoot, type Root } from "react-dom/client";
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
+ import { SuggestionTrigger } from "./SuggestionTrigger";
5
+
6
+ const h = vi.hoisted(() => ({
7
+ trigger: vi.fn<() => void>(),
8
+ useSuggestionTrigger: vi.fn<
9
+ (arg: {
10
+ prompt: string | undefined;
11
+ send?: boolean;
12
+ clearComposer?: boolean;
13
+ }) => {
14
+ trigger: () => void;
15
+ disabled: boolean;
16
+ }
17
+ >(),
18
+ suggestion: { prompt: "" as string | undefined },
19
+ }));
20
+
21
+ vi.mock("@assistant-ui/store", () => ({
22
+ useAuiState: <T,>(selector: (s: { suggestion: typeof h.suggestion }) => T) =>
23
+ selector({ suggestion: h.suggestion }),
24
+ }));
25
+
26
+ vi.mock("@assistant-ui/core/react", () => ({
27
+ useSuggestionTrigger: (arg: Parameters<typeof h.useSuggestionTrigger>[0]) =>
28
+ h.useSuggestionTrigger(arg),
29
+ }));
30
+
31
+ (globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
32
+
33
+ describe("SuggestionTrigger", () => {
34
+ let container: HTMLDivElement;
35
+ let root: Root;
36
+
37
+ beforeEach(() => {
38
+ h.trigger.mockReset();
39
+ h.suggestion.prompt = "Tell me a joke";
40
+ h.useSuggestionTrigger.mockReset();
41
+ h.useSuggestionTrigger.mockReturnValue({
42
+ trigger: h.trigger,
43
+ disabled: false,
44
+ });
45
+
46
+ container = document.createElement("div");
47
+ document.body.appendChild(container);
48
+ root = createRoot(container);
49
+ });
50
+
51
+ afterEach(async () => {
52
+ await act(async () => {
53
+ root.unmount();
54
+ });
55
+ container.remove();
56
+ });
57
+
58
+ const mount = async (
59
+ props: Partial<Parameters<typeof SuggestionTrigger>[0]> = {},
60
+ ) => {
61
+ await act(async () => {
62
+ root.render(
63
+ <SuggestionTrigger testID="t" {...props}>
64
+ {props.children ?? "Press me"}
65
+ </SuggestionTrigger>,
66
+ );
67
+ });
68
+ return container.querySelector('[data-testid="t"]') as HTMLElement;
69
+ };
70
+
71
+ const press = async (el: HTMLElement) => {
72
+ await act(async () => {
73
+ el.dispatchEvent(
74
+ new MouseEvent("click", { bubbles: true, cancelable: true }),
75
+ );
76
+ });
77
+ };
78
+
79
+ it("passes the store prompt and trigger options to useSuggestionTrigger", async () => {
80
+ h.suggestion.prompt = "Write a poem";
81
+ await mount({ send: true, clearComposer: false });
82
+
83
+ expect(h.useSuggestionTrigger).toHaveBeenCalledWith({
84
+ prompt: "Write a poem",
85
+ send: true,
86
+ clearComposer: false,
87
+ });
88
+ });
89
+
90
+ it("defaults clearComposer to true and leaves send undefined", async () => {
91
+ await mount();
92
+
93
+ expect(h.useSuggestionTrigger).toHaveBeenCalledWith({
94
+ prompt: "Tell me a joke",
95
+ send: undefined,
96
+ clearComposer: true,
97
+ });
98
+ });
99
+
100
+ it("fires the trigger when pressed", async () => {
101
+ const el = await mount();
102
+ await press(el);
103
+
104
+ expect(h.trigger).toHaveBeenCalledTimes(1);
105
+ });
106
+
107
+ it("renders its children", async () => {
108
+ const el = await mount({ children: "Suggested action" });
109
+ expect(el.textContent).toBe("Suggested action");
110
+ });
111
+
112
+ it("does not fire the trigger when the hook reports disabled", async () => {
113
+ h.useSuggestionTrigger.mockReturnValue({
114
+ trigger: h.trigger,
115
+ disabled: true,
116
+ });
117
+ const el = await mount();
118
+ await press(el);
119
+
120
+ expect(h.trigger).not.toHaveBeenCalled();
121
+ });
122
+
123
+ it("lets an explicit disabled prop override the hook's enabled state", async () => {
124
+ h.useSuggestionTrigger.mockReturnValue({
125
+ trigger: h.trigger,
126
+ disabled: false,
127
+ });
128
+ const el = await mount({ disabled: true });
129
+ await press(el);
130
+
131
+ expect(h.trigger).not.toHaveBeenCalled();
132
+ });
133
+
134
+ it("stays pressable when the prop disabled is false but the hook is disabled", async () => {
135
+ h.useSuggestionTrigger.mockReturnValue({
136
+ trigger: h.trigger,
137
+ disabled: true,
138
+ });
139
+ const el = await mount({ disabled: false });
140
+ await press(el);
141
+
142
+ expect(h.trigger).toHaveBeenCalledTimes(1);
143
+ });
144
+ });