@plannotator/ui 0.53.0 → 0.54.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 (145) hide show
  1. package/HANDOFF.md +284 -27
  2. package/README.md +84 -14
  3. package/components/AgentToolAnnouncementDialog.tsx +1 -1
  4. package/components/AgentsTab.tsx +1 -1
  5. package/components/AnnotationPanel.tsx +177 -34
  6. package/components/AnnotationSidebar.tsx +1 -1
  7. package/components/AnnotationToolbar.tsx +64 -9
  8. package/components/AnnotationToolstrip.tsx +45 -40
  9. package/components/ApproveDropdown.tsx +7 -9
  10. package/components/AskSessionAnnouncementDialog.tsx +1 -1
  11. package/components/AttachmentsButton.tsx +1 -1
  12. package/components/BlockRenderer.tsx +19 -5
  13. package/components/CodeFilePopout.tsx +140 -5
  14. package/components/CommentPopover.tsx +46 -8
  15. package/components/DecisionControl.tsx +31 -5
  16. package/components/DiagramBlock.tsx +17 -12
  17. package/components/EditorAnnotationCard.tsx +1 -1
  18. package/components/ExportModal.tsx +77 -7
  19. package/components/HostTitle.tsx +0 -4
  20. package/components/HtmlSurfaceControls.tsx +2 -1
  21. package/components/ImageAnnotator/Toolbar.tsx +25 -1
  22. package/components/ImageAnnotator/index.tsx +24 -4
  23. package/components/ImageLightbox.tsx +658 -120
  24. package/components/ImagePeek.tsx +38 -4
  25. package/components/ImageRegions.tsx +369 -0
  26. package/components/ImageThumbnail.tsx +1 -1
  27. package/components/ImageZoomStage.tsx +796 -0
  28. package/components/InboxAnnouncementDialog.tsx +202 -0
  29. package/components/InlineMarkdown.tsx +152 -24
  30. package/components/KeyboardShortcuts.tsx +23 -0
  31. package/components/LazyBlockBoundary.tsx +100 -0
  32. package/components/ListMarker.tsx +3 -3
  33. package/components/LookAndFeelAnnouncementDialog.tsx +12 -2
  34. package/components/MentionPicker.tsx +51 -0
  35. package/components/MenuVersionSection.tsx +31 -9
  36. package/components/PlanDecisionMenuExtras.tsx +129 -0
  37. package/components/PlanHeaderMenu.tsx +15 -1
  38. package/components/ProviderIcons.tsx +8 -0
  39. package/components/RefHoverCard.tsx +913 -0
  40. package/components/SendFailureToast.tsx +126 -0
  41. package/components/Settings.tsx +617 -126
  42. package/components/SkillReferenceMenu.tsx +39 -12
  43. package/components/TerminalToolsAnnouncementDialog.tsx +1 -1
  44. package/components/ThemeProvider.tsx +14 -1
  45. package/components/ThemeTab.tsx +16 -24
  46. package/components/Tooltip.tsx +49 -18
  47. package/components/Viewer.tsx +316 -47
  48. package/components/ai/DocumentAIChatPanel.tsx +29 -3
  49. package/components/ai/NewChatButton.tsx +74 -0
  50. package/components/blocks/CodeBlock.tsx +1 -1
  51. package/components/blocks/HtmlBlock.tsx +7 -1
  52. package/components/blocks/QuestionBlock.tsx +522 -62
  53. package/components/blocks/QuestionContext.tsx +3 -0
  54. package/components/diagram/DiagramComposer.tsx +5 -1
  55. package/components/diagram/DiagramPending.tsx +12 -2
  56. package/components/diagram/DiagramViewer.tsx +19 -3
  57. package/components/html-embed/HtmlEmbedBlock.tsx +374 -0
  58. package/components/html-embed/SvgFenceBlock.tsx +46 -0
  59. package/components/html-embed/index.ts +3 -0
  60. package/components/html-embed/languages.ts +15 -0
  61. package/components/html-embed/loadGate.ts +107 -0
  62. package/components/html-viewer/HtmlViewer.tsx +69 -6
  63. package/components/html-viewer/bridge-script.asset.js +504 -12
  64. package/components/html-viewer/bridge-script.lite.ts +1 -1
  65. package/components/html-viewer/bridge-script.ts +545 -12
  66. package/components/html-viewer/useHtmlAnnotation.ts +125 -0
  67. package/components/imageZoom.ts +265 -0
  68. package/components/plan-diff/PlanCleanDiffView.tsx +1 -1
  69. package/components/plan-diff/PlanDiffViewer.tsx +13 -2
  70. package/components/plan-diff/PlanRawDiffView.tsx +1 -1
  71. package/components/sidebar/FileBrowser.tsx +16 -2
  72. package/components/sidebar/SidebarTabs.tsx +3 -0
  73. package/config/index.ts +3 -0
  74. package/config/reviewView.ts +41 -1
  75. package/config/settings.ts +155 -4
  76. package/configure.ts +28 -0
  77. package/hooks/pfm/useCodeFilePopout.ts +7 -1
  78. package/hooks/useAIChat.ts +72 -25
  79. package/hooks/useAnnotationDraft.ts +71 -6
  80. package/hooks/useAnnotationHighlighter.ts +57 -3
  81. package/hooks/useCodeAnnotationDraft.ts +113 -17
  82. package/hooks/useCookieHostSetting.ts +96 -0
  83. package/hooks/useDocumentFont.ts +17 -0
  84. package/hooks/useDocumentTextSize.ts +20 -0
  85. package/hooks/useFileBrowser.ts +10 -1
  86. package/hooks/useLinkedDoc.ts +226 -13
  87. package/hooks/useMentionAutocomplete.ts +1 -1
  88. package/hooks/usePackageChangesSetting.ts +90 -0
  89. package/hooks/usePlanLookDialog.ts +67 -0
  90. package/hooks/useSharing.ts +11 -7
  91. package/hooks/useSkillReferenceAutocomplete.ts +7 -3
  92. package/hooks/useThemePaletteHalves.ts +37 -0
  93. package/hooks/useUpdateCheck.ts +22 -4
  94. package/hooks/useUpdateToast.ts +1 -1
  95. package/hooks/useVimSelection.ts +9 -0
  96. package/package.json +23 -22
  97. package/shortcuts/code-review/annotationToolbar.shortcuts.ts +1 -0
  98. package/shortcuts/code-review/explorer.shortcuts.ts +82 -0
  99. package/shortcuts/core.ts +63 -4
  100. package/shortcuts/index.ts +1 -0
  101. package/shortcuts/plan-review/annotationPanel.shortcuts.ts +1 -1
  102. package/shortcuts/plan-review/commentPopover.shortcuts.ts +1 -0
  103. package/shortcuts/runtime.ts +14 -6
  104. package/styles.css +1 -1
  105. package/theme.css +179 -1
  106. package/themes/plannotator-graphite.css +66 -0
  107. package/types.ts +26 -9
  108. package/utils/agentSwitch.ts +41 -20
  109. package/utils/agentToolSetting.ts +1 -1
  110. package/utils/aiChatFormat.ts +23 -7
  111. package/utils/annotationOwnsHighlight.ts +4 -2
  112. package/utils/asciidocParser.ts +570 -0
  113. package/utils/autoUpdateNotice.ts +24 -0
  114. package/utils/belowTitleSlot.ts +174 -0
  115. package/utils/codeLineScroll.ts +100 -0
  116. package/utils/commentSubmitKey.ts +48 -0
  117. package/utils/cssFontFamily.ts +65 -0
  118. package/utils/decisionSpec.ts +190 -2
  119. package/utils/defaultNotesApp.ts +1 -1
  120. package/utils/diffFonts.ts +4 -1
  121. package/utils/documentFont.ts +68 -0
  122. package/utils/documentTextSize.ts +54 -0
  123. package/utils/fontDetect.ts +63 -0
  124. package/utils/inboxAnnouncement.ts +78 -0
  125. package/utils/lookAndFeelAnnouncement.ts +82 -5
  126. package/utils/markdownExtensions.ts +2 -2
  127. package/utils/mentions.ts +34 -5
  128. package/utils/mermaid.ts +20 -3
  129. package/utils/mermaidTheme.ts +3 -51
  130. package/utils/notion.ts +58 -0
  131. package/utils/parser.ts +28 -1
  132. package/utils/platform.ts +46 -0
  133. package/utils/refCard.ts +82 -0
  134. package/utils/richClipboard.ts +380 -0
  135. package/utils/richClipboardLists.ts +57 -0
  136. package/utils/richCopyPolicy.ts +22 -0
  137. package/utils/scrollAnchor.ts +82 -0
  138. package/utils/sectionRefs.ts +123 -0
  139. package/utils/selectionBoundary.ts +40 -0
  140. package/utils/sendFailure.ts +85 -0
  141. package/utils/sharing.ts +5 -2
  142. package/utils/themeRegistry.ts +10 -0
  143. package/utils/themeTokens.ts +74 -0
  144. package/utils/updateNotice.ts +30 -8
  145. package/utils/vscodePanel.ts +9 -0
