@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,2489 @@
1
+ # Workspace v2 — Architecture & Implementation Plan
2
+
3
+ > **Historical plan.** This file is kept for implementation history. Use
4
+ > `../../INTERFACES.md` and `../../PLUGIN_STRUCTURE.md` for the current workspace
5
+ > contracts and plugin ownership rules.
6
+
7
+ ## Execution Tracker (2026-04-24)
8
+
9
+ Use this section as the live handoff ledger while executing this plan.
10
+
11
+ ### Plugin-model merger finalization (2026-04-29)
12
+
13
+ - j9p7 Phase 1.5 completed the migration from imperative chat shell wiring to declarative layouts.
14
+ - Current public surface is the three-tier API in `packages/workspace/README.md`: preset layouts, declarative shell, and raw dock runtime.
15
+ - App-level chat/workbench integration now passes callbacks through `ChatLayout` panel params; no shell context compatibility layer remains in the package source.
16
+
17
+ ### Surface resolver migration note (2026-04-30)
18
+
19
+ - File routing examples below that mention `filePatterns`, `fileFallback`, or
20
+ `PanelRegistry.resolve` are historical.
21
+ - Current source uses generic `surface-resolver` plugin outputs plus the
22
+ `openSurface` UI command. Filesystem path matching belongs to
23
+ `plugins/filesystemPlugin/surfaceResolver.ts`; data catalog row opening
24
+ belongs to `plugins/dataCatalogPlugin`.
25
+
26
+ ### Pass 2 — Full milestone verification (2026-04-24)
27
+
28
+ - Methodology: Three parallel verification agents compared every Phase 1-4 task item against actual source files, reading implementations line-by-line.
29
+ - **Result: ALL Phase 1-4 tasks verified as complete.** No gaps found.
30
+ - Verified items:
31
+ - **Phase 1 (Foundation):** package.json deps ✓, 18 shadcn components vendored ✓, DockviewShell with LayoutConfig ✓, PanelChrome ✓, dockview-overrides.css ✓, Zustand persist with 2 partition keys ✓, PanelRegistry (register/get/list/has/resolve + filePatterns + lazy loading) ✓, IdeLayout (~42 lines) + ChatLayout (~45 lines) ✓
32
+ - **Phase 2 (Panes):** FileTree (react-arborist) ✓, MarkdownEditor (tiptap, 10 extensions) ✓, CodeEditor (CodeMirror 6) ✓, DataCatalogPane ✓, EmptyPane ✓, DataProvider + useFileContent/useFileList/useFileWrite ✓, useEditorLifecycle shared hook ✓
33
+ - **Phase 3a (Integration):** Bridge SSE client ✓, Bridge command dispatch via Zustand ✓, ArtifactSurfacePane with nested DockviewShell ✓, WorkspaceProvider ✓
34
+ - **Phase 4 (Polish):** useTheme hook ✓, CommandPalette (Cmd+P/K) ✓, ResponsiveDockviewShell with Sheet for mobile ✓, PanelErrorBoundary ✓, Testing module (@boring/workspace/testing) ✓
35
+ - Current assessment: Package is feature-complete per this plan. Remaining work is app-shell migration (tracked in beads).
36
+
37
+ ### Pass 1 — Initial validation (2026-04-24)
38
+
39
+ - Full package verification: `pnpm --dir packages/workspace typecheck && pnpm --dir packages/workspace test` (green: 601 tests passing).
40
+ - Replaced placeholder lint script with real gate: `packages/workspace/package.json` now uses `"lint": "pnpm run typecheck"`.
41
+ - Core plan phases (foundation, panes, bridge integration, responsive behaviors, testing harness) are present and covered by the existing suite.
42
+ - Remaining plan execution is now mostly migration/integration work in app shells (`apps/ide`, `apps/chat`, etc.), tracked in beads rather than missing package internals.
43
+
44
+ ## Vision
45
+
46
+ A clean-slate workspace package that provides **layout composition with persistence**, **shadcn-native UI**, and a **live collaboration surface** where agents and users share the same view. Agents can generate panes at runtime (dynamic `import()` from the agent workspace folder) and manipulate the UI through a shared state bridge.
47
+
48
+ ## Decisions (from requirements interview)
49
+
50
+ | Decision | Choice | Notes |
51
+ |----------|--------|-------|
52
+ | Layouts | IDE + Chat-centered (presets, fully composable) | Both ship as thin config presets over DockviewShell. Every slot overridable. Apps can skip presets and compose DockviewShell directly, or skip dockview entirely and use standalone components. |
53
+ | Composability | 3-tier: presets → shell → standalone | Presets (IdeLayout/ChatLayout) for defaults with slot overrides. DockviewShell for custom group arrangements. Standalone components (FileTree, CodeEditor) for no-dockview apps. |
54
+ | Groups vs panels | Groups fixed, panels dynamic | LayoutConfig defines the group skeleton (declarative, static). Panels are dynamic content within groups (added/removed at runtime via useDockviewApi()). DockviewShell auto-manages placeholders and collapse. |
55
+ | Nested dockview | Supported (DockviewShell inside a pane) | ChatLayout's artifact surface renders its own DockviewShell with own state, persistence key, and API. Outer shell doesn't know inner is dockview. Follows v1's SurfaceDockview pattern. |
56
+ | Sizing constraints | GroupConfig only | PanelConfig has no constraints. Sizing is the group's concern, not the panel's. Clean separation: panels describe behavior, groups describe layout. |
57
+ | Panel engine | Dockview | Evaluated react-resizable-panels. Dockview wins: tabs, drag-drop, `toJSON`/`fromJSON`, group locking, header hiding. ~130h to replicate with resizable-panels. |
58
+ | Persistence | Zustand persist middleware, two keys | `boring-ui-v2:layout:{workspaceId}` (reset on schema change) + `boring-ui-v2:preferences` (theme, persists forever). Separate lifecycle. Auto-hydration on mount. `workspaceId` optional — falls back to `boring-ui-v2:layout` if not provided. |
59
+ | Styling | Full shadcn/tailwind | Minimal custom CSS (~150 lines dockview-overrides.css for sash/drop targets). All workspace code is tailwind-only. |
60
+ | Dockview styling | Wrap panels | All visible chrome is shadcn. Dockview is layout engine only. |
61
+ | Code editor | CodeMirror 6 (NEW, not a port) | v1 uses react-simple-code-editor + Prism. v2 upgrades to CodeMirror 6. Full rewrite of code editing, not a port. |
62
+ | Markdown editor | Tiptap (port from v1, reduced) | 10 extensions (down from 16). Drop Table suite (4 pkgs) + ImageResize + DiffExtension (deferred with diff mode). Replace with official Image. See Appendix J for full extension inventory. |
63
+ | Data layer | HTTP only, thin `DataProvider` context (React Query hooks + typed fetch) | Single HTTP provider shipped (no offline/second provider). `DataProvider` is a React context wrapper so panes can access data without threading fetch config through props — not an abstraction for swappable backends. |
64
+ | Panes | File tree, Markdown editor, Data catalog, Code editor | Core set. Agent pane consumed from `@boring/agent`. Each pane = component + dockview wrapper. Components reusable standalone. |
65
+ | Component vs Pane | Components export standalone | `FileTree` (component) usable anywhere. `FileTreePane` = FileTree in dockview. Apps like minimal import the component directly. |
66
+ | Layout toggle | Dropped | Apps that need IDE↔Chat toggle implement it themselves. Not workspace's job. |
67
+ | Offline mode | Dropped | agent-frontend's LightningFS/isomorphic-git stack not supported. HTTP-only. |
68
+ | Dynamic panes | Out of v2 scope | Post-launch feature — see Appendix I below. Not blocking v2 delivery. |
69
+ | Agent bridge | Read-only state + imperative commands | Agent reads Zustand store (reactive subscriptions). Writes go through typed bridge commands (openFile, openPanel, etc.) — NOT direct store mutations. See architecture below. |
70
+ | Boot boundary | Workspace = layout + context provider | App shell handles auth, routing, data fetching. Workspace provides `<WorkspaceProvider>` (registry + bridge + theme). Data provider injected by app shell. |
71
+ | Repo structure | v2 monorepo | `boring-ui-v2/packages/workspace`, `boring-ui-v2/packages/agent`, `boring-ui-v2/packages/core` |
72
+ | Capability gating | Hybrid | Static registration + optional runtime check for dynamic panes. |
73
+ | Pane lazy-loading | All panes lazy-loaded | Every built-in pane (including FileTree) loaded via dynamic import(). Only `empty` is eager. Initial bundle = shell + registry only. |
74
+ | Dynamic pane transform | Out of v2 scope | See Appendix I. Decision: server-side esbuild when implemented. |
75
+ | Migration | Hard cut | v2 replaces v1 when ready. No coexistence period. |
76
+ | Layout hooks | Shared from Phase 1 | Extract useSidebarLayout/usePanelSizing before building either layout. |
77
+ | Hydration strategy | Block mount | Don't render DockviewShell until Zustand onRehydrateStorage fires. Clean loading state. |
78
+ | Testing | 4-layer (unit + Storybook + Playwright + Bombadil) | Property-based exploration via Bombadil for unknown unknowns. |
79
+ | Language | TypeScript (.tsx) from day 1 | v1 is 100% .jsx with no types. v2 is clean-slate .tsx. Not a migration — new files with typed props. |
80
+ | React version | React 19 safe | dockview 4.13.1 already supports React 19. All deps compatible. |
81
+ | Git UI in workspace | Dropped entirely | No git status badges, no git diff viewer. Agent owns all git UI. |
82
+ | File tree sections | Dropped | Flat tree. No Projects/Sources sections. |
83
+ | Editor modes | Normal only in v1 | Drop git-diff modes entirely (side-by-side vs HEAD + unified). Git UI dropped, diff modes return when git routes ship in v1.x. |
84
+ | Backend | **None — workspace is frontend-only** | All HTTP routes (files, tree, stat, ui-bridge, agent) are hosted by `@boring/agent`. Workspace consumes them. See `boring-ui-v2/packages/agent/docs/plans/agent-package-spec.md` for the full HTTP surface. |
85
+ | ConnectedFileTree | Dropped (seventh pass) | Redundant. Keep FileTree (props) + FileTreePane (dockview wrapper). Pane IS the connected variant. |
86
+ | v1 REST bridge | Dropped (seventh pass) | Hard cut. No backward-compat REST endpoints. SSE + POST only (ninth pass). |
87
+ | Error recovery | 2-tier (seventh pass, clarified tenth) | Tier 1: error boundary per pane (includes stripping unknown panels during restore). Tier 2: full reset to defaults. The "strip unknown panels" step is part of Tier 1 restore, not a separate tier. |
88
+ | Bridge commands | Return Promise\<CommandResult\> (seventh pass, updated eighth) | openFile(), openPanel(), etc. return `Promise<CommandResult>` with seq, status, and optional error. Fire-and-forget still works. |
89
+ | Implementation ref | Added in sixth pass | API surface, WorkspaceProvider, core deps, bridge protocol, dockview config, shadcn inventory, phase DAG — see §Implementation Reference below Risk 14. |
90
+ | Bridge transport | SSE + POST, command-based (agent-hosted, tenth pass) | Workspace Zustand is the authority. Agent posts commands via `POST /api/v1/ui/commands`; SSE `GET /api/v1/ui/commands/next` streams `event: command` to workspace; workspace executes locally; workspace pushes state via `PUT /api/v1/ui/state`; agent reads via `get_ui_state` tool. All events carry `v:1` protocol version. Short-poll (2s) fallback. |
91
+ | Bridge validation | Per-kind Zod schemas (eighth pass, simplified ninth) | Zod validation per command kind (path length, allowed chars, registry existence). Rate limiting deferred to post-launch. |
92
+ | Bridge events | Typed BridgeEventMap + select() (eighth pass) | Discriminated union replaces `subscribe(event: string, handler: Function)`. Selector-based `select<T>(selector, handler)` for slice subscriptions. Apps extend via module augmentation. |
93
+ | Bridge state scope | Panels + lightweight file hints (eighth pass) | Bridge sends openPanels, activePanel, activeFile, visible file paths. NO full file tree state — agent queries files via its own tools. Eliminates bandwidth problem. |
94
+ | Shell API | Extended (eighth pass) | Add `activatePanel()`, `updatePanelParams()`, `movePanel()`, `batch()` to DockviewShellApi. Batch defers layout recalculation for multi-op sequences. |
95
+ | Panel lifecycle | Four hooks via PanelLifecycleApi (eighth pass, tenth) | PanelConfig gains `onActivate`, `onDeactivate`, `onClose` (returns Promise to block close), `serializeState`. Hooks receive workspace-owned `PanelLifecycleApi` (not raw DockviewPanelApi) to maintain dockview encapsulation. |
96
+ | Cascading errors | Dropped (ninth pass) | Individual pane error boundaries + bridge reconnection banner are sufficient for launch. WorkspaceHealthMonitor deferred to post-launch if correlated failures become a real problem. |
97
+ | Dirty files | Store-level slice (eighth pass) | `dirtyFiles: Map<path, { panelId, savedAt }>` in Zustand store. Editors call `bridge.markDirty/markClean`. Agent gets `bridge.getDirtyFiles()`. |
98
+ | Persistence hardening | Full (eighth pass) | Zod schema validation on restore, QuotaExceededError handling (disable + toast), cross-tab `storage` event listener, size budget <50KB. |
99
+ | Persist debounce | 300ms + flush (eighth pass) | `onDidLayoutChange` fires per-pixel during sash drags. Debounce toJSON/persist to 300ms. Flush on `beforeunload`. |
100
+ | File tree library | react-arborist (eighth pass) | ~15KB gz. Virtualized tree with keyboard nav, ARIA semantics, drag-and-drop. Replaces react-window + 300-500 LOC custom tree logic. |
101
+ | CM6 large files | Simple cutoff at 1MB (eighth pass) | Full highlighting <1MB, read-only above 1MB. No worker tokenization (doesn't exist in CM6). |
102
+ | Bundle budget | Two numbers (ninth pass) | Initial bundle (shell + registry) <150KB gz, total all-loaded <800KB gz. All panes lazy-loaded except `empty`. |
103
+ | Pane loading | All lazy (eighth pass) | Every built-in pane is lazy-loaded via dynamic import(). Initial bundle = dockview + shadcn + zustand + registry only. ChatLayout apps never download FileTree/editors. |
104
+ | Zustand selectors | Architectural (ninth pass) | `useWorkspaceStore()` is NOT exported. Only atomic hooks exported: `useActiveFile()`, `useActivePanel()`, `useSidebarState()`, `useOpenPanels()`, `useDirtyFiles()`. Selector discipline enforced by API design, not ESLint. |
105
+ | Nested shell isolation | Minimal + allowedPanels (ninth pass, tenth) | Each nested DockviewShell gets own `storageKey` + optional `allowedPanels` prop to filter registry. No panel ID namespacing, no bridge `shell` param. Add formal isolation protocol post-launch if multi-shell routing is needed. |
106
+ | CSP | Target policy + test (eighth pass) | `style-src 'unsafe-inline'` for CM6 style-mod, tiptap HTML sanitizer configured, Playwright CSP test. |
107
+ | definePanel() | Dropped (ninth pass) | Unnecessary — a typed object literal (`const myPanel: PanelConfig = { ... }`) gives the same autocomplete. Less API surface. |
108
+ | Test harness | @boring/workspace/testing (eighth pass) | TestWorkspaceProvider, createMockBridge(), renderPane(). Child apps run full test suite themselves. |
109
+ | Agent tool-selection regressions | `@boring/agent/testing` eval framework | YAML fixtures + matcher pipeline + live-LLM canary. Workspace and child apps write their own driver scripts that boot `createWorkspaceAgentApp` and call `runEvalSuite({ app, fixturesPath })`. See `packages/agent/docs/plans/AGENT_EVAL_FRAMEWORK.md`. |
110
+ | Crash recovery | Accepted risk (eighth pass) | 1s auto-save debounce gap on crash accepted. No sessionStorage journal — complexity not worth it. |
111
+ | Command palette | Static + file quick-open, separate CommandRegistry (eighth pass, tenth) | `registry/CommandRegistry.ts` with `CommandConfig { id, title, run, shortcut?, when? }`. Exported via `useCommandRegistry()`. Separate from PanelRegistry. Dynamic command providers deferred to post-launch. |
112
+ | i18n | Dropped (ninth pass) | No `t()` wrapper, no `yarn extract-messages`. Plain strings. When i18n is needed, run a codemod (ast-grep/jscodeshift) to extract. |
113
+ | Editor lifecycle | Shared hook with flushSave (ninth pass, tenth) | `useEditorLifecycle()` hook (~100 LOC) for Tiptap and CM6: dirty tracking, auto-save debounce, external file change detection, bridge markDirty/markClean. Exposes `flushSave()` for onClose integration: onClose calls flushSave() first, then prompts if still dirty. Single save path. |
114
+ | Git sidebar | Dropped (ninth pass) | No git changes view in sidebar. Agent owns all git UI. File tree is files-only. |
115
+ | Offline mode | Permanently dropped (ninth pass) | No DataProvider abstraction, no LightningFS, no isomorphic-git, no Pyodide. HTTP-only is permanent. agent-frontend app cannot exist on v2. |
116
+ | Terminal/PTY | Not used (ninth pass) | Terminal panels are no longer used in any app. Remove from migration concerns and "files not to port" rationale. |
117
+ | Dockview risk | Accepted (ninth pass) | Single-maintainer risk accepted. DockviewShell encapsulates all dockview interaction — panes never import dockview directly. If dockview dies, only DockviewShell internals (~500 LOC) change. No adapter layer (YAGNI). |
118
+ | Sample app | Minimal playground (ninth pass) | `apps/workspace-playground`: IdeLayout + mock data provider, no backend needed. `pnpm --filter workspace-playground dev`. Isolated test environment for workspace package. |
119
+ | Bridge E2E test | Added (ninth pass) | Playwright test that opens workspace + simulates agent HTTP client. Agent posts openFile command via POST → workspace receives on SSE `event: command` → applies locally → assert file panel appears. Tests real bridge protocol. ~50 LOC. |
120
+ | CM6 languages | Measure first (ninth pass) | Bundle all 6 languages (JS/TS, Python, JSON, YAML, Markdown, SQL). Measure actual chunk size in Phase 2. Cut to 3 bundled + lazy rest only if total budget exceeded. |
121
+ | Store topology | Single store, partitioned persist (tenth pass) | One `useWorkspaceStore()`. Persist middleware uses `partialize` to route: layout→localStorage, preferences→localStorage, bridge state→ephemeral (NOT persisted). Single file `store/index.ts`. `persistence/` and `bridge/` are thin modules reading/writing the single store. |
122
+ | Bridge authority | Workspace Zustand is source of truth (tenth pass) | SSE streams commands (not state). Workspace executes locally, PUTs resulting state. Agent reads via `get_ui_state` tool. `causedBy: 'user' \| 'agent' \| 'restore'` on PUT body prevents echo loops. All events carry `v:1` protocol version field. |
123
+ | PanelLifecycleApi | Workspace-owned wrapper (tenth pass) | Lifecycle hooks (`onActivate`, `onDeactivate`, `onClose`) receive `PanelLifecycleApi { panelId, title, setTitle, close, focus, isActive }` — not raw `DockviewPanelApi`. PanelChrome adapts. Maintains dockview encapsulation. |
124
+ | Layout version | String + migration callback (tenth pass) | `version: '2.0'` (string, not number). `WorkspaceProviderProps.onLayoutVersionMismatch?(persisted, current, layout) => SerializedLayout \| null`. Default: null (reset). Zero cost now, clean extension point for future layout migrations. |
125
+ | Standalone contract | Props-only, zero context (tenth pass) | Tier 3 standalone components (`FileTree`, `CodeEditor`, `MarkdownEditor`) accept ALL data via props. Never call hooks internally. `CodeEditor` takes `content: string, onChange, language`. DataProvider only needed by pane wrappers. |
126
+ | Storage scoping | workspaceId in key (tenth pass) | Default key: `boring-ui-v2:layout:{workspaceId}`. Falls back to `boring-ui-v2:layout` if workspaceId not provided. Prevents data loss when multi-workspace arrives. |
127
+ | Nested shell guard | allowedPanels prop (tenth pass) | DockviewShell accepts optional `allowedPanels: string[]` to filter registry for nested instances. ChatLayout artifact surface uses this to prevent outer-shell panels from rendering inside. Outer shell has no restriction. |
128
+
129
+ ## Architecture
130
+
131
+ ### Package boundary
132
+
133
+ ```
134
+ v2/packages/workspace/ # This package
135
+ src/
136
+ components/ # shadcn-wrapped building blocks
137
+ layouts/ # IDE, Chat-centered, layout primitives
138
+ panes/ # Built-in pane implementations
139
+ panels/ # Dockview panel wrappers (shadcn chrome)
140
+ persistence/ # Single-key localStorage engine
141
+ registry/ # Panel registry + dynamic loader
142
+ bridge/ # Agent-UI shared state (the coworking surface)
143
+ hooks/ # Layout, persistence, panel lifecycle hooks
144
+ theme/ # shadcn theme config + dockview overrides
145
+ index.ts # Public API
146
+ docs/
147
+ plans/ # This file
148
+ ```
149
+
150
+ ### Dependency graph
151
+
152
+ **Updated 2026-04-24 — fully inverted from v1.** Agent is the leaf. Workspace depends on agent (consumes `<ChatPanel />` as a pane). Core depends on workspace. Canonical graph lives in `packages/core/docs/CORE.md` §Dependency position (single fused doc replacing the old plans/ subfolder).
153
+
154
+ ```
155
+ @boring/agent (leaf — ZERO internal deps; ships standalone CLI)
156
+
157
+ @boring/workspace (depends on agent)
158
+ ├── @boring/agent (ChatPanel component + useAgentChat hook)
159
+ ├── dockview-react (layout engine)
160
+ ├── @codemirror/* (code editor)
161
+ ├── shadcn/ui components (vendored in ui-shadcn/ — consumed by core)
162
+ └── tailwindcss (styling)
163
+
164
+ @boring/core (depends on workspace)
165
+ └── @boring/workspace (imports shadcn primitives from /ui-shadcn)
166
+ ```
167
+
168
+ Standalone agent (`createAgentApp()` / `npx @boring/agent`) keeps **zero dependency on core**; the CLI must boot without DB/auth. Core apps mount agent via `registerAgentRoutes(app)` Fastify plugin — additive, not mandatory.
169
+
170
+ **Historical note**: sections below that mention `@boring/core` as a workspace dep (transport utils, config provider, hooks like `useTheme`/`useKeyboardShortcuts`) predate the inversion. In v2 these live in core. Sections that say "workspace never imports agent" are also outdated — workspace now consumes agent's `<ChatPanel />`. Read pre-2026-04-24 sections as v1 history, not v2 contract.
171
+
172
+ ### Composability model
173
+
174
+ The workspace is a library, not a framework. Three tiers of usage — pick the one that
175
+ matches your app's needs. Each tier is a superset of the one below.
176
+
177
+ #### Tier 1: Preset layouts (slot overrides)
178
+
179
+ Preset layouts define a group arrangement with sensible defaults. Every slot is overridable
180
+ by panel ID. The preset is ~30 lines — it builds a `LayoutConfig` and passes it to `DockviewShell`.
181
+
182
+ ```tsx
183
+ import { WorkspaceProvider, IdeLayout } from '@boring/workspace'
184
+ import { ChatPanel } from '@boring/agent'
185
+ import { MyCustomTree } from './MyCustomTree'
186
+
187
+ // Register custom panel, then reference by ID in layout slot
188
+ <WorkspaceProvider
189
+ panels={[
190
+ { id: 'my-tree', component: MyCustomTree, title: 'Explorer' },
191
+ { id: 'agent', component: ChatPanel, placement: 'right', hideHeader: true },
192
+ ]}
193
+ >
194
+ <IdeLayout
195
+ sidebar="my-tree" // override: your component instead of built-in FileTree
196
+ center="empty" // default
197
+ right="agent" // default
198
+ />
199
+ </WorkspaceProvider>
200
+ ```
201
+
202
+ **IdeLayout defaults**: `{ sidebar: 'filetree', center: 'empty', right: null }`
203
+ **ChatLayout defaults**: `{ nav: 'session-list', center: 'chat', sidebar: undefined, surface: undefined }`
204
+
205
+ #### Tier 2: DockviewShell (custom group arrangement)
206
+
207
+ Skip presets entirely. Define your own groups, positions, constraints.
208
+
209
+ ```tsx
210
+ import { WorkspaceProvider, DockviewShell } from '@boring/workspace'
211
+
212
+ <WorkspaceProvider panels={[...]}>
213
+ <DockviewShell
214
+ layout={{
215
+ groups: [
216
+ { id: 'left', position: 'left', locked: true, panel: 'filetree', constraints: { minWidth: 200 } },
217
+ { id: 'center', position: 'center', panel: 'empty' },
218
+ { id: 'bottom', position: 'bottom', panel: 'terminal', constraints: { maxHeight: 300 } },
219
+ { id: 'right', position: 'right', panel: 'agent', hideHeader: true },
220
+ ]
221
+ }}
222
+ />
223
+ </WorkspaceProvider>
224
+ ```
225
+
226
+ #### Tier 3: Standalone components (no dockview)
227
+
228
+ No layout engine at all. Import components and compose them in plain JSX.
229
+ No `WorkspaceProvider`, no `DataProvider`, no context — all data via props (tenth pass).
230
+
231
+ ```tsx
232
+ import { FileTree, CodeEditor } from '@boring/workspace'
233
+
234
+ <div className="flex h-screen">
235
+ <FileTree files={files} onSelect={setPath} />
236
+ <CodeEditor content={fileContent} onChange={setContent} language="typescript" />
237
+ </div>
238
+ // DataProvider is only needed by pane wrappers (CodeEditorPane, FileTreePane).
239
+ ```
240
+
241
+ #### Core principle: groups are fixed, panels are dynamic
242
+
243
+ **Groups** (the layout skeleton) are declarative and fixed — defined in LayoutConfig.
244
+ **Panels** (the content) are dynamic — they come and go at runtime.
245
+
246
+ LayoutConfig describes "the room has 3 zones." Runtime API handles "put this file on the desk."
247
+
248
+ DockviewShell auto-manages:
249
+ - **Placeholders**: if a dynamic group has no panels, the placeholder panel shows automatically
250
+ - **Collapse**: collapsible groups shrink to `collapsedWidth` when collapsed, restore on expand
251
+ - **Panel count**: dynamic groups accept new panels via `useDockviewApi().addPanel()`
252
+
253
+ #### Layout config type
254
+
255
+ ```typescript
256
+ interface LayoutConfig {
257
+ version: string // e.g. '2.0'. Increment when group structure changes.
258
+ // On mismatch: call WorkspaceProviderProps.onLayoutVersionMismatch()
259
+ // (default: discard persisted layout, use new config)
260
+ groups: GroupConfig[]
261
+ }
262
+
263
+ interface GroupConfig {
264
+ id: string // unique group identifier
265
+ position: 'left' | 'center' | 'right' | 'bottom'
266
+ panel?: string // initial panel ID (from registry)
267
+ locked?: boolean // prevent closing/dragging out
268
+ hideHeader?: boolean // hide tab bar (for single-panel groups)
269
+ dynamic?: boolean // accepts panels at runtime (default: false)
270
+ placeholder?: string // panel shown when group is empty (requires dynamic: true)
271
+ collapsible?: boolean // can be collapsed (sidebar pattern)
272
+ collapsedWidth?: number // width when collapsed (e.g., 80 for icon-only sidebar)
273
+ constraints?: { // sizing — ONLY GroupConfig has constraints (not PanelConfig)
274
+ minWidth?: number
275
+ maxWidth?: number
276
+ minHeight?: number
277
+ maxHeight?: number
278
+ }
279
+ }
280
+ ```
281
+
282
+ **Only GroupConfig has sizing constraints.** PanelConfig describes behavior (icon, title,
283
+ filePatterns). GroupConfig describes layout (size, locked, collapsible). Clean separation —
284
+ panels don't dictate their container size.
285
+
286
+ #### Runtime API — `useDockviewApi()`
287
+
288
+ Exposed by DockviewShell for panes and hooks that need to manipulate the layout at runtime.
289
+
290
+ ```typescript
291
+ interface DockviewShellApi {
292
+ addPanel(groupId: string, config: { id: string, component: string, params?: Record<string, unknown> }): void
293
+ removePanel(panelId: string): void
294
+ activatePanel(panelId: string): void
295
+ updatePanelParams(panelId: string, params: Record<string, unknown>): void
296
+ movePanel(panelId: string, target: { groupId: string } | { direction: 'left' | 'right' | 'up' | 'down', referencePanelId: string }): void
297
+ getGroup(id: string): DockviewGroupApi | null
298
+ getActivePanel(): string | null
299
+ setGroupCollapsed(groupId: string, collapsed: boolean): void
300
+
301
+ /** Batch multiple operations into a single layout recalculation + persistence write.
302
+ * Defers onDidLayoutChange until the callback completes. */
303
+ batch(fn: () => void): void
304
+ }
305
+ ```
306
+
307
+ Panes access this via `useDockviewApi()` hook (available inside WorkspaceProvider).
308
+ Bridge commands (`openFile`, `openPanel`) use this internally.
309
+
310
+ Preset layouts are just functions that return a `LayoutConfig`:
311
+
312
+ ```typescript
313
+ // layouts/IdeLayout.tsx — ~30 lines, not 200+
314
+ function IdeLayout({ sidebar = 'filetree', center = 'empty', right }: IdeLayoutProps) {
315
+ const layout: LayoutConfig = {
316
+ groups: [
317
+ { id: 'sidebar', position: 'left', panel: sidebar, locked: true,
318
+ collapsible: true, collapsedWidth: 0,
319
+ constraints: { minWidth: 200, maxWidth: 400 } },
320
+ { id: 'center', position: 'center', panel: center,
321
+ dynamic: true, placeholder: 'empty' },
322
+ ...(right ? [{ id: 'right', position: 'right' as const, panel: right, hideHeader: true,
323
+ constraints: { minWidth: 300 } }] : []),
324
+ ]
325
+ }
326
+ return <DockviewShell layout={layout} />
327
+ }
328
+ ```
329
+
330
+ #### PanelConfig — the extension contract
331
+
332
+ Every panel registered in the workspace follows this contract. This is what a developer
333
+ implements to add a new pane.
334
+
335
+ ```typescript
336
+ interface PanelConfig {
337
+ id: string // unique panel identifier
338
+ component: React.ComponentType<PaneProps> | (() => Promise<{ default: React.ComponentType<PaneProps> }>)
339
+ title?: string // display name in tab
340
+ icon?: React.ComponentType<{ size: number }> // tab icon (lucide-react compatible)
341
+ placement?: 'left' | 'center' | 'right' | 'bottom' // preferred initial position
342
+ essential?: boolean // cannot be closed by user
343
+ hideHeader?: boolean // hide tab bar when sole panel in group
344
+ requiresCapabilities?: string[] // only available when capabilities satisfied
345
+ filePatterns?: string[] // file globs this pane handles (e.g., ['*.csv', '*.parquet'])
346
+
347
+ // Lifecycle hooks — called by DockviewShell, run outside React render cycle.
348
+ // PanelChrome wires these automatically; pane components don't subscribe manually.
349
+ // Hooks receive PanelLifecycleApi (workspace-owned type), NOT raw DockviewPanelApi.
350
+ // This maintains dockview encapsulation — panel authors never import dockview types.
351
+ onActivate?: (api: PanelLifecycleApi) => void
352
+ onDeactivate?: (api: PanelLifecycleApi) => void
353
+ onClose?: (api: PanelLifecycleApi) => void | Promise<void> // return Promise to block close (e.g., call flushSave() then prompt if still dirty)
354
+
355
+ // State serialization for persistence. DockviewShell calls serializeState() during
356
+ // toJSON() and passes the result back via params.__restoredState on fromJSON().
357
+ // Enables panels to persist scroll position, cursor, expansion state across sessions.
358
+ // Max 4KB serialized per panel — PanelChrome enforces and logs warning if exceeded.
359
+ serializeState?: (panelId: string) => Record<string, JsonSerializable> | null
360
+
361
+ // NOTE: No constraints here. Sizing is GroupConfig's concern, not the panel's.
362
+ }
363
+
364
+ // Workspace-owned panel lifecycle API — wraps DockviewPanelApi.
365
+ // PanelChrome adapts dockview's API to this type before calling lifecycle hooks.
366
+ // Panel authors never import from dockview-react directly.
367
+ interface PanelLifecycleApi {
368
+ panelId: string
369
+ title: string
370
+ setTitle(title: string): void
371
+ close(): void
372
+ focus(): void
373
+ isActive: boolean
374
+ }
375
+
376
+ // Props injected into every pane component by the dockview wrapper
377
+ interface PaneProps {
378
+ panelId: string // dockview panel ID
379
+ params: Record<string, unknown> // params passed when opening (e.g., { path: '/src/main.ts' })
380
+ api: PanelLifecycleApi // workspace-owned panel API (not raw DockviewPanelApi)
381
+ bridge: WorkspaceBridge // workspace bridge for agent interaction
382
+ }
383
+ ```
384
+
385
+ #### Panel authoring — typed object literals
386
+
387
+ No factory function needed. A typed object literal gives full TypeScript autocomplete:
388
+
389
+ ```typescript
390
+ import type { PanelConfig } from '@boring/workspace'
391
+
392
+ export const csvViewer: PanelConfig = {
393
+ id: 'csv-viewer',
394
+ component: () => import('./CsvViewer'), // lazy by default
395
+ title: 'CSV Viewer',
396
+ icon: TableIcon,
397
+ filePatterns: ['*.csv', '*.tsv'],
398
+ }
399
+
400
+ // Then in app shell:
401
+ <WorkspaceProvider panels={[csvViewer, imageViewer, agentPane]}>
402
+ ```
403
+
404
+ **File-type routing**: when a file is opened, the registry checks `filePatterns` on all
405
+ registered panels. First match wins. Built-in defaults:
406
+
407
+ | Pattern | Panel |
408
+ |---------|-------|
409
+ | `*.md`, `*.mdx` | `markdown-editor` |
410
+ | `*` (fallback) | `code-editor` |
411
+
412
+ Apps can override by registering a panel with more specific patterns:
413
+
414
+ ```tsx
415
+ <WorkspaceProvider
416
+ panels={[
417
+ { id: 'csv-viewer', component: CsvViewer, filePatterns: ['*.csv', '*.tsv'] },
418
+ { id: 'image-viewer', component: ImageViewer, filePatterns: ['*.png', '*.jpg', '*.svg'] },
419
+ ]}
420
+ />
421
+ // Now opening a .csv file routes to CsvViewer instead of code-editor
422
+ ```
423
+
424
+ ### Agent-UI bridge (the "coworking surface")
425
+
426
+ **Recommended approach: shared reactive state store.**
427
+
428
+ The agent should feel like a co-user — seeing the same files, same open panels, same state. This means:
429
+
430
+ 1. **Workspace state store** (Zustand) holds the canonical UI state:
431
+ - Open panels and their params (which file, which view mode)
432
+ - Active panel / active file
433
+ - Sidebar collapse state
434
+ - Dirty files map (which files have unsaved changes)
435
+ - Notifications / toasts
436
+
437
+ 2. **Bridge API** — a thin command layer the agent calls:
438
+ ```typescript
439
+ interface WorkspaceBridge {
440
+ // Read (agent sees what user sees)
441
+ getOpenPanels(): PanelState[]
442
+ getActiveFile(): string | null
443
+ getDirtyFiles(): string[] // files with unsaved changes
444
+ getVisibleFiles(): string[] // file paths currently visible in the tree
445
+
446
+ // Write (agent acts like a user) — all return Promise<CommandResult>
447
+ openFile(path: string, opts?: { mode?: 'view' | 'edit' | 'diff' }): Promise<CommandResult>
448
+ openPanel(config: DynamicPaneConfig): Promise<CommandResult>
449
+ closePanel(id: string): Promise<CommandResult>
450
+ expandToFile(path: string): Promise<CommandResult>
451
+ showNotification(msg: string, level?: 'info' | 'warn' | 'error'): Promise<CommandResult>
452
+ navigateToLine(file: string, line: number): Promise<CommandResult>
453
+ markDirty(path: string): void
454
+ markClean(path: string): void
455
+
456
+ // Subscribe — typed events with discriminated union
457
+ subscribe<K extends keyof BridgeEventMap>(
458
+ event: K,
459
+ handler: (data: BridgeEventMap[K]) => void
460
+ ): Unsubscribe
461
+
462
+ // Selector-based subscription (fires only when selected slice changes)
463
+ select<T>(selector: (state: WorkspaceState) => T, handler: (value: T) => void): Unsubscribe
464
+
465
+ // Connectivity
466
+ onDisconnect(handler: () => void): Unsubscribe
467
+ onReconnect(handler: () => void): Unsubscribe
468
+ }
469
+
470
+ interface CommandResult {
471
+ seq: number
472
+ status: 'ok' | 'error'
473
+ error?: { code: string, message: string }
474
+ }
475
+
476
+ // Typed bridge events — exhaustive, no stringly typing
477
+ interface BridgeEventMap {
478
+ 'panel:opened': { panelId: string; params: Record<string, unknown> }
479
+ 'panel:closed': { panelId: string }
480
+ 'panel:activated': { panelId: string; previousPanelId: string | null }
481
+ 'file:opened': { path: string; mode: 'view' | 'edit' | 'diff' }
482
+ 'file:saved': { path: string }
483
+ 'file:dirty': { path: string; dirty: boolean }
484
+ 'sidebar:toggled': { collapsed: boolean }
485
+ 'notification:shown': { message: string; level: 'info' | 'warn' | 'error' }
486
+ 'pane:error': { panelId: string; error: string; stack?: string }
487
+ }
488
+ // Apps extend via module augmentation:
489
+ // declare module '@boring/workspace' {
490
+ // interface BridgeEventMap { 'myapp:custom': { payload: string } }
491
+ // }
492
+ ```
493
+
494
+ **Bridge state scope**: The bridge sends `openPanels`, `activePanel`, `activeFile`,
495
+ and `visibleFiles` (paths currently shown in the file tree). It does NOT send the full
496
+ file tree state — the agent queries files via its own filesystem tools. This eliminates
497
+ the bandwidth problem of broadcasting 10K+ file entries on every panel change.
498
+
499
+ 3. **Transport**: **SSE + POST, command-based** (tenth pass). Workspace Zustand store is the authority. Agent posts commands via `POST /api/v1/ui/commands` → agent server validates and queues → SSE `GET /api/v1/ui/commands/next` delivers `event: command` to workspace → workspace executes locally → workspace `PUT /api/v1/ui/state` with `causedBy` field → agent reads via `get_ui_state` tool. All events carry `v:1` protocol version. Short-poll fallback (2s) for environments where SSE is unavailable. For browser-side agents (iframe/worker), **postMessage**. The bridge abstraction hides the transport.
500
+
501
+ 4. **Server endpoint**: all UI bridge endpoints are hosted by `@boring/agent` (not workspace). Workspace is a client of `PUT /api/v1/ui/state` + SSE `GET /api/v1/ui/commands/next`. See `boring-ui-v2/packages/agent/docs/plans/agent-package-spec.md`.
502
+
503
+ ### Server-side ownership
504
+
505
+ **Workspace v2 is frontend-only — no server code, no routes.** All backend routes are hosted by `@boring/agent`; workspace consumes them over HTTP.
506
+
507
+ | Routes | Owner | Notes |
508
+ |--------|-------|-------|
509
+ | `/api/v1/files` (GET/POST/DELETE) | **Agent** | Read/write/delete. Workspace consumes from file-tree + editor. |
510
+ | `/api/v1/tree` (GET) | **Agent** | Directory listing (lazy, per-dir). |
511
+ | `/api/v1/files/search` (GET) | **Agent** | Filename / glob search. Consumed by file-tree search input. |
512
+ | `/api/v1/stat` (GET) | **Agent** | File metadata. |
513
+ | `/api/v1/ui/state` (GET/PUT) | **Agent** | UI state KV (workspace writes layout state; agent reads via `get_ui_state` tool). |
514
+ | `/api/v1/ui/commands` (POST) | **Agent** | Agent posts UI command via `exec_ui` tool. |
515
+ | `/api/v1/ui/commands/next` (GET, SSE) | **Agent** | Workspace subscribes; receives commands; dispatches via Zustand store. |
516
+ | `/api/v1/agent/*` | **Agent** | Chat, sessions. |
517
+ | `/api/v1/git/*` | **Not in v1** | Git UI dropped; agent runs git via bash. Routes land when a UI needs them. |
518
+ | `/api/v1/workspaces/*` | **Cloud** (future) | Multi-workspace lifecycle. Not in v1. |
519
+ | Path validation + sandbox adapters (bwrap, directBash, nodeFs) | **Agent** | Moved in full from v1 workspace server. |
520
+
521
+ **Total v2 workspace server: 0 lines.** Workspace is a frontend library — installed into an app that also wires up `@boring/agent`'s backend. See `boring-ui-v2/packages/agent/docs/plans/agent-package-spec.md` for the full HTTP surface + rationale.
522
+
523
+ **Bridge transport (command-based, tenth pass)**: Agent posts commands via `POST /api/v1/ui/commands` → server validates and streams via SSE `GET /api/v1/ui/commands/next` (`event: command`) → workspace executes locally → workspace `PUT /api/v1/ui/state` with `causedBy` field. Workspace Zustand is the authority. All events carry `v:1` protocol version. No WebSocket.
524
+
525
+ ### Dynamic panes (post-launch)
526
+
527
+ > Post-launch feature — see **Appendix I** below. Not part of v2 delivery.
528
+ > Prerequisite: Phases 1-3a must ship first. No current app uses dynamic panes.
529
+
530
+ ## Phases
531
+
532
+ ### Phase 1: Foundation (shadcn shell + dockview wrapper)
533
+
534
+ **Goal**: Empty workspace renders with shadcn-themed dockview. No panels, no data. Just the layout engine working with persistence.
535
+
536
+ **Tasks:**
537
+
538
+ 1.1. **Project scaffold**
539
+ - `v2/packages/workspace/package.json` with deps: `react`, `dockview-react`, `tailwindcss`, `class-variance-authority`, `clsx`, `tailwind-merge`
540
+ - `tailwind.config.ts` with shadcn preset
541
+ - `tsconfig.json` (strict, paths aliases)
542
+ - CSS entry point with `@tailwind base/components/utilities` + shadcn CSS variables
543
+
544
+ 1.2. **shadcn UI vendoring**
545
+ - Vendor core components: `Button`, `Tabs`, `Tooltip`, `DropdownMenu`, `Sheet`, `ResizablePanel`, `ScrollArea`, `Input`, `Badge`, `Separator`
546
+ - All under `@boring/ui/`
547
+ - Tailwind-only, no custom CSS classes
548
+
549
+ 1.3. **Dockview integration layer**
550
+ - `DockviewShell` component: accepts `LayoutConfig` (groups with positions, constraints, initial panels), resolves panel IDs via registry, initializes dockview, manages lifecycle
551
+ - `LayoutConfig` type: `{ groups: GroupConfig[] }` — the composability primitive. Presets build these, apps can build custom ones.
552
+ - `PanelChrome` wrapper: shadcn header (title, icon, close button), body slot. Injects `PaneProps` into every panel component.
553
+ - `TabBar` wrapper: shadcn-styled tabs replacing dockview's default tab component
554
+ - `GroupChrome` wrapper: shadcn-styled group headers
555
+ - `dockview-reset.css`: strip dockview's default theme, let wrappers take over
556
+ - All visible chrome is tailwind classes on our wrapper components
557
+
558
+ 1.4. **Persistence engine (Zustand persist middleware)**
559
+ - Zustand store with `persist` middleware, two partitions:
560
+ - `boring-ui-v2:layout:{workspaceId}` — `{ version: '2.0', layout: DockviewSerializedLayout, sidebar: CollapsedState, sizes: PanelSizes }`
561
+ - `boring-ui-v2:preferences` — `{ theme: 'light' | 'dark' }` (never reset)
562
+ - Layout partition: version mismatch → call `onLayoutVersionMismatch()` callback (default: reset to defaults). Apps can provide custom migration.
563
+ - `partialize` selects which state slices persist to which key
564
+ - Auto-hydration on mount via Zustand's `onRehydrateStorage`
565
+ - `useLayoutPersistence()` hook: auto-save on dockview `onDidLayoutChange` (**debounced 300ms**,
566
+ flush on `beforeunload`), auto-restore on mount. Critical: `onDidLayoutChange` fires per-pixel
567
+ during sash drags — raw `toJSON()` without debounce freezes the main thread.
568
+ - **Persistence hardening:**
569
+ - Zod schema validation on restore (reject negative dimensions, path separators in panel IDs, strings >1KB)
570
+ - `QuotaExceededError` handling: try/catch on `localStorage.setItem()`. On quota error, clear layout key
571
+ and retry once. If still failing, disable persistence for session + toast "Layout changes won't be saved."
572
+ - Cross-tab: listen to `window.addEventListener('storage', ...)` for layout key changes from other tabs.
573
+ Do NOT auto-apply foreign changes mid-session (jarring UX). Last-writer-wins, documented.
574
+ - Size budget: target <50KB serialized. Log warning if >100KB.
575
+
576
+ 1.5. **Panel registry**
577
+ - `PanelRegistry` class: `register(id, config)`, `get(id)`, `list()`, `has(id)`, `resolve(filePattern)`
578
+ - Config shape: `PanelConfig` (see composability model — id, component, title, icon, placement, constraints, filePatterns, requiresCapabilities)
579
+ - `filePatterns` enables file-type routing: `registry.resolve('data.csv')` → first panel whose glob matches
580
+ - Lazy loading via `React.lazy()` for async components (PanelConfig accepts `() => Promise<...>`)
581
+ - `useRegistry()` hook + `RegistryProvider` context
582
+ - Built-in panels registered by default — **all lazy-loaded except `empty`**:
583
+ - `filetree`: `() => import('./panes/FileTreePane')` (~15KB gz with react-arborist)
584
+ - `code-editor`: `() => import('./panes/CodeEditorPane')` (~250KB gz with CodeMirror)
585
+ - `markdown-editor`: `() => import('./panes/MarkdownEditorPane')` (~300KB gz with tiptap)
586
+ - `data-catalog`: `() => import('./panes/DataCatalogPane')` (~2KB gz)
587
+ - `empty`: eagerly loaded (shown immediately on shell mount, <1KB)
588
+ - Initial bundle = dockview + shadcn + zustand + registry only (~120KB gz). Every pane is a
589
+ separate chunk loaded on first use. ChatLayout apps never download FileTree or editors.
590
+ - Apps add/replace panels via `WorkspaceProvider panels` prop — same PanelConfig contract
591
+ - App-registered panels with matching `filePatterns` override built-in defaults (more specific wins)
592
+
593
+ 1.6. **DockviewShell lifecycle managers** (internal to DockviewShell, not exported as hooks)
594
+ - `wireGroupPlaceholder(api, groupId, placeholderId)` — add placeholder when group empties, remove when panel added
595
+ - `wireCollapsible(api, groupId, collapsedWidth, expandedConstraints)` — manage collapse/expand with constraint switching
596
+ - `useDockviewApi()` hook — exposes `DockviewShellApi` (addPanel, removePanel, activatePanel, updatePanelParams, movePanel, batch, getGroup, setGroupCollapsed) to panes
597
+ - These live inside DockviewShell. Presets don't need to call them — the shell reads `dynamic`, `placeholder`, `collapsible` from GroupConfig and wires automatically.
598
+
599
+ 1.7. **Layout presets** (thin config wrappers — ~30 lines each)
600
+ - `IdeLayout`: builds a `LayoutConfig` with sidebar (left) + center (tabs) + optional right rail
601
+ - Props: `{ sidebar?: string, center?: string, right?: string }` — panel IDs, all overridable
602
+ - Defaults: `{ sidebar: 'filetree', center: 'empty', right: undefined }`
603
+ - `ChatLayout`: builds a `LayoutConfig` with nav rail (left) + chat stage (center) + surface (right)
604
+ - Props: `{ nav?: string, center?: string, surface?: string }`
605
+ - Defaults: `{ nav: 'session-list', center: 'chat', surface: undefined }`
606
+ - Both call `<DockviewShell layout={config} />` — they are not special, just convenience
607
+ - Apps that need custom arrangements skip presets and use `DockviewShell` directly with a `LayoutConfig`
608
+
609
+ **Deliverable**: A working workspace that renders an empty IDE layout with shadcn-themed dockview panels. Panels can be opened/closed/resized. Layout persists to localStorage and restores on reload.
610
+
611
+ ### Phase 2: Built-in panes
612
+
613
+ **Goal**: Ship the 4 core panes — file tree, markdown viewer, data catalog, code editor.
614
+
615
+ **Tasks:**
616
+
617
+ 2.1. **File tree pane**
618
+ - Virtualized tree using `react-arborist` (~15KB gz) — provides virtualized rendering,
619
+ keyboard navigation (arrow keys for expand/collapse/navigate), ARIA tree semantics
620
+ (`role="tree"` / `role="treeitem"` / `aria-expanded`), and drag-and-drop reordering.
621
+ O(visible) rendering, handles 10K+ files. Eliminates ~400 lines of custom tree logic
622
+ that would be needed with raw `react-window`.
623
+ - Collapsible directories, file icons, click-to-open
624
+ - Consumes file list from HTTP provider (`/api/v1/tree`, agent-hosted)
625
+ - Polling: `refetchInterval: 3000` (3s) for file list, paused during edits
626
+ - Search/filter input at top (shadcn `Input`) with debounced search (200ms).
627
+ Client-side filter for <5K files; falls back to server-side `/api/v1/files/search`
628
+ for larger workspaces via `/api/v1/files/search` (threshold configurable via WorkspaceProvider prop).
629
+ - Context menu (shadcn `DropdownMenu`): new file (`POST /api/v1/files`), new folder (`POST /api/v1/dirs`), rename (`POST /api/v1/files/move`), delete (`DELETE /api/v1/files`), copy path (client-only), open to side (bridge command)
630
+ - Drag-and-drop file moving: drag file/folder onto directory to move via `POST /api/v1/files/move` (same op as rename — path change only). Drop validation (can't drop into self/children). Visual drag-over feedback.
631
+ - Flat tree (no section system). Single root directory.
632
+ - No git status badges (git UI is agent responsibility)
633
+ - Used by both IdeLayout (sidebar) and ChatLayout (file browser in surface)
634
+
635
+ 2.2. **Markdown editor pane**
636
+ - Port v1's tiptap editor: `packages/workspace/src/front/components/Editor.jsx` (912 lines)
637
+ - 10 extensions (reduced from 16): StarterKit, Underline, Link, Placeholder, TaskList+TaskItem, TextAlign, Highlight, Image (official, no resize), CodeBlockLowlight, Markdown. (DiffExtension dropped with diff mode.)
638
+ - **Dropped**: Table suite (4 packages), `tiptap-extension-resize-image` (third-party), FrontmatterEditor
639
+ - Toolbar: Bold, Italic, Underline, Strikethrough, H1/H2/H3, Bullet/Ordered/Task list, Quote, Code block, Link, Horizontal rule, Highlight, Image (URL prompt)
640
+ - Single mode: normal edit. Diff-vs-HEAD deferred to v1.x (git routes not in v1).
641
+ - Restyle with shadcn/tailwind (replace v1's custom CSS classes)
642
+ - Read/write via HTTP provider. Auto-save debounced 1000ms.
643
+ - Tab dirty state + external file change detection (same as code editor)
644
+ - Lazy-loaded: tiptap bundles (~650KB) only load on first markdown file open
645
+ - The component (`MarkdownEditor`) is standalone; the pane wraps it in dockview
646
+
647
+ 2.3. **Data catalog pane**
648
+ - List of data sources / connections
649
+ - shadcn `Card` components for each source
650
+ - Click to preview data in center panel
651
+ - Minimal — similar to current 87-line implementation
652
+
653
+ 2.4. **Code editor pane** (NEW — v1 uses react-simple-code-editor, v2 upgrades to CodeMirror 6)
654
+ - CodeMirror 6 integration (full rewrite, not a port)
655
+ - **Large file handling** (CM6 has NO worker-based tokenization — Lezer parses incrementally on main thread):
656
+ - **<1MB**: Full syntax highlighting, incremental Lezer parsing (default CM6 behavior)
657
+ - **≥1MB**: Read-only mode with syntax highlighting disabled. Show banner:
658
+ "Large file — editing disabled." + download link for files >10MB.
659
+ - Language support: JS/TS, Python, JSON, YAML, Markdown, SQL (all bundled initially; measure chunk size in Phase 2 — cut to 3 bundled + lazy rest only if total budget exceeded)
660
+ - Theme: wire CodeMirror theme to shadcn CSS variables via `EditorView.theme()`
661
+ - Read/write via HTTP provider
662
+ - Single mode: normal edit. Diff-vs-HEAD deferred to v1.x (git routes not in v1).
663
+ - Line numbers, word wrap toggle
664
+ - Tab dirty state: dot indicator on tab when unsaved changes exist
665
+ - Auto-save: debounced 1000ms after last keystroke
666
+ - External file change detection: if file changes on disk while editor is open, auto-sync when editor is not dirty. Suppress stale reads for 3s after save.
667
+ - Lazy-loaded: CodeMirror bundles (~250KB gz) only load on first code file open
668
+
669
+ 2.4a. **Shared editor lifecycle hook** (`useEditorLifecycle`)
670
+ - Shared between CodeMirror and Tiptap editors (~100 LOC)
671
+ - Handles: dirty state tracking (bridge `markDirty`/`markClean`), auto-save debounce (1000ms),
672
+ external file change detection (auto-sync when not dirty, suppress stale reads 3s after save),
673
+ tab dirty indicator wiring
674
+ - Each editor provides an adapter: `{ isDirty: () => boolean, save: () => Promise<void>, getContent: () => string }`
675
+ - Eliminates ~80 LOC duplication between CodeEditorPane and MarkdownEditorPane
676
+
677
+ 2.5. **Empty/placeholder pane**
678
+ - Shown when no file is open
679
+ - shadcn-styled welcome screen with keyboard shortcuts
680
+ - "Open file" action
681
+
682
+ 2.6. **HTTP data provider**
683
+ - Typed fetch client for `/api/v1/files`, `/api/v1/tree`, `/api/v1/stat`, `/api/v1/files/search` (all agent-hosted). Git routes NOT in v1 — git UI is dropped per decisions table.
684
+ - React Query integration: `useFileContent(path)`, `useFileList(dir)`, `useFileWrite()`
685
+ - `DataProvider` context so panes access data without knowing the transport
686
+ - Error handling: 401 → redirect to auth, 404 → file not found UI
687
+
688
+ **Deliverable**: Functional IDE with file tree sidebar, code editor + markdown editor center, data catalog. Files load from server. Edits save back. Components also usable standalone (without dockview) for simple apps.
689
+
690
+ ### Phase 3a: Agent integration — bridge + static pane
691
+
692
+ **Goal**: Agent pane renders in workspace. Agent can manipulate the UI through a shared state bridge.
693
+
694
+ **Tasks:**
695
+
696
+ 3a.1. **Agent pane slot**
697
+ - **Updated 2026-04-24 — supersedes earlier "workspace does NOT import agent" line.** Workspace now imports `ChatPanel` from `@boring/agent` directly and registers it as a built-in pane. Matches the new dep chain `agent (leaf) ← workspace ← core`. App shells can still override by passing their own `ChatPanel` via `WorkspaceProvider`'s `panels` prop.
698
+ - Registered in panel registry with `placement: 'right'` (IDE) or `slot: 'chat'` (Chat layout)
699
+ - Lazy-loaded with Suspense wrapper
700
+
701
+ 3a.2. **Workspace bridge — state store**
702
+ - Zustand store: `useWorkspaceStore()`
703
+ - State: `{ panels, activePanel, activeFile, sidebar, notifications, dirtyFiles }`
704
+ - `dirtyFiles` slice: `Map<string, { panelId: string, savedAt: number | null }>` — updated by
705
+ editor panes via `bridge.markDirty(path)` / `bridge.markClean(path)`. Exposed to agents via
706
+ `bridge.getDirtyFiles()`. Enables auto-save-before-commit workflows.
707
+ - Actions: `openFile`, `openPanel`, `closePanel`, `showNotification`, `navigateToLine`
708
+ - Subscribe API: `store.subscribe(selector, handler)`
709
+ - **Zustand selector discipline (enforced by API design, not ESLint):**
710
+ - `useWorkspaceStore()` is NOT exported from the public API
711
+ - Only atomic selector hooks are exported:
712
+ ```typescript
713
+ export const useActiveFile = () => useWorkspaceStore(s => s.activeFile)
714
+ export const useActivePanel = () => useWorkspaceStore(s => s.activePanel)
715
+ export const useSidebarState = () => useWorkspaceStore(s => s.sidebar)
716
+ export const useOpenPanels = () => useWorkspaceStore(s => s.panels)
717
+ export const useDirtyFiles = () => useWorkspaceStore(s => s.dirtyFiles)
718
+ ```
719
+ - Internal code CAN use `useWorkspaceStore(selector)` with selectors. External consumers cannot.
720
+
721
+ 3a.3. **Workspace bridge — server endpoint (command-based, tenth pass)**
722
+ - Agent posts commands via `POST /api/v1/ui/commands` → server validates (Zod) → queues
723
+ - SSE `GET /api/v1/ui/commands/next` delivers `event: command` to workspace
724
+ - Workspace executes commands locally (Zustand store update)
725
+ - Workspace pushes state via `PUT /api/v1/ui/state` with `causedBy: 'user' | 'agent' | 'restore'`
726
+ - Agent reads state via `get_ui_state` tool (from server's cached copy)
727
+ - All events carry `v:1` protocol version field
728
+ - Workspace Zustand store is the authority — server is a relay and cache
729
+ - Matches existing chat stream pattern, ~20 LOC server
730
+ - **Short-poll fallback** (2s): `GET /api/v1/ui/commands/next?poll=true` + `PUT /api/v1/ui/state` for environments where SSE is unavailable
731
+
732
+ 3a.4. **Chat-centered layout integration**
733
+ - Chat layout shell with agent chat in main area
734
+ - **Artifact surface = nested DockviewShell** (right panel, own state + persistence)
735
+ - `ArtifactSurfacePane` renders its own `<DockviewShell>` with `storageKey="v2:surface"` and `allowedPanels` guard (tenth pass)
736
+ - Follows v1's `SurfaceDockview` pattern: filtered registry via `allowedPanels`, gets layout via props, uses callbacks for state flow
737
+ - Sync suppression flag prevents feedback loops between outer and inner state
738
+ - Only rendered when artifacts exist (conditional mount)
739
+ - Session list UI (workspace renders, agent owns state — see resolved questions)
740
+ - **Chat hooks (simplified from v1's 8 → 2):**
741
+ - `useArtifactPanels()` (~50 lines) — open/close/track artifact panels in the surface dockview
742
+ - `useArtifactRouting()` (~30 lines) — optional convenience: maps tool names to panel types
743
+ - **Eliminated by bridge**: useShellStatePublisher (→ store subscriptions), useShellPersistence (→ Zustand persist), useSessionState/Store/ServerSessions (→ agent owns via bridge props), useToolBridge (→ agent calls bridge.openPanel() explicitly)
744
+ - NavRail props: `{ sessions, activeSessionId, onSwitch, onCreate, onNewChat, onToggleSurface, surfaceOpen }` — app shell passes user menu separately
745
+
746
+ **Deliverable**: Agent pane visible in workspace. Agent can open files, show artifacts, navigate. User sees agent actions in real-time.
747
+
748
+ ### Phase 4: Polish & production hardening
749
+
750
+ **Goal**: Theme, responsive, keyboard shortcuts, error recovery.
751
+
752
+ **Tasks:**
753
+
754
+ 4.1. **Theme system**
755
+ - Light/dark mode via shadcn CSS variables
756
+ - CodeMirror theme synced to workspace theme
757
+ - Dockview chrome follows theme automatically (via wrapper components)
758
+ - `useTheme()` hook + `ThemeProvider`
759
+
760
+ 4.2. **Command Palette + Keyboard shortcuts**
761
+ - `Cmd+P` → **Command Palette** (shadcn `CommandDialog`)
762
+ - No prefix = file quick-open (fuzzy match)
763
+ - `>` prefix = filter registered commands
764
+ - Commands contributed via `registry.registerCommand({ id, title, run, shortcut? })`
765
+ - Built-in commands: Toggle Sidebar, Toggle Agent Panel, Save File, Close Tab
766
+ - `Cmd+B` → toggle sidebar
767
+ - `Cmd+\` → toggle agent panel
768
+ - `Cmd+S` → save active file
769
+ - `Cmd+W` → close active tab
770
+ - Panels and extensions register custom commands at runtime via the registry
771
+
772
+ 4.3. **Responsive layout**
773
+ - Mobile: sidebar collapses to sheet (shadcn `Sheet`)
774
+ - Tablet: auto-collapse sidebar below breakpoint
775
+ - Minimum panel sizes enforced
776
+
777
+ 4.4. **Error handling & recovery**
778
+
779
+ **Network errors:**
780
+ - HTTP fetch wrapper: auto-retry 3x with exponential backoff for 5xx / network errors
781
+ - 401/403: fire `onAuthError` callback (app shell handles redirect to login)
782
+ - Timeout: 10s default, configurable per route. Show toast on timeout.
783
+ - Offline detection: `navigator.onLine` + fetch probe. Show banner, disable writes.
784
+
785
+ **Corrupted state:**
786
+ - Zustand `onRehydrateStorage`: validate layout JSON against schema before applying
787
+ - Invalid layout (missing required fields, negative dimensions): reset to defaults + toast
788
+ - Corrupted dockview state (panel references non-existent component): strip unknown panels, keep valid ones
789
+ - Reset button in UI: user can force-reset layout from workspace menu
790
+
791
+ **Panel crashes:**
792
+ - React error boundary per pane: crashed pane shows error + retry button, doesn't kill workspace
793
+ - Error logged to console with panel ID and stack trace
794
+ - Bridge notified: `bridge.emit('pane:error', { panelId, error })` so agent can react
795
+
796
+ **Recovery hierarchy (2-tier — simplified from 3):**
797
+ 1. Retry (panel error boundary — per-pane, doesn't kill workspace)
798
+ 2. Full reset (corrupted layout → load default layout, lose customization)
799
+ No middle tier ("strip invalid panels") — either the layout is valid or it resets.
800
+
801
+ **Correlated failure handling**: Deferred to post-launch (ninth pass). For v2 launch,
802
+ individual pane error boundaries + bridge SSE reconnection banner are sufficient.
803
+ If correlated failures (backend crash killing all panes) become a real problem, add
804
+ `WorkspaceHealthMonitor` then. Bridge `onDisconnect` / `onReconnect` callbacks
805
+ still exposed on `WorkspaceBridge` for app-level handling.
806
+
807
+ 4.5. **Testing (4-layer strategy)**
808
+
809
+ **Layer 1: Unit tests (fast, precise)**
810
+ - Hooks with mocked dockview API: `useSidebarLayout`, `usePanelSizing`, `usePanelActions`
811
+ - Registry: register, get, capability checks, available panes
812
+ - Persistence: Zustand store serialize/deserialize, version mismatch → callback (default reset)
813
+ - Bridge: command validation, rate limiter, state subscriptions
814
+ - Run: `vitest`, <5s total
815
+
816
+ **Layer 2: Storybook visual regression (design correctness)**
817
+ - Stories for each standalone component: FileTree, CodeEditor, MarkdownEditor, DataCatalog
818
+ - Stories for shadcn-wrapped dockview chrome: PanelChrome, TabBar, GroupChrome
819
+ - Stories for each pane in isolation (mocked data)
820
+ - Screenshot comparison via Chromatic or Percy
821
+ - Catches: CSS regressions, theme inconsistencies, responsive breakpoints
822
+
823
+ **Layer 3: Playwright E2E (golden paths)**
824
+ - Scripted scenarios: open file → edit → save, resize sidebar → persist → restore, open panel → close
825
+ - Chat layout: session switch, artifact open, agent message
826
+ - Bridge: agent opens file → workspace reflects, user action → bridge event fires
827
+ - **Bridge protocol E2E** (~50 LOC): Playwright opens workspace UI + a separate HTTP client
828
+ simulates the agent. Agent POSTs `openFile` command → SSE `event: command` delivers to workspace →
829
+ workspace applies locally (Zustand) → file panel appears in the UI. Tests the real SSE/polling path end-to-end.
830
+ - Run: CI, ~2 min
831
+
832
+ **Layer 4: Bombadil property-based exploration (unknown unknowns)**
833
+ - Framework: [Antithesis Bombadil](https://antithesishq.github.io/bombadil/) — autonomous
834
+ random exploration of the workspace UI, validating invariants after every action.
835
+ - **Properties (invariants that must always hold):**
836
+ - Layout has no overlapping panels (no negative widths/heights)
837
+ - Essential panels (filetree) cannot be closed
838
+ - Sidebar collapse/expand is reversible (collapse → expand = same width)
839
+ - Layout serialization round-trips: `toJSON() → fromJSON() → toJSON()` is idempotent
840
+ - No React error boundary triggers during normal exploration
841
+ - Bridge state matches visible UI (open panels list = actual rendered panels)
842
+ - Active file in bridge = file shown in editor
843
+ - **Actions (random sequences):**
844
+ - Open/close panels, resize sashes, collapse/expand sidebar
845
+ - Click files in tree, switch tabs, trigger keyboard shortcuts
846
+ - Send bridge commands (openFile, openPanel, closePanel, showNotification)
847
+ - Toggle theme, switch layouts (if toggle available)
848
+ - Write dynamic pane JSX, trigger hot-reload
849
+ - **Value**: Finds race conditions, state corruption, and edge cases that scripted tests
850
+ miss. Especially valuable for dockview (huge state space) and bridge (async commands).
851
+ - Run: Nightly CI or on-demand, 10-30 min exploration per run
852
+
853
+ **CI Pipeline (zero manual testing):**
854
+ ```
855
+ On every PR:
856
+ 1. vitest run # Layer 1: unit tests (~5s)
857
+ 2. tsc --noEmit # Type check (~10s)
858
+ 3. playwright test # Layer 3: E2E golden paths (~2 min)
859
+ 4. storybook build + chromatic # Layer 2: visual regression (~3 min)
860
+ └── Total: ~6 min per PR
861
+
862
+ Nightly:
863
+ 5. bombadil explore --duration 30m # Layer 4: property-based exploration
864
+ 6. axe-core scan (via Storybook) # Accessibility audit
865
+ └── Reports to Slack/GitHub issue if failures found
866
+
867
+ On merge to main:
868
+ 7. Full E2E suite (all browsers)
869
+ 8. Bundle size check (fail if > budget)
870
+ ```
871
+
872
+ No human testing required for any phase. Every feature is covered by at least one
873
+ automated layer before merge.
874
+
875
+ 4.6. **Accessibility**
876
+ - WCAG 2.1 AA target (shadcn components are already AA-compliant)
877
+ - Keyboard navigation: Tab through panels, Escape to close, arrow keys in file tree
878
+ - Focus management: focus ring on active panel, focus trap in modals (shadcn `Dialog`)
879
+ - Screen reader: ARIA labels on panels (`aria-label="File tree"`, `role="tabpanel"`)
880
+ - axe-core automated scanning on all Storybook stories (catches 57% of WCAG issues)
881
+ - Manual screen reader testing deferred to post-launch (not blocking v2)
882
+
883
+ 4.7. **Performance budgets**
884
+
885
+ | Metric | Target | Measured by |
886
+ |--------|--------|-------------|
887
+ | Initial JS (shell + registry, no panes) | <150KB gzipped | `vite build` chunk analysis |
888
+ | Total all-loaded ceiling | <800KB gzipped | sum of all workspace chunks |
889
+ | Shell render (empty layout) | <500ms TTI | Playwright `performance.mark()` |
890
+ | File tree (1000 files) | <200ms render | Vitest benchmark |
891
+ | First CodeMirror render | <500ms | Playwright timer |
892
+ | First tiptap render | <500ms | Playwright timer |
893
+ | Layout persistence round-trip | <50ms | Vitest benchmark |
894
+ | Bridge command latency | <100ms (SSE), <2s (short-poll fallback) | Playwright timer |
895
+
896
+ **Budget enforcement**: CI runs `vite build` and checks two numbers: initial chunk <150KB gz,
897
+ total workspace chunks <800KB gz. `import()` boundaries guarantee the initial bundle never
898
+ includes editor code. Per-chunk budgets are not tracked individually — if total is under 800KB,
899
+ the split between editors doesn't matter.
900
+
901
+ 4.8. **i18n**: Deferred entirely. Plain English strings in JSX. No `t()` wrapper, no extraction
902
+ tooling. When i18n is needed, run a codemod (ast-grep or jscodeshift) to extract strings —
903
+ modern tooling makes this trivial on an existing codebase.
904
+
905
+ 4.9. **Content Security Policy (CSP) compatibility**
906
+
907
+ The workspace must work under a strict CSP in hosted/multi-tenant deployments.
908
+ Target policy:
909
+ ```
910
+ default-src 'self';
911
+ script-src 'self';
912
+ style-src 'self' 'unsafe-inline'; // Required: CodeMirror 6 style-mod injects <style> tags
913
+ connect-src 'self'; // SSE + POST are standard HTTP, no wss: needed
914
+ img-src 'self' data: blob:; // Required: inline images in markdown
915
+ font-src 'self';
916
+ ```
917
+
918
+ **Known CSP constraints:**
919
+ - `style-src 'unsafe-inline'` required by CodeMirror 6's `style-mod` library (no nonce support upstream)
920
+ - Tiptap HTML paste: configure `HTMLSanitizer` extension to strip `<script>`, `on*` attributes,
921
+ `javascript:` URLs, and `<iframe>` on paste. Default tiptap does NOT sanitize HTML paste.
922
+ - Dynamic panes (POST-LAUNCH): esbuild returns JS from `'self'` origin, no `'unsafe-eval'` needed.
923
+ - `eval()` is never used. No `'unsafe-eval'` in script-src.
924
+ - **Test**: Playwright test that sets strict CSP header and verifies workspace loads and functions
925
+ (open file, edit, save, bridge command) without CSP violations.
926
+
927
+ 4.10. **Test harness for consumer apps** (`@boring/workspace/testing`)
928
+
929
+ Apps that consume `@boring/workspace` need to test their custom panels and integrations
930
+ without standing up a real server or full provider tree. Workspace exports test utilities
931
+ as a separate entry point, tree-shaken from production builds:
932
+
933
+ ```typescript
934
+ // @boring/workspace/testing
935
+ export { TestWorkspaceProvider } from './testing/TestWorkspaceProvider'
936
+ export { createMockBridge } from './testing/createMockBridge'
937
+ export { createMockRegistry } from './testing/createMockRegistry'
938
+ export { renderPane } from './testing/renderPane'
939
+ ```
940
+
941
+ `renderPane()` wraps the component in `TestWorkspaceProvider` (mock registry, mock bridge,
942
+ mock data provider with fixture data, shadcn theme). Apps never assemble the provider tree manually.
943
+
944
+ `createMockBridge()` returns a `WorkspaceBridge` with all methods as `vi.fn()` stubs plus
945
+ optional state overrides. Supports `bridge.emit()` for simulating agent events in tests.
946
+
947
+ Child apps can run the full workspace test suite against their own integrations.
948
+
949
+ 4.11. **Sample app** (`apps/workspace-playground`)
950
+
951
+ Minimal isolated test environment for the workspace package. No backend required.
952
+
953
+ ```
954
+ apps/workspace-playground/
955
+ ├── package.json # deps: @boring/workspace, @boring/core, vite, react
956
+ ├── vite.config.ts
957
+ ├── src/
958
+ │ ├── main.tsx
959
+ │ ├── App.tsx # IdeLayout with mock data provider
960
+ │ ├── mockProvider.ts # In-memory file data (~50 fixture files)
961
+ │ └── fixtures/ # .ts, .md, .json, .csv, .py sample files
962
+ ```
963
+
964
+ - `pnpm --filter workspace-playground dev` to run
965
+ - Uses `WorkspaceProvider` with mock data (no `/api` proxy, no backend)
966
+ - Renders `IdeLayout` with built-in panels (filetree, editors, empty)
967
+ - Smoke test for the package: if the playground renders, the package works
968
+ - Also usable as a visual development environment for workspace contributors
969
+
970
+ ## File structure (Phase 1 target)
971
+
972
+ ```
973
+ v2/packages/workspace/
974
+ ├── package.json
975
+ ├── tsconfig.json
976
+ ├── tailwind.config.ts
977
+ ├── postcss.config.js
978
+ ├── src/
979
+ │ ├── index.ts # Public API
980
+ │ ├── globals.css # Tailwind directives + shadcn CSS vars
981
+ │ ├── lib/
982
+ │ │ └── utils.ts # cn() helper (clsx + twMerge)
983
+ │ ├── components/ # Standalone React components (usable without dockview)
984
+ │ │ ├── FileTree.tsx # File browser — standalone, imported by FileTreePane
985
+ │ │ ├── CodeEditor.tsx # CodeMirror 6 wrapper — standalone
986
+ │ │ ├── MarkdownEditor.tsx # WYSIWYG markdown (Milkdown or tiptap) — standalone
987
+ │ │ ├── DataCatalog.tsx # Data source list — standalone
988
+ │ │ └── ui/ # Vendored shadcn components
989
+ │ │ ├── button.tsx
990
+ │ │ ├── tabs.tsx
991
+ │ │ ├── tooltip.tsx
992
+ │ │ ├── dropdown-menu.tsx
993
+ │ │ ├── sheet.tsx
994
+ │ │ ├── scroll-area.tsx
995
+ │ │ ├── input.tsx
996
+ │ │ ├── badge.tsx
997
+ │ │ ├── separator.tsx
998
+ │ │ └── resizable.tsx
999
+ │ ├── dock/ # Dockview integration (chrome + wrappers)
1000
+ │ │ ├── PanelChrome.tsx # Panel header + body wrapper (shadcn)
1001
+ │ │ ├── TabBar.tsx # shadcn-styled tab component
1002
+ │ │ ├── GroupChrome.tsx # Group header wrapper
1003
+ │ │ └── dockview-overrides.css # Minimal overrides for dockview internals (<200 lines)
1004
+ │ ├── layouts/
1005
+ │ │ ├── DockviewShell.tsx # Core dockview container — accepts LayoutConfig
1006
+ │ │ ├── IdeLayout.tsx # Preset: sidebar + center + right rail (~30 lines)
1007
+ │ │ ├── ChatLayout.tsx # Preset: nav rail + chat + surface (~30 lines)
1008
+ │ │ └── types.ts # LayoutConfig, GroupConfig, IdeLayoutProps, ChatLayoutProps
1009
+ │ ├── store/ # Single Zustand store (tenth pass — one store, partitioned persist)
1010
+ │ │ ├── index.ts # useWorkspaceStore (NOT exported). Persisted: layout, preferences. Ephemeral: bridge state.
1011
+ │ │ └── selectors.ts # Atomic hooks: useActiveFile, useActivePanel, useSidebarState, etc.
1012
+ │ ├── persistence/
1013
+ │ │ └── useLayoutPersistence.ts # Auto-save/restore hook (wires dockview events to store)
1014
+ │ ├── registry/
1015
+ │ │ ├── PanelRegistry.ts # Register/get/list/resolve panels
1016
+ │ │ ├── CommandRegistry.ts # Register/get commands (tenth pass — separate from panels)
1017
+ │ │ ├── RegistryProvider.tsx # React context
1018
+ │ │ ├── types.ts # PanelConfig, PaneProps, CommandConfig, PanelLifecycleApi
1019
+ │ │ └── dynamicLoader.ts # Hot-load agent panes (Phase 3b, see Appendix I)
1020
+ │ ├── panes/ # Dockview pane wrappers (component + panel params)
1021
+ │ │ ├── FileTreePane.tsx # Wraps FileTree + dockview params
1022
+ │ │ ├── MarkdownEditorPane.tsx # Wraps MarkdownEditor + file load/save
1023
+ │ │ ├── CodeEditorPane.tsx # Wraps CodeEditor + file load/save
1024
+ │ │ ├── DataCatalogPane.tsx # Wraps DataCatalog
1025
+ │ │ └── EmptyPane.tsx # Placeholder
1026
+ │ ├── bridge/ # Agent-UI bridge (Phase 3) — reads/writes the single store
1027
+ │ │ ├── commands.ts # Command definitions (client-side types + dispatcher)
1028
+ │ │ ├── client.ts # SSE subscriber + PUT helpers hitting @boring/agent
1029
+ │ │ └── useWorkspaceBridge.ts # Hook for panes to interact
1030
+ │ ├── hooks/
1031
+ │ │ ├── useDockLayout.ts # Sidebar discovery, collapse
1032
+ │ │ ├── useEditorLifecycle.ts # Shared editor lifecycle (dirty, auto-save, external change)
1033
+ │ │ ├── usePanelActions.ts # Open/close/activate
1034
+ │ │ ├── useFileData.ts # React Query file hooks
1035
+ │ │ └── useTheme.ts # Light/dark mode
1036
+ │ └── theme/
1037
+ │ ├── codemirror-theme.ts # CM6 theme from shadcn vars
1038
+ │ └── tokens.ts # Design token constants
1039
+ ├── docs/
1040
+ │ └── plans/
1041
+ │ └── WORKSPACE_V2_PLAN.md # This file
1042
+ └── __tests__/
1043
+ ├── persistence.test.ts
1044
+ ├── registry.test.ts
1045
+ └── bridge.test.ts
1046
+ ```
1047
+
1048
+ ## Resolved questions
1049
+
1050
+ | Question | Answer |
1051
+ |----------|--------|
1052
+ | Agent pane folder (DEFERRED) | `/workspace/panes/` — visible in workspace root when dynamic panes ship (Appendix I, post-v2). |
1053
+ | Dynamic pane API (DEFERRED) | **Full bridge access.** Pane receives `{ theme, data, panelId, bridge: WorkspaceBridge }`. Agent panes can orchestrate the workspace. Not in v2 scope — see Appendix I. |
1054
+ | Chat session ownership | **Agent owns session state** (CRUD, messages, active session). **Workspace owns session list UI** — renders `{ id, title, updatedAt }[]` from agent, calls agent's `switchSession()`/`createSession()`. Clean data/view split. |
1055
+ | Monorepo tooling | **pnpm workspaces + Vite.** Already familiar from v1. Minimal config. |
1056
+
1057
+ ### Dynamic pane props contract
1058
+
1059
+ ```typescript
1060
+ interface DynamicPaneProps {
1061
+ theme: 'light' | 'dark'
1062
+ data: unknown // passed by agent when opening the panel
1063
+ panelId: string
1064
+ bridge: WorkspaceBridge // full workspace API
1065
+ }
1066
+ ```
1067
+
1068
+ ### Session list ownership boundary
1069
+
1070
+ ```
1071
+ @boring/agent (owns state):
1072
+ sessions: Session[]
1073
+ activeSessionId: string
1074
+ switchSession(id: string): void
1075
+ createSession(): Session
1076
+ deleteSession(id: string): void
1077
+
1078
+ @boring/workspace (owns UI):
1079
+ <SessionList
1080
+ sessions={agent.sessions}
1081
+ activeId={agent.activeSessionId}
1082
+ onSwitch={agent.switchSession}
1083
+ onCreate={agent.createSession}
1084
+ onDelete={agent.deleteSession}
1085
+ />
1086
+ ```
1087
+
1088
+ Workspace never knows what a "session" contains (messages, AI state). It just renders a list with titles and handles the chrome (active indicator, create button, context menu).
1089
+
1090
+ ## v1 → v2 reference map
1091
+
1092
+ Guide for the implementing agent. For each v2 area, the v1 file(s) to study for patterns, logic to port, and lessons learned. All paths relative to repo root.
1093
+
1094
+ ### Phase 1: Foundation
1095
+
1096
+ | v2 target | v1 reference | What to learn |
1097
+ |-----------|-------------|---------------|
1098
+ | `DockviewShell.tsx` | `packages/workspace/src/front/layouts/ide/IdeLayout.jsx` (lines 1–80, 400–500) | Dockview initialization: `<DockviewReact>` setup, `onReady` callback, component map registration, `api.fromJSON()` for restore. **Simplify**: v1 is 1718 lines — most is panel lifecycle that belongs in hooks, not the shell. |
1099
+ | `PanelChrome.tsx` | `packages/workspace/src/front/components/DockTab.jsx` (143 lines) | Tab rendering with file icons, close button, active state. Replace all custom CSS classes with shadcn/tailwind. |
1100
+ | `dockview-reset.css` | `packages/workspace/src/front/layouts/ide/IdeLayout.jsx` — look for `.dockview-*` overrides in imported CSS | Identify which dockview classes need zeroing out. Also check `node_modules/dockview-react/dist/styles/dockview.css` for the full default theme. |
1101
+ | `store/index.ts` | `packages/workspace/src/front/persistence/LayoutManager.js` (618 lines) | Single Zustand store (tenth pass). Layout save/restore logic: `dockApi.toJSON()` serialization, localStorage read/write, validation. Persisted: layout, preferences. Ephemeral: bridge state. **Drop**: version migration system, `lastKnownGoodLayout` backup, multi-key storage. Keep: `toJSON()`/`fromJSON()` round-trip, validation. |
1102
+ | `PanelRegistry.ts` | `packages/workspace/src/front/registry/panes.jsx` (484 lines) | Registration pattern: `register(id, { component, title, icon, placement, constraints, requires })`. Lazy loading via `React.lazy()`. Capability gating pattern. **Drop**: `requiresRouters`, `requiresFeatures` (legacy). Keep: `requiresCapabilities` for optional runtime gating. |
1103
+ | `IdeLayout.tsx` | `packages/workspace/src/front/layouts/ide/IdeLayout.jsx` (full file, 1718 lines) | v1 is 1718 lines because layout logic, hooks, and panel lifecycle are inlined. **v2 approach**: IdeLayout is ~30 lines — it builds a `LayoutConfig` and passes it to `DockviewShell`. All the heavy logic lives in DockviewShell and shared hooks. Study v1 for the *group arrangement* (sidebar left locked, center tabs, right rail) and constraints, then express as a `LayoutConfig`. |
1104
+ | `ChatLayout.tsx` | `packages/workspace/src/front/layouts/chat/ChatCenteredWorkspace.jsx` (434 lines) | Same simplification as IdeLayout. v2 ChatLayout is ~30 lines building a `LayoutConfig` (nav rail left, chat center, surface right). Study v1 for group arrangement + `SurfaceShell.jsx` (544 lines) for the artifact surface dockview. |
1105
+ | `useDockLayout.ts` | `packages/workspace/src/front/hooks/useDockLayout.js` (337 lines) | Sidebar discovery (`findSidebarGroup()`), collapse toggle (`toggleSidebar()`), center group tracking. Panel activation. Group constraint management. |
1106
+ | `usePanelActions.ts` | `packages/workspace/src/front/hooks/usePanelActions.js` (16 KB) | Open/close/activate panel logic. File-to-panel routing. Editor tab management. **Heavy file** — extract only the core open/close/activate pattern, leave file-specific logic to panes. |
1107
+
1108
+ ### Phase 2: Built-in panes
1109
+
1110
+ | v2 target | v1 reference | What to learn |
1111
+ |-----------|-------------|---------------|
1112
+ | `FileTreePane.tsx` | `packages/workspace/src/front/components/FileTree.jsx` (1129 lines) + `packages/workspace/src/front/panels/FileTreePanel.jsx` (344 lines) | Tree rendering with sections (files, data catalog). Expand/collapse state. Click-to-open dispatching. Context menu (new/rename/delete). **Rewrite**: replace custom CSS with shadcn Tree/Accordion. Use CodeMirror file icons or lucide. |
1113
+ | `MarkdownEditor.tsx` + `MarkdownEditorPane.tsx` | `packages/workspace/src/front/components/Editor.jsx` (912 lines) | Port directly. Keep tiptap. Restyle with shadcn/tailwind. Port: toolbar, frontmatter section, task lists, tables, code blocks with syntax highlighting. |
1114
+ | `CodeEditorPane.tsx` | `packages/workspace/src/front/components/CodeEditor.jsx` (176 lines, uses `react-simple-code-editor` + Prism) + `packages/workspace/src/front/panels/EditorPanel.jsx` (417 lines) | File loading (`useFileContent()`), save (`useFileWrite()`), dirty state (`isDirty`, `isSaving`), external change detection, editor mode switching (normal/diff). **Full rewrite**: `react-simple-code-editor` → CodeMirror 6. Port the file load/save/dirty pattern, rewrite the editor internals. |
1115
+ | `DataCatalogPane.tsx` | `packages/workspace/src/front/panels/DataCatalogPanel.jsx` (87 lines) | Simple list of data sources. Minimal. Port almost directly, just swap CSS to shadcn Card/Badge. |
1116
+ | `EmptyPane.tsx` | `packages/workspace/src/front/panels/EmptyPanel.jsx` (44 lines) | Placeholder with welcome message. Trivial to rewrite with shadcn. |
1117
+ | `useFileData.ts` | `packages/workspace/src/front/providers/data/queries.js` (10 KB) + `packages/workspace/src/front/providers/data/httpProvider.js` (8.4 KB) | React Query hooks: `useFileContent(path)`, `useFileList(dir)`, `useFileWrite()`, `useFileSearch()`. Query key patterns. HTTP provider with auth header injection, 401 retry. **Port**: the query hooks + HTTP client. **Drop**: `useGitStatus` (git UI dropped in v2), lightningFs, isomorphicGit, pyodide providers (all moved to agent or dropped). |
1118
+
1119
+ ### Phase 3: Agent integration
1120
+
1121
+ | v2 target | v1 reference | What to learn |
1122
+ |-----------|-------------|---------------|
1123
+ | `bridge/client.ts` + `bridge/commands.ts` | `packages/workspace/src/server/services/uiStateImpl.ts` (201 lines) + `packages/workspace/src/front/utils/frontendState.js` (2.6 KB) | Current UI state bridge: panel snapshots, command queuing, client-scoped state. v2: bridge reads/writes the single Zustand store (tenth pass). SSE client in `bridge/client.ts`, command dispatcher in `bridge/commands.ts`. Study the state shape: `{ openPanels, activeFile, commands }`. |
1124
+ | `bridge/client.ts` | `packages/workspace/src/server/http/uiStateRoutes.ts` (289 lines) | Current HTTP endpoints: `GET /ui/state`, `POST /ui/command`, `GET /ui/panels`. **Replace** with command-based bridge (tenth pass): SSE `event: command` stream + `PUT /api/v1/ui/state` with `causedBy` + POST commands. Port the command vocabulary (openPanel, navigateFile, showNotification). |
1125
+ | `dynamicLoader.ts` | No direct v1 equivalent | New for v2. Reference: Vite's `import.meta.glob()` for dynamic imports. React `lazy()` + `Suspense` pattern from v1's pane registry (`panes.jsx` lines 20-50). Error boundary pattern from `PanelErrorBoundary.jsx` (58 lines). |
1126
+ | Agent pane slot | `packages/workspace/src/front/registry/panes.jsx` — agent pane registration (lines 440-470) | How agent panel is registered with capability gating (`requiresCapabilities: ['agent.chat']`). Lazy loading pattern. |
1127
+ | Session list UI | `packages/workspace/src/front/layouts/chat/ChatCenteredWorkspace.jsx` (lines 50-120) + `packages/workspace/src/front/layouts/chat/NavRail.jsx` (133 lines) | Session switcher in nav rail. Session create/switch callbacks. Active session indicator. **Port the UI**, not the state management (agent owns state in v2). |
1128
+ | Tool → artifact routing | `packages/workspace/src/front/layouts/chat/utils/toolArtifactBridge.js` (6.4 KB) + `packages/workspace/src/front/layouts/chat/hooks/useToolBridge.js` (5.3 KB) | How tool results become panel openers. Map of tool names to panel types. **Simplify**: in v2 the agent explicitly calls `bridge.openPanel()` instead of workspace inferring panel type from tool results. Keep as optional convenience. |
1129
+
1130
+ ### Phase 4: Polish
1131
+
1132
+ | v2 target | v1 reference | What to learn |
1133
+ |-----------|-------------|---------------|
1134
+ | Theme system | `packages/core/src/front/hooks/useTheme.jsx` | Light/dark toggle, localStorage persistence, system preference detection. Port directly, wire to shadcn CSS vars. |
1135
+ | Keyboard shortcuts | `packages/core/src/front/hooks/useKeyboardShortcuts.js` | Shortcut registration pattern. v1 uses a custom hook. Consider `cmdk` or shadcn `CommandDialog` for `Cmd+P` file picker. |
1136
+ | Responsive layout | `packages/workspace/src/front/hooks/useResponsiveSidebarCollapse.js` (1.5 KB) | Auto-collapse sidebar below breakpoint. Simple — port the breakpoint logic, use shadcn `Sheet` for mobile sidebar. |
1137
+ | Panel error boundary | `packages/workspace/src/front/components/PanelErrorBoundary.jsx` (58 lines) | Error boundary wrapper with retry button. Port directly, style with shadcn. Critical for dynamic pane safety. |
1138
+
1139
+ ### Files explicitly NOT to port
1140
+
1141
+ | v1 file | Reason |
1142
+ |---------|--------|
1143
+ | `components/Terminal.jsx` (578 lines) | Terminal/PTY no longer used in any app. Dead code. |
1144
+ | `panels/TerminalPanel.jsx` (384 lines) | Same — terminal not used |
1145
+ | `panels/ShellTerminalPanel.jsx` (298 lines) | Same — terminal not used |
1146
+ | `components/SyncStatusFooter.jsx` (489 lines) | Git sync UI — agent responsibility |
1147
+ | `hooks/useAutoSync.js` | Git auto-sync — agent responsibility |
1148
+ | `hooks/useLightningFsGitBootstrap.js` (8.5 KB) | Browser git — dropped (HTTP only) |
1149
+ | `providers/data/lightningFsProvider.js` | Browser FS — dropped |
1150
+ | `providers/data/isomorphicGitProvider.js` | Browser git — dropped |
1151
+ | `providers/data/pyodideRunner.js` | Browser Python — dropped |
1152
+ | `hooks/useGitHubConnection.js` | Auth flow — agent/core responsibility |
1153
+ | `hooks/useFrontendStatePersist.js` | Replaced by bridge in v2 |
1154
+ | `components/FrontmatterEditor.jsx` | YAML editing — not in v2 scope |
1155
+ | `chrome/UserMenu.jsx` | App chrome — belongs to app shell, not workspace |
1156
+ | `server/adapters/*` (nodeFs, directBash, bwrap) | Filesystem/sandbox — moved to agent |
1157
+ | `server/http/fileRoutes.ts`, `execRoutes.ts`, `gitRoutes.ts` | Backend routes — moved to agent |
1158
+ | `server/jobs/execJob.ts` | Exec job manager — moved to agent |
1159
+ | `server/workspace/paths.ts`, `helpers.ts` | Path validation — moved to agent |
1160
+ | All `*.d.ts`, `*.d.ts.map`, `*.js` build artifacts | v1 build artifacts, not source |
1161
+
1162
+ ## Risks & mitigations
1163
+
1164
+ ### Risk 1: Dockview wrapping complexity
1165
+
1166
+ **Risk**: Dockview injects DOM for tabs, drag-drop ghosts, drop targets, sash handles, floating
1167
+ groups. Not all of it can be wrapped in shadcn React components.
1168
+
1169
+ **Mitigation**: Best-effort wrap + single override file. Same approach as v1.
1170
+
1171
+ v1 solved this with:
1172
+ - Custom React tab components passed via `tabComponents`/`defaultTabComponent` props → full control over tab chrome
1173
+ - `.dockview-theme-abyss` CSS class (~200 lines in `packages/core/src/front/styles/base.css` lines 1519-1730) overriding `--dv-*` CSS variables and styling sash handles, active groups, content containers
1174
+ - Dockview's own CSS variable system (`--dv-tab-*`, `--dv-tabs-container-*`) mapped to design tokens
1175
+
1176
+ **v2 approach**: Same pattern, but map `--dv-*` variables to shadcn's `--primary`, `--background`, `--border`, etc. instead of custom tokens. Custom tab/header React components use tailwind classes. One `dockview-overrides.css` file (<200 lines) for sash handles, drop targets, and drag ghosts that can't be wrapped in React.
1177
+
1178
+ **v1 files to study**:
1179
+ - `packages/core/src/front/styles/base.css` lines 1519-1730 (dockview theme)
1180
+ - `packages/workspace/src/front/components/DockTab.jsx` (custom tab component)
1181
+ - `IdeLayout.jsx` lines 697-712 (DockviewReact props for custom components)
1182
+
1183
+ ### Risk 2: Circular dependency (workspace ↔ agent)
1184
+
1185
+ **Updated 2026-04-24 — superseded by the inverted dep chain.** This section's original "workspace never imports agent" rule is no longer the contract. Read the current rule in §Dependency graph (top of this file) and `packages/core/docs/CORE.md` §Dependency position.
1186
+
1187
+ Current rule:
1188
+
1189
+ - **Workspace imports `ChatPanel` directly from `@boring/agent`** as a built-in pane. Agent stays the leaf with zero knowledge of workspace or core. The `panels` prop override still works for apps that want to inject a custom chat panel.
1190
+ - **Agent never imports from workspace.** Agent does not depend on `WorkspaceBridge` as a workspace import; the bridge types live in core, and agent consumes them only when embedded via `registerAgentRoutes` (not in its standalone CLI build).
1191
+
1192
+ ```tsx
1193
+ // Default (workspace uses agent's ChatPanel as a built-in pane — no app-shell wiring needed):
1194
+ import { IdeLayout } from '@boring/workspace'
1195
+
1196
+ <IdeLayout /> // ChatPanel is included automatically
1197
+
1198
+ // Override (apps can inject a different chat panel):
1199
+ import { IdeLayout } from '@boring/workspace'
1200
+ import { MyCustomChat } from './my-chat'
1201
+
1202
+ <IdeLayout panels={[{ id: 'agent', component: MyCustomChat, placement: 'right' }]} />
1203
+ ```
1204
+
1205
+ ### Risk 3: Third-party CSS overrides
1206
+
1207
+ **Risk**: "Full shadcn, zero custom CSS" is aspirational. Dockview and CodeMirror inject their own styles.
1208
+
1209
+ **Mitigation**: One `vendor-overrides.css` file, budget <200 lines. Same as v1 pattern.
1210
+
1211
+ v1 solved this with a single `base.css` containing all vendor overrides scoped by parent class:
1212
+ - `.dockview-theme-abyss .dv-tab` for dockview
1213
+ - `.editor-content .tiptap` for tiptap
1214
+ - Xterm themed programmatically via `getComputedStyle()` reading CSS vars
1215
+
1216
+ **v2 approach**: Same pattern with shadcn CSS vars as the source of truth.
1217
+ - `dockview-overrides.css` — map `--dv-*` vars to shadcn vars, style sash/drop targets (~150 lines)
1218
+ - CodeMirror 6 — themed via `EditorView.theme()` API reading shadcn CSS vars at runtime (same approach as v1's xterm theming). Zero CSS override needed.
1219
+ - Everything else: pure tailwind/shadcn
1220
+
1221
+ ### Risk 4: Persistence resets during development
1222
+
1223
+ **Risk**: Single localStorage key with no migration means devs lose layout on every schema change during Phase 1-2.
1224
+
1225
+ **Mitigation**: Accepted. Schema changes are rare after Phase 1 stabilizes. Simple version check (version !== current → reset) is already in the plan. No migration system needed.
1226
+
1227
+ ### Risk 5: Dynamic pane production viability — OUT OF SCOPE
1228
+
1229
+ Dynamic panes moved to post-launch. See **Appendix I** below.
1230
+ Decision when it's time: server-side esbuild transform with two-layer validation.
1231
+
1232
+ ### Risk 6: Phase 3 scope — SIMPLIFIED
1233
+
1234
+ **Risk**: Phase 3 was originally bridge + dynamic panes + chat layout + session UI.
1235
+
1236
+ **Mitigation**: Dynamic panes removed from v2. Phase 3 is now: bridge + static agent pane +
1237
+ chat layout integration + session list UI. This is one coherent unit (agent needs the bridge,
1238
+ both layouts need the agent pane, chat layout needs session list).
1239
+
1240
+ ### Risk 7: Zustand hydration + dockview initialization race condition (CRITICAL)
1241
+
1242
+ **Risk**: v1 has a known timing issue. Layout persistence reads from localStorage (sync), then dockview
1243
+ mounts and calls `onReady` (async, next render cycle). If the user switches projects quickly, `panelSizesRef`
1244
+ may still hold the OLD project's sizes when `applyInitialSizes()` runs for the NEW project's panels.
1245
+
1246
+ v1's IdeLayout.jsx mitigates this with `layoutChromeHydratedPrefix` (line 127) — a flag that gates
1247
+ panel building. But the flag is set BEFORE panels fully load, creating a window where stale refs leak.
1248
+
1249
+ **v2 approach**: Zustand persist middleware `onRehydrateStorage` callback fires AFTER hydration completes.
1250
+ Gate dockview `onReady` behind a `hydrationComplete` flag from the store. Sequence:
1251
+
1252
+ ```
1253
+ 1. WorkspaceProvider mounts → Zustand persist starts hydrating from localStorage
1254
+ 2. onRehydrateStorage fires → set hydrationComplete = true
1255
+ 3. DockviewShell mounts → onReady fires → check hydrationComplete before calling fromJSON()
1256
+ 4. If not hydrated yet → queue the onReady callback, execute after hydration
1257
+ ```
1258
+
1259
+ This is cleaner than v1's ref-based approach. Zustand gives us a proper lifecycle hook.
1260
+
1261
+ ### Risk 8: Dual editor keybinding conflicts (tiptap + CodeMirror)
1262
+
1263
+ **Risk**: v2 ships both tiptap (markdown WYSIWYG) and CodeMirror 6 (code editing). Both register
1264
+ global-ish keyboard handlers. When both editors exist in the same dockview layout:
1265
+ - Ctrl+B → tiptap bold? CodeMirror fold? dockview toggle sidebar?
1266
+ - Ctrl+Z → which editor's undo stack?
1267
+ - Tab → tiptap list indent? CodeMirror tab insert?
1268
+
1269
+ **Mitigation**: Only the *focused* editor should capture keystrokes. Both tiptap and CodeMirror
1270
+ already scope their handlers to their DOM container. But dockview's panel activation system
1271
+ (`onDidActivePanelChange`) must coordinate:
1272
+
1273
+ 1. When a panel activates, disable keyboard listeners on the previously active editor panel
1274
+ 2. tiptap: `editor.setEditable(false)` on blur, `true` on focus
1275
+ 3. CodeMirror: `EditorView.dispatch({ effects: readOnly.reconfigure(...) })` on blur/focus
1276
+ 4. Workspace-level shortcuts (Cmd+P, Cmd+B sidebar) registered on the workspace container,
1277
+ not on individual editors — they always win via `event.stopPropagation()` guard
1278
+
1279
+ **v1 file to study**: `packages/core/src/front/hooks/useKeyboardShortcuts.js` — already handles
1280
+ shortcut priority. Port this pattern.
1281
+
1282
+ ### Risk 9: FileTree hidden context dependency blocks standalone usage
1283
+
1284
+ **Risk**: The plan exports `FileTree` as a standalone component for apps like `minimal`. But v1's
1285
+ `FileTree.jsx` calls `useDataProvider()` internally (line 55) — this throws if no `DataContext.Provider`
1286
+ wraps the component. Users who import `FileTree` standalone get a runtime crash with no clear error.
1287
+
1288
+ **Mitigation**: Two-tier component API (no middle "Connected" layer):
1289
+
1290
+ ```tsx
1291
+ // Standalone (Tier 3 — prop-based, no context needed):
1292
+ <FileTree
1293
+ files={files}
1294
+ onSelect={handleSelect}
1295
+ onExpand={handleExpand}
1296
+ />
1297
+
1298
+ // Pane (Tier 1/2 — dockview wrapper, reads from context):
1299
+ <FileTreePane /> // internally calls useFileData(), wired to bridge
1300
+ ```
1301
+
1302
+ The pane wrapper (`FileTreePane`) reads from context internally — it IS the connected variant.
1303
+ No separate `ConnectedFileTree`. Two layers: `FileTree` (pure props) and `FileTreePane` (context-aware dockview wrapper).
1304
+
1305
+ Same pattern for all components: `CodeEditor` (standalone) + `CodeEditorPane` (wrapper).
1306
+
1307
+ ### Risk 10: ChatLayout / IdeLayout code duplication (~400 LOC)
1308
+
1309
+ **Risk**: Both layouts implement left sidebar discovery, panel sizing constraints, and collapse/expand
1310
+ logic independently. v1's ChatCenteredWorkspace uses dockview for its artifact surface (`SurfaceShell.jsx`
1311
+ with `SurfaceDockview`). That's ~400 lines of duplicated sidebar + sizing code.
1312
+
1313
+ **Simplification**: Extract shared hooks before building v2 layouts:
1314
+
1315
+ | Shared hook | Extracted from | Used by |
1316
+ |-------------|----------------|---------|
1317
+ | `useSidebarLayout()` | `useDockLayout.js` lines 59-172 | IdeLayout, ChatLayout |
1318
+ | `usePanelSizing()` | `IdeLayout.jsx` lines 623-680 | IdeLayout, ChatLayout |
1319
+ | `usePanelConstraints()` | `IdeLayout.jsx` lines 210-280 | IdeLayout, ChatLayout |
1320
+
1321
+ Both are preset configs over DockviewShell (different group arrangements — chat has nav rail + surface,
1322
+ IDE has sidebar + editor tabs). DockviewShell handles all shared concerns (persistence, constraints,
1323
+ placeholders, collapse). Presets are ~30 lines each.
1324
+
1325
+ v2 architecture:
1326
+ ```
1327
+ DockviewShell (handles groups, constraints, placeholders, collapse internally)
1328
+ ├── wireGroupPlaceholder() (dynamic groups auto-manage empty state)
1329
+ ├── wireCollapsible() (sidebar collapse/expand with constraint switching)
1330
+ ├── useDockviewApi() (runtime API exposed to panes: addPanel, removePanel, etc.)
1331
+ ├── IdeLayout → LayoutConfig (sidebar + center[dynamic] + right rail)
1332
+ └── ChatLayout → LayoutConfig (nav rail + center + surface[nested DockviewShell])
1333
+ ```
1334
+
1335
+ ### Risk 11: Panel ID not found in registry (seventh-pass)
1336
+
1337
+ **Risk**: `<IdeLayout sidebar="typo" />` passes a panel ID that isn't registered. DockviewShell
1338
+ calls `api.addPanel()` with an unknown component — silent failure or crash.
1339
+
1340
+ **Mitigation**: DockviewShell validates all panel IDs against the registry in `onReady` before
1341
+ calling `api.addPanel()`. Unknown ID → console.error with available IDs, skip the panel.
1342
+ In dev mode, throw to catch typos early.
1343
+
1344
+ ### Risk 12: Persisted layout shadows new LayoutConfig (seventh-pass)
1345
+
1346
+ **Risk**: User's localStorage has a saved layout. App ships a new LayoutConfig with different
1347
+ groups. Persisted layout wins → user never sees the new arrangement.
1348
+
1349
+ **Mitigation**: LayoutConfig includes a `version: string` field (e.g., `'2.0'`). Persisted layout stores the
1350
+ config version it was created from. On restore, if versions differ → call `onLayoutVersionMismatch()` callback
1351
+ (defaults to reset). Apps can provide custom migration logic. See tenth-pass decisions.
1352
+
1353
+ ### Risk 13: filePatterns routing precedence (seventh-pass)
1354
+
1355
+ **Risk**: Multiple panels register overlapping file patterns. `*.ts` vs `*.test.ts` vs `*` —
1356
+ which wins? App-registered vs built-in — who takes priority?
1357
+
1358
+ **Mitigation**: Explicit precedence rules:
1359
+ 1. App-registered panels always checked before built-in panels
1360
+ 2. Within each group: longest suffix match wins (`*.test.ts` > `*.ts` > `*`)
1361
+ 3. If tied: first registered wins (registration order is deterministic)
1362
+ 4. `bridge.openFile(path, { panel: 'code-editor' })` with explicit panel ID bypasses routing
1363
+
1364
+ ### Risk 14: Bridge command lifecycle — async but returns void (seventh-pass)
1365
+
1366
+ **Risk**: `bridge.openFile('/file.ts')` returns void but is async internally (needs HTTP fetch +
1367
+ dockview panel creation). Agent calls openFile then immediately reads `getOpenPanels()` —
1368
+ file isn't there yet.
1369
+
1370
+ **Mitigation**: Bridge commands return `Promise<CommandResult>` (matches agent's bridge contract). Agent can await if it needs confirmation:
1371
+ ```typescript
1372
+ const { seq, status } = await bridge.openFile('/file.ts')
1373
+ const panels = bridge.getOpenPanels() // now includes the file
1374
+ ```
1375
+ Fire-and-forget usage still works — just don't `await`.
1376
+
1377
+ ### Risk 15: Dockview single-maintainer dependency (ninth-pass)
1378
+
1379
+ **Risk**: Dockview is maintained by one person (mathuo). The plan deeply couples to its API
1380
+ (`toJSON`/`fromJSON`, `DockviewApi`, group locking, header hiding). If the project goes
1381
+ unmaintained, there's no migration path.
1382
+
1383
+ **Mitigation**: Accepted. DockviewShell encapsulates all dockview interaction — no pane imports
1384
+ from `dockview-react` directly. All pane interaction goes through `useDockviewApi()` (our
1385
+ abstraction). If dockview dies, only DockviewShell internals (~500 LOC) need rewriting. No
1386
+ additional adapter layer (YAGNI — dockview is actively maintained, 3K+ GitHub stars).
1387
+
1388
+ ### Risk 16: Two editor stacks = duplicate maintenance (ninth-pass)
1389
+
1390
+ **Risk**: Tiptap (markdown) + CodeMirror (code) both need dirty tracking, auto-save, external
1391
+ file change detection, theme wiring. Maintaining the same lifecycle logic in two places.
1392
+
1393
+ **Mitigation**: Shared `useEditorLifecycle()` hook (~100 LOC) extracted in Phase 2.4a. Both
1394
+ editors provide an adapter interface: `{ isDirty, save, getContent }`. Hook handles dirty state,
1395
+ debounced auto-save, external change detection, and bridge `markDirty`/`markClean` calls.
1396
+
1397
+ ---
1398
+
1399
+ ## Implementation Reference (added in sixth-pass audit)
1400
+
1401
+ Everything below was derived from a line-by-line audit of v1 code against the v2 plan.
1402
+ An implementing agent should be able to start coding from this section alone.
1403
+
1404
+ ### A. Public API — exact `index.ts` exports
1405
+
1406
+ ```typescript
1407
+ // v2/packages/workspace/src/index.ts
1408
+
1409
+ // Layout shells
1410
+ export { IdeLayout } from './layouts/IdeLayout'
1411
+ export { ChatLayout } from './layouts/ChatLayout'
1412
+ export { DockviewShell } from './layouts/DockviewShell'
1413
+
1414
+ // Standalone components (usable WITHOUT WorkspaceProvider or dockview)
1415
+ export { FileTree } from './components/FileTree'
1416
+ export { CodeEditor } from './components/CodeEditor'
1417
+ export { MarkdownEditor } from './components/MarkdownEditor'
1418
+ export { DataCatalog } from './components/DataCatalog'
1419
+
1420
+ // Dockview pane wrappers (require WorkspaceProvider context)
1421
+ export { FileTreePane } from './panes/FileTreePane'
1422
+ export { CodeEditorPane } from './panes/CodeEditorPane'
1423
+ export { MarkdownEditorPane } from './panes/MarkdownEditorPane'
1424
+ export { DataCatalogPane } from './panes/DataCatalogPane'
1425
+ export { EmptyPane } from './panes/EmptyPane'
1426
+
1427
+ // Registry & panel management
1428
+ export { PanelRegistry } from './registry/PanelRegistry'
1429
+ export { CommandRegistry, useCommandRegistry } from './registry/CommandRegistry'
1430
+ export { RegistryProvider, useRegistry } from './registry/RegistryProvider'
1431
+
1432
+ // Bridge (agent-facing API)
1433
+ export { useWorkspaceBridge } from './bridge/useWorkspaceBridge'
1434
+
1435
+ // Persistence
1436
+ export { useLayoutPersistence } from './persistence/useLayoutPersistence'
1437
+
1438
+ // Runtime layout API (panels are dynamic)
1439
+ export { useDockviewApi } from './layouts/DockviewShell'
1440
+
1441
+ // Hooks
1442
+ export { usePanelActions } from './hooks/usePanelActions'
1443
+
1444
+ // Provider (wraps layout + registry + bridge + theme + data)
1445
+ export { WorkspaceProvider } from './WorkspaceProvider'
1446
+
1447
+ // Data hooks
1448
+ export { DataProvider, useFileData, useFileContent, useFileList, useFileWrite } from './providers/DataProvider'
1449
+
1450
+ // Theme
1451
+ export { ThemeProvider, useTheme } from './theme/ThemeProvider'
1452
+
1453
+ // Utilities
1454
+ export { getFileIcon } from './utils/fileIcons'
1455
+
1456
+ // Atomic selector hooks (useWorkspaceStore is NOT exported — only these hooks)
1457
+ export { useActiveFile, useActivePanel, useSidebarState, useOpenPanels, useDirtyFiles } from './store/selectors'
1458
+
1459
+ // Layout config (the composability primitive)
1460
+ export type { LayoutConfig, GroupConfig } from './layouts/types'
1461
+
1462
+ // Types
1463
+ export type { WorkspaceBridge, PanelState, CommandResult, BridgeEventMap } from './bridge/types'
1464
+ export type { PanelConfig, PaneProps, PanelRegistryType, CommandConfig } from './registry/types'
1465
+ export type { PanelLifecycleApi } from './registry/types'
1466
+ export type { DockviewShellApi } from './layouts/DockviewShell'
1467
+ // NOTE: WorkspaceStoreState is NOT exported — store is internal. Use atomic hooks.
1468
+ export type { WorkspaceProviderProps } from './WorkspaceProvider'
1469
+ export type { IdeLayoutProps, ChatLayoutProps, DockviewShellProps } from './layouts/types'
1470
+ ```
1471
+
1472
+ **Test harness exports** (`@boring/workspace/testing` — separate entry point, tree-shaken from production):
1473
+ ```typescript
1474
+ export { TestWorkspaceProvider } from './testing/TestWorkspaceProvider'
1475
+ export { createMockBridge } from './testing/createMockBridge'
1476
+ export { createMockRegistry } from './testing/createMockRegistry'
1477
+ export { renderPane } from './testing/renderPane'
1478
+ ```
1479
+
1480
+ **Server exports**: None. Workspace v2 is frontend-only. The ~52 route registrars, service
1481
+ interfaces, and adapter types from v1 now live in `@boring/agent`.
1482
+
1483
+ **Minimum viable for `minimal` / `custom-layout` apps:**
1484
+ ```
1485
+ FileTree, CodeEditor, MarkdownEditor (standalone components)
1486
+ DataProvider, useFileData, useFileContent (React Query hooks)
1487
+ // Plus @boring/core/ui: Button, Tabs, Input, Badge, Separator
1488
+ ```
1489
+
1490
+ ### B. WorkspaceProvider — full props specification
1491
+
1492
+ ```typescript
1493
+ interface WorkspaceProviderProps {
1494
+ children: React.ReactNode
1495
+
1496
+ // --- Registry ---
1497
+ panels?: PanelConfig[] // Additional panels beyond built-ins (e.g., ChatPanel)
1498
+ // Built-ins (filetree, editor, empty) auto-registered
1499
+
1500
+ // --- Capabilities (for panel registry filtering) ---
1501
+ capabilities?: Record<string, boolean> // e.g. { 'agent.chat': true, 'workspace.files': true }
1502
+
1503
+ // --- Data layer ---
1504
+ apiBaseUrl?: string // Base URL for HTTP data provider. Default: '' (same origin)
1505
+ authHeaders?: Record<string, string> // Injected into every HTTP request (e.g., { Authorization: 'Bearer ...' })
1506
+
1507
+ // --- Theme ---
1508
+ defaultTheme?: 'light' | 'dark' // Initial theme. Default: 'light'
1509
+ onThemeChange?: (theme: 'light' | 'dark') => void
1510
+
1511
+ // --- Persistence ---
1512
+ workspaceId?: string // Scopes persistence key: 'boring-ui-v2:layout:{workspaceId}'
1513
+ // Omit for single-workspace apps (falls back to 'boring-ui-v2:layout')
1514
+ storageKey?: string // Full override for persistence key (takes precedence over workspaceId)
1515
+ persistenceEnabled?: boolean // Default: true. Set false for tests / ephemeral mode
1516
+
1517
+ // --- Layout version migration (tenth pass) ---
1518
+ onLayoutVersionMismatch?: (persisted: string, current: string, layout: unknown) => SerializedLayout | null
1519
+ // Called when persisted layout version !== LayoutConfig.version
1520
+ // Return migrated layout or null (= reset to defaults)
1521
+ // Default: () => null (reset)
1522
+
1523
+ // --- Bridge (Phase 3) ---
1524
+ bridgeEndpoint?: string // SSE endpoint for agent→workspace commands. Default: '/api/v1/ui/commands/next'
1525
+ // Set null to disable bridge (standalone mode)
1526
+
1527
+ // --- Error callbacks ---
1528
+ onLayoutError?: (error: Error) => void // Invalid/corrupted layout detected
1529
+ onAuthError?: (statusCode: number) => void // 401/403 from data provider
1530
+ }
1531
+ ```
1532
+
1533
+ **Contexts provided (each accessible via hook):**
1534
+
1535
+ | Context | Hook | What it provides |
1536
+ |---------|------|-----------------|
1537
+ | `WorkspaceStoreContext` | `useWorkspaceStore()` | Zustand store — layout state, active panel, sidebar, file tree |
1538
+ | `WorkspaceBridgeContext` | `useWorkspaceBridge()` | Imperative commands — `openFile()`, `openPanel()`, etc. |
1539
+ | `RegistryContext` | `useRegistry()` | Panel registry — `get()`, `list()`, `has()`, `getComponents()` |
1540
+ | `ThemeContext` | `useTheme()` | Theme state — `{ theme, setTheme }` |
1541
+ | `DataProviderContext` | `useFileData()` | React Query hooks — `useFileContent()`, `useFileList()`, `useFileWrite()` |
1542
+
1543
+ **Usage patterns (three tiers of composability):**
1544
+
1545
+ ```tsx
1546
+ // TIER 1: Preset layout with slot overrides
1547
+ import { WorkspaceProvider, IdeLayout } from '@boring/workspace'
1548
+ import { ChatPanel } from '@boring/agent'
1549
+ import { MyCustomTree } from './MyCustomTree'
1550
+
1551
+ function App() {
1552
+ return (
1553
+ <WorkspaceProvider
1554
+ panels={[
1555
+ { id: 'agent', component: ChatPanel, placement: 'right', hideHeader: true },
1556
+ { id: 'my-tree', component: MyCustomTree, title: 'Explorer' },
1557
+ ]}
1558
+ capabilities={{ 'workspace.files': true, 'agent.chat': true }}
1559
+ onAuthError={(code) => redirectToLogin()}
1560
+ >
1561
+ <IdeLayout sidebar="my-tree" right="agent" />
1562
+ </WorkspaceProvider>
1563
+ )
1564
+ }
1565
+ ```
1566
+
1567
+ ```tsx
1568
+ // TIER 2: Custom layout via DockviewShell
1569
+ import { WorkspaceProvider, DockviewShell } from '@boring/workspace'
1570
+
1571
+ function App() {
1572
+ return (
1573
+ <WorkspaceProvider panels={[...]} capabilities={...}>
1574
+ <DockviewShell layout={{
1575
+ groups: [
1576
+ { id: 'nav', position: 'left', panel: 'filetree', locked: true,
1577
+ collapsible: true, collapsedWidth: 0, constraints: { minWidth: 200, maxWidth: 350 } },
1578
+ { id: 'main', position: 'center', panel: 'empty',
1579
+ dynamic: true, placeholder: 'empty' },
1580
+ { id: 'console', position: 'bottom', panel: 'terminal',
1581
+ dynamic: true, constraints: { maxHeight: 300 } },
1582
+ { id: 'ai', position: 'right', panel: 'agent', hideHeader: true },
1583
+ ]
1584
+ }} />
1585
+ </WorkspaceProvider>
1586
+ )
1587
+ }
1588
+ ```
1589
+
1590
+ ```tsx
1591
+ // TIER 3: Standalone components (no dockview, no WorkspaceProvider, no context)
1592
+ // All data via props — zero context dependency (tenth pass)
1593
+ import { FileTree, CodeEditor } from '@boring/workspace'
1594
+
1595
+ function App() {
1596
+ const [content, setContent] = useState('')
1597
+ return (
1598
+ <div className="flex h-screen">
1599
+ <FileTree files={files} onSelect={setPath} />
1600
+ <CodeEditor content={content} onChange={setContent} language="typescript" />
1601
+ </div>
1602
+ )
1603
+ }
1604
+ // Note: DataProvider is only needed by pane wrappers (FileTreePane, CodeEditorPane).
1605
+ // Standalone components accept all data via props.
1606
+ ```
1607
+
1608
+ ### C. @boring/core dependency contract — ARCHIVED 2026-04-24
1609
+
1610
+ **This section is archival.** It was written when the planned v2 dep graph was `workspace → core`. The actual v2 dep graph is inverted: `agent (leaf) ← workspace ← core`. Workspace does NOT import from core; core imports from workspace (specifically `@boring/ui`). Canonical graph: `packages/core/docs/CORE.md` §Dependency position.
1611
+
1612
+ The list below was intended to constrain what workspace could pull from core. It's preserved only so that if we ever invert the graph again, we have a record of what v1 workspace actually used. Any mention of `@boring/core` as a workspace dep below is historical, not a contract.
1613
+
1614
+ ---
1615
+
1616
+ Every import workspace v2 makes from `@boring/core`. No new dependencies added beyond v1's.
1617
+
1618
+ **HTTP transport (`@boring/core/front`):**
1619
+ - `apiFetchJson(url, opts)` — fetch wrapper with auth header injection
1620
+ - `buildApiUrl(path)` — resolve relative paths to absolute API URLs
1621
+ - `getHttpErrorDetail(error)` — parse HTTP error payloads
1622
+ - `routes` — constant map of API endpoint paths
1623
+
1624
+ **UI utilities (`@boring/core/front`):**
1625
+ - `cn()` — tailwind class merger (clsx + twMerge)
1626
+ - `copyTextToClipboard(text)` — clipboard API wrapper
1627
+ - `formatShortcut(shortcut)` — platform-aware shortcut notation (Cmd vs Ctrl)
1628
+ - `useViewportBreakpoint()` — responsive breakpoint hook
1629
+
1630
+ **Keyboard shortcuts (`@boring/core/front`):**
1631
+ - `useKeyboardShortcuts(shortcuts)` — global shortcut registration hook
1632
+ - `DEFAULT_SHORTCUTS` — predefined Cmd+P, Cmd+S, etc.
1633
+
1634
+ **Accessibility (`@boring/core/front`):**
1635
+ - `announceToScreenReader(message)` — ARIA live region
1636
+ - `trapFocus(containerEl)` — focus trap for modals
1637
+ - `useReducedMotion()` — respect prefers-reduced-motion
1638
+
1639
+ **Theme (`@boring/core/front`):**
1640
+ - `useTheme()` — get/set light/dark mode
1641
+ - `ThemeToggle` — toggle button component
1642
+
1643
+ **Configuration (`@boring/core/front`):**
1644
+ - `useConfig()` — read app config (storage prefix, feature flags)
1645
+ - `useCapabilities()` — read server capabilities
1646
+
1647
+ **Design tokens (`@boring/core/front`):**
1648
+ - `ICON_SIZE_INLINE`, `ICON_SIZE_TOOLBAR`, `ICON_SIZE_ACTIVITY`
1649
+
1650
+ **UI components (`@boring/core/ui` — shadcn, 13 vendored):**
1651
+ - `Button`, `Input`, `Badge`, `Separator`, `Tabs`, `Tooltip`, `DropdownMenu`
1652
+ - `Select`, `Dialog`, `Avatar`, `Switch`, `Label`, `Textarea`
1653
+ - **v2 imports these, does NOT re-vendor** — apps import from core directly
1654
+
1655
+ **Server (`@boring/core/server`):**
1656
+ - `createAuthHook` — auth middleware factory
1657
+ - `ServerConfig` type
1658
+
1659
+ **Shared (`@boring/core/shared`):**
1660
+ - `CapabilitiesResponse` type
1661
+ - Git types (`GitStatus`, `GitChange`, etc.)
1662
+
1663
+ ### D. Bridge protocol specification
1664
+
1665
+ **v1 status**: HTTP polling only (750ms interval). No WebSocket.
1666
+
1667
+ **v2 transport**: **SSE + POST** — matches the existing chat stream pattern. No WebSocket.
1668
+
1669
+ **State scope**: Bridge sends `openPanels`, `activePanel`, `activeFile`, `visibleFiles` (paths
1670
+ shown in file tree), and `dirtyFiles`. NO full file tree state — agent queries files via its own
1671
+ tools. This eliminates the bandwidth problem of broadcasting 10K+ entries on every panel change.
1672
+
1673
+ #### Endpoints
1674
+
1675
+ ```
1676
+ GET /api/v1/ui/commands/next — SSE stream (agent → workspace commands)
1677
+ POST /api/v1/ui/commands — agent sends commands
1678
+ PUT /api/v1/ui/state — workspace publishes UI state (workspace → agent)
1679
+ GET /api/v1/ui/state/latest — one-shot full state snapshot (for reconnection or polling)
1680
+ ```
1681
+
1682
+ #### Data flow (tenth pass — command-based, workspace-authoritative)
1683
+
1684
+ ```
1685
+ Agent POST {kind:'openFile', params:{path:'/foo.ts'}}
1686
+ → agent server validates (Zod per-kind schema)
1687
+ → agent server queues command
1688
+ → SSE delivers: event: command data: {v:1, kind:'openFile', params:{...}}
1689
+ → workspace executes locally (Zustand store update)
1690
+ → workspace PUT /api/v1/ui/state with {v:1, causedBy:'agent', ...}
1691
+ → agent reads state via get_ui_state tool
1692
+
1693
+ User clicks file in tree:
1694
+ → workspace executes locally (Zustand store update)
1695
+ → workspace PUT /api/v1/ui/state with {v:1, causedBy:'user', ...}
1696
+ → agent reads state via get_ui_state tool
1697
+ ```
1698
+
1699
+ **Authority**: Workspace Zustand store is the source of truth. Agent server is a relay for commands and a cache for state that agents can query. The server NEVER modifies UI state — it only validates and forwards commands.
1700
+
1701
+ #### SSE command stream (`GET /api/v1/ui/commands/next`)
1702
+
1703
+ ```
1704
+ event: command
1705
+ data: {"v":1,"kind":"openFile","params":{"path":"/src/main.ts","mode":"edit"}}
1706
+
1707
+ event: command
1708
+ data: {"v":1,"kind":"showNotification","params":{"msg":"File saved","level":"info"}}
1709
+
1710
+ event: error
1711
+ data: {"v":1,"code":"invalid_command","message":"Unknown panel ID"}
1712
+
1713
+ event: heartbeat
1714
+ data: {}
1715
+ ```
1716
+
1717
+ - On connect: server sends `event: init` with full state snapshot (for reconciliation).
1718
+ - Subsequent events are `event: command` — individual commands the workspace executes.
1719
+ - Browser `EventSource` auto-reconnects on disconnect (built-in).
1720
+ - Server sends `event: heartbeat` every 30s to keep the connection alive through proxies.
1721
+ - All events carry `v:1` protocol version. On version mismatch, workspace shows warning banner.
1722
+ - ~20 LOC server — same pattern as `GET /api/v1/agent/chat/:sessionId/:turnId`.
1723
+
1724
+ #### State publishing (`PUT /api/v1/ui/state`)
1725
+
1726
+ Workspace pushes its current UI state to the agent server after every meaningful state change.
1727
+ Agent reads this state via the `get_ui_state` tool (from the agent's own cached copy).
1728
+
1729
+ ```typescript
1730
+ // PUT body:
1731
+ interface UIStatePut {
1732
+ v: 1 // protocol version
1733
+ causedBy: 'user' | 'agent' | 'restore' // prevents agent echo loops
1734
+ openPanels: PanelState[]
1735
+ activePanel: string | null
1736
+ activeFile: string | null
1737
+ visibleFiles: string[] // file paths shown in tree
1738
+ dirtyFiles: string[] // files with unsaved changes
1739
+ }
1740
+ ```
1741
+
1742
+ - Debounced client-side (100ms) to coalesce rapid UI changes (e.g., clicking through files).
1743
+ - `causedBy` allows agents to distinguish user actions from their own command results.
1744
+ - 204 No Content response. No validation beyond JSON parsing — workspace is the authority.
1745
+
1746
+ #### Command format (`POST /api/v1/ui/commands`)
1747
+
1748
+ ```typescript
1749
+ interface BridgeCommand {
1750
+ kind: 'openFile' | 'openPanel' | 'closePanel' | 'showNotification'
1751
+ | 'navigateToLine' | 'expandToFile'
1752
+ params: Record<string, unknown>
1753
+ }
1754
+
1755
+ // Response:
1756
+ interface CommandResult {
1757
+ seq: number
1758
+ status: 'ok' | 'error'
1759
+ error?: { code: string, message: string }
1760
+ }
1761
+ ```
1762
+
1763
+ #### Bridge command validation (SECURITY-CRITICAL)
1764
+
1765
+ Every bridge command passes through a per-kind Zod validator before execution.
1766
+ Unknown kinds are rejected. Per-kind param schemas enforced server-side:
1767
+
1768
+ | Kind | Params | Constraints |
1769
+ |------|--------|-------------|
1770
+ | `openFile` | `path: string, mode?: 'view'\|'edit'\|'diff'` | path max 1024 chars, validated by `paths.ts` |
1771
+ | `openPanel` | `id: string, component: string, params?: Record<string, JsonSerializable>` | id alphanum+dash max 64, component must exist in registry, params max 16KB |
1772
+ | `closePanel` | `id: string` | must be open, must not be essential |
1773
+ | `showNotification` | `msg: string, level?: 'info'\|'warn'\|'error'` | msg max 500 chars, plain text only (NO HTML) |
1774
+ | `navigateToLine` | `file: string, line: number` | file validated by `paths.ts`, line positive integer |
1775
+ | `expandToFile` | `path: string` | validated by `paths.ts` |
1776
+
1777
+ **Rate limiting**: Deferred to post-launch. Bridge commands are infrequent (~1 per agent turn).
1778
+ Max 50 open panels per workspace enforced (hard cap, not rate).
1779
+
1780
+ **Path parameters** (`openFile`, `navigateToLine`, `expandToFile`): MUST go through same
1781
+ `validatePath()` + `assertRealPathWithinWorkspace()` as `fileRoutes.ts`. Bridge MUST NOT
1782
+ shortcircuit to direct file reads.
1783
+
1784
+ **Text parameters** (`showNotification` msg, panel titles): rendered as React text nodes
1785
+ (`{msg}`), NEVER via `dangerouslySetInnerHTML`. Truncated to max length server-side.
1786
+
1787
+ #### Authentication
1788
+
1789
+ Standard HTTP auth on both endpoints:
1790
+ - SSE stream: `Authorization: Bearer <token>` header (or session cookie)
1791
+ - POST commands: same `Authorization: Bearer <token>` header
1792
+ - No special ticket/upgrade mechanism needed (SSE is standard HTTP, not WebSocket)
1793
+ - 401/403 → SSE stream closes, client shows reconnection banner
1794
+
1795
+ #### Reconnection
1796
+
1797
+ SSE `EventSource` auto-reconnects with browser-default retry interval (~3s).
1798
+ On reconnect, server sends `event: init` with cached state snapshot for reconciliation.
1799
+ Workspace compares with its Zustand store and resolves any conflicts (workspace wins — it's the authority).
1800
+
1801
+ **Consistency guarantees:**
1802
+ - **At-most-once command delivery**: Commands are fire-and-forget during disconnect. Agent
1803
+ re-evaluates state and re-issues if needed.
1804
+ - **Server restart**: All bridge state is in-memory. Restart = clean slate. On reconnect,
1805
+ workspace re-PUTs its current state to the server.
1806
+ - **Idempotency**: `openPanel` with duplicate ID activates the existing panel (default
1807
+ `prefer_existing: true`). Makes accidental double-sends harmless.
1808
+
1809
+ #### Short-poll fallback (for environments where SSE is unavailable)
1810
+
1811
+ - `GET /api/v1/ui/state/latest` — poll every 2s for cached state (for reconciliation)
1812
+ - `POST /api/v1/ui/commands` — enqueue command (same endpoint as SSE mode)
1813
+ - `PUT /api/v1/ui/state` — workspace pushes state (same endpoint as SSE mode)
1814
+ - Workspace polls `GET /api/v1/ui/commands/next?poll=true` for pending commands (returns batch, not SSE)
1815
+ - Same JSON format, just over standard request/response
1816
+
1817
+ #### v1 REST bridge endpoints — DROPPED
1818
+
1819
+ v2 is a hard cut. No v1 bridge consumers. The v1 REST endpoints (`GET /ui/state`, `POST /ui/commands`,
1820
+ etc.) are not carried forward. SSE + POST is the only transport.
1821
+
1822
+ ### E. DockviewShell — configuration specification
1823
+
1824
+ Two responsibilities:
1825
+
1826
+ 1. **Initial layout from LayoutConfig** — groups are fixed, declarative
1827
+ 2. **Runtime panel manipulation via `useDockviewApi()`** — panels are dynamic, imperative
1828
+
1829
+ DockviewShell auto-manages: placeholder lifecycle for dynamic groups, sidebar collapse/expand,
1830
+ and group constraint enforcement. Preset layouts (`IdeLayout`, `ChatLayout`) are thin wrappers
1831
+ that build a `LayoutConfig`. Supports nesting (a pane can render its own DockviewShell).
1832
+
1833
+ Derived from v1 `IdeLayout.jsx` lines 1–500, 697–712 and dockview 4.13.1 API.
1834
+
1835
+ ```typescript
1836
+ interface DockviewShellProps {
1837
+ // Layout configuration (groups are fixed, declarative)
1838
+ layout: LayoutConfig
1839
+
1840
+ // Custom tab appearance (shadcn-styled)
1841
+ tabComponent?: React.ComponentType<DockviewDefaultTabProps>
1842
+ rightHeaderActions?: React.ComponentType<any>
1843
+
1844
+ // Persisted state override (takes precedence over layout config on restore)
1845
+ persistedLayout?: SerializedDockviewLayout
1846
+
1847
+ // Lifecycle
1848
+ onReady?: (api: DockviewApi) => void
1849
+ onLayoutChange?: (layout: SerializedDockviewLayout) => void
1850
+ onDidDrop?: (event: DockviewDropEvent) => void
1851
+
1852
+ // Persistence key (for nested dockview — each instance gets its own)
1853
+ storageKey?: string // default: uses WorkspaceProvider's key
1854
+
1855
+ // Panel guard for nested shells (tenth pass)
1856
+ allowedPanels?: string[] // filter registry to only these panel IDs
1857
+ // undefined = full registry access (default for root shell)
1858
+
1859
+ // Options
1860
+ className?: string
1861
+ }
1862
+ ```
1863
+
1864
+ **Runtime API exposed via hook (panels are dynamic, imperative):**
1865
+
1866
+ See §Architecture > Runtime API (`useDockviewApi()`) for the full `DockviewShellApi` type
1867
+ (8 methods: `addPanel`, `removePanel`, `activatePanel`, `updatePanelParams`, `movePanel`,
1868
+ `batch`, `getGroup`, `getActivePanel`, `setGroupCollapsed`). Defined once there — not
1869
+ duplicated here to avoid spec drift.
1870
+
1871
+ **DockviewShell internals:**
1872
+ ```tsx
1873
+ function DockviewShell({ layout, persistedLayout, storageKey, ...props }: DockviewShellProps) {
1874
+ const registry = useRegistry()
1875
+ const hydrationComplete = useWorkspaceStore(s => s.hydrationComplete)
1876
+ const apiRef = useRef<DockviewApi | null>(null)
1877
+
1878
+ const components = useMemo(() => registry.getComponents(), [registry])
1879
+ const shellApi = useMemo(() => createShellApi(apiRef), [])
1880
+
1881
+ return (
1882
+ <DockviewApiContext.Provider value={shellApi}>
1883
+ <DockviewReact
1884
+ className={props.className}
1885
+ components={components}
1886
+ defaultTabComponent={props.tabComponent ?? ShadcnDockTab}
1887
+ rightHeaderActionsComponent={props.rightHeaderActions}
1888
+ onReady={(event) => {
1889
+ if (!hydrationComplete) {
1890
+ pendingOnReady.current = event
1891
+ return
1892
+ }
1893
+ handleReady(event, layout, persistedLayout)
1894
+ }}
1895
+ onDidDrop={props.onDidDrop}
1896
+ />
1897
+ </DockviewApiContext.Provider>
1898
+ )
1899
+ }
1900
+ ```
1901
+
1902
+ **`onReady` callback — builds groups, applies constraints, wires lifecycle:**
1903
+ ```typescript
1904
+ function handleReady(event, layout, persistedLayout?) {
1905
+ const api = event.api
1906
+
1907
+ // 1. Restore: persisted layout wins, else build from LayoutConfig
1908
+ if (persistedLayout) {
1909
+ api.fromJSON(persistedLayout)
1910
+ } else {
1911
+ for (const group of layout.groups) {
1912
+ if (group.panel) {
1913
+ api.addPanel({
1914
+ id: group.panel, component: group.panel,
1915
+ position: { direction: positionToDirection(group.position) },
1916
+ })
1917
+ }
1918
+ }
1919
+ }
1920
+
1921
+ // 2. Apply group properties (groups are fixed, config is authoritative)
1922
+ for (const group of layout.groups) {
1923
+ const panel = api.getPanel(group.panel ?? group.id)
1924
+ if (!panel?.group) continue
1925
+ if (group.locked) panel.group.locked = true
1926
+ if (group.hideHeader) panel.group.header.hidden = true
1927
+ if (group.constraints) {
1928
+ panel.group.api.setConstraints({
1929
+ minimumWidth: group.constraints.minWidth,
1930
+ maximumWidth: group.constraints.maxWidth,
1931
+ })
1932
+ }
1933
+ }
1934
+
1935
+ // 3. Wire placeholder lifecycle for dynamic groups
1936
+ for (const group of layout.groups) {
1937
+ if (group.dynamic && group.placeholder) {
1938
+ wireGroupPlaceholder(api, group.id, group.placeholder)
1939
+ }
1940
+ }
1941
+
1942
+ // 4. Wire collapsible sidebar behavior
1943
+ for (const group of layout.groups) {
1944
+ if (group.collapsible) {
1945
+ wireCollapsible(api, group.id, group.collapsedWidth ?? 0, group.constraints)
1946
+ }
1947
+ }
1948
+
1949
+ // 5. Wire layout change listener for persistence (DEBOUNCED)
1950
+ // onDidLayoutChange fires on every pixel of sash drag — dozens/sec.
1951
+ // toJSON() serializes 5-15KB. Debounce to 300ms to avoid hammering localStorage.
1952
+ const debouncedPersist = debounce(() => {
1953
+ props.onLayoutChange?.(api.toJSON())
1954
+ }, 300)
1955
+ api.onDidLayoutChange(debouncedPersist)
1956
+ window.addEventListener('beforeunload', () => debouncedPersist.flush())
1957
+
1958
+ // 6. Store API ref
1959
+ apiRef.current = api
1960
+ }
1961
+ ```
1962
+
1963
+ **How presets use DockviewShell:**
1964
+ ```typescript
1965
+ // layouts/IdeLayout.tsx — ~30 lines
1966
+ export function IdeLayout({ sidebar = 'filetree', center = 'empty', right }: IdeLayoutProps) {
1967
+ return <DockviewShell layout={{
1968
+ groups: [
1969
+ { id: 'sidebar', position: 'left', panel: sidebar, locked: true,
1970
+ collapsible: true, collapsedWidth: 0,
1971
+ constraints: { minWidth: 200, maxWidth: 400 } },
1972
+ { id: 'center', position: 'center', panel: center,
1973
+ dynamic: true, placeholder: 'empty' },
1974
+ ...(right ? [{ id: 'right', position: 'right' as const, panel: right,
1975
+ hideHeader: true, constraints: { minWidth: 300 } }] : []),
1976
+ ]
1977
+ }} />
1978
+ }
1979
+
1980
+ // layouts/ChatLayout.tsx — ~30 lines
1981
+ export function ChatLayout({ nav = 'session-list', center = 'chat', surface, sidebar }: ChatLayoutProps) {
1982
+ return <DockviewShell layout={{
1983
+ groups: [
1984
+ { id: 'nav', position: 'left', panel: nav, locked: true, hideHeader: true,
1985
+ constraints: { minWidth: 60, maxWidth: 60 } },
1986
+ { id: 'center', position: 'center', panel: center },
1987
+ ...(sidebar ? [{ id: 'sidebar', position: 'left' as const, panel: sidebar,
1988
+ collapsible: true, collapsedWidth: 0, constraints: { minWidth: 200, maxWidth: 350 } }] : []),
1989
+ ...(surface ? [{ id: 'surface', position: 'right' as const, panel: surface,
1990
+ dynamic: true, placeholder: 'empty' }] : []),
1991
+ ]
1992
+ }} />
1993
+ }
1994
+ // NOTE: ChatLayout supports an optional file tree sidebar (sidebar='filetree')
1995
+ ```
1996
+
1997
+ **Nested DockviewShell (ChatLayout artifact surface):**
1998
+ ```typescript
1999
+ // panes/ArtifactSurfacePane.tsx — renders inside ChatLayout's surface group
2000
+ function ArtifactSurfacePane({ artifacts, onSelectArtifact, onCloseArtifact }) {
2001
+ return (
2002
+ <DockviewShell
2003
+ layout={{
2004
+ groups: [{ id: 'artifacts', position: 'center', dynamic: true, placeholder: 'empty' }]
2005
+ }}
2006
+ storageKey="boring-ui-v2:surface" // own persistence, independent of outer shell
2007
+ allowedPanels={['code-editor', 'markdown-editor', 'csv-viewer', 'empty']} // tenth pass: guard against outer-shell panels
2008
+ />
2009
+ )
2010
+ }
2011
+ // Filters outer panel registry to allowed IDs. Has own API, own state, own lifecycle.
2012
+ // v1 pattern: SurfaceDockview uses syncingRef to prevent feedback loops.
2013
+ ```
2014
+
2015
+ **Nested shell — minimal isolation (ninth pass simplification):**
2016
+
2017
+ Nesting works naturally because dockview instances are independent. No formal isolation
2018
+ protocol needed for v2 — only one consumer (ChatLayout artifact surface) exists.
2019
+
2020
+ 1. **Persistence**: Each DockviewShell with a unique `storageKey` creates its own Zustand
2021
+ persist partition. Root uses `boring-ui-v2:layout`, nested surface uses `boring-ui-v2:surface`.
2022
+
2023
+ 2. **API scoping**: `useDockviewApi()` returns the API for the nearest ancestor DockviewShell,
2024
+ so panes inside the nested shell automatically interact with the correct instance.
2025
+
2026
+ 3. **No panel ID namespacing** — panel IDs are plain strings. If two shells happen to have
2027
+ panels with the same ID, they are independent (different dockview instances).
2028
+
2029
+ 4. **No bridge routing** — bridge commands always target the root shell. Nested shell panels
2030
+ are managed directly by the component that renders the nested DockviewShell.
2031
+
2032
+ If multi-shell routing is needed post-launch (e.g., agent opening artifacts in the surface
2033
+ from outside), add `shell` parameter to bridge commands at that point.
2034
+
2035
+ ### F. shadcn component inventory — all phases
2036
+
2037
+ | Component | Phase | Used by |
2038
+ |-----------|-------|---------|
2039
+ | `Button` | 1 | Everywhere (toolbar, dialogs, actions) |
2040
+ | `Tabs` | 1 | TabBar (dockview tab chrome) |
2041
+ | `Tooltip` | 1 | Icon buttons, toolbar items |
2042
+ | `DropdownMenu` | 1 | Context menus, panel menu |
2043
+ | `Sheet` | 1 | Mobile sidebar overlay |
2044
+ | `ScrollArea` | 1 | File tree scroll container |
2045
+ | `Input` | 1 | File tree search, rename input |
2046
+ | `Badge` | 1 | Tab dirty indicator, panel labels |
2047
+ | `Separator` | 1 | Toolbar dividers, panel borders |
2048
+ | `Dialog` | 1 | Confirm actions, modals |
2049
+ | `Card` | 2 | Data catalog entries |
2050
+ | `Checkbox` | 2 | Task list items (tiptap markdown) |
2051
+ | `AlertDialog` | 2 | Confirm file delete |
2052
+ | `Command` / `CommandDialog` | 4 | Cmd+P file quick-open |
2053
+ | `Popover` | 4 | Inline context actions |
2054
+ | `Select` | 4 | Language selector (code editor) |
2055
+ | `Label` | 4 | Form labels |
2056
+
2057
+ **Total: 17 shadcn components** across all phases.
2058
+
2059
+ **Not vendored by workspace** (available from `@boring/core/ui`):
2060
+ `Avatar`, `Switch`, `Textarea` — only needed by app shells, not workspace itself.
2061
+
2062
+ ### G. Phase dependency DAG & parallelization
2063
+
2064
+ ```
2065
+ Phase 1 (Foundation) — Critical path: ~14 days (1 agent), ~11 days (2 agents)
2066
+ ═══════════════════════════════════════════════════════════════════════════════
2067
+
2068
+ 1.1 Scaffold ─────┬──→ 1.2 Vendor shadcn ──→ 1.3 Dockview wrappers ──┐
2069
+ (2-3d) │ (1-2d) (3-4d) │
2070
+ │ │
2071
+ ├──→ 1.4 Persistence (Zustand) ─────────────────────┤
2072
+ │ (2-3d, parallel with 1.3) │
2073
+ │ │
2074
+ └──→ 1.5 Panel registry ──────────────────────────→ │
2075
+ (2d, parallel, doesn't block) │
2076
+
2077
+ 1.6 Shared hooks ──→ 1.7 Layout shells
2078
+ (2-3d) (3-4d)
2079
+
2080
+
2081
+ Phase 2 (Panes) — after Phase 1 complete. ~16-22 days (1 agent), ~7 days (3 agents)
2082
+ ═══════════════════════════════════════════════════════════════════════════════════════
2083
+
2084
+ ┌── 2.6 HTTP data provider (2-3d) ───────────────────┐
2085
+ │ │
2086
+ ├── 2.1 File tree pane (3-5d) ───────────────────────→ │ (all parallel)
2087
+ ├── 2.2 Markdown editor pane (4-6d, port from v1) ──→ │
2088
+ ├── 2.4 Code editor pane (6-8d, FULL REWRITE) ──────→ │
2089
+ ├── 2.3 Data catalog pane (1-2d) ────────────────────→ │
2090
+ └── 2.5 Empty pane (0.5d) ───────────────────────────→ │
2091
+
2092
+ Longest task: 2.4 CodeMirror 6 integration (6-8 days)
2093
+
2094
+
2095
+ Phase 3a (Agent integration) — after Phase 1+2. ~9-13 days (1 agent), ~6 days (2 agents)
2096
+ ═════════════════════════════════════════════════════════════════════════════════════════════
2097
+
2098
+ 3a.1 Agent pane slot (0.5d) ──┐
2099
+
2100
+ 3a.2 Bridge state store (2-3d)─┤──→ 3a.3 Bridge server endpoint (3-4d) ──→ 3a.4 Chat layout (4-5d)
2101
+
2102
+ └──→ (parallel)
2103
+
2104
+
2105
+ Phase 4 (Polish) — all tasks independent, can overlap with Phase 3a
2106
+ ════════════════════════════════════════════════════════════════════
2107
+
2108
+ 4.1 Theme (2d) │ 4.2 Shortcuts (2d) │ 4.3 Responsive (2d) │ 4.4 Error handling (2d)
2109
+ 4.5 Testing CI (3d) │ 4.6 Accessibility (2d) │ 4.7 Perf budgets (1d) │ 4.8 i18n (deferred)
2110
+
2111
+
2112
+ TOTAL ESTIMATES:
2113
+ 1 agent, sequential: ~6-8 weeks
2114
+ 2 agents, coordinated: ~4-5 weeks
2115
+ 3 agents, parallel: ~3-4 weeks
2116
+
2117
+ KEY BLOCKERS:
2118
+ 1. Phase 1 must complete before Phase 2/3a start
2119
+ 2. CodeMirror 6 (2.4) is the longest single task (6-8 days)
2120
+ 3. Dockview wrappers (1.3) are second longest (3-4 days)
2121
+ 4. Bridge SSE+POST (3a.3) is NEW code (no v1 reference) — higher risk
2122
+ ```
2123
+
2124
+ ### H. Risk mitigation — implementation patterns
2125
+
2126
+ #### Risk 7: Zustand hydration race (pseudo-code)
2127
+
2128
+ ```typescript
2129
+ // store/index.ts — single store, partitioned persist (tenth pass)
2130
+ export const useWorkspaceStore = create<WorkspaceStoreState>()(
2131
+ persist(
2132
+ (set, get) => ({
2133
+ hydrationComplete: false,
2134
+ layout: null,
2135
+ // ... other state
2136
+ }),
2137
+ {
2138
+ name: 'boring-ui-v2:layout',
2139
+ onRehydrateStorage: () => (state) => {
2140
+ // Fires AFTER hydration completes
2141
+ state?.setHydrationComplete(true)
2142
+ },
2143
+ }
2144
+ )
2145
+ )
2146
+
2147
+ // layouts/DockviewShell.tsx
2148
+ function DockviewShell(props: DockviewShellProps) {
2149
+ const hydrationComplete = useWorkspaceStore(s => s.hydrationComplete)
2150
+ const pendingOnReady = useRef<DockviewReadyEvent | null>(null)
2151
+
2152
+ const handleReady = useCallback((event: DockviewReadyEvent) => {
2153
+ if (!hydrationComplete) {
2154
+ pendingOnReady.current = event
2155
+ return
2156
+ }
2157
+ initializeDockview(event)
2158
+ }, [hydrationComplete])
2159
+
2160
+ // Execute queued onReady when hydration completes
2161
+ useEffect(() => {
2162
+ if (hydrationComplete && pendingOnReady.current) {
2163
+ initializeDockview(pendingOnReady.current)
2164
+ pendingOnReady.current = null
2165
+ }
2166
+ }, [hydrationComplete])
2167
+
2168
+ if (!hydrationComplete) return <LoadingSkeleton />
2169
+
2170
+ return <DockviewReact onReady={handleReady} ... />
2171
+ }
2172
+ ```
2173
+
2174
+ #### Risk 8: Editor keybinding isolation
2175
+
2176
+ ```typescript
2177
+ // hooks/useEditorFocusManagement.ts
2178
+ function useEditorFocusManagement(api: DockviewApi) {
2179
+ useEffect(() => {
2180
+ const disposable = api.onDidActivePanelChange((event) => {
2181
+ const prevPanel = event.previous
2182
+ const nextPanel = event.panel
2183
+
2184
+ // Deactivate previous editor
2185
+ if (prevPanel?.params?.editorRef) {
2186
+ const editor = prevPanel.params.editorRef
2187
+ if (editor.type === 'tiptap') editor.setEditable(false)
2188
+ if (editor.type === 'codemirror') {
2189
+ editor.dispatch({ effects: readOnlyCompartment.reconfigure(EditorState.readOnly.of(true)) })
2190
+ }
2191
+ }
2192
+
2193
+ // Activate new editor
2194
+ if (nextPanel?.params?.editorRef) {
2195
+ const editor = nextPanel.params.editorRef
2196
+ if (editor.type === 'tiptap') editor.setEditable(true)
2197
+ if (editor.type === 'codemirror') {
2198
+ editor.dispatch({ effects: readOnlyCompartment.reconfigure(EditorState.readOnly.of(false)) })
2199
+ editor.focus()
2200
+ }
2201
+ }
2202
+ })
2203
+ return () => disposable.dispose()
2204
+ }, [api])
2205
+ }
2206
+ ```
2207
+
2208
+ #### Risk 9: FileTree standalone vs connected
2209
+
2210
+ ```typescript
2211
+ // components/FileTree.tsx — STANDALONE (props-based, zero context dependency)
2212
+ interface FileTreeProps {
2213
+ files: FileEntry[]
2214
+ selectedPath?: string
2215
+ expandedDirs?: string[]
2216
+ searchQuery?: string
2217
+ onSelect?: (path: string) => void
2218
+ onExpand?: (dir: string) => void
2219
+ onCollapse?: (dir: string) => void
2220
+ onContextMenu?: (path: string, action: string) => void
2221
+ onDragDrop?: (source: string, target: string) => void
2222
+ }
2223
+
2224
+ export function FileTree(props: FileTreeProps) { /* pure render */ }
2225
+
2226
+ // panes/FileTreePane.tsx — the pane wrapper IS the connected variant (no separate ConnectedFileTree)
2227
+ export function FileTreePane({ params, bridge }: PaneProps) {
2228
+ const { files, expandedDirs } = useFileData(params.dir as string)
2229
+ return (
2230
+ <FileTree
2231
+ files={files}
2232
+ expandedDirs={expandedDirs}
2233
+ onSelect={(path) => bridge.openFile(path)}
2234
+ onExpand={(dir) => bridge.expandToFile(dir)}
2235
+ />
2236
+ )
2237
+ }
2238
+ ```
2239
+
2240
+ ---
2241
+
2242
+ ## Appendix I: Dynamic Panes — Agent-Generated UI at Runtime
2243
+
2244
+ **Status: POST-LAUNCH** — Not part of the v2 workspace delivery.
2245
+ Prerequisite: v2 Phases 1-3a must ship first (layouts, panes, bridge). Dynamic panes build
2246
+ on top of the bridge infrastructure. No current app uses this feature — it will be validated
2247
+ against real agent workflows before committing to build.
2248
+
2249
+ This covers how agents write React components that the workspace hot-loads as panels.
2250
+
2251
+ ### Model
2252
+
2253
+ Agents write JSX files. Workspace hot-loads them. Error boundary provides the feedback loop.
2254
+
2255
+ ```
2256
+ Agent writes .jsx → Workspace hot-loads → Renders in panel
2257
+ ↓ (crash?)
2258
+ Error boundary catches
2259
+
2260
+ Error piped to agent via bridge
2261
+
2262
+ Agent fixes file
2263
+
2264
+ Workspace reloads automatically
2265
+ ```
2266
+
2267
+ ### File location
2268
+
2269
+ Agent-generated panes live at `/workspace/panes/` — visible in the workspace root.
2270
+ Users can inspect and edit them.
2271
+
2272
+ ### Loading mechanism
2273
+
2274
+ 1. Agent writes file via its file tools (or bridge)
2275
+ 2. Agent calls `bridge.openPanel({ id: 'chart-viz', source: '/workspace/panes/chart-visualization.jsx' })`
2276
+ 3. Workspace calls `dynamic import(source)` wrapped in `React.lazy()`
2277
+ 4. Component renders inside error boundary + Suspense wrapper
2278
+ 5. Pane receives full props: `{ theme, data, panelId, bridge }`
2279
+
2280
+ ### Props contract
2281
+
2282
+ ```typescript
2283
+ interface DynamicPaneProps {
2284
+ theme: 'light' | 'dark'
2285
+ data: unknown // passed by agent when opening the panel
2286
+ panelId: string
2287
+ bridge: WorkspaceBridge // full workspace API
2288
+ }
2289
+ ```
2290
+
2291
+ Full bridge access — agent-generated panes can read workspace state, open files,
2292
+ open other panels, show notifications, navigate. Same power as built-in panes.
2293
+
2294
+ ### Component kit (`@boring/workspace/blocks`)
2295
+
2296
+ A convenience library of pre-themed shadcn building blocks. Not a constraint —
2297
+ agents can use raw HTML/divs if they want, but the kit is faster and consistent.
2298
+
2299
+ **Blocks:**
2300
+ - **Layout**: `Card`, `Grid`, `Tabs`, `Accordion`, `ScrollArea`, `Separator`
2301
+ - **Data display**: `DataTable`, `KeyValue`, `Metric`, `CodeBlock`, `MarkdownView`, `JsonViewer`
2302
+ - **Charts**: `BarChart`, `LineChart`, `PieChart`, `ScatterChart` (thin wrappers over recharts)
2303
+ - **Forms**: `Form`, `Input`, `Select`, `Checkbox`, `Slider`, `Button`
2304
+ - **Feedback**: `Badge`, `Alert`, `Spinner`, `EmptyState`
2305
+
2306
+ All blocks inherit shadcn CSS variables, are tree-shakeable, have zero business logic,
2307
+ and accept standard React props + `className` for tailwind overrides.
2308
+
2309
+ ### Runtime boundary
2310
+
2311
+ - Dynamic panes run in **main thread** (not iframe) for full React context access
2312
+ - **Error boundary** catches crashes — panel shows error + stack trace, doesn't kill workspace
2313
+ - **Hot-reload**: workspace watches `/workspace/panes/` for file changes (via polling or
2314
+ bridge notification), invalidates module cache, re-renders
2315
+
2316
+ ### Transform approach: server-side esbuild (DECIDED)
2317
+
2318
+ **Problem**: In production, there's no Vite dev server. The browser receives raw `.jsx` it can't execute.
2319
+
2320
+ **Decision**: Agent-generated JSX goes through a server transform endpoint before the browser
2321
+ ever sees it. Two-layer validation catches errors before they reach the UI.
2322
+
2323
+ ```
2324
+ Agent writes .jsx to /workspace/panes/chart.jsx
2325
+
2326
+ Server: GET /api/v1/ui/pane?source=/workspace/panes/chart.jsx
2327
+
2328
+ Layer 1 — Prevalidation (esbuild transform):
2329
+ ✅ JSX syntax valid?
2330
+ ✅ Imports resolve? (@boring/workspace/blocks → bundled module URL)
2331
+ ✅ No dangerous patterns? (optional AST check)
2332
+ ↓ fails → 400 response with error details → agent fixes without UI disruption
2333
+ ↓ passes → returns transformed JS (browser-ready ESM)
2334
+
2335
+ Browser: dynamic import(transformed_url)
2336
+
2337
+ Layer 2 — Runtime validation (error boundary):
2338
+ ↓ render crash → error boundary catches → pipes error to agent → agent fixes → auto-reload
2339
+ ↓ renders OK → panel visible to user
2340
+ ```
2341
+
2342
+ **Why server-side esbuild**: Works identically in dev and prod, resolves bare specifiers
2343
+ at transform time (no import maps needed), prevalidation is free, ~10ms per file, no
2344
+ runtime dependency added to client bundle.
2345
+
2346
+ **Import resolution**: esbuild rewrites bare specifiers to URLs pointing at bundled modules
2347
+ using the Vite build manifest (`@boring/workspace/blocks` → `/assets/workspace-blocks-[hash].js`).
2348
+
2349
+ **Caching**: Transformed JS cached by content hash. Same source → same hash → browser cache hit.
2350
+
2351
+ ### Bridge safety layer (for dynamic panes)
2352
+
2353
+ - **Rate limiter**: max 10 bridge mutations per second per pane. Excess dropped + logged.
2354
+ - **Command validation**: bridge actions go through validator (e.g., can't close essential panels).
2355
+ - **Undo stack**: bridge records last N state snapshots. `bridge.undo()` available for recovery.
2356
+ - **Kill switch**: if a dynamic pane triggers > 50 errors in 10 seconds, workspace auto-closes it.
2357
+
2358
+ ### Implementation phases (Phase 3b)
2359
+
2360
+ | Task | Description |
2361
+ |------|-------------|
2362
+ | 3b.1 | Server-side esbuild transform endpoint (~100 LOC) |
2363
+ | 3b.2 | Dynamic pane loader (`registry/dynamicLoader.ts`) |
2364
+ | 3b.3 | Component kit (`blocks/`) — vendored shadcn + data blocks |
2365
+ | 3b.4 | File watcher + hot-reload |
2366
+ | 3b.5 | Error feedback loop (error → agent → fix → reload) |
2367
+ | 3b.6 | Bridge safety layer (rate limiter, validator, kill switch) |
2368
+
2369
+ ### v1 references for dynamic panes
2370
+
2371
+ | v2 target | v1 reference | Notes |
2372
+ |-----------|-------------|-------|
2373
+ | Dynamic loader | No direct v1 equivalent | New. Reference Vite's `import.meta.glob()` pattern. |
2374
+ | Error boundary | `PanelErrorBoundary.jsx` (58 lines) | Port and extend with error-to-agent piping. |
2375
+ | Component kit | `core/design-system/ui/*.jsx` | Already vendored shadcn. Move relevant to blocks export. |
2376
+ | File watching | `providers/data/queries.js` — polling pattern | React Query polling. Adapt for pane directory. |
2377
+ | Bridge error events | `server/services/uiStateImpl.ts` | v1 queues commands. v2 uses reactive events. |
2378
+
2379
+ ---
2380
+
2381
+ ## Appendix J: Gap Analysis — v1 Features vs v2 Plan
2382
+
2383
+ What breaks, what's dropped intentionally, what needs rethinking. All drop/keep decisions
2384
+ are already reflected in the Decisions table and Phase tasks above — this appendix provides
2385
+ the detailed rationale and per-app compatibility analysis.
2386
+
2387
+ ### Per-app v2 compatibility
2388
+
2389
+ | App | v2 compatibility | Key gaps |
2390
+ |-----|-----------------|----------|
2391
+ | **minimal** | ~95% | FileTree needs prop-based variant (solved by Tier 3 standalone) |
2392
+ | **custom-layout** | ~90% | Same as minimal — 3-column CSS grid + standalone components |
2393
+ | **chat** | ~20% (by design) | 80% is agent-package code. Workspace provides layout shell + artifact surface. |
2394
+ | **agent-backend** | ~15% (by design) | Layout toggle dropped (app implements). Capability fetching is app shell's job. |
2395
+ | **ide** | ~5% (by design) | 95% is app-shell code. Auth, routing, cloud features are not workspace's concern. |
2396
+ | **agent-frontend** | DROPPED (permanent) | Offline mode (LightningFS, isomorphic-git, Pyodide) permanently dropped. No DataProvider abstraction — HTTP-only is the final architecture. |
2397
+
2398
+ Low compatibility percentages are expected and correct — they reflect the boundary shift
2399
+ where auth, routing, cloud, and agent logic move to their proper owners (app shell, `@boring/agent`, `@boring/cloud`).
2400
+
2401
+ **Ninth-pass gap notes:**
2402
+ - **Terminal/PTY**: No longer used in any app. Dead code in v1 — not a migration concern.
2403
+ - **Git sidebar**: Dropped entirely. Agent owns all git UI. File tree is files-only.
2404
+ - **Tool-to-artifact bridge**: Agent must explicitly call `bridge.openPanel()` — workspace
2405
+ no longer infers panel type from tool results. Requires agent-side update.
2406
+ - **Layout toggle (IDE ↔ Chat)**: App implements conditional rendering. No workspace support.
2407
+ - **Offline/local-first**: Permanently gone. No path to re-add without DataProvider abstraction.
2408
+
2409
+ ### Custom events inventory (v1 → v2 replacement)
2410
+
2411
+ v1 uses `window.dispatchEvent(new CustomEvent(...))` for loose coupling. v2's bridge replaces most:
2412
+
2413
+ | Event | v2 replacement |
2414
+ |-------|----------------|
2415
+ | `boring-ui:user-settings-open` | App shell responsibility |
2416
+ | `boring-ui:sync-interval-changed` | Dropped (agent owns sync) |
2417
+ | `boring-ui:agent-prompt` | `bridge.sendMessage()` |
2418
+ | `boring-ui:shell-state` | `store.subscribe()` |
2419
+ | `theme-toggle-request` | `useTheme()` hook |
2420
+ | `bui:openFile` | `bridge.openFile()` |
2421
+ | PI session events (4) | Agent package responsibility |
2422
+
2423
+ ### localStorage key inventory (namespace collision avoidance)
2424
+
2425
+ v1 scatters state across 12+ keys. v2 consolidates to `boring-ui-v2:layout` + `boring-ui-v2:preferences`.
2426
+ Know v1 patterns to avoid collisions during coexistence:
2427
+
2428
+ | v1 key pattern | v2 status |
2429
+ |----------------|-----------|
2430
+ | `boring-ui:{prefix}:{projectRoot}:layout` | → `boring-ui-v2:layout` |
2431
+ | `boring-ui:{prefix}:{projectRoot}:tabs` | Dropped (derived from layout) |
2432
+ | `boring-ui:{prefix}:{projectRoot}:lastKnownGoodLayout` | Dropped |
2433
+ | `boring-ui-theme` / `kurt-web-theme` | → `boring-ui-v2:preferences` |
2434
+ | `boring-ui:chat-sessions:v1` | Agent package responsibility |
2435
+ | `boring-ui:terminal-*` | Dropped (terminal → agent) |
2436
+
2437
+ ### IDE boot flow boundary (app shell vs workspace)
2438
+
2439
+ ```
2440
+ main.jsx → App.jsx → useCapabilities → useWorkspaceAuth → useWorkspaceRouter
2441
+ → useDataProviderScope → useResolvedCapabilities → PageRouter
2442
+ → IdeLayout OR ChatCenteredWorkspace
2443
+ ```
2444
+
2445
+ | Step | v2 owner |
2446
+ |------|----------|
2447
+ | Capabilities fetch | App shell |
2448
+ | Auth flow (OIDC, session) | App shell |
2449
+ | Workspace routing | App shell |
2450
+ | Data provider scope | Workspace (`WorkspaceProvider`) |
2451
+ | Resolved capabilities | Workspace (registry filters) |
2452
+ | Layout selection | App shell |
2453
+ | Layout rendering | Workspace |
2454
+
2455
+ ### CSS audit: v1 breakpoints to parameterize
2456
+
2457
+ v1's `base.css` has hardcoded pixel breakpoints. v2 parameterizes all via tailwind config:
2458
+
2459
+ | Breakpoint | v1 purpose | v2 approach |
2460
+ |------------|-----------|-------------|
2461
+ | `56px` | Sidebar collapse width | `collapsedWidth` in GroupConfig |
2462
+ | `420px` | Surface min-width | `constraints.minWidth` in GroupConfig |
2463
+ | `1180px` | Compact layout trigger | tailwind `lg` breakpoint |
2464
+ | `960px` | Mobile layout trigger | tailwind `md` breakpoint |
2465
+
2466
+ ### Tiptap extension inventory (detailed)
2467
+
2468
+ | Extension | Package | v2 status |
2469
+ |-----------|---------|-----------|
2470
+ | StarterKit | `@tiptap/starter-kit` | **KEEP** |
2471
+ | Underline | `@tiptap/extension-underline` | **KEEP** |
2472
+ | Link | `@tiptap/extension-link` | **KEEP** |
2473
+ | Placeholder | `@tiptap/extension-placeholder` | **KEEP** |
2474
+ | TaskList + TaskItem | `@tiptap/extension-task-list`, `-task-item` | **KEEP** |
2475
+ | TextAlign | `@tiptap/extension-text-align` | **KEEP** |
2476
+ | Highlight | `@tiptap/extension-highlight` | **KEEP** |
2477
+ | Image | `@tiptap/extension-image` | **REPLACE** (official, no resize handles) |
2478
+ | CodeBlockLowlight | `@tiptap/extension-code-block-lowlight` | **KEEP** |
2479
+ | Markdown | `@tiptap/markdown` | **KEEP** |
2480
+ | Custom DiffExtension | Inline in Editor.jsx | **DEFER** (tenth pass: dropped with diff mode. Returns when git diff viewing ships.) |
2481
+ | ~~Table suite~~ | `@tiptap/extension-table*` (4 packages) | **DROP** |
2482
+ | ~~ImageResize~~ | `tiptap-extension-resize-image` | **DROP** |
2483
+
2484
+ **Result: 10 extensions (down from 16). Dropped 6 packages (including DiffExtension — deferred with diff mode).**
2485
+
2486
+ ### Deep import anti-pattern (v1 → v2 fix)
2487
+
2488
+ v1 apps use 11+ deep imports bypassing the public API. v2's `index.ts` exports everything
2489
+ apps need — zero deep imports required (see §A. Public API).