@gmickel/gno 2.4.0 → 2.6.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 (136) hide show
  1. package/README.md +69 -6
  2. package/assets/skill/README.md +5 -1
  3. package/assets/skill/SKILL.md +98 -1
  4. package/assets/skill/cli-reference.md +140 -0
  5. package/assets/skill/examples.md +45 -0
  6. package/assets/skill/mcp-reference.md +62 -0
  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.4.0.zip → gno-browser-clipper-v2.6.0.zip} +0 -0
  13. package/browser-extension/artifacts/gno-browser-clipper-v2.6.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 +358 -24
  17. package/spec/compiled-context.md +68 -0
  18. package/spec/mcp.md +223 -2
  19. package/spec/output-schemas/capture-receipt.schema.json +3 -0
  20. package/spec/output-schemas/compiled-context-check.schema.json +44 -0
  21. package/spec/output-schemas/compiled-context-file.schema.json +165 -0
  22. package/spec/output-schemas/compiled-context-preview.schema.json +142 -0
  23. package/spec/output-schemas/mcp-capture-result.schema.json +3 -0
  24. package/spec/output-schemas/memory-remember.schema.json +8 -2
  25. package/spec/output-schemas/request-status.schema.json +113 -0
  26. package/spec/output-schemas/sessions-automation-run.schema.json +46 -0
  27. package/spec/output-schemas/sessions-discovery.schema.json +38 -0
  28. package/spec/output-schemas/sessions-import-receipt.schema.json +156 -0
  29. package/spec/output-schemas/sessions-status.schema.json +432 -0
  30. package/src/app/compiled-context-files.ts +361 -0
  31. package/src/app/compiled-context.ts +240 -0
  32. package/src/app/context-surface.ts +7 -2
  33. package/src/cli/commands/ask.ts +14 -2
  34. package/src/cli/commands/audit.ts +4 -0
  35. package/src/cli/commands/capture.ts +55 -96
  36. package/src/cli/commands/context-compiled.ts +136 -0
  37. package/src/cli/commands/daemon.ts +41 -0
  38. package/src/cli/commands/ls.ts +3 -0
  39. package/src/cli/commands/memory.ts +12 -3
  40. package/src/cli/commands/request-status.ts +59 -0
  41. package/src/cli/commands/reset.ts +39 -5
  42. package/src/cli/commands/sessions.ts +713 -0
  43. package/src/cli/commands/shared.ts +14 -1
  44. package/src/cli/errors.ts +10 -3
  45. package/src/cli/program.ts +458 -1
  46. package/src/cli/session-binding.ts +49 -0
  47. package/src/config/types.ts +8 -0
  48. package/src/core/capture-publish.ts +239 -0
  49. package/src/core/capture-sync.ts +3 -0
  50. package/src/core/compiled-context.ts +254 -0
  51. package/src/core/context-budget.ts +2 -10
  52. package/src/core/file-lock.ts +22 -5
  53. package/src/core/folder-setup-planning.ts +2 -1
  54. package/src/core/memory-remember.ts +233 -122
  55. package/src/core/memory-types.ts +11 -0
  56. package/src/core/network-boundary-inventory.ts +16 -0
  57. package/src/core/request-receipts.ts +671 -0
  58. package/src/core/setup-receipt.ts +27 -20
  59. package/src/core/typed-metadata.ts +4 -0
  60. package/src/core/validation.ts +9 -2
  61. package/src/core/windows-private-path.ts +96 -0
  62. package/src/index.ts +11 -2
  63. package/src/ingestion/compiled-context.ts +15 -0
  64. package/src/ingestion/sync.ts +40 -8
  65. package/src/ingestion/walker.ts +5 -4
  66. package/src/llm/nodeLlamaCpp/simulator-install.ts +6 -2
  67. package/src/mcp/context.ts +8 -0
  68. package/src/mcp/http-egress.ts +15 -3
  69. package/src/mcp/http-transport.ts +2 -0
  70. package/src/mcp/retrieval-warnings.ts +24 -0
  71. package/src/mcp/tools/ask.ts +14 -1
  72. package/src/mcp/tools/capture.ts +87 -83
  73. package/src/mcp/tools/context.ts +46 -2
  74. package/src/mcp/tools/index.ts +119 -7
  75. package/src/mcp/tools/memory-remember.ts +7 -0
  76. package/src/mcp/tools/memory-shared.ts +7 -1
  77. package/src/mcp/tools/query.ts +3 -1
  78. package/src/mcp/tools/request-status.ts +73 -0
  79. package/src/mcp/tools/search.ts +3 -1
  80. package/src/mcp/tools/sessions.ts +208 -0
  81. package/src/mcp/tools/vsearch.ts +3 -1
  82. package/src/sdk/client.ts +252 -89
  83. package/src/sdk/index.ts +13 -0
  84. package/src/sdk/types.ts +82 -3
  85. package/src/serve/capture-service.ts +98 -32
  86. package/src/serve/compiled-context.ts +84 -0
  87. package/src/serve/config-sync.ts +3 -2
  88. package/src/serve/public/app.tsx +15 -1
  89. package/src/serve/public/components/CaptureModal.tsx +26 -8
  90. package/src/serve/public/components/sessions/AutomationPanel.tsx +800 -0
  91. package/src/serve/public/components/sessions/ImportReceipt.tsx +238 -0
  92. package/src/serve/public/components/sessions/SessionSearch.tsx +286 -0
  93. package/src/serve/public/components/sessions/SourcesPanel.tsx +541 -0
  94. package/src/serve/public/components/sessions/api.ts +40 -0
  95. package/src/serve/public/components/sessions/snippet.tsx +53 -0
  96. package/src/serve/public/globals.built.css +1 -1
  97. package/src/serve/public/hooks/use-api.ts +10 -2
  98. package/src/serve/public/lib/request-intent.ts +69 -0
  99. package/src/serve/public/lib/workspace-actions.ts +12 -1
  100. package/src/serve/public/lib/workspace-tabs.ts +4 -0
  101. package/src/serve/public/pages/CompiledContext.tsx +364 -0
  102. package/src/serve/public/pages/Dashboard.tsx +17 -0
  103. package/src/serve/public/pages/DocView.tsx +15 -1
  104. package/src/serve/public/pages/DocumentEditor.tsx +139 -96
  105. package/src/serve/public/pages/Sessions.tsx +350 -0
  106. package/src/serve/resident-runtime.ts +43 -3
  107. package/src/serve/routes/api.ts +476 -147
  108. package/src/serve/routes/sessions.ts +766 -0
  109. package/src/serve/security.ts +9 -0
  110. package/src/serve/server.ts +248 -3
  111. package/src/serve/session-automation.ts +146 -0
  112. package/src/serve/spa-production-build.ts +6 -5
  113. package/src/sessions/archive.ts +348 -0
  114. package/src/sessions/automation-state.ts +444 -0
  115. package/src/sessions/automation-status.ts +239 -0
  116. package/src/sessions/automation.ts +1169 -0
  117. package/src/sessions/binding.ts +105 -0
  118. package/src/sessions/claude-hook.ts +240 -0
  119. package/src/sessions/config.ts +176 -0
  120. package/src/sessions/format.ts +191 -0
  121. package/src/sessions/import-child-env.ts +8 -0
  122. package/src/sessions/import-child.ts +152 -0
  123. package/src/sessions/parsers/claude-code.ts +259 -0
  124. package/src/sessions/parsers/codex.ts +303 -0
  125. package/src/sessions/parsers/hermes.ts +248 -0
  126. package/src/sessions/parsers/openclaw.ts +496 -0
  127. package/src/sessions/parsers/shared.ts +184 -0
  128. package/src/sessions/sanitize.ts +222 -0
  129. package/src/sessions/service.ts +1533 -0
  130. package/src/sessions/setup.ts +477 -0
  131. package/src/sessions/sources.ts +518 -0
  132. package/src/sessions/state.ts +118 -0
  133. package/src/sessions/types.ts +457 -0
  134. package/src/store/sqlite/adapter.ts +68 -16
  135. package/src/store/sqlite/scoped-index.ts +9 -0
  136. package/browser-extension/artifacts/gno-browser-clipper-v2.4.0.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);
