@plannotator/ui 0.30.0 → 0.31.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 (74) hide show
  1. package/README.md +1 -0
  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 +62 -32
  6. package/components/AnnotationToolbar.tsx +28 -8
  7. package/components/AnnotationToolstrip.tsx +9 -0
  8. package/components/CommentPopover.tsx +212 -46
  9. package/components/ConfirmDialog.tsx +42 -28
  10. package/components/InlineMarkdown.tsx +3 -2
  11. package/components/KeyboardShortcuts.tsx +9 -0
  12. package/components/Landing.tsx +1 -1
  13. package/components/LookAndFeelAnnouncementDialog.tsx +147 -178
  14. package/components/MarkdownEditor/embedPicker.ts +349 -0
  15. package/components/MarkdownEditor.tsx +12 -0
  16. package/components/ModeToggle.tsx +2 -1
  17. package/components/PermissionModeSetup.tsx +24 -5
  18. package/components/PinpointOverlay.tsx +11 -6
  19. package/components/PlanHeaderMenu.tsx +140 -1
  20. package/components/SearchableSelect.tsx +2 -0
  21. package/components/Settings.tsx +136 -10
  22. package/components/SkillReferenceMenu.tsx +9 -0
  23. package/components/StickyHeaderLane.tsx +9 -2
  24. package/components/TableOfContents.tsx +9 -4
  25. package/components/TextShimmer.tsx +8 -5
  26. package/components/ThemeProvider.tsx +43 -1
  27. package/components/ThemeTab.tsx +52 -1
  28. package/components/Tooltip.tsx +3 -1
  29. package/components/Viewer.tsx +19 -5
  30. package/components/VimTargetReticle.tsx +12 -4
  31. package/components/ai/DocumentAIChatPanel.tsx +1 -0
  32. package/components/core/button.tsx +14 -6
  33. package/components/html-viewer/HtmlViewer.tsx +208 -39
  34. package/components/html-viewer/bridge-script.ts +319 -65
  35. package/components/html-viewer/composerYield.ts +1 -51
  36. package/components/html-viewer/useHtmlAnnotation.ts +146 -57
  37. package/components/plan-diff/PlanCleanDiffView.tsx +1 -0
  38. package/components/sidebar/FileBrowser.tsx +17 -5
  39. package/components/sidebar/SidebarContainer.tsx +124 -28
  40. package/components/ui/button.tsx +10 -8
  41. package/components/ui/dialog.tsx +35 -25
  42. package/config/index.ts +6 -1
  43. package/config/reviewView.ts +42 -9
  44. package/config/settings.ts +141 -0
  45. package/hooks/useAIProviderConfig.ts +8 -7
  46. package/hooks/useActiveSection.ts +6 -4
  47. package/hooks/useAgentJobs.ts +3 -0
  48. package/hooks/useAnnotationHighlighter.ts +20 -0
  49. package/hooks/useIsMobile.ts +37 -0
  50. package/hooks/useLinkedDoc.ts +7 -0
  51. package/hooks/useScrollViewport.ts +74 -0
  52. package/hooks/useViewportEnvironment.ts +350 -0
  53. package/package.json +3 -2
  54. package/shortcuts/index.ts +3 -0
  55. package/shortcuts/plan-review/annotationMode.shortcuts.ts +91 -0
  56. package/shortcuts/plan-review/documentView.shortcuts.ts +26 -0
  57. package/shortcuts/plan-review/htmlAnnotate.shortcuts.ts +24 -0
  58. package/styles.css +1 -1
  59. package/theme.css +229 -0
  60. package/types.ts +34 -0
  61. package/utils/annotateAgentTerminal.ts +36 -5
  62. package/utils/blockTargeting.ts +6 -3
  63. package/utils/composerYield.ts +45 -0
  64. package/utils/htmlChrome.ts +20 -16
  65. package/utils/lookAndFeelAnnouncement.ts +12 -8
  66. package/utils/markdownExtensions.ts +57 -0
  67. package/utils/parser.ts +37 -2
  68. package/utils/vimNavigation.ts +4 -1
  69. package/utils/vimScroll.ts +9 -4
  70. package/utils/wideMode.ts +20 -0
  71. package/components/PlanAIAnnouncementDialog.tsx +0 -187
  72. package/components/VimModeAnnouncementDialog.tsx +0 -557
  73. package/utils/planAIAnnouncement.ts +0 -17
  74. package/utils/vimModeAnnouncement.ts +0 -23
