@agent-native/toolkit 0.6.0 → 0.8.0

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 (174) hide show
  1. package/README.md +17 -1
  2. package/agent-native.eject.json +411 -0
  3. package/dist/chat-history/ChatHistoryList.d.ts +103 -0
  4. package/dist/chat-history/ChatHistoryList.d.ts.map +1 -0
  5. package/dist/chat-history/ChatHistoryList.js +109 -0
  6. package/dist/chat-history/ChatHistoryList.js.map +1 -0
  7. package/dist/chat-history/ChatHistoryList.spec.d.ts +2 -0
  8. package/dist/chat-history/ChatHistoryList.spec.d.ts.map +1 -0
  9. package/dist/chat-history/ChatHistoryList.spec.js +283 -0
  10. package/dist/chat-history/ChatHistoryList.spec.js.map +1 -0
  11. package/dist/chat-history/index.d.ts +2 -0
  12. package/dist/chat-history/index.d.ts.map +1 -0
  13. package/dist/chat-history/index.js +2 -0
  14. package/dist/chat-history/index.js.map +1 -0
  15. package/dist/chat-history.css +314 -0
  16. package/package.json +20 -1
  17. package/src/app-shell/header-actions.tsx +84 -0
  18. package/src/app-shell/index.ts +1 -0
  19. package/src/chat-history/ChatHistoryList.spec.tsx +453 -0
  20. package/src/chat-history/ChatHistoryList.tsx +469 -0
  21. package/src/chat-history/index.ts +7 -0
  22. package/src/chat-history.css +314 -0
  23. package/src/collab-ui/AgentPresenceChip.tsx +69 -0
  24. package/src/collab-ui/LiveCursorOverlay.tsx +241 -0
  25. package/src/collab-ui/PresenceBar.tsx +346 -0
  26. package/src/collab-ui/RecentEditHighlights.tsx +225 -0
  27. package/src/collab-ui/RemoteSelectionRings.tsx +227 -0
  28. package/src/collab-ui/agent-identity.ts +16 -0
  29. package/src/collab-ui/index.ts +39 -0
  30. package/src/collab-ui/lead-client.ts +32 -0
  31. package/src/collab-ui/types.spec.ts +18 -0
  32. package/src/collab-ui/types.ts +96 -0
  33. package/src/composer/AgentComposerFrame.tsx +59 -0
  34. package/src/composer/ComposerPlusMenu.tsx +983 -0
  35. package/src/composer/MentionPopover.tsx +412 -0
  36. package/src/composer/PastedTextChip.tsx +150 -0
  37. package/src/composer/PromptComposer.spec.ts +73 -0
  38. package/src/composer/PromptComposer.tsx +718 -0
  39. package/src/composer/RealtimeVoiceMode.spec.tsx +771 -0
  40. package/src/composer/RealtimeVoiceMode.tsx +856 -0
  41. package/src/composer/TiptapComposer.spec.ts +390 -0
  42. package/src/composer/TiptapComposer.tsx +2936 -0
  43. package/src/composer/VoiceButton.spec.ts +33 -0
  44. package/src/composer/VoiceButton.tsx +255 -0
  45. package/src/composer/asset-picker-url.spec.ts +56 -0
  46. package/src/composer/asset-picker-url.ts +80 -0
  47. package/src/composer/attachment-accept.spec.ts +22 -0
  48. package/src/composer/attachment-accept.ts +71 -0
  49. package/src/composer/draft-key.spec.ts +19 -0
  50. package/src/composer/draft-key.ts +7 -0
  51. package/src/composer/extensions/FileReference.tsx +55 -0
  52. package/src/composer/extensions/MentionReference.tsx +93 -0
  53. package/src/composer/extensions/SkillReference.tsx +56 -0
  54. package/src/composer/index.ts +79 -0
  55. package/src/composer/pasted-text.spec.ts +162 -0
  56. package/src/composer/pasted-text.ts +160 -0
  57. package/src/composer/prompt-attachments.spec.ts +80 -0
  58. package/src/composer/prompt-attachments.ts +93 -0
  59. package/src/composer/realtime-voice-audio-level.spec.ts +32 -0
  60. package/src/composer/realtime-voice-audio-level.ts +64 -0
  61. package/src/composer/realtime-voice-transcript.spec.ts +95 -0
  62. package/src/composer/realtime-voice-transcript.ts +134 -0
  63. package/src/composer/runtime-adapters.tsx +343 -0
  64. package/src/composer/types.ts +66 -0
  65. package/src/composer/use-file-search.ts +64 -0
  66. package/src/composer/use-mention-search.ts +90 -0
  67. package/src/composer/use-skills.ts +45 -0
  68. package/src/composer/useRealtimeVoiceMode.spec.ts +818 -0
  69. package/src/composer/useRealtimeVoiceMode.tsx +2117 -0
  70. package/src/composer/useVoiceDictation.spec.ts +45 -0
  71. package/src/composer/useVoiceDictation.ts +1191 -0
  72. package/src/context-ui/ContextMeter.tsx +101 -0
  73. package/src/context-ui/ContextSegmentRow.tsx +184 -0
  74. package/src/context-ui/ContextTreemap.tsx +125 -0
  75. package/src/context-ui/ContextXRayPanel.tsx +377 -0
  76. package/src/context-ui/format.ts +42 -0
  77. package/src/context-ui/index.ts +15 -0
  78. package/src/context-ui/types.ts +43 -0
  79. package/src/design-tweaks/index.ts +1 -0
  80. package/src/design-tweaks/visual-style-controls.tsx +1084 -0
  81. package/src/editor/BubbleToolbar.tsx +253 -0
  82. package/src/editor/CodeBlockNode.tsx +254 -0
  83. package/src/editor/DragHandle.spec.ts +588 -0
  84. package/src/editor/DragHandle.ts +1437 -0
  85. package/src/editor/ImageExtension.roundtrip.spec.ts +117 -0
  86. package/src/editor/ImageExtension.ts +306 -0
  87. package/src/editor/RegistryBlockContext.tsx +107 -0
  88. package/src/editor/RegistryBlockNode.spec.ts +279 -0
  89. package/src/editor/RegistryBlockNode.tsx +652 -0
  90. package/src/editor/RichMarkdownEditor.collab.spec.ts +79 -0
  91. package/src/editor/RichMarkdownEditor.roundtrip.spec.ts +226 -0
  92. package/src/editor/RichMarkdownEditor.tsx +81 -0
  93. package/src/editor/RunId.ts +61 -0
  94. package/src/editor/SharedRichEditor.tsx +321 -0
  95. package/src/editor/SlashCommandMenu.tsx +310 -0
  96. package/src/editor/disableHistory.spec.ts +85 -0
  97. package/src/editor/extensions.ts +402 -0
  98. package/src/editor/gfmDoc.spec.ts +230 -0
  99. package/src/editor/gfmDoc.ts +92 -0
  100. package/src/editor/index.ts +93 -0
  101. package/src/editor/registrySlashCommands.ts +131 -0
  102. package/src/editor/structuralReorderUndo.spec.ts +144 -0
  103. package/src/editor/surgical-apply.spec.ts +279 -0
  104. package/src/editor/surgical-apply.ts +192 -0
  105. package/src/editor/useCollabReconcile.concurrent.spec.ts +422 -0
  106. package/src/editor/useCollabReconcile.idempotent.spec.ts +324 -0
  107. package/src/editor/useCollabReconcile.ts +589 -0
  108. package/src/editor.css +510 -0
  109. package/src/hooks/index.ts +2 -0
  110. package/src/hooks/use-mobile.tsx +21 -0
  111. package/src/hooks/use-toast.ts +188 -0
  112. package/src/index.ts +8 -0
  113. package/src/onboarding/index.ts +1 -0
  114. package/src/provider.tsx +47 -0
  115. package/src/sharing/VisibilityBadge.spec.ts +96 -0
  116. package/src/sharing/VisibilityBadge.tsx +37 -0
  117. package/src/sharing/index.ts +4 -0
  118. package/src/styles.css +21 -0
  119. package/src/ui/accordion.tsx +56 -0
  120. package/src/ui/alert-dialog.tsx +141 -0
  121. package/src/ui/alert.tsx +59 -0
  122. package/src/ui/aspect-ratio.tsx +5 -0
  123. package/src/ui/avatar.tsx +48 -0
  124. package/src/ui/badge.tsx +37 -0
  125. package/src/ui/breadcrumb.tsx +115 -0
  126. package/src/ui/button.tsx +78 -0
  127. package/src/ui/calendar.tsx +214 -0
  128. package/src/ui/card.tsx +86 -0
  129. package/src/ui/carousel.tsx +260 -0
  130. package/src/ui/chart.tsx +375 -0
  131. package/src/ui/checkbox.tsx +28 -0
  132. package/src/ui/collapsible.tsx +9 -0
  133. package/src/ui/command.spec.tsx +51 -0
  134. package/src/ui/command.tsx +174 -0
  135. package/src/ui/context-menu.tsx +202 -0
  136. package/src/ui/date-picker.tsx +64 -0
  137. package/src/ui/dialog.spec.tsx +43 -0
  138. package/src/ui/dialog.tsx +146 -0
  139. package/src/ui/drawer.tsx +126 -0
  140. package/src/ui/dropdown-menu.tsx +218 -0
  141. package/src/ui/form.tsx +177 -0
  142. package/src/ui/hover-card.tsx +27 -0
  143. package/src/ui/index.ts +52 -0
  144. package/src/ui/input-otp.tsx +69 -0
  145. package/src/ui/input.tsx +22 -0
  146. package/src/ui/label.tsx +24 -0
  147. package/src/ui/logo.tsx +36 -0
  148. package/src/ui/menubar.tsx +235 -0
  149. package/src/ui/navigation-menu.tsx +128 -0
  150. package/src/ui/pagination.tsx +121 -0
  151. package/src/ui/popover.tsx +44 -0
  152. package/src/ui/progress.tsx +26 -0
  153. package/src/ui/radio-group.tsx +42 -0
  154. package/src/ui/resizable.tsx +43 -0
  155. package/src/ui/scroll-area.tsx +46 -0
  156. package/src/ui/select.tsx +158 -0
  157. package/src/ui/separator.tsx +29 -0
  158. package/src/ui/sheet.tsx +160 -0
  159. package/src/ui/sidebar.tsx +777 -0
  160. package/src/ui/skeleton.tsx +18 -0
  161. package/src/ui/slider.tsx +26 -0
  162. package/src/ui/sonner.tsx +58 -0
  163. package/src/ui/spinner.tsx +17 -0
  164. package/src/ui/switch.tsx +27 -0
  165. package/src/ui/table.tsx +117 -0
  166. package/src/ui/tabs.tsx +53 -0
  167. package/src/ui/textarea.tsx +23 -0
  168. package/src/ui/toast.tsx +127 -0
  169. package/src/ui/toaster.tsx +33 -0
  170. package/src/ui/toggle-group.tsx +59 -0
  171. package/src/ui/toggle.tsx +43 -0
  172. package/src/ui/tooltip.tsx +53 -0
  173. package/src/ui/use-toast.ts +3 -0
  174. package/src/utils.ts +8 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-native/toolkit",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "Reusable app-building UI and helpers for Agent-Native apps.",
