pi-weave 0.1.8 → 0.1.9

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 (39) hide show
  1. package/README.md +9 -3
  2. package/package.json +2 -2
  3. package/skills/weave-notepad/SKILL.md +13 -4
  4. package/src/core/concurrency.ts +36 -0
  5. package/src/core/frontmatter.ts +53 -0
  6. package/src/core/graph/build.ts +60 -1
  7. package/src/core/graph/model.ts +15 -0
  8. package/src/core/graph/wikilinks.ts +5 -1
  9. package/src/core/index.ts +2 -0
  10. package/src/core/paths.ts +7 -0
  11. package/src/core/sessions.ts +929 -0
  12. package/src/core/summaries.ts +1 -19
  13. package/src/core/vault.ts +264 -16
  14. package/src/core/workspace.ts +3 -3
  15. package/src/pi/index.ts +144 -37
  16. package/src/pi/sessionScan.ts +104 -0
  17. package/src/pi/summarize.ts +24 -4
  18. package/src/pi/tools/noteTool.ts +13 -4
  19. package/src/pi/viewer/tui/branding.ts +8 -7
  20. package/src/pi/viewer/tui/explorer.ts +1 -1
  21. package/src/web/client/dist/app.js +52 -39
  22. package/src/web/client/graph/Graph.tsx +70 -19
  23. package/src/web/client/graph/column.model.ts +11 -70
  24. package/src/web/client/graph/dynamics.ts +176 -0
  25. package/src/web/client/graph/graph.model.ts +10 -2
  26. package/src/web/client/graph/positions.ts +51 -10
  27. package/src/web/client/graph/renderer.ts +61 -1
  28. package/src/web/client/selection.storage.ts +69 -0
  29. package/src/web/client/shell/Header.tsx +11 -1
  30. package/src/web/client/shell/Shell.tsx +17 -0
  31. package/src/web/client/shell/theme.ts +15 -2
  32. package/src/web/client/state.ts +11 -0
  33. package/src/web/client/tree/Tree.tsx +4 -1
  34. package/src/web/client/workspace.ts +69 -5
  35. package/src/web/server/page.ts +2 -0
  36. package/src/web/server/routes.ts +19 -7
  37. package/src/web/shared/graph.ts +7 -0
  38. package/src/web/shared/layout.ts +158 -341
  39. package/src/web/shared/logo.ts +11 -0
@@ -94,6 +94,12 @@ export interface GraphRenderer {
94
94
  fit(): void;
95
95
  /** Current positions, for warm-starting a re-run. */
96
96
  positions(): Map<string, Point>;
97
+ /** Called when the user starts dragging a node. */
98
+ onDragStart(handler: (id: string) => void): void;
99
+ /** Called with graph-space coordinates while a node is dragged. */
100
+ onDragMove(handler: (id: string, at: Point) => void): void;
101
+ /** Called when a node drag ends. */
102
+ onDragEnd(handler: (id: string) => void): void;
97
103
  /** Tear down. Idempotent, and must leave no listener behind. */
98
104
  destroy(): void;
99
105
  }
