@plannotator/ui 0.29.1 → 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 +68 -35
  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 +215 -39
  34. package/components/html-viewer/bridge-script.ts +384 -66
  35. package/components/html-viewer/composerYield.ts +1 -51
  36. package/components/html-viewer/useHtmlAnnotation.ts +179 -58
  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;
@@ -65,7 +73,31 @@ type BridgeMessage =
65
73
  | { type: `${typeof PREFIX}selection-rect`; rect: BridgeRect }
66
74
  | { type: `${typeof PREFIX}keytype`; key: string }
67
75
  | { type: `${typeof PREFIX}mark-click`; id: string }
68
- | { type: `${typeof PREFIX}resize`; height: number };
76
+ | { type: `${typeof PREFIX}unanchored`; ids: string[] }
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
+ }
69
101
 
70
102
  /** Dependencies and callbacks for the sandboxed HTML annotation bridge. */
71
103
  export interface UseHtmlAnnotationOptions {
@@ -78,15 +110,44 @@ export interface UseHtmlAnnotationOptions {
78
110
  selectedAnnotationId: string | null;
79
111
  mode: EditorMode;
80
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;
81
119
  /** Validated pointer positions relayed from inside the iframe while a
82
120
  * pinpoint draft is open (iframe-local viewport coordinates), with the
83
121
  * Shift state observed by the iframe (the parent cannot see modifiers
84
122
  * held while the pointer lives in the sandbox). Drives the composer-yield
85
123
  * fade in the host component. */
86
124
  onBridgePointer?: (x: number, y: number, shift: boolean) => void;
125
+ /** Reports the full set of annotation ids that currently have NO live
126
+ * representation on the page — every target dead, or the restore never
127
+ * resolved (fail-closed anchors hide markers rather than guess). Called
128
+ * with the complete current set whenever it changes, including back to
129
+ * empty on recovery. Delivered in readOnly mode too: view-only surfaces
130
+ * are exactly where silently missing markers would go unnoticed. */
131
+ onUnanchoredChange?: (ids: string[]) => void;
87
132
  }
88
133
 
89
- 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
+ }
90
151
  iframe?.contentWindow?.postMessage(msg, "*");
91
152
  }
92
153
 
@@ -258,10 +319,29 @@ export function parseBridgeMessage(value: unknown): BridgeMessage | null {
258
319
  return typeof value.id === "string" && value.id.length <= 256
259
320
  ? { type: value.type, id: value.id }
260
321
  : null;
322
+ case `${PREFIX}unanchored`: {
323
+ // Bounded like the bridge's own emission (512 ids, 256 chars each); any
324
+ // out-of-contract entry rejects the whole report — the real bridge
325
+ // never sends one, so a violation means a forged message.
326
+ if (!Array.isArray(value.ids) || value.ids.length > 512) return null;
327
+ const unanchoredIds: string[] = [];
328
+ for (const entry of value.ids) {
329
+ if (typeof entry !== "string" || entry.length > 256) return null;
330
+ unanchoredIds.push(entry);
331
+ }
332
+ return { type: value.type, ids: unanchoredIds };
333
+ }
261
334
  case `${PREFIX}resize`:
262
335
  return typeof value.height === "number" && Number.isFinite(value.height)
263
336
  ? { type: value.type, height: value.height }
264
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;
265
345
  default:
266
346
  return null;
267
347
  }
@@ -281,7 +361,10 @@ export function useHtmlAnnotation({
281
361
  selectedAnnotationId,
282
362
  mode,
283
363
  onResize,
364
+ live,
365
+ onPageChange,
284
366
  onBridgePointer,
367
+ onUnanchoredChange,
285
368
  }: UseHtmlAnnotationOptions): Omit<
286
369
  UseAnnotationHighlighterReturn,
287
370
  "highlighterRef" | "highlightRange" | "highlightMathElement"
