@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,647 @@
1
+ # Unified event bus for workspace + agent
2
+
3
+ **Status:** historical revision 2 — filesystem event ownership superseded by
4
+ `PLUGIN_OUTPUTS_ISOLATION_PLAN.md`
5
+ **Owners:** workspace
6
+ **Last updated:** 2026-04-28
7
+
8
+ **2026-04-30 amendment:** filesystem events are no longer workspace-core
9
+ `file:*` events or a `src/data/fileEvents.ts` shim. Filesystem event names
10
+ are plugin-keyed (`filesystem:file.changed`, `filesystem:file.created`,
11
+ `filesystem:file.moved`, `filesystem:file.deleted`) and the filesystem plugin
12
+ owns its event stream, cache invalidation, and file-panel binding.
13
+
14
+ ## Review summary (2026-04-28)
15
+
16
+ Codex and Gemini reviewed revision 1 independently. Convergent feedback
17
+ (both reviewers raised) is taken as binding for revision 2; divergent
18
+ feedback is called out inline at the relevant step / question.
19
+
20
+ **Binding changes (both reviewers agreed):**
21
+
22
+ - **`write`-vs-create ambiguity** in step 3. The agent's `op: 'write'`
23
+ fires for both new files and overwrites. Mapping it to `file:created`
24
+ causes false-positive "new file" effects. → Split into a separate
25
+ `file:changed` event for content-only mutations, and require an
26
+ explicit `existsBefore` (or upstream signal) before emitting
27
+ `file:created`.
28
+ - **State vs transitions** in step 5. A pure edge-triggered bus loses
29
+ state for components that mount after the event. → Bus events are
30
+ *transitions only*, never state. Components query initial state on
31
+ mount (`getPanels()`, `getActivePanelId()`, `isSavingPath(p)`) and
32
+ then subscribe for updates. `getSnapshot()` and friends survive as
33
+ small read APIs.
34
+ - **`emit` stays synchronous.** No `await Promise.all(listeners)`. Slow
35
+ consumers fire-and-forget their own async work.
36
+ - **Question Q4 (replay-on-subscribe) is settled:** no replay in the
37
+ bus. State queries handle late-mount cases. (Open question removed.)
38
+ - **Question Q2 (sync vs async) is settled:** sync only. (Open
39
+ question removed.)
40
+ - **Stronger correlation typing:** `toolCallId` is required when
41
+ `cause === 'agent'`. Encode this as a discriminated union on `cause`
42
+ rather than a flat optional.
43
+
44
+ **Significant additions:**
45
+
46
+ - New event: **`file:error`** — broadcasts a failed file op so toasts
47
+ / status surfaces don't each re-implement the try/catch (Gemini).
48
+ - New events: **`agent:run:start`** / **`agent:run:end`** — let UI
49
+ group a flurry of agent-driven file ops into a single progress
50
+ indicator (Gemini).
51
+ - New event: **`panel:closing`** — pre-close hook so consumers can
52
+ cancel queries / flush saves before teardown (Codex).
53
+ - **Cascading directory operations** (Gemini). A rename of
54
+ `src/hooks/` → `src/utils/` is a single FS operation but every open
55
+ editor inside that subtree needs to update. Two viable shapes; the
56
+ plan picks fan-out (see below).
57
+ - **Saving badge keys off panelId, not path** (Codex). Rename-during-
58
+ save would break a path-keyed badge.
59
+ - **Step 2 shim hardened:** explicit per-name translators (no
60
+ `as never` / `as object` casts), `onAny` bridge filters to `file:*`
61
+ prefix only, and the legacy export gets a deprecation warning for one
62
+ release before removal.
63
+
64
+ **Most important still-open question:** Q7 (where the agent SSE
65
+ adapter lives). Gemini argues it must be a global non-UI singleton so
66
+ agent file events don't drop while the chat panel is unmounted. Codex
67
+ argues it should live in `@boring/agent` to avoid a workspace import
68
+ loop. These don't conflict — see the "Where the SSE adapter lives"
69
+ section below for the resolved shape.
70
+
71
+ The rest of this document is the original plan with edits inline.
72
+
73
+ ## Problem
74
+
75
+ We have at least four disjoint pubsub-shaped mechanisms today, each
76
+ reinventing emit/subscribe with subtly different semantics:
77
+
78
+ 1. **Historical:** `packages/workspace/src/data/fileEvents.ts` was a module-level set of
79
+ listeners. Mutations `useMoveFile` / `useDeleteFile` / `useCreateDir`
80
+ emit `moved | deleted | created`. `DockviewShell` listens and updates
81
+ open panels in place (rename-in-place + tab close on delete). User-
82
+ originated changes only.
83
+ 2. **`packages/agent/src/front/hooks/useFileChangeStream.ts`** —
84
+ translates the agent's SSE `data-file-changed` chunks (`op: 'write' |
85
+ 'edit' | 'unlink' | 'rename' | 'mkdir'`, `path`, `oldPath`,
86
+ `toolCallId`, `timestamp`) into react-query cache invalidations.
87
+ **Does not feed `fileEvents`**, so an agent-driven rename leaves any
88
+ open editor pane stale (the same bug we just fixed for the tree).
89
+ 3. **`packages/workspace/src/toast/index.tsx`** — module-level set of
90
+ listeners for UI notifications.
91
+ 4. **Dockview** — `onDidAddPanel` / `onDidRemovePanel` /
92
+ `onDidActivePanelChange` emitted by the dockview library; consumed
93
+ inside `SurfaceShell` to push snapshots through `onChange`.
94
+
95
+ Plus near-future asks that are clearly events:
96
+
97
+ - **Tab saving badge** — the editor needs to tell the dock tab "save
98
+ started / save finished" so the tab title can render a spinner.
99
+ - **DB query lifecycle** — the chart canvas / data explorer fires
100
+ long-running queries; closing a pane should cancel them, slow queries
101
+ should drive a status indicator.
102
+ - **Selection + navigation** — agent `select(...)` flows already exist
103
+ via `WorkspaceBridge.select()` but the dispatch path is bespoke.
104
+
105
+ Each net-new event becomes another module-level Set of listeners, or
106
+ another callback prop threaded down through three layers. We're paying
107
+ the cost twice and the user has hit the inconsistency once already
108
+ (agent moves file → tab stays stale).
109
+
110
+ ## Goal
111
+
112
+ A single typed event bus, owned by `@boring/workspace`, used by:
113
+
114
+ - mutation hooks in `data/hooks.ts`
115
+ - the agent SSE stream translator in `useFileChangeStream`
116
+ - the editor / pane lifecycle (save start/end, dirty/clean)
117
+ - pane lifecycle (opened/closed/active/title-changed)
118
+ - DB / query lifecycle
119
+ - any future cross-cutting signal
120
+
121
+ …with one ergonomic API:
122
+
123
+ ```ts
124
+ events.on('file:moved', ({ from, to, cause }) => { … })
125
+ events.emit('file:moved', { from: 'a', to: 'b', cause: 'user' })
126
+ events.onAny((name, payload) => log(name, payload))
127
+ const unsub = events.on('editor:save:start', …)
128
+ unsub()
129
+ ```
130
+
131
+ Subscribers don't care whether the event came from the user clicking in
132
+ the tree, the agent calling a tool, or an external sync stream — they
133
+ just see one typed payload.
134
+
135
+ ## Design
136
+
137
+ ### Primitive: `createEventBus<TMap>()`
138
+
139
+ ```ts
140
+ // packages/workspace/src/events/bus.ts (new)
141
+
142
+ export interface EventBus<TMap extends Record<string, unknown>> {
143
+ on<K extends keyof TMap>(name: K, fn: (payload: TMap[K]) => void): () => void
144
+ once<K extends keyof TMap>(name: K, fn: (payload: TMap[K]) => void): () => void
145
+ off<K extends keyof TMap>(name: K, fn: (payload: TMap[K]) => void): void
146
+ emit<K extends keyof TMap>(name: K, payload: TMap[K]): void
147
+ onAny(fn: <K extends keyof TMap>(name: K, payload: TMap[K]) => void): () => void
148
+ }
149
+
150
+ export function createEventBus<TMap extends Record<string, unknown>>(): EventBus<TMap>
151
+ ```
152
+
153
+ Implementation is ~50 lines of plain JS:
154
+ - `Map<keyof TMap, Set<Listener>>` for typed subscribers
155
+ - one `Set<AnyListener>` for `onAny`
156
+ - `emit` snapshots both before iterating (safe to subscribe/unsubscribe
157
+ during dispatch — same invariant `fileEvents.ts` already has)
158
+ - a thrown listener never takes down the chain (try/catch around each
159
+ call), but the error is forwarded to a single `_lastError` slot so
160
+ tests can assert (and we eventually surface to logs)
161
+
162
+ No runtime deps. No async/promise semantics on emit — listeners that
163
+ need to do async work fire-and-forget their own promise.
164
+
165
+ ### The event map
166
+
167
+ One file owns the canonical map. `Origin` is a discriminated union — we
168
+ get static guarantees that agent-originated events carry a
169
+ `toolCallId`, and that user-originated events can carry a
170
+ `correlationId` (e.g. for routing toasts back to the action that fired
171
+ them).
172
+
173
+ ```ts
174
+ // packages/workspace/src/events/types.ts
175
+
176
+ export type Origin =
177
+ | { cause: 'user'; correlationId?: string }
178
+ | { cause: 'agent'; toolCallId: string; runId?: string }
179
+ | { cause: 'sync' }
180
+ | { cause: 'system' }
181
+
182
+ /** Common envelope on every event payload. */
183
+ export type EventMeta = Origin & { ts: number }
184
+
185
+ export interface WorkspaceEventMap {
186
+ // ── filesystem ───────────────────────────────────────────────────
187
+ 'file:moved': EventMeta & { from: string; to: string; isDir?: boolean }
188
+ 'file:deleted': EventMeta & { path: string; isDir?: boolean }
189
+ 'file:created': EventMeta & { path: string; kind: 'file' | 'dir' }
190
+ 'file:changed': EventMeta & { path: string } // overwrite / content edit
191
+ 'file:error': EventMeta & { op: 'move' | 'delete' | 'create' | 'write'; path: string; error: string }
192
+
193
+ // ── panel lifecycle ──────────────────────────────────────────────
194
+ 'panel:opened': { id: string; component: string; params?: Record<string, unknown> }
195
+ 'panel:closing': { id: string } // pre-close hook (cancel queries, flush save)
196
+ 'panel:closed': { id: string }
197
+ 'panel:active': { id: string | null }
198
+ 'panel:title': { id: string; title: string }
199
+
200
+ // ── editor lifecycle (drives the saving badge) ───────────────────
201
+ // Keyed by panelId, NOT path: rename mid-save would orphan a
202
+ // path-keyed badge. Subscribers map panelId→path on their own when
203
+ // they need the path.
204
+ 'editor:dirty': { panelId: string }
205
+ 'editor:clean': { panelId: string }
206
+ 'editor:save:start': { panelId: string }
207
+ 'editor:save:end': { panelId: string; ok: boolean; error?: string }
208
+
209
+ // ── data / query lifecycle ───────────────────────────────────────
210
+ 'query:start': { id: string; ownerPanelId?: string; sql?: string; source?: string }
211
+ 'query:end': { id: string; ownerPanelId?: string; ok: boolean; rows?: number; ms: number }
212
+ 'query:error': { id: string; ownerPanelId?: string; error: string }
213
+ 'query:cancel': { id: string }
214
+
215
+ // ── agent run lifecycle ─────────────────────────────────────────
216
+ // Lets UI group a flurry of agent-driven file ops into a single
217
+ // progress indicator / undo grouping.
218
+ 'agent:run:start': { runId: string; sessionId?: string }
219
+ 'agent:run:end': { runId: string; ok: boolean }
220
+
221
+ // future: 'tree:expanded', 'selection:changed', 'workspace:switched', …
222
+ }
223
+ ```
224
+
225
+ **Cascading directory operations.** A directory rename is a single FS
226
+ op; the bus emits **one** `file:moved` with `isDir: true`, then the
227
+ emitter (mutation hook or SSE bridge) also emits one `file:moved` per
228
+ *known-open* descendant path. The DockviewShell listener already does
229
+ prefix matching on `params.path`, so a single `isDir` event PLUS the
230
+ fan-out lets both prefix-aware consumers (a tree breadcrumb) and
231
+ naive consumers (an open editor tab) get the right answer. Tree
232
+ subscribers MUST use prefix-matching to update their state for
233
+ unopened descendants — those don't get fan-out events.
234
+
235
+ Adding a new event = adding a key to the map. The bus is
236
+ parametric so consumers in agent/full-app can extend with their own
237
+ TMap if needed (rare; default is the workspace map).
238
+
239
+ ### `cause` is load-bearing (now a discriminated union)
240
+
241
+ The `Origin` discriminated union lets every consumer make local UX
242
+ decisions while staying type-safe:
243
+
244
+ - file tree drag → `{ cause: 'user', correlationId? }` → toast "Moved"
245
+ - agent tool call → `{ cause: 'agent', toolCallId, runId? }` → no toast
246
+ (agent's own chat message is the source of truth) but the open pane
247
+ rename-syncs and the event traces back to the exact tool call
248
+ - external file watcher → `{ cause: 'sync' }` → silent rename
249
+ - programmatic boot-time → `{ cause: 'system' }`
250
+
251
+ Encoding `toolCallId` as required-when-`cause==='agent'` (vs an
252
+ optional field) catches the "agent emit forgot to attach the
253
+ toolCallId" bug at compile time. Reviewer-driven change.
254
+
255
+ ### Where it lives
256
+
257
+ - **Bus instance:** module singleton at
258
+ `packages/workspace/src/events/index.ts`. Same shape and lifetime
259
+ semantics as the existing toast and `fileEvents` singletons.
260
+ - **Importable from:** `@boring/workspace` (both runtime export and the
261
+ `WorkspaceEventMap` type).
262
+ - **Mounted automatically:** nothing to mount — module side-effects
263
+ only. Listeners are React-friendly (use in `useEffect` with the
264
+ unsub return value).
265
+
266
+ ### Convenience hook
267
+
268
+ ```ts
269
+ // packages/workspace/src/events/useEvent.ts
270
+ export function useEvent<K extends keyof WorkspaceEventMap>(
271
+ name: K,
272
+ handler: (payload: WorkspaceEventMap[K]) => void,
273
+ ): void {
274
+ const ref = useRef(handler)
275
+ ref.current = handler
276
+ useEffect(() => events.on(name, (p) => ref.current(p)), [name])
277
+ }
278
+ ```
279
+
280
+ So the React side reads `useEvent('file:moved', ({ from, to }) => …)`
281
+ without needing to think about cleanup.
282
+
283
+ ## Migration
284
+
285
+ Each step keeps the test suite green and is independently shippable.
286
+
287
+ ### Step 1 — Add the bus, no consumers yet
288
+
289
+ - New files: `packages/workspace/src/events/bus.ts`, `events/types.ts`,
290
+ `events/index.ts`, `events/useEvent.ts`.
291
+ - Tests: `events/__tests__/bus.test.ts` covers on/once/off/emit/onAny,
292
+ unsubscribe-during-dispatch, listener-throws-doesn't-stop-chain,
293
+ type-only assertion that `WorkspaceEventMap` keys are exhaustive
294
+ (compile-time test via a `satisfies` block).
295
+ - Public API: `import { events, useEvent } from '@boring/workspace'`.
296
+ - No behavior change yet.
297
+
298
+ ### Step 2 — Re-point `fileEvents.ts` to the bus
299
+
300
+ `fileEvents.ts` becomes a deprecation shim with **explicit
301
+ per-name translators** (no `as never` / `as object` casts that defeat
302
+ the type system the bus is trying to introduce — reviewer-driven
303
+ change):
304
+
305
+ ```ts
306
+ // Historical shape only. The compatibility shim was not kept after the
307
+ // hard plugin migration; filesystem events now live under
308
+ // packages/workspace/src/plugins/filesystemPlugin/events.ts.
309
+ import { events } from '../events'
310
+
311
+ let warnedSubscribe = false
312
+
313
+ export type FileEvent =
314
+ | { type: 'moved'; from: string; to: string }
315
+ | { type: 'deleted'; path: string }
316
+ | { type: 'created'; path: string; kind: 'file' | 'dir' }
317
+
318
+ export function emitFileEvent(e: FileEvent): void {
319
+ const meta = { cause: 'user' as const, ts: Date.now() }
320
+ if (e.type === 'moved') events.emit('file:moved', { ...meta, from: e.from, to: e.to })
321
+ if (e.type === 'deleted') events.emit('file:deleted', { ...meta, path: e.path })
322
+ if (e.type === 'created') events.emit('file:created', { ...meta, path: e.path, kind: e.kind })
323
+ }
324
+
325
+ export function subscribeFileEvents(fn: (e: FileEvent) => void): () => void {
326
+ if (!warnedSubscribe && process.env.NODE_ENV !== 'production') {
327
+ console.warn('[workspace] subscribeFileEvents is deprecated. Use `events.on("file:moved", …)` from @boring/workspace.')
328
+ warnedSubscribe = true
329
+ }
330
+ // Subscribe explicitly to file:* names — onAny would receive
331
+ // unrelated events (panel:*, editor:*) and re-fire them as the
332
+ // legacy union, which is wrong.
333
+ const unsubs = [
334
+ events.on('file:moved', (p) => fn({ type: 'moved', from: p.from, to: p.to })),
335
+ events.on('file:deleted', (p) => fn({ type: 'deleted', path: p.path })),
336
+ events.on('file:created', (p) => fn({ type: 'created', path: p.path, kind: p.kind })),
337
+ ]
338
+ return () => unsubs.forEach((u) => u())
339
+ }
340
+ ```
341
+
342
+ Existing callers keep working; new callers use `events.on(...)`. After
343
+ one or two PRs migrating consumers, delete `fileEvents.ts`.
344
+
345
+ `DockviewShell`'s subscriber gets rewritten in this step:
346
+
347
+ ```ts
348
+ useEvent('file:moved', ({ from, to, isDir }) => {
349
+ // For isDir=true, also handle prefix-rewrite for any open child
350
+ // panels — but those panels also receive their own file:moved
351
+ // fan-out events (see the event map for details), so the prefix
352
+ // pass is only needed when the listener wants to update non-open
353
+ // breadcrumbs / labels.
354
+ })
355
+ useEvent('file:deleted', ({ path, isDir }) => { /* close panel or any panel under prefix */ })
356
+ ```
357
+
358
+ > **Release-gate this with step 3.** Reviewer flag (Codex): if step 2
359
+ > ships alone, the user→pane sync improves but the agent→pane bug
360
+ > stays. Treat 2+3 as one PR.
361
+
362
+ ### Step 3 — Wire the agent SSE stream
363
+
364
+ > **Reviewer-driven changes**:
365
+ > - `op: 'write'` is *not* unconditionally a `file:created` — overwrites
366
+ > are common and would generate false "new file" effects. Use the SSE
367
+ > schema's existing signal (the server-side workspace knows whether
368
+ > the path existed before the write) to disambiguate. If the schema
369
+ > doesn't carry that today, **add an `existsBefore: boolean` field to
370
+ > the SSE payload as a precondition for step 3** — without it,
371
+ > step 3 ships with a known false-positive bug.
372
+ > - The translator must run regardless of which UI components are
373
+ > mounted. Putting it inside `useFileChangeStream` (a React hook tied
374
+ > to ChatPanel) means agent file events drop while the chat panel is
375
+ > unmounted but the agent run is still in flight (Gemini). → Move to
376
+ > a long-lived consumer of the SSE stream that's installed when the
377
+ > agent client is created, not when ChatPanel mounts. See "Where the
378
+ > SSE adapter lives" below.
379
+
380
+ ```ts
381
+ const meta = (toolCallId: string, runId?: string) =>
382
+ ({ cause: 'agent', toolCallId, runId, ts: Date.now() }) as const
383
+
384
+ if (op === 'rename' && oldPath) {
385
+ events.emit('file:moved', { ...meta(toolCallId, runId), from: oldPath, to: path })
386
+ } else if (op === 'unlink') {
387
+ events.emit('file:deleted', { ...meta(toolCallId, runId), path })
388
+ } else if (op === 'mkdir') {
389
+ events.emit('file:created', { ...meta(toolCallId, runId), path, kind: 'dir' })
390
+ } else if (op === 'write') {
391
+ if (existsBefore) events.emit('file:changed', { ...meta(toolCallId, runId), path })
392
+ else events.emit('file:created', { ...meta(toolCallId, runId), path, kind: 'file' })
393
+ } else if (op === 'edit') {
394
+ events.emit('file:changed', { ...meta(toolCallId, runId), path })
395
+ }
396
+ ```
397
+
398
+ Now an agent-driven rename updates the open editor tab the same way a
399
+ user-driven rename does. Existing react-query invalidations stay — they
400
+ pre-warm caches; the bus drives UI state.
401
+
402
+ Regression tests:
403
+ 1. Render a `DockviewShell` with a `file:foo.ts` panel open, fire a
404
+ synthetic `data-file-changed` SSE chunk through the SSE adapter,
405
+ assert the tab title and `params.path` updated.
406
+ 2. Fire a `write` chunk for a path that existed before → expect
407
+ `file:changed`, NOT `file:created`. (Current code path would emit
408
+ `file:created` and is the bug we're fixing.)
409
+ 3. Unmount ChatPanel mid-run, fire a `data-file-changed` chunk via the
410
+ long-lived adapter, assert the bus still sees the event.
411
+
412
+ ### Where the SSE adapter lives
413
+
414
+ Codex flagged this as the highest-impact open question. Resolved shape:
415
+
416
+ - **The adapter lives in `@boring/agent`** (translator from SSE schema
417
+ to `events` calls). Reason: the SSE schema is owned by the agent
418
+ package; coupling the translator to the schema avoids a cycle.
419
+ - **It does not depend on a React tree.** It hooks into the long-lived
420
+ agent client (the same object the ChatPanel uses), so unmounting
421
+ ChatPanel doesn't drop events. Pseudocode:
422
+
423
+ ```ts
424
+ // packages/agent/src/front/eventsBridge.ts
425
+ import { events } from '@boring/workspace'
426
+ export function attachFileEventsBridge(client: AgentClient): () => void {
427
+ return client.onStreamChunk((chunk) => {
428
+ if (chunk.type !== 'data-file-changed') return
429
+ // …translate to events.emit(...) as in step 3 above
430
+ })
431
+ }
432
+ ```
433
+ - **The host wires it once at app boot**, e.g. in the WorkspaceProvider
434
+ or app entry point. We do not import `@boring/agent` from
435
+ `@boring/workspace` — the dependency stays one-way (agent → workspace).
436
+
437
+ ### Step 4 — Editor lifecycle + tab saving badge
438
+
439
+ > **Reviewer-driven change** (Codex): keyed off `panelId`, not `path`.
440
+ > A rename mid-save would orphan a path-keyed badge — the editor's save
441
+ > still completes, but the tab (now showing the new path) never sees
442
+ > the matching `editor:save:end` because the path it's watching is
443
+ > stale.
444
+
445
+ `MarkdownEditorPane` (and `CodeEditorPane`) — already receive a
446
+ `panelId` from dockview:
447
+
448
+ ```ts
449
+ // inside the debounced save flow
450
+ events.emit('editor:save:start', { panelId })
451
+ try {
452
+ await writeFile({ path, content })
453
+ events.emit('editor:save:end', { panelId, ok: true })
454
+ } catch (err) {
455
+ events.emit('editor:save:end', { panelId, ok: false, error: String(err) })
456
+ }
457
+ ```
458
+
459
+ The custom `ShadcnTab` already receives the panel's `id`, so the badge
460
+ hook keys off it directly:
461
+
462
+ ```tsx
463
+ function useIsSaving(panelId: string) {
464
+ const [saving, setSaving] = useState(false)
465
+ useEvent('editor:save:start', (p) => p.panelId === panelId && setSaving(true))
466
+ useEvent('editor:save:end', (p) => p.panelId === panelId && setSaving(false))
467
+ return saving
468
+ }
469
+ ```
470
+
471
+ The tab renders a small `<Loader2 className="animate-spin" />` next to
472
+ the dirty dot when `useIsSaving(panel.id)` is true.
473
+
474
+ Late-mount handling: a tab that mounts during an in-flight save
475
+ correctly shows "not saving" until the next transition. This is
476
+ intentional — the alternative (replay-on-subscribe) makes the bus a
477
+ state manager. If the user hits a real "I cmd+tabbed away mid-save and
478
+ came back to a stale display" complaint, fix it by querying the
479
+ editor pane's own `isSaving()` getter on tab mount, not by adding
480
+ replay to the bus.
481
+
482
+ ### Step 5 — Pane lifecycle (events for transitions, queries for state)
483
+
484
+ > **Reviewer-driven change** (both reviewers): a pure event stream
485
+ > loses state for late mounters. Don't replace `getSnapshot`; augment
486
+ > it with transition events.
487
+
488
+ DockviewShell emits transitions:
489
+
490
+ ```ts
491
+ api.onDidAddPanel((p) => events.emit('panel:opened', { id: p.id, component: …, params: p.params }))
492
+ api.onWillRemovePanel?.((p) => events.emit('panel:closing', { id: p.id }))
493
+ api.onDidRemovePanel((p) => events.emit('panel:closed', { id: p.id }))
494
+ api.onDidActivePanelChange((p) => events.emit('panel:active', { id: p?.id ?? null }))
495
+ ```
496
+
497
+ State stays queryable via the existing `SurfaceShellApi.getSnapshot()`
498
+ plus a new `useDockviewApi().getPanels()` (already exposed). Hosts that
499
+ want a derived `openTabs[]` either:
500
+
501
+ 1. Read once from `getSnapshot()` on mount, then patch with bus events
502
+ (recommended pattern for new code).
503
+ 2. Keep the `onChange` callback as a back-compat wrapper that
504
+ internally subscribes to the bus and re-derives the snapshot. We
505
+ keep `onChange` for one release after step 5 lands.
506
+
507
+ `panel:closing` is the new pre-close hook. Consumers (e.g. data
508
+ explorer with in-flight queries) listen and call `events.emit('query:cancel', …)`
509
+ before the tab is gone, so they aren't racing teardown.
510
+
511
+ ### Step 6 — Query lifecycle
512
+
513
+ > **Reviewer-driven change**: payloads carry `ownerPanelId` so a
514
+ > closed pane can cancel its in-flight queries without each consumer
515
+ > reinventing query→pane bookkeeping.
516
+
517
+ DataExplorer / chart canvas adapters emit on each query they fire,
518
+ attaching `ownerPanelId`. UI gates a "Cancel" button on `query:start`
519
+ state. A central `panel:closing` listener iterates active queries and
520
+ fires `query:cancel` for any whose `ownerPanelId` matches.
521
+
522
+ `query:error` is a separate event from `query:end ok:false` so simple
523
+ "slow query toast" subscribers can subscribe to one channel and not
524
+ have to inspect `ok` flags.
525
+
526
+ ### Step 7 — Drop the deprecation shim
527
+
528
+ After the in-tree consumers are migrated AND the deprecation warning
529
+ has shipped in at least one release, delete `fileEvents.ts`. External
530
+ consumers see a clean import error pointing to the bus.
531
+
532
+ > **Reviewer note** (Codex): if `@boring/workspace` is consumed as a
533
+ > non-major version by external repos, ship the deprecation warning
534
+ > for one release before removal — don't slip the removal into a minor.
535
+
536
+ ## Use cases proven by this design
537
+
538
+ - **Rename open file** — already shipped via `fileEvents`; migrating
539
+ this consumer is the canary. (No behavior change for users.)
540
+ - **Agent renames open file** — fixed by step 3.
541
+ - **Tab saving badge** — step 4 is exactly the right shape.
542
+ - **Cancel queries when pane closes** — step 6, falls out of
543
+ `panel:closed` + `query:cancel`.
544
+ - **Toast for user-driven file ops, silent for agent** — `cause` field;
545
+ `useEvent('file:moved', e => e.cause === 'user' && toast.success(…))`.
546
+ - **Future "recent files" panel** — subscribes to `panel:opened` once,
547
+ no glue code.
548
+
549
+ ## Non-goals
550
+
551
+ - **Server-side persistence.** This bus is in-process. Replay across
552
+ page reloads, durability, undo/redo are out of scope. The agent's SSE
553
+ stream is still authoritative for cross-process events.
554
+ - **Cross-window sync.** No `BroadcastChannel` integration in v1. If we
555
+ need it we can add an adapter that mirrors a subset of events.
556
+ - **Replace dockview's own emitter.** We adapt to it, not replace it —
557
+ step 5 just adds a translator.
558
+ - **Replace toasts.** `toast.success(…)` keeps its own module. Toasts
559
+ are UI state, not domain events. (They could subscribe to the bus and
560
+ auto-toast; we keep that optional.)
561
+
562
+ ## Open questions
563
+
564
+ Resolved by review (2026-04-28):
565
+
566
+ - ~~Q2 (async listeners):~~ **Settled — sync only.** Both reviewers
567
+ flagged async-emit as a trap (deadlocks, render stalls).
568
+ - ~~Q4 (replay-on-subscribe):~~ **Settled — no replay.** Bus emits
569
+ transitions; state lives in the owning component, queryable on
570
+ mount.
571
+ - ~~Q7 (where the SSE adapter lives):~~ **Settled — in
572
+ `@boring/agent`,** as a long-lived stream subscriber (not a React
573
+ hook). See "Where the SSE adapter lives" above.
574
+
575
+ Resolved by user (2026-04-28):
576
+
577
+ - ~~Q1 (single bus vs typed channels):~~ **Single bus.** One `events`
578
+ instance, one `WorkspaceEventMap`. Re-evaluate at 30+ events.
579
+ - ~~Q2 (naming convention):~~ **Colon namespacing.** `file:moved`,
580
+ `panel:opened`, `editor:save:start`. Matches cmdk + vscode; enables
581
+ prefix filtering (`file:*`).
582
+ - ~~Q3 (`cause: 'system'`):~~ **Keep.** Boot-time reconciliation /
583
+ migration scripts have semantically distinct origins from external
584
+ sync sources. Cheap to remove later if unused.
585
+ - ~~Q6 (cascading directory rename fan-out):~~ **Listener does prefix
586
+ rewrite.** The emitter fires ONE `file:moved` with `isDir: true`.
587
+ Each listener (DockviewShell, etc.) iterates its own state and
588
+ rewrites paths starting with `from + '/'`. Emitter stays dumb;
589
+ listeners are smart. (Pseudocode below.)
590
+
591
+ ```ts
592
+ useEvent('file:moved', ({ from, to, isDir }) => {
593
+ if (isDir) {
594
+ for (const p of api.panels) {
595
+ const path = (p.params as { path?: string } | undefined)?.path
596
+ if (path?.startsWith(from + '/')) {
597
+ const newPath = to + path.slice(from.length)
598
+ p.api.updateParameters({ ...(p.params as object), path: newPath })
599
+ p.api.setTitle(newPath.split('/').pop() ?? newPath)
600
+ }
601
+ }
602
+ } else {
603
+ // existing single-file path
604
+ }
605
+ })
606
+ ```
607
+
608
+ Still open (low-priority):
609
+
610
+ - **Idempotency / debouncing.** No debouncing in the bus; consumers
611
+ debounce themselves. Reaffirmed by step 4 (save-start/end are
612
+ discrete; we don't need to debounce `editor:dirty`).
613
+ - **Test ergonomics.** Ship `events._reset()` as a test helper (mirror
614
+ of `_resetFileEventListeners`) plus a documented vitest pattern. No
615
+ need for a `withTestBus()` factory yet.
616
+
617
+ ## Migration risk
618
+
619
+ Low. Each step is additive; the deprecation shim in step 2 means we
620
+ never have a state where a consumer is broken. The only tricky bit is
621
+ making sure `cause` is set everywhere — easiest enforced by typing
622
+ `cause` as required (no default), so a missing field is a TS error at
623
+ the emit site.
624
+
625
+ ## Acceptance criteria
626
+
627
+ - One typed bus instance accessible as `events` from `@boring/workspace`.
628
+ Discriminated `Origin` union enforced by TS.
629
+ - All four legacy mechanisms (`fileEvents`, agent SSE, dockview events,
630
+ any new editor lifecycle) emit through it.
631
+ - Agent-driven file rename updates an open editor pane in place
632
+ (regression test in `dock.test.tsx`, parallel to the user-driven
633
+ case).
634
+ - Agent overwrite (`op: 'write'` with `existsBefore: true`) emits
635
+ `file:changed`, not `file:created`. Regression test.
636
+ - ChatPanel can unmount and remount mid-agent-run without losing file
637
+ events. Regression test against the new long-lived SSE adapter.
638
+ - Tab title shows a saving spinner during debounced save, keyed off
639
+ `panelId`. Rename-mid-save still clears the badge.
640
+ - Directory rename updates every open editor whose path was under the
641
+ old prefix. Regression test.
642
+ - `panel:closing` fires before `panel:closed` and gives consumers a
643
+ chance to flush. Regression test.
644
+ - No `fileEvents.ts` in the tree after step 7 + one-release deprecation
645
+ window.
646
+ - No regressions on the workspace test suite (currently 760 passing,
647
+ excluding the 16 pre-existing CommandPalette flakes).