@hachej/boring-workspace 0.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 (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +94 -0
  3. package/dist/CodeEditor-DQqOn4xz.js +266 -0
  4. package/dist/CommandPalette-aM61U-b0.js +5229 -0
  5. package/dist/FileTree-DRq_bfue.js +245 -0
  6. package/dist/MarkdownEditor-DjiHxnRv.js +349 -0
  7. package/dist/WorkspaceLoadingState-By0dZoPD.js +568 -0
  8. package/dist/agent-tool-NvxKfist.d.ts +28 -0
  9. package/dist/app-front.d.ts +485 -0
  10. package/dist/app-front.js +452 -0
  11. package/dist/app-server.d.ts +53 -0
  12. package/dist/app-server.js +769 -0
  13. package/dist/bootstrapServer-BRUqUpVW.d.ts +66 -0
  14. package/dist/boring-workspace.css +1 -0
  15. package/dist/charts.d.ts +114 -0
  16. package/dist/charts.js +143 -0
  17. package/dist/events.d.ts +178 -0
  18. package/dist/events.js +88 -0
  19. package/dist/explorer-DtLUnuah.d.ts +129 -0
  20. package/dist/panel-DnvDNQac.js +6 -0
  21. package/dist/server.d.ts +84 -0
  22. package/dist/server.js +811 -0
  23. package/dist/shared.d.ts +113 -0
  24. package/dist/shared.js +11 -0
  25. package/dist/testing-e2e.d.ts +68 -0
  26. package/dist/testing-e2e.js +45 -0
  27. package/dist/testing.d.ts +464 -0
  28. package/dist/testing.js +10984 -0
  29. package/dist/utils-B6yFEsav.js +8 -0
  30. package/dist/workspace.css +5780 -0
  31. package/dist/workspace.d.ts +2119 -0
  32. package/dist/workspace.js +1884 -0
  33. package/docs/INTERFACES.md +58 -0
  34. package/docs/PLUGIN_STRUCTURE.md +162 -0
  35. package/docs/README.md +19 -0
  36. package/docs/bridge.md +135 -0
  37. package/docs/panels.md +102 -0
  38. package/docs/plans/GENERIC_EXPLORER_PLUGIN_PLAN.md +455 -0
  39. package/docs/plans/MACRO_PLUGIN_GENERIC_HELPERS_AUDIT.md +962 -0
  40. package/docs/plans/PLUGIN_OUTPUTS_ISOLATION_PLAN.md +301 -0
  41. package/docs/plans/README.md +9 -0
  42. package/docs/plans/UI_BRIDGE_OWNERSHIP_REFACTOR.md +303 -0
  43. package/docs/plans/archive/CODE_OWNERSHIP_CLEANUP_PLAN.md +387 -0
  44. package/docs/plans/archive/COMMAND_PALETTE_REGISTRY.md +814 -0
  45. package/docs/plans/archive/DECLARATIVE_LAYOUT_MIGRATION.md +277 -0
  46. package/docs/plans/archive/PLUGIN_MODEL.md +3674 -0
  47. package/docs/plans/archive/SRC_FOLDER_REORG_PLAN.md +307 -0
  48. package/docs/plans/archive/UNIFIED_EVENT_BUS.md +647 -0
  49. package/docs/plans/archive/WORKSPACE_V2_PLAN.md +2489 -0
  50. package/docs/plugins.md +158 -0
  51. package/package.json +164 -0
@@ -0,0 +1,814 @@
1
+ # Command palette: generic command + search registry
2
+
3
+ > **⚠️ SUPERSEDED by [`PLUGIN_MODEL.md`](./PLUGIN_MODEL.md) (2026-04-28).**
4
+ > The palette becomes ONE consumer of the wider plugin model, not a
5
+ > top-level concern. The catalog/command/panel/agent-tool/etc.
6
+ > abstractions all live as contributions inside a `Plugin`. Read
7
+ > `PLUGIN_MODEL.md` for the canonical design; this doc is kept for
8
+ > historical reference of the palette-first iterations.
9
+ >
10
+ > **2026-04-30 update:** references below to `src/data`, `front/data`, or
11
+ > `createFilesCatalog` as a core export are historical. Filesystem catalogs
12
+ > now live under `src/plugins/filesystemPlugin`; the palette searches generic
13
+ > catalog outputs, and "files" are only the filesystem plugin's catalog.
14
+
15
+ **Status:** SUPERSEDED — review v3 (factory pattern + shell auto-registration locked in)
16
+ **Owners:** workspace
17
+ **Last updated:** 2026-04-28
18
+
19
+ ## v3 lock-ins (supersede earlier sections where they conflict)
20
+
21
+ After codex + gemini reviews and three rounds of grilling, the final
22
+ shape is:
23
+
24
+ 1. **Catalogs are composed via factories, not auto-magic defaults.**
25
+ `@boring/workspace` exports `createFilesCatalog`,
26
+ `createSessionsCatalog`, etc. Hosts pass them through
27
+ `<WorkspaceProvider catalogs={[…]}>`. There is no `onOpenFile`
28
+ prop on WorkspaceProvider, no slot config, no auto-mount of
29
+ built-ins from a host-passed shape. Each factory takes
30
+ `onSelect` (the host-specific intent) plus optional overrides
31
+ (`paletteLimit`, `paletteIcon`, `label`, `order`).
32
+
33
+ ```tsx
34
+ <WorkspaceProvider catalogs={[
35
+ createFilesCatalog({
36
+ onSelect: (row) => surface.openPanel({
37
+ id: row.id, component: "code-editor", params: { path: row.id },
38
+ }),
39
+ }),
40
+ // host's own domain catalogs:
41
+ createReportsCatalog({ … }),
42
+ ]}>
43
+ ```
44
+
45
+ 2. **Components like `<ChatCenteredShell />` AUTO-REGISTER their own
46
+ catalogs and commands internally** (no new shell props). When the
47
+ shell receives `sessions` + `onSwitchSession`, it calls
48
+ `useCatalogRegistry().register(createSessionsCatalog({sessions,
49
+ onSelect: onSwitchSession}))` internally. When mounted, it
50
+ registers `toggleDrawer` / `toggleWorkbench` / `newChat` via
51
+ `useCommandRegistry()`.
52
+
53
+ Sessions "just work" from the shell; the host wires nothing
54
+ extra at the catalog level.
55
+
56
+ 3. **Late-wins-on-id is the universal override.** A host that wants
57
+ different palette limit / icon / behavior for ANY catalog
58
+ (including a shell-auto-registered one) registers a catalog with
59
+ the same `id` on `WorkspaceProvider`. The registry's
60
+ late-wins-on-id rule replaces the inner registration. Same
61
+ mechanism works for built-in catalogs, shell-internal catalogs,
62
+ and any future contribution.
63
+
64
+ ```tsx
65
+ <WorkspaceProvider catalogs={[
66
+ createSessionsCatalog({
67
+ sessions, onSelect: customSwitcher,
68
+ paletteLimit: 20, // override default 5
69
+ paletteIcon: <CustomIcon />,
70
+ }),
71
+ ]}>
72
+ <ChatCenteredShell sessions={sessions} onSwitchSession={customSwitcher} />
73
+ </WorkspaceProvider>
74
+ // Shell tries to register id:"sessions"; host's wins.
75
+ ```
76
+
77
+ 4. **Factories ARE the public API for advanced composition.** Both
78
+ user decisions from grilling rounds — "auto-register" and
79
+ "exported factories" — are the same thing under this shape:
80
+ factories are exported, hosts use them, late-wins-on-id is the
81
+ override.
82
+
83
+ 5. **Recent dropped from v2 stays dropped.** Typed Recent entries
84
+ ({kind, id, title, lastOpenedAt}); legacy `string[]` and
85
+ `"cmd:foo"` entries dropped on migration; ⌘Enter / async
86
+ onSelect deferred; commands stay `>`-only.
87
+
88
+ The detailed sections below describe the full design; where they
89
+ mention `onOpenFile`, "auto-mount built-ins", "default Files
90
+ catalog", or any other shape that conflicts with the lock-ins above,
91
+ the lock-ins win.
92
+
93
+
94
+
95
+ ## What changed in v2
96
+
97
+ Codex caught several P0 baseline errors in v1; gemini added concrete UX
98
+ gaps; user picked: 5-row default per catalog, single Enter action, typed
99
+ Recent entries.
100
+
101
+ Substantive corrections from v1:
102
+
103
+ 1. **Baseline was wrong about Files.** v1 implied the palette runs
104
+ `useFileSearch` today. It doesn't —
105
+ `packages/workspace/src/components/CommandPalette.tsx:36-37` exposes
106
+ a sync `fileSearchFn?: (q: string) => string[]` prop, and
107
+ `packages/workspace/src/WorkspaceProvider.tsx:377` mounts
108
+ `<CommandPalette />` with no props. **Files search is not wired in
109
+ the default runtime today.** That's a freebie this plan delivers,
110
+ not a regression to manage.
111
+ 2. **`ExplorerAdapter` reuse is NOT verbatim.** v1 implied identity;
112
+ `ExplorerRow.leading` is `Badge` (mono code chip), not a ReactNode
113
+ icon. The cmd-palette wants icons. v2 adds an explicit
114
+ `CatalogConfig.paletteIcon?: ReactNode` separate from the row's
115
+ badge so the same adapter feeds both surfaces with their own visual
116
+ language.
117
+ 3. **`withCommandPalette={false}` override doesn't exist on
118
+ `WorkspaceProvider` today.** v1 referenced it as if it did. v2
119
+ adds it as part of Phase 1 (BEFORE dropping the shell prop).
120
+ 4. **"No breaking changes" was false.** Dropping `CommandPaletteProps`
121
+ and the shell's `withCommandPalette` prop ARE public API breaks.
122
+ v2 calls them out + lists what consumers need to do.
123
+ 5. **Phase 1 omitted shell work.** v1 listed `ChatCenteredShell.tsx`
124
+ line removals in the dead-code section but Phase 1 steps didn't
125
+ include the shell. v2 adds shell migration to Phase 1.
126
+ 6. **Registry reactivity unspecified.** Current `CommandRegistry` is
127
+ a mutable Map with no subscribe. Late
128
+ `registry.register/unregister` won't trigger React re-renders. v2
129
+ moves to a useSyncExternalStore-backed registry with explicit
130
+ subscribe semantics.
131
+ 7. **File-open ownership.** The provider can't call into the chat
132
+ shell's `openArtifact` ref. v2: hosts register the FilesCatalog
133
+ with their OWN `onSelect`. WorkspaceProvider supplies a
134
+ sane-default Files adapter; the host overrides `onSelect` for
135
+ non-default file-open behavior (chat shell's surface, IDE's
136
+ dockview, …).
137
+ 8. **Recent stored command IDs but opened as paths.** Real bug,
138
+ currently exists at
139
+ `packages/workspace/src/components/CommandPalette.tsx:128-134`
140
+ (`addToRecent("cmd:" + id)`) +
141
+ `packages/workspace/src/components/CommandPalette.tsx:230-236`
142
+ (Recent group renders all recents through `handleFileSelect`).
143
+ v2 fixes this in Phase 1 alongside the typed-recent migration.
144
+ 9. **`pg_trgm` mentions deleted.** Workspace package plan should not
145
+ leak DB index choices for hypothetical future SessionsCatalog
146
+ backends.
147
+
148
+ User decisions (collected via grilling):
149
+
150
+ - **paletteLimit default = 5 per catalog.** Files goes from 50 → 5
151
+ (regression accepted in favor of spotlight-style multi-catalog
152
+ layout).
153
+ - **Single action per row.** `CatalogConfig.onSelect(row)` is the only
154
+ intent. ⌘Enter / right-arrow deferred until real demand.
155
+ - **Typed Recent entries now.** Recent localStorage migrates from
156
+ `string[]` of paths to `Array<{ kind: "file" | "session" | …, id:
157
+ string, lastOpenedAt: number }>` in Phase 1.
158
+
159
+ ## Problem
160
+
161
+ The workspace package ships a `<CommandPalette />` (⌘K) that today is
162
+ three result paths bolted together — and the Files path doesn't even
163
+ run by default:
164
+
165
+ 1. **Recent (Files only)** — `localStorage`-backed list rendered when
166
+ the search box is empty
167
+ (`packages/workspace/src/components/CommandPalette.tsx:34-43`).
168
+ 2. **Files (synchronous, prop-driven, currently dead in default
169
+ runtime)** —
170
+ `packages/workspace/src/components/CommandPalette.tsx:36`
171
+ exposes `fileSearchFn?: (q: string) => string[]`. Provider mounts
172
+ the palette with no props
173
+ (`packages/workspace/src/WorkspaceProvider.tsx:377`), so file results
174
+ never appear unless a host wraps another `<CommandPalette />` with
175
+ the prop wired (no host does today).
176
+ 3. **Commands (active when user types `>`)** — three default commands
177
+ from `WorkspaceProvider`
178
+ (`packages/workspace/src/WorkspaceProvider.tsx:292-323`) plus
179
+ 3-N more registered ad-hoc by `ChatCenteredShell` in a
180
+ `useEffect` (`packages/workspace/src/components/chat/ChatCenteredShell.tsx:400-431`).
181
+ The session-row loop is the giveaway — those aren't really
182
+ commands, they're search results.
183
+
184
+ Plus an actual bug: Recent stores command IDs as
185
+ `addToRecent("cmd:" + id)`
186
+ (`packages/workspace/src/components/CommandPalette.tsx:128-134`) but
187
+ the Recent group renders every entry as if it were a file path
188
+ (`packages/workspace/src/components/CommandPalette.tsx:230-236`).
189
+ Selecting a recent command currently fires `onOpenFile?.("cmd:foo")`,
190
+ which means recently-run commands silently break.
191
+
192
+ The shape stops scaling the moment a child app wants to:
193
+
194
+ - Surface its **sessions** alongside files (e.g. `Sessions: "Workspace
195
+ demo"`, `Sessions: "Plan review"`)
196
+ - Surface its **workspaces / members** for jump-to navigation
197
+ - Plug an arbitrary catalog into the palette (the same catalog that
198
+ already powers `<DataExplorer />`'s data surface, see
199
+ `packages/workspace/src/components/DataExplorer/types.ts`)
200
+
201
+ Doing any of those today means **forking `CommandPalette.tsx`** or
202
+ threading bespoke props down. Both are non-starters once we have more
203
+ than two child apps.
204
+
205
+ Adjacent observation: `@boring/workspace` already defines
206
+ `ExplorerAdapter` — an async, AbortSignal-aware, filterable, paginated
207
+ search interface used by the data catalog UI. That IS the search
208
+ engine the palette wants. We should not be inventing a second one.
209
+
210
+ ## Goal
211
+
212
+ A single command palette where any consumer (workspace, child app, or
213
+ even another `@boring/*` package) can register either:
214
+
215
+ - **Commands** — discrete actions with `id`, `title`, optional
216
+ `shortcut`, optional `when` predicate, `run()` callback. (Already
217
+ exists; scope here is to formalize the registration shape so the
218
+ shell stops registering imperatively.)
219
+ - **Search catalogs** — async, query-driven result lists with the
220
+ `ExplorerAdapter` shape that `<DataExplorer />` already consumes.
221
+ Each catalog renders its own group in the palette
222
+ (`<CommandGroup heading="Sessions">`, `<CommandGroup heading="Files">`,
223
+ …).
224
+
225
+ Two presentations of the same engine:
226
+
227
+ | Surface | Scope | Use case |
228
+ |---|---|---|
229
+ | `<DataExplorer />` pane | One catalog at a time, full-screen, with facets | Browse sessions / data / members in detail |
230
+ | `<CommandPalette />` (⌘K) | Top-N from EVERY registered catalog, plus commands | Spotlight-style jump-to-anything |
231
+
232
+ One adapter per entity, two presentations, zero duplicated search
233
+ logic.
234
+
235
+ ## Non-goals
236
+
237
+ - Replacing the file-tree's own search. The file tree still goes
238
+ through `useFileSearch` directly and renders its own UX; we just
239
+ register the same backend as a catalog so the palette can also
240
+ surface file results. (No double network calls — react-query caches
241
+ by `[base, "search", q, limit]`.)
242
+ - Adding a SessionsCatalog, WorkspacesCatalog, etc. in this PR. The
243
+ scope is the registry mechanism + migrating the existing Files /
244
+ Recent / Commands paths onto it. Future PRs add new catalogs as
245
+ ~30-line additions.
246
+ - Server-side coordination. Each catalog backend is its own HTTP
247
+ route with its own indexing strategy; the palette doesn't care.
248
+ Backend-specific concerns (postgres indexing, full-text search
249
+ configuration) are explicitly out of this frontend-package plan.
250
+ - Persistent favorites / pinned items. Out of scope; revisit in a
251
+ follow-up.
252
+ - Cross-catalog ranking. Each catalog's results are sorted within its
253
+ group; no inter-group score is attempted.
254
+
255
+ ## Design
256
+
257
+ ### The catalog interface
258
+
259
+ Reuse `ExplorerAdapter` verbatim from
260
+ `packages/workspace/src/components/DataExplorer/types.ts:68-72`:
261
+
262
+ ```ts
263
+ export type ExplorerAdapter = {
264
+ search(args: SearchArgs): Promise<SearchResult>
265
+ fetchFacets?(args: FacetsArgs): Promise<Facets>
266
+ }
267
+ ```
268
+
269
+ The cmd-palette doesn't use `fetchFacets` (no facet popover at this
270
+ size) and ignores `group` / `filters` in `SearchArgs`. `SearchResult`
271
+ provides `items: ExplorerRow[]` which renders 1:1 to a
272
+ `<CommandItem>` per row.
273
+
274
+ ### `ExplorerRow.leading` semantics
275
+
276
+ `ExplorerRow.leading` is a `Badge` (
277
+ `packages/workspace/src/components/DataExplorer/types.ts:10-14`,
278
+ `{ code: string, tooltip?: string }`) — a mono text chip, NOT a
279
+ ReactNode icon. `<DataExplorer />` renders it via the `<Chip>`
280
+ component
281
+ (`packages/workspace/src/components/DataExplorer/DataExplorer.tsx:498-507`).
282
+
283
+ For the palette we want lucide icons, not text chips. The
284
+ `CatalogConfig` exposes a SEPARATE `paletteIcon?: ReactNode` and the
285
+ palette renderer ignores `row.leading` entirely. That keeps the
286
+ adapter signature unchanged (no new field on `ExplorerRow`) while
287
+ giving each surface its own visual language.
288
+
289
+ ### `CatalogConfig`
290
+
291
+ ```ts
292
+ export type CatalogConfig = {
293
+ /** Stable id, used for keys + debugging. */
294
+ id: string
295
+ /** Group heading in the palette ("Files", "Sessions", "Members"). */
296
+ label: string
297
+ /**
298
+ * Top-N rows shown in the cmd-palette inline. Defaults to 5
299
+ * (per-spotlight-style: keep palette tight, room for multiple groups).
300
+ * Hosts can override per-catalog.
301
+ */
302
+ paletteLimit?: number
303
+ /**
304
+ * Optional priority for ordering palette groups (lower = earlier).
305
+ * Defaults to 100. Built-in: Commands group (when in `>` mode) is
306
+ * always rendered first, followed by Recent (when query is empty),
307
+ * followed by catalogs sorted by `order`.
308
+ */
309
+ order?: number
310
+ /**
311
+ * Lucide-style icon rendered ahead of every row. Replaces the
312
+ * adapter's `row.leading` Badge for palette presentation; the
313
+ * data-explorer surface continues to render `row.leading` as a
314
+ * mono text chip.
315
+ */
316
+ paletteIcon?: ReactNode
317
+ /**
318
+ * Single canonical action when the user picks a row. Fires on Enter
319
+ * / click. The palette closes itself + records a typed Recent entry
320
+ * automatically — catalogs only handle the side-effect.
321
+ */
322
+ onSelect: (row: ExplorerRow) => void
323
+ /**
324
+ * The "kind" written into the typed Recent entry on select. Must
325
+ * match the catalog id's domain (e.g. "file" for FilesCatalog,
326
+ * "session" for SessionsCatalog) so the Recent group can route
327
+ * each entry to the right catalog's onSelect on re-pick.
328
+ */
329
+ recentKind: string
330
+ /** The actual search engine. */
331
+ adapter: ExplorerAdapter
332
+ }
333
+ ```
334
+
335
+ Note what's absent vs v1: no `searchEmpty` flag (premature — Recent
336
+ is its own thing), no `defaultIcon` (replaced by `paletteIcon` to
337
+ disambiguate from the badge concept), no second action.
338
+
339
+ ### Reactivity-safe registry
340
+
341
+ Current `CommandRegistry` is a mutable Map
342
+ (`packages/workspace/src/registry/CommandRegistry.ts:4`) with no
343
+ subscribe API. If a host registers a catalog late (e.g. chat shell
344
+ adds a SessionsCatalog after `props.sessions` arrives), an
345
+ already-rendered palette won't update.
346
+
347
+ Phase 1 introduces `CatalogRegistry` as a useSyncExternalStore-friendly
348
+ store:
349
+
350
+ ```ts
351
+ export class CatalogRegistry {
352
+ private catalogs = new Map<string, CatalogConfig>()
353
+ private listeners = new Set<() => void>()
354
+
355
+ register(cfg: CatalogConfig) {
356
+ this.catalogs.set(cfg.id, cfg)
357
+ this.emit()
358
+ }
359
+ unregister(id: string) {
360
+ this.catalogs.delete(id)
361
+ this.emit()
362
+ }
363
+ list(): CatalogConfig[] {
364
+ return Array.from(this.catalogs.values()).sort(
365
+ (a, b) => (a.order ?? 100) - (b.order ?? 100),
366
+ )
367
+ }
368
+ subscribe(fn: () => void): () => void {
369
+ this.listeners.add(fn)
370
+ return () => this.listeners.delete(fn)
371
+ }
372
+ private emit() {
373
+ for (const fn of this.listeners) fn()
374
+ }
375
+ }
376
+ ```
377
+
378
+ `useCatalogs()` is implemented via `useSyncExternalStore(reg.subscribe,
379
+ reg.list, reg.list)`, so any palette already mounted re-renders when
380
+ register/unregister fires. We retrofit the same pattern onto
381
+ `CommandRegistry` (also currently mutation-without-subscribe) as part
382
+ of this PR — small change, fixes a latent bug where late
383
+ `registerCommand` calls in the chat shell race with palette open.
384
+
385
+ ### Catalog merge semantics
386
+
387
+ `WorkspaceProvider` accepts:
388
+
389
+ ```ts
390
+ interface WorkspaceProviderProps {
391
+ // … existing props …
392
+ catalogs?: CatalogConfig[]
393
+ /** Drop the built-in defaults (currently just FilesCatalog). */
394
+ withDefaultCatalogs?: boolean // default true
395
+ /** Hosts that want to suppress the palette entirely. */
396
+ withCommandPalette?: boolean // default true (currently always true)
397
+ }
398
+ ```
399
+
400
+ The provider's effective catalog list is:
401
+
402
+ 1. Built-in defaults (FilesCatalog) — only when `withDefaultCatalogs`
403
+ is `true` AND the host has supplied an `onOpenFile` (or whatever
404
+ shape we settle on for default file-open; see "File-open
405
+ ownership" below).
406
+ 2. Host-passed `catalogs` prop.
407
+ 3. Catalogs registered imperatively via
408
+ `useCatalogRegistry().register(cfg)`.
409
+
410
+ By id: later wins on collision. Hosts can override the default
411
+ FilesCatalog by registering their own `id: "files"` catalog.
412
+
413
+ ### File-open ownership
414
+
415
+ Codex flagged this. The provider can't dispatch into the chat shell's
416
+ `openArtifact` ref or the IDE's dockview API. So the provider doesn't
417
+ own file-open behavior — it just supplies the search adapter. Two
418
+ shapes:
419
+
420
+ - **Hosts that use `<DataProvider>` and want a sane default**: pass
421
+ `onOpenFile?: (path: string) => void` to `WorkspaceProvider`. The
422
+ provider builds the FilesCatalog with that callback wired into
423
+ `onSelect`. Apps that don't pass `onOpenFile` get no FilesCatalog
424
+ by default.
425
+ - **Hosts with custom file-open semantics** (chat shell): register
426
+ their own FilesCatalog via `catalogs={[customFilesCatalog]}` (or
427
+ via the registry), with `onSelect` that calls
428
+ `surface.openPanel(...)` or whatever they need. They get the
429
+ built-in adapter via a small helper:
430
+
431
+ ```ts
432
+ import { createFileSearchAdapter } from "@boring/workspace"
433
+
434
+ const filesCatalog: CatalogConfig = {
435
+ id: "files",
436
+ label: "Files",
437
+ order: 10,
438
+ paletteIcon: <FileIcon className="size-4" />,
439
+ recentKind: "file",
440
+ adapter: createFileSearchAdapter(client),
441
+ onSelect: (row) => surface.openPanel({ id: row.id, component: "code-editor", params: { path: row.id } }),
442
+ }
443
+ ```
444
+
445
+ `createFileSearchAdapter(client: DataClient)` is a thin pure factory
446
+ (no React) that takes a `DataClient` instance and returns the
447
+ adapter:
448
+
449
+ ```ts
450
+ export function createFileSearchAdapter(client: DataClient): ExplorerAdapter {
451
+ return {
452
+ async search({ query, limit, signal }) {
453
+ if (!query) return { items: [], total: 0, hasMore: false }
454
+ const paths = await client.search(query, limit, { signal })
455
+ return {
456
+ items: paths.map((p) => ({
457
+ id: p,
458
+ title: basename(p),
459
+ subtitle: dirname(p),
460
+ })),
461
+ total: paths.length,
462
+ hasMore: false,
463
+ }
464
+ },
465
+ }
466
+ }
467
+ ```
468
+
469
+ Note: this requires teaching `FetchClient.search` to accept an
470
+ AbortSignal
471
+ (`packages/workspace/src/data/fetchClient.ts:125-128` — currently
472
+ no signal arg). That's part of Phase 1's scope; ~5 lines.
473
+
474
+ ### Recent migration
475
+
476
+ Current shape:
477
+
478
+ ```ts
479
+ // localStorage["boring-ui-v2:command-palette:recent"] = ["foo.ts", "cmd:workspace.toggleSidebar", ...]
480
+ ```
481
+
482
+ New shape:
483
+
484
+ ```ts
485
+ type RecentEntry = {
486
+ kind: string // matches CatalogConfig.recentKind
487
+ id: string // catalog row id (file path, session id, …)
488
+ title: string // for display when the entry's catalog is unloaded
489
+ subtitle?: string
490
+ lastOpenedAt: number
491
+ }
492
+ // localStorage["boring-ui-v2:command-palette:recent:v2"] = RecentEntry[]
493
+ ```
494
+
495
+ Migration runs once on palette mount: read the old key, transform each
496
+ entry. `"cmd:foo"` → `{ kind: "command", id: "foo", title: ..., ...}`
497
+ (or simply DROPPED — see open question below). Plain strings →
498
+ `{ kind: "file", id: <path>, title: basename(path), subtitle:
499
+ dirname(path), lastOpenedAt: Date.now() }`. Old key deleted after
500
+ migration.
501
+
502
+ The Recent group renders each entry through the catalog matching
503
+ `entry.kind`. If the catalog isn't registered (e.g. user has a recent
504
+ session but the SessionsCatalog isn't loaded in this app), the entry
505
+ renders read-only (with the saved `title` / `subtitle`) and clicking
506
+ does nothing visible — better than firing the wrong handler.
507
+
508
+ ### Palette body shape
509
+
510
+ ```tsx
511
+ <CommandList>
512
+ <CommandEmpty>
513
+ {isCommandMode ? "No matching commands" : "No results"}
514
+ </CommandEmpty>
515
+ {!isCommandMode && !searchQuery && recentEntries.length > 0 && (
516
+ <RecentGroup entries={recentEntries} catalogs={catalogs} />
517
+ )}
518
+ {!isCommandMode && catalogs.map((c) => (
519
+ <CatalogGroup key={c.id} catalog={c} query={searchQuery} />
520
+ ))}
521
+ {isCommandMode && commandResults.length > 0 && (
522
+ <CommandsGroup commands={commandResults} … />
523
+ )}
524
+ </CommandList>
525
+ ```
526
+
527
+ `<CatalogGroup>`:
528
+
529
+ 1. Calls `catalog.adapter.search(...)` debounced 300ms via a SHARED
530
+ palette-level debounce (one debounced query → all catalogs
531
+ receive the same stable string). Avoids 10 catalogs × independent
532
+ timers.
533
+ 2. Skips the call when `searchQuery === ""` (catalogs have no
534
+ "default" results; Recent fills that role).
535
+ 3. Each catalog gets its own AbortController, cancelled on every
536
+ query change.
537
+ 4. Wraps each call in a try/catch so a 500 from one catalog renders
538
+ a small inline error chip in that group — the rest of the palette
539
+ keeps working.
540
+ 5. Renders `<CommandGroup heading={catalog.label}>` with up to
541
+ `catalog.paletteLimit ?? 5` `<CommandItem>`s.
542
+ 6. Wraps `catalog.onSelect(row)` so the palette closes + writes a
543
+ typed Recent entry; catalogs never have to remember either step.
544
+ 7. Returns `null` when results are empty so empty groups don't
545
+ render.
546
+
547
+ Async-arrival ordering: groups render in their declared `order`
548
+ priority regardless of which adapter resolves first. Slow Files +
549
+ fast Sessions = Sessions slot reserves space (renders an empty
550
+ group + a "loading" line for the first 250ms of a query) so the
551
+ Files results don't push Sessions down when they arrive. Layout is
552
+ stable from the first render.
553
+
554
+ ### `CommandEmpty`
555
+
556
+ Becomes `"No results"` (catalog-agnostic) when in search mode, and
557
+ `"No matching commands"` when in `>` mode. Distinct strings keep the
558
+ empty-state honest about which mode the user is in.
559
+
560
+ ### Test plan
561
+
562
+ Phase 1 ships with:
563
+
564
+ - **Unit: `CatalogRegistry`** — register/unregister/list ordering,
565
+ subscribe fires on mutation, useSyncExternalStore re-renders the
566
+ consumer.
567
+ - **Unit: `createFileSearchAdapter`** — empty query short-circuits,
568
+ populated query maps `paths` to `ExplorerRow`s, AbortSignal
569
+ threading.
570
+ - **Unit: typed Recent migration** — old `string[]` shape →
571
+ `RecentEntry[]`; `"cmd:..."` entries handled per the user
572
+ decision below.
573
+ - **Integration: `<CommandPalette />` end-to-end against
574
+ `<WorkspaceProvider catalogs={[stubCatalog]}>` (jsdom)** — open
575
+ palette, type query, assert one stub-catalog group renders top-5
576
+ rows, Enter on a row fires `onSelect` AND the palette closes.
577
+ Open palette again → first row of Recent is the just-selected
578
+ entry, with the right `kind`.
579
+ - **Integration: register-while-open** — open palette, register a
580
+ new catalog, palette re-renders with the new group.
581
+ - **Integration: error per group** — stub catalog rejects;
582
+ palette still renders other groups + an error chip in the failing
583
+ group.
584
+ - **Integration: keyboard nav across async-loaded groups** — ↑/↓
585
+ through groups while one's still loading; aria-selected stays
586
+ correct, scroll-into-view doesn't jump.
587
+ - **Regression: recents type-mix bug** — populate localStorage with
588
+ the legacy `["cmd:foo", "src/a.ts"]` shape, mount palette,
589
+ assert `"cmd:foo"` does NOT trigger the file open path on click.
590
+ - **E2E in workspace-playground** — register a stub second catalog
591
+ in the playground, ⌘K, type, assert two `<CommandGroup>` headings
592
+ appear with disjoint rows. Existing cmd-palette tests
593
+ (Escape/click-outside/effects) keep passing.
594
+
595
+ ## Code simplifications enabled by this pattern
596
+
597
+ After the registry lands several adjacent things either get smaller,
598
+ get more declarative, or just stop being broken. The accounting below
599
+ is grounded in actual current code (codex caught v1 inventing
600
+ `useFileSearch` in the palette — there's no such thing today).
601
+
602
+ 1. **`<CommandPalette />` body collapses to one loop + one commands
603
+ group.** Today
604
+ (`packages/workspace/src/components/CommandPalette.tsx:200-232`)
605
+ has three hardcoded result blocks (`recentFiles`, `fileResults`,
606
+ `commandResults`), each with its own group rendering. After: one
607
+ `catalogs.map(c => <CatalogGroup catalog={c} query={searchQuery}
608
+ />)` plus a single commands group when in `>` mode.
609
+
610
+ 2. **Palette props go from 2 to 0.** Today's
611
+ `CommandPaletteProps = { fileSearchFn, onOpenFile }`
612
+ (`packages/workspace/src/components/CommandPalette.tsx:36-39`)
613
+ exists only because the host had to thread the file-search
614
+ callback AND the file-open callback in, but
615
+ `WorkspaceProvider` mounts the palette with no props
616
+ (`packages/workspace/src/WorkspaceProvider.tsx:377`) — the props
617
+ are EFFECTIVELY DEAD in the default runtime. With catalogs they
618
+ move into the FilesCatalog config. Palette becomes prop-less.
619
+
620
+ 3. **`ChatCenteredShell`'s imperative `useEffect` block becomes
621
+ declarative.** Today
622
+ (`packages/workspace/src/components/chat/ChatCenteredShell.tsx:400-431`)
623
+ re-registers commands on every render. The 3 toggle/new-chat
624
+ actions stay as commands but become declarative — the shell
625
+ accepts a `commands?: CommandConfig[]` shape mirroring the
626
+ `catalogs?:` prop on the provider. The "Switch to: <session
627
+ title>" loop becomes a `SessionsCatalog` (in-memory adapter
628
+ filtering `props.sessions`) — Phase 1 includes this migration.
629
+
630
+ 4. **`withCommandPalette` no-op shell prop drops.** Today
631
+ (`packages/workspace/src/components/chat/ChatCenteredShell.tsx:77,
632
+ 197-205, 591-603`) the prop exists but is a runtime no-op (one
633
+ palette is mounted by the provider, not the shell). Phase 1 adds
634
+ `withCommandPalette` to `WorkspaceProvider` (where it actually
635
+ has somewhere to control), then drops the shell prop.
636
+
637
+ 5. **Recent type-mix bug gets fixed.** Today
638
+ (`packages/workspace/src/components/CommandPalette.tsx:128-134`)
639
+ stores `"cmd:" + id` for command recents but
640
+ (`packages/workspace/src/components/CommandPalette.tsx:230-236`)
641
+ renders every recent through `handleFileSelect`, calling
642
+ `onOpenFile?.("cmd:foo")`. The typed-Recent migration in this
643
+ PR removes the bug as a side-effect: typed entries route per
644
+ `kind`.
645
+
646
+ 6. **Test infra converges.** Catalog adapters become the unit of
647
+ test for both surfaces. A SessionsCatalog tested in isolation
648
+ against a mock adapter automatically tests the data path for
649
+ both the data-pane explorer view and the cmd-palette inline view.
650
+
651
+ ## Dead code that comes out
652
+
653
+ Not aspirational cleanup; each is gated on Phase 1 landing.
654
+
655
+ | File | Net | What |
656
+ |---|---|---|
657
+ | `packages/workspace/src/components/CommandPalette.tsx` | ~–80 | The dual `fileSearchFn` / `onOpenFile` props (lines 36-39, 64-65), the `fileResults` `useMemo` (140-147), `handleFileSelect` (158-168), the standalone Files `<CommandGroup>` (211-220). Consolidated into the catalog loop. |
658
+ | `packages/workspace/src/components/CommandPalette.tsx` (`FilePathLabel`) | –12 | `FilePathLabel` (239-251) is a perfect candidate for deletion. Its filename / dir split becomes `title` / `subtitle` on FilesCatalog rows; the row renderer uses `<DataExplorer />`-style "title bold + subtitle muted" baked in. |
659
+ | `packages/workspace/src/components/CommandPalette.tsx` (`CommandPaletteProps`) | –4 + breaking | Props type goes to `{}`; export removed. **Breaking change for any external consumer that imports `CommandPaletteProps`.** |
660
+ | `packages/workspace/src/components/chat/ChatCenteredShell.tsx` (imperative useEffect) | ~–30 | Lines 400-431. Replaced with one declarative `commands={[…]}` + `catalogs={[…]}` prop on the shell (or threaded via the `WorkspaceProvider` it sits under). |
661
+ | `packages/workspace/src/components/chat/ChatCenteredShell.tsx` (`withCommandPalette`) | –6 + breaking | Lines 77, 197-205, 591-603 (the no-op JSX comment block). **Breaking change for any external consumer that passes `withCommandPalette={false}` on the shell.** |
662
+ | `packages/workspace/src/components/CommandPalette.tsx` (loadRecent/saveRecent + RECENT_STORAGE_KEY constants 27-50) | rewritten | Stays in the file as the typed-Recent v2 implementation, but the v1 `string[]` shape is retired with a one-shot migration. |
663
+
664
+ **Files only changed (not deleted):**
665
+
666
+ - `packages/workspace/src/WorkspaceProvider.tsx` — gains
667
+ `catalogs?: CatalogConfig[]`, `withCommandPalette?: boolean`,
668
+ `withDefaultCatalogs?: boolean`, `onOpenFile?: (path: string) =>
669
+ void`. ~+15 lines.
670
+ - `packages/workspace/src/registry/RegistryProvider.tsx` — adds
671
+ `catalogRegistry` to the context. ~+8 lines.
672
+ - `packages/workspace/src/registry/CatalogRegistry.ts` (new, ~40
673
+ lines) — sibling of `CommandRegistry`, with subscribe semantics.
674
+ `CommandRegistry` retrofitted with the same subscribe pattern
675
+ (~+20 lines).
676
+ - `packages/workspace/src/registry/index.ts` — re-exports
677
+ `useCatalogs`, `useCatalogRegistry`, `CatalogRegistry`,
678
+ `CatalogConfig`.
679
+ - `packages/workspace/src/data/fetchClient.ts` — `search` accepts
680
+ `{ signal?: AbortSignal }`. ~+5 lines.
681
+ - `packages/workspace/src/components/CommandPalette.tsx` — net
682
+ ~–80 lines after removals + ~+50 for `<CatalogGroup>` (parallel
683
+ fetch + AbortSignal + render) + `<RecentGroup>` (typed routing).
684
+
685
+ **Net package size:** ~–60 lines of net source (well, minus
686
+ roughly +30 for new types and tests), simpler public surface, no
687
+ silently-broken Files path, no recent-type-mix bug. Two breaking
688
+ changes, both flagged.
689
+
690
+ ## Migration / rollout
691
+
692
+ **Breaking changes shipped in Phase 1** (call them out in the
693
+ release notes; bump the workspace package's minor):
694
+
695
+ 1. `CommandPaletteProps` export removed
696
+ (`packages/workspace/src/index.ts:88`). External code that imports
697
+ the type must remove the import. The component is now prop-less.
698
+ 2. `ChatCenteredShellProps.withCommandPalette` removed
699
+ (`packages/workspace/src/components/chat/ChatCenteredShell.tsx:77`).
700
+ External code that disables the shell-level palette must move the
701
+ flag onto `WorkspaceProvider` instead:
702
+ `<WorkspaceProvider withCommandPalette={false}>`.
703
+ 3. `localStorage["boring-ui-v2:command-palette:recent"]` keyspace
704
+ migrates to `:recent:v2` with typed entries. Migration runs once
705
+ on palette mount; old key deleted. Pre-migration recents from
706
+ external builds will be lost if they don't follow the legacy
707
+ shape — acceptable.
708
+
709
+ **Phase 1 (this PR) — the work, in order:**
710
+
711
+ 1. Add `CatalogRegistry` + `useCatalogs` + `useCatalogRegistry`
712
+ (subscribe-aware).
713
+ 2. Retrofit `CommandRegistry` with `subscribe` so late
714
+ `registerCommand` calls trigger palette re-render.
715
+ 3. Add `withCommandPalette?` + `catalogs?` + `withDefaultCatalogs?` +
716
+ `onOpenFile?` to `WorkspaceProvider`.
717
+ 4. Teach `FetchClient.search` to accept an AbortSignal.
718
+ 5. Add `createFileSearchAdapter(client)`.
719
+ 6. Refactor `<CommandPalette />` to consume catalogs + render
720
+ `<CatalogGroup>` / `<RecentGroup>`. Drop `fileSearchFn` /
721
+ `onOpenFile` props + `FilePathLabel` + the Files `<CommandGroup>`
722
+ block.
723
+ 7. Run typed-Recent migration on palette mount.
724
+ 8. Migrate `ChatCenteredShell`'s imperative `useEffect` to
725
+ declarative `commands={[…]}` + `catalogs={[sessionsCatalog]}` on
726
+ the shell (or threaded through the provider). Drop
727
+ `withCommandPalette` prop.
728
+ 9. Tests above.
729
+
730
+ **Phase 2 (separate PR per catalog):**
731
+
732
+ - `SessionsCatalog` for hosts with persistent sessions (full-app /
733
+ similar). Backend: an `/api/v1/sessions/search` route — frontend
734
+ package plan stays silent on the indexing strategy; backend's
735
+ problem.
736
+ - `WorkspacesCatalog` (small N, in-memory filter is fine).
737
+ - Whichever catalog the consuming app needs next.
738
+
739
+ ## Open questions
740
+
741
+ 1. **What should the typed-Recent migration do with legacy
742
+ `"cmd:foo"` entries?**
743
+ Options: (a) DROP them — fewer surprises, no broken handlers; (b)
744
+ convert to `{ kind: "command", id: "foo", title: ?, lastOpenedAt:
745
+ <now> }` — preserves history but the title is missing in
746
+ localStorage so we'd need a lookup at mount time. **Recommend
747
+ (a):** drop them. Recents are ephemeral; the bug they expose
748
+ (commands re-fired through file-open path) is more important than
749
+ preserving stale entries.
750
+
751
+ 2. **Should hosts that don't pass `onOpenFile` get NO FilesCatalog,
752
+ or get a FilesCatalog whose `onSelect` is a no-op (search but
753
+ can't open)?**
754
+ Search-without-open feels like a footgun (user clicks → nothing
755
+ happens). **Recommend: no FilesCatalog by default unless
756
+ `onOpenFile` is supplied.** Hosts with custom file-open logic
757
+ register their own. Documented + defaulted.
758
+
759
+ 3. **Should `CatalogConfig.onSelect` be allowed to be async?**
760
+ Codex flagged. The palette wants to close + record Recent
761
+ immediately, before the host's side-effect resolves. **Recommend:
762
+ `onSelect` returns `void` synchronously; if a host needs async
763
+ work, fire-and-forget inside the callback.** Palette doesn't
764
+ block on it.
765
+
766
+ 4. **Should the catalog set be scoped per provider or per shell?**
767
+ Per provider. Consistent with `commandRegistry` today. Multiple
768
+ shells under one provider share. If we ever need per-shell
769
+ scoping it can be a sibling provider — out of scope here.
770
+
771
+ 5. **Should we expose the imperative registry mutation pattern for
772
+ non-React-tree consumers (e.g. server-side code)?**
773
+ Defer. The chat shell scenario v1 worried about turns out to be
774
+ pure-React (registering catalogs in response to props). Premature
775
+ to design a non-React API. Revisit if a real consumer shows up.
776
+
777
+ ## Acceptance
778
+
779
+ - `WorkspaceProvider` accepts `catalogs?: CatalogConfig[]` and
780
+ exposes them via `useCatalogs()` (subscribe-aware).
781
+ - `<CommandPalette />` renders one group per registered catalog
782
+ with matching results (top 5 default, debounced palette-wide,
783
+ AbortSignal-aware, error-isolated per group).
784
+ - Files keep working when a host supplies `onOpenFile` — and works
785
+ for the FIRST TIME in the default runtime (today the Files path is
786
+ dead).
787
+ - Adding a second catalog is a 30-line addition: write the
788
+ `ExplorerAdapter`, write the `CatalogConfig`, pass it through
789
+ `<WorkspaceProvider catalogs={[…]}>`. No `<CommandPalette />`
790
+ changes needed.
791
+ - Recent entries are typed; selecting a "recent command" no longer
792
+ fires the file-open path.
793
+ - `ChatCenteredShell` no longer registers commands imperatively in
794
+ `useEffect`; the registration is declarative.
795
+ - Two flagged breaking changes (CommandPaletteProps export,
796
+ withCommandPalette shell prop) are documented in the release
797
+ notes.
798
+ - Tests in §Test plan all pass.
799
+
800
+ ## Reference
801
+
802
+ - Existing palette:
803
+ `packages/workspace/src/components/CommandPalette.tsx`
804
+ - Existing adapter shape:
805
+ `packages/workspace/src/components/DataExplorer/types.ts`
806
+ - Existing command registry:
807
+ `packages/workspace/src/registry/CommandRegistry.ts`
808
+ - File search HTTP route (one shared backend with the LLM tool, just
809
+ landed at commit `12098fd`):
810
+ `packages/agent/src/server/http/routes/search.ts`
811
+ - Workspace provider's current palette mount point:
812
+ `packages/workspace/src/WorkspaceProvider.tsx:377`
813
+ - Chat shell's imperative command useEffect to remove:
814
+ `packages/workspace/src/components/chat/ChatCenteredShell.tsx:400-431`