pi-weave 0.2.1 → 0.2.3

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.
@@ -8,8 +8,8 @@
8
8
  *
9
9
  * ## The editor
10
10
  *
11
- * Clicking the prose swaps it for a `<textarea>`; `draft` is `null` in read
12
- * mode, so text and mode cannot disagree. No reducer — the 686-line one that
11
+ * The explicit Edit control swaps the body for a `<textarea>`; `draft` is
12
+ * `null` in read mode, so text and mode cannot disagree. No reducer — the 686-line one that
13
13
  * stood here resolved conflicts against a revision the server no longer
14
14
  * issues. Being component state, the draft is out of the 2 s poll's reach;
15
15
  * the shell guards it through the `editor` slot below.
@@ -23,7 +23,7 @@
23
23
  * ## Wikilink preview (P6.3)
24
24
  *
25
25
  * Hovering or focusing a wikilink opens a small card for its target. The
26
- * delegated pattern the click handler established extends to the card for
26
+ * body's delegated handlers extend to the card for
27
27
  * free — `mouseover`, `focusin` and `keydown` are read through the same
28
28
  * ancestor walk (`previewAnchorOf`), the state machine is `reducePreview`,
29
29
  * and this file's only contribution is forwarding the pointer's viewport
@@ -46,17 +46,18 @@ import {
46
46
  artifactKeyOfNode,
47
47
  DISCARD_PROMPT,
48
48
  DONE_LABEL,
49
+ EDIT_LABEL,
49
50
  EDITED_WORD,
50
51
  EDITOR_ARIA_LABEL,
51
52
  EMPTY_PREVIEW,
52
53
  PREVIEW_ID,
53
54
  SAVE_LABEL,
54
55
  SAVING_LABEL,
56
+ TASK_CHECKBOX_ATTR,
55
57
  WIKILINK_ATTR,
56
58
  draftDirty,
57
59
  draftMoved,
58
60
  hasTextSelection,
59
- noteClickAction,
60
61
  saveLanded,
61
62
  noteEmptyMessage,
62
63
  noteHeader,
@@ -67,6 +68,9 @@ import {
67
68
  renderNote,
68
69
  selectedArtifactPath,
69
70
  tagLabel,
71
+ taskCheckboxIndexOf,
72
+ taskCheckboxLabel,
73
+ toggleTaskCheckbox,
70
74
  wikiIndex,
71
75
  wikilinkTargetOf,
72
76
  } from "./note.model";
@@ -83,6 +87,7 @@ const PREVIEW_Y = "weave-preview-y";
83
87
 
84
88
  export interface NoteProps {
85
89
  note: NotePayload | null;
90
+ loadFailed: boolean;
86
91
  graph: GraphPayload | null;
87
92
  selectedId: string | null;
88
93
  onSelect: (id: string) => void;
@@ -116,30 +121,28 @@ function Header({
116
121
  open,
117
122
  editing,
118
123
  saving,
119
- onDone,
124
+ onToggle,
120
125
  onSave,
121
126
  }: {
122
127
  view: NoteHeaderView;
123
128
  open: () => void;
124
129
  editing: boolean;
125
130
  saving: boolean;
126
- onDone: () => void;
131
+ onToggle: () => void;
127
132
  onSave: () => void;
128
133
  }) {
129
134
  return (
130
135
  <header class="weave-note-head">
131
136
  <h3 class="weave-note-title">{view.title}</h3>
132
137
  <p class="weave-note-meta">
133
- {/* Nothing in read mode: the prose is the way in. The way out needs a
134
- control, because "click the text" cannot also mean "stop". */}
135
138
  {editing ? (
136
139
  <>
137
140
  <button type="button" class="weave-note-save" disabled={saving} onClick={onSave}>
138
141
  {saving ? SAVING_LABEL : SAVE_LABEL}
139
142
  </button>
140
- <button type="button" class="weave-note-edit" onClick={onDone}>{DONE_LABEL}</button>
143
+ <button type="button" class="weave-note-edit" onClick={onToggle}>{DONE_LABEL}</button>
141
144
  </>
142
- ) : null}
145
+ ) : <button type="button" class="weave-note-edit" onClick={onToggle}>{EDIT_LABEL}</button>}
143
146
  <button type="button" class="weave-note-open" title="Open in $EDITOR" aria-label="Open in $EDITOR" onClick={open}>
144
147
  <span class="weave-note-open-mark" aria-hidden="true">↗</span>
145
148
  </button>
@@ -167,7 +170,7 @@ function Header({
167
170
  export function Note(props: NoteProps) {
168
171
  const note = props.note?.note ?? null;
169
172
  const artifactPath = selectedArtifactPath(props.graph, props.selectedId);
170
- const empty = noteEmptyMessage(props.selectedId, note);
173
+ const empty = noteEmptyMessage(props.selectedId, note, props.loadFailed);
171
174
 
172
175
  // Both hook calls sit before the empty-return so the hook order cannot
173
176
  // depend on whether a note is loaded. The memoization is cheap insurance:
@@ -189,6 +192,12 @@ export function Note(props: NoteProps) {
189
192
  const dispatch = (event: PreviewEvent): void => void sendPreview((state) => reducePreview(state, event));
190
193
  const bodyRef = useRef<HTMLDivElement | null>(null);
191
194
  const cardRef = useRef<HTMLDivElement | null>(null);
195
+ const taskSaving = useRef(false);
196
+ const taskSource = useRef<{ slug: string; serverBody: string; body: string } | null>(null);
197
+ if (note === null) taskSource.current = null;
198
+ else if (taskSource.current === null || taskSource.current.slug !== note.slug || taskSource.current.serverBody !== note.body) {
199
+ taskSource.current = { slug: note.slug, serverBody: note.body, body: note.body };
200
+ }
192
201
 
193
202
  const [draft, setDraft] = useState<string | null>(null);
194
203
  const [saving, setSaving] = useState(false);
@@ -233,6 +242,33 @@ export function Note(props: NoteProps) {
233
242
  else close();
234
243
  };
235
244
 
245
+ const saveTask = async (checkbox: HTMLInputElement, taskIndex: number): Promise<void> => {
246
+ const source = taskSource.current;
247
+ const restore = (): void => {
248
+ checkbox.checked = !checkbox.checked;
249
+ checkbox.setAttribute("aria-label", taskCheckboxLabel(checkbox.checked));
250
+ };
251
+ if (source === null || taskSaving.current) {
252
+ restore();
253
+ return;
254
+ }
255
+ const body = toggleTaskCheckbox(source.body, taskIndex);
256
+ if (body === null) {
257
+ restore();
258
+ return;
259
+ }
260
+
261
+ const inputs = [...(bodyRef.current?.querySelectorAll<HTMLInputElement>(`input[${TASK_CHECKBOX_ATTR}]`) ?? [])];
262
+ taskSaving.current = true;
263
+ checkbox.setAttribute("aria-label", taskCheckboxLabel(checkbox.checked));
264
+ for (const input of inputs) input.disabled = true;
265
+ const ok = await props.onSave(source.slug, body);
266
+ if (ok) source.body = body;
267
+ else restore();
268
+ for (const input of inputs) input.disabled = false;
269
+ taskSaving.current = false;
270
+ };
271
+
236
272
  const toggleEdit = (): void => {
237
273
  if (note === null) return;
238
274
  if (draft === null) open(note.body);
@@ -325,7 +361,7 @@ export function Note(props: NoteProps) {
325
361
  open={() => props.onOpen(note.slug)}
326
362
  editing={draft !== null}
327
363
  saving={saving}
328
- onDone={toggleEdit}
364
+ onToggle={toggleEdit}
329
365
  onSave={() => void save()}
330
366
  />
331
367
  {draft !== null ? (
@@ -380,16 +416,20 @@ export function Note(props: NoteProps) {
380
416
  }}
381
417
  onBlur={() => dispatch({ type: "hide" })}
382
418
  onClick={(event) => {
383
- // Three gestures, one element; `noteClickAction` owns the order.
419
+ const checkbox = event.target as HTMLInputElement;
420
+ const taskIndex = taskCheckboxIndexOf(checkbox);
421
+ if (taskIndex !== null) {
422
+ void saveTask(checkbox, taskIndex);
423
+ return;
424
+ }
384
425
  const selection =
385
426
  typeof window !== "undefined" && typeof window.getSelection === "function"
386
427
  ? window.getSelection()
387
428
  : null;
429
+ if (hasTextSelection(selection)) return;
388
430
  const target = wikilinkTargetOf(event.target as unknown as Parameters<typeof wikilinkTargetOf>[0]);
389
- const action = noteClickAction(hasTextSelection(selection), target);
390
431
  // A wikilink carries no href, so route it onto the context bus.
391
- if (action === "navigate" && target !== null) props.onSelect(target);
392
- else if (action === "edit") open(note.body);
432
+ if (target !== null) props.onSelect(target);
393
433
  }}
394
434
  onKeyDown={(event) => {
395
435
  // Escape is first so the card closes on the gesture a keyboard
@@ -404,11 +444,9 @@ export function Note(props: NoteProps) {
404
444
  }
405
445
  if (event.key !== "Enter" && event.key !== " ") return;
406
446
  const target = wikilinkTargetOf(event.target as unknown as Parameters<typeof wikilinkTargetOf>[0]);
447
+ if (target === null) return;
407
448
  event.preventDefault();
408
- // The keyboard's half of the click: without it, editing would be
409
- // mouse-only now that the Edit button is gone.
410
- if (target !== null) props.onSelect(target);
411
- else open(note.body);
449
+ props.onSelect(target);
412
450
  }}
413
451
  // Sanitised by `renderNote`'s three layers — see note.model.ts.
414
452
  dangerouslySetInnerHTML={{ __html: html }}
@@ -67,7 +67,7 @@
67
67
  * `tsconfig.json` project compile the tests.
68
68
  */
69
69
 
70
- import { Marked } from "marked";
70
+ import { Lexer, Marked } from "marked";
71
71
  import type { TokenizerAndRendererExtension, Tokens } from "marked";
72
72
  import { relTime } from "../../shared/view";
73
73
  import type { GraphPayload, ViewNote, WireGraphNode, WireNoteSource } from "../../shared/wire";
@@ -197,6 +197,14 @@ export function safeUrl(raw: string): string | null {
197
197
  */
198
198
  export const WIKILINK_ATTR = "data-weave-target";
199
199
 
200
+ /** The rendered task checkbox's zero-based source-order index. */
201
+ export const TASK_CHECKBOX_ATTR = "data-weave-task";
202
+
203
+ /** The action a task checkbox offers from its current state. */
204
+ export function taskCheckboxLabel(checked: boolean): string {
205
+ return checked ? "Mark task incomplete" : "Mark task complete";
206
+ }
207
+
200
208
  /** Prefix core's graph builder gives note nodes. Mirrors `noteSlug`. */
201
209
  const NOTE_PREFIX = "note:";
202
210
 
@@ -465,11 +473,16 @@ export function renderWikilink(index: WikiIndex, target: string, alias: string):
465
473
  * rather than `string | Promise<string>`; nothing here is asynchronous.
466
474
  */
467
475
  export function markdownRenderer(index: WikiIndex): Marked {
476
+ let taskIndex = 0;
468
477
  return new Marked({
469
478
  gfm: true,
470
479
  async: false,
471
480
  extensions: [wikilinkExtension(index)],
472
481
  renderer: {
482
+ checkbox({ checked }) {
483
+ const state = checked ? ' checked=""' : "";
484
+ return `<input type="checkbox"${state} ${TASK_CHECKBOX_ATTR}="${taskIndex++}" aria-label="${taskCheckboxLabel(checked)}"> `;
485
+ },
473
486
  /**
474
487
  * Layer 1. Raw HTML in a note body is rendered as **text**.
475
488
  *
@@ -551,7 +564,6 @@ export interface SanitizeConfig {
551
564
  readonly ALLOW_DATA_ATTR: boolean;
552
565
  readonly ALLOW_ARIA_ATTR: boolean;
553
566
  readonly ALLOW_UNKNOWN_PROTOCOLS: boolean;
554
- readonly USE_PROFILES: { readonly html: true };
555
567
  }
556
568
 
557
569
  /**
@@ -575,9 +587,9 @@ export interface SanitizeConfig {
575
587
  * and keeps the element. It also contains no `style`, so a CSS-based overlay
576
588
  * attack has no attribute to live in.
577
589
  *
578
- * `ALLOW_DATA_ATTR: false` with {@link WIKILINK_ATTR} named explicitly is
590
+ * `ALLOW_DATA_ATTR: false` with the two renderer-owned data attributes named explicitly is
579
591
  * narrower than leaving data attributes on: the only `data-` attribute that
580
- * may exist in rendered output is the one this module writes.
592
+ * may exist in rendered output is one this module writes.
581
593
  * `ADD_URI_SAFE_ATTR` then tells DOMPurify that the attribute's value is not a
582
594
  * URL and must not be run through `IS_ALLOWED_URI` — without it a slug like
583
595
  * `note-2` is fine but the check is being applied to a value that was never a
@@ -644,15 +656,15 @@ export const SANITIZE_CONFIG: SanitizeConfig = frozen({
644
656
  "align",
645
657
  "type",
646
658
  "checked",
647
- "disabled",
659
+ "aria-label",
648
660
  "start",
649
661
  WIKILINK_ATTR,
662
+ TASK_CHECKBOX_ATTR,
650
663
  ]),
651
- ADD_URI_SAFE_ATTR: frozen([WIKILINK_ATTR]),
664
+ ADD_URI_SAFE_ATTR: frozen([WIKILINK_ATTR, TASK_CHECKBOX_ATTR]),
652
665
  ALLOW_DATA_ATTR: false,
653
666
  ALLOW_ARIA_ATTR: false,
654
667
  ALLOW_UNKNOWN_PROTOCOLS: false,
655
- USE_PROFILES: frozen({ html: true as const }),
656
668
  });
657
669
 
658
670
  /**
@@ -671,12 +683,92 @@ export function renderNote(purify: Purifier, body: string, index: WikiIndex): st
671
683
 
672
684
  // --- reading a click back out of the DOM --------------------------------------------------
673
685
 
686
+ const TASK_LINE_RE = /^((?: {0,3}>[ \t]?)*[ \t]*(?:[*+-]|\d{1,9}[.)])[ \t]+\[)([ xX])(\] +\S)/;
687
+
688
+ /** Source offsets for the task markers marked will render, in render order. */
689
+ function taskMarkerOffsets(body: string): Array<number | null> {
690
+ const candidates: number[] = [];
691
+ const source = body.replace(/\r\n?/g, "\n");
692
+ let sourceOffset = 0;
693
+
694
+ for (const token of Lexer.lex(source, { gfm: true })) {
695
+ const tokenOffset = source.indexOf(token.raw, sourceOffset);
696
+ if (tokenOffset === -1) continue;
697
+ sourceOffset = tokenOffset + token.raw.length;
698
+ if (token.type !== "list" && token.type !== "blockquote") continue;
699
+
700
+ let lineOffset = 0;
701
+ while (lineOffset < token.raw.length) {
702
+ const newline = token.raw.indexOf("\n", lineOffset);
703
+ const lineEnd = newline === -1 ? token.raw.length : newline;
704
+ const line = token.raw.slice(lineOffset, lineEnd).replace(/\r$/, "");
705
+ const task = TASK_LINE_RE.exec(line);
706
+ if (task !== null) candidates.push(tokenOffset + lineOffset + (task[1] as string).length);
707
+ lineOffset = newline === -1 ? token.raw.length : newline + 1;
708
+ }
709
+ }
710
+
711
+ const walker = new Marked();
712
+ const states = (markdown: string): boolean[] => {
713
+ const checked: boolean[] = [];
714
+ walker.walkTokens(Lexer.lex(markdown, { gfm: true }), (token) => {
715
+ if (token.type === "checkbox") checked.push(token.checked);
716
+ });
717
+ return checked;
718
+ };
719
+ const baseline = states(source);
720
+ const offsets: Array<number | null> = baseline.map(() => null);
721
+ // ponytail: re-lex each candidate so Marked remains the sole judge of
722
+ // Markdown structure; use token source positions if Marked adds them.
723
+ for (const offset of candidates) {
724
+ const toggled = `${source.slice(0, offset)}${source[offset] === " " ? "x" : " "}${source.slice(offset + 1)}`;
725
+ const next = states(toggled);
726
+ if (next.length !== baseline.length) continue;
727
+ let changed = -1;
728
+ for (let index = 0; index < baseline.length; index += 1) {
729
+ if (next[index] === baseline[index]) continue;
730
+ if (changed !== -1) {
731
+ changed = -1;
732
+ break;
733
+ }
734
+ changed = index;
735
+ }
736
+ if (changed !== -1) offsets[changed] = offset;
737
+ }
738
+
739
+ return offsets.map((offset) => {
740
+ if (offset === null) return null;
741
+ let original = 0;
742
+ let normalized = 0;
743
+ while (normalized < offset) {
744
+ original += body[original] === "\r" && body[original + 1] === "\n" ? 2 : 1;
745
+ normalized += 1;
746
+ }
747
+ return original;
748
+ });
749
+ }
750
+
751
+ /** Flip one rendered GFM task marker without changing any other source byte. */
752
+ export function toggleTaskCheckbox(body: string, index: number): string | null {
753
+ if (!Number.isSafeInteger(index) || index < 0) return null;
754
+ const offset = taskMarkerOffsets(body)[index];
755
+ if (offset === undefined || offset === null) return null;
756
+ return `${body.slice(0, offset)}${body[offset] === " " ? "x" : " "}${body.slice(offset + 1)}`;
757
+ }
758
+
674
759
  /** The slice of `Element` {@link wikilinkTargetOf} walks. `HTMLElement` satisfies it. */
675
760
  export interface ClosestElement {
676
761
  getAttribute(name: string): string | null;
677
762
  readonly parentElement: ClosestElement | null;
678
763
  }
679
764
 
765
+ /** The task index carried by a rendered checkbox, if the target is one. */
766
+ export function taskCheckboxIndexOf(element: ClosestElement | null): number | null {
767
+ const raw = element?.getAttribute(TASK_CHECKBOX_ATTR);
768
+ if (raw === null || raw === undefined || !/^(?:0|[1-9]\d*)$/.test(raw)) return null;
769
+ return Number(raw);
770
+ }
771
+
680
772
  /**
681
773
  * Depth limit for the ancestor walk.
682
774
  *
@@ -747,11 +839,8 @@ export const CREATED_WORD = "created";
747
839
 
748
840
  // --- the editor -------------------------------------------------------------------------
749
841
 
750
- /**
751
- * The editor's strings. No "Edit" label — clicking the prose opens it (see
752
- * {@link noteClickAction}); "Done", because leaving a clean editor cancels
753
- * nothing.
754
- */
842
+ /** The editor's controls and prompts. */
843
+ export const EDIT_LABEL = "Edit";
755
844
  export const DONE_LABEL = "Done";
756
845
  export const SAVE_LABEL = "Save";
757
846
  export const SAVING_LABEL = "Saving…";
@@ -767,18 +856,6 @@ export function draftDirty(draft: string, body: string): boolean {
767
856
  return draft !== body;
768
857
  }
769
858
 
770
- /** What a click on the rendered body means. */
771
- export type NoteClickAction = "ignore" | "navigate" | "edit";
772
-
773
- /**
774
- * Resolve a click on the prose. Selection wins (a drag to copy ends in a
775
- * click, and 865da37 is that bug), then wikilinks, then editing.
776
- */
777
- export function noteClickAction(hasSelection: boolean, wikilinkTarget: string | null): NoteClickAction {
778
- if (hasSelection) return "ignore";
779
- return wikilinkTarget === null ? "edit" : "navigate";
780
- }
781
-
782
859
  /**
783
860
  * Whether a save's reply still belongs to the editor that issued it. A
784
861
  * counter, not the slug: reopening the same note is a new session too.
@@ -1151,9 +1228,10 @@ export function reducePreview(state: PreviewState, event: PreviewEvent): Preview
1151
1228
  * message for all of them is how a user concludes the app is broken when they
1152
1229
  * click a module and the reading pane does not change.
1153
1230
  */
1154
- export function noteEmptyMessage(selectedId: string | null, note: ViewNote | null): string | null {
1231
+ export function noteEmptyMessage(selectedId: string | null, note: ViewNote | null, failed = false): string | null {
1155
1232
  if (note !== null) return null;
1156
1233
  if (selectedId === null) return "Nothing open — search with ⌘K, or walk the tree with j and k.";
1234
+ if (failed) return "Couldn’t load this note — retrying…";
1157
1235
  if (selectedId.startsWith(NOTE_PREFIX)) return "Loading…";
1158
1236
  // A module, a file, a package, the git state: real nodes with no prose
1159
1237
  // behind them. The context rail beside this column is where their
@@ -26,6 +26,7 @@ export interface ColumnsProps {
26
26
  /** The §1.3 context bus, as the columns see it. */
27
27
  graph: GraphPayload | null;
28
28
  note: NotePayload | null;
29
+ noteFailed: boolean;
29
30
  selectedId: string | null;
30
31
  recentIds: ReadonlySet<string>;
31
32
  /**
@@ -88,6 +89,7 @@ function Column({ id, props }: { id: ColumnId; props: ColumnsProps }) {
88
89
  {id === "note" ? (
89
90
  <Note
90
91
  note={props.note}
92
+ loadFailed={props.noteFailed}
91
93
  graph={props.graph}
92
94
  selectedId={props.selectedId}
93
95
  onSelect={props.onSelect}
@@ -232,6 +232,7 @@ export function Shell(props: ShellProps) {
232
232
  bootFailed={workspaceState.graphFailed}
233
233
  graph={workspaceState.graph}
234
234
  note={workspaceState.note}
235
+ noteFailed={workspaceState.noteFailed}
235
236
  selectedId={workspaceState.selectedId}
236
237
  recentIds={workspaceState.recentIds}
237
238
  onSelect={select}
@@ -441,11 +441,6 @@ body{font-size:var(--weave-px-base)}
441
441
  chrome's 1.6: the extra leading is what a page of running text needs that a
442
442
  status bar does not. Code stays mono deliberately. */
443
443
  .weave-note-body{padding:12px var(--weave-note-gutter) 28px;font-size:var(--weave-px-body);line-height:1.7;color:var(--weave-fg)}
444
- /* The prose is the edit affordance: a caret and a faint wash, not a border.
445
- This column is read far more often than edited. */
446
- .weave-note-body{cursor:text}
447
- .weave-note-body:hover{background:var(--weave-panel)}
448
- @media (prefers-reduced-motion: no-preference){.weave-note-body{transition:background 120ms ease}}
449
444
  .weave-note-body>*:first-child{margin-top:0}
450
445
  .weave-note-body h1,.weave-note-body h2,.weave-note-body h3,
451
446
  .weave-note-body h4,.weave-note-body h5,.weave-note-body h6{
@@ -462,6 +457,8 @@ body{font-size:var(--weave-px-base)}
462
457
  .weave-note-body p,.weave-note-body ul,.weave-note-body ol,.weave-note-body blockquote{margin:0 0 12px}
463
458
  .weave-note-body ul,.weave-note-body ol{padding-left:20px}
464
459
  .weave-note-body li{margin:4px 0}
460
+ .weave-note-body input[type="checkbox"]{accent-color:var(--weave-accent);cursor:pointer}
461
+ .weave-note-body input[type="checkbox"]:disabled{cursor:wait}
465
462
  .weave-note-body blockquote{
466
463
  padding-left:10px;border-left:2px solid var(--weave-line-strong);color:var(--weave-dim);
467
464
  }
@@ -11,10 +11,11 @@ export interface WorkspaceState {
11
11
  graph: GraphPayload | null;
12
12
  graphFailed: boolean;
13
13
  note: NotePayload | null;
14
+ noteFailed: boolean;
14
15
  recentIds: ReadonlySet<string>;
15
16
  selectedId: string | null;
16
17
  }
17
18
 
18
19
  export function initialWorkspaceState(): WorkspaceState {
19
- return { graph: null, graphFailed: false, note: null, recentIds: new Set(), selectedId: null };
20
+ return { graph: null, graphFailed: false, note: null, noteFailed: false, recentIds: new Set(), selectedId: null };
20
21
  }
@@ -54,11 +54,12 @@ export function startWorkspace(opts: WorkspaceOptions): WorkspaceHandle {
54
54
  const loadNote = async (): Promise<void> => {
55
55
  const slug = noteSlug(state.selectedId);
56
56
  if (slug === null) {
57
- update({ note: null });
57
+ update({ note: null, noteFailed: false });
58
58
  return;
59
59
  }
60
60
  const result = await fetchNote(opts.fetch, slug);
61
- if (result.ok) update({ note: result.data });
61
+ if (result.ok) update({ note: result.data, noteFailed: false });
62
+ else update({ note: null, noteFailed: true });
62
63
  };
63
64
  const loadGraph = async (): Promise<ApiResult<unknown>> => {
64
65
  const previous = state.graph;
@@ -86,7 +87,7 @@ export function startWorkspace(opts: WorkspaceOptions): WorkspaceHandle {
86
87
  polling = true;
87
88
  try {
88
89
  const result = await loadGraph();
89
- if (result.ok && !result.cached && noteSlug(state.selectedId) !== null) await loadNote();
90
+ if (noteSlug(state.selectedId) !== null && (state.note === null || (result.ok && !result.cached))) await loadNote();
90
91
  } finally {
91
92
  polling = false;
92
93
  }