@plannotator/ui 0.53.1 → 0.54.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 (135) hide show
  1. package/HANDOFF.md +217 -11
  2. package/README.md +65 -10
  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 +19 -5
  12. package/components/CodeFilePopout.tsx +139 -4
  13. package/components/CommentPopover.tsx +44 -8
  14. package/components/DecisionControl.tsx +31 -5
  15. package/components/DiagramBlock.tsx +17 -12
  16. package/components/EditorAnnotationCard.tsx +1 -1
  17. package/components/ExportModal.tsx +77 -7
  18. package/components/HostTitle.tsx +0 -4
  19. package/components/ImageAnnotator/Toolbar.tsx +25 -1
  20. package/components/ImageAnnotator/index.tsx +24 -4
  21. package/components/ImageLightbox.tsx +658 -120
  22. package/components/ImagePeek.tsx +38 -4
  23. package/components/ImageRegions.tsx +369 -0
  24. package/components/ImageThumbnail.tsx +1 -1
  25. package/components/ImageZoomStage.tsx +796 -0
  26. package/components/InboxAnnouncementDialog.tsx +202 -0
  27. package/components/InlineMarkdown.tsx +152 -24
  28. package/components/KeyboardShortcuts.tsx +23 -0
  29. package/components/LazyBlockBoundary.tsx +100 -0
  30. package/components/ListMarker.tsx +3 -3
  31. package/components/LookAndFeelAnnouncementDialog.tsx +12 -2
  32. package/components/MenuVersionSection.tsx +31 -9
  33. package/components/PlanDecisionMenuExtras.tsx +129 -0
  34. package/components/PlanHeaderMenu.tsx +15 -1
  35. package/components/ProviderIcons.tsx +8 -0
  36. package/components/RefHoverCard.tsx +913 -0
  37. package/components/SendFailureToast.tsx +126 -0
  38. package/components/Settings.tsx +617 -126
  39. package/components/TerminalToolsAnnouncementDialog.tsx +1 -1
  40. package/components/ThemeProvider.tsx +14 -1
  41. package/components/ThemeTab.tsx +16 -24
  42. package/components/Tooltip.tsx +49 -18
  43. package/components/Viewer.tsx +280 -41
  44. package/components/ai/DocumentAIChatPanel.tsx +29 -3
  45. package/components/ai/NewChatButton.tsx +74 -0
  46. package/components/blocks/CodeBlock.tsx +1 -1
  47. package/components/blocks/HtmlBlock.tsx +7 -1
  48. package/components/blocks/QuestionBlock.tsx +522 -62
  49. package/components/diagram/DiagramComposer.tsx +5 -1
  50. package/components/diagram/DiagramPending.tsx +12 -2
  51. package/components/diagram/DiagramViewer.tsx +19 -3
  52. package/components/html-embed/HtmlEmbedBlock.tsx +374 -0
  53. package/components/html-embed/SvgFenceBlock.tsx +46 -0
  54. package/components/html-embed/index.ts +3 -0
  55. package/components/html-embed/languages.ts +15 -0
  56. package/components/html-embed/loadGate.ts +107 -0
  57. package/components/html-viewer/HtmlViewer.tsx +47 -6
  58. package/components/html-viewer/bridge-script.asset.js +173 -1
  59. package/components/html-viewer/bridge-script.ts +173 -1
  60. package/components/html-viewer/useHtmlAnnotation.ts +125 -0
  61. package/components/imageZoom.ts +265 -0
  62. package/components/plan-diff/PlanCleanDiffView.tsx +1 -1
  63. package/components/plan-diff/PlanDiffViewer.tsx +13 -2
  64. package/components/plan-diff/PlanRawDiffView.tsx +1 -1
  65. package/components/sidebar/FileBrowser.tsx +16 -2
  66. package/components/sidebar/SidebarTabs.tsx +3 -0
  67. package/config/index.ts +3 -0
  68. package/config/reviewView.ts +41 -1
  69. package/config/settings.ts +155 -4
  70. package/configure.ts +28 -0
  71. package/hooks/pfm/useCodeFilePopout.ts +7 -1
  72. package/hooks/useAIChat.ts +72 -25
  73. package/hooks/useAnnotationDraft.ts +71 -6
  74. package/hooks/useAnnotationHighlighter.ts +57 -3
  75. package/hooks/useCodeAnnotationDraft.ts +113 -17
  76. package/hooks/useCookieHostSetting.ts +96 -0
  77. package/hooks/useDocumentFont.ts +17 -0
  78. package/hooks/useDocumentTextSize.ts +20 -0
  79. package/hooks/useFileBrowser.ts +10 -1
  80. package/hooks/useLinkedDoc.ts +226 -13
  81. package/hooks/usePackageChangesSetting.ts +90 -0
  82. package/hooks/usePlanLookDialog.ts +67 -0
  83. package/hooks/useSharing.ts +11 -7
  84. package/hooks/useThemePaletteHalves.ts +37 -0
  85. package/hooks/useUpdateCheck.ts +22 -4
  86. package/hooks/useUpdateToast.ts +1 -1
  87. package/package.json +23 -22
  88. package/shortcuts/code-review/annotationToolbar.shortcuts.ts +1 -0
  89. package/shortcuts/code-review/explorer.shortcuts.ts +82 -0
  90. package/shortcuts/core.ts +63 -4
  91. package/shortcuts/index.ts +1 -0
  92. package/shortcuts/plan-review/annotationPanel.shortcuts.ts +1 -1
  93. package/shortcuts/plan-review/commentPopover.shortcuts.ts +1 -0
  94. package/shortcuts/runtime.ts +4 -3
  95. package/styles.css +1 -1
  96. package/theme.css +179 -1
  97. package/themes/plannotator-graphite.css +66 -0
  98. package/types.ts +26 -9
  99. package/utils/agentSwitch.ts +41 -20
  100. package/utils/agentToolSetting.ts +1 -1
  101. package/utils/aiChatFormat.ts +23 -7
  102. package/utils/annotationOwnsHighlight.ts +4 -2
  103. package/utils/asciidocParser.ts +570 -0
  104. package/utils/autoUpdateNotice.ts +24 -0
  105. package/utils/belowTitleSlot.ts +174 -0
  106. package/utils/codeLineScroll.ts +100 -0
  107. package/utils/commentSubmitKey.ts +48 -0
  108. package/utils/cssFontFamily.ts +65 -0
  109. package/utils/decisionSpec.ts +190 -2
  110. package/utils/defaultNotesApp.ts +1 -1
  111. package/utils/diffFonts.ts +4 -1
  112. package/utils/documentFont.ts +68 -0
  113. package/utils/documentTextSize.ts +54 -0
  114. package/utils/fontDetect.ts +63 -0
  115. package/utils/inboxAnnouncement.ts +78 -0
  116. package/utils/lookAndFeelAnnouncement.ts +82 -5
  117. package/utils/markdownExtensions.ts +2 -2
  118. package/utils/mermaid.ts +20 -3
  119. package/utils/mermaidTheme.ts +3 -51
  120. package/utils/notion.ts +58 -0
  121. package/utils/parser.ts +28 -1
  122. package/utils/platform.ts +46 -0
  123. package/utils/refCard.ts +82 -0
  124. package/utils/richClipboard.ts +380 -0
  125. package/utils/richClipboardLists.ts +57 -0
  126. package/utils/richCopyPolicy.ts +22 -0
  127. package/utils/scrollAnchor.ts +82 -0
  128. package/utils/sectionRefs.ts +123 -0
  129. package/utils/selectionBoundary.ts +40 -0
  130. package/utils/sendFailure.ts +85 -0
  131. package/utils/sharing.ts +5 -2
  132. package/utils/themeRegistry.ts +10 -0
  133. package/utils/themeTokens.ts +74 -0
  134. package/utils/updateNotice.ts +30 -8
  135. package/utils/vscodePanel.ts +9 -0
