pi-weave 0.3.8 → 0.3.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 (69) hide show
  1. package/README.md +17 -16
  2. package/package.json +4 -3
  3. package/skills/weave-explore/SKILL.md +3 -5
  4. package/skills/weave-notepad/SKILL.md +22 -2
  5. package/skills/weave-notepad/references/second-thoughts.md +57 -0
  6. package/src/core/graph/build.ts +1 -1
  7. package/src/core/graph/current.ts +10 -8
  8. package/src/core/history.ts +417 -0
  9. package/src/core/index.ts +19 -0
  10. package/src/core/links/repair.ts +5 -68
  11. package/src/core/noteAction.ts +72 -2
  12. package/src/core/openInEditor.ts +3 -8
  13. package/src/core/repoAction.ts +20 -61
  14. package/src/core/repoIndex.ts +38 -0
  15. package/src/core/trail.ts +124 -0
  16. package/src/core/types.ts +1 -3
  17. package/src/core/vault.ts +116 -29
  18. package/src/core/view/focus.ts +2 -2
  19. package/src/core/view/index.ts +2 -3
  20. package/src/core/view/rawTail.ts +153 -0
  21. package/src/core/workspace.ts +20 -26
  22. package/src/core/workspaceCommands.ts +14 -14
  23. package/src/opencode/tools.ts +3 -3
  24. package/src/opencode/tui.ts +1 -7
  25. package/src/pi/index.ts +68 -80
  26. package/src/pi/tools/noteTool.ts +1 -1
  27. package/src/pi/tools/repoTool.ts +3 -6
  28. package/src/pi/viewer/web/run.ts +6 -8
  29. package/src/web/client/api.ts +103 -5
  30. package/src/web/client/dist/app.js +183 -52
  31. package/src/web/client/graph/Graph.tsx +23 -2
  32. package/src/web/client/graph/column.model.ts +1 -1
  33. package/src/web/client/graph/graph.model.ts +69 -7
  34. package/src/web/client/graph/renderer.ts +19 -3
  35. package/src/web/client/note/Note.tsx +171 -20
  36. package/src/web/client/note/Replay.tsx +128 -0
  37. package/src/web/client/note/drafts.ts +50 -9
  38. package/src/web/client/note/note.model.ts +17 -0
  39. package/src/web/client/note/replay.model.ts +173 -0
  40. package/src/web/client/shell/ContextRail.tsx +7 -0
  41. package/src/web/client/shell/Settings.tsx +21 -1
  42. package/src/web/client/shell/Shell.tsx +34 -10
  43. package/src/web/client/shell/keys.model.ts +20 -7
  44. package/src/web/client/shell/theme.ts +118 -1
  45. package/src/web/client/shell/workspace.css.ts +4 -0
  46. package/src/web/client/trail/TrailStrip.tsx +55 -0
  47. package/src/web/client/trail/trail.model.ts +195 -0
  48. package/src/web/client/trail/trail.ts +45 -0
  49. package/src/web/client/tree/Tree.tsx +7 -3
  50. package/src/web/client/tree/tree.model.ts +2 -5
  51. package/src/web/server/page.ts +1 -1
  52. package/src/web/server/routes.ts +83 -3
  53. package/src/web/shared/logo.ts +1 -1
  54. package/src/web/shared/view.ts +10 -4
  55. package/src/web/shared/wire.ts +74 -1
  56. package/src/core/view/health.ts +0 -160
  57. package/src/pi/viewer/tui/bodyStore.ts +0 -95
  58. package/src/pi/viewer/tui/branding.ts +0 -14
  59. package/src/pi/viewer/tui/model.ts +0 -390
  60. package/src/pi/viewer/tui/openNote.ts +0 -11
  61. package/src/pi/viewer/tui/run.ts +0 -71
  62. package/src/pi/viewer/tui/surface/base.ts +0 -203
  63. package/src/pi/viewer/tui/surface/detail.ts +0 -315
  64. package/src/pi/viewer/tui/surface/explore.ts +0 -241
  65. package/src/pi/viewer/tui/surface/focus.ts +0 -139
  66. package/src/pi/viewer/tui/surface/health.ts +0 -102
  67. package/src/pi/viewer/tui/theme.ts +0 -90
  68. package/src/pi/viewer/tui/workspace.ts +0 -53
  69. package/src/pi/viewer/tui/workspaceRoot.ts +0 -389
@@ -45,6 +45,7 @@ import {
45
45
  } from "./column.model";
