@plannotator/ui 0.53.1 → 0.55.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (149) hide show
  1. package/HANDOFF.md +274 -14
  2. package/README.md +114 -11
  3. package/components/AgentToolAnnouncementDialog.tsx +1 -1
  4. package/components/AgentsTab.tsx +1 -1
  5. package/components/AnnotationPanel.tsx +142 -24
  6. package/components/AnnotationSidebar.tsx +1 -1
  7. package/components/AnnotationToolbar.tsx +64 -9
  8. package/components/ApproveDropdown.tsx +7 -9
  9. package/components/AskSessionAnnouncementDialog.tsx +1 -1
  10. package/components/AttachmentsButton.tsx +1 -1
  11. package/components/BlockRenderer.tsx +22 -5
  12. package/components/CodeFilePopout.tsx +139 -4
  13. package/components/CodeSnippetPreview.tsx +174 -0
  14. package/components/CommentPopover.tsx +44 -8
  15. package/components/DecisionControl.tsx +31 -5
  16. package/components/DeferredMarkdownContentsRail.tsx +38 -0
  17. package/components/DiagramBlock.tsx +17 -12
  18. package/components/DiffFileTree.tsx +7 -6
  19. package/components/DocumentContents.tsx +74 -0
  20. package/components/EditorAnnotationCard.tsx +1 -1
  21. package/components/ExportModal.tsx +77 -7
  22. package/components/HostTitle.tsx +0 -4
  23. package/components/ImageAnnotator/Toolbar.tsx +25 -1
  24. package/components/ImageAnnotator/index.tsx +24 -4
  25. package/components/ImageLightbox.tsx +722 -126
  26. package/components/ImagePeek.tsx +38 -4
  27. package/components/ImageRegions.tsx +369 -0
  28. package/components/ImageThumbnail.tsx +1 -1
  29. package/components/ImageZoomStage.tsx +796 -0
  30. package/components/InboxAnnouncementDialog.tsx +202 -0
  31. package/components/InlineMarkdown.tsx +167 -174
  32. package/components/KeyboardShortcuts.tsx +23 -0
  33. package/components/LazyBlockBoundary.tsx +100 -0
  34. package/components/ListMarker.tsx +3 -3
  35. package/components/LookAndFeelAnnouncementDialog.tsx +12 -2
  36. package/components/MarkdownContentsRail.tsx +777 -0
  37. package/components/MenuVersionSection.tsx +31 -9
  38. package/components/PlanDecisionMenuExtras.tsx +129 -0
  39. package/components/PlanHeaderMenu.tsx +15 -1
  40. package/components/ProviderIcons.tsx +8 -0
  41. package/components/RefHoverCard.tsx +913 -0
  42. package/components/SendFailureToast.tsx +126 -0
  43. package/components/Settings.tsx +617 -126
  44. package/components/TableOfContents.tsx +20 -6
  45. package/components/TerminalToolsAnnouncementDialog.tsx +1 -1
  46. package/components/ThemeProvider.tsx +14 -1
  47. package/components/ThemeTab.tsx +16 -24
  48. package/components/Tooltip.tsx +49 -18
  49. package/components/Viewer.tsx +288 -43
  50. package/components/ai/DocumentAIChatPanel.tsx +29 -3
  51. package/components/ai/NewChatButton.tsx +74 -0
  52. package/components/blocks/CodeBlock.tsx +1 -1
  53. package/components/blocks/HtmlBlock.tsx +7 -1
  54. package/components/blocks/QuestionBlock.tsx +532 -62
  55. package/components/diagram/DiagramComposer.tsx +5 -1
  56. package/components/diagram/DiagramPending.tsx +12 -2
  57. package/components/diagram/DiagramViewer.tsx +19 -3
  58. package/components/html-embed/HtmlEmbedBlock.tsx +374 -0
  59. package/components/html-embed/SvgFenceBlock.tsx +46 -0
  60. package/components/html-embed/index.ts +3 -0
  61. package/components/html-embed/languages.ts +15 -0
  62. package/components/html-embed/loadGate.ts +107 -0
  63. package/components/html-viewer/HtmlViewer.tsx +82 -6
  64. package/components/html-viewer/bridge-script.asset.js +173 -1
  65. package/components/html-viewer/bridge-script.ts +173 -1
  66. package/components/html-viewer/useHtmlAnnotation.ts +125 -0
  67. package/components/imageZoom.ts +276 -0
  68. package/components/plan-diff/PlanCleanDiffView.tsx +1 -1
  69. package/components/plan-diff/PlanDiffViewer.tsx +13 -2
  70. package/components/plan-diff/PlanRawDiffView.tsx +1 -1
  71. package/components/sidebar/FileBrowser.tsx +16 -2
  72. package/components/sidebar/SidebarTabs.tsx +3 -0
  73. package/config/index.ts +3 -0
  74. package/config/reviewView.ts +41 -1
  75. package/config/settings.ts +155 -4
  76. package/configure.ts +28 -0
  77. package/contents-rail.css +337 -0
  78. package/hooks/pfm/useCodeFilePopout.ts +7 -1
  79. package/hooks/useAIChat.ts +72 -25
  80. package/hooks/useAnnotationDraft.ts +71 -6
  81. package/hooks/useAnnotationHighlighter.ts +57 -3
  82. package/hooks/useCodeAnnotationDraft.ts +113 -17
  83. package/hooks/useCookieHostSetting.ts +96 -0
  84. package/hooks/useDocumentFont.ts +17 -0
  85. package/hooks/useDocumentTextSize.ts +20 -0
  86. package/hooks/useFileBrowser.ts +10 -1
  87. package/hooks/useHeadingScrollSpy.ts +77 -0
  88. package/hooks/useHoverPreview.ts +94 -0
  89. package/hooks/useLinkedDoc.ts +226 -13
  90. package/hooks/usePackageChangesSetting.ts +90 -0
  91. package/hooks/usePlanLookDialog.ts +67 -0
  92. package/hooks/usePrefersReducedMotion.ts +23 -0
  93. package/hooks/useSharing.ts +11 -7
  94. package/hooks/useSwipeStep.ts +597 -0
  95. package/hooks/useThemePaletteHalves.ts +37 -0
  96. package/hooks/useUpdateCheck.ts +22 -4
  97. package/hooks/useUpdateToast.ts +1 -1
  98. package/package.json +26 -23
  99. package/shortcuts/code-review/annotationToolbar.shortcuts.ts +1 -0
  100. package/shortcuts/code-review/explorer.shortcuts.ts +82 -0
  101. package/shortcuts/core.ts +63 -4
  102. package/shortcuts/index.ts +1 -0
  103. package/shortcuts/plan-review/annotationPanel.shortcuts.ts +1 -1
  104. package/shortcuts/plan-review/commentPopover.shortcuts.ts +1 -0
  105. package/shortcuts/runtime.ts +4 -3
  106. package/styles.css +1 -1
  107. package/theme.css +179 -1
  108. package/themes/plannotator-graphite.css +66 -0
  109. package/types.ts +26 -9
  110. package/utils/agentSwitch.ts +41 -20
  111. package/utils/agentToolSetting.ts +1 -1
  112. package/utils/aiChatFormat.ts +23 -7
  113. package/utils/annotationHelpers.ts +32 -4
  114. package/utils/annotationOwnsHighlight.ts +4 -2
  115. package/utils/asciidocParser.ts +570 -0
  116. package/utils/autoUpdateNotice.ts +24 -0
  117. package/utils/belowTitleSlot.ts +174 -0
  118. package/utils/blockParse.ts +45 -0
  119. package/utils/codeLineScroll.ts +100 -0
  120. package/utils/commentSubmitKey.ts +48 -0
  121. package/utils/cssFontFamily.ts +65 -0
  122. package/utils/decisionSpec.ts +190 -2
  123. package/utils/defaultNotesApp.ts +1 -1
  124. package/utils/diffFonts.ts +4 -1
  125. package/utils/documentFont.ts +68 -0
  126. package/utils/documentTextSize.ts +54 -0
  127. package/utils/fontDetect.ts +63 -0
  128. package/utils/headingScrollSpy.ts +92 -0
  129. package/utils/inboxAnnouncement.ts +78 -0
  130. package/utils/lookAndFeelAnnouncement.ts +82 -5
  131. package/utils/markdownExtensions.ts +2 -2
  132. package/utils/mermaid.ts +20 -3
  133. package/utils/mermaidTheme.ts +3 -51
  134. package/utils/notion.ts +58 -0
  135. package/utils/parser.ts +28 -1
  136. package/utils/platform.ts +46 -0
  137. package/utils/refCard.ts +82 -0
  138. package/utils/richClipboard.ts +380 -0
  139. package/utils/richClipboardLists.ts +57 -0
  140. package/utils/richCopyPolicy.ts +22 -0
  141. package/utils/scrollAnchor.ts +82 -0
  142. package/utils/sectionRefs.ts +123 -0
  143. package/utils/selectionBoundary.ts +40 -0
  144. package/utils/sendFailure.ts +85 -0
  145. package/utils/sharing.ts +5 -2
  146. package/utils/themeRegistry.ts +10 -0
  147. package/utils/themeTokens.ts +74 -0
  148. package/utils/updateNotice.ts +30 -8
  149. package/utils/vscodePanel.ts +9 -0
