pi-weave 0.3.8 → 0.3.10

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 (80) hide show
  1. package/README.md +21 -17
  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/context/context.model.ts +5 -0
  31. package/src/web/client/dist/app.js +499 -129
  32. package/src/web/client/graph/Graph.tsx +166 -14
  33. package/src/web/client/graph/column.model.ts +59 -2
  34. package/src/web/client/graph/graph.model.ts +183 -7
  35. package/src/web/client/graph/renderer.ts +39 -9
  36. package/src/web/client/graph/spotlight.model.ts +472 -0
  37. package/src/web/client/note/Note.tsx +370 -66
  38. package/src/web/client/note/Replay.tsx +139 -0
  39. package/src/web/client/note/drafts.ts +115 -11
  40. package/src/web/client/note/editbar.model.ts +153 -0
  41. package/src/web/client/note/note.model.ts +82 -1
  42. package/src/web/client/note/replay.model.ts +248 -0
  43. package/src/web/client/search/SearchPalette.tsx +103 -34
  44. package/src/web/client/search/search.model.ts +533 -26
  45. package/src/web/client/shell/ContextRail.tsx +7 -0
  46. package/src/web/client/shell/Settings.tsx +21 -1
  47. package/src/web/client/shell/Shell.tsx +93 -27
  48. package/src/web/client/shell/StatusBar.tsx +1 -0
  49. package/src/web/client/shell/icons.model.ts +68 -1
  50. package/src/web/client/shell/keys.model.ts +31 -8
  51. package/src/web/client/shell/shell.model.ts +48 -1
  52. package/src/web/client/shell/theme.model.ts +3 -0
  53. package/src/web/client/shell/theme.ts +354 -32
  54. package/src/web/client/shell/workspace.css.ts +40 -9
  55. package/src/web/client/trail/TrailStrip.tsx +55 -0
  56. package/src/web/client/trail/trail.model.ts +195 -0
  57. package/src/web/client/trail/trail.ts +45 -0
  58. package/src/web/client/tree/Tree.tsx +7 -3
  59. package/src/web/client/tree/tree.model.ts +2 -5
  60. package/src/web/server/page.ts +1 -1
  61. package/src/web/server/routes.ts +83 -3
  62. package/src/web/shared/logo.ts +1 -1
  63. package/src/web/shared/themes.ts +1 -1
  64. package/src/web/shared/view.ts +10 -4
  65. package/src/web/shared/wire.ts +74 -1
  66. package/src/web/shared/workspace.ts +10 -2
  67. package/src/core/view/health.ts +0 -160
  68. package/src/pi/viewer/tui/bodyStore.ts +0 -95
  69. package/src/pi/viewer/tui/branding.ts +0 -14
  70. package/src/pi/viewer/tui/model.ts +0 -390
  71. package/src/pi/viewer/tui/openNote.ts +0 -11
  72. package/src/pi/viewer/tui/run.ts +0 -71
  73. package/src/pi/viewer/tui/surface/base.ts +0 -203
  74. package/src/pi/viewer/tui/surface/detail.ts +0 -315
  75. package/src/pi/viewer/tui/surface/explore.ts +0 -241
  76. package/src/pi/viewer/tui/surface/focus.ts +0 -139
  77. package/src/pi/viewer/tui/surface/health.ts +0 -102
  78. package/src/pi/viewer/tui/theme.ts +0 -90
  79. package/src/pi/viewer/tui/workspace.ts +0 -53
  80. package/src/pi/viewer/tui/workspaceRoot.ts +0 -389