46
46
  import type { PositionStorage } from "./positions";
47
47
  import type { GraphRenderer, RendererFactory } from "./renderer";
48
+ import type { TrailGlow } from "./graph.model";
48
49
  import { schemeOf } from "./scheme";
49
50
  import type { SchemeHost } from "./scheme";
50
51
  import type { GraphTheme } from "../../shared/themes";
@@ -92,14 +93,21 @@ export interface GraphProps {
92
93
  * keeps the ownership where it is and costs one line at each end.
93
94
  */
94
95
  fit: { current: (() => void) | null };
95
-
96
+ /** Recall-trail glows by node id (`trail/trail.model.ts`). Omitted means none. */
97
+ trail?: ReadonlyMap<string, TrailGlow>;
96
98
  }
97
99
 
100
+ const NO_GLOWS: ReadonlyMap<string, TrailGlow> = new Map();
101
+
98
102
  export function Graph(props: GraphProps) {
99
103
  const canvas = useRef<HTMLDivElement | null>(null);
100
104
  const renderer = useRef<GraphRenderer | null>(null);
101
105
  const dynamics = useRef<GraphSimulation | null>(null);
102
106
  const clock = useRef<number | null>(null);
107
+ // Whether the camera has been fitted to the first settled layout. The
108
+ // mount frames the *starting* positions, which a settling simulation then
109
+ // pushes past the edges — a two-note vault opened with clipped circles.
110
+ const fitted = useRef(false);
103
111
  /**
104
112
  * Arm the simulation clock — idempotent.
105
113
  *
@@ -116,7 +124,13 @@ export function Graph(props: GraphProps) {
116
124
  if (engine === null) return;
117
125
  engine.tick();
118
126
  // Asleep: let the clock die. The next pin re-arms it.
119
- if (!engine.awake()) return;
127
+ if (!engine.awake()) {
128
+ if (!fitted.current) {
129
+ fitted.current = true;
130
+ renderer.current?.fit();
131
+ }
132
+ return;
133
+ }
120
134
  renderer.current?.setPositions(engine.positions());
121
135
  clock.current = requestAnimationFrame(step);
122
136
  };
@@ -146,6 +160,8 @@ export function Graph(props: GraphProps) {
146
160
  const preview = graphPreview(props.graph, props.previewId);
147
161
 
148
162
  // Read by the mount-time `onSelect`, which outlives this render.
163
+ const trailRef = useRef<ReadonlyMap<string, TrailGlow>>(NO_GLOWS);
164
+ trailRef.current = props.trail ?? NO_GLOWS;
149
165
  const live = useRef({ view, model, onSelect: props.onSelect, selectedId: props.selectedId });
150
166
  live.current = { view, model, onSelect: props.onSelect, selectedId: props.selectedId };
151
167
 
@@ -195,6 +211,7 @@ export function Graph(props: GraphProps) {
195
211
  // the column is already showing.
196
212
  instance.setGraph(live.current.model.graph);
197
213
  instance.setHighlight(live.current.model.highlight, live.current.selectedId);
214
+ instance.setTrail(trailRef.current);
198
215
  props.fit.current = () => instance.fit();
199
216
  return () => {
200
217
  instance.destroy();
@@ -242,6 +259,10 @@ export function Graph(props: GraphProps) {
242
259
  renderer.current?.setHighlight(model.highlight, props.selectedId);
243
260
  }, [model.highlight, props.selectedId]);
244
261
 
262
+ useEffect(() => {
263
+ renderer.current?.setTrail(trailRef.current);
264
+ }, [props.trail]);
265
+
245
266
  return (
246
267
  <div class="weave-graph">
247
268
  {/* Same shape as `ContextRail`'s: the *decision* is `graphEmptyMessage`,
@@ -235,7 +235,7 @@ export function graphCountLabel(visible: number, total: number): string {
235
235
  /** What the column says instead of a canvas. `null` means "there is a graph". */
236
236
  export function graphEmptyMessage(payload: GraphPayload | null, visible: number): string | null {
237
237
  if (payload === null) return "Loading…";
238
- if (payload.model.nodes.length === 0) return "Nothing indexed yet — add a note or run /weave-scan.";
238
+ if (payload.model.nodes.length === 0) return "Nothing here yet — add a note to get started.";
239
239
  // A payload with nodes but nothing visible cannot happen through the UI
240
240
  // (`clusterAggregate` always keeps the roots), but a truncated response
241
241
  // could produce it, and a blank canvas with no explanation reads as a crash.
@@ -83,11 +83,8 @@ export function graphPalette(theme: GraphTheme): Readonly<Record<ColorSlot, stri
83
83
  * - `note` keeps `text` — notes are the product (§1.1), and their identity
84
84
  * is carried by the provenance badge, not by a hue.
85
85
  * - Generated code — modules, packages, entry points, git, externals, files
86
- * — takes `dim`, the same grey a `generated` row takes in the tree. The
87
- * TUI's `kindStyle` (src/pi/viewer/tui/theme.ts) still maps these kinds to
88
- * success/warning; the two media disagree on purpose, because a pi theme
89
- * has no dark/light stylesheet to violate and a terminal can restyle
90
- * everything. Here the sheet decides, and the sheet says calm.
86
+ * — takes `dim`, the same grey a `generated` row takes in the tree. Here
87
+ * the sheet decides, and the sheet says calm.
91
88
  */
92
89
  export const KIND_SLOT: Readonly<Record<WireNodeKind, ColorSlot>> = {
93
90
  vault: "accent",
@@ -513,7 +510,8 @@ export interface ViewBox {
513
510
  * session: the node stays under the cursor, the canvas bounds the drag, and
514
511
  * `[fit]` re-frames onto whatever the current positions are.
515
512
  *
516
- * Use the position extent, with a minimum span of four collision radii.
513
+ * Use the position extent padded by the largest node size, so no circle
514
+ * at the edge is cut off, with a minimum span of four collision radii.
517
515
  * A single node (or coincident nodes) has no extent; Sigma scales layout-unit
518
516
  * node sizes by that span, so a near-zero box magnifies one node across the
519
517
  * canvas. Pixel-based `stagePadding` cannot prevent that.
@@ -536,7 +534,9 @@ export function frameBox(points: readonly Point[]): ViewBox | null {
536
534
  if (p.y > maxY) maxY = p.y;
537
535
  }
538
536
  if (seen === 0) return null;
539
- const padding = Math.max(0, (COLLIDE_RADIUS * 4 - Math.max(maxX - minX, maxY - minY)) / 2);
537
+ // The extent is of node *centres*; the circles reach past it by up to a
538
+ // full node size, which clipped the outermost nodes on a small graph.
539
+ const padding = Math.max(MAX_NODE_SIZE, (COLLIDE_RADIUS * 4 - Math.max(maxX - minX, maxY - minY)) / 2);
540
540
  return { x: [minX - padding, maxX + padding], y: [minY - padding, maxY + padding] };
541
541
  }
542
542
 
@@ -882,6 +882,54 @@ export function edgeReducer(
882
882
  };
883
883
  }
884
884
 
885
+ // --- the recall trail (issue #78) -----------------------------------------------------
886
+
887
+ /** The slice of `trail.model.ts`'s `TrailMark` this layer reads, restated to keep this file import-free of it. */
888
+ export interface TrailGlow {
889
+ readonly kind: "searched" | "read" | "wrote";
890
+ readonly strength: number;
891
+ }
892
+
893
+ /**
894
+ * How each kind of touch is painted: which slot it blends toward, how far, and
895
+ * how much the node grows. A search hit is a soft amber hint, a read is a
896
+ * filled glow, and a write is the accent at full presence — the one place
897
+ * this canvas spends the accent on a note, because a write is the event.
898
+ */
899
+ export const TRAIL_STYLE: Readonly<Record<TrailGlow["kind"], { slot: ColorSlot; blend: number; growth: number }>> = {
900
+ searched: { slot: "warning", blend: 0.35, growth: 1.08 },
901
+ read: { slot: "success", blend: 0.85, growth: 1.3 },
902
+ wrote: { slot: "accent", blend: 1, growth: 1.5 },
903
+ };
904
+
905
+ /**
906
+ * The recall-trail layer, applied **over** the selection reducer's answer.
907
+ *
908
+ * Over, because the trail is the live event: a note the agent just wrote must
909
+ * read as such even when it sits in the receded cloud of an unrelated
910
+ * selection. It is handed the *reduced* node, and colour and size scale with
911
+ * the glow's strength, so the fade is a smooth return to whatever the
912
+ * selection layer said; the label is kept so a glowing node always names itself.
913
+ */
914
+ export function trailOverride(
915
+ glow: TrailGlow | undefined,
916
+ /** The node as the selection layer left it, so the glow composes over recession and growth. */
917
+ data: Pick<RenderNode, "color" | "size" | "zIndex">,
918
+ scheme: GraphTheme,
919
+ /** The node's own label — the selection layer may have dropped it, and a glowing node always names itself. */
920
+ label: string,
921
+ ): NodeDisplayOverride {
922
+ if (glow === undefined || !(glow.strength > 0)) return {};
923
+ const style = TRAIL_STYLE[glow.kind];
924
+ const strength = Math.min(1, glow.strength);
925
+ return {
926
+ color: blendHex(data.color, graphPalette(scheme)[style.slot], style.blend * strength),
927
+ size: data.size * growth(style.growth, strength),
928
+ zIndex: data.zIndex + HIGHLIGHT_Z_LIFT * 3,
929
+ label,
930
+ };
931
+ }
932
+
885
933
  // --- sigma settings (§7.4) -------------------------------------------------------------
886
934
 
887
935
  /**
@@ -929,6 +977,20 @@ export interface GraphSettings {
929
977
  */
930
978
  export const LABEL_SIZE_THRESHOLD = (MIN_NODE_SIZE + MAX_NODE_SIZE) / 2;
931
979
 
980
+ /**
981
+ * Below this many nodes every node carries its name.
982
+ *
983
+ * The size threshold exists to keep a dense graph legible; on a vault of
984
+ * two or three notes it left all but one circle unnamed, and a small graph
985
+ * has no density to protect.
986
+ */
987
+ export const SMALL_GRAPH_NODES = 30;
988
+
989
+ /** `labelRenderedSizeThreshold` for a graph of `nodeCount` nodes. */
990
+ export function labelThreshold(nodeCount: number): number {
991
+ return nodeCount < SMALL_GRAPH_NODES ? 0 : LABEL_SIZE_THRESHOLD;
992
+ }
993
+
932
994
  /**
933
995
  * Label collision grid, in screen pixels.
934
996
  *
@@ -1,7 +1,7 @@
1
1
  import type { GraphTheme } from "../../shared/themes";
2
2
  import type { Point } from "../../shared/layout";
3
- import type { EdgeDisplayOverride, GraphSettings, NodeDisplayOverride, RenderEdge, RenderGraph, RenderNode, ViewBox } from "./graph.model";
4
- import { edgeReducer, fadeStep, frameBox, graphSettings, nodeReducer } from "./graph.model";
3
+ import type { EdgeDisplayOverride, GraphSettings, NodeDisplayOverride, RenderEdge, RenderGraph, RenderNode, TrailGlow, ViewBox } from "./graph.model";
4
+ import { edgeReducer, fadeStep, frameBox, graphSettings, labelThreshold, nodeReducer, trailOverride } from "./graph.model";
5
5
  import type { ProjectedGraph } from "./project";
6
6
  import { positionsOf, project, syncPositions } from "./project";
7
7
 
@@ -56,6 +56,7 @@ export interface SigmaLike {
56
56
  setSetting(key: "nodeReducer", value: (id: string, data: RenderNode) => NodeDisplayOverride): unknown;
57
57
  setSetting(key: "edgeReducer", value: (key: string, data: RenderEdge, scheme: GraphTheme) => EdgeDisplayOverride): unknown;
58
58
  setSetting(key: "enableCameraPanning", value: boolean): unknown;
59
+ setSetting(key: "labelRenderedSizeThreshold", value: number): unknown;
59
60
  setGraph(graph: ProjectedGraph): unknown;
60
61
  /**
61
62
  * Freeze the graph→viewport normalization onto a fixed box.
@@ -129,6 +130,8 @@ export function sigmaRenderer(
129
130
  let highlight: ReadonlySet<string> | null = null;
130
131
  /** The selection inside that neighbourhood — see `nodeReducer`. */
131
132
  let selected: string | null = null;
133
+ /** Recall-trail glows, layered over the selection — see `trailOverride`. */
134
+ let trail: ReadonlyMap<string, TrailGlow> = new Map();
132
135
  /**
133
136
  * How present the highlight currently *looks*, and where it is heading.
134
137
  *
@@ -175,7 +178,11 @@ export function sigmaRenderer(
175
178
  const applyReducers = (instance: SigmaLike): void => {
176
179
  const nodes = nodeReducer(highlight, selected, fade);
177
180
  const edges = edgeReducer(highlight, selected, fade);
178
- instance.setSetting("nodeReducer", (id, data) => ({ ...data, ...nodes(id, data, scheme) }));
181
+ const glows = trail;
182
+ instance.setSetting("nodeReducer", (id, data) => {
183
+ const reduced = { ...data, ...nodes(id, data, scheme) };
184
+ return { ...reduced, ...trailOverride(glows.get(id), reduced, scheme, data.label) };
185
+ });
179
186
  instance.setSetting("edgeReducer", (key, data) => ({ ...data, ...edges(key, data, scheme) }));
180
187
  };
181
188
 
@@ -246,6 +253,7 @@ export function sigmaRenderer(
246
253
  instance.on("upNode", () => endDrag(dragging));
247
254
  instance.on("upStage", () => endDrag(null));
248
255
  applyReducers(instance);
256
+ instance.setSetting("labelRenderedSizeThreshold", labelThreshold(graph.order));
249
257
  // Freeze the view box for whatever graph arrived before mount, so the
250
258
  // first frame is already framed and stable — see `SigmaLike.setCustomBBox`.
251
259
  instance.setCustomBBox(boxOf());
@@ -255,6 +263,8 @@ export function sigmaRenderer(
255
263
  setGraph(next: RenderGraph) {
256
264
  graph = project(next);
257
265
  sigma?.setGraph(graph);
266
+ // A graph that crossed the small-graph line gains or loses its labels.
267
+ sigma?.setSetting("labelRenderedSizeThreshold", labelThreshold(graph.order));
258
268
  // Re-frame on a shape change: new nodes legitimately change the extent,
259
269
  // and the box is what keeps the *drag* frames from also changing it.
260
270
  // Deliberately not touched by `setPositions` — a settling simulation
@@ -296,6 +306,12 @@ export function sigmaRenderer(
296
306
  if (fade !== fadeTo) armFade();
297
307
  },
298
308
 
309
+ /** Replace the recall-trail glows. The caller re-sends as the fade ticks. */
310
+ setTrail(next: ReadonlyMap<string, TrailGlow>) {
311
+ trail = next;
312
+ if (sigma !== null) applyReducers(sigma);
313
+ },
314
+
299
315
  onSelect(handler: (id: string | null) => void) {
300
316
  select = handler;
301
317
  },
@@ -40,7 +40,7 @@
40
40
 
41
41
  import DOMPurify from "dompurify";
42
42
  import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks";
43
- import type { GraphPayload, NotePayload } from "../../shared/wire";
43
+ import type { GraphPayload, HistoryPayload, NotePayload } from "../../shared/wire";
44
44
  import {
45
45
  CREATED_WORD,
46
46
  artifactKeyOfNode,
@@ -49,6 +49,7 @@ import {
49
49
  EDIT_LABEL,
50
50
  EDITED_WORD,
51
51
  EDITOR_ARIA_LABEL,
52
+ EDITOR_PLACEHOLDER,
52
53
  EMPTY_PREVIEW,
53
54
  SAVE_LABEL,
54
55
  SAVING_LABEL,
@@ -60,6 +61,10 @@ import {
60
61
  previewAnchorOf,
61
62
  previewCard,
62
63
  previewPlacement,
64
+ RAW_EDITOR_ARIA_LABEL,
65
+ RAW_TAIL_ARIA_LABEL,
66
+ RAW_TITLE,
67
+ rawSummary,
63
68
  reducePreview,
64
69
  renderNote,
65
70
  selectedArtifactPath,
@@ -72,7 +77,10 @@ import {
72
77
  } from "./note.model";
73
78
  import type { NoteHeaderView, PreviewElement, PreviewEvent } from "./note.model";
74
79
  import { createDraftStore } from "./drafts";
75
- import type { DraftStore } from "./drafts";
80
+ import type { DraftStore, SaveNote } from "./drafts";
81
+ import { ReplayBar, wrapMarks } from "./Replay";
82
+ import type { ReplayState } from "./Replay";
83
+ import { HISTORY_CLOSE_LABEL, HISTORY_LABEL, PLAY_INTERVAL_MS, nextStep, replayStep } from "./replay.model";
76
84
 
77
85
  /**
78
86
  * The custom properties the preview card is placed with, written by the
@@ -97,7 +105,12 @@ export interface NoteProps {
97
105
  * has already been reported to the user by the shell, which owns the one
98
106
  * error-reporting gesture the client has.
99
107
  */
100
- onSave: (slug: string, body: string) => Promise<boolean>;
108
+ onSave: SaveNote;
109
+ /**
110
+ * Load the note's saved revisions for the history slider. The History
111
+ * control is shown only when this is provided.
112
+ */
113
+ onHistory?: (slug: string) => Promise<{ ok: true; data: HistoryPayload } | { ok: false; message: string }>;
101
114
  /** Shared across Note instances so duplicate tabs edit one in-memory body. */
102
115
  drafts?: DraftStore;
103
116
  /** Stable per view; distinct when the same note is mounted more than once. */
@@ -124,6 +137,8 @@ function Header({
124
137
  taskSaving,
125
138
  onToggle,
126
139
  onSave,
140
+ replaying,
141
+ onHistory,
127
142
  }: {
128
143
  view: NoteHeaderView;
129
144
  open: () => void;
@@ -132,6 +147,8 @@ function Header({
132
147
  taskSaving: boolean;
133
148
  onToggle: () => void;
134
149
  onSave: () => void;
150
+ replaying: boolean;
151
+ onHistory: (() => void) | null;
135
152
  }) {
136
153
  return (
137
154
  <header class="weave-note-head">
@@ -144,7 +161,12 @@ function Header({
144
161
  </button>
145
162
  <button type="button" class="weave-note-edit" disabled={taskSaving} onClick={onToggle}>{DONE_LABEL}</button>
146
163
  </>
147
- ) : <button type="button" class="weave-note-edit" disabled={taskSaving} onClick={onToggle}>{EDIT_LABEL}</button>}
164
+ ) : replaying ? null : <button type="button" class="weave-note-edit" disabled={taskSaving} onClick={onToggle}>{EDIT_LABEL}</button>}
165
+ {editing || onHistory === null ? null : (
166
+ <button type="button" class="weave-note-edit weave-note-history" aria-pressed={replaying} onClick={onHistory}>
167
+ {replaying ? HISTORY_CLOSE_LABEL : HISTORY_LABEL}
168
+ </button>
169
+ )}
148
170
  <button type="button" class="weave-note-open" title="Open in $EDITOR" aria-label="Open in $EDITOR" onClick={open}>
149
171
  <span class="weave-note-open-mark" aria-hidden="true">↗</span>
150
172
  </button>
@@ -201,7 +223,16 @@ export function Note(props: NoteProps) {
201
223
  const dispatch = (event: PreviewEvent): void => void sendPreview((state) => reducePreview(state, event));
202
224
  const bodyRef = useRef<HTMLDivElement | null>(null);
203
225
  const cardRef = useRef<HTMLDivElement | null>(null);
226
+ const editorRef = useRef<HTMLTextAreaElement | null>(null);
227
+ // Set by the gesture that opened the editor *in this column*, consumed by
228
+ // the layout effect below once the textarea exists. A flag rather than
229
+ // "focus whenever a draft appears": the same note open in a second pane
230
+ // shares the draft, and must not take the cursor away from this one.
231
+ const focusEditor = useRef(false);
204
232
  const draft = note === null ? null : drafts.get(note.slug);
233
+ // The `## Raw` box under the editor starts folded: a long dictation would
234
+ // otherwise outgrow the note it belongs to.
235
+ const [rawOpen, setRawOpen] = useState(false);
205
236
  const taskSaving = note !== null && drafts.isTaskSaving(note.slug);
206
237
  const save = async (): Promise<void> => {
207
238
  if (note !== null && !drafts.isTaskSaving(note.slug)) await drafts.save(note.slug, props.onSave);
@@ -239,17 +270,92 @@ export function Note(props: NoteProps) {
239
270
  useEffect(() => {
240
271
  if (note !== null && defaultOpened.current !== note.slug) {
241
272
  defaultOpened.current = note.slug;
242
- if (props.defaultEdit && drafts.get(note.slug) === null) drafts.open(note.slug, body);
273
+ if (props.defaultEdit && drafts.get(note.slug) === null) {
274
+ focusEditor.current = true;
275
+ drafts.open(note.slug, body);
276
+ }
243
277
  }
244
278
  }, [note?.slug, props.defaultEdit]);
245
279
 
246
280
  const toggleEdit = (): void => {
247
281
  if (note === null || drafts.isTaskSaving(note.slug)) return;
248
- if (drafts.get(note.slug) === null) drafts.open(note.slug, body);
249
- else if (!drafts.isDirty(note.slug) || window.confirm(DISCARD_PROMPT)) drafts.discard(note.slug);
282
+ if (drafts.get(note.slug) === null) {
283
+ focusEditor.current = true;
284
+ drafts.open(note.slug, body);
285
+ } else if (!drafts.isDirty(note.slug) || window.confirm(DISCARD_PROMPT)) drafts.discard(note.slug);
286
+ };
287
+ // Both editor boxes: bound here, not in `keys.model.ts`, because a global
288
+ // binding is a workspace-wide claim and Escape must not reach the shell.
289
+ const editorKeys = (event: KeyboardEvent): void => {
290
+ if (event.key === "Escape") {
291
+ event.stopPropagation();
292
+ toggleEdit();
293
+ return;
294
+ }
295
+ if (event.key.toLowerCase() !== "s" || !(event.metaKey || event.ctrlKey)) return;
296
+ event.preventDefault();
297
+ void save();
250
298
  };
251
299
  const card = preview.anchor === null ? null : previewCard(props.graph, preview.anchor);
252
300
 
301
+ // Cursor in the textarea, after the last word, the moment it appears.
302
+ useLayoutEffect(() => {
303
+ const editor = editorRef.current;
304
+ if (editor === null || !focusEditor.current) return;
305
+ focusEditor.current = false;
306
+ editor.focus();
307
+ editor.setSelectionRange(editor.value.length, editor.value.length);
308
+ }, [draft !== null]);
309
+
310
+ // The history slider. Held per column, like the preview card: it is a view
311
+ // of the note on screen, and closes when the note changes.
312
+ const [replay, setReplay] = useState<ReplayState | null>(null);
313
+ const replaySlug = useRef<string | null>(null);
314
+ const loadHistory = props.onHistory;
315
+ const toggleHistory = (): void => {
316
+ if (note === null || loadHistory === undefined) return;
317
+ if (replay !== null) {
318
+ replaySlug.current = null;
319
+ setReplay(null);
320
+ return;
321
+ }
322
+ const slug = note.slug;
323
+ replaySlug.current = slug;
324
+ setReplay({ revisions: null, enabled: true, trimmed: false, index: 0, playing: false, error: null });
325
+ void loadHistory(slug).then((result) => {
326
+ if (replaySlug.current !== slug) return;
327
+ setReplay(result.ok
328
+ ? { revisions: result.data.revisions, enabled: result.data.enabled, trimmed: result.data.trimmed, index: Math.max(0, result.data.revisions.length - 1), playing: false, error: null }
329
+ : { revisions: null, enabled: true, trimmed: false, index: 0, playing: false, error: `Could not load history: ${result.message}` });
330
+ });
331
+ };
332
+ const step = replay?.revisions == null ? null : replayStep(replay.revisions, replay.index);
333
+ const replayHtml = useMemo(
334
+ () => (step === null || index === null ? null : renderNote(DOMPurify, step.marked, index)),
335
+ [step?.marked, index],
336
+ );
337
+ useLayoutEffect(() => {
338
+ replaySlug.current = null;
339
+ setReplay(null);
340
+ setRawOpen(false);
341
+ }, [note === null ? null : note.slug]);
342
+ useLayoutEffect(() => {
343
+ if (replayHtml === null || bodyRef.current === null) return;
344
+ wrapMarks(bodyRef.current)?.scrollIntoView?.({ block: "nearest" });
345
+ }, [replayHtml]);
346
+ useEffect(() => {
347
+ if (replay === null || !replay.playing || replay.revisions === null) return;
348
+ const count = replay.revisions.length;
349
+ const timer = window.setTimeout(() => {
350
+ setReplay((state) => {
351
+ if (state === null) return state;
352
+ const next = nextStep(state.index, count);
353
+ return next === null ? { ...state, playing: false } : { ...state, index: next };
354
+ });
355
+ }, PLAY_INTERVAL_MS);
356
+ return () => window.clearTimeout(timer);
357
+ }, [replay]);
358
+
253
359
  useLayoutEffect(() => {
254
360
  if (note !== null) drafts.taskBody(note.slug, note.body, props.loadVersion);
255
361
  }, [drafts, note === null ? null : note.slug, note?.body, props.loadVersion]);
@@ -348,27 +454,67 @@ export function Note(props: NoteProps) {
348
454
  taskSaving={taskSaving}
349
455
  onToggle={toggleEdit}
350
456
  onSave={() => void save()}
457
+ replaying={replay !== null}
458
+ onHistory={loadHistory === undefined ? null : toggleHistory}
351
459
  />
460
+ {replay === null ? null : (
461
+ <ReplayBar
462
+ state={replay}
463
+ onIndex={(value) => setReplay((state) => state === null ? state : { ...state, index: value, playing: false })}
464
+ onPlay={() => setReplay((state) => {
465
+ if (state === null || state.revisions === null) return state;
466
+ if (state.playing) return { ...state, playing: false };
467
+ // Playing from the end starts over: that is what "watch it being written" means.
468
+ const restart = state.index >= state.revisions.length - 1;
469
+ return { ...state, index: restart ? 0 : state.index, playing: true };
470
+ })}
471
+ />
472
+ )}
352
473
  {draft !== null ? (
474
+ <>
353
475
  <textarea
476
+ ref={editorRef}
354
477
  class="weave-note-editor"
355
478
  aria-label={EDITOR_ARIA_LABEL}
479
+ placeholder={EDITOR_PLACEHOLDER}
356
480
  spellcheck={props.spellcheck ?? true}
357
481
  value={draft.body}
358
482
  onInput={(event) => drafts.edit(note.slug, (event.target as HTMLTextAreaElement).value)}
359
- onKeyDown={(event) => {
360
- // Bound here, not in `keys.model.ts`: a global binding is a
361
- // workspace-wide claim, and Escape must not reach the shell.
362
- if (event.key === "Escape") {
363
- event.stopPropagation();
364
- toggleEdit();
365
- return;
366
- }
367
- if (event.key.toLowerCase() !== "s" || !(event.metaKey || event.ctrlKey)) return;
368
- event.preventDefault();
369
- void save();
370
- }}
483
+ onKeyDown={editorKeys}
371
484
  />
485
+ {/* A box of its own, never the body's textarea: text typed below
486
+ the heading there used to be dropped on save without a word. */}
487
+ {draft.tail === "" ? null : (
488
+ <section class="weave-raw" aria-label={RAW_TAIL_ARIA_LABEL} data-open={rawOpen ? "" : undefined}>
489
+ <div class="weave-raw-head">
490
+ <button
491
+ type="button"
492
+ class="weave-raw-toggle"
493
+ aria-expanded={rawOpen}
494
+ aria-controls={`${props.idPrefix ?? generatedId}-weave-raw`}
495
+ onClick={() => setRawOpen((open) => !open)}
496
+ >
497
+ <svg class="weave-raw-chevron" width="12" height="12" viewBox="0 0 12 12" aria-hidden="true">
498
+ <path d="M4.5 2.5 8 6l-3.5 3.5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
499
+ </svg>
500
+ {RAW_TITLE}
501
+ </button>
502
+ <span class="weave-raw-hint">{rawSummary(draft.raw, rawOpen)}</span>
503
+ </div>
504
+ {rawOpen ? (
505
+ <textarea
506
+ id={`${props.idPrefix ?? generatedId}-weave-raw`}
507
+ class="weave-raw-editor"
508
+ aria-label={RAW_EDITOR_ARIA_LABEL}
509
+ spellcheck={props.spellcheck ?? true}
510
+ value={draft.raw}
511
+ onInput={(event) => drafts.editRaw(note.slug, (event.target as HTMLTextAreaElement).value)}
512
+ onKeyDown={editorKeys}
513
+ />
514
+ ) : null}
515
+ </section>
516
+ )}
517
+ </>
372
518
  ) : (
373
519
  <div
374
520
  ref={bodyRef}
@@ -403,6 +549,11 @@ export function Note(props: NoteProps) {
403
549
  onClick={(event) => {
404
550
  const checkbox = event.target as HTMLInputElement;
405
551
  const taskIndex = taskCheckboxIndexOf(checkbox);
552
+ // A past version is read-only: its checkboxes must not write to the note.
553
+ if (taskIndex !== null && replayHtml !== null) {
554
+ event.preventDefault();
555
+ return;
556
+ }
406
557
  if (taskIndex !== null) {
407
558
  void saveTask(checkbox, taskIndex);
408
559
  return;
@@ -434,7 +585,7 @@ export function Note(props: NoteProps) {
434
585
  props.onSelect(target, event.metaKey || event.ctrlKey || event.shiftKey);
435
586
  }}
436
587
  // Sanitised by `renderNote`'s three layers — see note.model.ts.
437
- dangerouslySetInnerHTML={{ __html: html }}
588
+ dangerouslySetInnerHTML={{ __html: replayHtml ?? html }}
438
589
  />
439
590
  )}
440
591
  {card === null ? null : (