@@ -0,0 +1,174 @@
1
+ import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
2
+ import { createPortal } from "react-dom";
3
+ import { ensureHighlight, highlightToHtml } from "../utils/codeHighlight";
4
+ import { useFenceTheme } from "../hooks/useFenceTheme";
5
+
6
+ /**
7
+ * The floating code window a code-file reference opens on hover: a few lines
8
+ * of one file, syntax highlighted, with line numbers. Portalled to the body
9
+ * and positioned under (or above) its anchor.
10
+ *
11
+ * Used by plan / annotate code-file links (`CodeFileLink` in InlineMarkdown,
12
+ * the exact range only) and by code review's Call Flow locations
13
+ * (`CallFlowTreeView`, with `contextLines` around the target).
14
+ */
15
+
16
+ /** Lines highlighted above the shown ones so a range opening inside a block comment or string tokenises as one. */
17
+ const HIGHLIGHT_LEAD_IN_LINES = 40;
18
+ /** Gap kept between the window and the viewport edge. */
19
+ const VIEWPORT_MARGIN = 8;
20
+
21
+ /** The highlighter language for a file path, by extension; undefined renders plain text. */
22
+ export function codeSnippetLanguage(filepath: string): string | undefined {
23
+ const ext = filepath.split('.').pop()?.toLowerCase();
24
+ const map: Record<string, string> = {
25
+ ts: 'typescript', tsx: 'typescript', js: 'javascript', jsx: 'javascript',
26
+ mts: 'typescript', cts: 'typescript', mjs: 'javascript', cjs: 'javascript',
27
+ py: 'python', rb: 'ruby', rs: 'rust', go: 'go', java: 'java',
28
+ css: 'css', scss: 'scss', json: 'json', yml: 'yaml', yaml: 'yaml',
29
+ sql: 'sql', sh: 'bash', bash: 'bash', zsh: 'bash', md: 'markdown',
30
+ html: 'html', xml: 'xml', toml: 'toml', swift: 'swift', kt: 'kotlin',
31
+ lua: 'lua', c: 'c', h: 'c', cpp: 'cpp', hpp: 'cpp', cs: 'csharp',
32
+ scala: 'scala', dart: 'dart', ex: 'elixir', exs: 'elixir', zig: 'zig',
33
+ vue: 'vue', svelte: 'svelte', astro: 'astro', php: 'php',
34
+ };
35
+ return ext ? map[ext] : undefined;
36
+ }
37
+
38
+ export interface CodeSnippetPreviewProps {
39
+ anchorEl: HTMLElement | null;
40
+ /** The whole file; the preview slices the lines it shows. */
41
+ contents: string;
42
+ filepath: string;
43
+ /** One-based first line of the target range. */
44
+ line: number;
45
+ lineEnd?: number;
46
+ /** Lines shown above and below the target range, the target rows marked. 0 (default) shows the range alone. */
47
+ contextLines?: number;
48
+ /** Header label; defaults to the file's basename. */
49
+ title?: string;
50
+ /** Called when the page or a scroller scrolls: the window is placed once and would drift from its anchor. */
51
+ onDismiss?: () => void;
52
+ onMouseEnter?: () => void;
53
+ onMouseLeave?: () => void;
54
+ }
55
+
56
+ export const CodeSnippetPreview: React.FC<CodeSnippetPreviewProps> = ({
57
+ anchorEl,
58
+ contents,
59
+ filepath,
60
+ line,
61
+ lineEnd,
62
+ contextLines = 0,
63
+ title,
64
+ onDismiss,
65
+ onMouseEnter,
66
+ onMouseLeave,
67
+ }) => {
68
+ const allLines = contents.split('\n');
69
+ const targetStart = Math.max(0, line - 1);
70
+ const targetEnd = Math.min(allLines.length, lineEnd ?? line);
71
+ const start = Math.max(0, targetStart - contextLines);
72
+ const end = Math.min(allLines.length, targetEnd + contextLines);
73
+ const snippet = allLines.slice(start, end).join('\n');
74
+ // Highlight from a little earlier than the first shown line: a range that
75
+ // starts inside a block comment or template literal would otherwise colour
76
+ // that text as code.
77
+ const highlightStart = Math.max(0, start - HIGHLIGHT_LEAD_IN_LINES);
78
+ const highlightSource = allLines.slice(highlightStart, end).join('\n');
79
+ const marksTarget = contextLines > 0;
80
+
81
+ const fenceTheme = useFenceTheme();
82
+ // Bumped once the grammar is attached, to re-run the memo below with the
83
+ // highlighter warm. Until then the snippet renders as plain text — no
84
+ // auto-detection, and an unknown extension simply stays plain.
85
+ const [highlighterGeneration, setHighlighterGeneration] = useState(0);
86
+ const lang = codeSnippetLanguage(filepath);
87
+
88
+ useEffect(() => {
89
+ if (!lang) return;
90
+ let cancelled = false;
91
+ void ensureHighlight(lang, fenceTheme).then((ok) => {
92
+ if (ok && !cancelled) setHighlighterGeneration((n) => n + 1);
93
+ });
94
+ return () => { cancelled = true; };
95
+ }, [lang, fenceTheme]);
96
+
97
+ useEffect(() => {
98
+ if (!onDismiss) return;
99
+ const dismiss = () => onDismiss();
100
+ window.addEventListener('scroll', dismiss, { capture: true, passive: true });
101
+ return () => window.removeEventListener('scroll', dismiss, { capture: true });
102
+ }, [onDismiss]);
103
+
104
+ const highlightedLines = useMemo(() => {
105
+ // Highlight the snippet as one unit so multi-line constructs (block
106
+ // comments, template literals) tokenise correctly, then split back into
107
+ // rows: the highlighter joins lines with "\n" and never emits one inside a
108
+ // span, so the split is exact.
109
+ const html = lang ? highlightToHtml(highlightSource, lang, fenceTheme) : null;
110
+ if (html !== null) return html.split('\n').slice(start - highlightStart);
111
+ return snippet.split('\n').map(text =>
112
+ text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'));
113
+ // highlighterGeneration is the "grammar just became available" signal.
114
+ // eslint-disable-next-line react-hooks/exhaustive-deps
115
+ }, [snippet, highlightSource, start, highlightStart, lang, fenceTheme, highlighterGeneration]);
116
+
117
+ // Clamp against the window's rendered width (it sizes to its longest line,
118
+ // up to its max width) so a wide snippet never runs off the right edge.
119
+ const windowRef = useRef<HTMLDivElement | null>(null);
120
+ const [renderedWidth, setRenderedWidth] = useState<number | null>(null);
121
+ useLayoutEffect(() => {
122
+ const width = windowRef.current?.offsetWidth ?? null;
123
+ if (width !== renderedWidth) setRenderedWidth(width);
124
+ });
125
+
126
+ if (!anchorEl) return null;
127
+
128
+ const rect = anchorEl.getBoundingClientRect();
129
+ const spaceBelow = window.innerHeight - rect.bottom;
130
+ const showAbove = spaceBelow < 200 && rect.top > spaceBelow;
131
+ const top = showAbove ? undefined : rect.bottom + 4;
132
+ const bottom = showAbove ? window.innerHeight - rect.top + 4 : undefined;
133
+ const left = Math.max(VIEWPORT_MARGIN, Math.min(rect.left, window.innerWidth - (renderedWidth ?? 520) - VIEWPORT_MARGIN));
134
+
135
+ return createPortal(
136
+ <div
137
+ ref={windowRef}
138
+ className="code-snippet-preview-window fixed z-[9999] rounded-lg border border-border bg-card shadow-xl flex flex-col"
139
+ style={{ top, bottom, left, maxWidth: `min(600px, calc(100vw - ${VIEWPORT_MARGIN * 2}px))`, maxHeight: '300px' }}
140
+ data-code-snippet-preview={filepath}
141
+ onMouseEnter={onMouseEnter}
142
+ onMouseLeave={onMouseLeave}
143
+ >
144
+ <div className="px-3 py-1.5 border-b border-border/50 text-[10px] text-muted-foreground font-mono flex items-center justify-between gap-4 flex-shrink-0">
145
+ <span>{title ?? filepath.split('/').pop()}</span>
146
+ <span className="opacity-60">{lineEnd && lineEnd !== line ? `lines ${line}–${lineEnd}` : `line ${line}`}</span>
147
+ </div>
148
+ <div className="code-snippet-preview overflow-auto text-[12px] leading-5 min-h-0" style={{ padding: 0, background: 'var(--code-bg, #1e293b)' }}>
149
+ <table className="border-collapse w-full">
150
+ <tbody>
151
+ {snippet.split('\n').map((_, i) => {
152
+ const index = start + i;
153
+ const target = marksTarget && index >= targetStart && index < targetEnd;
154
+ return (
155
+ <tr
156
+ key={index}
157
+ className={target ? 'bg-primary/15' : 'hover:bg-white/5'}
158
+ data-target-line={target ? 'true' : undefined}
159
+ >
160
+ <td className={`select-none text-right pr-3 pl-3 py-0 align-top font-mono w-8 whitespace-nowrap ${target ? 'text-primary' : 'text-muted-foreground/40'}`} style={{ userSelect: 'none' }}>{index + 1}</td>
161
+ <td
162
+ className="font-mono pr-3 py-0 whitespace-pre"
163
+ dangerouslySetInnerHTML={{ __html: highlightedLines[i] ?? '' }}
164
+ />
165
+ </tr>
166
+ );
167
+ })}
168
+ </tbody>
169
+ </table>
170
+ </div>
171
+ </div>,
172
+ document.body,
173
+ );
174
+ };
@@ -5,6 +5,8 @@ import { AttachmentsButton } from './AttachmentsButton';
5
5
  import { useDraggable } from '../hooks/useDraggable';
6
6
  import { SparklesIcon } from './SparklesIcon';
7
7
  import { hasUnsavedCommentContent } from '../utils/commentContent';
8
+ import { isCommentSubmitKey } from '../utils/commentSubmitKey';
9
+ import { useConfigValue } from '../config/useConfig';
8
10
  import { useSkillReferenceAutocomplete } from '../hooks/useSkillReferenceAutocomplete';
9
11
  import { HumanOnlySkillNotice, SkillReferenceMenu } from './SkillReferenceMenu';
10
12
  import { ComposerTokenTextarea, type ComposerMentionChips } from './ComposerTokenTextarea';
@@ -119,6 +121,20 @@ interface CommentPopoverProps {
119
121
  /** Opt-in composer yield while shift-selecting: 'near' fades the composer,
120
122
  * 'over' makes it near-invisible and click-through. Undefined → no-op. */
121
123
  yieldState?: CommentPopoverYieldState;
124
+ /** Opt-in: the header's text in place of the quoted `contextText` (an image
125
+ * region's "Region 3 · 356 × 486 px"). Absent → the quote, as always. */
126
+ headerText?: string;
127
+ /** Opt-in: `side` opens the popover beside the anchor (right, else left),
128
+ * falling back to below/above when neither side has room (an image region
129
+ * in the image viewer). Absent → below/above, as always. */
130
+ placement?: 'below' | 'side';
131
+ /** Opt-in: the stacking level, for a composer over a surface that is
132
+ * itself above the page (the image viewer is z-200). Absent → 100. */
133
+ zIndex?: number;
134
+ /** Opt-out: `false` mounts the composer without taking focus (an HTML
135
+ * pin's composer shown again when its hidden target returns, so focus
136
+ * stays where the person is). Absent → focuses on mount, as always. */
137
+ autoFocus?: boolean;
122
138
  }
123
139
 
124
140
  const MAX_POPOVER_WIDTH = 384;
@@ -151,7 +167,19 @@ interface CommentPopoverPosition {
151
167
  export function computeCommentPopoverPosition(
152
168
  anchorRect: Pick<DOMRect, 'top' | 'right' | 'bottom' | 'left' | 'width'>,
153
169
  bounds: VisibleViewportBounds,
170
+ placement: 'below' | 'side' = 'below',
154
171
  ): CommentPopoverPosition {
172
+ if (placement === 'side') {
173
+ const width = Math.min(MAX_POPOVER_WIDTH, bounds.width);
174
+ const right = anchorRect.right + GAP * 1.75;
175
+ const left = anchorRect.left - GAP * 1.75 - width;
176
+ const sideLeft = right + width <= bounds.right ? right : left >= bounds.left ? left : null;
177
+ if (sideLeft !== null) {
178
+ const top = Math.max(bounds.top, Math.min(anchorRect.top, bounds.bottom - 280));
179
+ const maxHeight = Math.max(0, bounds.bottom - top);
180
+ if (maxHeight >= 280) return { top, left: sideLeft, flipAbove: false, width, maxHeight, requiresExpanded: false };
181
+ }
182
+ }
155
183
  const spaceBelow = Math.max(0, bounds.bottom - anchorRect.bottom - GAP);
156
184
  const spaceAbove = Math.max(0, anchorRect.top - bounds.top - GAP);
157
185
  const flipAbove = spaceBelow < 280 && spaceAbove > spaceBelow;
@@ -199,6 +227,10 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
199
227
  refocusToken,
200
228
  captureStrayKeys = false,
201
229
  yieldState,
230
+ headerText,
231
+ placement = 'below',
232
+ zIndex,
233
+ autoFocus = true,
202
234
  }) => {
203
235
  const visibleBounds = useVisibleViewportBounds(16);
204
236
  const coarsePointer = hasPrimaryCoarsePointer();
@@ -266,7 +298,7 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
266
298
  const update = () => {
267
299
  const rect = anchorEl?.getBoundingClientRect() ?? anchorRect;
268
300
  if (!rect) return;
269
- const nextPosition = computeCommentPopoverPosition(rect, visibleBounds);
301
+ const nextPosition = computeCommentPopoverPosition(rect, visibleBounds, placement);
270
302
  if (nextPosition.requiresExpanded) {
271
303
  setDialogIsForced(true);
272
304
  setMode('dialog');
@@ -280,7 +312,7 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
280
312
  return () => {
281
313
  window.removeEventListener('scroll', update, true);
282
314
  };
283
- }, [anchorEl, anchorRect, mode, visibleBounds, wasDragged]);
315
+ }, [anchorEl, anchorRect, mode, visibleBounds, wasDragged, placement]);
284
316
 
