@plannotator/ui 0.28.0 → 0.29.1

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 (105) hide show
  1. package/README.md +6 -0
  2. package/components/AISettingsTab.tsx +5 -4
  3. package/components/ActionMenu.tsx +5 -1
  4. package/components/AgentsTab.tsx +10 -23
  5. package/components/AnnotationPanel.tsx +9 -5
  6. package/components/AnnotationToolbar.tsx +5 -13
  7. package/components/AnnotationToolstrip.tsx +2 -2
  8. package/components/ApproveDropdown.tsx +1 -1
  9. package/components/BlockRenderer.tsx +1 -1
  10. package/components/CodeFilePopout.tsx +5 -4
  11. package/components/CommentPopover.tsx +391 -65
  12. package/components/DocBadges.tsx +29 -11
  13. package/components/ExportModal.tsx +16 -8
  14. package/components/GraphvizBlock.tsx +1 -1
  15. package/components/InlineMarkdown.tsx +30 -13
  16. package/components/KeyboardShortcuts.tsx +37 -2
  17. package/components/Landing.tsx +7 -7
  18. package/components/MenuVersionSection.tsx +4 -4
  19. package/components/MermaidBlock.tsx +1 -1
  20. package/components/ModeToggle.tsx +7 -6
  21. package/components/OpenInAppButton.tsx +2 -5
  22. package/components/PinpointOverlay.tsx +9 -7
  23. package/components/PlanHeaderMenu.tsx +8 -8
  24. package/components/PopoutDialog.tsx +6 -1
  25. package/components/ResizeHandle.tsx +1 -0
  26. package/components/Settings.tsx +172 -12
  27. package/components/SkillReferenceMenu.tsx +260 -0
  28. package/components/StickyHeaderLane.tsx +7 -0
  29. package/components/ThemeProvider.tsx +131 -32
  30. package/components/ThemeTab.tsx +123 -77
  31. package/components/ToolbarButtons.tsx +29 -8
  32. package/components/Viewer.tsx +396 -130
  33. package/components/VimKeyHud.tsx +695 -0
  34. package/components/VimModeAnnouncementDialog.tsx +557 -0
  35. package/components/VimModeOverlay.tsx +235 -0
  36. package/components/VimTargetReticle.tsx +284 -0
  37. package/components/ai/DocumentAIChatPanel.tsx +1 -1
  38. package/components/blocks/CodeBlock.tsx +18 -18
  39. package/components/blocks/TablePopout.tsx +7 -8
  40. package/components/blocks/TableToolbar.tsx +7 -8
  41. package/components/goal-setup/GoalSetupSurface.tsx +16 -3
  42. package/components/html-viewer/HtmlViewer.tsx +450 -47
  43. package/components/html-viewer/annotationNumbering.ts +37 -0
  44. package/components/html-viewer/bridge-script.ts +4051 -298
  45. package/components/html-viewer/composerYield.ts +51 -0
  46. package/components/html-viewer/srcdoc.ts +18 -3
  47. package/components/html-viewer/useHtmlAnnotation.ts +457 -32
  48. package/components/icons/themeIcons.tsx +1 -1
  49. package/components/plan-diff/PlanCleanDiffView.tsx +9 -9
  50. package/components/plan-diff/PlanDiffBadge.tsx +22 -1
  51. package/components/settings/HooksTab.tsx +12 -8
  52. package/components/sidebar/FileBrowser.tsx +4 -1
  53. package/components/themeModes.tsx +28 -0
  54. package/config/configStore.ts +76 -1
  55. package/config/settings.ts +152 -0
  56. package/configure.ts +9 -0
  57. package/globals.d.ts +7 -1
  58. package/hooks/useAIChat.ts +5 -2
  59. package/hooks/useAIProviderActivation.ts +47 -0
  60. package/hooks/useAIProviderConfig.ts +5 -1
  61. package/hooks/useAgentSettings.ts +64 -23
  62. package/hooks/useAgents.ts +4 -4
  63. package/hooks/useAnnotationHighlighter.ts +100 -3
  64. package/hooks/useArchive.ts +2 -1
  65. package/hooks/useFenceTheme.ts +17 -0
  66. package/hooks/useLinkedDoc.ts +68 -1
  67. package/hooks/usePinpoint.ts +76 -75
  68. package/hooks/usePlanDiff.ts +73 -2
  69. package/hooks/useSkillReferenceAutocomplete.ts +239 -0
  70. package/hooks/useUpdateCheck.ts +1 -2
  71. package/hooks/useVimDocumentFocus.ts +116 -0
  72. package/hooks/useVimSelection.ts +1063 -0
  73. package/package.json +4 -4
  74. package/print.css +14 -13
  75. package/shortcuts/core.ts +38 -13
  76. package/shortcuts/index.ts +10 -0
  77. package/shortcuts/plan-review/commentPopover.shortcuts.ts +7 -0
  78. package/shortcuts/plan-review/vimSelection.shortcuts.ts +251 -0
  79. package/shortcuts/runtime.ts +111 -12
  80. package/styles.css +1 -1
  81. package/theme.css +504 -0
  82. package/themes/colorblind.css +89 -0
  83. package/themes/plannotator.css +2 -2
  84. package/types.ts +93 -10
  85. package/utils/agentSwitch.ts +33 -7
  86. package/utils/blockTargeting.ts +462 -178
  87. package/utils/clipboard.ts +110 -0
  88. package/utils/codeBlockMark.ts +50 -0
  89. package/utils/codeHighlight.ts +293 -0
  90. package/utils/codexModels.ts +79 -0
  91. package/utils/domSelection.ts +84 -0
  92. package/utils/htmlChrome.ts +73 -0
  93. package/utils/inputMethod.ts +79 -6
  94. package/utils/parser.ts +517 -21
  95. package/utils/preferenceTtl.ts +15 -0
  96. package/utils/sharing.ts +0 -1
  97. package/utils/skillCatalog.ts +269 -0
  98. package/utils/skillReferences.ts +475 -0
  99. package/utils/syntaxTheme.ts +83 -0
  100. package/utils/themeRegistry.ts +154 -0
  101. package/utils/vimHud.ts +263 -0
  102. package/utils/vimModeAnnouncement.ts +23 -0
  103. package/utils/vimNavigation.ts +417 -0
  104. package/utils/vimReticle.ts +88 -0
  105. package/utils/vimScroll.ts +162 -0