5
5
  "homepage": "https://github.com/BuilderIO/agent-native#readme",
6
6
  "bugs": {
@@ -14,6 +14,8 @@
14
14
  },
15
15
  "files": [
16
16
  "dist",
17
+ "src",
18
+ "agent-native.eject.json",
17
19
  "migration-manifest.json",
18
20
  "README.md"
19
21
  ],
@@ -59,6 +61,20 @@
59
61
  "import": "./dist/collab-ui/*.js",
60
62
  "default": "./dist/collab-ui/*.js"
61
63
  },
64
+ "./chat-history": {
65
+ "types": "./dist/chat-history/index.d.ts",
66
+ "browser": "./dist/chat-history/index.js",
67
+ "development": "./dist/chat-history/index.js",
68
+ "import": "./dist/chat-history/index.js",
69
+ "default": "./dist/chat-history/index.js"
70
+ },
71
+ "./chat-history/*": {
72
+ "types": "./dist/chat-history/*.d.ts",
73
+ "browser": "./dist/chat-history/*.js",
74
+ "development": "./dist/chat-history/*.js",
75
+ "import": "./dist/chat-history/*.js",
76
+ "default": "./dist/chat-history/*.js"
77
+ },
62
78
  "./composer": {
63
79
  "types": "./dist/composer/index.d.ts",
64
80
  "browser": "./dist/composer/index.js",
@@ -171,8 +187,10 @@
171
187
  "import": "./dist/utils.js",
172
188
  "default": "./dist/utils.js"
173
189
  },
