@elabs-ai/components-ui 4.0.0 → 4.1.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 (223) hide show
  1. package/README.md +10 -8
  2. package/dist/chunk-JKPPTL63.js +48 -0
  3. package/dist/chunk-JKPPTL63.js.map +1 -0
  4. package/dist/form.d.ts +28 -0
  5. package/dist/form.js +123 -0
  6. package/dist/form.js.map +1 -0
  7. package/dist/index.d.ts +2485 -167
  8. package/dist/index.js +10716 -2706
  9. package/dist/index.js.map +1 -1
  10. package/package.json +21 -7
  11. package/src/_peer-deps.test.ts +59 -0
  12. package/src/blocks-comparison-table.stories.tsx +2 -2
  13. package/src/components/accordion/accordion.tsx +1 -1
  14. package/src/components/advanced-group/advanced-group.tsx +1 -1
  15. package/src/components/alert/alert.tsx +4 -1
  16. package/src/components/app-shell/app-shell.stories.tsx +44 -2
  17. package/src/components/app-shell/app-shell.tsx +35 -2
  18. package/src/components/app-sidebar/app-sidebar.stories.tsx +9 -1
  19. package/src/components/attribution-panel/attribution-panel.tsx +1 -1
  20. package/src/components/attribution-panel/attributions.generated.ts +81 -77
  21. package/src/components/badge/badge.stories.tsx +8 -0
  22. package/src/components/badge/badge.tsx +1 -1
  23. package/src/components/bento-grid/bento-grid.test.tsx +1 -1
  24. package/src/components/bento-grid/bento-grid.tsx +1 -1
  25. package/src/components/breadcrumb/breadcrumb.stories.tsx +38 -0
  26. package/src/components/breadcrumb/breadcrumb.test.tsx +24 -1
  27. package/src/components/breadcrumb/breadcrumb.tsx +7 -2
  28. package/src/components/button/button.stories.tsx +116 -8
  29. package/src/components/button/button.tsx +1 -1
  30. package/src/components/card/card.stories.tsx +11 -6
  31. package/src/components/card/card.tsx +1 -1
  32. package/src/components/change-review/change-review.stories.tsx +145 -1
  33. package/src/components/change-review/change-review.test.tsx +143 -0
  34. package/src/components/change-review/change-review.tsx +185 -4
  35. package/src/components/checkbox/checkbox.tsx +1 -1
  36. package/src/components/color-picker/color-picker.tsx +6 -5
  37. package/src/components/command/command.stories.tsx +16 -6
  38. package/src/components/command/command.test.tsx +73 -0
  39. package/src/components/command/command.tsx +30 -7
  40. package/src/components/command/index.ts +1 -0
  41. package/src/components/command-trigger/command-trigger.stories.tsx +39 -0
  42. package/src/components/command-trigger/command-trigger.test.tsx +18 -0
  43. package/src/components/command-trigger/command-trigger.tsx +70 -0
  44. package/src/components/command-trigger/index.ts +1 -0
  45. package/src/components/context-rail/context-rail.stories.tsx +285 -0
  46. package/src/components/context-rail/context-rail.test.tsx +263 -0
  47. package/src/components/context-rail/context-rail.tsx +678 -0
  48. package/src/components/context-rail/index.ts +1 -0
  49. package/src/components/copyable-value/copyable-value.tsx +1 -1
  50. package/src/components/dialog/dialog.tsx +2 -2
  51. package/src/components/dropdown-menu/dropdown-menu.stories.tsx +1 -1
  52. package/src/components/empty-state/empty-state.test.tsx +26 -0
  53. package/src/components/empty-state/empty-state.tsx +6 -1
  54. package/src/components/expand-dialog/expand-dialog.tsx +1 -2
  55. package/src/components/field/field-context.ts +59 -0
  56. package/src/components/field/field.stories.tsx +142 -0
  57. package/src/components/field/field.test.tsx +415 -0
  58. package/src/components/field/field.tsx +340 -0
  59. package/src/components/field/index.ts +9 -0
  60. package/src/components/field-row/field-row.stories.tsx +85 -1
  61. package/src/components/field-row/field-row.test.tsx +145 -0
  62. package/src/components/field-row/field-row.tsx +48 -6
  63. package/src/components/file-upload/file-upload.stories.tsx +16 -0
  64. package/src/components/file-upload/file-upload.test.tsx +7 -2
  65. package/src/components/file-upload/file-upload.tsx +15 -4
  66. package/src/components/form/form.tsx +5 -2
  67. package/src/components/icon-button/icon-button.test.tsx +1 -1
  68. package/src/components/icon-button/icon-button.tsx +2 -2
  69. package/src/components/input/input.stories.tsx +121 -0
  70. package/src/components/input/input.test.tsx +13 -0
  71. package/src/components/input/input.tsx +2 -2
  72. package/src/components/input-group/input-group.stories.tsx +74 -0
  73. package/src/components/input-group/input-group.tsx +13 -4
  74. package/src/components/input-otp/input-otp.tsx +5 -1
  75. package/src/components/keyboard-shortcuts/index.ts +6 -0
  76. package/src/components/keyboard-shortcuts/keyboard-shortcuts.stories.tsx +177 -0
  77. package/src/components/keyboard-shortcuts/keyboard-shortcuts.test.tsx +73 -0
  78. package/src/components/keyboard-shortcuts/keyboard-shortcuts.tsx +203 -0
  79. package/src/components/link-preview/link-preview.tsx +1 -1
  80. package/src/components/locale-provider/index.ts +6 -1
  81. package/src/components/locale-provider/locale-provider.stories.tsx +89 -1
  82. package/src/components/locale-provider/locale-provider.test.tsx +257 -1
  83. package/src/components/locale-provider/locale-provider.tsx +63 -8
  84. package/src/components/locale-provider/messages.ts +501 -2
  85. package/src/components/match-highlight/match-highlight.stories.tsx +1 -1
  86. package/src/components/mention-input/mention-input-mirror.stories.tsx +9 -1
  87. package/src/components/mention-input/mention-input.tsx +8 -38
  88. package/src/components/mention-input/mention-value.ts +8 -2
  89. package/src/components/metric-card/metric-card.stories.tsx +5 -3
  90. package/src/components/metric-card/metric-card.tsx +21 -2
  91. package/src/components/model-picker/model-picker.stories.tsx +63 -3
  92. package/src/components/model-picker/model-picker.test.tsx +63 -0
  93. package/src/components/model-picker/model-picker.tsx +62 -36
  94. package/src/components/nav-main/nav-main.tsx +1 -1
  95. package/src/components/nav-notifications/nav-notifications.stories.tsx +110 -0
  96. package/src/components/nav-notifications/nav-notifications.tsx +57 -5
  97. package/src/components/navigation-menu/navigation-menu.stories.tsx +22 -0
  98. package/src/components/navigation-menu/navigation-menu.test.tsx +174 -0
  99. package/src/components/navigation-menu/navigation-menu.tsx +189 -12
  100. package/src/components/page-shell/page-shell.stories.tsx +163 -0
  101. package/src/components/page-shell/page-shell.test.tsx +229 -0
  102. package/src/components/page-shell/page-shell.tsx +108 -25
  103. package/src/components/pagination/pagination.test.tsx +90 -0
  104. package/src/components/pagination/pagination.tsx +9 -5
  105. package/src/components/radio-group/radio-group.tsx +1 -1
  106. package/src/components/rating/rating.tsx +1 -1
  107. package/src/components/resizable/resizable.stories.tsx +114 -2
  108. package/src/components/resizable/resizable.tsx +1 -1
  109. package/src/components/revision-timeline/revision-timeline.stories.tsx +8 -1
  110. package/src/components/revision-timeline/revision-timeline.test.tsx +26 -0
  111. package/src/components/revision-timeline/revision-timeline.tsx +46 -3
  112. package/src/components/schema-form/from-json-schema.test.ts +390 -0
  113. package/src/components/schema-form/from-json-schema.ts +375 -0
  114. package/src/components/schema-form/index.ts +81 -0
  115. package/src/components/schema-form/schema-form-spec.ts +698 -0
  116. package/src/components/schema-form/schema-form.stories.tsx +566 -0
  117. package/src/components/schema-form/schema-form.test.tsx +900 -0
  118. package/src/components/schema-form/schema-form.tsx +1545 -0
  119. package/src/components/section-header/section-header.stories.tsx +37 -0
  120. package/src/components/section-header/section-header.tsx +12 -1
  121. package/src/components/select/select.tsx +6 -2
  122. package/src/components/sheet/sheet.tsx +1 -1
  123. package/src/components/side-dock/index.ts +1 -0
  124. package/src/components/side-dock/side-dock.stories.tsx +137 -0
  125. package/src/components/side-dock/side-dock.test.tsx +273 -0
  126. package/src/components/side-dock/side-dock.tsx +502 -0
  127. package/src/components/sidebar/index.ts +1 -0
  128. package/src/components/sidebar/sidebar-frame.test.tsx +162 -0
  129. package/src/components/sidebar/sidebar.stories.tsx +421 -1
  130. package/src/components/sidebar/sidebar.test.tsx +36 -1
  131. package/src/components/sidebar/sidebar.tsx +245 -33
  132. package/src/components/skip-link/index.ts +1 -0
  133. package/src/components/skip-link/skip-link.stories.tsx +106 -0
  134. package/src/components/skip-link/skip-link.test.tsx +20 -0
  135. package/src/components/skip-link/skip-link.tsx +34 -0
  136. package/src/components/slider/slider.tsx +1 -1
  137. package/src/components/split-panel/index.ts +6 -1
  138. package/src/components/split-panel/split-panel.stories.tsx +18 -2
  139. package/src/components/split-panel/split-panel.test.tsx +20 -0
  140. package/src/components/split-panel/split-panel.tsx +28 -8
  141. package/src/components/state-panel/state-panel.stories.tsx +52 -0
  142. package/src/components/state-panel/state-panel.test.tsx +125 -0
  143. package/src/components/state-panel/state-panel.tsx +96 -17
  144. package/src/components/status-badge/status-badge.stories.tsx +8 -0
  145. package/src/components/switch/switch.tsx +1 -1
  146. package/src/components/table/table.stories.tsx +62 -0
  147. package/src/components/table/table.test.tsx +142 -1
  148. package/src/components/table/table.tsx +60 -1
  149. package/src/components/tabs/tabs.tsx +8 -2
  150. package/src/components/tag-input/tag-input.tsx +3 -2
  151. package/src/components/team-switcher/team-switcher.tsx +34 -5
  152. package/src/components/textarea/textarea.tsx +1 -1
  153. package/src/components/theme-switcher/theme-switcher.tsx +6 -2
  154. package/src/components/theme-switcher/use-theme-transition.ts +2 -2
  155. package/src/components/timeline/timeline.stories.tsx +27 -0
  156. package/src/components/timeline/timeline.tsx +11 -1
  157. package/src/components/toggle/toggle.tsx +1 -1
  158. package/src/components/toolbar/toolbar.stories.tsx +10 -2
  159. package/src/components/tree/tree.stories.tsx +2 -2
  160. package/src/components/tree/tree.tsx +14 -9
  161. package/src/components/typography/prose.tsx +1 -1
  162. package/src/components/typography/typography.stories.tsx +228 -0
  163. package/src/components/view-toolbar/view-toolbar.stories.tsx +41 -1
  164. package/src/components/view-toolbar/view-toolbar.test.tsx +33 -0
  165. package/src/components/view-toolbar/view-toolbar.tsx +37 -4
  166. package/src/components/virtual-select/virtual-select.test.tsx +1 -1
  167. package/src/components/wizard/wizard.tsx +1 -1
  168. package/src/components/workspace-picker/index.ts +3 -0
  169. package/src/components/workspace-picker/workspace-picker-state.ts +50 -0
  170. package/src/components/workspace-picker/workspace-picker.stories.tsx +126 -0
  171. package/src/components/workspace-picker/workspace-picker.test.tsx +207 -0
  172. package/src/components/workspace-picker/workspace-picker.tsx +172 -0
  173. package/src/illustrations/empty-list-illustration.tsx +26 -0
  174. package/src/illustrations/error-illustration.tsx +60 -0
  175. package/src/illustrations/first-run-illustration.tsx +27 -0
  176. package/src/illustrations/illustration-base.tsx +108 -0
  177. package/src/illustrations/illustrations.stories.tsx +94 -0
  178. package/src/illustrations/illustrations.test.tsx +98 -0
  179. package/src/illustrations/index.ts +16 -0
  180. package/src/illustrations/no-access-illustration.tsx +45 -0
  181. package/src/illustrations/no-results-illustration.tsx +46 -0
  182. package/src/illustrations/offline-illustration.tsx +37 -0
  183. package/src/illustrations/success-illustration.tsx +40 -0
  184. package/src/index.ts +75 -2
  185. package/src/lib/agent-event-model.test.ts +12 -0
  186. package/src/lib/agent-event-model.ts +42 -0
  187. package/src/lib/approval-option.test.ts +17 -0
  188. package/src/lib/approval-option.ts +53 -0
  189. package/src/lib/check-result.ts +33 -0
  190. package/src/lib/diff-line.test.ts +36 -0
  191. package/src/lib/diff-line.ts +63 -0
  192. package/src/lib/diff-rows.test.ts +76 -0
  193. package/src/lib/diff-rows.ts +92 -0
  194. package/src/lib/format-duration.test.ts +22 -0
  195. package/src/lib/format-duration.ts +30 -0
  196. package/src/lib/has-renderable-content.test.ts +49 -0
  197. package/src/lib/has-renderable-content.ts +32 -0
  198. package/src/lib/operating-mode.test.ts +36 -0
  199. package/src/lib/operating-mode.ts +81 -0
  200. package/src/lib/optional-peer.ts +59 -0
  201. package/src/lib/session-launch.ts +37 -0
  202. package/src/lib/slash-command.test.ts +44 -0
  203. package/src/lib/slash-command.ts +45 -0
  204. package/src/lib/trigger-query.test.ts +94 -0
  205. package/src/lib/trigger-query.ts +88 -0
  206. package/src/lib/use-mobile.ts +12 -6
  207. package/src/motion.stories.tsx +6 -0
  208. package/src/templates-object-detail-hub.stories.tsx +2 -2
  209. package/src/templates-screen-states.stories.tsx +5 -5
  210. package/src/templates-settings.stories.tsx +1 -1
  211. package/src/blocks/sidebar-02/app-sidebar.tsx +0 -160
  212. package/src/blocks/sidebar-02/logo.tsx +0 -18
  213. package/src/blocks/sidebar-02/nav-main.tsx +0 -13
  214. package/src/blocks/sidebar-02/nav-notifications.tsx +0 -7
  215. package/src/blocks/sidebar-02/sidebar-02.stories.tsx +0 -23
  216. package/src/blocks/sidebar-02/team-switcher.tsx +0 -7
  217. package/src/blocks/sidebar-04/app-sidebar.tsx +0 -236
  218. package/src/blocks/sidebar-04/mail-context.tsx +0 -29
  219. package/src/blocks/sidebar-04/nav-user.tsx +0 -6
  220. package/src/blocks/sidebar-04/sidebar-04.stories.tsx +0 -150
  221. package/src/blocks/sidebar-05/app-sidebar.tsx +0 -375
  222. package/src/blocks/sidebar-05/sidebar-05.stories.tsx +0 -18
  223. package/src/blocks/sidebar-05/team-switcher.tsx +0 -6
