@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.
- package/LICENSE +21 -0
- package/README.md +94 -0
- package/dist/CodeEditor-DQqOn4xz.js +266 -0
- package/dist/CommandPalette-aM61U-b0.js +5229 -0
- package/dist/FileTree-DRq_bfue.js +245 -0
- package/dist/MarkdownEditor-DjiHxnRv.js +349 -0
- package/dist/WorkspaceLoadingState-By0dZoPD.js +568 -0
- package/dist/agent-tool-NvxKfist.d.ts +28 -0
- package/dist/app-front.d.ts +485 -0
- package/dist/app-front.js +452 -0
- package/dist/app-server.d.ts +53 -0
- package/dist/app-server.js +769 -0
- package/dist/bootstrapServer-BRUqUpVW.d.ts +66 -0
- package/dist/boring-workspace.css +1 -0
- package/dist/charts.d.ts +114 -0
- package/dist/charts.js +143 -0
- package/dist/events.d.ts +178 -0
- package/dist/events.js +88 -0
- package/dist/explorer-DtLUnuah.d.ts +129 -0
- package/dist/panel-DnvDNQac.js +6 -0
- package/dist/server.d.ts +84 -0
- package/dist/server.js +811 -0
- package/dist/shared.d.ts +113 -0
- package/dist/shared.js +11 -0
- package/dist/testing-e2e.d.ts +68 -0
- package/dist/testing-e2e.js +45 -0
- package/dist/testing.d.ts +464 -0
- package/dist/testing.js +10984 -0
- package/dist/utils-B6yFEsav.js +8 -0
- package/dist/workspace.css +5780 -0
- package/dist/workspace.d.ts +2119 -0
- package/dist/workspace.js +1884 -0
- package/docs/INTERFACES.md +58 -0
- package/docs/PLUGIN_STRUCTURE.md +162 -0
- package/docs/README.md +19 -0
- package/docs/bridge.md +135 -0
- package/docs/panels.md +102 -0
- package/docs/plans/GENERIC_EXPLORER_PLUGIN_PLAN.md +455 -0
- package/docs/plans/MACRO_PLUGIN_GENERIC_HELPERS_AUDIT.md +962 -0
- package/docs/plans/PLUGIN_OUTPUTS_ISOLATION_PLAN.md +301 -0
- package/docs/plans/README.md +9 -0
- package/docs/plans/UI_BRIDGE_OWNERSHIP_REFACTOR.md +303 -0
- package/docs/plans/archive/CODE_OWNERSHIP_CLEANUP_PLAN.md +387 -0
- package/docs/plans/archive/COMMAND_PALETTE_REGISTRY.md +814 -0
- package/docs/plans/archive/DECLARATIVE_LAYOUT_MIGRATION.md +277 -0
- package/docs/plans/archive/PLUGIN_MODEL.md +3674 -0
- package/docs/plans/archive/SRC_FOLDER_REORG_PLAN.md +307 -0
- package/docs/plans/archive/UNIFIED_EVENT_BUS.md +647 -0
- package/docs/plans/archive/WORKSPACE_V2_PLAN.md +2489 -0
- package/docs/plugins.md +158 -0
- 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).
|