pi-weave 0.1.12 → 0.1.13

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 (71) hide show
  1. package/README.md +8 -37
  2. package/package.json +1 -2
  3. package/src/core/concurrency.ts +3 -6
  4. package/src/core/frontmatter.ts +0 -53
  5. package/src/core/graph/build.ts +6 -7
  6. package/src/core/graph/current.ts +2 -4
  7. package/src/core/graph/model.ts +1 -1
  8. package/src/core/graph/wikilinks.ts +3 -3
  9. package/src/core/index.ts +26 -27
  10. package/src/core/paths.ts +0 -7
  11. package/src/core/vault.ts +16 -681
  12. package/src/core/view/detail.ts +1 -1
  13. package/src/core/view/health.ts +1 -1
  14. package/src/core/view/tree.ts +1 -1
  15. package/src/pi/index.ts +6 -85
  16. package/src/pi/summarize.ts +2 -2
  17. package/src/pi/viewer/tui/bodyStore.ts +4 -7
  18. package/src/pi/viewer/tui/branding.ts +7 -148
  19. package/src/pi/viewer/tui/run.ts +3 -17
  20. package/src/pi/viewer/tui/surface/base.ts +24 -3
  21. package/src/pi/viewer/tui/surface/explore.ts +41 -6
  22. package/src/pi/viewer/tui/workspace.ts +23 -351
  23. package/src/pi/viewer/tui/workspaceRoot.ts +31 -172
  24. package/src/pi/viewer/web/run.ts +7 -117
  25. package/src/web/client/api.dom.ts +2 -2
  26. package/src/web/client/api.ts +14 -223
  27. package/src/web/client/bootstrap.ts +5 -14
  28. package/src/web/client/context/context.model.ts +9 -11
  29. package/src/web/client/dist/app.js +93 -219
  30. package/src/web/client/graph/dynamics.ts +5 -65
  31. package/src/web/client/graph/renderer.dom.ts +7 -8
  32. package/src/web/client/graph/renderer.ts +9 -35
  33. package/src/web/client/main.tsx +1 -1
  34. package/src/web/client/note/Note.tsx +21 -63
  35. package/src/web/client/search/SearchPalette.tsx +45 -36
  36. package/src/web/client/search/search.model.ts +33 -454
  37. package/src/web/client/shell/Columns.tsx +13 -83
  38. package/src/web/client/shell/Header.tsx +2 -10
  39. package/src/web/client/shell/Shell.tsx +50 -125
  40. package/src/web/client/shell/StatusBar.tsx +1 -4
  41. package/src/web/client/shell/icons.model.ts +4 -7
  42. package/src/web/client/shell/keys.model.ts +5 -42
  43. package/src/web/client/shell/keys.ts +2 -2
  44. package/src/web/client/shell/shell.model.ts +10 -133
  45. package/src/web/client/shell/theme.model.ts +2 -2
  46. package/src/web/client/shell/theme.ts +33 -157
  47. package/src/web/client/state.ts +9 -89
  48. package/src/web/client/tree/Tree.tsx +25 -575
  49. package/src/web/client/tree/tree.model.ts +8 -162
  50. package/src/web/client/workspace.ts +72 -242
  51. package/src/web/server/page.ts +8 -10
  52. package/src/web/server/routes.ts +30 -563
  53. package/src/web/server/server.ts +6 -145
  54. package/src/web/shared/layout.ts +72 -624
  55. package/src/web/shared/wire.ts +10 -196
  56. package/src/core/sessions.ts +0 -929
  57. package/src/pi/sessionScan.ts +0 -104
  58. package/src/pi/viewer/tui/explorer.ts +0 -586
  59. package/src/web/client/live.model.ts +0 -275
  60. package/src/web/client/live.ts +0 -151
  61. package/src/web/client/note/Editor.tsx +0 -109
  62. package/src/web/client/note/editor.controller.ts +0 -151
  63. package/src/web/client/note/editor.model.ts +0 -686
  64. package/src/web/client/search/search.ts +0 -107
  65. package/src/web/client/shell/Divider.tsx +0 -44
  66. package/src/web/client/shell/cssvars.ts +0 -70
  67. package/src/web/client/shell/drag.model.ts +0 -170
  68. package/src/web/client/shell/layout.model.ts +0 -500
  69. package/src/web/client/shell/viewport.ts +0 -29
  70. package/src/web/server/sse.ts +0 -321
  71. package/src/web/server/watcher.ts +0 -507