@@ -8,14 +8,36 @@ import {
8
8
  useState,
9
9
  } from "react";
10
10
  import { createPortal } from "react-dom";
11
+ import { useVimDocumentFocus } from "../../hooks/useVimDocumentFocus";
12
+ import {
13
+ isVimSelectionActionId,
14
+ type VimSelectionHudContext,
15
+ } from "../../shortcuts";
11
16
  import type { Annotation, EditorMode, ImageAttachment, InputMethod } from "../../types";
12
17
  import { AnnotationType } from "../../types";
18
+ import { copyTextPreservingFocus } from "../../utils/clipboard";
13
19
  import { getIdentity } from "../../utils/identity";
20
+ import {
21
+ createVimHudCommand,
22
+ getVimHudPhase,
23
+ type VimHudCommand,
24
+ } from "../../utils/vimHud";
14
25
  import { AnnotationToolbar } from "../AnnotationToolbar";
15
26
  import { AttachmentsButton } from "../AttachmentsButton";
16
- import { CommentPopover, type CommentAskAIHandler } from "../CommentPopover";
27
+ import {
28
+ CommentPopover,
29
+ type CommentAskAIHandler,
30
+ type CommentTargetChip,
31
+ } from "../CommentPopover";
17
32
  import { FloatingQuickLabelPicker } from "../FloatingQuickLabelPicker";
33
+ import { VimKeyHud } from "../VimKeyHud";
18
34
  import type { ViewerHandle } from "../Viewer";
35
+ import {
36
+ computeComposerYield,
37
+ distanceToRect,
38
+ type ComposerYieldState,
39
+ } from "./composerYield";
40
+ import { buildSyncNumbering } from "./annotationNumbering";
19
41
  import { useHtmlAnnotation } from "./useHtmlAnnotation";