@@ -309,23 +318,44 @@ export default function DocumentEditor({ navigate }: PageProps) {
309
318
  }
310
319
  }, [syncScroll]);
311
320
 
312
- // Save function
313
- const saveDocument = useCallback(
314
- async (contentToSave: string) => {
315
- if (!doc) return;
321
+ // Autosave timers and Cmd+S run later than the render that scheduled them:
322
+ // read the current revision and last committed content from refs.
323
+ const docRef = useRef(doc);
324
+ const committedContentRef = useRef(originalContent);
325
+ useEffect(() => {
326
+ docRef.current = doc;
327
+ }, [doc]);
328
+ // One request ID per (document revision, content) save intent: retrying a
329
+ // save whose response was lost replays it instead of reporting a conflict.
330
+ const saveIntentRef = useRef<RequestIntent | null>(null);
331
+
332
+ /** Save `contentToSave`; resolves true once it is committed on disk. */
333
+ const persistContent = useCallback(
334
+ async (contentToSave: string): Promise<boolean> => {
335
+ const current = docRef.current;
336
+ if (!current) return false;
337
+ if (contentToSave === committedContentRef.current) {
338
+ setSaveStatus("saved");
339
+ return true;
340
+ }
316
341
 
317
342
  setSaveStatus("saving");
318
343
  setSaveError(null);
319
344
 
320
345
  const { data, error: err } = await apiFetch<UpdateDocResponse>(
321
- `/api/docs/${encodeURIComponent(doc.docid)}`,
346
+ `/api/docs/${encodeURIComponent(current.docid)}`,
322
347
  {
323
348
  method: "PUT",
324
349
  body: JSON.stringify({
325
350
  content: contentToSave,
326
- expectedSourceHash: doc.source.sourceHash,
327
- expectedModifiedAt: doc.source.modifiedAt,
328
- uri: doc.uri,
351
+ expectedSourceHash: current.source.sourceHash,
352
+ expectedModifiedAt: current.source.modifiedAt,
353
+ uri: current.uri,
354
+ requestId: requestIdForIntent(
355
+ saveIntentRef,
356
+ `${current.uri}\u0000${current.source.sourceHash}\u0000${contentToSave}`,
357
+ SAVE_INTENT_KEY
358
+ ),
329
359
  }),
330
360
  }
331
361
  );
@@ -333,32 +363,49 @@ export default function DocumentEditor({ navigate }: PageProps) {
333
363
  if (err) {
334
364
  setSaveStatus("error");
335
365
  setSaveError(err);
366
+ return false;
367
+ }
368
+
369
+ if (data?.request?.replayed) {
370
+ // A replay reports an earlier commit; disk may have moved on since.
371
+ // Clear the change notice only if the file still holds that commit.
372
+ const { data: latest } = await apiFetch<DocData>(
373
+ `/api/doc?uri=${encodeURIComponent(current.uri)}`
374
+ );
375
+ if (latest?.source.sourceHash === data.version.sourceHash) {
376
+ setExternalChangeNotice(null);
377
+ }
336
378
  } else {
379
+ // Written now against the loaded revision: the next change event is
380
+ // this save's own sync, and any earlier notice is stale.
337
381
  ignoreDocEventsUntilRef.current = Date.now() + 5_000;
338
- if (originalContent !== contentToSave) {
339
- appendLocalHistory(doc.docid, originalContent);
340
- refreshHistoryEntries(doc.docid);
341
- }
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
357
- );
358
- }
382
+ setExternalChangeNotice(null);
383
+ }
384
+ const previous = committedContentRef.current;
385
+ if (previous !== contentToSave) {
386
+ appendLocalHistory(current.docid, previous);
387
+ refreshHistoryEntries(current.docid);
388
+ }
389
+ clearRequestIntent(saveIntentRef, SAVE_INTENT_KEY);
390
+ committedContentRef.current = contentToSave;
391
+ setSaveStatus("saved");
392
+ setOriginalContent(contentToSave);
393
+ setLastSaved(new Date());
394
+ if (data) {
395
+ const next = {
396
+ ...current,
397
+ source: {
398
+ ...current.source,
399
+ sourceHash: data.version.sourceHash,
400
+ modifiedAt: data.version.modifiedAt,
401
+ },
402
+ };
403
+ docRef.current = next;
404
+ setDoc(next);
359
405
  }
406
+ return true;
360
407
  },
361
- [doc]
408
+ []
362
409
  );
363
410
 
364
411
  const handleCreateEditableCopy = useCallback(async () => {
@@ -476,16 +523,18 @@ export default function DocumentEditor({ navigate }: PageProps) {
476
523
  );
477
524
 
478
525
  // Debounced auto-save
479
- const { debouncedFn: debouncedSave } = useDebouncedCallback(
480
- saveDocument,
481
- 2000
482
- );
526
+ const { debouncedFn: debouncedSave, cancel: cancelAutosave } =
527
+ useDebouncedCallback(persistContent, 2000);
483
528
 
484
529
  // Handle content changes
485
530
  const handleContentChange = useCallback(
486
531
  (newContent: string) => {
487
532
  setContent(newContent);
488
- if (newContent !== originalContent) {
533
+ if (newContent === originalContent) {
534
+ // Back to the committed text: drop a pending autosave of a draft.
535
+ cancelAutosave();
536
+ setSaveStatus("saved");
537
+ } else {
489
538
  setSaveStatus("unsaved");
490
539
  debouncedSave(newContent);
491
540
  }
@@ -509,7 +558,7 @@ export default function DocumentEditor({ navigate }: PageProps) {
509
558
  setWikiLinkOpen(true);
510
559
  setWikiLinkActiveIndex(0);
511
560
  },
512
- [originalContent, debouncedSave]
561
+ [cancelAutosave, originalContent, debouncedSave]
513
562
  );
514
563
 
515
564
  useEffect(() => {
@@ -530,55 +579,15 @@ export default function DocumentEditor({ navigate }: PageProps) {
530
579
  });
531
580
  }, [doc?.collection, wikiLinkOpen, wikiLinkQuery]);
532
581
 
533
- // Force save (Cmd+S) - saves and triggers embedding
582
+ // Force save (Cmd+S) - supersedes a pending autosave, then triggers embedding
534
583
  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;
584
+ cancelAutosave();
585
+ if (!hasUnsavedChanges) return;
586
+ if (await persistContent(content)) {
587
+ // Fire and forget - don't block on result
588
+ void apiFetch("/api/embed", { method: "POST" });
558
589
  }
559
-
560
- ignoreDocEventsUntilRef.current = Date.now() + 5_000;
561
- if (originalContent !== content) {
562
- appendLocalHistory(doc.docid, originalContent);
563
- refreshHistoryEntries(doc.docid);
564
- }
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]);
590
+ }, [cancelAutosave, content, hasUnsavedChanges, persistContent]);
582
591
 