@@ -1,51 +1 @@
1
- /**
2
- * Composer-yield state machine for shift-click multi-select.
3
- *
4
- * While Shift is held during an HTML pinpoint draft, the comment composer must
5
- * get out of the way of the pointer: fade to ~40% as the pointer approaches,
6
- * and to near-zero + click-through (pointer-events: none) when the pointer is
7
- * over it — the DOM equivalent of Codex's setIgnoreMouseEvents click-through
8
- * popup, so the shift-click lands on the iframe beneath.
9
- *
10
- * Restoration uses hysteresis so the composer doesn't flicker at the border:
11
- * once 'over', the pointer must clear the composer by OVER_EXIT px before the
12
- * state drops back, and 'near' persists until NEAR_EXIT px (> NEAR_ENTER).
13
- */
14
-
15
- export type ComposerYieldState = "none" | "near" | "over";
16
-
17
- /** Distance (px from the composer rect) at which the composer starts fading. */
18
- export const COMPOSER_YIELD_NEAR_ENTER_PX = 80;
19
- /** Distance the pointer must exceed before a faded composer fully restores. */
20
- export const COMPOSER_YIELD_NEAR_EXIT_PX = 96;
21
- /** Distance the pointer must clear a click-through composer by to restore. */
22
- export const COMPOSER_YIELD_OVER_EXIT_PX = 48;
23
-
24
- /** Shortest distance from a point to a rect's edge; <= 0 means inside. */
25
- export function distanceToRect(
26
- x: number,
27
- y: number,
28
- rect: { left: number; top: number; right: number; bottom: number },
29
- ): number {
30
- const dx = Math.max(rect.left - x, 0, x - rect.right);
31
- const dy = Math.max(rect.top - y, 0, y - rect.bottom);
32
- if (dx === 0 && dy === 0) return 0;
33
- return Math.hypot(dx, dy);
34
- }
35
-
36
- /** Next yield state for a pointer at `distance` px from the composer rect. */
37
- export function computeComposerYield(
38
- prev: ComposerYieldState,
39
- distance: number,
40
- ): ComposerYieldState {
41
- if (distance <= 0) return "over";
42
- if (prev === "over") {
43
- // Hysteresis: don't restore until the pointer has moved well away.
44
- if (distance <= COMPOSER_YIELD_OVER_EXIT_PX) return "over";
45
- return distance <= COMPOSER_YIELD_NEAR_EXIT_PX ? "near" : "none";
46
- }
47
- if (prev === "near") {
48
- return distance <= COMPOSER_YIELD_NEAR_EXIT_PX ? "near" : "none";
49
- }
50
- return distance <= COMPOSER_YIELD_NEAR_ENTER_PX ? "near" : "none";
51
- }
1
+ export * from '../../utils/composerYield';
@@ -18,6 +18,14 @@ function nextHtmlAnnId(): string {
18
18
  return `html-ann-${Date.now().toString(36)}-${(htmlAnnSeq++).toString(36)}`;
19
19
  }
20
20
 
