@gmickel/gno 2.5.1 → 2.7.0

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 (160) hide show
  1. package/README.md +60 -5
  2. package/assets/skill/README.md +5 -1
  3. package/assets/skill/SKILL.md +80 -4
  4. package/assets/skill/cli-reference.md +132 -2
  5. package/assets/skill/examples.md +30 -0
  6. package/assets/skill/mcp-reference.md +54 -1
  7. package/assets/skill/recipes/capture-and-file.md +6 -0
  8. package/assets/skill/recipes/memory-file-decision.md +6 -0
  9. package/assets/skill/recipes/memory-supersede-fact.md +5 -0
  10. package/assets/skill/recipes/session-evidence-lookup.md +98 -0
  11. package/assets/spa-production.json.gz +0 -0
  12. package/browser-extension/artifacts/{gno-browser-clipper-v2.5.1.zip → gno-browser-clipper-v2.7.0.zip} +0 -0
  13. package/browser-extension/artifacts/gno-browser-clipper-v2.7.0.zip.sha256 +1 -0
  14. package/browser-extension/dist/manifest.json +1 -1
  15. package/package.json +2 -1
  16. package/spec/cli.md +449 -35
  17. package/spec/mcp.md +234 -4
  18. package/spec/output-schemas/ask.schema.json +1 -1
  19. package/spec/output-schemas/capture-receipt.schema.json +4 -1
  20. package/spec/output-schemas/doctor.schema.json +88 -0
  21. package/spec/output-schemas/error.schema.json +11 -2
  22. package/spec/output-schemas/get.schema.json +1 -1
  23. package/spec/output-schemas/mcp-capture-result.schema.json +4 -2
  24. package/spec/output-schemas/memory-remember.schema.json +10 -4
  25. package/spec/output-schemas/multi-get.schema.json +4 -1
  26. package/spec/output-schemas/peek.schema.json +2 -9
  27. package/spec/output-schemas/request-status.schema.json +113 -0
  28. package/spec/output-schemas/resident-status.schema.json +22 -0
  29. package/spec/output-schemas/search-result.schema.json +1 -1
  30. package/spec/output-schemas/search-results.schema.json +1 -1
  31. package/spec/output-schemas/sessions-automation-run.schema.json +46 -0
  32. package/spec/output-schemas/sessions-discovery.schema.json +38 -0
  33. package/spec/output-schemas/sessions-import-receipt.schema.json +156 -0
  34. package/spec/output-schemas/sessions-status.schema.json +432 -0
  35. package/spec/output-schemas/status.schema.json +98 -0
  36. package/src/cli/commands/ask.ts +14 -2
  37. package/src/cli/commands/capture.ts +55 -96
  38. package/src/cli/commands/daemon.ts +41 -0
  39. package/src/cli/commands/doctor.ts +54 -20
  40. package/src/cli/commands/embed.ts +41 -3
  41. package/src/cli/commands/ls.ts +3 -0
  42. package/src/cli/commands/memory.ts +12 -3
  43. package/src/cli/commands/query.ts +5 -0
  44. package/src/cli/commands/request-status.ts +59 -0
  45. package/src/cli/commands/reset.ts +39 -5
  46. package/src/cli/commands/sessions.ts +713 -0
  47. package/src/cli/commands/shared.ts +14 -1
  48. package/src/cli/commands/status.ts +63 -5
  49. package/src/cli/commands/vec.ts +54 -0
  50. package/src/cli/detach.ts +29 -1
  51. package/src/cli/errors.ts +13 -9
  52. package/src/cli/program.ts +441 -2
  53. package/src/cli/session-binding.ts +49 -0
  54. package/src/config/types.ts +8 -0
  55. package/src/core/capture-publish.ts +239 -0
  56. package/src/core/capture-sync.ts +12 -2
  57. package/src/core/host-paths.ts +31 -0
  58. package/src/core/memory-remember.ts +234 -122
  59. package/src/core/memory-types.ts +11 -0
  60. package/src/core/network-boundary-inventory.ts +8 -0
  61. package/src/core/request-receipts.ts +671 -0
  62. package/src/core/shutdown-budget.ts +6 -0
  63. package/src/core/vector-partition-status.ts +52 -0
  64. package/src/embed/backlog.ts +124 -18
  65. package/src/embed/fingerprint.ts +6 -3
  66. package/src/embed/retry.ts +66 -27
  67. package/src/embed/variant-backlog.ts +15 -10
  68. package/src/embed/variant-retry.ts +31 -22
  69. package/src/index.ts +30 -2
  70. package/src/llm/native-worker/dispatcher.ts +2 -0
  71. package/src/llm/native-worker/embedding-identity.ts +42 -0
  72. package/src/llm/native-worker/protocol.ts +1 -0
  73. package/src/llm/types.ts +3 -0
  74. package/src/mcp/context.ts +17 -0
  75. package/src/mcp/http-egress.ts +4 -0
  76. package/src/mcp/http-transport.ts +2 -0
  77. package/src/mcp/resources/index.ts +6 -5
  78. package/src/mcp/tool-descriptions-core.ts +1 -1
  79. package/src/mcp/tools/capture.ts +87 -85
  80. package/src/mcp/tools/index.ts +77 -4
  81. package/src/mcp/tools/memory-remember.ts +8 -1
  82. package/src/mcp/tools/memory-shared.ts +7 -1
  83. package/src/mcp/tools/request-status.ts +73 -0
  84. package/src/mcp/tools/sessions.ts +208 -0
  85. package/src/mcp/tools/status.ts +4 -0
  86. package/src/pipeline/hybrid.ts +37 -7
  87. package/src/pipeline/vsearch.ts +14 -2
  88. package/src/sdk/client.ts +180 -84
  89. package/src/sdk/index.ts +6 -0
  90. package/src/sdk/types.ts +54 -2
  91. package/src/serve/capture-service.ts +98 -32
  92. package/src/serve/config-sync.ts +3 -2
  93. package/src/serve/embed-scheduler.ts +133 -19
  94. package/src/serve/host-path-redaction.ts +79 -0
  95. package/src/serve/public/app.tsx +4 -1
  96. package/src/serve/public/components/CaptureModal.tsx +26 -8
  97. package/src/serve/public/components/sessions/AutomationPanel.tsx +800 -0
  98. package/src/serve/public/components/sessions/ImportReceipt.tsx +238 -0
  99. package/src/serve/public/components/sessions/SessionSearch.tsx +286 -0
  100. package/src/serve/public/components/sessions/SourcesPanel.tsx +541 -0
  101. package/src/serve/public/components/sessions/api.ts +40 -0
  102. package/src/serve/public/globals.built.css +1 -1
  103. package/src/serve/public/hooks/use-api.ts +26 -3
  104. package/src/serve/public/lib/request-intent.ts +77 -0
  105. package/src/serve/public/lib/snippet.tsx +52 -0
  106. package/src/serve/public/lib/workspace-actions.ts +12 -1
  107. package/src/serve/public/lib/workspace-tabs.ts +2 -0
  108. package/src/serve/public/pages/Dashboard.tsx +22 -9
  109. package/src/serve/public/pages/DocView.tsx +25 -6
  110. package/src/serve/public/pages/DocumentEditor.tsx +224 -104
  111. package/src/serve/public/pages/Search.tsx +1 -41
  112. package/src/serve/public/pages/Sessions.tsx +350 -0
  113. package/src/serve/resident-runtime.ts +69 -4
  114. package/src/serve/resident-status.ts +13 -1
  115. package/src/serve/routes/api.ts +476 -147
  116. package/src/serve/routes/sessions.ts +766 -0
  117. package/src/serve/security.ts +9 -0
  118. package/src/serve/server.ts +215 -10
  119. package/src/serve/session-automation.ts +146 -0
  120. package/src/serve/status-model.ts +16 -0
  121. package/src/serve/status.ts +2 -0
  122. package/src/serve/watch-reconciliation-shared.ts +3 -0
  123. package/src/serve/watch-service-events.ts +3 -2
  124. package/src/serve/watch-service-run-flush.ts +35 -2
  125. package/src/serve/watch-service.ts +5 -0
  126. package/src/sessions/archive.ts +348 -0
  127. package/src/sessions/automation-state.ts +444 -0
  128. package/src/sessions/automation-status.ts +239 -0
  129. package/src/sessions/automation.ts +1169 -0
  130. package/src/sessions/binding.ts +105 -0
  131. package/src/sessions/claude-hook.ts +240 -0
  132. package/src/sessions/config.ts +176 -0
  133. package/src/sessions/format.ts +191 -0
  134. package/src/sessions/import-child-env.ts +8 -0
  135. package/src/sessions/import-child.ts +152 -0
  136. package/src/sessions/parsers/claude-code.ts +259 -0
  137. package/src/sessions/parsers/codex.ts +303 -0
  138. package/src/sessions/parsers/hermes.ts +248 -0
  139. package/src/sessions/parsers/openclaw.ts +496 -0
  140. package/src/sessions/parsers/shared.ts +184 -0
  141. package/src/sessions/sanitize.ts +222 -0
  142. package/src/sessions/service.ts +1533 -0
  143. package/src/sessions/setup.ts +477 -0
  144. package/src/sessions/sources.ts +518 -0
  145. package/src/sessions/state.ts +118 -0
  146. package/src/sessions/types.ts +457 -0
  147. package/src/store/migrations/031-runtime-independent-vectors.ts +29 -0
  148. package/src/store/migrations/032-vector-runtime-callers.ts +17 -0
  149. package/src/store/migrations/index.ts +4 -0
  150. package/src/store/sqlite/adapter.ts +76 -16
  151. package/src/store/sqlite/scoped-index.ts +9 -0
  152. package/src/store/types.ts +11 -1
  153. package/src/store/vector/lazy.ts +46 -43
  154. package/src/store/vector/runtime-compat.ts +651 -0
  155. package/src/store/vector/sqlite-vec.ts +20 -2
  156. package/src/store/vector/status.ts +276 -35
  157. package/src/store/vector/types.ts +2 -0
  158. package/src/store/vector/variant-search.ts +71 -23
  159. package/src/store/vector/variants.ts +49 -14
  160. package/browser-extension/artifacts/gno-browser-clipper-v2.5.1.zip.sha256 +0 -1