@@ -328,9 +411,24 @@ export function useHtmlAnnotation({
328
411
  onAddRef.current = onAddAnnotation;
329
412
  const onSelectRef = useRef(onSelectAnnotation);
330
413
  onSelectRef.current = onSelectAnnotation;
414
+ const onUnanchoredChangeRef = useRef(onUnanchoredChange);
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;
331
420
 
332
421
  const anchorRef = useRef<HTMLDivElement | null>(null);
333
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
+
334
432
  /**
335
433
  * Remove one draft target. Removing the primary promotes the next remaining
336
434
  * target (the composer's context text and pending anchor follow it);
@@ -345,7 +443,7 @@ export function useHtmlAnnotation({
345
443
  const targets = draftTargetsRef.current;
346
444
  const index = targets.findIndex((t) => t.key === key);
347
445
  if (index < 0) return;
348
- postToIframe(iframeRef.current, { type: `${PREFIX}remove-target`, key });
446
+ post({ type: `${PREFIX}remove-target`, key });
349
447
  const remaining = targets.filter((t) => t.key !== key);
350
448
  if (remaining.length === 0) {
351
449
  // Final target removed — the draft is cancelled (bridge side already
@@ -369,7 +467,7 @@ export function useHtmlAnnotation({
369
467
  setDraftTargets(remaining);
370
468
  setComposerFocusToken((t) => t + 1);
371
469
  },
372
- [iframeRef],
470
+ [post],
373
471
  );
374
472
 
375
473
  const getOrCreateAnchor = useCallback(() => {
@@ -406,12 +504,24 @@ export function useHtmlAnnotation({
406
504
  useEffect(() => {
407
505
  function handler(e: MessageEvent<unknown>) {
408
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;
409
512
  const message = parseBridgeMessage(e.data);
410
513
  if (!message) return;
411
514
 
412
515
  const type = message.type;
413
516
 
414
- if (!enabledRef.current && type !== `${PREFIX}mark-click` && type !== `${PREFIX}resize`) {
517
+ if (
518
+ !enabledRef.current
519
+ && type !== `${PREFIX}mark-click`
520
+ && type !== `${PREFIX}unanchored`
521
+ && type !== `${PREFIX}resize`
522
+ // Page identity is navigation state, not an annotation mutation.
523
+ && type !== `${PREFIX}page-change`
524
+ ) {
415
525
  return;
416
526
  }
417
527
 
@@ -422,25 +532,20 @@ export function useHtmlAnnotation({
422
532
  const anchor = positionAnchor(message.rect);
423
533
  if (!anchor) return;
424
534
 
425
- const currentMode = message.modeOverride ?? modeRef.current;
426
-
427
- if (currentMode === "redline") {
428
- const id = nextHtmlAnnId();
429
- postToIframe(iframeRef.current, { type: `${PREFIX}create-mark`, id, annotationType: "deletion" });
430
- onAddRef.current?.({
431
- id,
432
- blockId: "",
433
- startOffset: 0,
434
- endOffset: 0,
435
- type: AnnotationType.DELETION,
436
- originalText: message.text,
437
- author: getIdentity(),
438
- createdA: Date.now(),
439
- htmlAnchor: message.anchor,
440
- });
441
- pendingTextRef.current = "";
442
- pendingAnchorRef.current = null;
443
- } 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 (
444
549
  currentMode === "comment"
445
550
  // Pinpoint click-to-pin: the click already chose the target, so skip
446
551
  // the intermediate toolbar and go straight to the comment composer.
@@ -453,6 +558,7 @@ export function useHtmlAnnotation({
453
558
  anchorEl: anchor,
454
559
  contextText: message.text,
455
560
  selectedText: message.text,
561
+ draftKey: htmlCommentDraftKey(message.text, message.anchor, message.targetKey),
456
562
  });
457
563
  // Pinpoint drafts arm shift-click multi-select: the clicked element
458
564
  // becomes the primary target of the (single) draft comment. The
@@ -468,16 +574,11 @@ export function useHtmlAnnotation({
468
574
  anchor: message.anchor ?? null,
469
575
  },
470
576
  ]);
471
- postToIframe(iframeRef.current, {
577
+ post({
472
578
  type: `${PREFIX}arm-multi-select`,
473
579
  key: message.targetKey,
474
580
  });
475
581
  }
476
- } else if (currentMode === "quickLabel") {
477
- setQuickLabelPicker({
478
- anchorEl: anchor,
479
- cursorHint: { x: parseFloat(anchor.style.left), y: parseFloat(anchor.style.top) },
480
- });
481
582
  } else {
482
583
  setToolbarState({
483
584
  element: anchor,
@@ -557,16 +658,30 @@ export function useHtmlAnnotation({
557
658
  // the typing) — otherwise the iframe keeps focus and the bridge eats keys.
558
659
  iframeRef.current?.blur();
559
660
  setToolbarState(null);
560
- 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
+ });
561
668
  }
562
669
 
563
670
  if (type === `${PREFIX}mark-click`) {
564
671
  onSelectRef.current?.(message.id);
565
672
  }
566
673
 
674
+ if (type === `${PREFIX}unanchored`) {
675
+ onUnanchoredChangeRef.current?.(message.ids);
676
+ }
677
+
567
678
  if (type === `${PREFIX}resize`) {
568
679
  onResize?.(message.height);
569
680
  }
681
+
682
+ if (type === `${PREFIX}page-change`) {
683
+ onPageChangeRef.current?.(message.pageUrl);
684
+ }
570
685
  }
571
686
 
572
687
  window.addEventListener("message", handler);
@@ -577,7 +692,7 @@ export function useHtmlAnnotation({
577
692
  anchorRef.current = null;
578
693
  }
579
694
  };
580
- }, [iframeRef, positionAnchor, onResize, getOrCreateAnchor, applyTargetRemoval]);
695
+ }, [post, iframeRef, positionAnchor, onResize, getOrCreateAnchor, applyTargetRemoval]);
581
696
 
582
697
  useEffect(() => {
583
698
  if (enabled) return;
@@ -589,22 +704,22 @@ export function useHtmlAnnotation({
589
704
  pendingAnchorRef.current = null;
590
705
  anchorRef.current?.remove();
591
706
  anchorRef.current = null;
592
- postToIframe(iframeRef.current, { type: `${PREFIX}cancel-selection` });
593
- }, [enabled, iframeRef]);
707
+ post({ type: `${PREFIX}cancel-selection` });
708
+ }, [enabled, post]);
594
709
 
595
710
  useEffect(() => {
596
711
  if (selectedAnnotationId) {
597
- postToIframe(iframeRef.current, {
712
+ post({
598
713
  type: `${PREFIX}scroll-to`,
599
714
  id: selectedAnnotationId,
600
715
  });
601
716
  } else {
602
- postToIframe(iframeRef.current, {
717
+ post({
603
718
  type: `${PREFIX}focus-mark`,
604
719
  id: null,
605
720
  });
606
721
  }
607
- }, [selectedAnnotationId, iframeRef]);
722
+ }, [selectedAnnotationId, post]);
608
723
 
609
724
  const handleAnnotate = useCallback(
610
725
  (type: AnnotationType) => {
@@ -613,7 +728,7 @@ export function useHtmlAnnotation({
613
728
  if (!text || type !== AnnotationType.DELETION) return;
614
729
 
615
730
  const id = nextHtmlAnnId();
616
- postToIframe(iframeRef.current, { type: `${PREFIX}create-mark`, id, annotationType: "deletion" });
731
+ post({ type: `${PREFIX}create-mark`, id, annotationType: "deletion" });
617
732
  onAddRef.current?.({
618
733
  id,
619
734
  blockId: "",
@@ -630,7 +745,7 @@ export function useHtmlAnnotation({
630
745
  pendingTextRef.current = "";
631
746
  pendingAnchorRef.current = null;
632
747
  },
633
- [iframeRef],
748
+ [post],
634
749
  );
635
750
 
636
751
  const handleRequestComment = useCallback(
@@ -640,7 +755,13 @@ export function useHtmlAnnotation({
640
755
  if (!text) return;
641
756
  const anchor = anchorRef.current ?? getOrCreateAnchor();
642
757
  setToolbarState(null);
643
- 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
+ });
644
765
  },
645
766
  [getOrCreateAnchor],
646
767
  );
@@ -665,7 +786,7 @@ export function useHtmlAnnotation({
665
786
  : undefined;
666
787
 
667
788
  const id = nextHtmlAnnId();
668
- postToIframe(iframeRef.current, { type: `${PREFIX}create-mark`, id, annotationType: "comment" });
789
+ post({ type: `${PREFIX}create-mark`, id, annotationType: "comment" });
669
790
  onAddRef.current?.({
670
791
  id,
671
792
  blockId: "",
@@ -686,16 +807,16 @@ export function useHtmlAnnotation({
686
807
  pendingTextRef.current = "";
687
808
  pendingAnchorRef.current = null;
688
809
  },
689
- [iframeRef],
810
+ [post],
690
811
  );
691
812
 
692
813
  const handleCommentClose = useCallback(() => {
693
- postToIframe(iframeRef.current, { type: `${PREFIX}cancel-selection` });
814
+ post({ type: `${PREFIX}cancel-selection` });
694
815
  setCommentPopover(null);
695
816
  setDraftTargets([]);
696
817
  pendingTextRef.current = "";
697
818
  pendingAnchorRef.current = null;
698
- }, [iframeRef]);
819
+ }, [post]);
699
820
 
700
821
  const removeDraftTarget = useCallback(
701
822
  (key: string) => {
@@ -707,17 +828,17 @@ export function useHtmlAnnotation({
707
828
 
708
829
  const flashDraftTarget = useCallback(
709
830
  (key: string) => {
710
- postToIframe(iframeRef.current, { type: `${PREFIX}flash-target`, key });
831
+ post({ type: `${PREFIX}flash-target`, key });
711
832
  },
712
- [iframeRef],
833
+ [post],
713
834
  );
714
835
 
715
836
  const handleToolbarClose = useCallback(() => {
716
- postToIframe(iframeRef.current, { type: `${PREFIX}cancel-selection` });
837
+ post({ type: `${PREFIX}cancel-selection` });
717
838
  setToolbarState(null);
718
839
  pendingTextRef.current = "";
719
840
  pendingAnchorRef.current = null;
720
- }, [iframeRef]);
841
+ }, [post]);
721
842
 
722
843
  const applyQuickLabel = useCallback(
723
844
  (label: QuickLabel, clearState: () => void) => {
@@ -725,7 +846,7 @@ export function useHtmlAnnotation({
725
846
  const text = pendingTextRef.current;
726
847
  if (!text) return;
727
848
  const id = nextHtmlAnnId();
728
- postToIframe(iframeRef.current, { type: `${PREFIX}create-mark`, id, annotationType: "comment" });
849
+ post({ type: `${PREFIX}create-mark`, id, annotationType: "comment" });
729
850
  onAddRef.current?.({
730
851
  id,
731
852
  blockId: "",
@@ -744,7 +865,7 @@ export function useHtmlAnnotation({
744
865
  pendingTextRef.current = "";
745
866
  pendingAnchorRef.current = null;
746
867
  },
747
- [iframeRef],
868
+ [post],
748
869
  );
749
870
 
750
871
  const handleQuickLabel = useCallback(
@@ -758,22 +879,22 @@ export function useHtmlAnnotation({
758
879
  );
759
880
 
760
881
  const handleQuickLabelPickerDismiss = useCallback(() => {
761
- postToIframe(iframeRef.current, { type: `${PREFIX}cancel-selection` });
882
+ post({ type: `${PREFIX}cancel-selection` });
762
883
  setQuickLabelPicker(null);
763
884
  pendingTextRef.current = "";
764
885
  pendingAnchorRef.current = null;
765
- }, [iframeRef]);
886
+ }, [post]);
766
887
 
767
888
  const removeHighlight = useCallback(
768
889
  (id: string) => {
769
- postToIframe(iframeRef.current, { type: `${PREFIX}remove-mark`, id });
890
+ post({ type: `${PREFIX}remove-mark`, id });
770
891
  },
771
- [iframeRef],
892
+ [post],
772
893
  );
773
894
 
774
895
  const clearAllHighlights = useCallback(() => {
775
- postToIframe(iframeRef.current, { type: `${PREFIX}clear-marks` });
776
- }, [iframeRef]);
896
+ post({ type: `${PREFIX}clear-marks` });
897
+ }, [post]);
777
898
 
778
899
  const applyAnnotations = useCallback(
779
900
  (anns: Annotation[]) => {
@@ -786,7 +907,7 @@ export function useHtmlAnnotation({
786
907
  .map((t) => t.anchor)
787
908
  .filter((a): a is HtmlElementAnchor => !!a)
788
909
  .slice(0, MAX_ADDITIONAL_TARGETS);
789
- postToIframe(iframeRef.current, {
910
+ post({
790
911
  type: `${PREFIX}find-and-mark`,
791
912
  id: ann.id,
792
913
  originalText: ann.originalText,
@@ -798,7 +919,7 @@ export function useHtmlAnnotation({
798
919
  });
799
920
  }
800
921
  },
801
- [iframeRef],
922
+ [post],
802
923
  );
803
924
 
804
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>