@@ -4,6 +4,7 @@ import { createPortal } from "react-dom";
4
4
  import { useDismissOnOutsideAndEscape } from "../hooks/useDismissOnOutsideAndEscape";
5
5
  import { type QuickLabel, getQuickLabels, quickLabelIndexForDigit, THUMBS_UP_LABEL } from "../utils/quickLabels";
6
6
  import { copyTextToClipboard } from "../utils/clipboard";
7
+ import { copyRichSelection, richCopyOutcomeLabel, type RichCopyOutcome, type RichCopySource } from "../utils/richClipboard";
7
8
  import { acquireTypeToCommentCapture } from "../shortcuts/plan-review/annotationMode.shortcuts";
8
9
  import { FloatingQuickLabelPicker } from "./FloatingQuickLabelPicker";
9
10
  import { FloatingSelectionActionsPicker } from "./SelectionActionsDropdown";
@@ -30,6 +31,12 @@ interface AnnotationToolbarProps {
30
31
  onQuickLabel?: (label: QuickLabel) => void;
31
32
  /** Text to copy when the button is clicked */
32
33
  copyText?: string;
34
+ /** Builds the captured text selection's sanitized HTML and plain text when
35
+ * Copy is pressed (never before). When present, Copy writes text/html plus
36
+ * text/plain in one clipboard write (a plain field still pastes plain
37
+ * text), and the Copy button shows even where hideCopyButton hides it.
38
+ * Absent: Copy copies plain text, exactly as before. No shortcut. */
39
+ getRichCopy?: () => RichCopySource | Promise<RichCopySource>;
33
40
  /** Comment-only surfaces (HTML / live-app viewer): hide the Delete action,
34
41
  * the quick-label picker, and the Alt+digit label shortcuts. A provided
35
42
  * onQuickLabel then renders ONLY the hardcoded 👍 "Looks good" button —
@@ -57,7 +64,7 @@ interface AnnotationToolbarProps {
57
64
  * this toolbar. The one-click 👍 is a separate affordance and is unaffected.
58
65
  */
59
66
  quickLabels?: boolean;
60
- /** Hide the copy button (set when a keyboard copy handler exists) */
67
+ /** Hide the plain copy button (set when a keyboard copy handler exists) */
61
68
  hideCopyButton?: boolean;
62
69
  /** Close toolbar when element scrolls out of viewport */
63
70
  closeOnScrollOut?: boolean;
@@ -79,6 +86,7 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
79
86
  selectionActionsIcon,
80
87
  quickLabels: quickLabelsEnabled = true,
81
88
  copyText,
89
+ getRichCopy,
82
90
  commentOnly = false,
83
91
  hideCopyButton = false,
84
92
  closeOnScrollOut = false,
@@ -88,6 +96,10 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
88
96
  }) => {
89
97
  const [position, setPosition] = useState<{ top: number; left?: number; right?: number } | null>(null);
90
98
  const [copied, setCopied] = useState(false);
99
+ const [richCopied, setRichCopied] = useState<RichCopyOutcome | false>(false);
100
+ const richCopyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
101
+ const richCopySelectionRef = useRef({ element, getRichCopy });
102
+ richCopySelectionRef.current = { element, getRichCopy };
91
103
  const [showQuickLabels, setShowQuickLabels] = useState(false);
92
104
  const [showSelectionActions, setShowSelectionActions] = useState(false);
93
105
  const toolbarRef = useRef<HTMLDivElement>(null);
@@ -107,7 +119,18 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
107
119
  const selectionActionsOpen = showSelectionActions && hasSelectionActions;
108
120
  const quickLabelsOpen = showQuickLabels && !commentOnly && quickLabelsEnabled;
109
121
 
110
- useEffect(() => { setCopied(false); }, [element]);
122
+ useEffect(() => {
123
+ setCopied(false);
124
+ setRichCopied(false);
125
+ }, [element]);
126
+ useEffect(() => {
127
+ setRichCopied(false);
128
+ if (richCopyTimeoutRef.current !== null) clearTimeout(richCopyTimeoutRef.current);
129
+ richCopyTimeoutRef.current = null;
130
+ }, [element, getRichCopy]);
131
+ useEffect(() => () => {
132
+ if (richCopyTimeoutRef.current !== null) clearTimeout(richCopyTimeoutRef.current);
133
+ }, []);
111
134
 
112
135
  const handleCopy = async () => {
113
136
  let textToCopy = copyText;
@@ -121,6 +144,24 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
121
144
  }
122
145
  };