285
317
  // Surface a "jump back" arrow when an open popover scrolls out of view.
286
318
  // Re-measures whenever the popover repositions (position updates every scroll
@@ -307,7 +339,7 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
307
339
  // renders after `position` is measured, and WebKit fires 0ms timers ahead of
308
340
  // that commit, so an effect keyed on mode alone can run before the textarea
309
341
  // exists and never focus it (e.g. in WKWebView hosts like Glimpse).
310
- const shouldAutoFocus = !coarsePointer || initialText.length > 0 || isGlobal;
342
+ const shouldAutoFocus = autoFocus && (!coarsePointer || initialText.length > 0 || isGlobal);
311
343
  const focusOnMountRef = useCallback((el: HTMLTextAreaElement | null) => {
312
344
  textareaRef.current = el;
313
345
  if (!el || !shouldAutoFocus) return;
@@ -322,13 +354,13 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
322
354
  // summon the software keyboard), but hardware-keyboard users still need a
323
355
  // coherent focus target and Escape dismissal.
324
356
  useEffect(() => {
325
- if (mode !== 'dialog' || shouldAutoFocus) return;
357
+ if (mode !== 'dialog' || shouldAutoFocus || !autoFocus) return;
326
358
  const timer = setTimeout(() => {
327
359
  if (!popoverRef.current?.isConnected) return;
328
360
  popoverRef.current.focus({ preventScroll: true });
329
361
  }, 0);
330
362
  return () => clearTimeout(timer);
331
- }, [mode, shouldAutoFocus]);
363
+ }, [autoFocus, mode, shouldAutoFocus]);
332
364
 