190
+ "./agent-native.eject.json": "./agent-native.eject.json",
174
191
  "./migration-manifest.json": "./migration-manifest.json",
175
192
  "./styles.css": "./dist/styles.css",
193
+ "./chat-history.css": "./dist/chat-history.css",
176
194
  "./editor.css": "./dist/editor.css"
177
195
  },
178
196
  "publishConfig": {
@@ -256,6 +274,7 @@
256
274
  "react": "^19.0.0",
257
275
  "react-dom": "^19.0.0"
258
276
  },
277
+ "agentNativeEjectManifest": "agent-native.eject.json",
259
278
  "scripts": {
260
279
  "build": "tsc && node scripts/finalize-build.mjs",
261
280
  "typecheck": "tsc --noEmit",
@@ -0,0 +1,84 @@
1
+ import {
2
+ useEffect,
3
+ useSyncExternalStore,
4
+ type ReactNode,
5
+ type FC,
6
+ } from "react";
7
+
8
+ /**
9
+ * External store for the page's header title + actions. We use an external
10
+ * store (not React context) so that pages can inject ReactNode without
11
+ * subscribing to the header state themselves — subscribing would trigger a
12
+ * re-render on every update, which in turn creates new JSX, which updates
13
+ * the store again, which re-renders… an infinite loop.
14
+ *
15
+ * Only <Header /> reads the store via useSyncExternalStore; pages only write.
16
+ */
17
+
18
+ type Listener = () => void;
19
+
20
+ let currentTitle: ReactNode = null;
21
+ let currentActions: ReactNode = null;
22
+ const listeners = new Set<Listener>();
23
+
24
+ function notify() {
25
+ for (const l of listeners) l();
26
+ }
27
+
28
+ function subscribe(l: Listener): () => void {
29
+ listeners.add(l);
30
+ return () => {
31
+ listeners.delete(l);
32
+ };
33
+ }
34
+
35
+ /** Consumed only by <Header /> — returns the current title. */
36
+ export function useHeaderTitle(): ReactNode {
37
+ return useSyncExternalStore(
38
+ subscribe,
39
+ () => currentTitle,
40
+ () => currentTitle,
41
+ );
42
+ }
43
+
44
+ /** Consumed only by <Header /> — returns the current actions slot. */
45
+ export function useHeaderActions(): ReactNode {
46
+ return useSyncExternalStore(
47
+ subscribe,
48
+ () => currentActions,
49
+ () => currentActions,
50
+ );
51
+ }
52
+
53
+ /**
54
+ * Provider is now a no-op wrapper for backwards compatibility — the state
55
+ * lives in the module-level store above. Kept as a component so callers of
56
+ * <HeaderActionsProvider> don't need to change.
57
+ */
58
+ export const HeaderActionsProvider: FC<{ children: ReactNode }> = ({
59
+ children,
60
+ }) => <>{children}</>;
61
+
62
+ /** Mount a custom title into the app header. Cleans up on unmount. */
63
+ export function useSetPageTitle(node: ReactNode) {
64
+ useEffect(() => {
65
+ currentTitle = node;
66
+ notify();
67
+ return () => {
68
+ currentTitle = null;
69
+ notify();
70
+ };
71
+ });
72
+ }
73
+
74
+ /** Mount ReactNode into the header's actions slot. Cleans up on unmount. */
75
+ export function useSetHeaderActions(node: ReactNode) {
76
+ useEffect(() => {
77
+ currentActions = node;
78
+ notify();
79
+ return () => {
80
+ currentActions = null;
81
+ notify();
82
+ };
83
+ });
84
+ }
@@ -0,0 +1 @@
1
+ export * from "./header-actions.js";
@@ -0,0 +1,453 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { act } from "react";
4
+ import { createRoot, type Root } from "react-dom/client";
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
+
7
+ import {
8
+ ChatHistoryList,
9
+ type ChatHistoryItem,
10
+ type ChatHistorySection,
11
+ } from "./ChatHistoryList.js";
12
+
13
+ function item(
14
+ overrides: Partial<ChatHistoryItem> & { id: string },
15
+ ): ChatHistoryItem {
16
+ return {
17
+ title: overrides.id,
18
+ ...overrides,
19
+ };
20
+ }
21
+
22
+ /** React tracks the DOM input's value via a wrapped setter to decide whether
23
+ * to fire its synthetic change handler, so setting `.value` directly is not
24
+ * observed. Go through the native prototype setter instead, matching the
25
+ * pattern used by CommandMenu.spec.tsx. */
26
+ function typeIntoInput(input: HTMLInputElement, value: string) {
27
+ const setter = Object.getOwnPropertyDescriptor(
28
+ window.HTMLInputElement.prototype,
29
+ "value",
30
+ )?.set;
31
+ setter?.call(input, value);
32
+ input.dispatchEvent(new Event("input", { bubbles: true }));
33
+ input.dispatchEvent(new Event("change", { bubbles: true }));
34
+ }
35
+
36
+ describe("ChatHistoryList", () => {
37
+ let container: HTMLDivElement;
38
+ let root: Root;
39
+
40
+ beforeEach(() => {
41
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
42
+ vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
43
+ cb(0);
44
+ return 0;
45
+ });
46
+ container = document.createElement("div");
47
+ document.body.appendChild(container);
48
+ root = createRoot(container);
49
+ });
50
+
51
+ afterEach(() => {
52
+ act(() => root.unmount());
53
+ container.remove();
54
+ document.body.innerHTML = "";
55
+ vi.restoreAllMocks();
56
+ vi.unstubAllGlobals();
57
+ });
58
+
59
+ it("renders a flat item list and calls onSelect when a row is clicked", () => {
60
+ const onSelect = vi.fn();
61
+ const items: ChatHistoryItem[] = [
62
+ item({ id: "thread-1", title: "First chat", timestamp: "Yesterday" }),
63
+ item({ id: "thread-2", title: "Second chat", timestamp: "2 min ago" }),
64
+ ];
65
+
66
+ act(() => {
67
+ root.render(<ChatHistoryList items={items} onSelect={onSelect} />);
68
+ });
69
+
70
+ const titles = Array.from(
71
+ container.querySelectorAll(".an-chat-history-row__title"),
72
+ ).map((el) => el.textContent);
73
+ expect(titles).toEqual(["First chat", "Second chat"]);
74
+ expect(container.textContent).toContain("Yesterday");
75
+ expect(container.textContent).toContain("2 min ago");
76
+
77
+ const buttons = container.querySelectorAll(".an-chat-history-row__button");
78
+ act(() => {
79
+ (buttons[1] as HTMLButtonElement).click();
80
+ });
81
+ expect(onSelect).toHaveBeenCalledWith("thread-2");
82
+ });
83
+
84
+ it("highlights the active item", () => {
85
+ const items: ChatHistoryItem[] = [
86
+ item({ id: "thread-1" }),
87
+ item({ id: "thread-2" }),
88
+ ];
89
+
90
+ act(() => {
91
+ root.render(
92
+ <ChatHistoryList
93
+ items={items}
94
+ activeId="thread-2"
95
+ onSelect={() => {}}
96
+ />,
97
+ );
98
+ });
99
+
100
+ const rows = container.querySelectorAll(".an-chat-history-row");
101
+ expect(rows[0].className).not.toContain("an-chat-history-row--active");
102
+ expect(rows[1].className).toContain("an-chat-history-row--active");
103
+ });
104
+
105
+ it("renders grouped sections with labels", () => {
106
+ const sections: ChatHistorySection[] = [
107
+ {
108
+ id: "scoped",
109
+ label: "This deck",
110
+ items: [item({ id: "thread-1", title: "Deck chat" })],
111
+ },
112
+ {
113
+ id: "other",
114
+ label: "All chats",
115
+ items: [item({ id: "thread-2", title: "Other chat" })],
116
+ },
117
+ ];
118
+
119
+ act(() => {
120
+ root.render(<ChatHistoryList sections={sections} onSelect={() => {}} />);
121
+ });
122
+
123
+ const labels = Array.from(
124
+ container.querySelectorAll(".an-chat-history__section-label"),
125
+ ).map((el) => el.textContent);
126
+ expect(labels).toEqual(["This deck", "All chats"]);
127
+ });
128
+
129
+ it("shows the empty state, distinguishing plain-empty from no-search-results", () => {
130
+ act(() => {
131
+ root.render(<ChatHistoryList items={[]} onSelect={() => {}} />);
132
+ });
133
+ expect(container.textContent).toContain("No chats yet");
134
+
135
+ act(() => {
136
+ root.render(
137
+ <ChatHistoryList
138
+ items={[]}
139
+ searchValue="foo"
140
+ onSearchChange={() => {}}
141
+ onSelect={() => {}}
142
+ />,
143
+ );
144
+ });
145
+ expect(container.textContent).toContain("No matching chats");
146
+ });
147
+
148
+ it("shows a loading state instead of the list", () => {
149
+ const items: ChatHistoryItem[] = [item({ id: "thread-1" })];
150
+ act(() => {
151
+ root.render(
152
+ <ChatHistoryList
153
+ items={items}
154
+ loading
155
+ loadingLabel="Searching..."
156
+ onSelect={() => {}}
157
+ />,
158
+ );
159
+ });
160
+ expect(container.textContent).toContain("Searching...");
161
+ expect(container.querySelector(".an-chat-history-row")).toBeNull();
162
+ });
163
+
164
+ it("shows an error state that takes priority over loading and items", () => {
165
+ const items: ChatHistoryItem[] = [item({ id: "thread-1" })];
166
+ act(() => {
167
+ root.render(
168
+ <ChatHistoryList
169
+ items={items}
170
+ loading
171
+ error="Could not load chats"
172
+ onSelect={() => {}}
173
+ />,
174
+ );
175
+ });
176
+ expect(container.textContent).toContain("Could not load chats");
177
+ expect(container.querySelector(".an-chat-history-row")).toBeNull();
178
+ });
179
+
180
+ it("renders a controlled search box only when onSearchChange is supplied", () => {
181
+ const onSearchChange = vi.fn();
182
+ act(() => {
183
+ root.render(
184
+ <ChatHistoryList
185
+ items={[]}
186
+ searchValue="abc"
187
+ onSearchChange={onSearchChange}
188
+ onSelect={() => {}}
189
+ />,
190
+ );
191
+ });
192
+ const input = container.querySelector<HTMLInputElement>(
193
+ ".an-chat-history__search-input",
194
+ );
195
+ expect(input).not.toBeNull();
196
+ expect(input!.value).toBe("abc");
197
+
198
+ act(() => {
199
+ typeIntoInput(input!, "abcd");
200
+ });
201
+ expect(onSearchChange).toHaveBeenCalledWith("abcd");
202
+
203
+ act(() => {
204
+ root.render(<ChatHistoryList items={[]} onSelect={() => {}} />);
205
+ });
206
+ expect(
207
+ container.querySelector(".an-chat-history__search-input"),
208
+ ).toBeNull();
209
+ });
210
+
211
+ it("does not render a row action menu when no menu callbacks are given", () => {
212
+ const items: ChatHistoryItem[] = [item({ id: "thread-1" })];
213
+ act(() => {
214
+ root.render(<ChatHistoryList items={items} onSelect={() => {}} />);
215
+ });
216
+ expect(container.querySelector(".an-chat-history-row__menu")).toBeNull();
217
+ });
218
+
219
+ it("supports pin toggling via the row action menu", () => {
220
+ const onTogglePin = vi.fn();
221
+ const items: ChatHistoryItem[] = [
222
+ item({ id: "thread-1", title: "First chat" }),
223
+ ];
224
+ act(() => {
225
+ root.render(
226
+ <ChatHistoryList
227
+ items={items}
228
+ onSelect={() => {}}
229
+ onTogglePin={onTogglePin}
230
+ />,
231
+ );
232
+ });
233
+
234
+ const trigger = container.querySelector<HTMLButtonElement>(
235
+ ".an-chat-history-row__menu-trigger",
236
+ );
237
+ expect(trigger).not.toBeNull();
238
+ act(() => {
239
+ trigger!.click();
240
+ });
241
+
242
+ const pinItem = Array.from(
243
+ container.querySelectorAll(".an-chat-history-row__menu-item"),
244
+ ).find((el) => el.textContent?.includes("Pin to top"));
245
+ expect(pinItem).toBeDefined();
246
+ act(() => {
247
+ (pinItem as HTMLButtonElement).click();
248
+ });
249
+ expect(onTogglePin).toHaveBeenCalledWith("thread-1");
250
+ });
251
+
252
+ it("uses custom labels for localized row actions", () => {
253
+ const onTogglePin = vi.fn();
254
+ const onDelete = vi.fn();
255
+ const labels = {
256
+ options: "Conversation actions",
257
+ renameInput: "Rename analytics conversation",
258
+ rename: "Rename conversation",
259
+ pin: "Keep at top",
260
+ unpin: "Remove from top",
261
+ delete: "Archive conversation",
262
+ };
263
+ const render = (pinned = false) => {
264
+ act(() => {
265
+ root.render(
266
+ <ChatHistoryList
267
+ key={String(pinned)}
268
+ items={[item({ id: "thread-1", titleText: "Campaign", pinned })]}
269
+ onSelect={() => {}}
270
+ onRename={() => {}}
271
+ onTogglePin={onTogglePin}
272
+ onDelete={onDelete}
273
+ labels={labels}
274
+ />,
275
+ );
276
+ });
277
+ };
278
+
279
+ render();
280
+ const trigger = container.querySelector<HTMLButtonElement>(
281
+ ".an-chat-history-row__menu-trigger",
282
+ );
283
+ expect(trigger?.getAttribute("aria-label")).toBe(labels.options);
284
+ act(() => {
285
+ trigger!.click();
286
+ });
287
+ expect(container.textContent).toContain(labels.rename);
288
+ expect(container.textContent).toContain(labels.pin);
289
+ expect(container.textContent).toContain(labels.delete);
290
+
291
+ const archiveItem = Array.from(
292
+ container.querySelectorAll(".an-chat-history-row__menu-item"),
293
+ ).find((element) => element.textContent?.includes(labels.delete));
294
+ act(() => {
295
+ (archiveItem as HTMLButtonElement).click();
296
+ });
297
+ expect(onDelete).toHaveBeenCalledWith("thread-1");
298
+
299
+ render();
300
+ const renameTrigger = container.querySelector<HTMLButtonElement>(
301
+ ".an-chat-history-row__menu-trigger",
302
+ );
303
+ act(() => {
304
+ renameTrigger!.click();
305
+ });
306
+ const renameItem = Array.from(
307
+ container.querySelectorAll(".an-chat-history-row__menu-item"),
308
+ ).find((element) => element.textContent?.includes(labels.rename));
309
+ act(() => {
310
+ (renameItem as HTMLButtonElement).click();
311
+ });
312
+ expect(
313
+ container
314
+ .querySelector<HTMLInputElement>(".an-chat-history-row__rename-input")
315
+ ?.getAttribute("aria-label"),
316
+ ).toBe(labels.renameInput);
317
+
318
+ render(true);
319
+ const pinnedTrigger = container.querySelector<HTMLButtonElement>(
320
+ ".an-chat-history-row__menu-trigger",
321
+ );
322
+ act(() => {
323
+ pinnedTrigger!.click();
324
+ });
325
+ expect(container.textContent).toContain(labels.unpin);
326
+ });
327
+
328
+ it("supports inline rename via the row action menu", () => {
329
+ const onRename = vi.fn();
330
+ const items: ChatHistoryItem[] = [
331
+ item({ id: "thread-1", title: "Old title", titleText: "Old title" }),
332
+ ];
333
+ act(() => {
334
+ root.render(
335
+ <ChatHistoryList
336
+ items={items}
337
+ onSelect={() => {}}
338
+ onRename={onRename}
339
+ renameMaxLength={160}
340
+ />,
341
+ );
342
+ });
343
+
344
+ const trigger = container.querySelector<HTMLButtonElement>(
345
+ ".an-chat-history-row__menu-trigger",
346
+ );
347
+ act(() => {
348
+ trigger!.click();
349
+ });
350
+ const renameItem = Array.from(
351
+ container.querySelectorAll(".an-chat-history-row__menu-item"),
352
+ ).find((el) => el.textContent?.includes("Rename"));
353
+ act(() => {
354
+ (renameItem as HTMLButtonElement).click();
355
+ });
356
+
357
+ const input = container.querySelector<HTMLInputElement>(
358
+ ".an-chat-history-row__rename-input",
359
+ );
360
+ expect(input).not.toBeNull();
361
+ expect(input!.value).toBe("Old title");
362
+ expect(input!.maxLength).toBe(160);
363
+
364
+ act(() => {
365
+ typeIntoInput(input!, "New title");
366
+ });
367
+ act(() => {
368
+ input!.dispatchEvent(
369
+ new KeyboardEvent("keydown", { key: "Enter", bubbles: true }),
370
+ );
371
+ });
372
+ expect(onRename).toHaveBeenCalledWith("thread-1", "New title");
373
+ });
374
+
375
+ it("supports item-aware labels and app-specific actions", () => {
376
+ const onShare = vi.fn();
377
+ const items: ChatHistoryItem[] = [
378
+ item({ id: "thread-1", title: "Campaign", titleText: "Campaign" }),
379
+ ];
380
+ act(() => {
381
+ root.render(
382
+ <ChatHistoryList
383
+ items={items}
384
+ onSelect={() => {}}
385
+ onRename={() => {}}
386
+ labels={{
387
+ options: (chat) => `Options for ${chat.titleText}`,
388
+ renameInput: (chat) => `Rename ${chat.titleText}`,
389
+ }}
390
+ renderAdditionalRowActions={(chat, closeMenu) => (
391
+ <button
392
+ type="button"
393
+ className="an-chat-history-row__menu-item"
394
+ onClick={() => {
395
+ closeMenu();
396
+ onShare(chat.id);
397
+ }}
398
+ >
399
+ Share
400
+ </button>
401
+ )}
402
+ />,
403
+ );
404
+ });
405
+
406
+ const trigger = container.querySelector<HTMLButtonElement>(
407
+ ".an-chat-history-row__menu-trigger",
408
+ );
409
+ expect(trigger?.getAttribute("aria-label")).toBe("Options for Campaign");
410
+ act(() => {
411
+ trigger!.click();
412
+ });
413
+
414
+ const shareItem = Array.from(
415
+ container.querySelectorAll(".an-chat-history-row__menu-item"),
416
+ ).find((element) => element.textContent === "Share");
417
+ act(() => {
418
+ (shareItem as HTMLButtonElement).click();
419
+ });
420
+ expect(onShare).toHaveBeenCalledWith("thread-1");
421
+ expect(
422
+ container.querySelector(".an-chat-history-row__menu-content"),
423
+ ).toBeNull();
424
+
425
+ act(() => {
426
+ trigger!.click();
427
+ });
428
+ const renameItem = Array.from(
429
+ container.querySelectorAll(".an-chat-history-row__menu-item"),
430
+ ).find((element) => element.textContent === "Rename");
431
+ act(() => {
432
+ (renameItem as HTMLButtonElement).click();
433
+ });
434
+ expect(
435
+ container
436
+ .querySelector<HTMLInputElement>(".an-chat-history-row__rename-input")
437
+ ?.getAttribute("aria-label"),
438
+ ).toBe("Rename Campaign");
439
+ });
440
+
441
+ it("renders the footer inside the scroll region regardless of list state", () => {
442
+ act(() => {
443
+ root.render(
444
+ <ChatHistoryList
445
+ items={[]}
446
+ onSelect={() => {}}
447
+ footer={<div data-testid="footer">Load older chats</div>}
448
+ />,
449
+ );
450
+ });
451
+ expect(container.textContent).toContain("Load older chats");
452
+ });
453
+ });