@agent-native/toolkit 0.9.0 → 0.10.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 (190) hide show
  1. package/README.md +12 -3
  2. package/agent-native.eject.json +71 -0
  3. package/dist/chat-history/ChatHistoryRail.d.ts +33 -0
  4. package/dist/chat-history/ChatHistoryRail.d.ts.map +1 -0
  5. package/dist/chat-history/ChatHistoryRail.js +35 -0
  6. package/dist/chat-history/ChatHistoryRail.js.map +1 -0
  7. package/dist/chat-history/ChatHistoryRail.spec.d.ts +2 -0
  8. package/dist/chat-history/ChatHistoryRail.spec.d.ts.map +1 -0
  9. package/dist/chat-history/ChatHistoryRail.spec.js +157 -0
  10. package/dist/chat-history/ChatHistoryRail.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 -1
  13. package/dist/chat-history/index.js +2 -0
  14. package/dist/chat-history/index.js.map +1 -1
  15. package/dist/chat-history/useChatHistoryRailController.d.ts +26 -0
  16. package/dist/chat-history/useChatHistoryRailController.d.ts.map +1 -0
  17. package/dist/chat-history/useChatHistoryRailController.js +36 -0
  18. package/dist/chat-history/useChatHistoryRailController.js.map +1 -0
  19. package/dist/chat-history/useChatHistoryRailController.spec.d.ts +2 -0
  20. package/dist/chat-history/useChatHistoryRailController.spec.d.ts.map +1 -0
  21. package/dist/chat-history/useChatHistoryRailController.spec.js +101 -0
  22. package/dist/chat-history/useChatHistoryRailController.spec.js.map +1 -0
  23. package/dist/chat-history.css +61 -0
  24. package/dist/composer/ComposerPlusMenu.js +1 -1
  25. package/dist/composer/ComposerPlusMenu.js.map +1 -1
  26. package/dist/composer/PromptComposer.d.ts.map +1 -1
  27. package/dist/composer/PromptComposer.js +22 -4
  28. package/dist/composer/PromptComposer.js.map +1 -1
  29. package/dist/composer/RealtimeVoiceMode.d.ts +5 -1
  30. package/dist/composer/RealtimeVoiceMode.d.ts.map +1 -1
  31. package/dist/composer/RealtimeVoiceMode.js +27 -8
  32. package/dist/composer/RealtimeVoiceMode.js.map +1 -1
  33. package/dist/composer/RealtimeVoiceMode.spec.js +51 -11
  34. package/dist/composer/RealtimeVoiceMode.spec.js.map +1 -1
  35. package/dist/composer/VoiceButton.d.ts +2 -0
  36. package/dist/composer/VoiceButton.d.ts.map +1 -1
  37. package/dist/composer/VoiceButton.js +33 -1
  38. package/dist/composer/VoiceButton.js.map +1 -1
  39. package/dist/composer/VoiceButton.spec.js +9 -1
  40. package/dist/composer/VoiceButton.spec.js.map +1 -1
  41. package/dist/composer/realtime-voice-audio-level.d.ts.map +1 -1
  42. package/dist/composer/realtime-voice-audio-level.js +2 -1
  43. package/dist/composer/realtime-voice-audio-level.js.map +1 -1
  44. package/dist/composer/realtime-voice-audio-level.spec.js +7 -0
  45. package/dist/composer/realtime-voice-audio-level.spec.js.map +1 -1
  46. package/dist/composer/useRealtimeVoiceMode.d.ts +7 -0
  47. package/dist/composer/useRealtimeVoiceMode.d.ts.map +1 -1
  48. package/dist/composer/useRealtimeVoiceMode.js +95 -23
  49. package/dist/composer/useRealtimeVoiceMode.js.map +1 -1
  50. package/dist/composer/useRealtimeVoiceMode.spec.js +58 -9
  51. package/dist/composer/useRealtimeVoiceMode.spec.js.map +1 -1
  52. package/dist/conformance/__fixtures__/css-in-js-adapter.d.ts +7 -0
  53. package/dist/conformance/__fixtures__/css-in-js-adapter.d.ts.map +1 -0
  54. package/dist/conformance/__fixtures__/css-in-js-adapter.js +84 -0
  55. package/dist/conformance/__fixtures__/css-in-js-adapter.js.map +1 -0
  56. package/dist/conformance/conformance.spec.d.ts +2 -0
  57. package/dist/conformance/conformance.spec.d.ts.map +1 -0
  58. package/dist/conformance/conformance.spec.js +58 -0
  59. package/dist/conformance/conformance.spec.js.map +1 -0
  60. package/dist/conformance/index.d.ts +4 -0
  61. package/dist/conformance/index.d.ts.map +1 -0
  62. package/dist/conformance/index.js +4 -0
  63. package/dist/conformance/index.js.map +1 -0
  64. package/dist/conformance/runner.d.ts +12 -0
  65. package/dist/conformance/runner.d.ts.map +1 -0
  66. package/dist/conformance/runner.js +441 -0
  67. package/dist/conformance/runner.js.map +1 -0
  68. package/dist/conformance/types.d.ts +26 -0
  69. package/dist/conformance/types.d.ts.map +1 -0
  70. package/dist/conformance/types.js +13 -0
  71. package/dist/conformance/types.js.map +1 -0
  72. package/dist/conformance/version.d.ts +6 -0
  73. package/dist/conformance/version.d.ts.map +1 -0
  74. package/dist/conformance/version.js +14 -0
  75. package/dist/conformance/version.js.map +1 -0
  76. package/dist/design-system/components.d.ts +64 -0
  77. package/dist/design-system/components.d.ts.map +1 -0
  78. package/dist/design-system/components.js +50 -0
  79. package/dist/design-system/components.js.map +1 -0
  80. package/dist/design-system/context.d.ts +11 -0
  81. package/dist/design-system/context.d.ts.map +1 -0
  82. package/dist/design-system/context.js +17 -0
  83. package/dist/design-system/context.js.map +1 -0
  84. package/dist/design-system/default-adapter.d.ts +3 -0
  85. package/dist/design-system/default-adapter.d.ts.map +1 -0
  86. package/dist/design-system/default-adapter.js +166 -0
  87. package/dist/design-system/default-adapter.js.map +1 -0
  88. package/dist/design-system/default-adapter.spec.d.ts +2 -0
  89. package/dist/design-system/default-adapter.spec.d.ts.map +1 -0
  90. package/dist/design-system/default-adapter.spec.js +96 -0
  91. package/dist/design-system/default-adapter.spec.js.map +1 -0
  92. package/dist/design-system/definition.d.ts +9 -0
  93. package/dist/design-system/definition.d.ts.map +1 -0
  94. package/dist/design-system/definition.js +4 -0
  95. package/dist/design-system/definition.js.map +1 -0
  96. package/dist/design-system/design-system.spec.d.ts +2 -0
  97. package/dist/design-system/design-system.spec.d.ts.map +1 -0
  98. package/dist/design-system/design-system.spec.js +121 -0
  99. package/dist/design-system/design-system.spec.js.map +1 -0
  100. package/dist/design-system/error-boundary.d.ts +18 -0
  101. package/dist/design-system/error-boundary.d.ts.map +1 -0
  102. package/dist/design-system/error-boundary.js +16 -0
  103. package/dist/design-system/error-boundary.js.map +1 -0
  104. package/dist/design-system/index.d.ts +8 -0
  105. package/dist/design-system/index.d.ts.map +1 -0
  106. package/dist/design-system/index.js +8 -0
  107. package/dist/design-system/index.js.map +1 -0
  108. package/dist/design-system/theme.d.ts +30 -0
  109. package/dist/design-system/theme.d.ts.map +1 -0
  110. package/dist/design-system/theme.js +95 -0
  111. package/dist/design-system/theme.js.map +1 -0
  112. package/dist/design-system/theme.spec.d.ts +2 -0
  113. package/dist/design-system/theme.spec.d.ts.map +1 -0
  114. package/dist/design-system/theme.spec.js +43 -0
  115. package/dist/design-system/theme.spec.js.map +1 -0
  116. package/dist/design-system/types.d.ts +288 -0
  117. package/dist/design-system/types.d.ts.map +1 -0
  118. package/dist/design-system/types.js +2 -0
  119. package/dist/design-system/types.js.map +1 -0
  120. package/dist/provider.d.ts +4 -3
  121. package/dist/provider.d.ts.map +1 -1
  122. package/dist/provider.js +46 -4
  123. package/dist/provider.js.map +1 -1
  124. package/dist/styles.css +35 -0
  125. package/dist/ui/button.d.ts +5 -0
  126. package/dist/ui/button.d.ts.map +1 -1
  127. package/dist/ui/button.js +25 -4
  128. package/dist/ui/button.js.map +1 -1
  129. package/dist/ui/dialog.d.ts +4 -0
  130. package/dist/ui/dialog.d.ts.map +1 -1
  131. package/dist/ui/dialog.js +2 -2
  132. package/dist/ui/dialog.js.map +1 -1
  133. package/dist/ui/popover.d.ts +1 -0
  134. package/dist/ui/popover.d.ts.map +1 -1
  135. package/dist/ui/popover.js +2 -2
  136. package/dist/ui/popover.js.map +1 -1
  137. package/dist/ui/select.d.ts +3 -1
  138. package/dist/ui/select.d.ts.map +1 -1
  139. package/dist/ui/select.js +1 -1
  140. package/dist/ui/select.js.map +1 -1
  141. package/dist/ui/sidebar.d.ts +6 -2
  142. package/dist/ui/sidebar.d.ts.map +1 -1
  143. package/dist/ui/sidebar.js +12 -15
  144. package/dist/ui/sidebar.js.map +1 -1
  145. package/dist/ui/tooltip.d.ts +3 -1
  146. package/dist/ui/tooltip.d.ts.map +1 -1
  147. package/dist/ui/tooltip.js +2 -2
  148. package/dist/ui/tooltip.js.map +1 -1
  149. package/package.json +31 -1
  150. package/src/chat-history/ChatHistoryRail.spec.tsx +312 -0
  151. package/src/chat-history/ChatHistoryRail.tsx +150 -0
  152. package/src/chat-history/index.ts +14 -0
  153. package/src/chat-history/useChatHistoryRailController.spec.tsx +131 -0
  154. package/src/chat-history/useChatHistoryRailController.ts +76 -0
  155. package/src/chat-history.css +61 -0
  156. package/src/composer/ComposerPlusMenu.tsx +11 -8
  157. package/src/composer/PromptComposer.tsx +27 -6
  158. package/src/composer/RealtimeVoiceMode.spec.tsx +116 -11
  159. package/src/composer/RealtimeVoiceMode.tsx +106 -36
  160. package/src/composer/VoiceButton.spec.ts +17 -1
  161. package/src/composer/VoiceButton.tsx +49 -0
  162. package/src/composer/realtime-voice-audio-level.spec.ts +15 -0
  163. package/src/composer/realtime-voice-audio-level.ts +2 -1
  164. package/src/composer/useRealtimeVoiceMode.spec.ts +82 -8
  165. package/src/composer/useRealtimeVoiceMode.tsx +112 -25
  166. package/src/conformance/__fixtures__/css-in-js-adapter.tsx +401 -0
  167. package/src/conformance/conformance.spec.tsx +83 -0
  168. package/src/conformance/index.ts +3 -0
  169. package/src/conformance/runner.tsx +710 -0
  170. package/src/conformance/types.ts +45 -0
  171. package/src/conformance/version.ts +22 -0
  172. package/src/design-system/components.tsx +146 -0
  173. package/src/design-system/context.tsx +31 -0
  174. package/src/design-system/default-adapter.spec.tsx +143 -0
  175. package/src/design-system/default-adapter.tsx +915 -0
  176. package/src/design-system/definition.ts +14 -0
  177. package/src/design-system/design-system.spec.tsx +183 -0
  178. package/src/design-system/error-boundary.tsx +37 -0
  179. package/src/design-system/index.ts +7 -0
  180. package/src/design-system/theme.spec.ts +61 -0
  181. package/src/design-system/theme.ts +145 -0
  182. package/src/design-system/types.ts +363 -0
  183. package/src/provider.tsx +98 -5
  184. package/src/styles.css +35 -0
  185. package/src/ui/button.tsx +95 -6
  186. package/src/ui/dialog.tsx +48 -23
  187. package/src/ui/popover.tsx +16 -2
  188. package/src/ui/select.tsx +7 -3
  189. package/src/ui/sidebar.tsx +47 -48
  190. package/src/ui/tooltip.tsx +7 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-native/toolkit",