@@ -144,6 +144,19 @@ export interface DraftSavedFileChange {
144
144
  sourceSave: DraftSourceSaveCapability;
145
145
  }
146
146
 
147
+ /**
148
+ * A linked document's saved comments, offered with the session draft. The
149
+ * server writes this list (`documents` on GET /api/draft, plan review and
150
+ * annotate sessions on a URL or the last message: packages/shared/annotate-draft.ts);
151
+ * the hook never sends it. Each entry belongs to the document at `path`, not
152
+ * to the session's root.
153
+ */
154
+ export interface DraftDocumentCopy {
155
+ path: string;
156
+ annotations: Annotation[];
157
+ globalAttachments: ImageAttachment[];
158
+ }
159
+
147
160
  /** New format: full objects. */
148
161
  interface DraftData {
149
162
  annotations: Annotation[];
@@ -158,6 +171,8 @@ interface DraftData {
158
171
  savedFileChanges?: DraftSavedFileChange[];
159
172
  /** Client-side generation used to ignore stale saves after a draft delete. */
160
173
  draftGeneration?: number;
174
+ /** Server-written on load only: other documents' saved comments. */
175
+ documents?: unknown;
161
176
  ts: number;
162
177
  }
163
178
 
@@ -253,6 +268,28 @@ function isDraftSourceSaveCapability(value: unknown): value is DraftSourceSaveCa
253
268
  );
254
269
  }
255
270
 
271
+ /** The server's `documents` list: entries with a path and at least one comment or attachment. */
272
+ function parseDraftDocuments(value: unknown): DraftDocumentCopy[] {
273
+ if (!Array.isArray(value)) return [];
274
+ const documents: DraftDocumentCopy[] = [];
275
+ for (const entry of value) {
276
+ const doc = entry as Partial<DraftDocumentCopy> | null;
277
+ if (!doc || typeof doc.path !== 'string' || doc.path.length === 0) continue;
278
+ const annotations = Array.isArray(doc.annotations)
279
+ ? doc.annotations.filter((a): a is Annotation => !!a && typeof a === 'object' && typeof (a as Annotation).id === 'string')
280
+ : [];
281
+ const globalAttachments = Array.isArray(doc.globalAttachments)
282
+ ? doc.globalAttachments.filter((g): g is ImageAttachment => !!g && typeof g === 'object' && typeof (g as ImageAttachment).path === 'string')
283
+ : [];
284
+ if (annotations.length === 0 && globalAttachments.length === 0) continue;
285
+ documents.push({ path: doc.path, annotations, globalAttachments });
286
+ }
287
+ return documents;
288
+ }
289
+
290
+ const countDraftDocuments = (documents: DraftDocumentCopy[]): number =>
291
+ documents.reduce((total, doc) => total + doc.annotations.length + doc.globalAttachments.length, 0);
292
+
256
293
  function readDraftGeneration(value: unknown): number | null {
257
294
  return typeof value === 'number' && Number.isInteger(value) && value >= 0 ? value : null;
258
295
  }