333
365
  const restoreOpeningFocus = useCallback(() => {
334
366
  const target = openingFocusRef.current;
@@ -561,6 +593,7 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
561
593
  const composerListboxOpen = skillAc.menu !== null || mentionAc.menu !== null;
562
594
  const activeComposerOptionId = activeSkillOptionId ?? activeMentionOptionId;
563
595
 
596
+ const enterSavesComment = useConfigValue('enterSavesComment');
564
597
  const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
565
598
  if (skillAc.onKeyDown(e)) return;
566
599
  if (mentionAc.onKeyDown(e)) return;
@@ -577,17 +610,17 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
577
610
  }
578
611
  return;
579
612
  }
580
- if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && !e.nativeEvent.isComposing) {
613
+ if (isCommentSubmitKey(e, enterSavesComment)) {
581
614
  e.preventDefault();
582
615
  handleSubmit();
583
616
  }
584
617
  };
585
618
 
586
- const headerLabel = isGlobal
619
+ const headerLabel = headerText ?? (isGlobal
587
620
  ? 'Global Comment'
588
621
  : contextText
589
622
  ? `"${contextText.length > 50 ? contextText.slice(0, 50) + '...' : contextText}"`
590
- : 'Comment';
623
+ : 'Comment');
591
624
 
592
625
  const canSubmit =