3
- "version": "0.9.0",
3
+ "version": "0.10.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": {
@@ -103,6 +103,20 @@
103
103
  "import": "./dist/composer/*.js",
104
104
  "default": "./dist/composer/*.js"
105
105
  },
106
+ "./conformance": {
107
+ "types": "./dist/conformance/index.d.ts",
108
+ "browser": "./dist/conformance/index.js",
109
+ "development": "./dist/conformance/index.js",
110
+ "import": "./dist/conformance/index.js",
111
+ "default": "./dist/conformance/index.js"
112
+ },
113
+ "./conformance/*": {
114
+ "types": "./dist/conformance/*.d.ts",
115
+ "browser": "./dist/conformance/*.js",
116
+ "development": "./dist/conformance/*.js",
117
+ "import": "./dist/conformance/*.js",
118
+ "default": "./dist/conformance/*.js"
119
+ },
106
120
  "./context-ui": {
107
121
  "types": "./dist/context-ui/index.d.ts",
108
122
  "browser": "./dist/context-ui/index.js",
@@ -117,6 +131,20 @@
117
131
  "import": "./dist/design-tweaks/index.js",
118
132
  "default": "./dist/design-tweaks/index.js"
119
133
  },
134
+ "./design-system": {
135
+ "types": "./dist/design-system/index.d.ts",
136
+ "browser": "./dist/design-system/index.js",
137
+ "development": "./dist/design-system/index.js",
138
+ "import": "./dist/design-system/index.js",
139
+ "default": "./dist/design-system/index.js"
140
+ },
141
+ "./design-system/*": {
142
+ "types": "./dist/design-system/*.d.ts",
143
+ "browser": "./dist/design-system/*.js",
144
+ "development": "./dist/design-system/*.js",
145
+ "import": "./dist/design-system/*.js",
146
+ "default": "./dist/design-system/*.js"
147
+ },
120
148
  "./editor": {
121
149
  "types": "./dist/editor/index.d.ts",
122
150
  "browser": "./dist/editor/index.js",
@@ -261,6 +289,7 @@
261
289
  "class-variance-authority": "^0.7.1",
262
290
  "clsx": "^2.1.1",
263
291
  "cmdk": "^1.1.1",
292
+ "culori": "^4.0.2",
264
293
  "date-fns": "^4.4.0",
265
294
  "embla-carousel-react": "^8.6.0",
266
295
  "highlight.js": "^11.11.1",
@@ -279,6 +308,7 @@
279
308
  "yjs": "^13.6.31"
280
309
  },
