@plannotator/ui 0.53.1 → 0.55.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 (149) hide show
  1. package/HANDOFF.md +274 -14
  2. package/README.md +114 -11
  3. package/components/AgentToolAnnouncementDialog.tsx +1 -1
  4. package/components/AgentsTab.tsx +1 -1
  5. package/components/AnnotationPanel.tsx +142 -24
  6. package/components/AnnotationSidebar.tsx +1 -1
  7. package/components/AnnotationToolbar.tsx +64 -9
  8. package/components/ApproveDropdown.tsx +7 -9
  9. package/components/AskSessionAnnouncementDialog.tsx +1 -1
  10. package/components/AttachmentsButton.tsx +1 -1
  11. package/components/BlockRenderer.tsx +22 -5
  12. package/components/CodeFilePopout.tsx +139 -4
  13. package/components/CodeSnippetPreview.tsx +174 -0
  14. package/components/CommentPopover.tsx +44 -8
  15. package/components/DecisionControl.tsx +31 -5
  16. package/components/DeferredMarkdownContentsRail.tsx +38 -0
  17. package/components/DiagramBlock.tsx +17 -12
  18. package/components/DiffFileTree.tsx +7 -6
  19. package/components/DocumentContents.tsx +74 -0
  20. package/components/EditorAnnotationCard.tsx +1 -1
  21. package/components/ExportModal.tsx +77 -7
  22. package/components/HostTitle.tsx +0 -4
  23. package/components/ImageAnnotator/Toolbar.tsx +25 -1
  24. package/components/ImageAnnotator/index.tsx +24 -4
  25. package/components/ImageLightbox.tsx +722 -126
  26. package/components/ImagePeek.tsx +38 -4
  27. package/components/ImageRegions.tsx +369 -0
  28. package/components/ImageThumbnail.tsx +1 -1
  29. package/components/ImageZoomStage.tsx +796 -0
  30. package/components/InboxAnnouncementDialog.tsx +202 -0
  31. package/components/InlineMarkdown.tsx +167 -174
  32. package/components/KeyboardShortcuts.tsx +23 -0
  33. package/components/LazyBlockBoundary.tsx +100 -0
  34. package/components/ListMarker.tsx +3 -3
  35. package/components/LookAndFeelAnnouncementDialog.tsx +12 -2
  36. package/components/MarkdownContentsRail.tsx +777 -0
  37. package/components/MenuVersionSection.tsx +31 -9
  38. package/components/PlanDecisionMenuExtras.tsx +129 -0
  39. package/components/PlanHeaderMenu.tsx +15 -1
  40. package/components/ProviderIcons.tsx +8 -0
  41. package/components/RefHoverCard.tsx +913 -0
  42. package/components/SendFailureToast.tsx +126 -0
  43. package/components/Settings.tsx +617 -126
  44. package/components/TableOfContents.tsx +20 -6
  45. package/components/TerminalToolsAnnouncementDialog.tsx +1 -1
  46. package/components/ThemeProvider.tsx +14 -1
  47. package/components/ThemeTab.tsx +16 -24
  48. package/components/Tooltip.tsx +49 -18
  49. package/components/Viewer.tsx +288 -43
  50. package/components/ai/DocumentAIChatPanel.tsx +29 -3
  51. package/components/ai/NewChatButton.tsx +74 -0
  52. package/components/blocks/CodeBlock.tsx +1 -1
  53. package/components/blocks/HtmlBlock.tsx +7 -1
  54. package/components/blocks/QuestionBlock.tsx +532 -62
  55. package/components/diagram/DiagramComposer.tsx +5 -1
  56. package/components/diagram/DiagramPending.tsx +12 -2
  57. package/components/diagram/DiagramViewer.tsx +19 -3
  58. package/components/html-embed/HtmlEmbedBlock.tsx +374 -0
  59. package/components/html-embed/SvgFenceBlock.tsx +46 -0
  60. package/components/html-embed/index.ts +3 -0
  61. package/components/html-embed/languages.ts +15 -0
  62. package/components/html-embed/loadGate.ts +107 -0
  63. package/components/html-viewer/HtmlViewer.tsx +82 -6
  64. package/components/html-viewer/bridge-script.asset.js +173 -1
  65. package/components/html-viewer/bridge-script.ts +173 -1
  66. package/components/html-viewer/useHtmlAnnotation.ts +125 -0
  67. package/components/imageZoom.ts +276 -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/contents-rail.css +337 -0
  78. package/hooks/pfm/useCodeFilePopout.ts +7 -1
  79. package/hooks/useAIChat.ts +72 -25
  80. package/hooks/useAnnotationDraft.ts +71 -6
  81. package/hooks/useAnnotationHighlighter.ts +57 -3
  82. package/hooks/useCodeAnnotationDraft.ts +113 -17
  83. package/hooks/useCookieHostSetting.ts +96 -0
  84. package/hooks/useDocumentFont.ts +17 -0
  85. package/hooks/useDocumentTextSize.ts +20 -0
  86. package/hooks/useFileBrowser.ts +10 -1
  87. package/hooks/useHeadingScrollSpy.ts +77 -0
  88. package/hooks/useHoverPreview.ts +94 -0
  89. package/hooks/useLinkedDoc.ts +226 -13
  90. package/hooks/usePackageChangesSetting.ts +90 -0
  91. package/hooks/usePlanLookDialog.ts +67 -0
  92. package/hooks/usePrefersReducedMotion.ts +23 -0
  93. package/hooks/useSharing.ts +11 -7
  94. package/hooks/useSwipeStep.ts +597 -0
  95. package/hooks/useThemePaletteHalves.ts +37 -0
  96. package/hooks/useUpdateCheck.ts +22 -4
  97. package/hooks/useUpdateToast.ts +1 -1
  98. package/package.json +26 -23
  99. package/shortcuts/code-review/annotationToolbar.shortcuts.ts +1 -0
  100. package/shortcuts/code-review/explorer.shortcuts.ts +82 -0
  101. package/shortcuts/core.ts +63 -4
  102. package/shortcuts/index.ts +1 -0
  103. package/shortcuts/plan-review/annotationPanel.shortcuts.ts +1 -1
  104. package/shortcuts/plan-review/commentPopover.shortcuts.ts +1 -0
  105. package/shortcuts/runtime.ts +4 -3
  106. package/styles.css +1 -1
  107. package/theme.css +179 -1
  108. package/themes/plannotator-graphite.css +66 -0
  109. package/types.ts +26 -9
  110. package/utils/agentSwitch.ts +41 -20
  111. package/utils/agentToolSetting.ts +1 -1
  112. package/utils/aiChatFormat.ts +23 -7
  113. package/utils/annotationHelpers.ts +32 -4
  114. package/utils/annotationOwnsHighlight.ts +4 -2
  115. package/utils/asciidocParser.ts +570 -0
  116. package/utils/autoUpdateNotice.ts +24 -0
  117. package/utils/belowTitleSlot.ts +174 -0
  118. package/utils/blockParse.ts +45 -0
  119. package/utils/codeLineScroll.ts +100 -0
  120. package/utils/commentSubmitKey.ts +48 -0
  121. package/utils/cssFontFamily.ts +65 -0
  122. package/utils/decisionSpec.ts +190 -2
  123. package/utils/defaultNotesApp.ts +1 -1
  124. package/utils/diffFonts.ts +4 -1
  125. package/utils/documentFont.ts +68 -0
  126. package/utils/documentTextSize.ts +54 -0
  127. package/utils/fontDetect.ts +63 -0
  128. package/utils/headingScrollSpy.ts +92 -0
  129. package/utils/inboxAnnouncement.ts +78 -0
  130. package/utils/lookAndFeelAnnouncement.ts +82 -5
  131. package/utils/markdownExtensions.ts +2 -2
  132. package/utils/mermaid.ts +20 -3
  133. package/utils/mermaidTheme.ts +3 -51
  134. package/utils/notion.ts +58 -0
  135. package/utils/parser.ts +28 -1
  136. package/utils/platform.ts +46 -0
  137. package/utils/refCard.ts +82 -0
  138. package/utils/richClipboard.ts +380 -0
  139. package/utils/richClipboardLists.ts +57 -0
  140. package/utils/richCopyPolicy.ts +22 -0
  141. package/utils/scrollAnchor.ts +82 -0
  142. package/utils/sectionRefs.ts +123 -0
  143. package/utils/selectionBoundary.ts +40 -0
  144. package/utils/sendFailure.ts +85 -0
  145. package/utils/sharing.ts +5 -2
  146. package/utils/themeRegistry.ts +10 -0
  147. package/utils/themeTokens.ts +74 -0
  148. package/utils/updateNotice.ts +30 -8
  149. package/utils/vscodePanel.ts +9 -0