@@ -1,22 +1,20 @@
1
1
  /**
2
2
  * workspaceRoot.ts — the v2 workspace ROOT component (weave-view-tui-v2 §4, §7).
3
3
  *
4
- * Sits above the pure `workspace.ts` split-tree model and the `surface/*`
4
+ * Sits above the pure fixed `workspace.ts` model and the `surface/*`
5
5
  * components. It composes panes from a `Workspace` value into a real pi-tui
6
- * tree (VStack/HStack/Pane), owns the single input listener, routes keys
7
- * (workspace §7.1 / pane §7.2) and cross-pane SurfaceEvents, renders the
6
+ * tree (HStack/Pane), owns the single input listener, routes keys and
7
+ * cross-pane SurfaceEvents, renders the
8
8
  * branded header + footer + narrow-mode tab bar (§9.2, decision 5), and
9
9
  * applies responsive collapse.
10
10
  *
11
- * It is drivable with a fake tui/theme/loaders exactly like v1's WeaveExplorer.
12
- * `WeaveExplorer` (v1 single-pane path) is kept intact for backward
13
- * compatibility; the workspace root is the v2 multi-pane path wired in run.ts.
11
+ * It is drivable with a fake tui/theme/loaders and is the path wired in run.ts.
14
12
  */
15
13
 
16
- import { HStack, matchesKey, truncateToWidth, visibleWidth, VStack, type Component } from "@earendil-works/pi-tui";
14
+ import { HStack, matchesKey, truncateToWidth, visibleWidth, type Component } from "@earendil-works/pi-tui";
17
15
  import { BodyStore } from "./bodyStore";
18
16
  import { renderMark } from "./branding";
19
- import type { WeaveLoaders, WeaveTheme, WeaveTui } from "./explorer";
17
+ import type { WeaveLoaders, WeaveTheme, WeaveTui } from "./surface/base";
20
18
  import { countProvenance } from "./model";
21
19
  import type { GraphModel, GraphNode } from "../../../core/graph/model";
22
20
  import { Pane } from "./surface/base";
@@ -25,19 +23,7 @@ import { ExploreSurface } from "./surface/explore";
25
23
  import { bindDetail, DetailSurface } from "./surface/detail";
26
24
  import { FocusSurface } from "./surface/focus";
27
25
  import { HealthSurface } from "./surface/health";
28
- import {
29
- close,
30
- collapseForWidth,
31
- defaultWorkspace,
32
- focusNext,
33
- resize,
34
- setPaneSurface,
35
- split,
36
- workspacePanes,
37
- type PaneNode,
38
- type Workspace,
39
- type WorkspaceNode,
40
- } from "./workspace";
26
+ import { collapseForWidth, defaultWorkspace, focusNext, workspacePanes, type PaneNode, type Workspace } from "./workspace";
41
27
 