281
310
  "devDependencies": {
311
+ "@types/culori": "^4.0.1",
282
312
  "@types/react": "^19.2.14",
283
313
  "@types/react-dom": "^19.2.3",
284
314
  "typescript-7": "npm:typescript@^7.0.2",
@@ -0,0 +1,312 @@
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 { ActionButton, IconButton } from "../design-system/components.js";
8
+ import { defineDesignSystem } from "../design-system/definition.js";
9
+ import { ToolkitProvider } from "../provider.js";
10
+ import type { ChatHistoryItem } from "./ChatHistoryList.js";
11
+ import { ChatHistoryRail } from "./ChatHistoryRail.js";
12
+
13
+ const railLabels = {
14
+ newChat: "New chat",
15
+ showMore: "Show more chats",
16
+ showLess: "Show fewer chats",
17
+ };
18
+
19
+ function makeItems(count: number): ChatHistoryItem[] {
20
+ return Array.from({ length: count }, (_, index) => ({
21
+ id: `thread-${index + 1}`,
22
+ title: `Chat ${index + 1}`,
23
+ }));
24
+ }
25
+
26
+ describe("ChatHistoryRail", () => {
27
+ let container: HTMLDivElement;
28
+ let root: Root;
29
+
30
+ beforeEach(() => {
31
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
32
+ container = document.createElement("div");
33
+ document.body.appendChild(container);
34
+ root = createRoot(container);
35
+ });
36
+
37
+ afterEach(() => {
38
+ act(() => root.unmount());
39
+ container.remove();
40
+ vi.restoreAllMocks();
41
+ vi.unstubAllGlobals();
42
+ });
43
+
44
+ it("shows five recent chats before progressively disclosing up to fifteen", () => {
45
+ act(() => {
46
+ root.render(
47
+ <ChatHistoryRail
48
+ items={makeItems(20)}
49
+ onSelect={() => {}}
50
+ onNewChat={() => {}}
51
+ railLabels={railLabels}
52
+ />,
53
+ );
54
+ });
55
+
56
+ expect(container.querySelectorAll(".an-chat-history-row")).toHaveLength(5);
57
+ const disclosure = container.querySelector<HTMLButtonElement>(
58
+ ".an-chat-history-rail__disclosure",
59
+ );
60
+ expect(disclosure?.getAttribute("aria-label")).toBe("Show more chats");
61
+
62
+ act(() => disclosure?.click());
63
+ expect(container.querySelectorAll(".an-chat-history-row")).toHaveLength(15);
64
+ expect(disclosure?.getAttribute("aria-label")).toBe("Show fewer chats");
65
+
66
+ act(() => disclosure?.click());
67
+ expect(container.querySelectorAll(".an-chat-history-row")).toHaveLength(5);
68
+ });
69
+
70
+ it("keeps the disclosure to the right of new chat and calls its handler", () => {
71
+ const onNewChat = vi.fn();
72
+ act(() => {
73
+ root.render(
74
+ <ChatHistoryRail
75
+ items={makeItems(6)}
76
+ onSelect={() => {}}
77
+ onNewChat={onNewChat}
78
+ railLabels={railLabels}
79
+ />,
80
+ );
81
+ });
82
+
83
+ const disclosure = container.querySelector<HTMLButtonElement>(
84
+ ".an-chat-history-rail__disclosure",
85
+ );
86
+ const newChat = container.querySelector<HTMLButtonElement>(
87
+ ".an-chat-history-rail__new-chat",
88
+ );
89
+ expect(newChat?.textContent).toBe("New chat");
90
+ expect(newChat?.nextElementSibling).toBe(disclosure);
91
+ expect(newChat?.parentElement?.lastElementChild).toBe(disclosure);
92
+
93
+ act(() => newChat?.click());
94
+ expect(onNewChat).toHaveBeenCalledOnce();
95
+ });
96
+
97
+ it("lets new chat fill the footer when there are no more chats", () => {
98
+ act(() => {
99
+ root.render(
100
+ <ChatHistoryRail
101
+ items={makeItems(3)}
102
+ onSelect={() => {}}
103
+ onNewChat={() => {}}
104
+ railLabels={railLabels}
105
+ />,
106
+ );
107
+ });
108
+
109
+ expect(
110
+ container.querySelector(".an-chat-history-rail__disclosure"),
111
+ ).toBeNull();
112
+ expect(
113
+ container.querySelector(".an-chat-history-rail__new-chat"),
114
+ ).not.toBeNull();
115
+ });
116
+
117
+ it("preserves list actions for progressively disclosed rows", () => {
118
+ const onSelect = vi.fn();
119
+ act(() => {
120
+ root.render(
121
+ <ChatHistoryRail
122
+ items={makeItems(6)}
123
+ onSelect={onSelect}
124
+ onNewChat={() => {}}
125
+ railLabels={railLabels}
126
+ />,
127
+ );
128
+ });
129
+
130
+ act(() => {
131
+ container
132
+ .querySelector<HTMLButtonElement>(".an-chat-history-rail__disclosure")
133
+ ?.click();
134
+ });
135
+ const rows = container.querySelectorAll<HTMLButtonElement>(
136
+ ".an-chat-history-row__button",
137
+ );
138
+ act(() => rows[5]?.click());
139
+ expect(onSelect).toHaveBeenCalledWith("thread-6");
140
+ });
141
+
142
+ it("lets a design system replace the whole view without replacing its controller", () => {
143
+ const onNewChat = vi.fn();
144
+ const CustomActionButton = vi.fn(
145
+ ({ children, onPress }: Parameters<typeof ActionButton>[0]) => (
146
+ <button
147
+ data-acme-action
148
+ type="button"
149
+ onClick={(event) => onPress?.(event)}
150
+ >
151
+ {children}
152
+ </button>
153
+ ),
154
+ );
155
+ const designSystem = defineDesignSystem({
156
+ name: "Acme",
157
+ components: { ActionButton: CustomActionButton },
158
+ });
159
+
160
+ act(() => {
161
+ root.render(
162
+ <ToolkitProvider designSystem={designSystem}>
163
+ <ChatHistoryRail
164
+ items={makeItems(20)}
165
+ onSelect={() => {}}
166
+ onNewChat={onNewChat}
167
+ railLabels={railLabels}
168
+ renderRail={({ controller }) => (
169
+ <section data-acme-rail>
170
+ <output>{controller.visibleItems.length}</output>
171
+ <ActionButton onPress={controller.onNewChat}>
172
+ {controller.newChatLabel}
173
+ </ActionButton>
174
+ <ActionButton onPress={controller.toggleExpanded}>
175
+ {controller.disclosureLabel}
176
+ </ActionButton>
177
+ </section>
178
+ )}
179
+ />
180
+ </ToolkitProvider>,
181
+ );
182
+ });
183
+
184
+ const actions =
185
+ container.querySelectorAll<HTMLButtonElement>("[data-acme-action]");
186
+ expect(container.querySelector("[data-acme-rail]")).not.toBeNull();
187
+ expect(container.querySelector("output")?.textContent).toBe("5");
188
+ expect(actions[1]?.textContent).toBe("Show more chats");
189
+
190
+ act(() => actions[1]?.click());
191
+ expect(container.querySelector("output")?.textContent).toBe("15");
192
+ expect(actions[1]?.textContent).toBe("Show fewer chats");
193
+
194
+ act(() => actions[0]?.click());
195
+ expect(onNewChat).toHaveBeenCalledOnce();
196
+ expect(CustomActionButton).toHaveBeenCalled();
197
+ });
198
+
199
+ it("routes the default footer through the semantic action bridge", () => {
200
+ const onNewChat = vi.fn();
201
+ const CustomActionButton = vi.fn(
202
+ ({ children, onPress }: Parameters<typeof ActionButton>[0]) => (
203
+ <button
204
+ data-semantic-action
205
+ type="button"
206
+ onClick={(event) => onPress?.(event)}
207
+ >
208
+ {children}
209
+ </button>
210
+ ),
211
+ );
212
+ const CustomIconButton = vi.fn(
213
+ ({ icon, label, onPress }: Parameters<typeof IconButton>[0]) => (
214
+ <button
215
+ data-semantic-icon
216
+ type="button"
217
+ aria-label={label}
218
+ onClick={(event) => onPress?.(event)}
219
+ >
220
+ {icon}
221
+ </button>
222
+ ),
223
+ );
224
+ const designSystem = defineDesignSystem({
225
+ name: "Acme",
226
+ components: {
227
+ ActionButton: CustomActionButton,
228
+ IconButton: CustomIconButton,
229
+ },
230
+ });
231
+
232
+ act(() => {
233
+ root.render(
234
+ <ToolkitProvider designSystem={designSystem}>
235
+ <ChatHistoryRail
236
+ items={makeItems(6)}
237
+ onSelect={() => {}}
238
+ onNewChat={onNewChat}
239
+ railLabels={railLabels}
240
+ />
241
+ </ToolkitProvider>,
242
+ );
243
+ });
244
+
245
+ const newChat = container.querySelector<HTMLButtonElement>(
246
+ "[data-semantic-action]",
247
+ );
248
+ const disclosure = container.querySelector<HTMLButtonElement>(
249
+ "[data-semantic-icon]",
250
+ );
251
+ expect(newChat).not.toBeNull();
252
+ expect(disclosure?.getAttribute("aria-label")).toBe("Show more chats");
253
+ expect(CustomActionButton.mock.calls[0]?.[0]).toMatchObject({
254
+ emphasis: "ghost",
255
+ size: "compact",
256
+ leadingIcon: expect.anything(),
257
+ });
258
+ expect(CustomIconButton.mock.calls[0]?.[0]).toMatchObject({
259
+ size: "compact",
260
+ "aria-expanded": false,
261
+ label: "Show more chats",
262
+ });
263
+
264
+ act(() => newChat?.click());
265
+ expect(onNewChat).toHaveBeenCalledOnce();
266
+ });
267
+
268
+ it("keeps native button semantics and focus in the default view", () => {
269
+ const onNewChat = vi.fn();
270
+ act(() => {
271
+ root.render(
272
+ <ChatHistoryRail
273
+ items={makeItems(6)}
274
+ onSelect={() => {}}
275
+ onNewChat={onNewChat}
276
+ railLabels={railLabels}
277
+ />,
278
+ );
279
+ });
280
+
281
+ const newChat = container.querySelector<HTMLButtonElement>(
282
+ ".an-chat-history-rail__new-chat",
283
+ );
284
+ newChat?.focus();
285
+ act(() => newChat?.click());
286
+
287
+ expect(newChat?.type).toBe("button");
288
+ expect(document.activeElement).toBe(newChat);
289
+ expect(onNewChat).toHaveBeenCalledOnce();
290
+ });
291
+
292
+ it("falls back to the default rail when a product renderer fails", () => {
293
+ vi.spyOn(console, "error").mockImplementation(() => {});
294
+ act(() => {
295
+ root.render(
296
+ <ChatHistoryRail
297
+ items={makeItems(6)}
298
+ onSelect={() => {}}
299
+ onNewChat={() => {}}
300
+ railLabels={railLabels}
301
+ renderRail={() => {
302
+ throw new Error("broken company rail");
303
+ }}
304
+ />,
305
+ );
306
+ });
307
+
308
+ expect(
309
+ container.querySelector(".an-chat-history-rail__new-chat"),
310
+ ).not.toBeNull();
311
+ });
312
+ });
@@ -0,0 +1,150 @@
1
+ import { IconDots, IconPlus } from "@tabler/icons-react";
2
+ import type { ReactNode } from "react";
3
+
4
+ import { ActionButton, IconButton } from "../design-system/components.js";
5
+ import { DesignSystemErrorBoundary } from "../design-system/error-boundary.js";
6
+ import {
7
+ ChatHistoryList,
8
+ type ChatHistoryItem,
9
+ type ChatHistoryListProps,
10
+ } from "./ChatHistoryList.js";
11
+ import {
12
+ type ChatHistoryRailController,
13
+ useChatHistoryRailController,
14
+ } from "./useChatHistoryRailController.js";
15
+
16
+ export interface ChatHistoryRailLabels {
17
+ newChat: string;
18
+ showMore: string;
19
+ showLess: string;
20
+ }
21
+
22
+ export interface ChatHistoryRailProps extends Omit<
23
+ ChatHistoryListProps,
24
+ "footer" | "items" | "sections" | "variant"
25
+ > {
26
+ items: ChatHistoryItem[];
27
+ onNewChat: () => void;
28
+ railLabels: ChatHistoryRailLabels;
29
+ previewCount?: number;
30
+ expandedCount?: number;
31
+ /**
32
+ * Product-level presentation slot for design systems that want to replace
33
+ * the rail wholesale. State and actions still come from the same controller
34
+ * as the default view.
35
+ */
36
+ renderRail?: (context: ChatHistoryRailRenderContext) => ReactNode;
37
+ }
38
+
39
+ export interface ChatHistoryRailRenderContext {
40
+ controller: ChatHistoryRailController<ChatHistoryItem>;
41
+ listProps: Omit<
42
+ ChatHistoryListProps,
43
+ "footer" | "items" | "sections" | "variant"
44
+ >;
45
+ }
46
+
47
+ export type DefaultChatHistoryRailViewProps = ChatHistoryRailRenderContext;
48
+
49
+ export function DefaultChatHistoryRailView({
50
+ controller,
51
+ listProps,
52
+ }: ChatHistoryRailRenderContext) {
53
+ const {
54
+ canExpand,
55
+ disclosureLabel,
56
+ expanded,
57
+ newChatLabel,
58
+ onNewChat,
59
+ toggleExpanded,
60
+ visibleItems,
61
+ } = controller;
62
+ const { className, emptyLabel, ...rest } = listProps;
63
+
64
+ const footer = (
65
+ <div className="an-chat-history-rail__footer">
66
+ <ActionButton
67
+ type="button"
68
+ className="an-chat-history-rail__new-chat"
69
+ emphasis="ghost"
70
+ size="compact"
71
+ leadingIcon={
72
+ <IconPlus size={13} strokeWidth={1.8} aria-hidden="true" />
73
+ }
74
+ onPress={onNewChat}
75
+ >
76
+ <span>{newChatLabel}</span>
77
+ </ActionButton>
78
+ {canExpand && (
79
+ <IconButton
80
+ type="button"
81
+ className="an-chat-history-rail__disclosure"
82
+ size="compact"
83
+ icon={<IconDots size={14} strokeWidth={1.8} aria-hidden="true" />}
84
+ onPress={toggleExpanded}
85
+ aria-expanded={expanded}
86
+ label={disclosureLabel}
87
+ title={disclosureLabel}
88
+ />
89
+ )}
90
+ </div>
91
+ );
92
+
93
+ return (
94
+ <ChatHistoryList
95
+ {...rest}
96
+ items={visibleItems}
97
+ footer={footer}
98
+ emptyLabel={emptyLabel ?? null}
99
+ variant="rail"
100
+ className={["an-chat-history-rail", className].filter(Boolean).join(" ")}
101
+ />
102
+ );
103
+ }
104
+
105
+ /**
106
+ * Compact recent-chat rail for app sidebars. Hosts own thread persistence,
107
+ * sorting, routing, and mutations; the rail only owns progressive disclosure.
108
+ */
109
+ export function ChatHistoryRail({
110
+ items,
111
+ onNewChat,
112
+ railLabels,
113
+ previewCount = 5,
114
+ expandedCount = 15,
115
+ renderRail,
116
+ className,
117
+ emptyLabel,
118
+ ...listProps
119
+ }: ChatHistoryRailProps) {
120
+ const controller = useChatHistoryRailController({
121
+ items,
122
+ onNewChat,
123
+ labels: railLabels,
124
+ previewCount,
125
+ expandedCount,
126
+ });
127
+ const context: ChatHistoryRailRenderContext = {
128
+ controller,
129
+ listProps: { ...listProps, className, emptyLabel },
130
+ };
131
+ const fallback = <DefaultChatHistoryRailView {...context} />;
132
+
133
+ return renderRail ? (
134
+ <DesignSystemErrorBoundary component="ChatHistoryRail" fallback={fallback}>
135
+ <ChatHistoryRailCustomView renderRail={renderRail} context={context} />
136
+ </DesignSystemErrorBoundary>
137
+ ) : (
138
+ fallback
139
+ );
140
+ }
141
+
142
+ function ChatHistoryRailCustomView({
143
+ renderRail,
144
+ context,
145
+ }: {
146
+ renderRail: NonNullable<ChatHistoryRailProps["renderRail"]>;
147
+ context: ChatHistoryRailRenderContext;
148
+ }) {
149
+ return renderRail(context);
150
+ }
@@ -5,3 +5,17 @@ export {
5
5
  type ChatHistoryListProps,
6
6
  type ChatHistorySection,
7
7
  } from "./ChatHistoryList.js";
8
+ export {
9
+ ChatHistoryRail,
10
+ DefaultChatHistoryRailView,
11
+ type DefaultChatHistoryRailViewProps,
12
+ type ChatHistoryRailLabels,
13
+ type ChatHistoryRailProps,
14
+ type ChatHistoryRailRenderContext,
15
+ } from "./ChatHistoryRail.js";
16
+ export {
17
+ type ChatHistoryRailController,
18
+ type ChatHistoryRailControllerLabels,
19
+ type UseChatHistoryRailControllerOptions,
20
+ useChatHistoryRailController,
21
+ } from "./useChatHistoryRailController.js";
@@ -0,0 +1,131 @@
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
+ type ChatHistoryRailController,
9
+ type UseChatHistoryRailControllerOptions,
10
+ useChatHistoryRailController,
11
+ } from "./useChatHistoryRailController.js";
12
+
13
+ interface Item {
14
+ id: string;
15
+ }
16
+
17
+ const labels = {
18
+ newChat: "New chat",
19
+ showMore: "Show more chats",
20
+ showLess: "Show fewer chats",
21
+ };
22
+
23
+ function makeItems(count: number): Item[] {
24
+ return Array.from({ length: count }, (_, index) => ({
25
+ id: `thread-${index + 1}`,
26
+ }));
27
+ }
28
+
29
+ describe("useChatHistoryRailController", () => {
30
+ let container: HTMLDivElement;
31
+ let root: Root;
32
+ let controller: ChatHistoryRailController<Item>;
33
+
34
+ function Harness(props: UseChatHistoryRailControllerOptions<Item>) {
35
+ controller = useChatHistoryRailController(props);
36
+ return null;
37
+ }
38
+
39
+ function render(options: Partial<UseChatHistoryRailControllerOptions<Item>>) {
40
+ const props: UseChatHistoryRailControllerOptions<Item> = {
41
+ items: makeItems(20),
42
+ labels,
43
+ onNewChat: () => {},
44
+ ...options,
45
+ };
46
+ act(() => root.render(<Harness {...props} />));
47
+ }
48
+
49
+ beforeEach(() => {
50
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
51
+ container = document.createElement("div");
52
+ document.body.appendChild(container);
53
+ root = createRoot(container);
54
+ });
55
+
56
+ afterEach(() => {
57
+ act(() => root.unmount());
58
+ container.remove();
59
+ vi.restoreAllMocks();
60
+ vi.unstubAllGlobals();
61
+ });
62
+
63
+ it("progressively discloses the default five and fifteen item limits", () => {
64
+ const items = makeItems(20);
65
+ render({ items });
66
+
67
+ expect(controller.expanded).toBe(false);
68
+ expect(controller.collapsedLimit).toBe(5);
69
+ expect(controller.expandedLimit).toBe(15);
70
+ expect(controller.visibleItems).toEqual(items.slice(0, 5));
71
+ expect(controller.canExpand).toBe(true);
72
+ expect(controller.disclosureLabel).toBe("Show more chats");
73
+
74
+ act(() => controller.toggleExpanded());
75
+
76
+ expect(controller.expanded).toBe(true);
77
+ expect(controller.visibleItems).toEqual(items.slice(0, 15));
78
+ expect(controller.disclosureLabel).toBe("Show fewer chats");
79
+ });
80
+
81
+ it("clamps finite limits while preserving legacy disclosure behavior", () => {
82
+ render({ previewCount: -2.5, expandedCount: 1.9 });
83
+
84
+ expect(controller.collapsedLimit).toBe(1);
85
+ expect(controller.expandedLimit).toBe(1);
86
+ expect(controller.visibleItems).toHaveLength(1);
87
+ expect(controller.canExpand).toBe(true);
88
+
89
+ act(() => controller.toggleExpanded());
90
+ expect(controller.expanded).toBe(true);
91
+ });
92
+
93
+ it("uses defaults for non-finite limits and never puts expanded below preview", () => {
94
+ render({
95
+ previewCount: Number.NaN,
96
+ expandedCount: Number.POSITIVE_INFINITY,
97
+ });
98
+ expect(controller.collapsedLimit).toBe(5);
99
+ expect(controller.expandedLimit).toBe(15);
100
+
101
+ render({ previewCount: 8, expandedCount: 3 });
102
+ expect(controller.collapsedLimit).toBe(8);
103
+ expect(controller.expandedLimit).toBe(8);
104
+ });
105
+
106
+ it("resets disclosure explicitly and when the collection becomes too short", () => {
107
+ render({});
108
+ act(() => controller.toggleExpanded());
109
+ expect(controller.expanded).toBe(true);
110
+
111
+ act(() => controller.resetExpanded());
112
+ expect(controller.expanded).toBe(false);
113
+
114
+ act(() => controller.toggleExpanded());
115
+ expect(controller.expanded).toBe(true);
116
+
117
+ render({ items: makeItems(3) });
118
+ expect(controller.expanded).toBe(false);
119
+ expect(controller.canExpand).toBe(false);
120
+ expect(controller.visibleItems).toHaveLength(3);
121
+ });
122
+
123
+ it("exposes view labels and the host new-chat action without UI assumptions", () => {
124
+ const onNewChat = vi.fn();
125
+ render({ onNewChat });
126
+
127
+ expect(controller.newChatLabel).toBe("New chat");
128
+ act(() => controller.onNewChat());
129
+ expect(onNewChat).toHaveBeenCalledOnce();
130
+ });
131
+ });