@@ -30,6 +30,9 @@ export interface AIChatThread {
30
30
 
31
31
  export interface AskAIParams {
32
32
  prompt: string;
33
+ /** What the chat shows as the question when it differs from `prompt`, which
34
+ * is what the agent gets (e.g. a short "Explain this finding: …"). */
35
+ displayPrompt?: string;
33
36
  filePath?: string;
34
37
  lineStart?: number;
35
38
  lineEnd?: number;
@@ -52,6 +55,12 @@ export interface AskAIParams {
52
55
  * received, and the server frames it as read-only context, never as
53
56
  * feedback (#1748). Leave unset for every other provider. */
54
57
  draftAnnotations?: string;
58
+ /** Called once, when the stream first shows the question reached the
59
+ * session (an answer started, or the session took it over). A busy, gone
60
+ * or failed ask never calls it; a retry keeps it, so the retried question
61
+ * calls it when it lands. Lets a caller record what the session was told
62
+ * (e.g. which document a note named) only once it was received. */
63
+ onDelivered?: () => void;
55
64
  }
56
65
 
57
66
  /** What to send as `draftAnnotations` for one question: the list when this
@@ -73,6 +82,15 @@ const BRIDGE_DRAFTS_KEY = 'session-bridge';
73
82
  /** Stream messages that show the session received the question. */
74
83
  const DELIVERED_MESSAGE_TYPES = new Set(['text_delta', 'text', 'tool_use', 'tool_result', 'result']);
75
84
 
85
+ /**
86
+ * A busy "Ask this session" answer still offers its choice ("Ask when it
87
+ * finishes" / "Interrupt and ask now"): the question has not been asked yet,
88
+ * and clearing the thread would drop it without a word.
89
+ */
90
+ export function hasPendingBusyChoice(messages: AIChatEntry[]): boolean {
91
+ return messages.some(m => m.response.errorCode === SESSION_ASK_ERROR_CODES.agentBusy);
92
+ }
93
+
76
94
  /** Overrides for re-asking a question that ended in a busy/gone/blocked answer. */
77
95
  export interface AIRetryOptions {
78
96
  busyPolicy?: 'wait' | 'interrupt';
@@ -179,8 +197,9 @@ export interface AITransport {
179
197
  session(body: unknown, signal: AbortSignal): Promise<Response>;
180
198
  /** POST /api/ai/query — send a message; returns the streaming SSE response. */
181
199
  query(body: unknown, signal: AbortSignal): Promise<Response>;
182
- /** POST /api/ai/abort — abort a session (supersede + standalone). Resolves once
183
- the server acknowledges so a following query can await it (best-effort, never rejects). */
200
+ /** POST /api/ai/abort. A superseding question sends `reason: 'supersede'`
201
+ * so session bridges close the answer without stopping the host turn.
202
+ * Resolves once acknowledged, before the old fetch is disconnected. */
184
203
  abort(body: unknown): Promise<unknown>;
185
204
  /** POST /api/ai/permission — respond to a permission request. Fire-and-forget. */
186
205
  permission(body: unknown): void;
@@ -238,9 +257,9 @@ export const resetAITransport = (): void => {
238
257
  * ask() relies on this resolving. Single-sourced so every abort call site stays
239
258
  * hardened. Reads `aiTransport` at call time so a late override is honored.
240
259
  */
241
- function safeAbort(sessionId: string): Promise<unknown> {
260
+ function safeAbort(sessionId: string, reason?: 'supersede'): Promise<unknown> {
242
261
  return Promise.resolve()
243
- .then(() => aiTransport.abort({ sessionId }))
262
+ .then(() => aiTransport.abort({ sessionId, ...(reason && { reason }) }))
244
263
  .catch(() => {});
245
264
  }
246
265
 
@@ -261,10 +280,15 @@ export function useAIChat({
261
280
  // In-flight server abort (from Stop or a superseding question). The next ask()
262
281
  // waits on it so a new query can't race the still-active turn into busy.
263
282
  const pendingAbortRef = useRef<Promise<unknown> | null>(null);
283
+ // Answers a newer question replaced. Their fetch stays open until the server
284
+ // acknowledges the supersede, but nothing more they stream is shown.
285
+ const supersededRef = useRef(new WeakSet<AbortController>());
264
286
  const sessionEpochRef = useRef(0);
265
287
  const createRequestRef = useRef(0);
266
288
  const sessionIdRef = useRef<string | null>(null);
267
289
  sessionIdRef.current = thread.sessionId;
290
+ const threadRef = useRef(thread);
291
+ threadRef.current = thread;
268
292
 
269
293
  const updateMessages = useCallback((updater: (messages: AIChatEntry[]) => AIChatEntry[]) => {
270
294
  setThread(prev => ({ ...prev, messages: updater(prev.messages) }));
@@ -316,16 +340,13 @@ export function useAIChat({
316
340
  }
317
341
  }, [context, model, providerId, reasoningEffort, setSessionId]);
318
342
 
319
- // Tell the server to stop the current session's in-flight turn, and resolve
320
- // once it has. Used by the Stop button and when a new question supersedes a
321
- // streaming one — aborting the browser fetch alone leaves the agent's turn
322
- // running. The /api/ai/abort handler clears the session's active flag before
323
- // it responds, so awaiting this guarantees a following query won't race it
324
- // into a session_busy error. Best-effort (never rejects).
325
- const postServerAbort = useCallback((): Promise<unknown> => {
343
+ // Stop cancels the turn. Supersede closes the old answer but lets a session
344
+ // bridge's host keep working until the reviewer chooses to interrupt it.
345
+ // Both clear the server's query before the next one. Best-effort (never rejects).
346
+ const postServerAbort = useCallback((reason?: 'supersede'): Promise<unknown> => {
326
347
  const sessionId = sessionIdRef.current; // capture the session being aborted now
327
348
  if (!sessionId) return Promise.resolve();
328
- return safeAbort(sessionId);
349
+ return safeAbort(sessionId, reason);
329
350
  }, []);
330
351
 
331
352
  // The params each question was asked with, so a busy answer can be
@@ -341,19 +362,18 @@ export function useAIChat({
341
362
  const deliveredDraftsRef = useRef<{ sessionId: string; text: string } | null>(null);
342
363
 
343
364
  const ask = useCallback(async (params: AskAIParams, askOptions?: { providerId?: string }) => {
344
- if (abortRef.current) {
345
- abortRef.current.abort();
346
- // Supersede: stop the server-side turn (not just the browser fetch).
347
- pendingAbortRef.current = postServerAbort();
365
+ const previous = abortRef.current;
366
+ if (previous) {
367
+ supersededRef.current.add(previous);
368
+ pendingAbortRef.current = postServerAbort('supersede');
348
369
  }
349
- // Wait for any in-flight server abort — from this supersede OR a prior Stop
350
- // click — so the query below doesn't race the still-active turn into a
351
- // session_busy error. Stopping still kills the turn; chatting right after
352
- // just waits the one round-trip for the stop to land.
370
+ // Let the server distinguish supersede from Stop before disconnecting:
371
+ // cancelling the SSE fetch first would still abort the host's turn.
353
372
  if (pendingAbortRef.current) {
354
373
  await pendingAbortRef.current;
355
374
  pendingAbortRef.current = null;
356
375
  }
376
+ previous?.abort();
357
377
 
358
378
  const controller = new AbortController();
359
379
  abortRef.current = controller;
@@ -364,7 +384,7 @@ export function useAIChat({
364
384
  paramsByQuestionRef.current.set(questionId, params);
365
385
  const question: AIQuestion = {
366
386
  id: questionId,
367
- prompt: params.prompt,
387
+ prompt: params.displayPrompt ?? params.prompt,
368
388
  scope: params.scope,
369
389
  filePath: params.filePath,
370
390
  lineStart: params.lineStart,
@@ -392,7 +412,7 @@ export function useAIChat({
392
412
  sid = await createSession(controller.signal, epoch);
393
413
  }
394
414
 
395
- if (controller.signal.aborted || epoch !== sessionEpochRef.current) {
415
+ if (controller.signal.aborted || supersededRef.current.has(controller) || epoch !== sessionEpochRef.current) {
396
416
  throw createAbortError('AI question was superseded');
397
417
  }
398
418
 
@@ -400,11 +420,20 @@ export function useAIChat({
400
420
  const drafts = draftAnnotationsToSend(deliveredDraftsRef.current, BRIDGE_DRAFTS_KEY, params.draftAnnotations);
401
421
  // Recorded once the session shows it received the question, so a busy,
402
422
  // gone or failed ask sends the list again next time.
423
+ let deliveredCalled = false;
403
424
  const markDraftsDelivered = (msg: { type?: unknown; code?: unknown }) => {
404
- if (drafts === undefined) return;
405
425
  const received = DELIVERED_MESSAGE_TYPES.has(String(msg.type))
406
426
  || (msg.type === 'error' && msg.code === SESSION_ASK_ERROR_CODES.takenOver);
407
- if (received) deliveredDraftsRef.current = { sessionId: BRIDGE_DRAFTS_KEY, text: drafts };
427
+ if (!received) return;
428
+ if (drafts !== undefined) deliveredDraftsRef.current = { sessionId: BRIDGE_DRAFTS_KEY, text: drafts };
429
+ if (!deliveredCalled) {
430
+ deliveredCalled = true;
431
+ try {
432
+ params.onDelivered?.();
433
+ } catch {
434
+ // A caller's bookkeeping must never break the stream.
435
+ }
436
+ }
408
437
  };
409
438
  const res = await aiTransport.query({
410
439
  sessionId: sid,
@@ -425,6 +454,7 @@ export function useAIChat({
425
454
 
426
455
  while (true) {
427
456
  const { done, value } = await reader.read();
457
+ if (supersededRef.current.has(controller)) throw createAbortError('AI question was superseded');
428
458
  if (done) break;
429
459
  buffer += decoder.decode(value, { stream: true });
430
460
 
@@ -566,7 +596,7 @@ export function useAIChat({
566
596
  )
567
597
  );
568
598
  } finally {
569
- if (abortRef.current === controller) {
599
+ if (abortRef.current === controller && !supersededRef.current.has(controller)) {
570
600
  setIsStreaming(false);
571
601
  abortRef.current = null;
572
602
  }
@@ -649,6 +679,22 @@ export function useAIChat({
649
679
  setError(null);
650
680
  }, [postServerAbort, threadTitle]);
651
681
 
682
+ // The panel's "New chat": clears the thread and forgets the Plannotator AI
683
+ // session, so the next question starts a fresh one (a separate AI then gets
684
+ // the full context again). Unlike `resetThread` (a document switch), it never
685
+ // stops anything: while a question is in flight (streaming, creating its
686
+ // session, or waiting on a busy "Ask this session") or a busy answer still
687
+ // offers its choice, it does nothing and answers false, so a running turn is
688
+ // not aborted and a pending ask is not dropped. What the agent session already received (`deliveredDraftsRef`) is
689
+ // kept: with "Ask this session" a new chat reaches the SAME host session,
690
+ // which still remembers it.
691
+ const newChat = useCallback((): boolean => {
692
+ if (abortRef.current) return false;
693
+ if (hasPendingBusyChoice(threadRef.current.messages)) return false;
694
+ resetThread();
695
+ return true;
696
+ }, [resetThread]);
697
+
652
698
  useEffect(() => {
653
699
  return () => {
654
700
  sessionEpochRef.current += 1;
@@ -672,6 +718,7 @@ export function useAIChat({
672
718
  abort,
673
719
  resetSession,
674
720
  resetThread,
721
+ newChat,
675
722
  sessionId: thread.sessionId,
676
723
  };
677
724
  }
@@ -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