@@ -0,0 +1,207 @@
1
+ import { cleanup, render, screen } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
+ import { WorkspacePicker } from "./workspace-picker";
5
+ import { formatLastOpened } from "./workspace-picker-state";
6
+ import type { Workspace } from "./workspace-picker-state";
7
+
8
+ afterEach(cleanup);
9
+
10
+ // cmdk calls Element.scrollIntoView to keep the auto-highlighted item visible.
11
+ // jsdom doesn't implement it and this package's vitest setup deliberately ships
12
+ // no global stub, so third-party libraries that don't feature-detect get a
13
+ // local, scoped stub — the same pattern as model-picker.test.tsx and
14
+ // combobox.test.tsx.
15
+ let originalScrollIntoView: typeof Element.prototype.scrollIntoView;
16
+ beforeEach(() => {
17
+ originalScrollIntoView = Element.prototype.scrollIntoView;
18
+ Element.prototype.scrollIntoView = vi.fn();
19
+ });
20
+ afterEach(() => {
21
+ Element.prototype.scrollIntoView = originalScrollIntoView;
22
+ });
23
+
24
+ // No `LocaleProvider` wrapper: `useLocale()` falls back to the shipped English
25
+ // defaults, so these assertions prove the real `ui.workspacePicker.*` catalogue in
26
+ // `packages/ui/src/components/locale-provider/messages.ts` rather than a local stub
27
+ // that would keep passing if the central entry were missing or misspelled.
28
+ function renderPicker(ui: React.ReactElement) {
29
+ return render(ui);
30
+ }
31
+
32
+ const workspaces: Workspace[] = [
33
+ {
34
+ id: "ws-1",
35
+ name: "elabs-components",
36
+ path: "~/dev/elabs-components",
37
+ lastOpenedAt: new Date(Date.now() - 2 * 60 * 60 * 1000),
38
+ },
39
+ {
40
+ id: "ws-2",
41
+ name: "brainless",
42
+ path: "~/dev/brainless",
43
+ },
44
+ ];
45
+
46
+ const open = async () => {
47
+ await userEvent.click(screen.getAllByRole("combobox")[0]!);
48
+ };
49
+
50
+ describe("formatLastOpened", () => {
51
+ const now = Date.parse("2026-09-01T12:00:00.000Z");
52
+
53
+ it("picks minutes for recent times", () => {
54
+ expect(formatLastOpened(new Date(now - 5 * 60 * 1000), "en-US", now)).toBe("5 minutes ago");
55
+ });
56
+
57
+ it("picks hours once minutes would read awkwardly large", () => {
58
+ expect(formatLastOpened(new Date(now - 3 * 60 * 60 * 1000), "en-US", now)).toBe("3 hours ago");
59
+ });
60
+
61
+ it("picks days once hours would read awkwardly large", () => {
62
+ expect(formatLastOpened(new Date(now - 2 * 24 * 60 * 60 * 1000), "en-US", now)).toBe(
63
+ "2 days ago",
64
+ );
65
+ });
66
+
67
+ it("formats a future time as relative-forward", () => {
68
+ expect(formatLastOpened(new Date(now + 10 * 60 * 1000), "en-US", now)).toBe("in 10 minutes");
69
+ });
70
+ });
71
+
72
+ describe("WorkspacePicker", () => {
73
+ it("renders each workspace's path as truncating secondary text", async () => {
74
+ renderPicker(<WorkspacePicker workspaces={workspaces} />);
75
+ await open();
76
+ const description = screen.getByText("~/dev/elabs-components");
77
+ expect(description).toBeInTheDocument();
78
+ expect(description).toHaveClass("truncate");
79
+ });
80
+
81
+ it("marks the current workspace in the row's accessible name, not only by icon", async () => {
82
+ renderPicker(<WorkspacePicker workspaces={workspaces} currentId="ws-1" />);
83
+ await open();
84
+ const current = screen.getByRole("option", { name: /elabs-components/ });
85
+ const other = screen.getByRole("option", { name: /brainless/ });
86
+ expect(current).toHaveAccessibleName(/current/i);
87
+ expect(other).not.toHaveAccessibleName(/current/i);
88
+ });
89
+
90
+ it("names the trigger from the current workspace, and falls back to a placeholder", async () => {
91
+ const { unmount } = renderPicker(<WorkspacePicker workspaces={workspaces} currentId="ws-1" />);
92
+ expect(screen.getByRole("combobox")).toHaveTextContent("elabs-components");
93
+ unmount();
94
+
95
+ renderPicker(<WorkspacePicker workspaces={workspaces} />);
96
+ expect(screen.getByRole("combobox")).toHaveTextContent("No workspace selected");
97
+ });
98
+
99
+ it("reports the whole Workspace object on selection, not just the id", async () => {
100
+ const onSelect = vi.fn();
101
+ renderPicker(<WorkspacePicker workspaces={workspaces} onSelect={onSelect} />);
102
+ await open();
103
+ await userEvent.click(screen.getByText("brainless"));
104
+ expect(onSelect).toHaveBeenCalledTimes(1);
105
+ expect(onSelect).toHaveBeenCalledWith(workspaces[1]);
106
+ });
107
+
108
+ it("shows a designed empty state whose one action is the free-text path entry", async () => {
109
+ renderPicker(<WorkspacePicker workspaces={[]} onSubmitPath={vi.fn()} />);
110
+ await open();
111
+ // ModelPicker's own empty panel — reused, not reimplemented.
112
+ expect(screen.getByText("Nothing to show yet")).toBeInTheDocument();
113
+ expect(screen.queryByRole("option")).not.toBeInTheDocument();
114
+ // The action: the free-text path entry always renders, even with zero rows.
115
+ expect(screen.getByPlaceholderText("/path/to/project…")).toBeInTheDocument();
116
+ });
117
+
118
+ it("validates non-empty free text before reporting the raw string", async () => {
119
+ const onSubmitPath = vi.fn();
120
+ renderPicker(<WorkspacePicker workspaces={workspaces} onSubmitPath={onSubmitPath} />);
121
+ await open();
122
+ const submit = screen.getByRole("button", { name: "Open" });
123
+
124
+ await userEvent.click(submit);
125
+ expect(onSubmitPath).not.toHaveBeenCalled();
126
+
127
+ const input = screen.getByPlaceholderText("/path/to/project…");
128
+ await userEvent.type(input, " ");
129
+ await userEvent.click(submit);
130
+ expect(onSubmitPath).not.toHaveBeenCalled();
131
+
132
+ await userEvent.clear(input);
133
+ await userEvent.type(input, "/tmp/new-workspace");
134
+ await userEvent.click(submit);
135
+ expect(onSubmitPath).toHaveBeenCalledTimes(1);
136
+ expect(onSubmitPath).toHaveBeenCalledWith("/tmp/new-workspace");
137
+ });
138
+
139
+ it("performs no filesystem access — onSubmitPath is the only side effect of a submit", async () => {
140
+ const onSubmitPath = vi.fn();
141
+ const onSelect = vi.fn();
142
+ renderPicker(
143
+ <WorkspacePicker workspaces={workspaces} onSubmitPath={onSubmitPath} onSelect={onSelect} />,
144
+ );
145
+ await open();
146
+ const input = screen.getByPlaceholderText("/path/to/project…");
147
+ await userEvent.type(input, "/tmp/new-workspace");
148
+ await userEvent.click(screen.getByRole("button", { name: "Open" }));
149
+ expect(onSubmitPath).toHaveBeenCalledWith("/tmp/new-workspace");
150
+ expect(onSelect).not.toHaveBeenCalled();
151
+ // The field clears after a successful submit.
152
+ expect(input).toHaveValue("");
153
+ });
154
+
155
+ it("submits the path on Enter instead of cmdk hijacking it as list navigation", async () => {
156
+ const onSubmitPath = vi.fn();
157
+ const onSelect = vi.fn();
158
+ renderPicker(
159
+ <WorkspacePicker workspaces={workspaces} onSubmitPath={onSubmitPath} onSelect={onSelect} />,
160
+ );
161
+ await open();
162
+ const input = screen.getByPlaceholderText("/path/to/project…");
163
+ await userEvent.type(input, "/tmp/other-workspace{Enter}");
164
+ expect(onSubmitPath).toHaveBeenCalledWith("/tmp/other-workspace");
165
+ expect(onSelect).not.toHaveBeenCalled();
166
+ });
167
+
168
+ it("truncates a very long path without overflow", async () => {
169
+ const longPath = "~/dev/" + "very-long-segment/".repeat(20) + "elabs-components";
170
+ renderPicker(
171
+ <WorkspacePicker workspaces={[{ id: "ws-long", name: "long-one", path: longPath }]} />,
172
+ );
173
+ await open();
174
+ const description = screen.getByText(longPath);
175
+ expect(description).toHaveClass("truncate");
176
+ // The truncating span is a min-w-0 flex child — the min-w-0 + flex-1 rule
177
+ // that lets truncate actually clip instead of overflowing.
178
+ expect(description.parentElement).toHaveClass("min-w-0");
179
+ });
180
+
181
+ it("uses the caller's explicit aria-label over the default", async () => {
182
+ renderPicker(<WorkspacePicker workspaces={workspaces} aria-label="Session workspace" />);
183
+ expect(screen.getByRole("combobox")).toHaveAccessibleName("Session workspace");
184
+ });
185
+
186
+ it("renders the shipped English default with no LocaleProvider mounted", () => {
187
+ // No provider: `t()` falls back to `DEFAULT_MESSAGES[key] ?? key`. The keys
188
+ // landed in messages.ts with this component, so the fallback is now real
189
+ // microcopy — a regression that dropped them would surface here as the raw
190
+ // key string instead.
191
+ render(<WorkspacePicker workspaces={workspaces} />);
192
+ expect(screen.getByRole("combobox")).toHaveTextContent("No workspace selected");
193
+ });
194
+
195
+ it("scopes the workspace-picker data-slot to the root, distinct from model-picker", async () => {
196
+ const { container } = renderPicker(<WorkspacePicker workspaces={workspaces} />);
197
+ expect(container.querySelector('[data-slot="workspace-picker"]')).toBeInTheDocument();
198
+ expect(container.querySelector('[data-slot="model-picker"]')).not.toBeInTheDocument();
199
+ });
200
+
201
+ it("renders the free-text path form outside CommandList — the search query cannot hide it", async () => {
202
+ renderPicker(<WorkspacePicker workspaces={workspaces} />);
203
+ await open();
204
+ await userEvent.type(screen.getByPlaceholderText("Search workspaces…"), "no-match-xyz");
205
+ expect(screen.getByPlaceholderText("/path/to/project…")).toBeInTheDocument();
206
+ });
207
+ });
@@ -0,0 +1,172 @@
1
+ "use client";
2
+
3
+ import { forwardRef, useState, type FormEvent, type HTMLAttributes } from "react";
4
+ import { cn } from "../../lib/cn";
5
+ import { Button } from "../button";
6
+ import { Input } from "../input";
7
+ import { useLocale } from "../locale-provider";
8
+ import { ModelPicker, type ModelPickerGroup, type ModelPickerStatus } from "../model-picker";
9
+ import { formatLastOpened, type Workspace } from "./workspace-picker-state";
10
+
11
+ export type { Workspace };
12
+
13
+ export interface WorkspacePickerProps extends Omit<HTMLAttributes<HTMLDivElement>, "onSelect"> {
14
+ /** Recent workspaces, most-relevant first — the caller owns ordering. */
15
+ workspaces: Workspace[];
16
+ /** The currently open workspace's id. */
17
+ currentId?: string;
18
+ /** Fires with the chosen workspace; the popover closes first. */
19
+ onSelect?: (workspace: Workspace) => void;
20
+ /**
21
+ * Free-text path entry, rendered in `ModelPicker`'s `footer` slot below the
22
+ * list. Fires with the raw string the user typed once it validates as
23
+ * non-empty — this component performs no filesystem access of any kind
24
+ * (presentation layer, D5); resolving, normalizing or checking the path is
25
+ * the caller's job.
26
+ */
27
+ onSubmitPath?: (path: string) => void;
28
+ /**
29
+ * Drives the same four-body state grid as `ModelPicker` — see
30
+ * `ModelPickerStatus`. Default `"ready"`, inherited from `ModelPicker`.
31
+ */
32
+ status?: ModelPickerStatus;
33
+ disabled?: boolean;
34
+ /** Accessible name for the trigger and the popover — what the control is FOR. */
35
+ "aria-label"?: string;
36
+ /** Controlled popover open state — see `ModelPicker`'s controlled contract. */
37
+ open?: boolean;
38
+ onOpenChange?: (open: boolean) => void;
39
+ }
40
+
41
+ /**
42
+ * Recent-workspace switcher — issue #111, decided in
43
+ * `docs/decisions/2026-09-01-brainless-adoption-architecture.md` § 7.
44
+ *
45
+ * A `ModelPicker` PRESET, not a new picker: `ModelPickerItem`'s
46
+ * `id`/`label`/`description`/`meta` shape is already a superset of what a
47
+ * workspace needs, so this component only supplies workspace-shaped
48
+ * defaults — it maps `path` into the truncating `description` slot, formats
49
+ * `lastOpenedAt` with `Intl.RelativeTimeFormat`, marks the current workspace
50
+ * in the row's TEXT (never colour alone — `ModelPicker`'s check glyph is
51
+ * `aria-hidden` and cannot carry that on its own), and renders the free-text
52
+ * path entry into `ModelPicker`'s `footer` slot so it sits below the list,
53
+ * un-filtered by the search query.
54
+ *
55
+ * Unlike `TeamSwitcher`, this has no `Sidebar` dependency — it works anywhere
56
+ * a picker is needed (a session launch card, a command palette), not only
57
+ * inside a sidebar.
58
+ */
59
+ export const WorkspacePicker = forwardRef<HTMLDivElement, WorkspacePickerProps>(
60
+ function WorkspacePicker(
61
+ {
62
+ workspaces,
63
+ currentId,
64
+ onSelect,
65
+ onSubmitPath,
66
+ status,
67
+ disabled,
68
+ className,
69
+ open,
70
+ onOpenChange,
71
+ "aria-label": ariaLabelProp,
72
+ ...props
73
+ },
74
+ ref,
75
+ ) {
76
+ const { t, locale } = useLocale();
77
+ const [path, setPath] = useState("");
78
+
79
+ const current = workspaces.find((workspace) => workspace.id === currentId);
80
+ const triggerLabel = current?.name ?? t("ui.workspacePicker.placeholder");
81
+ const ariaLabel = ariaLabelProp ?? t("ui.workspacePicker.label");
82
+
83
+ const group: ModelPickerGroup = {
84
+ key: "workspaces",
85
+ label: t("ui.workspacePicker.recent"),
86
+ items: workspaces.map((workspace) => {
87
+ const isCurrent = workspace.id === currentId;
88
+ const meta: string[] = [];
89
+ if (workspace.lastOpenedAt) {
90
+ meta.push(formatLastOpened(workspace.lastOpenedAt, locale));
91
+ }
92
+ // Marked in TEXT so the marker reaches the row's accessible name and
93
+ // survives greyscale (accessibility.md's colour-is-never-the-only-
94
+ // channel test) — ModelPicker's own check glyph is `aria-hidden` and
95
+ // cannot carry this alone.
96
+ if (isCurrent) meta.push(t("ui.workspacePicker.current"));
97
+ return {
98
+ id: workspace.id,
99
+ label: workspace.name,
100
+ description: workspace.path,
101
+ meta,
102
+ };
103
+ }),
104
+ };
105
+
106
+ const handleSelect = (item: { id: string }) => {
107
+ const workspace = workspaces.find((w) => w.id === item.id);
108
+ if (workspace) onSelect?.(workspace);
109
+ };
110
+
111
+ const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
112
+ event.preventDefault();
113
+ if (path.trim() === "") return;
114
+ onSubmitPath?.(path);
115
+ setPath("");
116
+ };
117
+
118
+ return (
119
+ <ModelPicker
120
+ ref={ref}
121
+ data-slot="workspace-picker"
122
+ className={cn(className)}
123
+ groups={[group]}
124
+ value={currentId}
125
+ onSelect={handleSelect}
126
+ status={status}
127
+ triggerLabel={triggerLabel}
128
+ aria-label={ariaLabel}
129
+ searchPlaceholder={t("ui.workspacePicker.searchPlaceholder")}
130
+ disabled={disabled}
131
+ open={open}
132
+ onOpenChange={onOpenChange}
133
+ footer={
134
+ // Outside `CommandList` on purpose (ModelPicker's own contract): a
135
+ // persistent action row, never filtered by the search query and
136
+ // never part of cmdk's roving selection.
137
+ //
138
+ // `onKeyDown` stops propagation because cmdk binds ITS OWN keydown
139
+ // handler on the `Command` root and reacts to Enter/Home/End/
140
+ // ArrowUp/ArrowDown regardless of which descendant is focused —
141
+ // without this, pressing Enter here would move the list's
142
+ // highlight instead of submitting the path.
143
+ <form
144
+ data-slot="workspace-picker-path-form"
145
+ onSubmit={handleSubmit}
146
+ onKeyDown={(event) => event.stopPropagation()}
147
+ className="flex items-center gap-2 border-t p-2"
148
+ >
149
+ <Input
150
+ data-slot="workspace-picker-path-input"
151
+ value={path}
152
+ onChange={(event) => setPath(event.target.value)}
153
+ placeholder={t("ui.workspacePicker.pathPlaceholder")}
154
+ aria-label={t("ui.workspacePicker.pathLabel")}
155
+ className="h-8 min-w-0 flex-1"
156
+ />
157
+ <Button
158
+ type="submit"
159
+ size="sm"
160
+ variant="outline"
161
+ aria-disabled={path.trim() === ""}
162
+ data-slot="workspace-picker-path-submit"
163
+ >
164
+ {t("ui.workspacePicker.openPath")}
165
+ </Button>
166
+ </form>
167
+ }
168
+ {...props}
169
+ />
170
+ );
171
+ },
172
+ );
@@ -0,0 +1,26 @@
1
+ import { forwardRef } from "react";
2
+ import { illustrationSvgProps, type IllustrationProps } from "./illustration-base";
3
+
4
+ /**
5
+ * Empty list / zero-data-yet state. Three line-art rows read as "empty list"
6
+ * on their own silhouette — no accent mark, per the accent-semantics
7
+ * convention in `illustration-base.tsx` (#48 finding 1): a floating "add"
8
+ * badge here would repeat information the rows already supply, not add any.
9
+ */
10
+ export const EmptyListIllustration = forwardRef<SVGSVGElement, IllustrationProps>(
11
+ function EmptyListIllustration({ size, className, ...props }, ref) {
12
+ return (
13
+ <svg
14
+ ref={ref}
15
+ {...illustrationSvgProps(size, className)}
16
+ data-slot="empty-list-illustration"
17
+ {...props}
18
+ >
19
+ <rect x="36" y="68" width="64" height="10" rx="5" />
20
+ <rect x="36" y="90" width="64" height="10" rx="5" />
21
+ <rect x="36" y="112" width="44" height="10" rx="5" />
22
+ </svg>
23
+ );
24
+ },
25
+ );
26
+ EmptyListIllustration.displayName = "EmptyListIllustration";
@@ -0,0 +1,60 @@
1
+ import { forwardRef } from "react";
2
+ import {
3
+ illustrationSvgProps,
4
+ illustrationAccent,
5
+ type IllustrationProps,
6
+ } from "./illustration-base";
7
+
8
+ /**
9
+ * Recoverable-failure state. A line-art document split into two halves with a
10
+ * visible gap between them — distinguishable from `NoAccessIllustration`/
11
+ * `OfflineIllustration` by silhouette alone (a split document reads as
12
+ * "broken" even in greyscale), per the WCAG 1.4.1 "never color-only" rule.
13
+ * The exclamation mark IS meaning-bearing (per the accent-semantics
14
+ * convention in `illustration-base.tsx`, #48 finding 1) — it turns "torn
15
+ * document" into "warning", information the silhouette alone doesn't carry —
16
+ * so it stays, in the upper-right quadrant of the right-hand half, clear of
17
+ * both documents' outlines.
18
+ *
19
+ * Redrawn for #24 fix round 1 (P1-3) — the previous cracked-card + tiny alert
20
+ * triangle read as a lightning bolt or a broken-image placeholder and the
21
+ * triangle's exclamation collapsed to an illegible blob at 4rem.
22
+ *
23
+ * Redrawn again for #48 finding 3 — the mark previously sat at (76-84,
24
+ * 54-95), exactly where the two document halves' torn edges converge, so it
25
+ * read as a knot of overlapping lines rather than a "!". Moved to
26
+ * (98-113, 44-85), 8-15 units clear of the nearest tear line at every
27
+ * y-coordinate it spans.
28
+ */
29
+ export const ErrorIllustration = forwardRef<SVGSVGElement, IllustrationProps>(
30
+ function ErrorIllustration({ size, className, ...props }, ref) {
31
+ return (
32
+ <svg
33
+ ref={ref}
34
+ {...illustrationSvgProps(size, className)}
35
+ data-slot="error-illustration"
36
+ {...props}
37
+ >
38
+ <path d="M36,32 L83.06,32 L71.29,55.16 L92.47,69.06 L66.59,92.24 L76,120 L36,120 Z" />
39
+ <path d="M124,36 L124,124 L84,124 L74.59,96.24 L100.47,73.06 L79.29,59.16 L91.06,36 Z" />
40
+ <rect
41
+ x="104"
42
+ y="46"
43
+ width="8"
44
+ height="24"
45
+ rx="4"
46
+ style={{ fill: illustrationAccent("var(--destructive-text)") }}
47
+ stroke="none"
48
+ />
49
+ <circle
50
+ cx="108"
51
+ cy="80"
52
+ r="5"
53
+ style={{ fill: illustrationAccent("var(--destructive-text)") }}
54
+ stroke="none"
55
+ />
56
+ </svg>
57
+ );
58
+ },
59
+ );
60
+ ErrorIllustration.displayName = "ErrorIllustration";
@@ -0,0 +1,27 @@
1
+ import { forwardRef } from "react";
2
+ import { illustrationSvgProps, type IllustrationProps } from "./illustration-base";
3
+
4
+ /**
5
+ * First-run / onboarding state. A line-art planted flag — reads as "start
6
+ * here" on its own silhouette, distinct from `SuccessIllustration`. No
7
+ * accent mark, per the accent-semantics convention in `illustration-base.tsx`
8
+ * (#48 finding 1): a decorative sparkle here would repeat information the
9
+ * flag already supplies, not add any.
10
+ */
11
+ export const FirstRunIllustration = forwardRef<SVGSVGElement, IllustrationProps>(
12
+ function FirstRunIllustration({ size, className, ...props }, ref) {
13
+ return (
14
+ <svg
15
+ ref={ref}
16
+ {...illustrationSvgProps(size, className)}
17
+ data-slot="first-run-illustration"
18
+ {...props}
19
+ >
20
+ <line x1="64" y1="122" x2="64" y2="46" />
21
+ <path d="M64,46 L100,54 L86,64 L100,74 L64,82 Z" strokeLinejoin="round" />
22
+ <line x1="36" y1="122" x2="90" y2="122" />
23
+ </svg>
24
+ );
25
+ },
26
+ );
27
+ FirstRunIllustration.displayName = "FirstRunIllustration";
@@ -0,0 +1,108 @@
1
+ import type { SVGProps } from "react";
2
+ import { cn } from "../lib/cn";
3
+
4
+ /**
5
+ * Shared 160×160 canvas every illustration draws on. Keeping one constant
6
+ * means every illustration's hand-authored coordinates line up on the same
7
+ * grid — every subject's ink is drawn to fill an ~88×88 box centered at
8
+ * (80, 78) (#24 fix round 1, P1-1), so the seven read as one optically-sized
9
+ * family instead of varying 2.1× against each other.
10
+ */
11
+ export const ILLUSTRATION_VIEW_BOX = "0 0 160 160";
12
+
13
+ export interface IllustrationProps extends SVGProps<SVGSVGElement> {
14
+ /**
15
+ * Rendered width & height (any CSS length — pass a `rem` value to stay
16
+ * density/zoom-aware). Illustrations are drawn to read clearly from about
17
+ * `4rem` (64px) up to `10rem` (160px).
18
+ * @default "7rem"
19
+ */
20
+ size?: string | number;
21
+ }
22
+
23
+ /**
24
+ * The `<svg>`-level attributes every illustration shares: canvas, stroke
25
+ * defaults (`currentColor`, so it themes with whatever text color the caller
26
+ * or ambient context applies — e.g. `StatePanel` tints the error kind's slot
27
+ * `text-destructive`), rem-based sizing, and decorative a11y (`aria-hidden`,
28
+ * `role="presentation"`) — every illustration is decorative; the state's
29
+ * title/description text is the accessible content, never the artwork.
30
+ *
31
+ * Deliberately does NOT include `data-slot` — each illustration file declares
32
+ * its own `data-slot="<name>-illustration"` literal alongside this spread, so
33
+ * the stable-selector convention (`.claude/rules/component-api.md`) is
34
+ * visible per-component, not hidden behind a shared factory.
35
+ */
36
+ export function illustrationSvgProps(size: string | number = "7rem", className?: string) {
37
+ return {
38
+ viewBox: ILLUSTRATION_VIEW_BOX,
39
+ width: size,
40
+ height: size,
41
+ fill: "none",
42
+ stroke: "currentColor",
43
+ strokeWidth: 4,
44
+ strokeLinecap: "round" as const,
45
+ strokeLinejoin: "round" as const,
46
+ "aria-hidden": true as const,
47
+ role: "presentation" as const,
48
+ focusable: "false" as const,
49
+ className: cn("shrink-0", className),
50
+ };
51
+ }
52
+
53
+ /**
54
+ * Accent-semantics convention (#48 finding 1): an illustration draws at most
55
+ * ONE accent mark (via `illustrationAccent()` below), and it exists ONLY
56
+ * when it carries information the silhouette (the `currentColor`-stroked
57
+ * subject) does not already supply on its own — never as ornamental
58
+ * flourish wearing a meaning-bearing token. Applied across the seven
59
+ * shipped illustrations:
60
+ * - **Meaning-bearing (kept):** the "×" that turns a magnifying glass into
61
+ * "no results" (`NoResultsIllustration`), the keyhole that turns a padlock
62
+ * body into "restricted" (`NoAccessIllustration`), the checkmark that
63
+ * turns a bare ring into "success" (`SuccessIllustration`), the
64
+ * exclamation that turns a torn document into "warning" rather than
65
+ * merely "broken" (`ErrorIllustration`).
66
+ * - **Was decorative-only, removed (#48):** the "add" badge on
67
+ * `EmptyListIllustration` (three rows already read as "empty list" on
68
+ * their own), the sparkle on `FirstRunIllustration` (the planted flag
69
+ * already reads as "start here"), and the broken-link fragments on
70
+ * `OfflineIllustration` (a slashed cloud already reads as "disconnected" —
71
+ * the universal "no signal" idiom). Their accent-free silhouettes carry
72
+ * the full state alone, matching how `ErrorIllustration`'s silhouette
73
+ * already did before this convention was written down.
74
+ * Every illustration is `aria-hidden` regardless (the state's title/
75
+ * description text is the ACCESSIBLE channel — see `illustrationSvgProps`
76
+ * below), so this is a visual-consistency rule, not an a11y requirement on
77
+ * its own; don't regress the silhouette-carries-meaning property while
78
+ * redrawing any of the four kept accents above.
79
+ */
80
+
81
+ /**
82
+ * The custom property every illustration's single meaning-bearing accent
83
+ * reads through. `StatePanel` sets it on the illustration slot's wrapper for
84
+ * `kind="error"` (`var(--destructive-text)`) so the accent follows the
85
+ * panel's tint instead of staying pinned to one hue inside a re-colored slot
86
+ * (#24 fix round 1, P0-2) — without this, an illustration whose accent
87
+ * defaults to the brand hue renders a lime badge on a red error panel. Falls
88
+ * back to each illustration's own default hue when no ambient override is
89
+ * set.
90
+ */
91
+ export const ILLUSTRATION_ACCENT_VAR = "--illustration-accent";
92
+
93
+ /**
94
+ * Build the `style` value for an illustration's accent ink: honors the
95
+ * ambient `--illustration-accent` override when set, otherwise `fallback`.
96
+ * `fallback` must be the correct **mark rung** for the accent's hue — e.g.
97
+ * `var(--primary-text)` (NOT `var(--primary)`, a plate color that measures
98
+ * 1.42:1 on `--card` in the `light` theme — #24 fix round 1, P0-1; see
99
+ * `.claude/rules/styling-and-tokens.md` "Which status rung a graphical MARK
100
+ * reaches for"), `var(--success-text)` for a positive-outcome accent, or
101
+ * `var(--destructive-text)` for an error accent — the TEXT rung, deliberately
102
+ * distinct from the FILL rung (`--destructive`) `StatePanel` puts the
103
+ * SUBJECT on for `kind="error"`, so the two don't collapse onto one colour
104
+ * (#48 finding 2).
105
+ */
106
+ export function illustrationAccent(fallback: string): string {
107
+ return `var(${ILLUSTRATION_ACCENT_VAR}, ${fallback})`;
108
+ }