123
146
 
147
+ const handleRichCopy = async () => {
148
+ setRichCopied(false);
149
+ const build = getRichCopy;
150
+ if (!build) return;
151
+ const fallback = copyText || element.querySelector('code')?.textContent || element.textContent || '';
152
+ const result = await copyRichSelection(build, fallback);
153
+ const current = richCopySelectionRef.current;
154
+ if (current.element !== element || current.getRichCopy !== build) return;
155
+ if (result) {
156
+ setRichCopied(result);
157
+ if (richCopyTimeoutRef.current !== null) clearTimeout(richCopyTimeoutRef.current);
158
+ richCopyTimeoutRef.current = setTimeout(() => {
159
+ setRichCopied(false);
160
+ richCopyTimeoutRef.current = null;
161
+ }, 1500);
162
+ }
163
+ };
164
+
124
165
  // Update position on scroll/resize
125
166
  useEffect(() => {
126
167
  const updatePosition = () => {
@@ -252,14 +293,28 @@ export const AnnotationToolbar: React.FC<AnnotationToolbarProps> = ({
252
293
  }
253
294
  `}</style>
254
295
  <div data-pn-annotation-toolbar-row="true" className="flex items-center p-1 gap-0.5">
255
- {!hideCopyButton && (
296
+ {(!hideCopyButton || getRichCopy) && (
256
297
  <>
257
- <ToolbarButton
258
- onClick={handleCopy}
259
- icon={copied ? <CheckIcon /> : <CopyIcon />}
260
- label={copied ? "Copied!" : "Copy"}
261
- className={copied ? "text-success" : "text-muted-foreground hover:bg-muted hover:text-foreground"}
262
- />
298
+ {getRichCopy ? (
299
+ // One Copy button: rich text (text/html plus text/plain) where
300
+ // the host turned rich copy on. "Copied!" for a rich copy; a
301
+ // plain-text fallback says what went on the clipboard.
302
+ <ToolbarButton
303
+ onClick={handleRichCopy}
304
+ icon={richCopied ? <CheckIcon /> : <CopyIcon />}
305
+ label={!richCopied ? "Copy"
306
+ : richCopied.copied === 'rich' ? "Copied!"
307
+ : richCopyOutcomeLabel(richCopied)}
308
+ className={richCopied ? "text-success" : "text-muted-foreground hover:bg-muted hover:text-foreground"}
309
+ />
310
+ ) : (
311
+ <ToolbarButton
312
+ onClick={handleCopy}
313
+ icon={copied ? <CheckIcon /> : <CopyIcon />}
314
+ label={copied ? "Copied!" : "Copy"}
315
+ className={copied ? "text-success" : "text-muted-foreground hover:bg-muted hover:text-foreground"}
316
+ />
317
+ )}
263
318
  <div className="w-px h-5 bg-border mx-0.5" />
264
319
  </>
265
320
  )}
@@ -1,7 +1,8 @@
1
1
  import React, { useState, useRef, useCallback } from 'react';
2
2
  import type { Agent } from '../hooks/useAgents';
3
3
  import { useDismissablePopover } from '../hooks/useDismissablePopover';
4
- import { getAgentSwitchSettings, saveAgentSwitchSettings, type AgentSwitchSettings } from '../utils/agentSwitch';
4
+ import { agentSwitchTargetLabel, getAgentSwitchDefaults, saveAgentSwitchSettings, type AgentSwitchSettings } from '../utils/agentSwitch';
5
+ import { useConfigValue } from '../config/useConfig';
5
6
 
6
7
  interface ApproveDropdownProps {
7
8
  onApprove: () => void;
@@ -11,12 +12,7 @@ interface ApproveDropdownProps {
11
12
  }
12
13
 
13
14
  function getSelectedLabel(setting: AgentSwitchSettings, agents: Agent[]): string | null {
14
- if (setting.switchTo === 'disabled') return null;
15
- if (setting.switchTo === 'custom' && setting.customName) {
16
- return setting.customName;
17
- }
18
- const match = agents.find(a => a.id.toLowerCase() === setting.switchTo.toLowerCase());
19
- return match?.name ?? setting.switchTo;
15
+ return agentSwitchTargetLabel(setting, agents);
20
16
  }
21
17
 
22
18
  function isSelected(agentId: string, setting: AgentSwitchSettings): boolean {
@@ -37,7 +33,10 @@ export const ApproveDropdown: React.FC<ApproveDropdownProps> = ({
37
33
  disabled = false,
38
34
  isLoading = false,
39
35
  }) => {
40
- const [setting, setSetting] = useState<AgentSwitchSettings>(() => getAgentSwitchSettings('plan'));
36
+ // Read reactively: config.json's value lands through configStore.init()
37
+ // once /api/plan answers, which may be after this mounts (#1390).
38
+ const stored = useConfigValue('agentSwitch');
39
+ const setting: AgentSwitchSettings = stored ?? getAgentSwitchDefaults('plan');
41
40
  const [isOpen, setIsOpen] = useState(false);
42
41
  const dropdownRef = useRef<HTMLDivElement>(null);
43
42
 
@@ -52,7 +51,6 @@ export const ApproveDropdown: React.FC<ApproveDropdownProps> = ({
52
51
  });
53
52
 
54
53
  const handleSelect = (newSetting: AgentSwitchSettings) => {
55
- setSetting(newSetting);
56
54
  saveAgentSwitchSettings(newSetting);
57
55
  setIsOpen(false);
58
56
  };
@@ -151,7 +151,7 @@ function DemoPlayer({ reducedMotion }: { readonly reducedMotion: boolean }) {
151
151
  data-ask-session-playback={playing ? 'pause' : 'play'}
152
152
  className={
153
153
  playing
154
- ? 'absolute bottom-3 left-3 grid size-9 place-items-center rounded-full border border-border/60 bg-background/70 text-foreground opacity-0 outline-none backdrop-blur-sm transition-opacity motion-reduce:transition-none group-hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-primary'
154
+ ? 'absolute bottom-3 left-3 grid size-9 place-items-center rounded-full border border-border/60 bg-background/70 text-foreground opacity-0 outline-none backdrop-blur-sm transition-opacity motion-reduce:transition-none group-hover:opacity-100 focus-visible:opacity-100 [@media(hover:none)]:opacity-100 focus-visible:ring-2 focus-visible:ring-primary'
155
155
  : 'absolute left-1/2 top-1/2 grid size-16 -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full border border-border/60 bg-background/80 text-foreground shadow-lg outline-none backdrop-blur-sm transition-transform motion-reduce:transition-none hover:scale-105 focus-visible:ring-2 focus-visible:ring-primary'
156
156
  }
157
157
  >
@@ -248,7 +248,7 @@ export const AttachmentsButton: React.FC<AttachmentsButtonProps> = ({
248
248
  {/* Clear all button on hover */}
249
249
  <button
250
250
  onClick={handleClearAll}
251
- className="absolute -top-1 -right-1 w-3.5 h-3.5 bg-destructive text-destructive-foreground rounded-full opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
251
+ className="absolute -top-1 -right-1 w-3.5 h-3.5 bg-destructive text-destructive-foreground rounded-full opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 [@media(hover:none)]:opacity-100 transition-opacity flex items-center justify-center"
252
252
  >
253
253
  <svg className="w-2 h-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
254
254
  <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
@@ -18,9 +18,10 @@ import {
18
18
  } from "@plannotator/core/question-block";
19
19
 
20
20
  /** A question block rendered outside a Viewer's document index (plan diff,
21
- * other markdown surfaces): parsed alone, unnumbered. */
22
- const standaloneQuestion = (block: Block): IndexedQuestion | undefined => {
23
- const [indexed] = indexQuestionBlocks([block]);
21
+ * other markdown surfaces): parsed alone, unnumbered. Its contract lines
22
+ * show as context unless the host asks for them (`questionContractLines`). */
23
+ const standaloneQuestion = (block: Block, contractLines: boolean): IndexedQuestion | undefined => {
24
+ const [indexed] = indexQuestionBlocks([block], { contractLines });
24
25
  return indexed ? { ...indexed, number: 0 } : undefined;
25
26
  };
26
27
 
@@ -66,7 +67,14 @@ export const BlockRenderer: React.FC<{
66
67
  questionStatusLabel?: QuestionBlockProps['statusLabel'];
67
68
  /** Host content under a question's prompt (see `QuestionBlock`). */
68
69
  renderQuestionBelowPrompt?: QuestionBlockProps['renderBelowPrompt'];
69
- }> = ({ block, onOpenLinkedDoc, onOpenCodeFile, imageBaseDir, onImageClick, onToggleCheckbox, checkboxOverrides, orderedIndex, githubRepo, repoHost, headingAnchorId, onNavigateAnchor, question, questionTotal, questionAnswer, onAnswerQuestion, onSaveQuestionAnswer, saveQuestionAnswerLabel, renderQuestionFooter, questionDecisionRecording, questionDecisionScope, onToggleQuestionDecisionRecording, onOpenQuestionDecision, questionStatusTag, questionStatusLabel, renderQuestionBelowPrompt }) => {
70
+ /** Read a standalone question's contract lines into the card (default
71
+ * false: context). A `question` the host passes was parsed by the host. */
72
+ questionContractLines?: boolean;
73
+ /** Picture choices as tiles (see `QuestionBlock` `pictureChoices`). */
74
+ questionPictureChoices?: QuestionBlockProps['pictureChoices'];
75
+ /** A label over each picture tile (see `QuestionBlock` `pictureBadge`). */
76
+ questionPictureBadge?: QuestionBlockProps['pictureBadge'];
77
+ }> = ({ block, onOpenLinkedDoc, onOpenCodeFile, imageBaseDir, onImageClick, onToggleCheckbox, checkboxOverrides, orderedIndex, githubRepo, repoHost, headingAnchorId, onNavigateAnchor, question, questionTotal, questionAnswer, onAnswerQuestion, onSaveQuestionAnswer, saveQuestionAnswerLabel, renderQuestionFooter, questionDecisionRecording, questionDecisionScope, onToggleQuestionDecisionRecording, onOpenQuestionDecision, questionStatusTag, questionStatusLabel, renderQuestionBelowPrompt, questionContractLines = false, questionPictureChoices, questionPictureBadge }) => {
70
78
  switch (block.type) {
71
79
  case 'heading': {
72
80
  const Tag = `h${block.level || 1}` as React.ElementType;
@@ -133,6 +141,10 @@ export const BlockRenderer: React.FC<{
133
141
  <div
134
142
  className="flex items-start gap-3 my-1.5"
135
143
  data-block-id={block.id}
144
+ data-rich-list-level={block.level || 0}
145
+ data-rich-list-kind={block.ordered ? 'ol' : 'ul'}
146
+ data-rich-list-index={orderedIndex ?? undefined}
147
+ data-rich-list-checked={isCheckbox ? String(isChecked) : undefined}
136
148
  style={{ marginLeft: `${indent}rem` }}
137
149
  >
138
150
  <ListItemBody
@@ -180,7 +192,7 @@ export const BlockRenderer: React.FC<{
180
192
  const kind = block.directiveKind || 'note';
181
193
  if (isQuestionDirectiveKind(kind)) {
182
194
  // An unparseable question (no prompt) falls through to the callout.
183
- const indexed = question ?? standaloneQuestion(block);
195
+ const indexed = question ?? standaloneQuestion(block, questionContractLines);
184
196
  if (indexed) {
185
197
  return (
186
198
  <QuestionBlock
@@ -199,6 +211,8 @@ export const BlockRenderer: React.FC<{
199
211
  statusTag={questionStatusTag}
200
212
  statusLabel={questionStatusLabel}
201
213
  renderBelowPrompt={renderQuestionBelowPrompt}
214
+ pictureChoices={questionPictureChoices}
215
+ pictureBadge={questionPictureBadge}
202
216
  onOpenLinkedDoc={onOpenLinkedDoc}
203
217
  onOpenCodeFile={onOpenCodeFile}
204
218
  imageBaseDir={imageBaseDir}
@@ -2,11 +2,14 @@ import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react'
2
2
  import { File, type LineAnnotation } from '@pierre/diffs/react';
3
3
  import type { SelectedLineRange as PierreSelectedLineRange, LineEventBaseProps } from '@pierre/diffs';
4
4
  import { PopoutDialog } from './PopoutDialog';
5
+ import { isCommentSubmitKey } from '../utils/commentSubmitKey';
6
+ import { useConfigValue } from '../config/useConfig';
5
7
  import { useTheme } from './ThemeProvider';
6
8
  import { CommentPopover } from './CommentPopover';
7
9
  import { ImageThumbnail } from './ImageThumbnail';
8
10
  import type { CodeAnnotation, ImageAttachment } from '../types';
9
11
  import { copyTextToClipboard } from '../utils/clipboard';
12
+ import { whenCodeLineRendered } from '../utils/codeLineScroll';
10
13
 
11
14
  export interface CodeFileAnnotationInput {
12
15
  filePath: string;
@@ -22,6 +25,10 @@ interface CodeFilePopoutProps {
22
25
  onClose: () => void;
23
26
  filepath: string;
24
27
  contents: string;
28
+ line?: number;
29
+ lineEnd?: number;
30
+ /** Changes on every open, so reopening the same file:line scrolls to it again. */
31
+ revealKey?: number;
25
32
  prerenderedHTML?: string;
26
33
  error?: string;
27
34
  requestedPath?: string;
@@ -145,6 +152,7 @@ const CodeInlineAnnotation: React.FC<{
145
152
  const [isEditing, setIsEditing] = useState(false);
146
153
  const [editText, setEditText] = useState(annotation.text ?? '');
147
154
  const textareaRef = useRef<HTMLTextAreaElement>(null);
155
+ const enterSavesComment = useConfigValue('enterSavesComment');
148
156
 
149
157
  useEffect(() => {
150
158
  if (!isEditing) setEditText(annotation.text ?? '');
@@ -181,7 +189,17 @@ const CodeInlineAnnotation: React.FC<{
181
189
  {lineLabel(annotation.lineStart, annotation.lineEnd)}
182
190
  </span>
183
191
  {annotation.author && <span className="truncate normal-case">by <bdi>{annotation.author}</bdi></span>}
184
- <div className="ml-auto flex items-center gap-1 opacity-0 group-hover:opacity-100 [@media(hover:none)]:opacity-100">
192
+ {annotation.outsideDiff && (
193
+ <span
194
+ className="rounded px-1.5 py-0.5 normal-case font-medium"
195
+ style={{ background: 'color-mix(in oklab, var(--warning) 18%, transparent)', color: 'var(--warning)' }}
196
+ data-code-annotation-outside-diff=""
197
+ title="A comment on lines outside the diff"
198
+ >
199
+ not in the diff
200
+ </span>
201
+ )}
202
+ <div className="ml-auto flex items-center gap-1 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 [@media(hover:none)]:opacity-100">
185
203
  {onEdit && !isEditing && (
186
204
  <button
187
205
  type="button"
@@ -227,7 +245,7 @@ const CodeInlineAnnotation: React.FC<{
227
245
  e.preventDefault();
228
246
  setIsEditing(false);
229
247
  setEditText(annotation.text ?? '');
230
- } else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && !e.nativeEvent.isComposing) {
248
+ } else if (isCommentSubmitKey(e, enterSavesComment)) {
231
249
  e.preventDefault();
232
250
  save();
233
251
  }
@@ -286,6 +304,10 @@ const CodeInlineAnnotation: React.FC<{
286
304
  export interface CodeFileViewProps {
287
305
  filepath: string;
288
306
  contents: string;
307
+ line?: number;
308
+ lineEnd?: number;
309
+ /** Changes on every open, so reopening the same file:line scrolls to it again. */
310
+ revealKey?: number;
289
311
  prerenderedHTML?: string;
290
312
  annotations?: CodeAnnotation[];
291
313
  selectedAnnotationId?: string | null;
@@ -301,6 +323,26 @@ export interface CodeFileViewProps {
301
323
  lang?: string;
302
324
  /** Long lines: `'scroll'` sideways (the default) or `'wrap'` onto the next row, for a minified file. */
303
325
  overflow?: 'scroll' | 'wrap';
326
+ /** CSS added inside the file's shadow root after the theme (a host's line
327
+ * marks, e.g. code review's change bars on `[data-column-number="12"]`). */
328
+ extraCss?: string;
329
+ /** Scroll this line to the middle and mark it, once per `token` (a jump to
330
+ * a definition, a reference or a line). */
331
+ revealLine?: { line: number; token: number };
332
+ /** The token under the pointer (`enter` / `leave`) and the last one clicked
333
+ * (`click`): a host's "go to definition" looks there. Turning it on wraps
334
+ * the file's tokens (Pierre's token transformer). */
335
+ onTokenTarget?: (kind: 'enter' | 'leave' | 'click', target: CodeFileTokenTarget) => void;
336
+ /** Cmd/Ctrl-click (or Alt-click) on a token: a host's references. */
337
+ onTokenNavigate?: (target: CodeFileTokenTarget) => void;
338
+ }
339
+
340
+ /** A token of a file drawn by `CodeFileView` (Pierre's token event, file mode). */
341
+ export interface CodeFileTokenTarget {
342
+ tokenText: string;
343
+ tokenElement: HTMLElement;
344
+ lineNumber: number;
345
+ lineCharStart: number;
304
346
  }
305
347
 
306
348
  /**
@@ -312,6 +354,9 @@ export interface CodeFileViewProps {
312
354
  export const CodeFileView: React.FC<CodeFileViewProps> = ({
313
355
  filepath,
314
356
  contents,
357
+ line,
358
+ lineEnd,
359
+ revealKey,
315
360
  prerenderedHTML,
316
361
  annotations = [],
317
362
  selectedAnnotationId,
@@ -323,6 +368,10 @@ export const CodeFileView: React.FC<CodeFileViewProps> = ({
323
368
  className = 'flex-1 min-h-0',
324
369
  lang,
325
370
  overflow = 'scroll',
371
+ extraCss,
372
+ revealLine,
373
+ onTokenTarget,
374
+ onTokenNavigate,
326
375
  }) => {
327
376
  const { resolvedMode } = useTheme();
328
377
  const mode = resolvedMode ?? 'dark';
@@ -367,6 +416,9 @@ export const CodeFileView: React.FC<CodeFileViewProps> = ({
367
416
  }));
368
417
  }, [annotations]);
369
418
 
419
+ // The line a host jumped to stays marked until a comment or a selection
420
+ // takes the mark (or the next jump moves it).
421
+ const [revealedLine, setRevealedLine] = useState<number | null>(null);
370
422
  const selectedLines = useMemo((): PierreSelectedLineRange | null => {
371
423
  if (pendingComment) {
372
424
  return { start: pendingComment.range.start, end: pendingComment.range.end };
@@ -374,8 +426,49 @@ export const CodeFileView: React.FC<CodeFileViewProps> = ({
374
426
  if (selectedCodeAnnotation) {
375
427
  return { start: selectedCodeAnnotation.lineStart, end: selectedCodeAnnotation.lineEnd };
376
428
  }
429
+ // A jump (Go to Definition, a reference) marks its line until a comment
430
+ // or a selection takes the mark; otherwise the host's line, if any.
431
+ if (revealedLine != null) return { start: revealedLine, end: revealedLine };
432
+ if (line != null) {
433
+ return { start: line, end: lineEnd ?? line };
434
+ }
377
435
  return null;
378
- }, [pendingComment, selectedCodeAnnotation]);
436
+ }, [pendingComment, selectedCodeAnnotation, revealedLine, line, lineEnd]);
437
+
438
+ useEffect(() => {
439
+ if (!revealLine) return;
440
+ setRevealedLine(revealLine.line);
441
+ // Pierre renders after mount (and again after highlighting): look for the
442
+ // line for a moment.
443
+ let frame = 0;
444
+ let attempts = 0;
445
+ const find = () => {
446
+ const root = fileAreaRef.current?.querySelector('diffs-container')?.shadowRoot;
447
+ const row = root?.querySelector(`[data-line="${revealLine.line}"]`);
448
+ if (row) {
449
+ // Vertically only: scrollIntoView would also scroll a long line sideways.
450
+ let node: Element | null = fileAreaRef.current;
451
+ while (node) {
452
+ if (node instanceof HTMLElement && node.scrollHeight > node.clientHeight && /(auto|scroll)/.test(getComputedStyle(node).overflowY)) {
453
+ const box = node.getBoundingClientRect();
454
+ const rect = row.getBoundingClientRect();
455
+ node.scrollTop += rect.top - (box.top + box.height / 2) + rect.height / 2;
456
+ return;
457
+ }
458
+ node = node.parentElement;
459
+ }
460
+ row.scrollIntoView({ block: 'center', inline: 'nearest' });
461
+ return;
462
+ }
463
+ attempts += 1;
464
+ if (attempts < 120) frame = requestAnimationFrame(find);
465
+ };
466
+ frame = requestAnimationFrame(find);
467
+ return () => cancelAnimationFrame(frame);
468
+ // eslint-disable-next-line react-hooks/exhaustive-deps
469
+ }, [revealLine?.token, revealLine?.line]);
470
+ // Another file drops the mark (cleanups run before the next jump sets one).
471
+ useEffect(() => () => setRevealedLine(null), [filepath]);
379
472
  const effectivePrerenderedHTML = lineAnnotations.length === 0 ? prerenderedHTML : undefined;
380
473
 
381
474
  useEffect(() => {
@@ -388,6 +481,19 @@ export const CodeFileView: React.FC<CodeFileViewProps> = ({
388
481
  return () => clearTimeout(timer);
389
482
  }, [selectedAnnotationId, filepath]);
390
483
 
484
+ // Bring the referenced line into view once Pierre has drawn it. Runs again
485
+ // only when the file or the line changes; a selected comment scrolls itself.
486
+ const selectedAnnotationIdRef = useRef(selectedAnnotationId);
487
+ selectedAnnotationIdRef.current = selectedAnnotationId;
488
+ const lineCount = useMemo(() => contents.split('\n').length, [contents]);
489
+ useEffect(() => {
490
+ const area = fileAreaRef.current;
491
+ if (line == null || !area || selectedAnnotationIdRef.current) return;
492
+ return whenCodeLineRendered(area, line, (lineEl) => {
493
+ lineEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
494
+ }, { lineCount });
495
+ }, [line, filepath, lineCount, revealKey]);
496
+
391
497
  const openCommentForRange = useCallback((
392
498
  range: { start: number; end: number },
393
499
  anchorEl?: HTMLElement,
@@ -470,6 +576,28 @@ export const CodeFileView: React.FC<CodeFileViewProps> = ({
470
576
  openCommentForRange({ start: range.start, end: range.end }, undefined, lastPointerRectRef.current ?? undefined);
471
577
  }, [onAddAnnotation, openCommentForRange]);
472
578
 
579
+ const tokenTargetOf = (props: { tokenText: string; tokenElement: HTMLElement; lineNumber: number; lineCharStart: number }): CodeFileTokenTarget => ({
580
+ tokenText: props.tokenText,
581
+ tokenElement: props.tokenElement,
582
+ lineNumber: props.lineNumber,
583
+ lineCharStart: props.lineCharStart,
584
+ });
585
+ const tokenOptions = onTokenTarget || onTokenNavigate
586
+ ? {
587
+ useTokenTransformer: true,
588
+ onTokenEnter: (props: CodeFileTokenTarget) => onTokenTarget?.('enter', tokenTargetOf(props)),
589
+ onTokenLeave: (props: CodeFileTokenTarget) => onTokenTarget?.('leave', tokenTargetOf(props)),
590
+ onTokenClick: (props: CodeFileTokenTarget, event: MouseEvent) => {
591
+ const target = tokenTargetOf(props);
592
+ onTokenTarget?.('click', target);
593
+ if (onTokenNavigate && (event.metaKey || event.ctrlKey || event.altKey)) {
594
+ suppressLineClickUntilRef.current = Date.now() + 300;
595
+ onTokenNavigate(target);
596
+ }
597
+ },
598
+ }
599
+ : {};
600
+
473
601
  const handleLineClick = useCallback((props: LineEventBaseProps & { event: PointerEvent }) => {
474
602
  if (!onAddAnnotation) return;
475
603
  if (Date.now() < suppressLineClickUntilRef.current) return;
@@ -509,7 +637,7 @@ export const CodeFileView: React.FC<CodeFileViewProps> = ({
509
637
  } as React.CSSProperties}
510
638
  options={{
511
639
  themeType: pierreTheme.type,
512
- unsafeCSS: pierreTheme.css,
640
+ unsafeCSS: extraCss ? `${pierreTheme.css}\n${extraCss}` : pierreTheme.css,
513
641
  overflow,
514
642
  disableFileHeader: true,
515
643
  enableLineSelection: true,
@@ -517,6 +645,7 @@ export const CodeFileView: React.FC<CodeFileViewProps> = ({
517
645
  lineHoverHighlight: onAddAnnotation ? 'line' : 'disabled',
518
646
  onLineClick: handleLineClick,
519
647
  onLineSelectionEnd: handleLineSelectionEnd,
648
+ ...tokenOptions,
520
649
  }}
521
650
  />
522
651
  </div>
@@ -551,6 +680,9 @@ export const CodeFilePopout: React.FC<CodeFilePopoutProps> = ({
551
680
  onClose,
552
681
  filepath,
553
682
  contents,
683
+ line,
684
+ lineEnd,
685
+ revealKey,
554
686
  prerenderedHTML,
555
687
  error,
556
688
  requestedPath,
@@ -653,6 +785,9 @@ export const CodeFilePopout: React.FC<CodeFilePopoutProps> = ({
653
785
  <CodeFileView
654
786
  filepath={filepath}
655
787
  contents={contents}
788
+ line={line}
789
+ lineEnd={lineEnd}
790
+ revealKey={revealKey}
656
791
  prerenderedHTML={prerenderedHTML}
657
792
  annotations={annotations}
658
793
  selectedAnnotationId={selectedAnnotationId}
@@ -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,