@@ -291,17 +328,37 @@ interface RestoredDraft {
291
328
  editedMarkdown: string | null;
292
329
  editedDocuments: DraftEditedDocument[];
293
330
  savedFileChanges: DraftSavedFileChange[];
331
+ /** Other documents' saved comments, each for the document at its path.
332
+ `annotations` / `globalAttachments` above are the root document's. */
333
+ documents: DraftDocumentCopy[];
334
+ }
335
+
336
+ /** What the Draft Recovered banner says it found. */
337
+ export interface DraftBanner {
338
+ count: number;
339
+ timeAgo: string;
340
+ hasEdits: boolean;
341
+ /** How many other documents hold comments (absent: the root's alone). */
342
+ documents?: number;
343
+ /** How many of `count` are on those other documents. */
344
+ documentComments?: number;
294
345
  }
295
346
 
296
347
  interface UseAnnotationDraftResult {
297
- draftBanner: { count: number; timeAgo: string; hasEdits: boolean } | null;
348
+ /** `count` includes the other documents' comments; `documents` is how many
349
+ other documents hold some and `documentComments` how many of `count`
350
+ they hold (both absent when the draft is the root's alone). */
351
+ draftBanner: DraftBanner | null;
298
352
  restoreDraft: () => RestoredDraft;
299
353
  /** Debounced save trigger for changes the reactive deps can't see
300
354
  (editor keystrokes, edit commit/discard). Stable identity. */
301
355
  scheduleDraftSave: () => void;
302
356
  scheduleDraftSaveAfterSubmitFailure: () => void;
303
357
  getDraftGeneration: () => number;
304
- dismissDraft: () => void;
358
+ /** Discard the offered draft. Returns the other documents' comments that
359
+ were offered with it (empty for a root-only draft), so the host can
360
+ discard their copies too. */
361
+ dismissDraft: () => DraftDocumentCopy[];
305
362
  /** Send a pending (debounced) save now, if there is one. For a session
306
363
  that ends WITHOUT a decision body (the agent closed it): the comment
307
364
  typed just before must still reach the kept draft. */
@@ -319,7 +376,7 @@ export function useAnnotationDraft({
319
376
  isSharedSession,
320
377
  submitted,
321
378
  }: UseAnnotationDraftOptions): UseAnnotationDraftResult {
322
- const [draftBanner, setDraftBanner] = useState<{ count: number; timeAgo: string; hasEdits: boolean } | null>(null);
379
+ const [draftBanner, setDraftBanner] = useState<DraftBanner | null>(null);
323
380
  const draftDataRef = useRef<RestoredDraft | null>(null);
324
381
  const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
325
382
  const hasMountedRef = useRef(false);
@@ -394,8 +451,10 @@ export function useAnnotationDraft({
394
451
  !isLegacyDraft(data) && Array.isArray((data as DraftData).savedFileChanges)
395
452
  ? (data as DraftData).savedFileChanges!.filter(isDraftSavedFileChange)
396
453
  : [];
454
+ const restoredDocuments = !isLegacyDraft(data) ? parseDraftDocuments((data as DraftData).documents) : [];
397
455
 
398
- const totalCount = restoredAnnotations.length + restoredCodeAnnotations.length + restoredGlobal.length;
456
+ const totalCount =
457
+ restoredAnnotations.length + restoredCodeAnnotations.length + restoredGlobal.length + countDraftDocuments(restoredDocuments);
399
458
  if (totalCount > 0 || restoredEdited !== null || restoredEditedDocuments.length > 0 || restoredSavedFileChanges.length > 0) {
400
459
  draftDataRef.current = {
401
460
  annotations: restoredAnnotations,
@@ -404,11 +463,15 @@ export function useAnnotationDraft({
404
463
  editedMarkdown: restoredEdited,
405
464
  editedDocuments: restoredEditedDocuments,
406
465
  savedFileChanges: restoredSavedFileChanges,
466
+ documents: restoredDocuments,
407
467
  };
408
468
  setDraftBanner({
409
469
  count: totalCount,
410
470
  timeAgo: formatTimeAgo(data.ts || 0),
411
471
  hasEdits: restoredEdited !== null || restoredEditedDocuments.length > 0 || restoredSavedFileChanges.length > 0,
472
+ ...(restoredDocuments.length > 0
473
+ ? { documents: restoredDocuments.length, documentComments: countDraftDocuments(restoredDocuments) }
474
+ : {}),
412
475
  });
413
476
  }
414
477
  hasMountedRef.current = true;
@@ -547,12 +610,13 @@ export function useAnnotationDraft({
547
610
  setDraftBanner(null);
548
611
  draftDataRef.current = null;
549
612
 
550
- if (!data) return { annotations: [], codeAnnotations: [], globalAttachments: [], editedMarkdown: null, editedDocuments: [], savedFileChanges: [] };
613
+ if (!data) return { annotations: [], codeAnnotations: [], globalAttachments: [], editedMarkdown: null, editedDocuments: [], savedFileChanges: [], documents: [] };
551
614
 
552
615
  return data;
553
616
  }, []);
554
617
 
555
- const dismissDraft = useCallback(() => {
618
+ const dismissDraft = useCallback((): DraftDocumentCopy[] => {
619
+ const offeredDocuments = draftDataRef.current?.documents ?? [];
556
620
  const deletedGeneration = draftGenerationRef.current + 1;
557
621
  draftGenerationRef.current = deletedGeneration;
558
622
  if (timerRef.current) {
@@ -563,6 +627,7 @@ export function useAnnotationDraft({
563
627
  draftDataRef.current = null;
564
628
 
565
629
  getDraftTransport().remove(deletedGeneration, { keepalive: false }).catch(() => {});
630
+ return offeredDocuments;
566
631
  }, []);
567
632
 
568
633
  return { draftBanner, restoreDraft, scheduleDraftSave, scheduleDraftSaveAfterSubmitFailure, getDraftGeneration, dismissDraft, flushPendingSave };
@@ -8,11 +8,17 @@
8
8
  import { useEffect, useRef, useState, useCallback, type RefObject } from 'react';
9
9
  import Highlighter from '@plannotator/web-highlighter';
10
10
  import type { Annotation, EditorMode, HostDraft, ImageAttachment } from '../types';
11
+ import { metaWithBelowTitle, metaWithoutBelowTitle, snapRangeOutOfBelowTitle } from '../utils/belowTitleSlot';
11
12
  import { AnnotationType } from '../types';
12
13
  import type { QuickLabel } from '../utils/quickLabels';
14
+ import {
15
+ trimWhitespaceOnlyBoundaryNodes,
16
+ type SelectedNodeLike,
17
+ } from '../utils/selectionBoundary';
13
18
  import { getIdentity } from '../utils/identity';
14
19
  import { annotationOwnsHighlight } from '../utils/annotationOwnsHighlight';
15
20
  import { transformPlainText } from '../utils/inlineTransforms';
21
+ import { richCopySourceFromRange, type RichCopySource } from '../utils/richClipboard';
16
22
 
17
23
  // --- Exported state types ---
18
24
 
@@ -20,6 +26,9 @@ export interface ToolbarState {
20
26
  element: HTMLElement;
21
27
  source: any;
22
28
  selectionText: string;
29
+ /** Builds the selection's rich-text copy when the toolbar button is pressed
30
+ * (synchronous in the markdown Viewer, a bridge round trip on HTML surfaces). */
31
+ getRichCopy?: () => RichCopySource | Promise<RichCopySource>;
23
32
  }
24
33
 
25
34
  export interface CommentPopoverState {
@@ -1248,6 +1257,10 @@ export function useAnnotationHighlighter({
1248
1257
  // verdict, so it is neither attempted nor unanchored here (the same
1249
1258
  // rule the raw-HTML pinpoints follow on their surface).
1250
1259
  if (ann.diagramAnchor) return;
1260
+ // A comment on part of an image has no text either: the image viewer
1261
+ // draws its box, and the Viewer reports it unanchored when its image is
1262
+ // gone from the document.
1263
+ if (ann.imageRegion != null) return;
1251
1264
  // An answer to a `:::question` block quotes the prompt but is not a
1252
1265
  // selection: the question block draws it from the annotation, so it is
1253
1266
  // never painted as a highlight (and never reported as unanchored here).
@@ -1286,7 +1299,12 @@ export function useAnnotationHighlighter({
1286
1299
 
1287
1300
  if (ann.startMeta && ann.endMeta) {
1288
1301
  try {
1289
- highlighter.fromStore(ann.startMeta, ann.endMeta, ann.originalText, ann.id);
1302
+ highlighter.fromStore(
1303
+ metaWithBelowTitle(containerRef.current, ann.startMeta),
1304
+ metaWithBelowTitle(containerRef.current, ann.endMeta),
1305
+ ann.originalText,
1306
+ ann.id,
1307
+ );
1290
1308
  const restoredDoms = highlighter.getDoms(ann.id);
1291
1309
  if (restoredDoms && restoredDoms.length > 0) {
1292
1310
  const restoredText = paintedTextOf(restoredDoms as HTMLElement[]);
@@ -1480,6 +1498,15 @@ export function useAnnotationHighlighter({
1480
1498
  style: { className: 'annotation-highlight' },
1481
1499
  });
1482
1500
 
1501
+ // Chromium can extend a triple-clicked line into empty or indentation nodes of the next block.
1502
+ // Trim only boundary whitespace so spacing inside genuine multi-node selections stays highlighted.
1503
+ // The hook's callback type is `(...args: unknown[]) => SelectedNode[]`; a SelectedNode
1504
+ // structurally satisfies SelectedNodeLike, so the trimmed subset goes back through the
1505
+ // hook's own callback type rather than importing SelectedNode from the package's dist/.
1506
+ type SelectedNodesTap = Parameters<typeof highlighter.hooks.Render.SelectedNodes.tap>[0];
1507
+ highlighter.hooks.Render.SelectedNodes.tap(((...args: unknown[]) =>
1508
+ trimWhitespaceOnlyBoundaryNodes(args[1] as SelectedNodeLike[])) as SelectedNodesTap);
1509
+
1483
1510
  highlighterRef.current = highlighter;
1484
1511
 
1485
1512
  // Stored positions can resolve into chrome the reviewer could never have
@@ -1499,6 +1526,15 @@ export function useAnnotationHighlighter({
1499
1526
 
1500
1527
  highlighter.on(Highlighter.event.CREATE, ({ sources, type }: { sources: any[]; type?: string }) => {
1501
1528
  if (type === 'from-store') return;
1529
+ // Store positions as if a host's `belowTitle` slot were not there, so
1530
+ // its contents never renumber a comment's parent element (identity
1531
+ // when the document has no slot). See utils/belowTitleSlot.
1532
+ for (const created of sources) {
1533
+ const startMeta = metaWithoutBelowTitle(containerRef.current, created.startMeta, 'start');
1534
+ const endMeta = metaWithoutBelowTitle(containerRef.current, created.endMeta, 'end');
1535
+ if (startMeta !== created.startMeta) created.startMeta = startMeta;
1536
+ if (endMeta !== created.endMeta) created.endMeta = endMeta;
1537
+ }
1502
1538
  if (sources.length > 0) {
1503
1539
  const source = sources[0];
1504
1540
  const doms = highlighter.getDoms(source.id);
@@ -1561,10 +1597,24 @@ export function useAnnotationHighlighter({
1561
1597
  } else {
1562
1598
  // Selection mode — show toolbar
1563
1599
  pendingSourceRef.current = source;
1600
+ const marks = [...doms] as HTMLElement[];
1601
+ const selectionText = source.text;
1564
1602
  setToolbarState({
1565
1603
  element: doms[0] as HTMLElement,
1566
1604
  source,
1567
- selectionText: source.text,
1605
+ selectionText,
1606
+ // Built only when Copy (rich text) is pressed: the pending
1607
+ // highlight's marks still bound the selected words, and the
1608
+ // sanitizer drops the marks themselves.
1609
+ getRichCopy: () => {
1610
+ const first = marks[0];
1611
+ const last = marks[marks.length - 1];
1612
+ if (!first?.isConnected || !last?.isConnected) return null;
1613
+ const range = document.createRange();
1614
+ range.setStartBefore(first);
1615
+ range.setEndAfter(last);
1616
+ return richCopySourceFromRange(range, selectionText);
1617
+ },
1568
1618
  });
1569
1619
  }
1570
1620
  }
@@ -1609,7 +1659,9 @@ export function useAnnotationHighlighter({
1609
1659
  pendingRangeRunsRef.current = null;
1610
1660
  return;
1611
1661
  }
1612
- if (snapRangeStartPastExcluded(range)) {
1662
+ const snappedOutOfSlot = snapRangeOutOfBelowTitle(container, range);
1663
+ const snappedPastExcluded = snapRangeStartPastExcluded(range);
1664
+ if (snappedOutOfSlot || snappedPastExcluded) {
1613
1665
  selection.removeAllRanges();
1614
1666
  selection.addRange(range);
1615
1667
  }
@@ -1724,6 +1776,7 @@ export function useAnnotationHighlighter({
1724
1776
  // Same fail-closed rule as the pointer-end guard (#881): touch
1725
1777
  // selections settle on block boundaries just as often.
1726
1778
  if (isBlankQuote(sel.toString()) || !isSerializableRange(range)) return false;
1779
+ snapRangeOutOfBelowTitle(containerRef.current, range);
1727
1780
  snapRangeStartPastExcluded(range);
1728
1781
  pendingRangeRunsRef.current = rangeTextPieces(range);
1729
1782
  try {
@@ -1776,6 +1829,7 @@ export function useAnnotationHighlighter({
1776
1829
  // visually hidden type word; snap off it before painting (the caller's own
1777
1830
  // range is left untouched).
1778
1831
  const painted = range.cloneRange();
1832
+ snapRangeOutOfBelowTitle(container, painted);
1779
1833
  snapRangeStartPastExcluded(painted);
1780
1834
  pendingRangeRunsRef.current = rangeTextPieces(painted);
1781
1835
 
@@ -7,12 +7,18 @@
7
7
 
8
8
  import { useState, useEffect, useCallback, useRef } from 'react';
9
9
  import type { CodeAnnotation, Annotation, CommentAnnotation } from '../types';
10
- import { getDraftTransport } from './useAnnotationDraft';
10
+ import { getDraftTransport, type DraftTransport } from './useAnnotationDraft';
11
11
 
12
12
  const DEBOUNCE_MS = 500;
13
13
  /** Bound on one read of a switched-onto target's draft (#1590). */
14
14
  export const TARGET_LOAD_TIMEOUT_MS = 5000;
15
15
 
16
+ /** A target-bound transport refuses to write into a different review. The host
17
+ * must refresh/adopt a target before saving can resume; retrying cannot fix it. */
18
+ export class DraftTargetChangedError extends Error {
19
+ constructor() { super('Review draft target changed'); }
20
+ }
21
+
16
22
  function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
17
23
  return new Promise<T>((resolve, reject) => {
18
24
  const timer = setTimeout(() => reject(new Error('draft load timed out')), ms);
@@ -41,8 +47,8 @@ interface DraftData {
41
47
  autoViewSuppressed?: string[];
42
48
  draftGeneration?: number;
43
49
  /**
44
- * Set by the review server (never by the client) when a PR draft is served
45
- * through the PR's target key for a patch different from the one it was
50
+ * Set by the review server (never by the client) when a draft is served
51
+ * through its target key for a patch different from the one it was
46
52
  * saved on (#1590). The host re-checks line comments' anchors on restore.
47
53
  */
48
54
  patchChanged?: boolean;
@@ -65,6 +71,8 @@ function formatTimeAgo(ts: number): string {
65
71
  }
66
72
 
67
73
  interface UseCodeAnnotationDraftOptions {
74
+ /** Optional target-bound transport; the configured host transport is the default. */
75
+ transport?: DraftTransport;
68
76
  annotations: CodeAnnotation[];
69
77
  descriptionAnnotations?: Annotation[];
70
78
  commentAnnotations?: CommentAnnotation[];
@@ -101,6 +109,10 @@ export interface CodeDraftTargetState {
101
109
  }
102
110
 
103
111
  interface UseCodeAnnotationDraftResult {
112
+ /** Flush pending edits, then hold autosave until adoptDraftTarget resumes it. */
113
+ flushBeforeSwitch: () => Promise<void>;
114
+ /** Keep unsaved annotations in memory and offer an in-place refresh. */
115
+ draftTargetChanged: boolean;
104
116
  draftBanner: { count: number; viewedCount: number; timeAgo: string } | null;
105
117
  /** `patchChanged` is true when the server served the draft for a patch other
106
118
  * than the one it was saved on (the host re-checks line anchors). */
@@ -114,10 +126,11 @@ interface UseCodeAnnotationDraftResult {
114
126
  * that one load settles (success, failure, or a newer switch) autosave does
115
127
  * not write under the new target, so the switch cannot overwrite a draft
116
128
  * it has not read yet; nothing else ever waits. */
117
- adoptDraftTarget: (state: CodeDraftTargetState | undefined) => void;
129
+ adoptDraftTarget: (state: CodeDraftTargetState | undefined, transport?: DraftTransport) => void;
118
130
  }
119
131
 
120
132
  export function useCodeAnnotationDraft({
133
+ transport: transportOverride,
121
134
  annotations,
122
135
  descriptionAnnotations = [],
123
136
  commentAnnotations = [],
@@ -129,6 +142,14 @@ export function useCodeAnnotationDraft({
129
142
  targetLoadTimeoutMs = TARGET_LOAD_TIMEOUT_MS,
130
143
  persistViewedFiles = true,
131
144
  }: UseCodeAnnotationDraftOptions): UseCodeAnnotationDraftResult {
145
+ const transport = transportOverride ?? getDraftTransport();
146
+ const transportRef = useRef(transport);
147
+ transportRef.current = transport;
148
+ const pendingSaveRef = useRef<(() => Promise<void>) | null>(null);
149
+ const savingRef = useRef<Promise<void> | null>(null);
150
+ const switchPausedRef = useRef(false);
151
+ const [draftTargetChanged, setDraftTargetChanged] = useState(false);
152
+ const targetChangedRef = useRef(false);
132
153
  const [draftBanner, setDraftBanner] = useState<{ count: number; viewedCount: number; timeAgo: string } | null>(null);
133
154
  const draftDataRef = useRef<DraftData | null>(null);
134
155
  const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -147,6 +168,14 @@ export function useCodeAnnotationDraft({
147
168
  // would overwrite a draft it has not merged yet) and remembers that it did,
148
169
  // so settling — success, failure, or supersede — always resumes saving.
149
170
  const switchSeqRef = useRef(0);
171
+ const reportTargetChanged = (error: unknown, seq: number): boolean => {
172
+ if (!(error instanceof DraftTargetChangedError)) return false;
173
+ if (seq === switchSeqRef.current) {
174
+ targetChangedRef.current = true;
175
+ setDraftTargetChanged(true);
176
+ }
177
+ return true;
178
+ };
150
179
  const awaitingTargetLoadRef = useRef(false);
151
180
  const skippedWhileAwaitingRef = useRef(false);
152
181
  // The switched-onto target's draft could not be read (both attempts failed
@@ -182,15 +211,19 @@ export function useCodeAnnotationDraft({
182
211
  // Load draft on mount
183
212
  useEffect(() => {
184
213
  if (!isApiMode) return;
214
+ const seq = switchSeqRef.current;
215
+ let cancelled = false;
185
216
 
186
- getDraftTransport().load()
217
+ transportRef.current.load()
187
218
  .then(({ data, generation }) => {
219
+ if (cancelled || seq !== switchSeqRef.current) return null;
188
220
  if (generation !== null) {
189
221
  draftGenerationRef.current = Math.max(draftGenerationRef.current, generation);
190
222
  }
191
223
  return data as DraftData | null;
192
224
  })
193
225
  .then((data: DraftData | null) => {
226
+ if (cancelled || seq !== switchSeqRef.current) return;
194
227
  // Keep even a viewed-only draft while independent progress support is
195
228
  // unresolved; a failed/unsupported load may need to offer it later.
196
229
  draftDataRef.current = data;
@@ -212,9 +245,13 @@ export function useCodeAnnotationDraft({
212
245
  }
213
246
  hasMountedRef.current = true;
214
247
  })
215
- .catch(() => {
216
- hasMountedRef.current = true;
248
+ .catch((error) => {
249
+ if (!cancelled && seq === switchSeqRef.current) {
250
+ reportTargetChanged(error, seq);
251
+ hasMountedRef.current = true;
252
+ }
217
253
  });
254
+ return () => { cancelled = true; };
218
255
  }, [isApiMode]);
219
256
 
220
257
  useEffect(() => {
@@ -248,7 +285,9 @@ export function useCodeAnnotationDraft({
248
285
 
249
286
  if (timerRef.current) clearTimeout(timerRef.current);
250
287
 
251
- timerRef.current = setTimeout(() => {
288
+ const seq = switchSeqRef.current;
289
+ const persist = async () => {
290
+ if (targetChangedRef.current) return;
252
291
  // The switched-onto target's draft is still loading: writing now would
253
292
  // overwrite it unread. Settling the load re-runs this effect.
254
293
  if (awaitingTargetLoadRef.current) {
@@ -268,7 +307,7 @@ export function useCodeAnnotationDraft({
268
307
  // tombstone so it can't resurface on refresh and a late save can't revive
269
308
  // it. Mirrors useAnnotationDraft.persistNow — routed through the draft
270
309
  // transport seam so a host backend tombstones its own stored draft too.
271
- getDraftTransport().remove(draftGeneration, { keepalive: false }).catch(() => {});
310
+ await transport.remove(draftGeneration, { keepalive: false });
272
311
  return;
273
312
  }
274
313
 
@@ -284,18 +323,48 @@ export function useCodeAnnotationDraft({
284
323
  ts: Date.now(),
285
324
  };
286
325
 
287
- getDraftTransport().save(payload, { keepalive: false }).catch(() => {});
326
+ await transport.save(payload, { keepalive: false });
327
+ };
328
+ const save = async () => {
329
+ try { await persist(); } catch (error) {
330
+ reportTargetChanged(error, seq);
331
+ throw error;
332
+ }
333
+ };
334
+ pendingSaveRef.current = save;
335
+ timerRef.current = setTimeout(() => {
336
+ if (switchPausedRef.current) return;
337
+ pendingSaveRef.current = null;
338
+ savingRef.current = save();
339
+ savingRef.current.catch(() => {});
288
340
  }, DEBOUNCE_MS);
289
341
 
290
342
  return () => {
291
343
  if (timerRef.current) clearTimeout(timerRef.current);
344
+ pendingSaveRef.current = null;
292
345
  };
293
- }, [annotations, descriptionAnnotations, commentAnnotations, viewedFiles, autoViewSuppressed, isApiMode, submitted, saveNudge, persistViewedFiles]);
346
+ }, [annotations, descriptionAnnotations, commentAnnotations, viewedFiles, autoViewSuppressed, isApiMode, submitted, saveNudge, persistViewedFiles, transport]);
347
+
348
+ const flushBeforeSwitch = useCallback(async () => {
349
+ switchPausedRef.current = true;
350
+ if (timerRef.current) clearTimeout(timerRef.current);
351
+ try {
352
+ // Persistence is best-effort: a rejected earlier write must neither
353
+ // block navigation nor skip a newer edit still waiting on debounce.
354
+ await savingRef.current?.catch(() => {});
355
+ const pending = pendingSaveRef.current;
356
+ pendingSaveRef.current = null;
357
+ if (pending) await pending().catch(() => {});
358
+ } finally {
359
+ savingRef.current = null;
360
+ }
361
+ }, []);
294
362
 
295
363
  const restoreDraft = useCallback(() => {
296
364
  // Cancel any pending autosave so it can't fire with pre-restore state and
297
365
  // overwrite what we're about to restore.
298
366
  if (timerRef.current) clearTimeout(timerRef.current);
367
+ pendingSaveRef.current = null;
299
368
  const data = draftDataRef.current;
300
369
  setDraftBanner(null);
301
370
  draftDataRef.current = null;
@@ -315,19 +384,43 @@ export function useCodeAnnotationDraft({
315
384
  // Cancel any pending autosave so a late save can't revive the draft the user
316
385
  // just dismissed.
317
386
  if (timerRef.current) clearTimeout(timerRef.current);
387
+ pendingSaveRef.current = null;
318
388
  const deletedGeneration = draftGenerationRef.current + 1;
319
389
  draftGenerationRef.current = deletedGeneration;
320
390
  setDraftBanner(null);
321
391
  draftDataRef.current = null;
322
- getDraftTransport().remove(deletedGeneration, { keepalive: false }).catch(() => {});
392
+ const seq = switchSeqRef.current;
393
+ transportRef.current.remove(deletedGeneration, { keepalive: false }).catch(error => {
394
+ reportTargetChanged(error, seq);
395
+ });
323
396
  }, []);
324
397
 
325
398
  // Unmount supersedes any in-flight target read, so a retry never fires
326
399
  // after the page (or a test host) is gone.
327
400
  useEffect(() => () => { switchSeqRef.current += 1; }, []);
328
401
 
329
- const adoptDraftTarget = useCallback((state: CodeDraftTargetState | undefined) => {
402
+ const adoptDraftTarget = useCallback((state: CodeDraftTargetState | undefined, nextTransport?: DraftTransport) => {
330
403
  if (!isApiMode) return;
404
+ // undefined also resumes a FAILED switch, which has not repaired a
405
+ // target mismatch. Only an applied server response may clear the notice.
406
+ if (state) {
407
+ targetChangedRef.current = false;
408
+ setDraftTargetChanged(false);
409
+ }
410
+ if (switchPausedRef.current) {
411
+ switchPausedRef.current = false;
412
+ setSaveNudge(n => n + 1);
413
+ }
414
+ // A failed/superseded request did not adopt another target. Its current
415
+ // draft may still be loading (or unreadable); retain that write protection.
416
+ if (!state) return;
417
+ // An applied switch supersedes the page-load read (its seq guard drops
418
+ // the result), so it must open autosave itself. A failed/superseded
419
+ // switch must not: the page-load read may still be in flight, and
420
+ // saving before it settles could overwrite a draft the banner has not
421
+ // offered yet.
422
+ hasMountedRef.current = true;
423
+ const targetTransport = nextTransport ?? transportRef.current;
331
424
  // Every switch starts clean: a previous switch's load (if still in
332
425
  // flight) is superseded, an unreadable-target block is lifted, and any
333
426
  // write either of them held back resumes (under the new target).
@@ -341,7 +434,6 @@ export function useCodeAnnotationDraft({
341
434
  awaitingTargetLoadRef.current = false;
342
435
  targetUnreadableRef.current = false;
343
436
  retryTargetLoadRef.current = null;
344
- if (!state) { resumeHeldWrite(); return; }
345
437
  const floor = readDraftGeneration(state.draftGeneration);
346
438
  if (floor !== null) draftGenerationRef.current = Math.max(draftGenerationRef.current, floor);
347
439
  if (!state.found) { resumeHeldWrite(); return; }
@@ -381,7 +473,7 @@ export function useCodeAnnotationDraft({
381
473
  awaitingTargetLoadRef.current = true;
382
474
  const attempt = (remaining: number): void => {
383
475
  if (switchSeqRef.current !== seq) return; // superseded or unmounted
384
- withTimeout(getDraftTransport().load(), loadTimeoutRef.current)
476
+ withTimeout(targetTransport.load(), loadTimeoutRef.current)
385
477
  .then(({ data, generation }) => {
386
478
  if (switchSeqRef.current !== seq) return; // a newer switch owns the keys now
387
479
  // A debounced save armed before the read settled captured the
@@ -396,8 +488,12 @@ export function useCodeAnnotationDraft({
396
488
  targetUnreadableRef.current = false;
397
489
  resumeHeldWrite();
398
490
  })
399
- .catch(() => {
491
+ .catch((error) => {
400
492
  if (switchSeqRef.current !== seq) return;
493
+ if (reportTargetChanged(error, seq)) {
494
+ awaitingTargetLoadRef.current = false;
495
+ return;
496
+ }
401
497
  if (remaining > 0) { attempt(remaining - 1); return; }
402
498
  // Still unreadable: never overwrite it blind. Autosave keeps
403
499
  // running for the session and retries this read on its next
@@ -412,5 +508,5 @@ export function useCodeAnnotationDraft({
412
508
  read();
413
509
  }, [isApiMode]);
414
510
 
415
- return { draftBanner, restoreDraft, getDraftGeneration, dismissDraft, adoptDraftTarget };
511
+ return { draftBanner, draftTargetChanged, restoreDraft, getDraftGeneration, dismissDraft, adoptDraftTarget, flushBeforeSwitch };
416
512
  }
@@ -0,0 +1,96 @@
1
+ import { useCallback, useState } from 'react';
2
+
3
+ /**
4
+ * "Open Plannotator at plannotator.localhost" (#2184) for Settings → General: on
5
+ * writes `urlHost: "plannotator.localhost"` to config.json, off removes it.
6
+ * The server sends it as `serverConfig.cookieHost` only for a local session
7
+ * (never remote mode or `--tailscale`), and writes it with POST /api/config.
8
+ * Deliberately not a settings-registry entry: the registry is bundled into
9
+ * the portable guides.show viewer, whose pinned build must not change.
10
+ */
11
+ export interface CookieHostSetting {
12
+ on: boolean;
13
+ /** PLANNOTATOR_URL_HOST is set (its value; "" when set empty): the switch is locked. */
14
+ env?: string;
15
+ /** config.json holds another `urlHost` (a tailnet name, `auto`, an IP): locked, never replaced. */
16
+ urlHost?: string;
17
+ /** The last save failed: the reason, shown under the switch. */
18
+ error?: string;
19
+ onChange: (next: boolean) => void;
20
+ }
21
+
22
+ /**
23
+ * The cookie switch's one sub line: what it does, or why the switch is
24
+ * locked. `inbox` adds that the Inbox's notifications need allowing once at
25
+ * the new address.
26
+ */
27
+ export function cookieHostDescription(
28
+ setting: Pick<CookieHostSetting, 'on' | 'env' | 'urlHost'>,
29
+ options: { inbox?: boolean } = {},
30
+ ): string {
31
+ if (setting.env !== undefined) {
32
+ return setting.env.trim() === ''
33
+ ? 'Set by PLANNOTATOR_URL_HOST in your environment (empty).'
34
+ : `Set by PLANNOTATOR_URL_HOST=${setting.env.trim()} in your environment.`;
35
+ }
36
+ if (setting.urlHost !== undefined) return `config.json sets urlHost to ${setting.urlHost}; this switch never replaces it.`;
37
+ const notifications = options.inbox ? ' Notifications need allowing once there.' : '';
38
+ return `Keeps Plannotator's cookies away from your other localhost apps. Starts from the next session; this browser's preferences start fresh once.${notifications}`;
39
+ }
40
+
41
+ interface CookieHostState {
42
+ on: boolean;
43
+ env?: string;
44
+ urlHost?: string;
45
+ }
46
+
47
+ function parseState(value: unknown): CookieHostState | undefined {
48
+ if (!value || typeof value !== 'object') return undefined;
49
+ const v = value as Record<string, unknown>;
50
+ if (typeof v.on !== 'boolean') return undefined;
51
+ return {
52
+ on: v.on,
53
+ ...(typeof v.env === 'string' && { env: v.env }),
54
+ ...(typeof v.urlHost === 'string' && { urlHost: v.urlHost }),
55
+ };
56
+ }
57
+
58
+ export function useCookieHostSetting(): {
59
+ setting: CookieHostSetting | undefined;
60
+ adopt: (serverConfig: Record<string, unknown> | undefined) => void;
61
+ } {
62
+ const [state, setState] = useState<CookieHostState | undefined>();
63
+ const [error, setError] = useState<string | undefined>();
64
+
65
+ const adopt = useCallback((serverConfig: Record<string, unknown> | undefined) => {
66
+ setState(parseState(serverConfig?.cookieHost));
67
+ }, []);
68
+
69
+ const onChange = useCallback((next: boolean) => {
70
+ setError(undefined);
71
+ setState((current) => (current ? { ...current, on: next } : current));
72
+ void fetch('/api/config', {
73
+ method: 'POST',
74
+ headers: { 'Content-Type': 'application/json' },
75
+ body: JSON.stringify({ cookieHost: next }),
76
+ })
77
+ .then(async (res) => {
78
+ const body = (await res.json().catch(() => ({}))) as { error?: unknown; cookieHost?: unknown };
79
+ const saved = parseState(body.cookieHost);
80
+ if (saved) setState(saved);
81
+ if (!res.ok) {
82
+ if (!saved) setState((current) => (current ? { ...current, on: !next } : current));
83
+ setError(typeof body.error === 'string' ? body.error : 'The setting could not be saved.');
84
+ }
85
+ })
86
+ .catch(() => {
87
+ setState((current) => (current ? { ...current, on: !next } : current));
88
+ setError('The setting could not be saved.');
89
+ });
90
+ }, []);
91
+
92
+ return {
93
+ setting: state ? { ...state, ...(error && { error }), onChange } : undefined,
94
+ adopt,
95
+ };
96
+ }