20
42
  import {
21
43
  THEME_TOKENS,
@@ -41,6 +63,79 @@ function isLightTheme(): boolean {
41
63
  return document.documentElement.classList.contains("light");
42
64
  }
43
65
 
66
+ function isBridgeReadyMessage(value: unknown): boolean {
67
+ return typeof value === "object"
68
+ && value !== null
69
+ && "type" in value
70
+ && value.type === `${PREFIX}ready`;
71
+ }
72
+
73
+ function isRecord(value: unknown): value is Record<string, unknown> {
74
+ return typeof value === "object" && value !== null;
75
+ }
76
+
77
+ function parseVimSelectionHudContext(
78
+ value: unknown,
79
+ ): VimSelectionHudContext | null {
80
+ return value === "inactive"
81
+ || value === "block"
82
+ || value === "inline"
83
+ || value === "text"
84
+ || value === "visual"
85
+ || value === "visual-block"
86
+ || value === "action"
87
+ ? value
88
+ : null;
89
+ }
90
+
91
+ interface VimBridgeCommand {
92
+ readonly actionId: Parameters<typeof createVimHudCommand>[1];
93
+ readonly key: string;
94
+ readonly context: VimSelectionHudContext;
95
+ }
96
+
97
+ function parseVimBridgeCommand(value: unknown): VimBridgeCommand | null {
98
+ if (
99
+ !isRecord(value)
100
+ || value.type !== `${PREFIX}vim-command`
101
+ || !isVimSelectionActionId(value.actionId)
102
+ || typeof value.key !== "string"
103
+ ) {
104
+ return null;
105
+ }
106
+ const context = parseVimSelectionHudContext(value.context);
107
+ return context
108
+ ? { actionId: value.actionId, key: value.key, context }
109
+ : null;
110
+ }
111
+
112
+ function parseVimBridgeState(value: unknown): VimSelectionHudContext | null {
113
+ return isRecord(value) && value.type === `${PREFIX}vim-state`
114
+ ? parseVimSelectionHudContext(value.phase)
115
+ : null;
116
+ }
117
+
118
+ function parseVimBridgeHelp(value: unknown): boolean | null {
119
+ return isRecord(value)
120
+ && value.type === `${PREFIX}vim-help`
121
+ && typeof value.open === "boolean"
122
+ ? value.open
123
+ : null;
124
+ }
125
+
126
+ const MAX_VIM_COPY_TEXT_LENGTH = 2 * 1024 * 1024;
127
+
128
+ function parseVimBridgeCopy(value: unknown): string | null {
129
+ return isRecord(value)
130
+ && value.type === `${PREFIX}vim-copy`
131
+ && typeof value.text === "string"
132
+ && value.text.length > 0
133
+ && value.text.length <= MAX_VIM_COPY_TEXT_LENGTH
134
+ ? value.text
135
+ : null;
136
+ }
137
+
138
+ /** Inputs for the sandboxed raw-HTML viewer and its parent-side annotation UI. */
44
139
  export interface HtmlViewerProps {
45
140
  rawHtml: string;
46
141
  annotations: Annotation[];
@@ -50,6 +145,14 @@ export interface HtmlViewerProps {
50
145
  mode: EditorMode;
51
146
  /** Input method: 'drag' = text selection, 'pinpoint' = click an element. */
52
147
  inputMethod: InputMethod;
148
+ /** Opt-in Vim-style keyboard selection. Default false for compatibility. */
149
+ vimModeEnabled?: boolean;
150
+ /** Replace the iframe-local compact badge with the shared live key HUD. */
151
+ vimHudEnabled?: boolean;
152
+ /** Show the parent key panel without affecting the iframe HUD reticle. */
153
+ vimHudKeyPanelEnabled?: boolean;
154
+ /** Persist a user request to hide the parent key panel. */
155
+ onVimHudKeyPanelChange?: (enabled: boolean) => void;
53
156
  globalAttachments?: ImageAttachment[];
54
157
  onAddGlobalAttachment?: (image: ImageAttachment) => void;
55
158
  onRemoveGlobalAttachment?: (path: string) => void;
@@ -67,8 +170,16 @@ export interface HtmlViewerProps {
67
170
  /** Toggle the diff-highlighted view on/off. */
68
171
  onToggleDiff?: () => void;
69
172
  onAskAI?: CommentAskAIHandler;
173
+ /** Disable every annotation mutation entry point while preserving reading and navigation. */
174
+ readOnly?: boolean;
175
+ /** Accessible iframe title. */
176
+ title?: string;
70
177
  }
71
178
 
179
+ /**
180
+ * Render arbitrary HTML in a sandbox and adapt its validated bridge messages
181
+ * to the same annotation controls used by the Markdown viewer.
182
+ */
72
183
  export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
73
184
  (
74
185
  {
@@ -79,6 +190,10 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
79
190
  selectedAnnotationId,
80
191
  mode,
81
192
  inputMethod,
193
+ vimModeEnabled = false,
194
+ vimHudEnabled = false,
195
+ vimHudKeyPanelEnabled = true,
196
+ onVimHudKeyPanelChange,
82
197
  globalAttachments = [],
83
198
  onAddGlobalAttachment,
84
199
  onRemoveGlobalAttachment,
@@ -89,13 +204,24 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
89
204
  diffActive,
90
205
  onToggleDiff,
91
206
  onAskAI,
207
+ readOnly = false,
208
+ title = "HTML Plan Viewer",
92
209
  },
93
210
  ref,
94
211
  ) => {
95
212
  const iframeRef = useRef<HTMLIFrameElement>(null);
96
213
  const globalCommentButtonRef = useRef<HTMLButtonElement>(null);
97
214
  const [iframeHeight, setIframeHeight] = useState(600);
98
- const [iframeReady, setIframeReady] = useState(false);
215
+ // Increment on every bridge-ready event so srcdoc navigations re-send
216
+ // state even though the iframe element and its WindowProxy are reused.
217
+ const [iframeReadyVersion, setIframeReadyVersion] = useState(0);
218
+ const [iframeFocused, setIframeFocused] = useState(false);
219
+ const [vimBridgePhase, setVimBridgePhase] =
220
+ useState<VimSelectionHudContext>("inactive");
221
+ const [vimHudCommand, setVimHudCommand] = useState<VimHudCommand | null>(null);
222
+ const [vimHelpOpen, setVimHelpOpen] = useState(false);
223
+ const vimHudSequenceRef = useRef(0);
224
+ const vimHudActive = !readOnly && vimModeEnabled && vimHudEnabled;
99
225
  const [globalCommentPopover, setGlobalCommentPopover] = useState<{
100
226
  anchorEl: HTMLElement;
101
227
  contextText: string;
@@ -119,45 +245,269 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
119
245
  setIframeHeight(height);
120
246
  }, []);
121
247
 
248
+ // Composer yield while shift-selecting (multi-target drafts): fade the
249
+ // composer as the pointer approaches, click-through when over it. Pointer
250
+ // positions arrive from parent mousemoves AND from the bridge (the iframe
251
+ // consumes moves over the page, so the bridge relays them).
252
+ const [composerYield, setComposerYield] = useState<ComposerYieldState>("none");
253
+ const composerYieldRef = useRef(composerYield);
254
+ composerYieldRef.current = composerYield;
255
+ const shiftHeldRef = useRef(false);
256
+
257
+ const handleYieldPointer = useCallback((clientX: number, clientY: number) => {
258
+ if (!shiftHeldRef.current) return;
259
+ const popover = document.querySelector("[data-comment-popover]");
260
+ if (!popover) return;
261
+ const rect = popover.getBoundingClientRect();
262
+ const next = computeComposerYield(
263
+ composerYieldRef.current,
264
+ distanceToRect(clientX, clientY, rect),
265
+ );
266
+ if (next !== composerYieldRef.current) setComposerYield(next);
267
+ }, []);
268
+
269
+ const handleBridgePointer = useCallback(
270
+ (x: number, y: number, shift: boolean) => {
271
+ // The bridge is the only observer of Shift while the pointer lives
272
+ // inside the sandbox (parent keydowns don't fire there, and window
273
+ // blur clears our local flag when focus enters the iframe) — so the
274
+ // relayed shift state arms/disarms the yield directly.
275
+ shiftHeldRef.current = shift;
276
+ if (!shift) {
277
+ setComposerYield("none");
278
+ return;
279
+ }
280
+ const iframeRect = iframeRef.current?.getBoundingClientRect();
281
+ if (!iframeRect) return;
282
+ handleYieldPointer(iframeRect.left + x, iframeRect.top + y);
283
+ },
284
+ [handleYieldPointer],
285
+ );
286
+
122
287
  const hook = useHtmlAnnotation({
123
288
  iframeRef,
289
+ enabled: !readOnly,
124
290
  annotations,
125
291
  onAddAnnotation,
126
292
  onSelectAnnotation,
127
293
  selectedAnnotationId,
128
294
  mode,
129
295
  onResize: handleResize,
296
+ onBridgePointer: handleBridgePointer,
130
297
  });
131
298
 
299
+ const multiSelectActive = !readOnly && !!hook.commentPopover && hook.draftTargets.length > 0;
300
+
301
+ // Track Shift while a multi-select draft composer is open; releasing it
302
+ // (or losing window focus) always restores the composer.
303
+ useEffect(() => {
304
+ if (!multiSelectActive) {
305
+ shiftHeldRef.current = false;
306
+ setComposerYield("none");
307
+ return;
308
+ }
309
+ const down = (e: KeyboardEvent) => {
310
+ if (e.key === "Shift") shiftHeldRef.current = true;
311
+ };
312
+ const release = () => {
313
+ shiftHeldRef.current = false;
314
+ setComposerYield("none");
315
+ };
316
+ const up = (e: KeyboardEvent) => {
317
+ if (e.key === "Shift") release();
318
+ };
319
+ const move = (e: MouseEvent) => {
320
+ // Parent-side pointer (over app chrome or the composer itself).
321
+ if (e.shiftKey) shiftHeldRef.current = true;
322
+ handleYieldPointer(e.clientX, e.clientY);
323
+ };
324
+ window.addEventListener("keydown", down);
325
+ window.addEventListener("keyup", up);
326
+ window.addEventListener("blur", release);
327
+ window.addEventListener("mousemove", move);
328
+ return () => {
329
+ window.removeEventListener("keydown", down);
330
+ window.removeEventListener("keyup", up);
331
+ window.removeEventListener("blur", release);
332
+ window.removeEventListener("mousemove", move);
333
+ };
334
+ }, [multiSelectActive, handleYieldPointer]);
335
+
336
+ // Chip data for the composer: semantic label + short excerpt per target.
337
+ const targetChips = useMemo<CommentTargetChip[] | undefined>(() => {
338
+ if (!hook.draftTargets.length) return undefined;
339
+ return hook.draftTargets.map((t) => ({
340
+ key: t.key,
341
+ label: t.label,
342
+ excerpt: t.text.replace(/\s+/g, " ").trim().slice(0, 80),
343
+ }));
344
+ }, [hook.draftTargets]);
345
+
132
346
  useEffect(() => {
133
- function handler(e: MessageEvent) {
134
- if (e.data?.type === `${PREFIX}ready`) {
135
- setIframeReady(true);
347
+ function handler(e: MessageEvent<unknown>) {
348
+ if (e.source !== iframeRef.current?.contentWindow) return;
349
+ if (isBridgeReadyMessage(e.data)) {
350
+ setIframeReadyVersion((version) => version + 1);
351
+ setVimBridgePhase("inactive");
352
+ setVimHudCommand(null);
353
+ setVimHelpOpen(false);
354
+ return;
355
+ }
356
+ const vimCopy = parseVimBridgeCopy(e.data);
357
+ if (vimCopy !== null) {
358
+ const iframe = iframeRef.current;
359
+ if (
360
+ !readOnly
361
+ && vimModeEnabled
362
+ && iframe
363
+ && document.activeElement === iframe
364
+ ) {
365
+ copyTextPreservingFocus(vimCopy, iframe);
366
+ }
367
+ return;
368
+ }
369
+ if (!vimHudActive) return;
370
+ const vimHelp = parseVimBridgeHelp(e.data);
371
+ if (vimHelp !== null) {
372
+ setVimHelpOpen(vimHelp);
373
+ return;
374
+ }
375
+ const vimState = parseVimBridgeState(e.data);
376
+ if (vimState) {
377
+ setVimBridgePhase(vimState);
378
+ return;
379
+ }
380
+ const vimCommand = parseVimBridgeCommand(e.data);
381
+ if (vimCommand) {
382
+ vimHudSequenceRef.current += 1;
383
+ setVimHudCommand(createVimHudCommand(
384
+ vimHudSequenceRef.current,
385
+ vimCommand.actionId,
386
+ vimCommand.key,
387
+ vimCommand.context,
388
+ ));
136
389
  }
137
390
  }
138
391
  window.addEventListener("message", handler);
139
392
  return () => window.removeEventListener("message", handler);
393
+ }, [readOnly, vimHudActive, vimModeEnabled]);
394
+
395
+ useEffect(() => {
396
+ if (vimHudActive) return;
397
+ setVimBridgePhase("inactive");
398
+ setVimHudCommand(null);
399
+ setVimHelpOpen(false);
400
+ }, [vimHudActive]);
401
+
402
+ const handleVimHelpOpenChange = useCallback((open: boolean) => {
403
+ setVimHelpOpen(open);
404
+ iframeRef.current?.contentWindow?.postMessage(
405
+ { type: `${PREFIX}set-vim-help`, open },
406
+ "*",
407
+ );
140
408
  }, []);
141
409
 
410
+ const handleVimHudFocusLeave = useCallback(() => {
411
+ if (iframeRef.current === document.activeElement) return;
412
+ setIframeFocused(false);
413
+ }, []);
414
+
415
+ const focusVimDocument = useCallback((): boolean => {
416
+ const iframe = iframeRef.current;
417
+ if (readOnly || !vimModeEnabled || !iframe) return false;
418
+ if (document.activeElement === iframe) return false;
419
+ iframe.focus({ preventScroll: true });
420
+ if (document.activeElement !== iframe) return false;
421
+ iframe.contentWindow?.postMessage(
422
+ { type: `${PREFIX}focus-vim` },
423
+ "*",
424
+ );
425
+ return true;
426
+ }, [readOnly, vimModeEnabled]);
427
+
428
+ useVimDocumentFocus({
429
+ enabled: !readOnly && vimModeEnabled,
430
+ blocked: !!hook.toolbarState || !!hook.commentPopover || !!hook.quickLabelPicker,
431
+ focusDocument: focusVimDocument,
432
+ });
433
+
142
434
  useEffect(() => {
143
- if (!iframeReady) return;
435
+ if (iframeReadyVersion === 0) return;
144
436
  if (annotations.length > 0) {
145
437
  hook.applyAnnotations(annotations);
146
438
  }
147
- }, [iframeReady]); // eslint-disable-line react-hooks/exhaustive-deps
439
+ }, [iframeReadyVersion]); // eslint-disable-line react-hooks/exhaustive-deps
440
+
441
+ // Placed-marker numbering is parent-authoritative and matches the
442
+ // numbers exportAnnotations writes into the submitted feedback: the full
443
+ // list INCLUDING globals is numbered by ARRAY position (the export's
444
+ // effective order — its sort keys tie for raw-HTML annotations), and
445
+ // globals then ship no entry (no page location) — see buildSyncNumbering
446
+ // for the contract. Renumbers on delete; the bridge's own registration
447
+ // order is only a pre-sync fallback.
448
+ useEffect(() => {
449
+ if (iframeReadyVersion === 0) return;
450
+ iframeRef.current?.contentWindow?.postMessage(
451
+ { type: `${PREFIX}sync-annotations`, annotations: buildSyncNumbering(annotations) },
452
+ "*",
453
+ );
454
+ }, [iframeReadyVersion, annotations]);
148
455
 
149
456
  // Tell the bridge the current input method (drag vs pinpoint). Re-posts on
150
457
  // ready (fresh iframe) and whenever the user switches it in the toolstrip.
151
458
  useEffect(() => {
152
- if (!iframeReady) return;
459
+ if (iframeReadyVersion === 0) return;
153
460
  iframeRef.current?.contentWindow?.postMessage(
154
461
  { type: `${PREFIX}set-input-method`, method: inputMethod },
155
462
  "*",
156
463
  );
157
- }, [iframeReady, inputMethod]);
464
+ }, [iframeReadyVersion, inputMethod]);
465
+
466
+ useEffect(() => {
467
+ if (iframeReadyVersion === 0) return;
468
+ const iframe = iframeRef.current;
469
+ iframe?.contentWindow?.postMessage(
470
+ {
471
+ type: `${PREFIX}set-vim-mode`,
472
+ enabled: !readOnly && vimModeEnabled,
473
+ hudEnabled: vimHudEnabled,
474
+ mode,
475
+ },
476
+ "*",
477
+ );
478
+ if (!readOnly && vimModeEnabled && iframe && iframe === document.activeElement) {
479
+ // The initial parent focus can land before the sandbox bridge is ready.
480
+ // Reassert it after configuration so raw HTML enters BLOCK immediately,
481
+ // matching the Markdown surface instead of waiting for the first key.
482
+ iframe.contentWindow?.postMessage(
483
+ { type: `${PREFIX}focus-vim` },
484
+ "*",
485
+ );
486
+ }
487
+ }, [iframeReadyVersion, mode, readOnly, vimHudEnabled, vimModeEnabled]);
488
+
489
+ const vimOverlayWasOpenRef = useRef(false);
490
+ useEffect(() => {
491
+ const overlayOpen = !!hook.toolbarState || !!hook.commentPopover || !!hook.quickLabelPicker;
492
+ const wasOpen = vimOverlayWasOpenRef.current;
493
+ vimOverlayWasOpenRef.current = overlayOpen;
494
+ if (
495
+ !readOnly
496
+ && vimModeEnabled
497
+ && wasOpen
498
+ && !overlayOpen
499
+ && (document.activeElement === document.body || document.activeElement === null)
500
+ ) {
501
+ iframeRef.current?.focus({ preventScroll: true });
502
+ iframeRef.current?.contentWindow?.postMessage(
503
+ { type: `${PREFIX}focus-vim` },
504
+ "*",
505
+ );
506
+ }
507
+ }, [hook.commentPopover, hook.quickLabelPicker, hook.toolbarState, readOnly, vimModeEnabled]);
158
508
 
159
509
  useEffect(() => {
160
- if (!iframeReady) return;
510
+ if (iframeReadyVersion === 0) return;
161
511
  function sendTheme() {
162
512
  iframeRef.current?.contentWindow?.postMessage(
163
513
  {
@@ -176,7 +526,7 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
176
526
  attributeFilter: ["class", "style"],
177
527
  });
178
528
  return () => observer.disconnect();
179
- }, [iframeReady, hostTheme]);
529
+ }, [iframeReadyVersion, hostTheme]);
180
530
 