@@ -130,6 +136,9 @@ export function nullRenderer(): GraphRenderer {
130
136
  setHighlight() {},
131
137
  onSelect() {},
132
138
  fit() {},
139
+ onDragStart() {},
140
+ onDragMove() {},
141
+ onDragEnd() {},
133
142
  positions() {
134
143
  return new Map();
135
144
  },
@@ -174,8 +183,13 @@ export interface CameraLike {
174
183
  export interface SigmaLike {
175
184
  on(event: "clickNode", handler: (payload: { node: string }) => void): unknown;
176
185
  on(event: "clickStage", handler: () => void): unknown;
186
+ on(event: "downNode", handler: (payload: { node: string }) => void): unknown;
187
+ on(event: "moveBody", handler: (payload: { event: { x: number; y: number }; preventSigmaDefault(): void }) => void): unknown;
188
+ on(event: "upNode" | "upStage", handler: () => void): unknown;
189
+ viewportToGraph(position: { x: number; y: number }): Point;
177
190
  setSetting(key: "nodeReducer", value: (id: string, data: RenderNode) => NodeDisplayOverride): unknown;
178
191
  setSetting(key: "edgeReducer", value: (key: string, data: RenderEdge) => EdgeDisplayOverride): unknown;
192
+ setSetting(key: "enableCameraPanning", value: boolean): unknown;
179
193
  setGraph(graph: ProjectedGraph): unknown;
180
194
  refresh(): unknown;
181
195
  getCamera(): CameraLike;
@@ -212,6 +226,21 @@ export function sigmaRenderer(create: SigmaFactory, scheme: ColorScheme): GraphR
212
226
  let graph: ProjectedGraph = project({ nodes: [], edges: [] });
213
227
  let highlight: ReadonlySet<string> | null = null;
214
228
  let select: (id: string | null) => void = () => {};
229
+ let dragStart: (id: string) => void = () => {};
230
+ let dragMove: (id: string, at: Point) => void = () => {};
231
+ let dragEnd: (id: string) => void = () => {};
232
+ let dragging: string | null = null;
233
+
234
+ const endDrag = (id: string | null): void => {
235
+ if (dragging === null) return;
236
+ const current = dragging;
237
+ dragging = null;
238
+ // A node drag is a pin, not a pan: sigma's captor would otherwise move the
239
+ // camera on the same gesture that is moving the node, and the node would
240
+ // slide away from the cursor. Re-enable panning only when the drag ends.
241
+ sigma?.setSetting("enableCameraPanning", true);
242
+ if (id === null || id === current) dragEnd(current);
243
+ };
215
244
 
216
245
  /**
217
246
  * Push the current highlight into sigma's reducers (§7.4).
@@ -236,6 +265,26 @@ export function sigmaRenderer(create: SigmaFactory, scheme: ColorScheme): GraphR
236
265
  // the tree and the context rail all recompute from it.
237
266
  instance.on("clickNode", ({ node }) => select(node));
238
267
  instance.on("clickStage", () => select(null));
268
+ instance.on("downNode", ({ node }) => {
269
+ dragging = node;
270
+ // Hold the view still while the node follows the cursor (see endDrag).
271
+ sigma?.setSetting("enableCameraPanning", false);
272
+ dragStart(node);
273
+ });
274
+ instance.on("moveBody", (payload) => {
275
+ if (dragging !== null) {
276
+ // Sigma's captor pans the camera on every mouse move while the
277
+ // button is down; during a node drag that is the pan the gesture
278
+ // must not be. `preventSigmaDefault` is the captor's own gate — it
279
+ // is consulted right after this handler returns — and without it
280
+ // the camera follows the cursor 1:1 while the pin follows it too,
281
+ // so the node appears to slide away under the view.
282
+ payload.preventSigmaDefault();
283
+ dragMove(dragging, instance.viewportToGraph({ x: payload.event.x, y: payload.event.y }));
284
+ }
285
+ });
286
+ instance.on("upNode", () => endDrag(dragging));
287
+ instance.on("upStage", () => endDrag(null));
239
288
  applyReducers(instance);
240
289
  sigma = instance;
241
290
  },
@@ -261,6 +310,18 @@ export function sigmaRenderer(create: SigmaFactory, scheme: ColorScheme): GraphR
261
310
  select = handler;
262
311
  },
263
312
 
313
+ onDragStart(handler) {
314
+ dragStart = handler;
315
+ },
316
+
317
+ onDragMove(handler) {
318
+ dragMove = handler;
319
+ },
320
+
321
+ onDragEnd(handler) {
322
+ dragEnd = handler;
323
+ },
324
+
264
325
  fit() {
265
326
  // `animatedReset` settles when the animation ends. Nothing awaits a
266
327
  // camera move, and a rejection from a camera killed mid-flight is noise.
@@ -270,7 +331,6 @@ export function sigmaRenderer(create: SigmaFactory, scheme: ColorScheme): GraphR
270
331
  positions() {
271
332
  return positionsOf(graph);
272
333
  },
273
-
274
334
  destroy() {
275
335
  sigma?.kill();
276
336
  sigma = null;
@@ -0,0 +1,69 @@
1
+ /**
2
+ * selection.storage.ts — the §1.3 context bus's memory across reloads.
3
+ *
4
+ * A manual refresh used to drop the open note: `selectedId` is a Preact
5
+ * signal, and signals do not survive a page load. The layout already
6
+ * persists (`shell/layout.model.ts`), and this is the same trick for the
7
+ * one piece of selection state the user actually misses: which note they
8
+ * were reading.
9
+ *
10
+ * ## Restore needs the graph
11
+ *
12
+ * A saved id is only offered back if it still names a node of the freshly
13
+ * loaded graph — a note deleted in another window must not resurrect as a
14
+ * selection pointing at nothing. That check needs the graph, which arrives
15
+ * after mount, so the restore is deliberately a pure function of
16
+ * `(graph, storage)` the shell calls once the first graph lands, not
17
+ * something this module schedules itself.
18
+ *
19
+ * ## Tier rules (§2)
20
+ *
21
+ * `src/web/client/**`: storage arrives as a two-method port (same shape as
22
+ * `LayoutStorage`) rather than the global, because the real `localStorage`
23
+ * throws in partitioned-storage contexts and a module this small should be
24
+ * testable without a DOM.
25
+ */
26
+
27
+ import type { GraphPayload } from "../shared/wire";
28
+
29
+ /** The slice of `Storage` this module needs. */
30
+ export interface SelectionStorage {
31
+ getItem(key: string): string | null;
32
+ setItem(key: string, value: string): void;
33
+ }
34
+
35
+ /** Versioned like `pi-weave.layout.v1`: an id-shape change gets a new key. */
36
+ export const SELECTION_STORAGE_KEY = "pi-weave.selection.v1";
37
+
38
+ /**
39
+ * Remember the selection; an empty string records "nothing open".
40
+ * Reports failure as `false` instead of throwing — a quota or
41
+ * partitioned-storage error must never break a selection.
42
+ */
43
+ export function saveSelection(storage: SelectionStorage, id: string | null): boolean {
44
+ try {
45
+ storage.setItem(SELECTION_STORAGE_KEY, id ?? "");
46
+ return true;
47
+ } catch {
48
+ return false;
49
+ }
50
+ }
51
+
52
+ /**
53
+ * The saved selection to restore, or `null`.
54
+ *
55
+ * `graph` gates the offer: the id must still name a node of the current
56
+ * payload, so a note deleted elsewhere is quietly not restored. Reads are
57
+ * failure-absorbing for the same reason writes are.
58
+ */
59
+ export function restoreSelection(graph: GraphPayload | null, storage: SelectionStorage): string | null {
60
+ if (graph === null) return null;
61
+ let raw: string | null;
62
+ try {
63
+ raw = storage.getItem(SELECTION_STORAGE_KEY);
64
+ } catch {
65
+ return null;
66
+ }
67
+ if (raw === null || raw === "") return null;
68
+ return graph.model.nodes.some((node) => node.id === raw) ? raw : null;
69
+ }
@@ -13,6 +13,7 @@
13
13
  * opened would be a worse lie than the disabled version it replaced.
14
14
  */
15
15
 
16
+ import { LOGO_MARK_B64, LOGO_MARK_MIME } from "../../shared/logo";
16
17
  import type { ConnectionView, HeaderSummary } from "./shell.model";
17
18
  import { SEARCH_PLACEHOLDER, searchHint, summaryParts } from "./shell.model";
18
19
 
@@ -28,7 +29,16 @@ export interface HeaderProps {
28
29
  export function Header(props: HeaderProps) {
29
30
  return (
30
31
  <header class="weave-header">
31
- <span class="weave-brand">🧵 pi-weave</span>
32
+ <span class="weave-brand">
33
+ <img
34
+ class="weave-brand-mark"
35
+ src={`data:${LOGO_MARK_MIME};base64,${LOGO_MARK_B64}`}
36
+ alt=""
37
+ width={18}
38
+ height={18}
39
+ />
40
+ pi-weave
41
+ </span>
32
42
  <button type="button" class="weave-search" title={searchHint(props.shortcut)} onClick={props.onSearch}>
33
43
  <span class="weave-search-text">{SEARCH_PLACEHOLDER}</span>
34
44
  <kbd>{props.shortcut}</kbd>
@@ -30,6 +30,7 @@ import { domEventSource } from "../live";
30
30
  import { createEditor, watchUnload, type EditorHandle } from "../note/editor.controller";
31
31
  import { editorPrompt, editorToolbar, initialEditorState, shouldBlockUnload } from "../note/editor.model";
32
32
  import { SearchPalette } from "../search/SearchPalette";
33
+ import { restoreSelection, saveSelection } from "../selection.storage";
33
34
  import { connection, graph, noteBody, selectedId } from "../state";
34
35
  import type { WorkspaceHandle } from "../workspace";
35
36
  import { observeNotes, select, startWorkspace } from "../workspace";
@@ -83,6 +84,22 @@ export function Shell(props: ShellProps) {
83
84
  return () => handle.stop();
84
85
  }, []);
85
86
 
87
+ // §1.3 continuity: a reload keeps the note you were reading. Saving is
88
+ // gated on the restore decision so the mount-time `null` cannot wipe the
89
+ // saved id before the first graph arrives to validate it against.
90
+ const selectionRestored = useRef(false);
91
+ useEffect(() => {
92
+ if (!selectionRestored.current) return;
93
+ saveSelection(localStorage, selectedId.value);
94
+ }, [selectedId.value]);
95
+ useEffect(() => {
96
+ if (selectionRestored.current || graph.value === null) return;
97
+ selectionRestored.current = true;
98
+ if (selectedId.value !== null) return;
99
+ const saved = restoreSelection(graph.value, localStorage);
100
+ if (saved !== null) void select(fetchJson, saved);
101
+ }, [graph.value]);
102
+
86
103
  // Every note that arrives, from any of the three directions it can arrive
87
104
  // from (mount, selection, SSE refetch), so the editor can decide whether it
88
105
  // is news or an interruption — see `editor.model.ts`'s header.
@@ -69,6 +69,7 @@ export const THEME_CSS = `
69
69
  --weave-bg:#141317;--weave-panel:#191820;--weave-fg:#e8e6ee;--weave-dim:#8f8a9c;
70
70
  --weave-faint:#5d5869;--weave-line:#26242e;--weave-line-strong:#343141;
71
71
  --weave-accent:#a48cff;--weave-ok:#4ade80;--weave-warn:#fbbf24;--weave-bad:#f87171;
72
+ --weave-new:rgba(164,140,255,.20);
72
73
  --weave-row:26px;--weave-gutter:10px;
73
74
  }
74
75
  @media (prefers-color-scheme: light){
@@ -76,6 +77,7 @@ export const THEME_CSS = `
76
77
  --weave-bg:#faf9f7;--weave-panel:#ffffff;--weave-fg:#1c1b19;--weave-dim:#6f6b66;
77
78
  --weave-faint:#9a958e;--weave-line:#e6e3de;--weave-line-strong:#d2cec8;
78
79
  --weave-accent:#6d4aff;--weave-ok:#15803d;--weave-warn:#b45309;--weave-bad:#b91c1c;
80
+ --weave-new:rgba(109,74,255,.14);
79
81
  }
80
82
  }
81
83
  body{font-size:13px}
@@ -86,7 +88,8 @@ body{font-size:13px}
86
88
  display:flex;align-items:center;gap:14px;padding:0 var(--weave-gutter);
87
89
  height:34px;border-bottom:1px solid var(--weave-line);background:var(--weave-panel);
88
90
  }
89
- .weave-brand{font-weight:600;letter-spacing:.01em;white-space:nowrap}
91
+ .weave-brand{display:inline-flex;align-items:center;gap:6px;font-size:14px;font-weight:600;letter-spacing:.01em;white-space:nowrap}
92
+ .weave-brand-mark{height:20px;width:20px;border-radius:5px}
90
93
  /* A button styled as a search field: the palette owns the only text input,
91
94
  so this opens it rather than pretending to accept a query. */
92
95
  .weave-search{
@@ -185,6 +188,17 @@ body{font-size:13px}
185
188
  }
186
189
  .weave-row:hover{background:var(--weave-line)}
187
190
  .weave-row-on{background:var(--weave-line-strong);color:var(--weave-fg)}
191
+ /* A newly-arrived node (a file or note added since the last update, §6):
192
+ one short highlight that fades while the label settles from bold back to
193
+ normal. The class is computed from the frame diff in workspace.ts and
194
+ expires with it, so collapsing and re-expanding later does not replay the
195
+ arrival, and the first load never flashes the whole tree. */
196
+ .weave-row-new{animation:weave-row-new 2.6s ease-out both}
197
+ @keyframes weave-row-new{
198
+ 0%{background:var(--weave-new);font-weight:700}
199
+ 40%{font-weight:700}
200
+ 100%{background:transparent;font-weight:400}
201
+ }
188
202
  .weave-twisty{width:10px;flex:none;color:var(--weave-faint);cursor:pointer;text-align:center}
189
203
  .weave-kind{flex:none;color:var(--weave-faint);font-size:11px}
190
204
  .weave-prov{flex:none;font-size:9px}
@@ -309,7 +323,6 @@ body{font-size:13px}
309
323
  display:flex;align-items:center;gap:6px;flex-wrap:wrap;
310
324
  padding:5px var(--weave-gutter);border-top:1px solid var(--weave-line);
311
325
  }
312
- .weave-depth{font:inherit;font-size:10.5px;padding:3px 4px}
313
326
  .weave-graph-legend{
314
327
  margin-left:auto;display:flex;gap:9px;font-size:10.5px;color:var(--weave-faint);
315
328
  white-space:nowrap;
@@ -76,3 +76,14 @@ export const treeState = signal<TreeState>(initialTreeState());
76
76
 
77
77
  /** SSE liveness (§6). */
78
78
  export const connection = signal<ConnectionState>("live");
79
+
80
+ /**
81
+ * Node ids that appeared in the most recent graph update (weave-workspace
82
+ * §6 frame → tree feedback).
83
+ *
84
+ * Empty on the mount fetch — a first load must not animate the whole tree.
85
+ * The controller replaces the set on every published payload and clears it
86
+ * after {@link ../workspace!RECENT_TTL_MS}, so a row collapsed and re-expanded
87
+ * minutes later does not replay its arrival animation.
88
+ */
89
+ export const recentIds = signal<ReadonlySet<string>>(new Set());
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import { useState } from "preact/hooks";
11
+ import { recentIds } from "../state";
11
12
  import type { GraphPayload } from "../../shared/wire";
12
13
  import type { TreeRowView, TreeViewState } from "./tree.model";
13
14
  import {
@@ -45,7 +46,9 @@ function Row({ view, onSelect, onToggle }: { view: TreeRowView; onSelect: () =>
45
46
  return (
46
47
  <li
47
48
  id={view.domId}
48
- class={`weave-row weave-row-${view.kind}${view.selected ? " weave-row-on" : ""}`}
49
+ class={`weave-row weave-row-${view.kind}${view.selected ? " weave-row-on" : ""}${
50
+ recentIds.value.has(view.id) ? " weave-row-new" : ""
51
+ }`}
49
52
  role="treeitem"
50
53
  aria-level={view.level}
51
54
  aria-posinset={view.posinset}
@@ -31,9 +31,10 @@
31
31
  * the socket, is what tells the user something is wrong.
32
32
  */
33
33
 
34
- import type { NotePayload } from "../shared/wire";
34
+ import type { GraphPayload, NotePayload } from "../shared/wire";
35
35
  import type { ApiResult, FetchLike } from "./api";
36
36
  import { fetchGraph, fetchNote } from "./api";
37
+ import { recentIds } from "./state";
37
38
  import type { EventSourceFactory, LiveHandle } from "./live";
38
39
  import { startLive } from "./live";
39
40
  import type { RefetchPlan } from "./live.model";
@@ -46,6 +47,34 @@ export interface WorkspaceOptions {
46
47
  open: EventSourceFactory;
47
48
  /** Overrides the SSE path. Tests use it; the shell does not. */
48
49
  path?: string;
50
+ /**
51
+ * One-shot timer, injectable for tests. Defaults to `setTimeout`. Used
52
+ * only to expire the recent-arrivals highlight ({@link RECENT_TTL_MS}).
53
+ */
54
+ defer?: (fn: () => void, ms: number) => () => void;
55
+ }
56
+
57
+ /** How long a newly-arrived node stays flagged in the tree (the animation is shorter). */
58
+ export const RECENT_TTL_MS = 3_000;
59
+
60
+ const NO_IDS: ReadonlySet<string> = new Set();
61
+
62
+ /**
63
+ * Node ids present in `next` but not in `previous`.
64
+ *
65
+ * The mount fetch passes `previous === null`, which yields the empty set —
66
+ * a first load must not animate the entire tree as "new". A node that left
67
+ * and returned is new again: from the reader's point of view it *is* a
68
+ * fresh arrival.
69
+ */
70
+ export function addedNodeIds(previous: GraphPayload | null, next: GraphPayload): ReadonlySet<string> {
71
+ if (previous === null) return NO_IDS;
72
+ const before = new Set(previous.model.nodes.map((node) => node.id));
73
+ const added = new Set<string>();
74
+ for (const node of next.model.nodes) {
75
+ if (!before.has(node.id)) added.add(node.id);
76
+ }
77
+ return added;
49
78
  }
50
79
 
51
80
  /**
@@ -97,10 +126,20 @@ export interface WorkspaceHandle {
97
126
  * subscriber. Skipping it is the difference between an idle workspace doing
98
127
  * nothing and one re-rendering three columns every time the watcher twitches.
99
128
  */
100
- async function loadGraph(fetchImpl: FetchLike, live: LiveHandle | null): Promise<ApiResult<unknown>> {
129
+ async function loadGraph(
130
+ fetchImpl: FetchLike,
131
+ live: LiveHandle | null,
132
+ onPublished?: (previous: GraphPayload | null, next: GraphPayload) => void,
133
+ ): Promise<ApiResult<unknown>> {
134
+ // Captured before the fetch so the diff describes exactly what the reader
135
+ // was looking at when the update landed.
136
+ const previous = graph.value;
101
137
  const result = await fetchGraph(fetchImpl, graph.value);
102
138
  if (!result.ok) return result;
103
- if (!result.cached) graph.value = result.data;
139
+ if (!result.cached) {
140
+ graph.value = result.data;
141
+ onPublished?.(previous, result.data);
142
+ }
104
143
  live?.seen(result.data.stamp);
105
144
  return result;
106
145
  }
@@ -157,13 +196,34 @@ export function noteSlug(id: string | null): string | null {
157
196
  */
158
197
  export function startWorkspace(opts: WorkspaceOptions): WorkspaceHandle {
159
198
  let live: LiveHandle | null = null;
199
+ let cancelRecentExpiry: (() => void) | null = null;
200
+ const defer =
201
+ opts.defer ??
202
+ ((fn: () => void, ms: number) => {
203
+ const handle = setTimeout(fn, ms);
204
+ return () => clearTimeout(handle);
205
+ });
206
+
207
+ /** Publish the frame's arrivals; the tree flashes them while they are new. */
208
+ const onPublished = (previous: GraphPayload | null, next: GraphPayload): void => {
209
+ const added = addedNodeIds(previous, next);
210
+ recentIds.value = added;
211
+ cancelRecentExpiry?.();
212
+ cancelRecentExpiry = null;
213
+ if (added.size > 0) {
214
+ cancelRecentExpiry = defer(() => {
215
+ cancelRecentExpiry = null;
216
+ recentIds.value = NO_IDS;
217
+ }, RECENT_TTL_MS);
218
+ }
219
+ };
160
220
 
161
221
  const runPlan = (plan: RefetchPlan): void => {
162
222
  // Fire-and-forget: this is called from a socket callback, which cannot
163
223
  // await. Failures are values (`api.ts`), so there is nothing to reject —
164
224
  // `void` documents that rather than hiding a floating promise.
165
225
  void (async () => {
166
- if (plan.graph) await loadGraph(opts.fetch, live);
226
+ if (plan.graph) await loadGraph(opts.fetch, live, onPublished);
167
227
  if (plan.note) await loadNote(opts.fetch);
168
228
  })();
169
229
  };
@@ -175,7 +235,7 @@ export function startWorkspace(opts: WorkspaceOptions): WorkspaceHandle {
175
235
  ...(opts.path === undefined ? {} : { path: opts.path }),
176
236
  });
177
237
 
178
- void loadGraph(opts.fetch, live);
238
+ void loadGraph(opts.fetch, live, onPublished);
179
239
 
180
240
  return {
181
241
  refresh() {
@@ -187,6 +247,9 @@ export function startWorkspace(opts: WorkspaceOptions): WorkspaceHandle {
187
247
  stop() {
188
248
  live?.stop();
189
249
  live = null;
250
+ cancelRecentExpiry?.();
251
+ cancelRecentExpiry = null;
252
+ recentIds.value = NO_IDS;
190
253
  },
191
254
  };
192
255
  }
@@ -211,4 +274,5 @@ export function resetWorkspace(): void {
211
274
  graph.value = null;
212
275
  noteBody.value = null;
213
276
  connection.value = "live";
277
+ recentIds.value = NO_IDS;
214
278
  }
@@ -43,6 +43,7 @@
43
43
  */
44
44
 
45
45
  import { randomBytes } from "node:crypto";
46
+ import { LOGO_MARK_B64, LOGO_MARK_MIME } from "../shared/logo";
46
47
  import type { Bootstrap } from "../shared/wire";
47
48
  import { BOOTSTRAP_ELEMENT_ID } from "../shared/wire";
48
49
 
@@ -236,6 +237,7 @@ export function renderPage(opts: RenderPageOptions): RenderedPage {
236
237
  '<meta name="viewport" content="width=device-width,initial-scale=1">',
237
238
  '<meta name="referrer" content="no-referrer">',
238
239
  "<title>pi-weave workspace</title>",
240
+ `<link rel="icon" type="image/png" href="data:${escapeAttr(LOGO_MARK_MIME)};base64,${escapeAttr(LOGO_MARK_B64)}">`,
239
241
  `<style nonce="${escapeAttr(nonce)}">`,
240
242
  THEME_CSS,
241
243
  "</style>",
@@ -363,6 +363,12 @@ export function toGraphPayload(model: CoreGraphModel, notes: readonly TaggedNote
363
363
  * prompted a refetch. A digest changes if and only if the bytes change, which
364
364
  * is the property both consumers actually need.
365
365
  *
366
+ * The digest is exact, but the payload is only an *excerpt* of the workspace:
367
+ * a note body below `PREVIEW_LEN` does not appear in the payload at all, so
368
+ * a body-only edit used to reproduce byte-identical payload and re-open the
369
+ * same hole through both dedupe layers. `GraphModel.contentDigest` — hashed
370
+ * over the note bodies themselves — closes that; see `core/graph/model.ts`.
371
+ *
366
372
  * `generatedAt` keeps its own, different job: it is the human-facing
367
373
  * "data as of" marker on the model, and the status bar still reads it.
368
374
  *
@@ -657,11 +663,15 @@ function normalizeEtag(value: string): string {
657
663
  * `DELETE /api/graph` and `PUT /api/note/x` answering the same `404` as any
658
664
  * other unrouted request: the family claims a request or it does not.
659
665
  *
660
- * The `rest` after the slug is matched **exactly**, not by prefix. A slug can
661
- * never legitimately contain `/` — `resolveNotePath` rejects it — so
662
- * `/api/note/a/b` is not a note named `a` with a sub-resource `b`, it is a
663
- * request for nothing, and it gets the `404` it deserves.
666
+ * The `rest` after the slug is matched **exactly**, not by prefix. Slugs may
667
+ * nest (`sessions/foo` — session memory lives in a vault subdirectory), so
668
+ * the family has exactly one sub-resource path (`/rename`) and everything
669
+ * else after a `/` belongs to the slug itself; anything else a caller
670
+ * appends is a request for nothing and gets the `404` it deserves. Core's
671
+ * `resolveNotePath` remains the traversal guard for whatever slug arrives.
664
672
  */
673
+ const renameSuffix = "/rename";
674
+
665
675
  async function routeNote(
666
676
  deps: RouteDeps,
667
677
  method: string,
@@ -669,9 +679,11 @@ async function routeNote(
669
679
  req: IncomingMessage,
670
680
  res: ServerResponse,
671
681
  ): Promise<boolean> {
672
- const cut = target.indexOf("/");
673
- const slug = cut === -1 ? target : target.slice(0, cut);
674
- const rest = cut === -1 ? "" : target.slice(cut);
682
+ // One sub-resource, matched at the end: a nested slug can itself contain
683
+ // slashes, so the split is anchored at the end, not the first slash.
684
+ const isRename = target.endsWith(renameSuffix);
685
+ const slug = isRename ? target.slice(0, target.length - renameSuffix.length) : target;
686
+ const rest = isRename ? renameSuffix : "";
675
687
 
676
688
  if (rest === "" && method === "GET") {
677
689
  await sendNote(deps, slug, res);
@@ -153,6 +153,13 @@ export interface WireGraphModel {
153
153
  staleness: WireStalenessReport | null;
154
154
  nodes: WireGraphNode[];
155
155
  edges: WireGraphEdge[];
156
+ /**
157
+ * Content fingerprint of the note bodies (see core `GraphModel`). Rides
158
+ * the wire so `GraphPayload.stamp` — hashed over these bytes — moves on a
159
+ * body-only edit, which is what un-dedupes the SSE frame that refetches
160
+ * an open note.
161
+ */
162
+ contentDigest: string;
156
163
  }
157
164
 
158
165
  /**