583
592
  const loadDocument = useCallback(() => {
584
593
  const uri = currentTarget.uri;
@@ -599,6 +608,7 @@ export default function DocumentEditor({ navigate }: PageProps) {
599
608
  const docContent = data.content ?? "";
600
609
  setContent(docContent);
601
610
  setOriginalContent(docContent);
611
+ committedContentRef.current = docContent;
602
612
  refreshHistoryEntries(data.docid);
603
613
  // Ensure CodeMirror reflects content after async load
604
614
  requestAnimationFrame(() => {
@@ -617,10 +627,16 @@ export default function DocumentEditor({ navigate }: PageProps) {
617
627
  loadDocument();
618
628
  }, [loadDocument]);
619
629
 
630
+ // Judge each change event once; later doc updates must not re-raise it.
631
+ const handledDocEventRef = useRef<string | null>(null);
620
632
  useEffect(() => {
621
633
  if (!doc || latestDocEvent?.uri !== doc.uri) {
622
634
  return;
623
635
  }
636
+ if (handledDocEventRef.current === latestDocEvent.changedAt) {
637
+ return;
638
+ }
639
+ handledDocEventRef.current = latestDocEvent.changedAt;
624
640
  if (Date.now() < ignoreDocEventsUntilRef.current) {
625
641
  return;
626
642
  }
@@ -752,7 +768,12 @@ export default function DocumentEditor({ navigate }: PageProps) {
752
768
 
753
769
  // Save and navigate
754
770
  const handleSaveAndNavigate = async () => {
755
- await saveDocument(content);
771
+ cancelAutosave();
772
+ // A failed save keeps the editor (and its error) instead of losing the draft.
773
+ if (!(await persistContent(content))) {
774
+ setShowUnsavedDialog(false);
775
+ return;
776
+ }
756
777
  // Trigger embedding (fire and forget)
757
778
  void apiFetch("/api/embed", { method: "POST" });
758
779
  setShowUnsavedDialog(false);
@@ -793,9 +814,13 @@ export default function DocumentEditor({ navigate }: PageProps) {
793
814
  <TooltipProvider>
794
815
  <Tooltip>
795
816
  <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">
817
+ <div
818
+ aria-label={text}
819
+ className="flex min-w-0 max-w-[16rem] shrink items-center gap-1.5 text-sm"
820
+ role="status"
821
+ >
822
+ <Icon className={`size-4 shrink-0 ${className}`} />
823
+ <span className="hidden truncate text-muted-foreground sm:inline">
799
824
  {saveStatus === "saved" && lastSaved
800
825
  ? formatTime(lastSaved)
801
826
  : text}
@@ -907,7 +932,7 @@ export default function DocumentEditor({ navigate }: PageProps) {
907
932
  <div className="flex h-screen flex-col overflow-hidden">
908
933
  {/* Toolbar */}
909
934
  <header className="glass shrink-0 border-border/50 border-b">
910
- <div className="flex items-center gap-3 px-4 py-2">
935
+ <div className="flex items-center gap-2 px-2 py-2 sm:gap-3 sm:px-4">
911
936
  {/* Home button - Scholarly Dusk brass accent */}
912
937
  <Button
913
938
  aria-label="Go to dashboard"
@@ -954,7 +979,7 @@ export default function DocumentEditor({ navigate }: PageProps) {
954
979
  <Separator className="h-5" orientation="vertical" />
955
980
 
956
981
  {/* Document info */}
957
- <div className="flex min-w-0 flex-1 items-center gap-2">
982
+ <div className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden">
958
983
  <PenIcon className="size-4 shrink-0 text-muted-foreground" />
959
984
  <h1 className="truncate font-medium">{doc.title || doc.relPath}</h1>
960
985
  {currentTarget.lineStart && (
@@ -968,7 +993,7 @@ export default function DocumentEditor({ navigate }: PageProps) {
968
993
  )}
969
994
  {hasUnsavedChanges && (
970
995
  <Badge
971
- className="shrink-0 bg-yellow-500/20 text-yellow-500"
996
+ className="hidden shrink-0 bg-yellow-500/20 text-yellow-500 sm:inline-flex"
972
997
  variant="outline"
973
998
  >
974
999
  Unsaved
@@ -982,6 +1007,7 @@ export default function DocumentEditor({ navigate }: PageProps) {
982
1007
  <Separator className="h-5" orientation="vertical" />
983
1008
 
984
1009
  <Button
1010
+ className="hidden sm:inline-flex"
985
1011
  onClick={() => {
986
1012
  void navigator.clipboard.writeText(
987
1013
  `${window.location.origin}${buildEditDeepLink({
@@ -999,7 +1025,7 @@ export default function DocumentEditor({ navigate }: PageProps) {
999
1025
 
1000
1026
  {/* Preview toggle */}
1001
1027
  <Select onValueChange={setInsertPresetId} value={insertPresetId}>
1002
- <SelectTrigger className="w-[180px]">
1028
+ <SelectTrigger className="hidden w-[180px] md:flex">
1003
1029
  <SelectValue placeholder="Preset" />
1004
1030
  </SelectTrigger>
1005
1031
  <SelectContent>
@@ -1012,7 +1038,12 @@ export default function DocumentEditor({ navigate }: PageProps) {
1012
1038
  )}
1013
1039
  </SelectContent>
1014
1040
  </Select>
1015
- <Button onClick={handleInsertPreset} size="sm" variant="outline">
1041
+ <Button
1042
+ className="hidden md:inline-flex"
1043
+ onClick={handleInsertPreset}
1044
+ size="sm"
1045
+ variant="outline"
1046
+ >
1016
1047
  Insert preset
1017
1048
  </Button>
1018
1049
 
@@ -1047,7 +1078,7 @@ export default function DocumentEditor({ navigate }: PageProps) {
1047
1078
  aria-label={
1048
1079
  syncScroll ? "Disable scroll sync" : "Enable scroll sync"
1049
1080
  }
1050
- className="transition-all duration-200"
1081
+ className="hidden transition-all duration-200 sm:inline-flex"
1051
1082
  onClick={() => setSyncScroll(!syncScroll)}
1052
1083
  size="sm"
1053
1084
  variant={syncScroll ? "secondary" : "ghost"}
@@ -1070,15 +1101,18 @@ export default function DocumentEditor({ navigate }: PageProps) {
1070
1101
 
1071
1102
  {/* Save button */}
1072
1103
  <Button
1104
+ aria-label="History"
1105
+ className="shrink-0"
1073
1106
  disabled={!hasLocalSnapshot}
1074
1107
  onClick={() => setHistoryDialogOpen(true)}
1075
1108
  size="sm"
1076
1109
  variant="outline"
1077
1110
  >
1078
- <HistoryIcon className="mr-1.5 size-4" />
1079
- History
1111
+ <HistoryIcon className="size-4 sm:mr-1.5" />
1112
+ <span className="hidden sm:inline">History</span>
1080
1113
  </Button>
1081
1114
  <Button
1115
+ className="shrink-0"
1082
1116
  disabled={!hasUnsavedChanges || saveStatus === "saving"}
1083
1117
  onClick={handleForceSave}
1084
1118
  size="sm"
@@ -1093,6 +1127,15 @@ export default function DocumentEditor({ navigate }: PageProps) {
1093
1127
  </div>
1094
1128
  </header>
1095
1129
 
1130
+ {saveStatus === "error" && saveError && (
1131
+ <p
1132
+ className="border-destructive/30 border-b bg-destructive/10 px-4 py-2 text-destructive text-sm sm:hidden"
1133
+ role="alert"
1134
+ >
1135
+ {saveError}
1136
+ </p>
1137
+ )}
1138
+
1096
1139
  {externalChangeNotice && (
1097
1140
  <div className="border-amber-500/30 border-b bg-amber-500/10 px-4 py-3">
1098
1141
  <div className="flex flex-wrap items-center justify-between gap-3">