181
531
  useImperativeHandle(ref, () => ({
182
532
  removeHighlight: hook.removeHighlight,
@@ -186,6 +536,7 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
186
536
 
187
537
  const handleGlobalCommentSubmit = useCallback(
188
538
  (text: string, images?: ImageAttachment[]) => {
539
+ if (readOnly) return;
189
540
  onAddAnnotation({
190
541
  id: `global-${Date.now()}`,
191
542
  blockId: "",
@@ -200,9 +551,15 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
200
551
  });
201
552
  setGlobalCommentPopover(null);
202
553
  },
203
- [onAddAnnotation],
554
+ [onAddAnnotation, readOnly],
204
555
  );
205
556
 
557
+ useEffect(() => {
558
+ if (readOnly) setGlobalCommentPopover(null);
559
+ }, [readOnly]);
560
+
561
+ const hasActionButtons = !readOnly || Boolean(diffAvailable && onToggleDiff);
562
+
206
563
  // Document-level controls (attachments + global comment). Shared between the
207
564
  // normal layout (bar above the card) and full-viewport (floating overlay), so
208
565
  // edge-to-edge HTML keeps these affordances rather than dropping them.
@@ -220,7 +577,7 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
220
577
  <span>{diffActive ? "Hide changes" : "Show changes"}</span>
221
578
  </button>
222
579
  )}
223
- {onAddGlobalAttachment && onRemoveGlobalAttachment && (
580
+ {!readOnly && onAddGlobalAttachment && onRemoveGlobalAttachment && (
224
581
  <AttachmentsButton
225
582
  images={globalAttachments}
226
583
  onAdd={onAddGlobalAttachment}
@@ -228,22 +585,23 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
228
585
  variant="toolbar"
229
586
  />
230
587
  )}
231
- <button
232
- ref={globalCommentButtonRef}
233
- onClick={() => {
234
- setGlobalCommentPopover({
235
- anchorEl: globalCommentButtonRef.current!,
236
- contextText: "",
237
- });
238
- }}
239
- className="flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground bg-muted/50 hover:bg-muted rounded-md transition-colors cursor-pointer"
240
- title="Add global comment"
241
- >
242
- <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
243
- <path strokeLinecap="round" strokeLinejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.087.16 2.185.283 3.293.369V21l4.076-4.076a1.526 1.526 0 011.037-.443 48.282 48.282 0 005.68-.494c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z" />
244
- </svg>
245
- <span>Comment</span>
246
- </button>
588
+ {!readOnly && (
589
+ <button
590
+ ref={globalCommentButtonRef}
591
+ onClick={() => {
592
+ const anchorEl = globalCommentButtonRef.current;
593
+ if (!anchorEl) return;
594
+ setGlobalCommentPopover({ anchorEl, contextText: "" });
595
+ }}
596
+ className="flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground bg-muted/50 hover:bg-muted rounded-md transition-colors cursor-pointer"
597
+ title="Add global comment"
598
+ >
599
+ <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
600
+ <path strokeLinecap="round" strokeLinejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.087.16 2.185.283 3.293.369V21l4.076-4.076a1.526 1.526 0 011.037-.443 48.282 48.282 0 005.68-.494c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z" />
601
+ </svg>
602
+ <span>Comment</span>
603
+ </button>
604
+ )}
247
605
  </>
248
606
  );
249
607
 
@@ -254,7 +612,7 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
254
612
  style={fullViewport ? undefined : { maxWidth: maxWidth ?? undefined }}
255
613
  >
256
614
  {/* Action bar — above the iframe in normal mode (outside overflow:hidden). */}
257
- {!fullViewport && (
615
+ {!fullViewport && hasActionButtons && (
258
616
  <div data-print-hide className="flex justify-end gap-1 md:gap-2 mb-2">
259
617
  {actionButtons}
260
618
  </div>
@@ -267,7 +625,7 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
267
625
  {/* Full-viewport mode has no card chrome, so float the same controls
268
626
  over the top-right of the iframe (with a backdrop so they read over
269
627
  any HTML). The selection toolbar is portaled separately. */}
270
- {fullViewport && !hideControls && (
628
+ {fullViewport && !hideControls && hasActionButtons && (
271
629
  <div
272
630
  data-print-hide
273
631
  className="absolute top-3 right-3 z-10 flex items-center gap-1 md:gap-2 rounded-lg border border-border/50 bg-background/80 px-1.5 py-1 shadow-md backdrop-blur-sm"
@@ -276,23 +634,60 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
276
634
  </div>
277
635
  )}
278
636
  <iframe
279
- ref={iframeRef}
280
- srcDoc={srcdoc}
281
- sandbox="allow-scripts"
282
- style={{
283
- width: "100%",
284
- height: fullViewport ? "100%" : `${iframeHeight}px`,
285
- border: "none",
286
- display: "block",
287
- colorScheme: "auto",
288
- }}
289
- title="HTML Plan Viewer"
290
- />
637
+ ref={iframeRef}
638
+ srcDoc={srcdoc}
639
+ sandbox="allow-scripts"
640
+ style={{
641
+ width: "100%",
642
+ height: fullViewport ? "100%" : `${iframeHeight}px`,
643
+ border: "none",
644
+ display: "block",
645
+ colorScheme: "auto",
646
+ outline: !readOnly && vimModeEnabled ? "none" : undefined,
647
+ }}
648
+ title={title}
649
+ onFocus={() => setIframeFocused(true)}
650
+ onBlur={(event) => {
651
+ if (
652
+ event.relatedTarget instanceof Element
653
+ && event.relatedTarget.closest('[data-vim-key-hud]')
654
+ ) {
655
+ return;
656
+ }
657
+ setIframeFocused(false);
658
+ }}
659
+ />
291
660
  </article>
292
661
  </div>
293
662
 
663
+ {vimHudActive
664
+ && (vimHelpOpen || (
665
+ vimHudKeyPanelEnabled
666
+ && vimBridgePhase !== "inactive"
667
+ ))
668
+ && (iframeFocused || vimBridgePhase === "action" || vimHelpOpen)
669
+ && createPortal(
670
+ <VimKeyHud
671
+ command={vimHudCommand}
672
+ phase={getVimHudPhase(vimBridgePhase, vimHudCommand?.actionId)}
673
+ inputMethod={inputMethod}
674
+ expanded={vimHelpOpen}
675
+ onExpandedChange={handleVimHelpOpenChange}
676
+ onHide={
677
+ onVimHudKeyPanelChange
678
+ ? () => {
679
+ handleVimHelpOpenChange(false);
680
+ onVimHudKeyPanelChange(false);
681
+ }
682
+ : undefined
683
+ }
684
+ onFocusLeave={handleVimHudFocusLeave}
685
+ />,
686
+ document.body,
687
+ )}
688
+
294
689
  {/* Toolbar portal */}
295
- {hook.toolbarState &&
690
+ {!readOnly && hook.toolbarState &&
296
691
  createPortal(
297
692
  <AnnotationToolbar
298
693
  positionMode="center-above"
@@ -307,7 +702,7 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
307
702
  )}
308
703
 
309
704
  {/* Comment popover portal */}
310
- {hook.commentPopover &&
705
+ {!readOnly && hook.commentPopover &&
311
706
  createPortal(
312
707
  <CommentPopover
313
708
  anchorEl={hook.commentPopover.anchorEl}
@@ -316,18 +711,25 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
316
711
  isGlobal={false}
317
712
  onSubmit={hook.handleCommentSubmit}
318
713
  onClose={hook.handleCommentClose}
714
+ skillReferences
319
715
  onAskAI={onAskAI}
320
716
  askAIContext={{
321
717
  kind: "selection",
322
718
  label: "Selected HTML",
323
719
  text: hook.commentPopover.selectedText ?? hook.commentPopover.contextText,
324
720
  }}
721
+ targetChips={targetChips}
722
+ onRemoveTargetChip={targetChips ? hook.removeDraftTarget : undefined}
723
+ onHoverTargetChip={targetChips ? hook.flashDraftTarget : undefined}
724
+ refocusToken={targetChips ? hook.composerFocusToken : undefined}
725
+ captureStrayKeys={multiSelectActive}
726
+ yieldState={multiSelectActive ? composerYield : undefined}
325
727
  />,
326
728
  document.body,
327
729
  )}
328
730
 
329
731
  {/* Quick label picker portal */}
330
- {hook.quickLabelPicker &&
732
+ {!readOnly && hook.quickLabelPicker &&
331
733
  createPortal(
332
734
  <FloatingQuickLabelPicker
333
735
  anchorEl={hook.quickLabelPicker.anchorEl}
@@ -339,7 +741,7 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
339
741
  )}
340
742
 
341
743
  {/* Global comment popover portal */}
342
- {globalCommentPopover &&
744
+ {!readOnly && globalCommentPopover &&
343
745
  createPortal(
344
746
  <CommentPopover
345
747
  anchorEl={globalCommentPopover.anchorEl}
@@ -347,6 +749,7 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
347
749
  isGlobal={true}
348
750
  onSubmit={handleGlobalCommentSubmit}
349
751
  onClose={() => setGlobalCommentPopover(null)}
752
+ skillReferences
350
753
  onAskAI={onAskAI}
351
754
  askAIContext={{ kind: "general", label: "Document" }}
352
755
  />,