21
+ function htmlCommentDraftKey(
22
+ text: string,
23
+ anchor?: HtmlElementAnchor | null,
24
+ targetKey?: string,
25
+ ): string {
26
+ return `html-selection:${targetKey ?? JSON.stringify(anchor ?? null)}:${text}`;
27
+ }
28
+
21
29
  interface BridgeSelectionMessage {
22
30
  type: `${typeof PREFIX}selection`;
23
31
  text: string;
@@ -66,7 +74,30 @@ type BridgeMessage =
66
74
  | { type: `${typeof PREFIX}keytype`; key: string }
67
75
  | { type: `${typeof PREFIX}mark-click`; id: string }
68
76
  | { type: `${typeof PREFIX}unanchored`; ids: string[] }
69
- | { type: `${typeof PREFIX}resize`; height: number };
77
+ | { type: `${typeof PREFIX}resize`; height: number }
78
+ | { type: `${typeof PREFIX}page-change`; pageUrl: string };
79
+
80
+ /** Live proxied-app session credentials: the proxy origin messages must come
81
+ * from, and the per-session token every message must echo. */
82
+ export interface HtmlLiveSession {
83
+ origin: string;
84
+ token: string;
85
+ }
86
+
87
+ /** Cap for live-mode page identity strings (mirrors the bridge's slice). */
88
+ export const MAX_PAGE_URL_LENGTH = 2048;
89
+
90
+ /** True when a live-session message event fails the origin or token check.
91
+ * Exported for protocol tests. */
92
+ export function rejectsLiveMessage(
93
+ live: HtmlLiveSession,
94
+ origin: string,
95
+ data: unknown,
96
+ ): boolean {
97
+ if (origin !== live.origin) return true;
98
+ if (!isRecord(data) || data.token !== live.token) return true;
99
+ return false;
100
+ }
70
101
 
71
102
  /** Dependencies and callbacks for the sandboxed HTML annotation bridge. */
72
103
  export interface UseHtmlAnnotationOptions {
@@ -79,6 +110,12 @@ export interface UseHtmlAnnotationOptions {
79
110
  selectedAnnotationId: string | null;
80
111
  mode: EditorMode;
81
112
  onResize?: (height: number) => void;
113
+ /** Live proxied-app session: reject messages that fail the origin or token
114
+ * check before parsing, and stamp token + concrete targetOrigin on every
115
+ * outbound post. Absent for srcdoc sessions (behavior unchanged). */
116
+ live?: HtmlLiveSession;
117
+ /** Live-mode page navigation reports (validated, capped at 2048 chars). */
118
+ onPageChange?: (pageUrl: string) => void;
82
119
  /** Validated pointer positions relayed from inside the iframe while a
83
120
  * pinpoint draft is open (iframe-local viewport coordinates), with the
84
121
  * Shift state observed by the iframe (the parent cannot see modifiers
@@ -94,7 +131,23 @@ export interface UseHtmlAnnotationOptions {
94
131
  onUnanchoredChange?: (ids: string[]) => void;
95
132
  }
96
133
 
97
- function postToIframe(iframe: HTMLIFrameElement | null, msg: Record<string, unknown>) {
134
+ function postToIframe(
135
+ iframe: HTMLIFrameElement | null,
136
+ msg: Record<string, unknown>,
137
+ live?: HtmlLiveSession | null,
138
+ ) {
139
+ if (live) {
140
+ // Live proxied-app sessions: token on every message, concrete targetOrigin.
141
+ // Browsers silently DROP a post whose targetOrigin does not match the
142
+ // receiving window (mid-navigation frames); some DOM environments throw
143
+ // instead, so align with the browser semantics explicitly.
144
+ try {
145
+ iframe?.contentWindow?.postMessage({ ...msg, token: live.token }, live.origin);
146
+ } catch {
147
+ // Dropped, matching browser behavior for unmatched target origins.
148
+ }
149
+ return;
150
+ }
98
151
  iframe?.contentWindow?.postMessage(msg, "*");
99
152
  }
100
153
 
@@ -282,6 +335,13 @@ export function parseBridgeMessage(value: unknown): BridgeMessage | null {
282
335
  return typeof value.height === "number" && Number.isFinite(value.height)
283
336
  ? { type: value.type, height: value.height }
284
337
  : null;
338
+ case `${PREFIX}page-change`:
339
+ // Live-mode SPA navigation report. Bounded like every bridge string.
340
+ return typeof value.pageUrl === "string"
341
+ && value.pageUrl.length > 0
342
+ && value.pageUrl.length <= MAX_PAGE_URL_LENGTH
343
+ ? { type: value.type, pageUrl: value.pageUrl }
344
+ : null;
285
345
  default:
286
346
  return null;
287
347
  }
@@ -301,6 +361,8 @@ export function useHtmlAnnotation({
301
361
  selectedAnnotationId,
302
362
  mode,
303
363
  onResize,
364
+ live,
365
+ onPageChange,
304
366
  onBridgePointer,
305
367
  onUnanchoredChange,
306
368
  }: UseHtmlAnnotationOptions): Omit<
@@ -351,9 +413,22 @@ export function useHtmlAnnotation({
351
413
  onSelectRef.current = onSelectAnnotation;
352
414
  const onUnanchoredChangeRef = useRef(onUnanchoredChange);
353
415
  onUnanchoredChangeRef.current = onUnanchoredChange;
416
+ const liveRef = useRef<HtmlLiveSession | null>(live ?? null);
417
+ liveRef.current = live ?? null;
418
+ const onPageChangeRef = useRef(onPageChange);
419
+ onPageChangeRef.current = onPageChange;
354
420
 
355
421
  const anchorRef = useRef<HTMLDivElement | null>(null);
356
422
 
423
+ /** Post into the iframe with the live token + targetOrigin when a live
424
+ * session is active; srcdoc posts keep targetOrigin "*" and no token. */
425
+ const post = useCallback(
426
+ (msg: Record<string, unknown>) => {
427
+ postToIframe(iframeRef.current, msg, liveRef.current);
428
+ },
429
+ [iframeRef],
430
+ );
431
+
357
432
  /**
358
433
  * Remove one draft target. Removing the primary promotes the next remaining
359
434
  * target (the composer's context text and pending anchor follow it);
@@ -368,7 +443,7 @@ export function useHtmlAnnotation({
368
443
  const targets = draftTargetsRef.current;
369
444
  const index = targets.findIndex((t) => t.key === key);
370
445
  if (index < 0) return;
371
- postToIframe(iframeRef.current, { type: `${PREFIX}remove-target`, key });
446
+ post({ type: `${PREFIX}remove-target`, key });
372
447
  const remaining = targets.filter((t) => t.key !== key);
373
448
  if (remaining.length === 0) {
374
449
  // Final target removed — the draft is cancelled (bridge side already
@@ -392,7 +467,7 @@ export function useHtmlAnnotation({
392
467
  setDraftTargets(remaining);
393
468
  setComposerFocusToken((t) => t + 1);
394
469
  },
395
- [iframeRef],
470
+ [post],
396
471
  );
397
472
 
398
473
  const getOrCreateAnchor = useCallback(() => {
@@ -429,6 +504,11 @@ export function useHtmlAnnotation({
429
504
  useEffect(() => {
430
505
  function handler(e: MessageEvent<unknown>) {
431
506
  if (e.source !== iframeRef.current?.contentWindow) return;
507
+ // Live sessions verify origin + session token BEFORE parsing. The
508
+ // existing caps stay exactly as they are: live content is friendlier
509
+ // but the trust boundary does not relax.
510
+ const liveSession = liveRef.current;
511
+ if (liveSession && rejectsLiveMessage(liveSession, e.origin, e.data)) return;
432
512
  const message = parseBridgeMessage(e.data);
433
513
  if (!message) return;
434
514
 
@@ -439,6 +519,8 @@ export function useHtmlAnnotation({
439
519
  && type !== `${PREFIX}mark-click`
440
520
  && type !== `${PREFIX}unanchored`
441
521
  && type !== `${PREFIX}resize`
522
+ // Page identity is navigation state, not an annotation mutation.
523
+ && type !== `${PREFIX}page-change`
442
524
  ) {
443
525
  return;
444
526
  }
@@ -450,25 +532,20 @@ export function useHtmlAnnotation({
450
532
  const anchor = positionAnchor(message.rect);
451
533
  if (!anchor) return;
452
534
 
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 (
535
+ // HTML and live-app surfaces are COMMENT-ONLY: redline (auto-DELETION)
536
+ // and quickLabel are markdown-surface features, so both the host's
537
+ // mode and any bridge-posted modeOverride clamp to the plain selection
538
+ // flow here (a hostile page can post modeOverride, so the clamp sits
539
+ // at the trust boundary, not in the host). Persisted DELETION
540
+ // annotations still restore through applyAnnotations — only CREATION
541
+ // is comment-only.
542
+ const requestedMode = message.modeOverride ?? modeRef.current;
543
+ const currentMode =
544
+ requestedMode === "redline" || requestedMode === "quickLabel"
545
+ ? "selection"
546
+ : requestedMode;
547
+
548
+ if (
472
549
  currentMode === "comment"
473
550
  // Pinpoint click-to-pin: the click already chose the target, so skip
474
551
  // the intermediate toolbar and go straight to the comment composer.
@@ -481,6 +558,7 @@ export function useHtmlAnnotation({
481
558
  anchorEl: anchor,
482
559
  contextText: message.text,
483
560
  selectedText: message.text,
561
+ draftKey: htmlCommentDraftKey(message.text, message.anchor, message.targetKey),
484
562
  });
485
563
  // Pinpoint drafts arm shift-click multi-select: the clicked element
486
564
  // becomes the primary target of the (single) draft comment. The
@@ -496,16 +574,11 @@ export function useHtmlAnnotation({
496
574
  anchor: message.anchor ?? null,
497
575
  },
498
576
  ]);
499
- postToIframe(iframeRef.current, {
577
+ post({
500
578
  type: `${PREFIX}arm-multi-select`,
501
579
  key: message.targetKey,
502
580
  });
503
581
  }
504
- } else if (currentMode === "quickLabel") {
505
- setQuickLabelPicker({
506
- anchorEl: anchor,
507
- cursorHint: { x: parseFloat(anchor.style.left), y: parseFloat(anchor.style.top) },
508
- });
509
582
  } else {
510
583
  setToolbarState({
511
584
  element: anchor,
@@ -585,7 +658,13 @@ export function useHtmlAnnotation({
585
658
  // the typing) — otherwise the iframe keeps focus and the bridge eats keys.
586
659
  iframeRef.current?.blur();
587
660
  setToolbarState(null);
588
- setCommentPopover({ anchorEl: anchor, contextText: text, selectedText: text, initialText: key });
661
+ setCommentPopover({
662
+ anchorEl: anchor,
663
+ contextText: text,
664
+ selectedText: text,
665
+ initialText: key,
666
+ draftKey: htmlCommentDraftKey(text, pendingAnchorRef.current),
667
+ });
589
668
  }
590
669
 
591
670
  if (type === `${PREFIX}mark-click`) {
@@ -599,6 +678,10 @@ export function useHtmlAnnotation({
599
678
  if (type === `${PREFIX}resize`) {
600
679
  onResize?.(message.height);
601
680
  }
681
+
682
+ if (type === `${PREFIX}page-change`) {
683
+ onPageChangeRef.current?.(message.pageUrl);
684
+ }
602
685
  }
603
686
 
604
687
  window.addEventListener("message", handler);
@@ -609,7 +692,7 @@ export function useHtmlAnnotation({
609
692
  anchorRef.current = null;
610
693
  }
611
694
  };
612
- }, [iframeRef, positionAnchor, onResize, getOrCreateAnchor, applyTargetRemoval]);
695
+ }, [post, iframeRef, positionAnchor, onResize, getOrCreateAnchor, applyTargetRemoval]);
613
696
 
614
697
  useEffect(() => {
615
698
  if (enabled) return;
@@ -621,22 +704,22 @@ export function useHtmlAnnotation({
621
704
  pendingAnchorRef.current = null;
622
705
  anchorRef.current?.remove();
623
706
  anchorRef.current = null;
624
- postToIframe(iframeRef.current, { type: `${PREFIX}cancel-selection` });
625
- }, [enabled, iframeRef]);
707
+ post({ type: `${PREFIX}cancel-selection` });
708
+ }, [enabled, post]);
626
709
 
627
710
  useEffect(() => {
628
711
  if (selectedAnnotationId) {
629
- postToIframe(iframeRef.current, {
712
+ post({
630
713
  type: `${PREFIX}scroll-to`,
631
714
  id: selectedAnnotationId,
632
715
  });
633
716
  } else {
634
- postToIframe(iframeRef.current, {
717
+ post({
635
718
  type: `${PREFIX}focus-mark`,
636
719
  id: null,
637
720
  });
638
721
  }
639
- }, [selectedAnnotationId, iframeRef]);
722
+ }, [selectedAnnotationId, post]);
640
723
 
641
724
  const handleAnnotate = useCallback(
642
725
  (type: AnnotationType) => {
@@ -645,7 +728,7 @@ export function useHtmlAnnotation({
645
728
  if (!text || type !== AnnotationType.DELETION) return;
646
729
 
647
730
  const id = nextHtmlAnnId();
648
- postToIframe(iframeRef.current, { type: `${PREFIX}create-mark`, id, annotationType: "deletion" });
731
+ post({ type: `${PREFIX}create-mark`, id, annotationType: "deletion" });
649
732
  onAddRef.current?.({
650
733
  id,
651
734
  blockId: "",
@@ -662,7 +745,7 @@ export function useHtmlAnnotation({
662
745
  pendingTextRef.current = "";
663
746
  pendingAnchorRef.current = null;
664
747
  },
665
- [iframeRef],
748
+ [post],
666
749
  );
667
750
 
668
751
  const handleRequestComment = useCallback(
@@ -672,7 +755,13 @@ export function useHtmlAnnotation({
672
755
  if (!text) return;
673
756
  const anchor = anchorRef.current ?? getOrCreateAnchor();
674
757
  setToolbarState(null);
675
- setCommentPopover({ anchorEl: anchor, contextText: text, selectedText: text, initialText: initialChar });
758
+ setCommentPopover({
759
+ anchorEl: anchor,
760
+ contextText: text,
761
+ selectedText: text,
762
+ initialText: initialChar,
763
+ draftKey: htmlCommentDraftKey(text, pendingAnchorRef.current),
764
+ });
676
765
  },
677
766
  [getOrCreateAnchor],
678
767
  );
@@ -697,7 +786,7 @@ export function useHtmlAnnotation({
697
786
  : undefined;
698
787
 
699
788
  const id = nextHtmlAnnId();
700
- postToIframe(iframeRef.current, { type: `${PREFIX}create-mark`, id, annotationType: "comment" });
789
+ post({ type: `${PREFIX}create-mark`, id, annotationType: "comment" });
701
790
  onAddRef.current?.({
702
791
  id,
703
792
  blockId: "",
@@ -718,16 +807,16 @@ export function useHtmlAnnotation({
718
807
  pendingTextRef.current = "";
719
808
  pendingAnchorRef.current = null;
720
809
  },
721
- [iframeRef],
810
+ [post],
722
811
  );
723
812
 
724
813
  const handleCommentClose = useCallback(() => {
725
- postToIframe(iframeRef.current, { type: `${PREFIX}cancel-selection` });
814
+ post({ type: `${PREFIX}cancel-selection` });
726
815
  setCommentPopover(null);
727
816
  setDraftTargets([]);
728
817
  pendingTextRef.current = "";
729
818
  pendingAnchorRef.current = null;
730
- }, [iframeRef]);
819
+ }, [post]);
731
820
 
732
821
  const removeDraftTarget = useCallback(
733
822
  (key: string) => {
@@ -739,17 +828,17 @@ export function useHtmlAnnotation({
739
828
 
740
829
  const flashDraftTarget = useCallback(
741
830
  (key: string) => {
742
- postToIframe(iframeRef.current, { type: `${PREFIX}flash-target`, key });
831
+ post({ type: `${PREFIX}flash-target`, key });
743
832
  },
744
- [iframeRef],
833
+ [post],
745
834
  );
746
835
 
747
836
  const handleToolbarClose = useCallback(() => {
748
- postToIframe(iframeRef.current, { type: `${PREFIX}cancel-selection` });
837
+ post({ type: `${PREFIX}cancel-selection` });
749
838
  setToolbarState(null);
750
839
  pendingTextRef.current = "";
751
840
  pendingAnchorRef.current = null;
752
- }, [iframeRef]);
841
+ }, [post]);
753
842
 
754
843
  const applyQuickLabel = useCallback(
755
844
  (label: QuickLabel, clearState: () => void) => {
@@ -757,7 +846,7 @@ export function useHtmlAnnotation({
757
846
  const text = pendingTextRef.current;
758
847
  if (!text) return;
759
848
  const id = nextHtmlAnnId();
760
- postToIframe(iframeRef.current, { type: `${PREFIX}create-mark`, id, annotationType: "comment" });
849
+ post({ type: `${PREFIX}create-mark`, id, annotationType: "comment" });
761
850
  onAddRef.current?.({
762
851
  id,
763
852
  blockId: "",
@@ -776,7 +865,7 @@ export function useHtmlAnnotation({
776
865
  pendingTextRef.current = "";
777
866
  pendingAnchorRef.current = null;
778
867
  },
779
- [iframeRef],
868
+ [post],
780
869
  );
781
870
 
782
871
  const handleQuickLabel = useCallback(
@@ -790,22 +879,22 @@ export function useHtmlAnnotation({
790
879
  );
791
880
 
792
881
  const handleQuickLabelPickerDismiss = useCallback(() => {
793
- postToIframe(iframeRef.current, { type: `${PREFIX}cancel-selection` });
882
+ post({ type: `${PREFIX}cancel-selection` });
794
883
  setQuickLabelPicker(null);
795
884
  pendingTextRef.current = "";
796
885
  pendingAnchorRef.current = null;
797
- }, [iframeRef]);
886
+ }, [post]);
798
887
 
799
888
  const removeHighlight = useCallback(
800
889
  (id: string) => {
801
- postToIframe(iframeRef.current, { type: `${PREFIX}remove-mark`, id });
890
+ post({ type: `${PREFIX}remove-mark`, id });
802
891
  },
803
- [iframeRef],
892
+ [post],
804
893
  );
805
894
 
806
895
  const clearAllHighlights = useCallback(() => {
807
- postToIframe(iframeRef.current, { type: `${PREFIX}clear-marks` });
808
- }, [iframeRef]);
896
+ post({ type: `${PREFIX}clear-marks` });
897
+ }, [post]);
809
898
 
810
899
  const applyAnnotations = useCallback(
811
900
  (anns: Annotation[]) => {
@@ -818,7 +907,7 @@ export function useHtmlAnnotation({
818
907
  .map((t) => t.anchor)
819
908
  .filter((a): a is HtmlElementAnchor => !!a)
820
909
  .slice(0, MAX_ADDITIONAL_TARGETS);
821
- postToIframe(iframeRef.current, {
910
+ post({
822
911
  type: `${PREFIX}find-and-mark`,
823
912
  id: ann.id,
824
913
  originalText: ann.originalText,
@@ -830,7 +919,7 @@ export function useHtmlAnnotation({
830
919
  });
831
920
  }
832
921
  },
833
- [iframeRef],
922
+ [post],
834
923
  );
835
924
 
836
925
  return {
@@ -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>