@plannotator/ui 0.30.0 → 0.32.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 (102) hide show
  1. package/README.md +46 -1
  2. package/components/ActionMenu.tsx +6 -1
  3. package/components/AgentsTab.tsx +8 -9
  4. package/components/AnalysisLayerToggle.tsx +48 -0
  5. package/components/AnnotationPanel.tsx +158 -36
  6. package/components/AnnotationToolbar.tsx +50 -30
  7. package/components/AnnotationToolstrip.tsx +9 -0
  8. package/components/CommentPopover.tsx +238 -46
  9. package/components/ConfirmDialog.tsx +42 -28
  10. package/components/GraphvizBlock.tsx +86 -7
  11. package/components/HtmlSurfaceControls.tsx +170 -0
  12. package/components/InlineMarkdown.tsx +25 -4
  13. package/components/KeyboardShortcuts.tsx +9 -0
  14. package/components/Landing.tsx +1 -1
  15. package/components/LookAndFeelAnnouncementDialog.tsx +147 -178
  16. package/components/MarkdownEditor/embedPicker.ts +349 -0
  17. package/components/MarkdownEditor.tsx +12 -0
  18. package/components/MermaidBlock.tsx +60 -26
  19. package/components/ModeToggle.tsx +2 -1
  20. package/components/PermissionModeSetup.tsx +24 -5
  21. package/components/PinpointOverlay.tsx +11 -6
  22. package/components/PlanHeaderMenu.tsx +140 -1
  23. package/components/SearchableSelect.tsx +2 -0
  24. package/components/Settings.tsx +175 -10
  25. package/components/SkillReferenceMenu.tsx +9 -0
  26. package/components/StickyHeaderLane.tsx +9 -2
  27. package/components/TableOfContents.tsx +9 -4
  28. package/components/TextShimmer.tsx +8 -5
  29. package/components/ThemeProvider.tsx +43 -1
  30. package/components/ThemeTab.tsx +52 -1
  31. package/components/Tooltip.tsx +3 -1
  32. package/components/Viewer.tsx +19 -5
  33. package/components/VimTargetReticle.tsx +12 -4
  34. package/components/ai/DocumentAIChatPanel.tsx +1 -0
  35. package/components/blocks/MathBlock.tsx +26 -14
  36. package/components/core/button.tsx +14 -6
  37. package/components/html-viewer/HtmlViewer.tsx +320 -41
  38. package/components/html-viewer/bridge-script.ts +360 -72
  39. package/components/html-viewer/composerYield.ts +1 -51
  40. package/components/html-viewer/hostThreads.ts +37 -0
  41. package/components/html-viewer/index.ts +9 -0
  42. package/components/html-viewer/unanchored.ts +47 -0
  43. package/components/html-viewer/useHtmlAnnotation.ts +240 -61
  44. package/components/plan-diff/PlanCleanDiffView.tsx +1 -0
  45. package/components/sidebar/FileBrowser.tsx +17 -5
  46. package/components/sidebar/SidebarContainer.tsx +124 -28
  47. package/components/ui/button.tsx +10 -8
  48. package/components/ui/dialog.tsx +35 -25
  49. package/config/index.ts +6 -1
  50. package/config/reviewView.ts +42 -9
  51. package/config/settings.ts +141 -0
  52. package/configure.ts +32 -0
  53. package/hooks/useAIProviderConfig.ts +8 -7
  54. package/hooks/useActiveSection.ts +6 -4
  55. package/hooks/useAgentJobs.ts +3 -0
  56. package/hooks/useAnnotationHighlighter.ts +20 -0
  57. package/hooks/useHtmlRefresh.ts +149 -0
  58. package/hooks/useIsMobile.ts +37 -0
  59. package/hooks/useLinkedDoc.ts +7 -0
  60. package/hooks/useMathRenderer.ts +30 -0
  61. package/hooks/useScrollViewport.ts +74 -0
  62. package/hooks/useSharing.ts +31 -5
  63. package/hooks/useViewportEnvironment.ts +350 -0
  64. package/package.json +5 -2
  65. package/shortcuts/index.ts +3 -0
  66. package/shortcuts/plan-review/annotationMode.shortcuts.ts +91 -0
  67. package/shortcuts/plan-review/documentView.shortcuts.ts +26 -0
  68. package/shortcuts/plan-review/htmlAnnotate.shortcuts.ts +24 -0
  69. package/styles.css +1 -1
  70. package/theme.css +229 -0
  71. package/types.ts +35 -0
  72. package/utils/annotateAgentTerminal.ts +36 -5
  73. package/utils/blockTargeting.ts +6 -3
  74. package/utils/composerYield.ts +45 -0
  75. package/utils/generateIdentity.ts +64 -14
  76. package/utils/htmlChrome.ts +20 -16
  77. package/utils/identity-tater.ts +36 -0
  78. package/utils/lookAndFeelAnnouncement.ts +12 -8
  79. package/utils/markdownExtensions.ts +57 -0
  80. package/utils/math-eager.ts +25 -0
  81. package/utils/math.ts +146 -0
  82. package/utils/mermaid-eager.ts +28 -0
  83. package/utils/mermaid.ts +132 -0
  84. package/utils/parser.ts +75 -2
  85. package/utils/quickLabels.ts +13 -0
  86. package/utils/vimNavigation.ts +4 -1
  87. package/utils/vimScroll.ts +9 -4
  88. package/utils/wideMode.ts +20 -0
  89. package/webmcp/activity.ts +46 -0
  90. package/webmcp/changes.ts +227 -0
  91. package/webmcp/index.ts +72 -0
  92. package/webmcp/modelContext.ts +103 -0
  93. package/webmcp/nudges.ts +174 -0
  94. package/webmcp/policy.ts +50 -0
  95. package/webmcp/preference.ts +50 -0
  96. package/webmcp/schema.ts +81 -0
  97. package/webmcp/toolset.ts +337 -0
  98. package/webmcp/useToolset.ts +74 -0
  99. package/components/PlanAIAnnouncementDialog.tsx +0 -187
  100. package/components/VimModeAnnouncementDialog.tsx +0 -557
  101. package/utils/planAIAnnouncement.ts +0 -17
  102. package/utils/vimModeAnnouncement.ts +0 -23