@@ -73,6 +73,13 @@ import {
73
73
  loadLatestLocalHistory,
74
74
  type LocalHistoryEntry,
75
75
  } from "../lib/local-history";
76
+ import {
77
+ clearRequestIntent,
78
+ type RequestIntent,
79
+ requestIdForIntent,
80
+ } from "../lib/request-intent";
81
+
82
+ const SAVE_INTENT_KEY = "gno:save-intent";
76
83
  import { getActiveWikiLinkQuery } from "../lib/wiki-link";
77
84
 
78
85
  interface PageProps {
@@ -123,6 +130,8 @@ interface UpdateDocResponse {
123
130
  sourceHash: string;
124
131
  modifiedAt?: string;
125
132
  };
133
+ /** Present when the save carried a request ID. */
134
+ request?: { replayed: boolean };
126
135
  }
127
136
 
128
137
  interface DocsAutocompleteResponse {
@@ -132,7 +141,7 @@ interface DocsAutocompleteResponse {
132
141
  type SaveStatus = "saved" | "saving" | "unsaved" | "error";
133
142
 
134
143
  function useDebouncedCallback<T extends unknown[]>(
135
- callback: (...args: T) => void | Promise<void>,
144
+ callback: (...args: T) => unknown,
136
145
  delay: number
137
146
  ) {
138
147
  const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -198,8 +207,12 @@ export default function DocumentEditor({ navigate }: PageProps) {
198
207
  const [saveError, setSaveError] = useState<string | null>(null);
199
208
  const [creatingCopy, setCreatingCopy] = useState(false);
200
209
  const [copyError, setCopyError] = useState<string | null>(null);
201
- const [externalChangeNotice, setExternalChangeNotice] = useState<
202
- string | null
210
+ /**
211
+ * `outside`: the file changed under the editor. `unconfirmed`: a save's
212
+ * response was lost, so a change on disk may be that save's own commit.
213
+ */
214
+ const [changeNotice, setChangeNotice] = useState<
215
+ "outside" | "unconfirmed" | null
203
216
  >(null);
204
217
  const [historyEntries, setHistoryEntries] = useState<LocalHistoryEntry[]>([]);
205
218
  const [historyDialogOpen, setHistoryDialogOpen] = useState(false);
@@ -309,23 +322,58 @@ export default function DocumentEditor({ navigate }: PageProps) {
309
322
  }
310
323
  }, [syncScroll]);
311
324
 
312
- // Save function
313
- const saveDocument = useCallback(
314
- async (contentToSave: string) => {
315
- if (!doc) return;
325
+ // Autosave timers and Cmd+S run later than the render that scheduled them:
326
+ // read the current revision and last committed content from refs.
327
+ const docRef = useRef(doc);
328
+ const committedContentRef = useRef(originalContent);
329
+ const draftRef = useRef(content);
330
+ useEffect(() => {
331
+ docRef.current = doc;
332
+ }, [doc]);
333
+ useEffect(() => {
334
+ draftRef.current = content;
335
+ }, [content]);
336
+ // One request ID per (document revision, content) save intent: retrying a
337
+ // save whose response was lost replays it instead of reporting a conflict.
338
+ const saveIntentRef = useRef<RequestIntent | null>(null);
339
+ // Content of a save that is in flight or whose response was lost: its
340
+ // commit is unknown, so change events are held until a response says whose
341
+ // change they were. Retry save re-sends exactly this content.
342
+ const unknownSaveRef = useRef<string | null>(null);
343
+ const changedWhileUnknownRef = useRef(false);
344
+
345
+ /** Save `contentToSave`; resolves true once it is committed on disk. */
346
+ const persistContent = useCallback(
347
+ async (contentToSave: string): Promise<boolean> => {
348
+ const current = docRef.current;
349
+ if (!current) return false;
350
+ if (contentToSave === committedContentRef.current) {
351
+ setSaveStatus("saved");
352
+ return true;
353
+ }
316
354
 
317
355
  setSaveStatus("saving");
318
356
  setSaveError(null);
319
-
320
- const { data, error: err } = await apiFetch<UpdateDocResponse>(
321
- `/api/docs/${encodeURIComponent(doc.docid)}`,
357
+ unknownSaveRef.current = contentToSave;
358
+
359
+ const {
360
+ data,
361
+ error: err,
362
+ outcomeUnknown,
363
+ } = await apiFetch<UpdateDocResponse>(
364
+ `/api/docs/${encodeURIComponent(current.docid)}`,
322
365
  {
323
366
  method: "PUT",
324
367
  body: JSON.stringify({
325
368
  content: contentToSave,
326
- expectedSourceHash: doc.source.sourceHash,
327
- expectedModifiedAt: doc.source.modifiedAt,
328
- uri: doc.uri,
369
+ expectedSourceHash: current.source.sourceHash,
370
+ expectedModifiedAt: current.source.modifiedAt,
371
+ uri: current.uri,
372
+ requestId: requestIdForIntent(
373
+ saveIntentRef,
374
+ `${current.uri}\u0000${current.source.sourceHash}\u0000${contentToSave}`,
375
+ SAVE_INTENT_KEY
376
+ ),
329
377
  }),
330
378
  }
331
379
  );
@@ -333,32 +381,71 @@ export default function DocumentEditor({ navigate }: PageProps) {
333
381
  if (err) {
334
382
  setSaveStatus("error");
335
383
  setSaveError(err);
336
- } else {
337
- ignoreDocEventsUntilRef.current = Date.now() + 5_000;
338
- if (originalContent !== contentToSave) {
339
- appendLocalHistory(doc.docid, originalContent);
340
- refreshHistoryEntries(doc.docid);
384
+ if (outcomeUnknown) {
385
+ // Never downgrade a warning about a change from elsewhere.
386
+ setChangeNotice((notice) =>
387
+ notice === "outside" ? notice : "unconfirmed"
388
+ );
389
+ return false;
341
390
  }
342
- setSaveStatus("saved");
343
- setOriginalContent(contentToSave);
344
- setLastSaved(new Date());
345
- if (data) {
346
- setDoc((currentDoc) =>
347
- currentDoc
348
- ? {
349
- ...currentDoc,
350
- source: {
351
- ...currentDoc.source,
352
- sourceHash: data.version.sourceHash,
353
- modifiedAt: data.version.modifiedAt,
354
- },
355
- }
356
- : currentDoc
391
+ // A definitive rejection: this save did not commit, so a change seen
392
+ // meanwhile came from elsewhere.
393
+ unknownSaveRef.current = null;
394
+ if (changedWhileUnknownRef.current) {
395
+ changedWhileUnknownRef.current = false;
396
+ setChangeNotice("outside");
397
+ } else {
398
+ setChangeNotice((notice) =>
399
+ notice === "unconfirmed" ? null : notice
357
400
  );
358
401
  }
402
+ return false;
403
+ }
404
+
405
+ unknownSaveRef.current = null;
406
+ changedWhileUnknownRef.current = false;
407
+ if (data?.request?.replayed) {
408
+ // A replay reports an earlier commit; disk may have moved on since.
409
+ // Only a read showing a different hash is evidence of another writer.
410
+ const { data: latest } = await apiFetch<DocData>(
411
+ `/api/doc?uri=${encodeURIComponent(current.uri)}`
412
+ );
413
+ setChangeNotice(
414
+ latest && latest.source.sourceHash !== data.version.sourceHash
415
+ ? "outside"
416
+ : null
417
+ );
418
+ } else {
419
+ // Written now against the loaded revision: the next change event is
420
+ // this save's own sync, and any earlier notice is stale.
421
+ ignoreDocEventsUntilRef.current = Date.now() + 5_000;
422
+ setChangeNotice(null);
359
423
  }
424
+ const previous = committedContentRef.current;
425
+ if (previous !== contentToSave) {
426
+ appendLocalHistory(current.docid, previous);
427
+ refreshHistoryEntries(current.docid);
428
+ }
429
+ clearRequestIntent(saveIntentRef, SAVE_INTENT_KEY);
430
+ committedContentRef.current = contentToSave;
431
+ setSaveStatus("saved");
432
+ setOriginalContent(contentToSave);
433
+ setLastSaved(new Date());
434
+ if (data) {
435
+ const next = {
436
+ ...current,
437
+ source: {
438
+ ...current.source,
439
+ sourceHash: data.version.sourceHash,
440
+ modifiedAt: data.version.modifiedAt,
441
+ },
442
+ };
443
+ docRef.current = next;
444
+ setDoc(next);
445
+ }
446
+ return true;
360
447
  },
361
- [doc]
448
+ []
362
449
  );
363
450
 
364
451
  const handleCreateEditableCopy = useCallback(async () => {
@@ -476,16 +563,18 @@ export default function DocumentEditor({ navigate }: PageProps) {
476
563
  );
477
564
 
478
565
  // Debounced auto-save
479
- const { debouncedFn: debouncedSave } = useDebouncedCallback(
480
- saveDocument,
481
- 2000
482
- );
566
+ const { debouncedFn: debouncedSave, cancel: cancelAutosave } =
567
+ useDebouncedCallback(persistContent, 2000);
483
568
 
484
569
  // Handle content changes
485
570
  const handleContentChange = useCallback(
486
571
  (newContent: string) => {
487
572
  setContent(newContent);
488
- if (newContent !== originalContent) {
573
+ if (newContent === originalContent) {
574
+ // Back to the committed text: drop a pending autosave of a draft.
575
+ cancelAutosave();
576
+ setSaveStatus("saved");
577
+ } else {
489
578
  setSaveStatus("unsaved");
490
579
  debouncedSave(newContent);
491
580
  }
@@ -509,7 +598,7 @@ export default function DocumentEditor({ navigate }: PageProps) {
509
598
  setWikiLinkOpen(true);
510
599
  setWikiLinkActiveIndex(0);
511
600
  },
512
- [originalContent, debouncedSave]
601
+ [cancelAutosave, originalContent, debouncedSave]
513
602
  );
514
603
 
515
604
  useEffect(() => {
@@ -530,55 +619,27 @@ export default function DocumentEditor({ navigate }: PageProps) {
530
619
  });
531
620
  }, [doc?.collection, wikiLinkOpen, wikiLinkQuery]);
532
621
 
533
- // Force save (Cmd+S) - saves and triggers embedding
622
+ // Force save (Cmd+S) - supersedes a pending autosave, then triggers embedding
534
623
  const handleForceSave = useCallback(async () => {
535
- if (!hasUnsavedChanges || !doc) return;
536
-
537
- setSaveStatus("saving");
538
- setSaveError(null);
539
-
540
- // Save document
541
- const { data, error: err } = await apiFetch<UpdateDocResponse>(
542
- `/api/docs/${encodeURIComponent(doc.docid)}`,
543
- {
544
- method: "PUT",
545
- body: JSON.stringify({
546
- content,
547
- expectedSourceHash: doc.source.sourceHash,
548
- expectedModifiedAt: doc.source.modifiedAt,
549
- uri: doc.uri,
550
- }),
551
- }
552
- );
553
-
554
- if (err) {
555
- setSaveStatus("error");
556
- setSaveError(err);
557
- return;
624
+ cancelAutosave();
625
+ if (!hasUnsavedChanges) return;
626
+ if (await persistContent(content)) {
627
+ // Fire and forget - don't block on result
628
+ void apiFetch("/api/embed", { method: "POST" });
558
629
  }
559
-
560
- ignoreDocEventsUntilRef.current = Date.now() + 5_000;
561
- if (originalContent !== content) {
562
- appendLocalHistory(doc.docid, originalContent);
563
- refreshHistoryEntries(doc.docid);
630
+ }, [cancelAutosave, content, hasUnsavedChanges, persistContent]);
631
+
632
+ // Retry the unconfirmed save itself (same content, same request ID), even
633
+ // when the draft has since changed or returned to the loaded text; once it
634
+ // is confirmed, the draft as it is then is saved on top of it.
635
+ const retryUnconfirmedSave = useCallback(async () => {
636
+ const pending = unknownSaveRef.current;
637
+ if (pending === null) return;
638
+ cancelAutosave();
639
+ if (await persistContent(pending)) {
640
+ await persistContent(draftRef.current);
564
641
  }
565
- setSaveStatus("saved");
566
- setOriginalContent(content);
567
- setLastSaved(new Date());
568
- if (data) {
569
- setDoc({
570
- ...doc,
571
- source: {
572
- ...doc.source,
573
- sourceHash: data.version.sourceHash,
574
- modifiedAt: data.version.modifiedAt,
575
- },
576
- });
577
- }
578
-
579
- // Trigger embedding (fire and forget - don't block on result)
580
- void apiFetch("/api/embed", { method: "POST" });
581
- }, [hasUnsavedChanges, doc, content]);
642
+ }, [cancelAutosave, persistContent]);
582
643
 
583
644
  const loadDocument = useCallback(() => {
584
645
  const uri = currentTarget.uri;
@@ -599,6 +660,7 @@ export default function DocumentEditor({ navigate }: PageProps) {
599
660
  const docContent = data.content ?? "";
600
661
  setContent(docContent);
601
662
  setOriginalContent(docContent);
663
+ committedContentRef.current = docContent;
602
664
  refreshHistoryEntries(data.docid);
603
665
  // Ensure CodeMirror reflects content after async load
604
666
  requestAnimationFrame(() => {
@@ -617,20 +679,30 @@ export default function DocumentEditor({ navigate }: PageProps) {
617
679
  loadDocument();
618
680
  }, [loadDocument]);
619
681
 
682
+ // Judge each change event once; later doc updates must not re-raise it.
683
+ const handledDocEventRef = useRef<string | null>(null);
620
684
  useEffect(() => {
621
685
  if (!doc || latestDocEvent?.uri !== doc.uri) {
622
686
  return;
623
687
  }
688
+ if (handledDocEventRef.current === latestDocEvent.changedAt) {
689
+ return;
690
+ }
691
+ handledDocEventRef.current = latestDocEvent.changedAt;
692
+ if (unknownSaveRef.current !== null) {
693
+ changedWhileUnknownRef.current = true;
694
+ return;
695
+ }
624
696
  if (Date.now() < ignoreDocEventsUntilRef.current) {
625
697
  return;
626
698
  }
627
- setExternalChangeNotice(
628
- "This document changed on disk. Reload before continuing."
629
- );
699
+ setChangeNotice("outside");
630
700
  }, [doc, latestDocEvent?.changedAt, latestDocEvent?.uri]);
631
701
 
632
702
  const reloadDocument = useCallback(() => {
633
- setExternalChangeNotice(null);
703
+ unknownSaveRef.current = null;
704
+ changedWhileUnknownRef.current = false;
705
+ setChangeNotice(null);
634
706
  loadDocument();
635
707
  }, [loadDocument]);
636
708
 
@@ -752,7 +824,12 @@ export default function DocumentEditor({ navigate }: PageProps) {
752
824
 
753
825
  // Save and navigate
754
826
  const handleSaveAndNavigate = async () => {
755
- await saveDocument(content);
827
+ cancelAutosave();
828
+ // A failed save keeps the editor (and its error) instead of losing the draft.
829
+ if (!(await persistContent(content))) {
830
+ setShowUnsavedDialog(false);
831
+ return;
832
+ }
756
833
  // Trigger embedding (fire and forget)
757
834
  void apiFetch("/api/embed", { method: "POST" });
758
835
  setShowUnsavedDialog(false);
@@ -793,9 +870,13 @@ export default function DocumentEditor({ navigate }: PageProps) {
793
870
  <TooltipProvider>
794
871
  <Tooltip>
795
872
  <TooltipTrigger asChild>
796
- <div className="flex items-center gap-1.5 text-sm">
797
- <Icon className={`size-4 ${className}`} />
798
- <span className="hidden text-muted-foreground sm:inline">
873
+ <div
874
+ aria-label={text}
875
+ className="flex min-w-0 max-w-[16rem] shrink items-center gap-1.5 text-sm"
876
+ role="status"
877
+ >
878
+ <Icon className={`size-4 shrink-0 ${className}`} />
879
+ <span className="hidden truncate text-muted-foreground sm:inline">
799
880
  {saveStatus === "saved" && lastSaved
800
881
  ? formatTime(lastSaved)
801
882
  : text}
@@ -907,7 +988,7 @@ export default function DocumentEditor({ navigate }: PageProps) {
907
988
  <div className="flex h-screen flex-col overflow-hidden">
908
989
  {/* Toolbar */}
909
990
  <header className="glass shrink-0 border-border/50 border-b">
910
- <div className="flex items-center gap-3 px-4 py-2">
991
+ <div className="flex items-center gap-2 px-2 py-2 sm:gap-3 sm:px-4">
911
992
  {/* Home button - Scholarly Dusk brass accent */}
912
993
  <Button
913
994
  aria-label="Go to dashboard"
@@ -954,7 +1035,7 @@ export default function DocumentEditor({ navigate }: PageProps) {
954
1035
  <Separator className="h-5" orientation="vertical" />
955
1036
 
956
1037
  {/* Document info */}
957
- <div className="flex min-w-0 flex-1 items-center gap-2">
1038
+ <div className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden">
958
1039
  <PenIcon className="size-4 shrink-0 text-muted-foreground" />
959
1040
  <h1 className="truncate font-medium">{doc.title || doc.relPath}</h1>
960
1041
  {currentTarget.lineStart && (
@@ -968,7 +1049,7 @@ export default function DocumentEditor({ navigate }: PageProps) {
968
1049
  )}
969
1050
  {hasUnsavedChanges && (
970
1051
  <Badge
971
- className="shrink-0 bg-yellow-500/20 text-yellow-500"
1052
+ className="hidden shrink-0 bg-yellow-500/20 text-yellow-500 sm:inline-flex"
972
1053
  variant="outline"
973
1054
  >
974
1055
  Unsaved
@@ -982,6 +1063,7 @@ export default function DocumentEditor({ navigate }: PageProps) {
982
1063
  <Separator className="h-5" orientation="vertical" />
983
1064
 
984
1065
  <Button
1066
+ className="hidden sm:inline-flex"
985
1067
  onClick={() => {
986
1068
  void navigator.clipboard.writeText(
987
1069
  `${window.location.origin}${buildEditDeepLink({
@@ -999,7 +1081,7 @@ export default function DocumentEditor({ navigate }: PageProps) {
999
1081
 
1000
1082
  {/* Preview toggle */}
1001
1083
  <Select onValueChange={setInsertPresetId} value={insertPresetId}>
1002
- <SelectTrigger className="w-[180px]">
1084
+ <SelectTrigger className="hidden w-[180px] md:flex">
1003
1085
  <SelectValue placeholder="Preset" />
1004
1086
  </SelectTrigger>
1005
1087
  <SelectContent>
@@ -1012,7 +1094,12 @@ export default function DocumentEditor({ navigate }: PageProps) {
1012
1094
  )}
1013
1095
  </SelectContent>
1014
1096
  </Select>
1015
- <Button onClick={handleInsertPreset} size="sm" variant="outline">
1097
+ <Button
1098
+ className="hidden md:inline-flex"
1099
+ onClick={handleInsertPreset}
1100
+ size="sm"
1101
+ variant="outline"
1102
+ >
1016
1103
  Insert preset
1017
1104
  </Button>
1018
1105
 
@@ -1047,7 +1134,7 @@ export default function DocumentEditor({ navigate }: PageProps) {
1047
1134
  aria-label={
1048
1135
  syncScroll ? "Disable scroll sync" : "Enable scroll sync"
1049
1136
  }
1050
- className="transition-all duration-200"
1137
+ className="hidden transition-all duration-200 sm:inline-flex"
1051
1138
  onClick={() => setSyncScroll(!syncScroll)}
1052
1139
  size="sm"
1053
1140
  variant={syncScroll ? "secondary" : "ghost"}
@@ -1070,15 +1157,18 @@ export default function DocumentEditor({ navigate }: PageProps) {
1070
1157
 
1071
1158
  {/* Save button */}
1072
1159
  <Button
1160
+ aria-label="History"
1161
+ className="shrink-0"
1073
1162
  disabled={!hasLocalSnapshot}
1074
1163
  onClick={() => setHistoryDialogOpen(true)}
1075
1164
  size="sm"
1076
1165
  variant="outline"
1077
1166
  >
1078
- <HistoryIcon className="mr-1.5 size-4" />
1079
- History
1167
+ <HistoryIcon className="size-4 sm:mr-1.5" />
1168
+ <span className="hidden sm:inline">History</span>
1080
1169
  </Button>
1081
1170
  <Button
1171
+ className="shrink-0"
1082
1172
  disabled={!hasUnsavedChanges || saveStatus === "saving"}
1083
1173
  onClick={handleForceSave}
1084
1174
  size="sm"
@@ -1093,10 +1183,40 @@ export default function DocumentEditor({ navigate }: PageProps) {
1093
1183
  </div>
1094
1184
  </header>
1095
1185
 
1096
- {externalChangeNotice && (
1186
+ {saveStatus === "error" && saveError && (
1187
+ <p
1188
+ className="border-destructive/30 border-b bg-destructive/10 px-4 py-2 text-destructive text-sm sm:hidden"
1189
+ role="alert"
1190
+ >
1191
+ {saveError}
1192
+ </p>
1193
+ )}
1194
+
1195
+ {changeNotice === "unconfirmed" && (
1196
+ <div className="border-amber-500/30 border-b bg-amber-500/10 px-4 py-3">
1197
+ <div className="flex flex-wrap items-center justify-between gap-3">
1198
+ <p className="text-amber-500 text-sm">
1199
+ The save may have completed, but its response was lost. Retry to
1200
+ confirm it.
1201
+ </p>
1202
+ <Button
1203
+ disabled={saveStatus === "saving"}
1204
+ onClick={retryUnconfirmedSave}
1205
+ size="sm"
1206
+ variant="outline"
1207
+ >
1208
+ Retry save
1209
+ </Button>
1210
+ </div>
1211
+ </div>
1212
+ )}
1213
+
1214
+ {changeNotice === "outside" && (
1097
1215
  <div className="border-amber-500/30 border-b bg-amber-500/10 px-4 py-3">
1098
1216
  <div className="flex flex-wrap items-center justify-between gap-3">
1099
- <p className="text-amber-500 text-sm">{externalChangeNotice}</p>
1217
+ <p className="text-amber-500 text-sm">
1218
+ This document changed on disk. Reload before continuing.
1219
+ </p>
1100
1220
  <Button onClick={reloadDocument} size="sm" variant="outline">
1101
1221
  Reload
1102
1222
  </Button>
@@ -50,50 +50,10 @@ import {
50
50
  fetchServerCapabilities,
51
51
  type ServerCapabilities,
52
52
  } from "../lib/server-capabilities";
53
+ import { renderSnippet } from "../lib/snippet";
53
54
  import { cn } from "../lib/utils";
54
55
  import { AIModelSelector, TagFacets } from "./search-page-widgets";
55
56
 
56
- /**
57
- * Render snippet with <mark> tags as highlighted spans.
58
- * Only allows mark tags - strips all other HTML for safety.
59
- */
60
- function renderSnippet(snippet: string): React.ReactNode {
61
- const parts: React.ReactNode[] = [];
62
- let remaining = snippet;
63
- let key = 0;
64
-
65
- while (remaining.length > 0) {
66
- const markStart = remaining.indexOf("<mark>");
67
- if (markStart === -1) {
68
- parts.push(remaining);
69
- break;
70
- }
71
-
72
- if (markStart > 0) {
73
- parts.push(remaining.slice(0, markStart));
74
- }
75
-
76
- const markEnd = remaining.indexOf("</mark>", markStart);
77
- if (markEnd === -1) {
78
- parts.push(remaining.slice(markStart));
79
- break;
80
- }
81
-
82
- const highlighted = remaining.slice(markStart + 6, markEnd);
83
- parts.push(
84
- <mark
85
- className="rounded bg-primary/20 px-0.5 font-medium text-primary"
86
- key={key++}
87
- >
88
- {highlighted}
89
- </mark>
90
- );
91
- remaining = remaining.slice(markEnd + 7);
92
- }
93
-
94
- return parts;
95
- }
96
-
97
57
  interface PageProps {
98
58
  navigate: (to: string | number) => void;
99
59
  }