@@ -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,9 +49,8 @@ import {
49
49
  EDIT_LABEL,
50
50
  EDITED_WORD,
51
51
  EDITOR_ARIA_LABEL,
52
+ EDITOR_PLACEHOLDER,
52
53
  EMPTY_PREVIEW,
53
- SAVE_LABEL,
54
- SAVING_LABEL,
55
54
  TASK_CHECKBOX_ATTR,
56
55
  WIKILINK_ATTR,
57
56
  hasTextSelection,
@@ -60,6 +59,10 @@ import {
60
59
  previewAnchorOf,
61
60
  previewCard,
62
61
  previewPlacement,
62
+ RAW_EDITOR_ARIA_LABEL,
63
+ RAW_TAIL_ARIA_LABEL,
64
+ RAW_TITLE,
65
+ rawSummary,
63
66
  reducePreview,
64
67
  renderNote,
65
68
  selectedArtifactPath,
@@ -72,7 +75,24 @@ import {
72
75
  } from "./note.model";
73
76
  import type { NoteHeaderView, PreviewElement, PreviewEvent } from "./note.model";
74
77
  import { createDraftStore } from "./drafts";
75
- import type { DraftStore } from "./drafts";
78
+ import type { DraftStore, SaveNote } from "./drafts";
79
+ import { ReplayBar, wrapMarks } from "./Replay";
80
+ import type { ReplayState } from "./Replay";
81
+ import { HISTORY_CLOSE_LABEL, HISTORY_LABEL, PLAY_INTERVAL_MS, nextStep, replayStep } from "./replay.model";
82
+ import {
83
+ AUTOSAVE_MS,
84
+ DISCARD_EDITS_LABEL,
85
+ EDITING_LABEL,
86
+ FORMAT_TOOLS,
87
+ REVERT_PROMPT,
88
+ RETRY_LABEL,
89
+ applyFormat,
90
+ editStatus,
91
+ formatForKey,
92
+ replacedSpan,
93
+ } from "./editbar.model";
94
+ import type { EditStatus, FormatKind } from "./editbar.model";
95
+ import { Icon } from "../tree/Tree";
76
96
 
77
97
  /**
78
98
  * The custom properties the preview card is placed with, written by the
@@ -97,7 +117,12 @@ export interface NoteProps {
97
117
  * has already been reported to the user by the shell, which owns the one
98
118
  * error-reporting gesture the client has.
99
119
  */
100
- onSave: (slug: string, body: string) => Promise<boolean>;
120
+ onSave: SaveNote;
121
+ /**
122
+ * Load the note's saved revisions for the history slider. The History
123
+ * control is shown only when this is provided.
124
+ */
125
+ onHistory?: (slug: string) => Promise<{ ok: true; data: HistoryPayload } | { ok: false; message: string }>;
101
126
  /** Shared across Note instances so duplicate tabs edit one in-memory body. */
102
127
  drafts?: DraftStore;
103
128
  /** Stable per view; distinct when the same note is mounted more than once. */
@@ -109,66 +134,134 @@ export interface NoteProps {
109
134
  }
110
135
 
111
136
  /**
112
- * Title, one quiet meta line, tags.
137
+ * Folder crumb, title, one quiet meta line, tags.
113
138
  *
114
139
  * The hierarchy is the point: the title is the page's largest voice and the
115
- * meta line is its footnote — provenance glyph and word, edited, created.
116
- * The Open in `$EDITOR` action sits at the end of that line as an icon, where
117
- * a workflow control belongs in a column whose job is reading.
140
+ * meta line is its footnote, in plain words: who wrote it and when it last
141
+ * changed. The note's own actions (History, Edit, Open in `$EDITOR`) sit at
142
+ * the end of that line, where a workflow control belongs in a column whose job
143
+ * is reading. While editing, the line gives way to the edit bar above.
118
144
  */
119
145
  function Header({
120
146
  view,
121
147
  open,
122
148
  editing,
123
- saving,
124
149
  taskSaving,
125
- onToggle,
126
- onSave,
150
+ onEdit,
151
+ replaying,
152
+ onHistory,
127
153
  }: {
128
154
  view: NoteHeaderView;
129
155
  open: () => void;
130
156
  editing: boolean;
131
- saving: boolean;
132
157
  taskSaving: boolean;
133
- onToggle: () => void;
134
- onSave: () => void;
158
+ onEdit: () => void;
159
+ replaying: boolean;
160
+ onHistory: (() => void) | null;
135
161
  }) {
136
162
  return (
137
163
  <header class="weave-note-head">
164
+ <p class="weave-note-crumb">
165
+ <Icon name="folder" class="weave-icon" />
166
+ {view.folder}
167
+ </p>
138
168
  <h3 class="weave-note-title">{view.title}</h3>
139
- <p class="weave-note-meta">
140
- {editing ? (
141
- <>
142
- <button type="button" class="weave-note-save" disabled={saving || taskSaving} onClick={onSave}>
143
- {saving ? SAVING_LABEL : SAVE_LABEL}
169
+ {editing ? null : (
170
+ <p class="weave-note-meta">
171
+ <span class={`weave-prov weave-prov-${view.provenance}`} title={view.provenanceTitle}>
172
+ <span class="weave-prov-dot" aria-hidden="true" />
173
+ {view.byline}
174
+ </span>
175
+ <span class="weave-note-time" title={`${CREATED_WORD} ${view.created} · ${view.createdIso}`}>
176
+ {EDITED_WORD} {view.updated}
177
+ </span>
178
+ <span class="weave-note-actions">
179
+ {onHistory === null ? null : (
180
+ <button type="button" class="weave-note-action weave-note-history" aria-pressed={replaying} onClick={onHistory}>
181
+ <Icon name="history" class="weave-icon" />
182
+ {replaying ? HISTORY_CLOSE_LABEL : HISTORY_LABEL}
183
+ </button>
184
+ )}
185
+ {replaying ? null : (
186
+ <button type="button" class="weave-note-action weave-note-edit" disabled={taskSaving} onClick={onEdit}>
187
+ <Icon name="edit" class="weave-icon" />
188
+ {EDIT_LABEL}
189
+ </button>
190
+ )}
191
+ <button type="button" class="weave-note-open" title="Open in $EDITOR" aria-label="Open in $EDITOR" onClick={open}>
192
+ <span class="weave-note-open-mark" aria-hidden="true">↗</span>
144
193
  </button>
145
- <button type="button" class="weave-note-edit" disabled={taskSaving} onClick={onToggle}>{DONE_LABEL}</button>
146
- </>
147
- ) : <button type="button" class="weave-note-edit" disabled={taskSaving} onClick={onToggle}>{EDIT_LABEL}</button>}
148
- <button type="button" class="weave-note-open" title="Open in $EDITOR" aria-label="Open in $EDITOR" onClick={open}>
149
- <span class="weave-note-open-mark" aria-hidden="true">↗</span>
150
- </button>
151
- <span class={`weave-prov weave-prov-${view.provenance}`} title={view.provenanceTitle}>
152
- {view.provenanceGlyph} {view.provenance}
153
- </span>
154
- <span class="weave-note-time" title={view.updatedIso}>
155
- {EDITED_WORD} {view.updated}
156
- </span>
157
- <span class="weave-note-time" title={view.createdIso}>
158
- {CREATED_WORD} {view.created}
159
- </span>
160
- </p>
161
- <p class="weave-note-tags">
162
- {view.tags.map((tag) => (
163
- <span key={tag} class="weave-tag">
164
- {tagLabel(tag)}
165
194
  </span>
166
- ))}
167
- </p>
195
+ </p>
196
+ )}
197
+ {view.tags.length === 0 ? null : (
198
+ <p class="weave-note-tags">
199
+ {view.tags.map((tag) => (
200
+ <span key={tag} class="weave-tag">
201
+ {tagLabel(tag)}
202
+ </span>
203
+ ))}
204
+ </p>
205
+ )}
168
206
  </header>
169
207
  );
170
208
  }
171
209
 
210
+ /**
211
+ * The edit bar (E1): says the note is being edited, carries the formatting
212
+ * buttons, shows whether the words are on disk yet, and ends the session.
213
+ * Saving is automatic, so there is no Save button: Done catches up and closes.
214
+ */
215
+ function EditBar({
216
+ status,
217
+ busy,
218
+ onFormat,
219
+ onRetry,
220
+ onDiscard,
221
+ onDone,
222
+ }: {
223
+ status: EditStatus;
224
+ busy: boolean;
225
+ onFormat: (kind: FormatKind) => void;
226
+ onRetry: () => void;
227
+ onDiscard: () => void;
228
+ onDone: () => void;
229
+ }) {
230
+ return (
231
+ <div class="weave-editbar" role="toolbar" aria-label={EDITING_LABEL}>
232
+ <span class="weave-editbar-badge">
233
+ <Icon name="edit" class="weave-icon" />
234
+ {EDITING_LABEL}
235
+ </span>
236
+ <span class="weave-editbar-tools">
237
+ {FORMAT_TOOLS.map((tool) => (
238
+ <button
239
+ key={tool.kind}
240
+ type="button"
241
+ class="weave-editbar-tool"
242
+ title={tool.key === null ? tool.label : `${tool.label} (⌘${tool.key.toUpperCase()})`}
243
+ aria-label={tool.label}
244
+ // Keep the cursor and selection in the text box the button acts on.
245
+ onMouseDown={(event) => event.preventDefault()}
246
+ onClick={() => onFormat(tool.kind)}
247
+ >
248
+ <Icon name={tool.icon} class="weave-icon" />
249
+ </button>
250
+ ))}
251
+ </span>
252
+ <span class={`weave-editbar-status weave-editbar-${status.kind}`} role="status">
253
+ {status.kind === "saved" ? <Icon name="check" class="weave-icon" /> : null}
254
+ {status.label}
255
+ {status.kind === "failed" ? (
256
+ <button type="button" class="weave-editbar-retry" onClick={onRetry}>{RETRY_LABEL}</button>
257
+ ) : null}
258
+ </span>
259
+ <button type="button" class="weave-editbar-discard" disabled={busy || status.kind === "saving"} onClick={onDiscard}>{DISCARD_EDITS_LABEL}</button>
260
+ <button type="button" class="weave-editbar-done" disabled={busy} onClick={onDone}>{DONE_LABEL}</button>
261
+ </div>
262
+ );
263
+ }
264
+
172
265
  export function Note(props: NoteProps) {
173
266
  const localDrafts = useRef(createDraftStore());
174
267
  const drafts = props.drafts ?? localDrafts.current;
@@ -201,10 +294,20 @@ export function Note(props: NoteProps) {
201
294
  const dispatch = (event: PreviewEvent): void => void sendPreview((state) => reducePreview(state, event));
202
295
  const bodyRef = useRef<HTMLDivElement | null>(null);
203
296
  const cardRef = useRef<HTMLDivElement | null>(null);
297
+ const editorRef = useRef<HTMLTextAreaElement | null>(null);
298
+ // Set by the gesture that opened the editor *in this column*, consumed by
299
+ // the layout effect below once the textarea exists. A flag rather than
300
+ // "focus whenever a draft appears": the same note open in a second pane
301
+ // shares the draft, and must not take the cursor away from this one.
302
+ const focusEditor = useRef(false);
204
303
  const draft = note === null ? null : drafts.get(note.slug);
304
+ // The `## Raw` box under the editor starts folded: a long dictation would
305
+ // otherwise outgrow the note it belongs to.
306
+ const [rawOpen, setRawOpen] = useState(false);
205
307
  const taskSaving = note !== null && drafts.isTaskSaving(note.slug);
206
- const save = async (): Promise<void> => {
207
- if (note !== null && !drafts.isTaskSaving(note.slug)) await drafts.save(note.slug, props.onSave);
308
+ /** Write the draft and keep editing: autosave, ⌘S and Retry. */
309
+ const saveNow = (): void => {
310
+ if (note !== null && !drafts.isTaskSaving(note.slug)) void drafts.save(note.slug, props.onSave, { keepOpen: true });
208
311
  };
209
312
 
210
313
  const saveTask = async (checkbox: HTMLInputElement, taskIndex: number): Promise<void> => {
@@ -239,19 +342,163 @@ export function Note(props: NoteProps) {
239
342
  useEffect(() => {
240
343
  if (note !== null && defaultOpened.current !== note.slug) {
241
344
  defaultOpened.current = note.slug;
242
- if (props.defaultEdit && drafts.get(note.slug) === null) drafts.open(note.slug, body);
345
+ if (props.defaultEdit && drafts.get(note.slug) === null) {
346
+ focusEditor.current = true;
347
+ drafts.open(note.slug, body);
348
+ }
243
349
  }
244
350
  }, [note?.slug, props.defaultEdit]);
245
351
 
246
- const toggleEdit = (): void => {
247
- 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);
352
+ const startEdit = (): void => {
353
+ if (note === null || drafts.isTaskSaving(note.slug) || drafts.get(note.slug) !== null) return;
354
+ focusEditor.current = true;
355
+ drafts.open(note.slug, body);
356
+ };
357
+ // Done while an autosave is in flight waits for it, then closes.
358
+ const closeWhenSaved = useRef(false);
359
+ /** Catch up and close. A failed write leaves the editor open, saying so. */
360
+ const done = (): void => {
361
+ if (note === null || draft === null || drafts.isTaskSaving(note.slug)) return;
362
+ if (draft.saving) {
363
+ closeWhenSaved.current = true;
364
+ return;
365
+ }
366
+ closeWhenSaved.current = false;
367
+ if (drafts.isDirty(note.slug)) void drafts.save(note.slug, props.onSave);
368
+ else drafts.discard(note.slug);
369
+ };
370
+ /**
371
+ * Back to the note as it was when editing began. Before any autosave that
372
+ * is dropping the draft; after one, it writes the opening version back.
373
+ */
374
+ const discardEdits = (): void => {
375
+ if (note === null || draft === null || draft.saving) return;
376
+ closeWhenSaved.current = false;
377
+ const autosaved = draft.baseline !== draft.original || draft.rawBaseline !== draft.originalRaw;
378
+ if (autosaved) {
379
+ if (!window.confirm(REVERT_PROMPT)) return;
380
+ if (drafts.revert(note.slug)) void drafts.save(note.slug, props.onSave);
381
+ else drafts.discard(note.slug);
382
+ } else if (!drafts.isDirty(note.slug) || window.confirm(DISCARD_PROMPT)) drafts.discard(note.slug);
383
+ };
384
+ // The box the last formatting button or key acted on: the body, or Raw.
385
+ const rawEditorRef = useRef<HTMLTextAreaElement | null>(null);
386
+ const lastBox = useRef<"body" | "raw">("body");
387
+ const format = (kind: FormatKind): void => {
388
+ if (note === null) return;
389
+ const raw = lastBox.current === "raw" && rawEditorRef.current !== null;
390
+ const box = raw ? rawEditorRef.current : editorRef.current;
391
+ if (box === null) return;
392
+ const next = applyFormat({ value: box.value, start: box.selectionStart, end: box.selectionEnd }, kind);
393
+ // Insert only the changed span, through the browser, so ⌘Z undoes it.
394
+ // Where that is not supported, set the value (and lose undo) instead.
395
+ const span = replacedSpan(box.value, next.value);
396
+ box.focus();
397
+ box.setSelectionRange(span.start, span.end);
398
+ const inserted = typeof document.execCommand === "function"
399
+ && document.execCommand(span.text === "" ? "delete" : "insertText", false, span.text);
400
+ if (!inserted || box.value !== next.value) box.value = next.value;
401
+ if (raw) drafts.editRaw(note.slug, next.value);
402
+ else drafts.edit(note.slug, next.value);
403
+ // The selection is set now so typing replaces the placeholder the button inserted.
404
+ box.setSelectionRange(next.start, next.end);
405
+ };
406
+ // Both editor boxes: bound here, not in `keys.model.ts`, because a global
407
+ // binding is a workspace-wide claim and Escape must not reach the shell.
408
+ const editorKeys = (event: KeyboardEvent): void => {
409
+ if (event.key === "Escape") {
410
+ event.stopPropagation();
411
+ done();
412
+ return;
413
+ }
414
+ if (!(event.metaKey || event.ctrlKey) || event.altKey) return;
415
+ if (event.key.toLowerCase() === "s") {
416
+ event.preventDefault();
417
+ if (note !== null && drafts.isDirty(note.slug)) saveNow();
418
+ return;
419
+ }
420
+ const kind = formatForKey(event.key);
421
+ if (kind === null || event.shiftKey) return;
422
+ event.preventDefault();
423
+ format(kind);
250
424
  };
425
+ // Autosave: a pause in typing writes the draft. A failed write waits for
426
+ // new words or Retry rather than hammering a server that just said no.
427
+ useEffect(() => {
428
+ if (note === null || draft === null || draft.saving || draft.failed || !drafts.isDirty(note.slug)) return;
429
+ const timer = window.setTimeout(saveNow, AUTOSAVE_MS);
430
+ return () => window.clearTimeout(timer);
431
+ }, [note?.slug, draft, draft?.body, draft?.raw, draft?.saving, draft?.failed]);
432
+ useEffect(() => {
433
+ if (closeWhenSaved.current && draft !== null && !draft.saving) done();
434
+ }, [draft?.saving]);
251
435
  const card = preview.anchor === null ? null : previewCard(props.graph, preview.anchor);
252
436
 
437
+ // Cursor in the textarea, after the last word, the moment it appears.
438
+ useLayoutEffect(() => {
439
+ const editor = editorRef.current;
440
+ if (editor === null || !focusEditor.current) return;
441
+ focusEditor.current = false;
442
+ editor.focus();
443
+ editor.setSelectionRange(editor.value.length, editor.value.length);
444
+ }, [draft !== null]);
445
+
446
+ // The history slider. Held per column, like the preview card: it is a view
447
+ // of the note on screen, and closes when the note changes.
448
+ const [replay, setReplay] = useState<ReplayState | null>(null);
449
+ const replaySlug = useRef<string | null>(null);
450
+ const loadHistory = props.onHistory;
451
+ const toggleHistory = (): void => {
452
+ if (note === null || loadHistory === undefined) return;
453
+ if (replay !== null) {
454
+ replaySlug.current = null;
455
+ setReplay(null);
456
+ return;
457
+ }
458
+ const slug = note.slug;
459
+ replaySlug.current = slug;
460
+ setReplay({ revisions: null, enabled: true, trimmed: false, index: 0, playing: false, error: null });
461
+ void loadHistory(slug).then((result) => {
462
+ if (replaySlug.current !== slug) return;
463
+ setReplay(result.ok
464
+ ? { 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 }
465
+ : { revisions: null, enabled: true, trimmed: false, index: 0, playing: false, error: `Could not load history: ${result.message}` });
466
+ });
467
+ };
468
+ const step = replay?.revisions == null ? null : replayStep(replay.revisions, replay.index);
469
+ const replayHtml = useMemo(
470
+ () => (step === null || index === null ? null : renderNote(DOMPurify, step.marked, index)),
471
+ [step?.marked, index],
472
+ );
473
+ useLayoutEffect(() => {
474
+ replaySlug.current = null;
475
+ setReplay(null);
476
+ setRawOpen(false);
477
+ }, [note === null ? null : note.slug]);
478
+ useLayoutEffect(() => {
479
+ if (replayHtml === null || bodyRef.current === null) return;
480
+ const first = wrapMarks(bodyRef.current);
481
+ // A Raw append is marked but not followed: see `ReplayStep.follow`.
482
+ if (step?.follow) first?.scrollIntoView?.({ block: "nearest" });
483
+ }, [replayHtml]);
484
+ useEffect(() => {
485
+ if (replay === null || !replay.playing || replay.revisions === null) return;
486
+ const count = replay.revisions.length;
487
+ const timer = window.setTimeout(() => {
488
+ setReplay((state) => {
489
+ if (state === null) return state;
490
+ const next = nextStep(state.index, count);
491
+ return next === null ? { ...state, playing: false } : { ...state, index: next };
492
+ });
493
+ }, PLAY_INTERVAL_MS);
494
+ return () => window.clearTimeout(timer);
495
+ }, [replay]);
496
+
253
497
  useLayoutEffect(() => {
254
- if (note !== null) drafts.taskBody(note.slug, note.body, props.loadVersion);
498
+ if (note === null) return;
499
+ drafts.taskBody(note.slug, note.body, props.loadVersion);
500
+ // Each load is the note as it is on disk now, after an autosave included.
501
+ drafts.syncTail(note.slug, note.body);
255
502
  }, [drafts, note === null ? null : note.slug, note?.body, props.loadVersion]);
256
503
 
257
504
  // A card whose target note left the screen is a claim about a document that
@@ -340,35 +587,87 @@ export function Note(props: NoteProps) {
340
587
  const header = noteHeader(note, props.now);
341
588
  return (
342
589
  <article key={note.slug} class={`weave-note weave-note-${header.provenance}`}>
590
+ {draft === null ? null : (
591
+ <EditBar
592
+ status={editStatus({ saving: draft.saving, failed: draft.failed, dirty: drafts.isDirty(note.slug), savedAt: draft.savedAt })}
593
+ busy={taskSaving}
594
+ onFormat={format}
595
+ onRetry={saveNow}
596
+ onDiscard={discardEdits}
597
+ onDone={done}
598
+ />
599
+ )}
343
600
  <Header
344
601
  view={header}
345
602
  open={() => props.onOpen(note.slug)}
346
603
  editing={draft !== null}
347
- saving={draft?.saving ?? false}
348
604
  taskSaving={taskSaving}
349
- onToggle={toggleEdit}
350
- onSave={() => void save()}
605
+ onEdit={startEdit}
606
+ replaying={replay !== null}
607
+ onHistory={loadHistory === undefined ? null : toggleHistory}
351
608
  />
609
+ {replay === null ? null : (
610
+ <ReplayBar
611
+ state={replay}
612
+ now={props.now}
613
+ onIndex={(value) => setReplay((state) => state === null ? state : { ...state, index: value, playing: false })}
614
+ onPlay={() => setReplay((state) => {
615
+ if (state === null || state.revisions === null) return state;
616
+ if (state.playing) return { ...state, playing: false };
617
+ // Playing from the end starts over: that is what "watch it being written" means.
618
+ const restart = state.index >= state.revisions.length - 1;
619
+ return { ...state, index: restart ? 0 : state.index, playing: true };
620
+ })}
621
+ />
622
+ )}
352
623
  {draft !== null ? (
624
+ <>
353
625
  <textarea
626
+ ref={editorRef}
354
627
  class="weave-note-editor"
355
628
  aria-label={EDITOR_ARIA_LABEL}
629
+ placeholder={EDITOR_PLACEHOLDER}
356
630
  spellcheck={props.spellcheck ?? true}
357
631
  value={draft.body}
358
632
  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
- }}
633
+ onFocus={() => { lastBox.current = "body"; }}
634
+ onKeyDown={editorKeys}
371
635
  />
636
+ {/* A box of its own, never the body's textarea: text typed below
637
+ the heading there used to be dropped on save without a word. */}
638
+ {draft.tail === "" ? null : (
639
+ <section class="weave-raw" aria-label={RAW_TAIL_ARIA_LABEL} data-open={rawOpen ? "" : undefined}>
640
+ <div class="weave-raw-head">
641
+ <button
642
+ type="button"
643
+ class="weave-raw-toggle"
644
+ aria-expanded={rawOpen}
645
+ aria-controls={`${props.idPrefix ?? generatedId}-weave-raw`}
646
+ onClick={() => setRawOpen((open) => !open)}
647
+ >
648
+ <svg class="weave-raw-chevron" width="12" height="12" viewBox="0 0 12 12" aria-hidden="true">
649
+ <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" />
650
+ </svg>
651
+ {RAW_TITLE}
652
+ </button>
653
+ <span class="weave-raw-hint">{rawSummary(draft.raw, rawOpen)}</span>
654
+ </div>
655
+ {rawOpen ? (
656
+ <textarea
657
+ id={`${props.idPrefix ?? generatedId}-weave-raw`}
658
+ class="weave-raw-editor"
659
+ aria-label={RAW_EDITOR_ARIA_LABEL}
660
+ spellcheck={props.spellcheck ?? true}
661
+ value={draft.raw}
662
+ ref={rawEditorRef}
663
+ onInput={(event) => drafts.editRaw(note.slug, (event.target as HTMLTextAreaElement).value)}
664
+ onFocus={() => { lastBox.current = "raw"; }}
665
+ onKeyDown={editorKeys}
666
+ />
667
+ ) : null}
668
+ </section>
669
+ )}
670
+ </>
372
671
  ) : (
373
672
  <div
374
673
  ref={bodyRef}
@@ -403,6 +702,11 @@ export function Note(props: NoteProps) {
403
702
  onClick={(event) => {
404
703
  const checkbox = event.target as HTMLInputElement;
405
704
  const taskIndex = taskCheckboxIndexOf(checkbox);
705
+ // A past version is read-only: its checkboxes must not write to the note.
706
+ if (taskIndex !== null && replayHtml !== null) {
707
+ event.preventDefault();
708
+ return;
709
+ }
406
710
  if (taskIndex !== null) {
407
711
  void saveTask(checkbox, taskIndex);
408
712
  return;
@@ -434,7 +738,7 @@ export function Note(props: NoteProps) {
434
738
  props.onSelect(target, event.metaKey || event.ctrlKey || event.shiftKey);
435
739
  }}
436
740
  // Sanitised by `renderNote`'s three layers — see note.model.ts.
437
- dangerouslySetInnerHTML={{ __html: html }}
741
+ dangerouslySetInnerHTML={{ __html: replayHtml ?? html }}
438
742
  />
439
743
  )}
440
744
  {card === null ? null : (