42
28
  export interface WeaveWorkspaceOptions {
43
29
  model: GraphModel;
@@ -47,24 +33,10 @@ export interface WeaveWorkspaceOptions {
47
33
  done: (result: null) => void;
48
34
  rows?: number;
49
35
  now?: () => number;
50
- /** Pre-rendered brand mark line (from branding.renderMark). */
51
- logo?: string;
52
- /**
53
- * Raster kitty logo (branding.bundledLogoImage) rendered on its own row(s)
54
- * above the header text strip when Kitty graphics are available. `null`/
55
- * absent keeps the one-line glyph header (decision 1 favicon, not a splash).
56
- */
57
- logoImage?: Component | null;
58
- /** Optional initial workspace (defaults to the Explore default). */
59
- workspace?: Workspace;
60
36
  }
61
37
 
62
- /** Decode a workspace-level key (returns null when it's a pane key). */
38
+ /** Decode the few root-level keys; all other input belongs to the active surface. */
63
39
  export function decodeWorkspaceKey(data: string): string | null {
64
- if (data === "\\") return "splitV";
65
- if (data === "|") return "splitH";
66
- if (data === "x") return "close";
67
- if (data === "w") return "workspace";
68
40
  if (data === "?") return "help";
69
41
  if (data === "q") return "quit";
70
42
  if (data === "r") return "refresh";
@@ -80,12 +52,11 @@ export class WeaveWorkspace implements Component {
80
52
  private readonly rows: number;
81
53
  private readonly nowFn: () => number;
82
54
  private readonly logo: string;
83
- private readonly logoImage: Component | null;
84
55
  private readonly bodies: BodyStore;
85
56
  private ctx: SurfaceContext;
86
57
  /** paneId → surface instance. */
87
58
  private panes = new Map<string, Surface>();
88
- /** The workspace split tree + focus. */
59
+ /** The fixed pane layout + focus. */
89
60
  workspace: Workspace;
90
61
  private helpOpen = false;
91
62
  refreshing = false;
@@ -101,8 +72,7 @@ export class WeaveWorkspace implements Component {
101
72
  this.done = opts.done;
102
73
  this.rows = opts.rows ?? 24;
103
74
  this.nowFn = opts.now ?? Date.now;
104
- this.logo = opts.logo ?? renderMark("glyph", opts.theme, 20);
105
- this.logoImage = opts.logoImage ?? null;
75
+ this.logo = renderMark(opts.theme, 20);
106
76
  this.bodies = new BodyStore({
107
77
  loaders: opts.loaders,
108
78
  onChange: () => this.invalidateAndRender(),
@@ -114,7 +84,7 @@ export class WeaveWorkspace implements Component {
114
84
  bodies: this.bodies,
115
85
  now: this.nowFn,
116
86
  };
117
- this.workspace = opts.workspace ?? defaultWorkspace(opts.model);
87
+ this.workspace = defaultWorkspace(opts.model);
118
88
  this.syncPanes();
119
89
  }
120
90
 
@@ -192,17 +162,6 @@ export class WeaveWorkspace implements Component {
192
162
  this.applyWorkspaceKey(wsKey);
193
163
  return;
194
164
  }
195
- if (data === "e" || data === "d" || data === "h") {
196
- this.swapSurface(data);
197
- return;
198
- }
199
- // Ctrl+letters resize — only the four resize bytes are intercepted so
200
- // enter (\r/0x0d), esc (0x1b) and other control keys reach the pane.
201
- const ctrl = charCode(data);
202
- if (ctrl !== undefined && (ctrl === 0x08 || ctrl === 0x0a || ctrl === 0x0b || ctrl === 0x0c)) {
203
- this.applyResize(ctrl);
204
- return;
205
- }
206
165
  const active = this.activePane();
207
166
  if (active?.handleInput) {
208
167
  active.handleInput(data);
@@ -225,21 +184,6 @@ export class WeaveWorkspace implements Component {
225
184
 
226
185
  private applyWorkspaceKey(key: string): void {
227
186
  switch (key) {
228
- case "splitV":
229
- this.workspace = split(this.workspace, this.workspace.activePaneId, "vertical");
230
- this.syncPanes();
231
- this.invalidateAndRender();
232
- return;
233
- case "splitH":
234
- this.workspace = split(this.workspace, this.workspace.activePaneId, "horizontal");
235
- this.syncPanes();
236
- this.invalidateAndRender();
237
- return;
238
- case "close":
239
- this.workspace = close(this.workspace, this.workspace.activePaneId);
240
- this.syncPanes();
241
- this.invalidateAndRender();
242
- return;
243
187
  case "quit":
244
188
  this.quit();
245
189
  return;
@@ -255,11 +199,6 @@ export class WeaveWorkspace implements Component {
255
199
  this.invalidateAndRender();
256
200
  });
257
201
  return;
258
- case "workspace":
259
- // TODO(M5): named-workspace switcher overlay; for now toggles help.
260
- this.helpOpen = !this.helpOpen;
261
- this.invalidateAndRender();
262
- return;
263
202
  case "help":
264
203
  this.helpOpen = !this.helpOpen;
265
204
  this.invalidateAndRender();
@@ -267,28 +206,6 @@ export class WeaveWorkspace implements Component {
267
206
  }
268
207
  }
269
208
 
270
- /** Ctrl+h/l adjust row weights; Ctrl+j/k adjust column weights. */
271
- private applyResize(byte: number): void {
272
- const active = this.workspace.activePaneId;
273
- const d = 2;
274
- if (byte === 0x08) this.workspace = resize(this.workspace, active, "row", -d); // Ctrl-h
275
- else if (byte === 0x0c) this.workspace = resize(this.workspace, active, "row", d); // Ctrl-l
276
- else if (byte === 0x0a) this.workspace = resize(this.workspace, active, "column", d); // Ctrl-j
277
- else if (byte === 0x0b) this.workspace = resize(this.workspace, active, "column", -d); // Ctrl-k
278
- this.syncPanes();
279
- this.invalidateAndRender();
280
- }
281
-
282
- /** e/d/h — swap the active pane's surface in place (f stays the pane focus key). */
283
- private swapSurface(key: string): void {
284
- const kind = key === "e" ? "explore" : key === "d" ? "detail" : "health";
285
- const activeId = this.workspace.activePaneId;
286
- this.workspace = setPaneSurface(this.workspace, activeId, kind);
287
- const s = this.createSurface({ type: "pane", id: activeId, surface: kind, nodeId: null });
288
- this.panes.set(activeId, s);
289
- this.invalidateAndRender();
290
- }
291
-
292
209
  private cycleFocus(dir: 1 | -1): void {
293
210
  this.workspace = focusNext(this.workspace, dir);
294
211
  this.refreshFocusFlags();
@@ -331,27 +248,15 @@ export class WeaveWorkspace implements Component {
331
248
  if (node.detail.slug) void this.loaders.openNote(node.detail.slug);
332
249
  }
333
250
 
334
- /** Obsidian "open in main": nearest Detail pane to the right, else split. */
251
+ /** Open a selected node in the fixed Detail pane. */
335
252
  private openDetail(id: string): void {
336
253
  // (The active Detail surface already rebinds itself on enter, so no
337
254
  // active-pane special case is needed here.)
338
255
  const panes = workspacePanes(this.workspace);
339
- const activeIdx = panes.findIndex((p) => p.id === this.workspace.activePaneId);
340
- const toRight = panes.slice(activeIdx + 1).find((p) => p.surface === "detail");
341
- if (toRight) {
342
- this.bindDetail(toRight.id, id);
343
- this.setActive(toRight.id);
344
- return;
345
- }
346
- // none to the right — split the active pane horizontally into a Detail
347
- this.workspace = split(this.workspace, this.workspace.activePaneId, "horizontal", "detail");
348
- this.syncPanes();
349
- const detail = workspacePanes(this.workspace).find((p) => p.surface === "detail");
350
- if (detail) {
351
- this.bindDetail(detail.id, id);
352
- this.setActive(detail.id);
353
- }
354
- this.invalidateAndRender();
256
+ const detail = panes.find((p) => p.surface === "detail");
257
+ if (!detail) return;
258
+ this.bindDetail(detail.id, id);
259
+ this.setActive(detail.id);
355
260
  }
356
261
 
357
262
  private bindDetail(paneId: string, id: string): void {
@@ -375,16 +280,7 @@ export class WeaveWorkspace implements Component {
375
280
  (this.panes.get(focus.id) as FocusSurface).setFocus(id);
376
281
  this.setActive(focus.id);
377
282
  this.invalidateAndRender();
378
- return;
379
- }
380
- this.workspace = split(this.workspace, this.workspace.activePaneId, "horizontal", "focus");
381
- this.syncPanes();
382
- const fp = workspacePanes(this.workspace).find((p) => p.surface === "focus");
383
- if (fp) {
384
- (this.panes.get(fp.id) as FocusSurface).setFocus(id);
385
- this.setActive(fp.id);
386
283
  }
387
- this.invalidateAndRender();
388
284
  }
389
285
 
390
286
  private requestBody(id: string): void {
@@ -414,25 +310,11 @@ export class WeaveWorkspace implements Component {
414
310
  const repo = this.model.nodes.find((n) => n.kind === "repository");
415
311
  const repoState = repoStaleness(this.model, repo);
416
312
  const repoPart = repo ? ` · repo ${repo.label}:${repoState}` : "";
417
- // Kitty: the raster logo leads on its own row(s) and replaces the glyph in
418
- // the text strip (decision 1 favicon). Otherwise the one-line glyph header
419
- // stays intact.
420
- let imageLines: string[] | null = null;
421
- if (this.logoImage) {
422
- try {
423
- const lines = this.logoImage.render(width);
424
- if (lines && lines.length) imageLines = lines;
425
- } catch {
426
- imageLines = null;
427
- }
428
- }
429
- const mark = imageLines ? "" : this.logo;
313
+ const mark = this.logo;
430
314
  const head = `${mark}${mark ? " " : ""}${t.bold("weave view")}`;
431
315
  const fill = "─".repeat(Math.max(1, width - visibleWidth(head) - 2));
432
316
  const line1 = `${head} ${fill} ${this.workspace.name} · ${workspacePanes(this.workspace).length} panes${repoPart}`;
433
- const out: string[] = [];
434
- if (imageLines) out.push(...imageLines);
435
- out.push(truncateToWidth(line1, width));
317
+ const out: string[] = [truncateToWidth(line1, width)];
436
318
  const banner = this.bannerText();
437
319
  if (banner) out.push(t.fg("warning", truncateToWidth(banner, width)));
438
320
  const countsLine = `notes ${counts.total} (● ${counts.human} / ◐ ${counts.agent} / ○ ${counts.generated})`;
@@ -447,36 +329,19 @@ export class WeaveWorkspace implements Component {
447
329
  private renderBody(width: number): string[] {
448
330
  const effective = collapseForWidth(this.workspace, width);
449
331
  const bodyRows = Math.max(1, this.rows - 4);
450
- const tree = this.buildSplit(effective.root, bodyRows);
451
- return tree.render(width);
332
+ const stack = new HStack();
333
+ for (const pane of effective.panes) stack.addChild(this.buildPane(pane, bodyRows), { grow: 1 });
334
+ return stack.render(width);
452
335
  }
453
336
 
454
- private buildSplit(node: WorkspaceNode, rows: number): Component {
455
- if (node.type === "pane") {
456
- const s = this.panes.get(node.id);
457
- if (!s) return { render: () => [], invalidate: () => {} };
458
- (s as { paneRows?: number }).paneRows = Math.max(1, rows - 3);
459
- const p = new Pane(s, this.theme);
460
- p.rows = rows;
461
- p.setFocused(node.id === this.workspace.activePaneId);
462
- return p;
463
- }
464
- const StackCtor = node.direction === "row" ? HStack : VStack;
465
- const stack = new StackCtor();
466
- // VStack stacks panes vertically, so each child's height must be its share
467
- // of the available rows (by grow weight) — otherwise every child renders at
468
- // full height, the stack overflows, and the render clamp leaves only the
469
- // first pane visible (the "split hides the new pane" bug). HStack shares
470
- // width, so its children keep the full height.
471
- const vertical = node.direction !== "row";
472
- const sizes = node.sizes.length ? node.sizes : node.children.map(() => 1);
473
- const total = sizes.reduce((a, b) => a + b, 0) || node.children.length;
474
- node.children.forEach((c, i) => {
475
- const grow = sizes[i] ?? 1;
476
- const childRows = vertical ? Math.max(1, Math.round((rows * grow) / total)) : rows;
477
- stack.addChild(this.buildSplit(c, childRows), { grow });
478
- });
479
- return stack;
337
+ private buildPane(node: PaneNode, rows: number): Component {
338
+ const s = this.panes.get(node.id);
339
+ if (!s) return { render: () => [], invalidate: () => {} };
340
+ (s as { paneRows?: number }).paneRows = Math.max(1, rows - 3);
341
+ const p = new Pane(s, this.theme);
342
+ p.rows = rows;
343
+ p.setFocused(node.id === this.workspace.activePaneId);
344
+ return p;
480
345
  }
481
346
 
482
347
  private renderTabBar(width: number): string[] {
@@ -493,22 +358,16 @@ export class WeaveWorkspace implements Component {
493
358
  const t = this.theme;
494
359
  if (this.helpOpen) {
495
360
  const help = [
496
- "Tab focus · \\ | split · Ctrl-hjkl resize · x close · e/d/f/h swap",
361
+ "Tab focus · ↑↓/jk move · ←→/hl expand · enter open · f focus · r refresh · ? help · q quit",
497
362
  "↑↓/jk move · ←→/hl expand · enter open · / filter · p prov · f focus · r refresh · ? help · q quit",
498
363
  ];
499
364
  return help.map((l) => t.fg("dim", truncateToWidth(l, width)));
500
365
  }
501
- const hint = "Tab focus · \\ split · Ctrl-hjkl resize · e/d/f/h pane · r refresh · ? help · q quit";
366
+ const hint = "Tab focus · ↑↓/jk move · ←→/hl expand · enter open · f focus · r refresh · ? help · q quit";
502
367
  return [t.fg("dim", truncateToWidth(hint, width))];
503
368
  }
504
369
  }
505
370
 
506
- function charCode(data: string): number | undefined {
507
- if (data.length !== 1) return undefined;
508
- const c = data.charCodeAt(0);
509
- return c >= 0 && c < 0x20 ? c : undefined;
510
- }
511
-
512
371
  /** True when the active surface is in a sub-mode that Esc should clear instead
513
372
  * of quitting — Explore's search (`state.searching`) or Detail's goto-line
514
373
  * (`state.gotoBuf`). Surfaces own their own state; read defensively so a
@@ -3,19 +3,15 @@
3
3
  * (weave-workspace §5.4, §6, §13).
4
4
  *
5
5
  * This is the adapter half of the browser workspace: everything portable
6
- * already lives in `src/core` (the cache) and `src/web/server` (the server,
7
- * the SSE hub, the watcher). What is left here is the wiring those three
6
+ * already lives in `src/core` (the cache) and `src/web/server` (the server).
7
+ * What is left here is the wiring those two
8
8
  * cannot do for themselves, because it is session-shaped:
9
9
  *
10
10
  * 1. **Composition.** Build one {@link WorkspaceCache} over `ctx.cwd` +
11
- * the resolved vault root, one {@link SseHub}, one {@link Watcher}, and
12
- * hand the last two to `startWorkspaceServer` so it owns their teardown.
13
- * 2. **The liveness loop.** `watcher.onPath → cache.invalidate` (evict
14
- * precisely) and `watcher.onChange → cache.graph() → sse.broadcast`
15
- * (one frame per scope). §6's diagram, in code.
16
- * 3. **Singleton per session.** A second `/weave-view` must reuse the
11
+ * the resolved vault root and hand it to `startWorkspaceServer`.
12
+ * 2. **Singleton per session.** A second `/weave-view` must reuse the
17
13
  * running server — a second server would mean a second port, a second
18
- * watcher on the same directories, and a browser tab pointed at a
14
+ * second browser tab pointed at a
19
15
  * workspace nobody is going to close (§5.4).
20
16
  * 4. **Browser handoff**, with an honest fallback when there is no browser
21
17
  * to hand off to.
@@ -40,15 +36,11 @@
40
36
  import { platform as osPlatform } from "node:os";
41
37
  import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
42
38
  import { resolveVaultRoot, WorkspaceCache } from "../../../core";
43
- import { graphStamp, type Watcher as ServerWatcher } from "../../../web/server/routes";
44
39
  import {
45
40
  startWorkspaceServer,
46
41
  type StartWorkspaceServerOptions,
47
42
  type WorkspaceServer,
48
43
  } from "../../../web/server/server";
49
- import { SseHub } from "../../../web/server/sse";
50
- import { Watcher } from "../../../web/server/watcher";
51
- import type { ChangeScope } from "../../../web/shared/wire";
52
44
 
53
45
  /** The slice of `ExtensionAPI.exec` this module uses. */
54
46
  export type ExecFn = (command: string, args: string[]) => Promise<{ code: number; stderr: string }>;
@@ -73,73 +65,10 @@ export function browserOpenCommand(
73
65
  return { command: "xdg-open", args: [url] };
74
66
  }
75
67
 
76
- /**
77
- * The §6 liveness loop, as three callbacks over a cache and a hub.
78
- *
79
- * Extracted from the boot closure so it can be driven directly in a test
80
- * instead of through a real filesystem event: "a change arrives, the cache
81
- * is evicted, the graph is rebuilt, one frame per scope goes out" is the
82
- * behaviour worth asserting, and waiting on `fs.watch` to assert it would
83
- * be slow, platform-dependent and flaky for no gain.
84
- */
85
- export interface LivenessBridge {
86
- /** One call per accepted path, immediately — precise eviction. */
87
- onPath(absPath: string): void;
88
- /** One call per debounce window — rebuild once, broadcast per scope. */
89
- onChange(scopes: readonly ChangeScope[]): void;
90
- /** The stamp a newly attached SSE client is told about. */
91
- currentStamp(): string | null;
92
- /** Test seam: resolves when the in-flight rebuild settles. */
93
- settled(): Promise<void>;
94
- }
95
-
96
- export function createLivenessBridge(cache: WorkspaceCache, sse: SseHub): LivenessBridge {
97
- // The last stamp we broadcast, or `null` before the first change. Held
98
- // rather than derived because `currentStamp` is synchronous (sse.ts) and
99
- // `cache.snapshot()` is not.
100
- let lastStamp: string | null = null;
101
- let inFlight: Promise<void> = Promise.resolve();
102
-
103
- return {
104
- onPath: (absPath) => cache.invalidate(absPath),
105
- onChange: (scopes) => {
106
- const done = (async () => {
107
- // A rebuild can fail — the vault was deleted mid-session — and a
108
- // failed broadcast must cost one missed frame, not an unhandled
109
- // rejection inside a filesystem callback.
110
- const snapshot = await cache.snapshot().catch(() => null);
111
- if (snapshot === null) return;
112
- // The *same* key `/api/graph` puts in its ETag (§5.3, §6): a content
113
- // digest, not `generatedAt`.
114
- //
115
- // This is the half of §15.6 that was easy to miss. The client dedupes
116
- // frames on `stamp` (`live.model.ts`) and already holds the stamp of
117
- // the graph it last fetched, so while this broadcast a timestamp max,
118
- // an edit that did not advance that maximum produced a frame the
119
- // client discarded *before* issuing the conditional GET. The stale
120
- // `304` was the second line of defence; this was the first, and it
121
- // failed for the same three cases. Sharing one key fixes both.
122
- //
123
- // `snapshot()`, not `graph()`, because the payload — and therefore
124
- // the digest — depends on the notes as well as the model (§4.3).
125
- const stamp = graphStamp(snapshot);
126
- lastStamp = stamp;
127
- for (const scope of scopes) sse.broadcast({ scope, stamp });
128
- })();
129
- inFlight = inFlight.then(() => done);
130
- },
131
- currentStamp: () => lastStamp,
132
- settled: () => inFlight,
133
- };
134
- }
135
-
136
68
  /** Everything one running workspace owns. Closed as a unit. */
137
69
  export interface WebWorkspaceSession {
138
70
  server: WorkspaceServer;
139
71
  cache: WorkspaceCache;
140
- sse: SseHub;
141
- watcher: Watcher;
142
- liveness: LivenessBridge;
143
72
  }
144
73
 
145
74
  export interface WebWorkspaceDeps {
@@ -223,7 +152,7 @@ export class WebWorkspaceController {
223
152
  return { session, started, opened, fallbackToTui: wantsBrowser && !opened };
224
153
  }
225
154
 
226
- /** Stop the workspace: server, watcher and every SSE stream. Idempotent. */
155
+ /** Stop the workspace server. Idempotent. */
227
156
  async close(): Promise<void> {
228
157
  // A close arriving mid-boot must still close what that boot produced,
229
158
  // or `session_shutdown` during a slow start leaks the whole stack.
@@ -232,8 +161,6 @@ export class WebWorkspaceController {
232
161
  const session = this.session;
233
162
  if (session === null) return;
234
163
  this.session = null;
235
- // `server.close()` closes the hub and awaits the watcher — it was handed
236
- // both at construction and owns their teardown (server.ts §5.4).
237
164
  await session.server.close();
238
165
  this.deps.onStateChange?.();
239
166
  }
@@ -258,34 +185,14 @@ export class WebWorkspaceController {
258
185
  const cwd = ctx.cwd;
259
186
  const vaultRoot = (this.deps.vaultRoot ?? resolveVaultRoot)();
260
187
  const cache = new WorkspaceCache({ cwd, vaultRoot });
261
- const sse = new SseHub({ currentStamp: () => liveness.currentStamp() });
262
- const liveness = createLivenessBridge(cache, sse);
263
-
264
- const watcher = new Watcher({
265
- cwd,
266
- vaultRoot,
267
- // Per-path, immediately: eviction wants every path (§6).
268
- onPath: (absPath) => liveness.onPath(absPath),
269
- // Per debounce window: one frame per scope, whatever the path count.
270
- onChange: (scopes) => liveness.onChange(scopes),
271
- });
272
-
273
188
  const startServer = this.deps.startServer ?? startWorkspaceServer;
274
189
  const server = await startServer({
275
190
  cwd,
276
191
  vaultRoot,
277
192
  cache,
278
- sse,
279
- watcher: asServerWatcher(watcher),
280
- // The idle timeout closed the server behind our back; forget it so the
281
- // next `/weave-view` boots a fresh one instead of handing out a dead port.
282
- onIdleShutdown: () => {
283
- this.session = null;
284
- this.deps.onStateChange?.();
285
- },
286
193
  });
287
194
 
288
- return { server, cache, sse, watcher, liveness };
195
+ return { server, cache };
289
196
  }
290
197
 
291
198
  /** Spawn the browser, reporting failure rather than throwing it. */
@@ -303,23 +210,6 @@ export class WebWorkspaceController {
303
210
  }
304
211
  }
305
212
 
306
- /**
307
- * Adapt the concrete {@link Watcher} to the promise-shaped contract
308
- * `server.ts` owns. The class is synchronous because `fs.watch` is; the
309
- * interface is async because a future watcher (a `chokidar`-style poller, a
310
- * remote FS) would not be.
311
- */
312
- function asServerWatcher(watcher: Watcher): ServerWatcher {
313
- return {
314
- start: async () => {
315
- watcher.start();
316
- },
317
- close: async () => {
318
- watcher.close();
319
- },
320
- };
321
- }
322
-
323
213
  /** The one notification, worded for whichever path got us here. */
324
214
  function describe(facts: { url: string; started: boolean; opened: boolean; wantsBrowser: boolean }): string {
325
215
  const where = facts.started ? "workspace running at" : "workspace already running at";
@@ -10,8 +10,7 @@
10
10
  * Something still has to call the real thing. That is this file, and it is
11
11
  * separate so the untestable surface — one reference to a DOM global — is
12
12
  * isolated in four lines rather than smeared through the module that holds
13
- * all the request logic. Same shape as `domEventSource` in `live.ts`, for the
14
- * same reason.
13
+ * all the request logic.
15
14
  *
16
15
  * This module is only reachable from `.tsx` entry points, so it is compiled
17
16
  * by `tsconfig.web.json` (which has `DOM`) and never pulled into the root
@@ -32,6 +31,7 @@ export const fetchJson: FetchLike = (url: string, init?: HttpRequest): Promise<H
32
31
  ...(init?.method === undefined ? {} : { method: init.method }),
33
32
  ...(init?.headers === undefined ? {} : { headers: init.headers }),
34
33
  ...(init?.body === undefined ? {} : { body: init.body }),
34
+ ...(init?.signal === undefined ? {} : { signal: init.signal as AbortSignal }),
35
35
  // Loopback only, and the `__Host-weave` cookie is how §5.1 authenticates.
36
36
  // `same-origin` is the default in modern browsers; stated explicitly
37
37
  // because the whole security model depends on it and a default is a worse