@@ -1,6 +1,6 @@
1
1
  import { useState, useEffect, useCallback, useRef, type RefObject } from "react";
2
2
  import { AnnotationType, type Annotation, type EditorMode, type HtmlAnnotationTarget, type HtmlElementAnchor, type ImageAttachment } from "../../types";
3
- import type { QuickLabel } from "../../utils/quickLabels";
3
+ import { THUMBS_UP_LABEL, type QuickLabel } from "../../utils/quickLabels";
4
4
  import { getIdentity } from "../../utils/identity";
5
5
  import type {
6
6
  ToolbarState,
@@ -18,6 +18,21 @@ function nextHtmlAnnId(): string {
18
18
  return `html-ann-${Date.now().toString(36)}-${(htmlAnnSeq++).toString(36)}`;
19
19
  }
20
20
 
21
+ /** Ids minted by this module for locally created annotations (create-mark).
22
+ * Module-scoped like the sequence above: a host that swaps a local id for
23
+ * its own server id keeps the local mark until it removes it, and the
24
+ * unanchored union needs to recognise such ids whichever viewer instance
25
+ * minted them. Bounded: only ids from this page load, one entry per create. */
26
+ const mintedHtmlAnnIds = new Set<string>();
27
+
28
+ function htmlCommentDraftKey(
29
+ text: string,
30
+ anchor?: HtmlElementAnchor | null,
31
+ targetKey?: string,
32
+ ): string {
33
+ return `html-selection:${targetKey ?? JSON.stringify(anchor ?? null)}:${text}`;
34
+ }
35
+
21
36
  interface BridgeSelectionMessage {
22
37
  type: `${typeof PREFIX}selection`;
23
38
  text: string;
@@ -66,7 +81,30 @@ type BridgeMessage =
66
81
  | { type: `${typeof PREFIX}keytype`; key: string }
67
82
  | { type: `${typeof PREFIX}mark-click`; id: string }
68
83
  | { type: `${typeof PREFIX}unanchored`; ids: string[] }
69
- | { type: `${typeof PREFIX}resize`; height: number };
84
+ | { type: `${typeof PREFIX}resize`; height: number }
85
+ | { type: `${typeof PREFIX}page-change`; pageUrl: string };
86
+
87
+ /** Live proxied-app session credentials: the proxy origin messages must come
88
+ * from, and the per-session token every message must echo. */
89
+ export interface HtmlLiveSession {
90
+ origin: string;
91
+ token: string;
92
+ }
93
+
94
+ /** Cap for live-mode page identity strings (mirrors the bridge's slice). */
95
+ export const MAX_PAGE_URL_LENGTH = 2048;
96
+
97
+ /** True when a live-session message event fails the origin or token check.
98
+ * Exported for protocol tests. */
99
+ export function rejectsLiveMessage(
100
+ live: HtmlLiveSession,
101
+ origin: string,
102
+ data: unknown,
103
+ ): boolean {
104
+ if (origin !== live.origin) return true;
105
+ if (!isRecord(data) || data.token !== live.token) return true;
106
+ return false;
107
+ }
70
108
 
71
109
  /** Dependencies and callbacks for the sandboxed HTML annotation bridge. */
72
110
  export interface UseHtmlAnnotationOptions {
@@ -79,6 +117,12 @@ export interface UseHtmlAnnotationOptions {
79
117
  selectedAnnotationId: string | null;
80
118
  mode: EditorMode;
81
119
  onResize?: (height: number) => void;
120
+ /** Live proxied-app session: reject messages that fail the origin or token
121
+ * check before parsing, and stamp token + concrete targetOrigin on every
122
+ * outbound post. Absent for srcdoc sessions (behavior unchanged). */
123
+ live?: HtmlLiveSession;
124
+ /** Live-mode page navigation reports (validated, capped at 2048 chars). */
125
+ onPageChange?: (pageUrl: string) => void;
82
126
  /** Validated pointer positions relayed from inside the iframe while a
83
127
  * pinpoint draft is open (iframe-local viewport coordinates), with the
84
128
  * Shift state observed by the iframe (the parent cannot see modifiers
@@ -92,9 +136,39 @@ export interface UseHtmlAnnotationOptions {
92
136
  * empty on recovery. Delivered in readOnly mode too: view-only surfaces
93
137
  * are exactly where silently missing markers would go unnoticed. */
94
138
  onUnanchoredChange?: (ids: string[]) => void;
139
+ /** Product cap on additional (shift-click) targets per comment, 0..16.
140
+ * Applied at the trust boundary, on submit, on restore, and carried to
141
+ * the bridge on arm-multi-select so the in-page toggle stops at the
142
+ * same number. Absent: the package's 16, and the arm message is unchanged. */
143
+ maxAdditionalTargets?: number;
144
+ /** scrollIntoView behavior for scroll-to (selecting an annotation).
145
+ * Absent: smooth, as before; pass 'auto' to honor reduced motion. */
146
+ scrollBehavior?: 'smooth' | 'auto';
95
147
  }
96
148
 
97
- function postToIframe(iframe: HTMLIFrameElement | null, msg: Record<string, unknown>) {
149
+ /** Clamp a host cap into the package's bound; anything unusable is the default. */
150
+ export function resolveMaxAdditionalTargets(value: number | undefined): number {
151
+ if (value === undefined || !Number.isFinite(value)) return MAX_ADDITIONAL_TARGETS;
152
+ return Math.max(0, Math.min(MAX_ADDITIONAL_TARGETS, Math.floor(value)));
153
+ }
154
+
155
+ function postToIframe(
156
+ iframe: HTMLIFrameElement | null,
157
+ msg: Record<string, unknown>,
158
+ live?: HtmlLiveSession | null,
159
+ ) {
160
+ if (live) {
161
+ // Live proxied-app sessions: token on every message, concrete targetOrigin.
162
+ // Browsers silently DROP a post whose targetOrigin does not match the
163
+ // receiving window (mid-navigation frames); some DOM environments throw
164
+ // instead, so align with the browser semantics explicitly.
165
+ try {
166
+ iframe?.contentWindow?.postMessage({ ...msg, token: live.token }, live.origin);
167
+ } catch {
168
+ // Dropped, matching browser behavior for unmatched target origins.
169
+ }
170
+ return;
171
+ }
98
172
  iframe?.contentWindow?.postMessage(msg, "*");
99
173
  }
100
174
 
@@ -282,6 +356,13 @@ export function parseBridgeMessage(value: unknown): BridgeMessage | null {
282
356
  return typeof value.height === "number" && Number.isFinite(value.height)
283
357
  ? { type: value.type, height: value.height }
284
358
  : null;
359
+ case `${PREFIX}page-change`:
360
+ // Live-mode SPA navigation report. Bounded like every bridge string.
361
+ return typeof value.pageUrl === "string"
362
+ && value.pageUrl.length > 0
363
+ && value.pageUrl.length <= MAX_PAGE_URL_LENGTH
364
+ ? { type: value.type, pageUrl: value.pageUrl }
365
+ : null;
285
366
  default:
286
367
  return null;
287
368
  }
@@ -301,8 +382,12 @@ export function useHtmlAnnotation({
301
382
  selectedAnnotationId,
302
383
  mode,
303
384
  onResize,
385
+ live,
386
+ onPageChange,
304
387
  onBridgePointer,
305
388
  onUnanchoredChange,
389
+ maxAdditionalTargets,
390
+ scrollBehavior,
306
391
  }: UseHtmlAnnotationOptions): Omit<
307
392
  UseAnnotationHighlighterReturn,
308
393
  "highlighterRef" | "highlightRange" | "highlightMathElement"
@@ -315,6 +400,13 @@ export function useHtmlAnnotation({
315
400
  flashDraftTarget: (key: string) => void;
316
401
  /** Bumped after every target add/remove so the composer can refocus its textarea. */
317
402
  composerFocusToken: number;
403
+ /** Composer one-click "Looks good": submits the hardcoded positive label
404
+ * with the same anchor and multi-select targets a typed comment would carry. */
405
+ handleCommentLooksGood: () => void;
406
+ /** Ids this module minted for locally created annotations (create-mark),
407
+ * for the unanchored union: a minted id the host never listed is a
408
+ * swapped-out local mark, not a host row. Read-only, stable identity. */
409
+ createdAnnotationIds: ReadonlySet<string>;
318
410
  } {
319
411
  const [toolbarState, setToolbarState] = useState<ToolbarState | null>(null);
320
412
  const [commentPopover, setCommentPopover] = useState<CommentPopoverState | null>(null);
@@ -351,9 +443,28 @@ export function useHtmlAnnotation({
351
443
  onSelectRef.current = onSelectAnnotation;
352
444
  const onUnanchoredChangeRef = useRef(onUnanchoredChange);
353
445
  onUnanchoredChangeRef.current = onUnanchoredChange;
446
+ const liveRef = useRef<HtmlLiveSession | null>(live ?? null);
447
+ liveRef.current = live ?? null;
448
+ const onPageChangeRef = useRef(onPageChange);
449
+ onPageChangeRef.current = onPageChange;
450
+ // The effective cap and whether the host set one: only an explicit cap
451
+ // rides on arm-multi-select, so an unconfigured viewer posts today's message.
452
+ const maxTargetsRef = useRef(resolveMaxAdditionalTargets(maxAdditionalTargets));
453
+ maxTargetsRef.current = resolveMaxAdditionalTargets(maxAdditionalTargets);
454
+ const hostCapRef = useRef(maxAdditionalTargets !== undefined);
455
+ hostCapRef.current = maxAdditionalTargets !== undefined;
354
456
 
355
457
  const anchorRef = useRef<HTMLDivElement | null>(null);
356
458
 
459
+ /** Post into the iframe with the live token + targetOrigin when a live
460
+ * session is active; srcdoc posts keep targetOrigin "*" and no token. */
461
+ const post = useCallback(
462
+ (msg: Record<string, unknown>) => {
463
+ postToIframe(iframeRef.current, msg, liveRef.current);
464
+ },
465
+ [iframeRef],
466
+ );
467
+
357
468
  /**
358
469
  * Remove one draft target. Removing the primary promotes the next remaining
359
470
  * target (the composer's context text and pending anchor follow it);
@@ -368,7 +479,7 @@ export function useHtmlAnnotation({
368
479
  const targets = draftTargetsRef.current;
369
480
  const index = targets.findIndex((t) => t.key === key);
370
481
  if (index < 0) return;
371
- postToIframe(iframeRef.current, { type: `${PREFIX}remove-target`, key });
482
+ post({ type: `${PREFIX}remove-target`, key });
372
483
  const remaining = targets.filter((t) => t.key !== key);
373
484
  if (remaining.length === 0) {
374
485
  // Final target removed — the draft is cancelled (bridge side already
@@ -392,7 +503,7 @@ export function useHtmlAnnotation({
392
503
  setDraftTargets(remaining);
393
504
  setComposerFocusToken((t) => t + 1);
394
505
  },
395
- [iframeRef],
506
+ [post],
396
507
  );
397
508
 
398
509
  const getOrCreateAnchor = useCallback(() => {
@@ -429,6 +540,11 @@ export function useHtmlAnnotation({
429
540
  useEffect(() => {
430
541
  function handler(e: MessageEvent<unknown>) {
431
542
  if (e.source !== iframeRef.current?.contentWindow) return;
543
+ // Live sessions verify origin + session token BEFORE parsing. The
544
+ // existing caps stay exactly as they are: live content is friendlier
545
+ // but the trust boundary does not relax.
546
+ const liveSession = liveRef.current;
547
+ if (liveSession && rejectsLiveMessage(liveSession, e.origin, e.data)) return;
432
548
  const message = parseBridgeMessage(e.data);
433
549
  if (!message) return;
434
550
 
@@ -439,6 +555,8 @@ export function useHtmlAnnotation({
439
555
  && type !== `${PREFIX}mark-click`
440
556
  && type !== `${PREFIX}unanchored`
441
557
  && type !== `${PREFIX}resize`
558
+ // Page identity is navigation state, not an annotation mutation.
559
+ && type !== `${PREFIX}page-change`
442
560
  ) {
443
561
  return;
444
562
  }
@@ -450,25 +568,20 @@ export function useHtmlAnnotation({
450
568
  const anchor = positionAnchor(message.rect);
451
569
  if (!anchor) return;
452
570
 
453
- const currentMode = message.modeOverride ?? modeRef.current;
454
-
455
- if (currentMode === "redline") {
456
- const id = nextHtmlAnnId();
457
- postToIframe(iframeRef.current, { type: `${PREFIX}create-mark`, id, annotationType: "deletion" });
458
- onAddRef.current?.({
459
- id,
460
- blockId: "",
461
- startOffset: 0,
462
- endOffset: 0,
463
- type: AnnotationType.DELETION,
464
- originalText: message.text,
465
- author: getIdentity(),
466
- createdA: Date.now(),
467
- htmlAnchor: message.anchor,
468
- });
469
- pendingTextRef.current = "";
470
- pendingAnchorRef.current = null;
471
- } else if (
571
+ // HTML and live-app surfaces are COMMENT-ONLY: redline (auto-DELETION)
572
+ // and quickLabel are markdown-surface features, so both the host's
573
+ // mode and any bridge-posted modeOverride clamp to the plain selection
574
+ // flow here (a hostile page can post modeOverride, so the clamp sits
575
+ // at the trust boundary, not in the host). Persisted DELETION
576
+ // annotations still restore through applyAnnotations — only CREATION
577
+ // is comment-only.
578
+ const requestedMode = message.modeOverride ?? modeRef.current;
579
+ const currentMode =
580
+ requestedMode === "redline" || requestedMode === "quickLabel"
581
+ ? "selection"
582
+ : requestedMode;
583
+
584
+ if (
472
585
  currentMode === "comment"
473
586
  // Pinpoint click-to-pin: the click already chose the target, so skip
474
587
  // the intermediate toolbar and go straight to the comment composer.
@@ -481,6 +594,7 @@ export function useHtmlAnnotation({
481
594
  anchorEl: anchor,
482
595
  contextText: message.text,
483
596
  selectedText: message.text,
597
+ draftKey: htmlCommentDraftKey(message.text, message.anchor, message.targetKey),
484
598
  });
485
599
  // Pinpoint drafts arm shift-click multi-select: the clicked element
486
600
  // becomes the primary target of the (single) draft comment. The
@@ -496,16 +610,12 @@ export function useHtmlAnnotation({
496
610
  anchor: message.anchor ?? null,
497
611
  },
498
612
  ]);
499
- postToIframe(iframeRef.current, {
613
+ post({
500
614
  type: `${PREFIX}arm-multi-select`,
501
615
  key: message.targetKey,
616
+ ...(hostCapRef.current ? { max: maxTargetsRef.current } : {}),
502
617
  });
503
618
  }
504
- } else if (currentMode === "quickLabel") {
505
- setQuickLabelPicker({
506
- anchorEl: anchor,
507
- cursorHint: { x: parseFloat(anchor.style.left), y: parseFloat(anchor.style.top) },
508
- });
509
619
  } else {
510
620
  setToolbarState({
511
621
  element: anchor,
@@ -523,7 +633,7 @@ export function useHtmlAnnotation({
523
633
  if (
524
634
  commentPopoverRef.current
525
635
  && targets.length > 0
526
- && targets.length < 1 + MAX_ADDITIONAL_TARGETS
636
+ && targets.length < 1 + maxTargetsRef.current
527
637
  && !targets.some((t) => t.key === message.key)
528
638
  ) {
529
639
  setDraftTargets([
@@ -585,7 +695,13 @@ export function useHtmlAnnotation({
585
695
  // the typing) — otherwise the iframe keeps focus and the bridge eats keys.
586
696
  iframeRef.current?.blur();
587
697
  setToolbarState(null);
588
- setCommentPopover({ anchorEl: anchor, contextText: text, selectedText: text, initialText: key });
698
+ setCommentPopover({
699
+ anchorEl: anchor,
700
+ contextText: text,
701
+ selectedText: text,
702
+ initialText: key,
703
+ draftKey: htmlCommentDraftKey(text, pendingAnchorRef.current),
704
+ });
589
705
  }
590
706
 
591
707
  if (type === `${PREFIX}mark-click`) {
@@ -599,6 +715,10 @@ export function useHtmlAnnotation({
599
715
  if (type === `${PREFIX}resize`) {
600
716
  onResize?.(message.height);
601
717
  }
718
+
719
+ if (type === `${PREFIX}page-change`) {
720
+ onPageChangeRef.current?.(message.pageUrl);
721
+ }
602
722
  }
603
723
 
604
724
  window.addEventListener("message", handler);
@@ -609,7 +729,7 @@ export function useHtmlAnnotation({
609
729
  anchorRef.current = null;
610
730
  }
611
731
  };
612
- }, [iframeRef, positionAnchor, onResize, getOrCreateAnchor, applyTargetRemoval]);
732
+ }, [post, iframeRef, positionAnchor, onResize, getOrCreateAnchor, applyTargetRemoval]);
613
733
 
614
734
  useEffect(() => {
615
735
  if (enabled) return;
@@ -621,22 +741,25 @@ export function useHtmlAnnotation({
621
741
  pendingAnchorRef.current = null;
622
742
  anchorRef.current?.remove();
623
743
  anchorRef.current = null;
624
- postToIframe(iframeRef.current, { type: `${PREFIX}cancel-selection` });
625
- }, [enabled, iframeRef]);
744
+ post({ type: `${PREFIX}cancel-selection` });
745
+ }, [enabled, post]);
626
746
 
627
747
  useEffect(() => {
628
748
  if (selectedAnnotationId) {
629
- postToIframe(iframeRef.current, {
749
+ post({
630
750
  type: `${PREFIX}scroll-to`,
631
751
  id: selectedAnnotationId,
752
+ // Only an explicit host preference rides along; the default message
753
+ // is unchanged and the bridge scrolls smoothly as before.
754
+ ...(scrollBehavior ? { behavior: scrollBehavior } : {}),
632
755
  });
633
756
  } else {
634
- postToIframe(iframeRef.current, {
757
+ post({
635
758
  type: `${PREFIX}focus-mark`,
636
759
  id: null,
637
760
  });
638
761
  }
639
- }, [selectedAnnotationId, iframeRef]);
762
+ }, [selectedAnnotationId, post, scrollBehavior]);
640
763
 
641
764
  const handleAnnotate = useCallback(
642
765
  (type: AnnotationType) => {
@@ -645,7 +768,8 @@ export function useHtmlAnnotation({
645
768
  if (!text || type !== AnnotationType.DELETION) return;
646
769
 
647
770
  const id = nextHtmlAnnId();
648
- postToIframe(iframeRef.current, { type: `${PREFIX}create-mark`, id, annotationType: "deletion" });
771
+ mintedHtmlAnnIds.add(id);
772
+ post({ type: `${PREFIX}create-mark`, id, annotationType: "deletion" });
649
773
  onAddRef.current?.({
650
774
  id,
651
775
  blockId: "",
@@ -662,7 +786,7 @@ export function useHtmlAnnotation({
662
786
  pendingTextRef.current = "";
663
787
  pendingAnchorRef.current = null;
664
788
  },
665
- [iframeRef],
789
+ [post],
666
790
  );
667
791
 
668
792
  const handleRequestComment = useCallback(
@@ -672,7 +796,13 @@ export function useHtmlAnnotation({
672
796
  if (!text) return;
673
797
  const anchor = anchorRef.current ?? getOrCreateAnchor();
674
798
  setToolbarState(null);
675
- setCommentPopover({ anchorEl: anchor, contextText: text, selectedText: text, initialText: initialChar });
799
+ setCommentPopover({
800
+ anchorEl: anchor,
801
+ contextText: text,
802
+ selectedText: text,
803
+ initialText: initialChar,
804
+ draftKey: htmlCommentDraftKey(text, pendingAnchorRef.current),
805
+ });
676
806
  },
677
807
  [getOrCreateAnchor],
678
808
  );
@@ -689,7 +819,7 @@ export function useHtmlAnnotation({
689
819
  const targets = draftTargetsRef.current;
690
820
  const additionalTargets: HtmlAnnotationTarget[] | undefined =
691
821
  targets.length > 1
692
- ? targets.slice(1, 1 + MAX_ADDITIONAL_TARGETS).map((t) => ({
822
+ ? targets.slice(1, 1 + maxTargetsRef.current).map((t) => ({
693
823
  label: t.label,
694
824
  text: t.text,
695
825
  anchor: t.anchor ?? undefined,
@@ -697,7 +827,8 @@ export function useHtmlAnnotation({
697
827
  : undefined;
698
828
 
699
829
  const id = nextHtmlAnnId();
700
- postToIframe(iframeRef.current, { type: `${PREFIX}create-mark`, id, annotationType: "comment" });
830
+ mintedHtmlAnnIds.add(id);
831
+ post({ type: `${PREFIX}create-mark`, id, annotationType: "comment" });
701
832
  onAddRef.current?.({
702
833
  id,
703
834
  blockId: "",
@@ -718,16 +849,61 @@ export function useHtmlAnnotation({
718
849
  pendingTextRef.current = "";
719
850
  pendingAnchorRef.current = null;
720
851
  },
721
- [iframeRef],
852
+ [post],
722
853
  );
723
854
 
855
+ // The composer's one-click "Looks good" (the restored thumbs-up for
856
+ // comment-only surfaces, where pinpoint clicks land straight in the
857
+ // composer and never see the selection toolbar). Mirrors
858
+ // handleCommentSubmit — same anchor, same multi-select targets — but
859
+ // emits the hardcoded positive label instead of typed prose.
860
+ const handleCommentLooksGood = useCallback(() => {
861
+ if (!enabledRef.current) return;
862
+ const text = commentPopoverRef.current?.selectedText || pendingTextRef.current;
863
+ if (!text) return;
864
+
865
+ const targets = draftTargetsRef.current;
866
+ const additionalTargets: HtmlAnnotationTarget[] | undefined =
867
+ targets.length > 1
868
+ ? targets.slice(1, 1 + maxTargetsRef.current).map((t) => ({
869
+ label: t.label,
870
+ text: t.text,
871
+ anchor: t.anchor ?? undefined,
872
+ }))
873
+ : undefined;
874
+
875
+ const id = nextHtmlAnnId();
876
+ mintedHtmlAnnIds.add(id);
877
+ post({ type: `${PREFIX}create-mark`, id, annotationType: "comment" });
878
+ onAddRef.current?.({
879
+ id,
880
+ blockId: "",
881
+ startOffset: 0,
882
+ endOffset: 0,
883
+ type: AnnotationType.COMMENT,
884
+ text: THUMBS_UP_LABEL.text,
885
+ originalText: text,
886
+ isQuickLabel: true,
887
+ quickLabelTip: THUMBS_UP_LABEL.tip,
888
+ author: getIdentity(),
889
+ createdA: Date.now(),
890
+ htmlAnchor: pendingAnchorRef.current ?? undefined,
891
+ htmlAdditionalTargets: additionalTargets,
892
+ });
893
+
894
+ setCommentPopover(null);
895
+ setDraftTargets([]);
896
+ pendingTextRef.current = "";
897
+ pendingAnchorRef.current = null;
898
+ }, [post]);
899
+
724
900
  const handleCommentClose = useCallback(() => {
725
- postToIframe(iframeRef.current, { type: `${PREFIX}cancel-selection` });
901
+ post({ type: `${PREFIX}cancel-selection` });
726
902
  setCommentPopover(null);
727
903
  setDraftTargets([]);
728
904
  pendingTextRef.current = "";
729
905
  pendingAnchorRef.current = null;
730
- }, [iframeRef]);
906
+ }, [post]);
731
907
 
732
908
  const removeDraftTarget = useCallback(
733
909
  (key: string) => {
@@ -739,17 +915,17 @@ export function useHtmlAnnotation({
739
915
 
740
916
  const flashDraftTarget = useCallback(
741
917
  (key: string) => {
742
- postToIframe(iframeRef.current, { type: `${PREFIX}flash-target`, key });
918
+ post({ type: `${PREFIX}flash-target`, key });
743
919
  },
744
- [iframeRef],
920
+ [post],
745
921
  );
746
922
 
747
923
  const handleToolbarClose = useCallback(() => {
748
- postToIframe(iframeRef.current, { type: `${PREFIX}cancel-selection` });
924
+ post({ type: `${PREFIX}cancel-selection` });
749
925
  setToolbarState(null);
750
926
  pendingTextRef.current = "";
751
927
  pendingAnchorRef.current = null;
752
- }, [iframeRef]);
928
+ }, [post]);
753
929
 
754
930
  const applyQuickLabel = useCallback(
755
931
  (label: QuickLabel, clearState: () => void) => {
@@ -757,7 +933,8 @@ export function useHtmlAnnotation({
757
933
  const text = pendingTextRef.current;
758
934
  if (!text) return;
759
935
  const id = nextHtmlAnnId();
760
- postToIframe(iframeRef.current, { type: `${PREFIX}create-mark`, id, annotationType: "comment" });
936
+ mintedHtmlAnnIds.add(id);
937
+ post({ type: `${PREFIX}create-mark`, id, annotationType: "comment" });
761
938
  onAddRef.current?.({
762
939
  id,
763
940
  blockId: "",
@@ -776,7 +953,7 @@ export function useHtmlAnnotation({
776
953
  pendingTextRef.current = "";
777
954
  pendingAnchorRef.current = null;
778
955
  },
779
- [iframeRef],
956
+ [post],
780
957
  );
781
958
 
782
959
  const handleQuickLabel = useCallback(
@@ -790,22 +967,22 @@ export function useHtmlAnnotation({
790
967
  );
791
968
 
792
969
  const handleQuickLabelPickerDismiss = useCallback(() => {
793
- postToIframe(iframeRef.current, { type: `${PREFIX}cancel-selection` });
970
+ post({ type: `${PREFIX}cancel-selection` });
794
971
  setQuickLabelPicker(null);
795
972
  pendingTextRef.current = "";
796
973
  pendingAnchorRef.current = null;
797
- }, [iframeRef]);
974
+ }, [post]);
798
975
 
799
976
  const removeHighlight = useCallback(
800
977
  (id: string) => {
801
- postToIframe(iframeRef.current, { type: `${PREFIX}remove-mark`, id });
978
+ post({ type: `${PREFIX}remove-mark`, id });
802
979
  },
803
- [iframeRef],
980
+ [post],
804
981
  );
805
982
 
806
983
  const clearAllHighlights = useCallback(() => {
807
- postToIframe(iframeRef.current, { type: `${PREFIX}clear-marks` });
808
- }, [iframeRef]);
984
+ post({ type: `${PREFIX}clear-marks` });
985
+ }, [post]);
809
986
 
810
987
  const applyAnnotations = useCallback(
811
988
  (anns: Annotation[]) => {
@@ -817,8 +994,8 @@ export function useHtmlAnnotation({
817
994
  const additionalAnchors = (ann.htmlAdditionalTargets ?? [])
818
995
  .map((t) => t.anchor)
819
996
  .filter((a): a is HtmlElementAnchor => !!a)
820
- .slice(0, MAX_ADDITIONAL_TARGETS);
821
- postToIframe(iframeRef.current, {
997
+ .slice(0, maxTargetsRef.current);
998
+ post({
822
999
  type: `${PREFIX}find-and-mark`,
823
1000
  id: ann.id,
824
1001
  originalText: ann.originalText,
@@ -830,7 +1007,7 @@ export function useHtmlAnnotation({
830
1007
  });
831
1008
  }
832
1009
  },
833
- [iframeRef],
1010
+ [post],
834
1011
  );
835
1012
 
836
1013
  return {
@@ -842,6 +1019,7 @@ export function useHtmlAnnotation({
842
1019
  handleToolbarClose,
843
1020
  handleRequestComment,
844
1021
  handleCommentSubmit,
1022
+ handleCommentLooksGood,
845
1023
  handleCommentClose,
846
1024
  handleFloatingQuickLabel,
847
1025
  handleQuickLabelPickerDismiss,
@@ -852,5 +1030,6 @@ export function useHtmlAnnotation({
852
1030
  removeDraftTarget,
853
1031
  flashDraftTarget,
854
1032
  composerFocusToken,
1033
+ createdAnnotationIds: mintedHtmlAnnIds,
855
1034
  };
856
1035
  }
@@ -330,6 +330,7 @@ export const PlanCleanDiffView: React.FC<PlanCleanDiffViewProps> = ({
330
330
  contextText={commentPopover.contextText}
331
331
  isGlobal={false}
332
332
  initialText={commentPopover.initialText}
333
+ draftKey={`plan-diff:${commentPopover.index}:${commentPopover.diffContext}:${commentPopover.contextText}`}
333
334
  onSubmit={handleCommentSubmit}
334
335
  onClose={handleCommentClose}
335
336
  skillReferences
@@ -27,6 +27,8 @@ interface FileBrowserProps {
27
27
  annotationCounts?: Map<string, number>;
28
28
  highlightedFiles?: Set<string>;
29
29
  editStatuses?: Map<string, FileEditStatus>;
30
+ /** Prevent competing destination changes while a document is activating. */
31
+ selectionPending?: boolean;
30
32
  }
31
33
 
32
34
  export interface FileEditStatus {
@@ -246,7 +248,8 @@ const TreeNode: React.FC<{
246
248
  editStatuses?: Map<string, FileEditStatus>;
247
249
  workspaceStatus?: WorkspaceStatusPayload;
248
250
  forceExpandFolders?: boolean;
249
- }> = ({ node, depth, dirPath, expandedFolders, onToggleFolder, onSelectFile, activeFile, annotationCounts, highlightedFiles, editStatuses, workspaceStatus, forceExpandFolders = false }) => {
251
+ selectionPending?: boolean;
252
+ }> = ({ node, depth, dirPath, expandedFolders, onToggleFolder, onSelectFile, activeFile, annotationCounts, highlightedFiles, editStatuses, workspaceStatus, forceExpandFolders = false, selectionPending = false }) => {
250
253
  const folderKey = `${dirPath}:${node.path}`;
251
254
  const absolutePath = `${dirPath}/${node.path}`;
252
255
  const isExpanded = forceExpandFolders || expandedFolders.has(folderKey);
@@ -305,6 +308,7 @@ const TreeNode: React.FC<{
305
308
  editStatuses={editStatuses}
306
309
  workspaceStatus={workspaceStatus}
307
310
  forceExpandFolders={forceExpandFolders}
311
+ selectionPending={selectionPending}
308
312
  />
309
313
  ))}
310
314
  </>
@@ -347,7 +351,8 @@ const TreeNode: React.FC<{
347
351
  onClick={() => {
348
352
  if (!isSelectionDisabled) onSelectFile(absolutePath, dirPath);
349
353
  }}
350
- disabled={isSelectionDisabled}
354
+ disabled={isSelectionDisabled || selectionPending}
355
+ aria-busy={isActive && selectionPending ? true : undefined}
351
356
  className={`file-tree-item w-full text-left group ${isActive ? "active" : ""} ${fileCount > 0 ? "has-annotations" : ""} ${isHighlighted ? 'file-annotation-flash' : ''} ${isSelectionDisabled ? 'opacity-70 cursor-default' : ''}`}
352
357
  style={{ paddingLeft: paddingLeft + 15 }}
353
358
  title={isDeleted ? `${node.path} (${editStatus?.status === "missing" ? "missing on disk" : "deleted on disk"})` : node.path}
@@ -357,6 +362,9 @@ const TreeNode: React.FC<{
357
362
  </svg>
358
363
  <span className={`truncate flex-1 min-w-0 ${isDeleted ? "line-through" : ""}`}>{displayName}</span>
359
364
  <div className="ml-auto flex flex-shrink-0 items-center gap-1.5 text-[10px]">
365
+ {isActive && selectionPending && (
366
+ <span className="text-primary">Opening…</span>
367
+ )}
360
368
  {editMarker && (
361
369
  <span
362
370
  className={`inline-flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-[9px] font-semibold leading-none ${editMarker.className}`}
@@ -393,7 +401,8 @@ const DirSection: React.FC<{
393
401
  highlightedFiles?: Set<string>;
394
402
  editStatuses?: Map<string, FileEditStatus>;
395
403
  forceExpandFolders?: boolean;
396
- }> = ({ dir, expandedFolders, onToggleFolder, onSelectFile, activeFile, onRetry, annotationCounts, highlightedFiles, editStatuses, forceExpandFolders = false }) => {
404
+ selectionPending?: boolean;
405
+ }> = ({ dir, expandedFolders, onToggleFolder, onSelectFile, activeFile, onRetry, annotationCounts, highlightedFiles, editStatuses, forceExpandFolders = false, selectionPending = false }) => {
397
406
  const workspaceStatus = React.useMemo(() => normalizeWorkspaceStatus(dir.workspaceStatus), [dir.workspaceStatus]);
398
407
 
399
408
  if (dir.isLoading) {
@@ -443,6 +452,7 @@ const DirSection: React.FC<{
443
452
  editStatuses={editStatuses}
444
453
  workspaceStatus={workspaceStatus}
445
454
  forceExpandFolders={forceExpandFolders}
455
+ selectionPending={selectionPending}
446
456
  />
447
457
  ))}
448
458
  </div>
@@ -462,6 +472,7 @@ export const FileBrowser: React.FC<FileBrowserProps> = ({
462
472
  annotationCounts,
463
473
  highlightedFiles,
464
474
  editStatuses,
475
+ selectionPending = false,
465
476
  }) => {
466
477
  const [isFilterOpen, setIsFilterOpen] = React.useState(false);
467
478
  const [filterQuery, setFilterQuery] = React.useState("");
@@ -528,7 +539,7 @@ export const FileBrowser: React.FC<FileBrowserProps> = ({
528
539
  {showFilterInput ? (
529
540
  <div
530
541
  onBlur={handleFilterBlur}
531
- className="flex h-6 items-center gap-1.5 rounded-sm bg-muted/25 px-1.5 text-muted-foreground focus-within:bg-muted/40"
542
+ className="file-browser-filter-field flex h-6 items-center gap-1.5 rounded-sm bg-muted/25 px-1.5 text-muted-foreground focus-within:bg-muted/40"
532
543
  >
533
544
  <Search size={12} className="shrink-0 text-muted-foreground/55" aria-hidden="true" />
534
545
  <input
@@ -556,7 +567,7 @@ export const FileBrowser: React.FC<FileBrowserProps> = ({
556
567
  setFilterQuery("");
557
568
  inputRef.current?.focus();
558
569
  }}
559
- className="flex h-5 w-5 shrink-0 items-center justify-center rounded-sm text-muted-foreground/55 hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:bg-muted"
570
+ className="file-browser-filter-clear flex h-5 w-5 shrink-0 items-center justify-center rounded-sm text-muted-foreground/55 hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:bg-muted"
560
571
  aria-label="Clear file filter"
561
572
  title="Clear file filter"
562
573
  >
@@ -617,6 +628,7 @@ export const FileBrowser: React.FC<FileBrowserProps> = ({
617
628
  highlightedFiles={highlightedFiles}
618
629
  editStatuses={editStatuses}
619
630
  forceExpandFolders={isFiltering}
631
+ selectionPending={selectionPending}
620
632
  />
621
633
  )}
622
634
  </div>