593
626
  hasUnsavedContent ||
@@ -630,6 +663,7 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
630
663
  <div
631
664
  data-comment-popover="true"
632
665
  className="pn-visible-viewport-overlay z-[100] flex items-center justify-center"
666
+ style={zIndex !== undefined ? { zIndex } : undefined}
633
667
  >
634
668
  {/* Backdrop */}
635
669
  <button
@@ -797,6 +831,7 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
797
831
  className={`fixed z-[100] bg-popover border border-border rounded-xl shadow-2xl flex flex-col${yieldClass}`}
798
832
  style={dragPosition
799
833
  ? {
834
+ ...(zIndex !== undefined ? { zIndex } : {}),
800
835
  top: dragPosition.top,
801
836
  left: dragPosition.left,
802
837
  width: position.width,
@@ -804,6 +839,7 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
804
839
  overflowY: showsSkillMenu ? 'visible' : 'auto',
805
840
  }
806
841
  : {
842
+ ...(zIndex !== undefined ? { zIndex } : {}),
807
843
  top: position.top,
808
844
  left: position.left,
809
845
  width: position.width,
@@ -8,6 +8,7 @@ import { ConfirmDialog, type ConfirmDialogProps } from './ConfirmDialog';
8
8
  import { useDismissablePopover } from '../hooks/useDismissablePopover';
9
9
  import { useCompactTouchLayout } from '../hooks/useIsMobile';
10
10
  import { cn } from '../lib/utils';
11
+ import { isCommentSubmitKey } from '../utils/commentSubmitKey';
11
12
  import type {
12
13
  DecisionActionId,
13
14
  DecisionComposer,
@@ -57,6 +58,16 @@ export interface DecisionControlProps {
57
58
  dismissOnIframeFocus?: boolean;
58
59
  /** Confirm surface override; defaults to the ui ConfirmDialog. */
59
60
  confirmDialog?: React.ComponentType<ConfirmDialogProps>;
61
+ /**
62
+ * Rows rendered under the decisions, inside the same menu (#1057: plan
63
+ * review's "After approval, switch to" agent picker and Claude Code's
64
+ * permission-mode line). Not decisions: they never close the menu by
65
+ * themselves. Interactive rows use `role="menuitem"` or
66
+ * `role="menuitemradio"` so the arrow keys reach them. Absent: the menu is
67
+ * the decisions alone, exactly as before. A function receives
68
+ * `closeMenu`, for a row that leaves the menu (e.g. one opening Settings).
69
+ */
70
+ menuFooter?: React.ReactNode | ((api: { closeMenu: () => void }) => React.ReactNode);
60
71
  }
61
72
 
62
73
  const NOTE_MAX_HEIGHT_PX = 144;
@@ -98,6 +109,14 @@ export interface DecisionNoteFieldProps {
98
109
  placeholder?: string;
99
110
  disabled?: boolean;
100
111
  autoFocus?: boolean;
112
+ /**
113
+ * The field is a COMMENT composer (the review sidebar's "+ General
114
+ * comment"), so the "Enter saves a comment" setting applies: pass its value
115
+ * and a plain Enter also submits (Shift+Enter stays a new line). Default
116
+ * false: in the decision control's own composer a note rides a decision,
117
+ * and only Mod+Enter fires it.
118
+ */
119
+ enterSubmits?: boolean;
101
120
  }
102
121
 
103
122
  /**
@@ -114,6 +133,7 @@ export const DecisionNoteField: React.FC<DecisionNoteFieldProps> = ({
114
133
  placeholder = 'Add a note...',
115
134
  disabled = false,
116
135
  autoFocus = true,
136
+ enterSubmits = false,
117
137
  }) => {
118
138
  const ref = useAutoGrow(text);
119
139
 
@@ -132,15 +152,15 @@ export const DecisionNoteField: React.FC<DecisionNoteFieldProps> = ({
132
152
  onCancel();
133
153
  return;
134
154
  }
135
- if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
155
+ if (enterSubmits ? isCommentSubmitKey(event, true) : event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
136
156
  event.preventDefault();
137
157
  event.stopPropagation();
138
158
  if (!disabled) onSubmit();
139
159
  }
140
- // Plain Enter falls through: the field is multi-line and Enter's job is
141
- // a newline.
160
+ // Otherwise plain Enter falls through: the field is multi-line and
161
+ // Enter's job is a newline.
142
162
  },
143
- [disabled, onCancel, onSubmit],
163
+ [disabled, enterSubmits, onCancel, onSubmit],
144
164
  );
145
165
 
146
166
  return (
@@ -255,6 +275,7 @@ export const DecisionControl: React.FC<DecisionControlProps> = ({
255
275
  labelBreakpoint = 'md',
256
276
  dismissOnIframeFocus = false,
257
277
  confirmDialog,
278
+ menuFooter,
258
279
  }) => {
259
280
  const rootRef = useRef<HTMLDivElement>(null);
260
281
  const popoverRef = useRef<HTMLDivElement>(null);
@@ -402,7 +423,9 @@ export const DecisionControl: React.FC<DecisionControlProps> = ({
402
423
  const keys = ['ArrowDown', 'ArrowUp', 'Home', 'End'];
403
424
  if (!keys.includes(event.key)) return;
404
425
  const items = Array.from(
405
- popoverRef.current?.querySelectorAll<HTMLButtonElement>('[role="menuitem"]:not(:disabled)') ?? [],
426
+ popoverRef.current?.querySelectorAll<HTMLButtonElement>(
427
+ '[role="menuitem"]:not(:disabled), [role="menuitemradio"]:not(:disabled)',
428
+ ) ?? [],
406
429
  );
407
430
  if (items.length === 0) return;
408
431
  event.preventDefault();
@@ -548,6 +571,9 @@ export const DecisionControl: React.FC<DecisionControlProps> = ({
548
571
  />
549
572
  </React.Fragment>
550
573
  ))}
574
+ {typeof menuFooter === 'function'
575
+ ? menuFooter({ closeMenu: () => closePopover() })
576
+ : menuFooter}
551
577
  </div>
552
578
  ) : activeItem?.composer ? (
553
579
  <div className="p-2">
@@ -0,0 +1,38 @@
1
+ // Moved from the Workspaces app's DeferredMarkdownContentsRail with its
2
+ // contents rail (MarkdownContentsRail, approved 2026-10-03). Port: the props
3
+ // are the rail's own (`MarkdownContentsRailProps`), passed through.
4
+
5
+ import { lazy, Suspense, useEffect, useState } from 'react';
6
+
7
+ import type { MarkdownContentsRailProps } from './MarkdownContentsRail';
8
+
9
+ const MarkdownContentsRail = lazy(() =>
10
+ import('./MarkdownContentsRail').then((module) => ({
11
+ default: module.MarkdownContentsRail,
12
+ })),
13
+ );
14
+
15
+ export function scheduleMarkdownContentsRail(callback: () => void): () => void {
16
+ if (typeof window.requestIdleCallback === 'function') {
17
+ const handle = window.requestIdleCallback(callback, { timeout: 2000 });
18
+ return () => window.cancelIdleCallback(handle);
19
+ }
20
+ const handle = window.setTimeout(callback, 0);
21
+ return () => window.clearTimeout(handle);
22
+ }
23
+
24
+ /** Load the reading aid after the document is interactive, never in the
25
+ * critical document-paint path. The timeout still guarantees it appears on
26
+ * a busy page; Safari and tests use the zero-delay fallback. */
27
+ export function DeferredMarkdownContentsRail(props: MarkdownContentsRailProps) {
28
+ const [ready, setReady] = useState(false);
29
+
30
+ useEffect(() => scheduleMarkdownContentsRail(() => setReady(true)), []);
31
+
32
+ if (!ready) return null;
33
+ return (
34
+ <Suspense fallback={null}>
35
+ <MarkdownContentsRail {...props} />
36
+ </Suspense>
37
+ );
38
+ }
@@ -16,6 +16,7 @@ import { svgContentSize } from './diagram/svgContentSize';
16
16
  import type { DiagramComment, DiagramCreateComment } from './diagram/useDiagramComments';
17
17
  import type { DiagramRenderState } from './diagram/useDiagramRender';
18
18
  import { useTheme } from './ThemeProvider';
19
+ import { LazyBlockBoundary } from './LazyBlockBoundary';
19
20
 
20
21
  /**
21
22
  * A diagram fence in the document: the fence's language picks the engine
@@ -345,7 +346,7 @@ export const DiagramBlock: React.FC<DiagramBlockProps & { kind: DiagramKind }> =
345
346
  appears in a node label) can never wrap a <mark> inside the svg. */}
346
347
  <div ref={rootRef} className="annotation-exclude my-5 group relative" data-block-id={block.id} data-pinpoint-ignore="" data-diagram-block={kind}>
347
348
  {svgReady && !showSource && (
348
- <div data-print-hide="" className="absolute top-2 right-2 z-10 flex items-center gap-1 opacity-0 group-hover:opacity-100 focus-within:opacity-100 transition-opacity">
349
+ <div data-print-hide="" className="absolute top-2 right-2 z-10 flex items-center gap-1 opacity-0 group-hover:opacity-100 focus-within:opacity-100 [@media(hover:none)]:opacity-100 transition-opacity">
349
350
  <button
350
351
  type="button"
351
352
  onClick={() => setShowSource(true)}
@@ -404,17 +405,21 @@ export const DiagramBlock: React.FC<DiagramBlockProps & { kind: DiagramKind }> =
404
405
  )}
405
406
  </div>
406
407
  {isExpanded && svgReady && typeof document !== 'undefined' && (
407
- <Suspense fallback={null}>
408
- <DiagramPopout
409
- {...viewerProps}
410
- open
411
- onClose={() => setIsExpanded(false)}
412
- title={`${label} diagram`}
413
- renderId={`${kind}-${block.id}-popout`}
414
- onAskAI={handlePopoutAskAI}
415
- dataAttributes={{ 'data-block-id': block.id }}
416
- />
417
- </Suspense>
408
+ // A popout chunk that cannot load leaves the inline diagram as it is
409
+ // and says so under it.
410
+ <LazyBlockBoundary fallback={(notice) => <div className="mb-5">{notice}</div>}>
411
+ <Suspense fallback={null}>
412
+ <DiagramPopout
413
+ {...viewerProps}
414
+ open
415
+ onClose={() => setIsExpanded(false)}
416
+ title={`${label} diagram`}
417
+ renderId={`${kind}-${block.id}-popout`}
418
+ onAskAI={handlePopoutAskAI}
419
+ dataAttributes={{ 'data-block-id': block.id }}
420
+ />
421
+ </Suspense>
422
+ </LazyBlockBoundary>
418
423
  )}
419
424
  </>
420
425
  );
@@ -11,12 +11,13 @@ export type { DiffFileTreeFile, DiffFileTreeNode, DiffFileTreeStatus } from '../
11
11
  export type { DiffFileTreeDefaultExpanded } from '../hooks/useDiffFileTreeExpansion';
12
12
 
13
13
  /*
14
- * The diff file tree's presentational parts. Plannotator's code review renders
15
- * its tree rows from the atoms below (folder row, file-row content, change
16
- * letter, +/- counts, indentation), adding its review-only decorations
17
- * (viewed/stage controls, annotation badge, context menu) around them; the
18
- * `DiffFileTree` component at the bottom is the read-only, embeddable tree a
19
- * host drops beside its own diff list.
14
+ * The diff file tree's presentational parts: the atoms below (folder row,
15
+ * file-row content, change letter, +/- counts, indentation) for a host's own
16
+ * rows, and the `DiffFileTree` component at the bottom, the read-only,
17
+ * embeddable tree a host drops beside its own diff list. Plannotator's code
18
+ * review shares `ChangeTypeLetter` and `diffFileTreeIndent` (its All files
19
+ * tree) but draws its own Changes rows since its aligned sidebar (see
20
+ * HANDOFF.md § "Diff file tree").
20
21
  */
21
22
 
22
23
  /** Left padding of a row at `depth` (px). */
@@ -0,0 +1,74 @@
1
+ import { useLayoutEffect, useMemo, useRef } from 'react';
2
+ import type { Annotation, Block } from '../types';
3
+ import { CONTENTS_RAIL_MAX_LEVEL, tocHeadings } from '../utils/annotationHelpers';
4
+ import { useHeadingScrollSpy } from '../hooks/useHeadingScrollSpy';
5
+ import { TableOfContents } from './TableOfContents';
6
+
7
+ interface DocumentContentsProps {
8
+ blocks: Block[];
9
+ annotations: Annotation[];
10
+ /**
11
+ * The element the document scrolls in. The current heading is read from it
12
+ * and a click scrolls it. `null` until it has mounted.
13
+ */
14
+ scrollViewport: HTMLElement | null;
15
+ /** Called with the heading's block id after a click (a sheet closes itself here). */
16
+ onNavigate?: (blockId: string) => void;
17
+ className?: string;
18
+ }
19
+
20
+ /**
21
+ * A document's Contents list for a host without plan review's sidebar, on the
22
+ * contents rail's surface (the Inbox's sheet, where the rail yields): the same
23
+ * `TableOfContents` (click to scroll, the current heading highlighted) with
24
+ * the rail's headings (levels 1 to 4) and the rail's scroll math
25
+ * (`useHeadingScrollSpy`), so the list and the rail never disagree. It draws
26
+ * no chrome; the host places it and titles it.
27
+ *
28
+ * The host mounts it afresh (a sheet that opens), often mid-document: the
29
+ * current heading is read at once. The marked entry is kept in view inside the
30
+ * list's own scroll box (a sheet).
31
+ */
32
+ export function DocumentContents({ blocks, annotations, scrollViewport, onNavigate, className = '' }: DocumentContentsProps) {
33
+ const headingIds = useMemo(
34
+ () => tocHeadings(blocks, CONTENTS_RAIL_MAX_LEVEL).map((heading) => heading.id),
35
+ [blocks],
36
+ );
37
+ const activeId = useHeadingScrollSpy(scrollViewport, headingIds);
38
+
39
+ const wrapRef = useRef<HTMLDivElement>(null);
40
+ useLayoutEffect(() => {
41
+ const item = wrapRef.current?.querySelector<HTMLElement>('[aria-current="location"]');
42
+ if (item) revealInList(item);
43
+ }, [activeId]);
44
+
45
+ return (
46
+ <div ref={wrapRef} style={{ display: 'contents' }}>
47
+ <TableOfContents
48
+ blocks={blocks}
49
+ annotations={annotations}
50
+ activeId={activeId}
51
+ onNavigate={onNavigate ?? noop}
52
+ scrollViewport={scrollViewport}
53
+ maxLevel={CONTENTS_RAIL_MAX_LEVEL}
54
+ className={className}
55
+ />
56
+ </div>
57
+ );
58
+ }
59
+
60
+ /**
61
+ * Scroll the list's own scroll box (the nearest ancestor that scrolls) just
62
+ * enough to show `item`, at once. Never the page or the document behind it.
63
+ */
64
+ function revealInList(item: HTMLElement) {
65
+ let box = item.parentElement;
66
+ while (box && !/(auto|scroll)/.test(getComputedStyle(box).overflowY)) box = box.parentElement;
67
+ if (!box || box.scrollHeight <= box.clientHeight) return;
68
+ const area = box.getBoundingClientRect();
69
+ const rect = item.getBoundingClientRect();
70
+ if (rect.top < area.top) box.scrollTop -= area.top - rect.top;
71
+ else if (rect.bottom > area.bottom) box.scrollTop += Math.min(rect.bottom - area.bottom, rect.top - area.top);
72
+ }
73
+
74
+ function noop() {}
@@ -42,7 +42,7 @@ export const EditorAnnotationCard: React.FC<EditorAnnotationCardProps> = ({ anno
42
42
  {onDelete && (
43
43
  <button
44
44
  onClick={(e) => { e.stopPropagation(); onDelete(); }}
45
- className="ml-auto relative rounded-md p-1.5 text-muted-foreground transition-colors before:absolute before:-inset-1.5 before:content-[''] opacity-0 group-hover:opacity-100 [@media(hover:none)]:opacity-100 hover:text-destructive"
45
+ className="ml-auto relative rounded-md p-1.5 text-muted-foreground transition-colors before:absolute before:-inset-1.5 before:content-[''] opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 [@media(hover:none)]:opacity-100 hover:text-destructive"
46
46
  title="Delete annotation"
47
47
  >
48
48
  <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>