@plannotator/ui 0.53.0 → 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 (145) hide show
  1. package/HANDOFF.md +284 -27
  2. package/README.md +84 -14
  3. package/components/AgentToolAnnouncementDialog.tsx +1 -1
  4. package/components/AgentsTab.tsx +1 -1
  5. package/components/AnnotationPanel.tsx +177 -34
  6. package/components/AnnotationSidebar.tsx +1 -1
  7. package/components/AnnotationToolbar.tsx +64 -9
  8. package/components/AnnotationToolstrip.tsx +45 -40
  9. package/components/ApproveDropdown.tsx +7 -9
  10. package/components/AskSessionAnnouncementDialog.tsx +1 -1
  11. package/components/AttachmentsButton.tsx +1 -1
  12. package/components/BlockRenderer.tsx +19 -5
  13. package/components/CodeFilePopout.tsx +140 -5
  14. package/components/CommentPopover.tsx +46 -8
  15. package/components/DecisionControl.tsx +31 -5
  16. package/components/DiagramBlock.tsx +17 -12
  17. package/components/EditorAnnotationCard.tsx +1 -1
  18. package/components/ExportModal.tsx +77 -7
  19. package/components/HostTitle.tsx +0 -4
  20. package/components/HtmlSurfaceControls.tsx +2 -1
  21. package/components/ImageAnnotator/Toolbar.tsx +25 -1
  22. package/components/ImageAnnotator/index.tsx +24 -4
  23. package/components/ImageLightbox.tsx +658 -120
  24. package/components/ImagePeek.tsx +38 -4
  25. package/components/ImageRegions.tsx +369 -0
  26. package/components/ImageThumbnail.tsx +1 -1
  27. package/components/ImageZoomStage.tsx +796 -0
  28. package/components/InboxAnnouncementDialog.tsx +202 -0
  29. package/components/InlineMarkdown.tsx +152 -24
  30. package/components/KeyboardShortcuts.tsx +23 -0
  31. package/components/LazyBlockBoundary.tsx +100 -0
  32. package/components/ListMarker.tsx +3 -3
  33. package/components/LookAndFeelAnnouncementDialog.tsx +12 -2
  34. package/components/MentionPicker.tsx +51 -0
  35. package/components/MenuVersionSection.tsx +31 -9
  36. package/components/PlanDecisionMenuExtras.tsx +129 -0
  37. package/components/PlanHeaderMenu.tsx +15 -1
  38. package/components/ProviderIcons.tsx +8 -0
  39. package/components/RefHoverCard.tsx +913 -0
  40. package/components/SendFailureToast.tsx +126 -0
  41. package/components/Settings.tsx +617 -126
  42. package/components/SkillReferenceMenu.tsx +39 -12
  43. package/components/TerminalToolsAnnouncementDialog.tsx +1 -1
  44. package/components/ThemeProvider.tsx +14 -1
  45. package/components/ThemeTab.tsx +16 -24
  46. package/components/Tooltip.tsx +49 -18
  47. package/components/Viewer.tsx +316 -47
  48. package/components/ai/DocumentAIChatPanel.tsx +29 -3
  49. package/components/ai/NewChatButton.tsx +74 -0
  50. package/components/blocks/CodeBlock.tsx +1 -1
  51. package/components/blocks/HtmlBlock.tsx +7 -1
  52. package/components/blocks/QuestionBlock.tsx +522 -62
  53. package/components/blocks/QuestionContext.tsx +3 -0
  54. package/components/diagram/DiagramComposer.tsx +5 -1
  55. package/components/diagram/DiagramPending.tsx +12 -2
  56. package/components/diagram/DiagramViewer.tsx +19 -3
  57. package/components/html-embed/HtmlEmbedBlock.tsx +374 -0
  58. package/components/html-embed/SvgFenceBlock.tsx +46 -0
  59. package/components/html-embed/index.ts +3 -0
  60. package/components/html-embed/languages.ts +15 -0
  61. package/components/html-embed/loadGate.ts +107 -0
  62. package/components/html-viewer/HtmlViewer.tsx +69 -6
  63. package/components/html-viewer/bridge-script.asset.js +504 -12
  64. package/components/html-viewer/bridge-script.lite.ts +1 -1
  65. package/components/html-viewer/bridge-script.ts +545 -12
  66. package/components/html-viewer/useHtmlAnnotation.ts +125 -0
  67. package/components/imageZoom.ts +265 -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/hooks/pfm/useCodeFilePopout.ts +7 -1
  78. package/hooks/useAIChat.ts +72 -25
  79. package/hooks/useAnnotationDraft.ts +71 -6
  80. package/hooks/useAnnotationHighlighter.ts +57 -3
  81. package/hooks/useCodeAnnotationDraft.ts +113 -17
  82. package/hooks/useCookieHostSetting.ts +96 -0
  83. package/hooks/useDocumentFont.ts +17 -0
  84. package/hooks/useDocumentTextSize.ts +20 -0
  85. package/hooks/useFileBrowser.ts +10 -1
  86. package/hooks/useLinkedDoc.ts +226 -13
  87. package/hooks/useMentionAutocomplete.ts +1 -1
  88. package/hooks/usePackageChangesSetting.ts +90 -0
  89. package/hooks/usePlanLookDialog.ts +67 -0
  90. package/hooks/useSharing.ts +11 -7
  91. package/hooks/useSkillReferenceAutocomplete.ts +7 -3
  92. package/hooks/useThemePaletteHalves.ts +37 -0
  93. package/hooks/useUpdateCheck.ts +22 -4
  94. package/hooks/useUpdateToast.ts +1 -1
  95. package/hooks/useVimSelection.ts +9 -0
  96. package/package.json +23 -22
  97. package/shortcuts/code-review/annotationToolbar.shortcuts.ts +1 -0
  98. package/shortcuts/code-review/explorer.shortcuts.ts +82 -0
  99. package/shortcuts/core.ts +63 -4
  100. package/shortcuts/index.ts +1 -0
  101. package/shortcuts/plan-review/annotationPanel.shortcuts.ts +1 -1
  102. package/shortcuts/plan-review/commentPopover.shortcuts.ts +1 -0
  103. package/shortcuts/runtime.ts +14 -6
  104. package/styles.css +1 -1
  105. package/theme.css +179 -1
  106. package/themes/plannotator-graphite.css +66 -0
  107. package/types.ts +26 -9
  108. package/utils/agentSwitch.ts +41 -20
  109. package/utils/agentToolSetting.ts +1 -1
  110. package/utils/aiChatFormat.ts +23 -7
  111. package/utils/annotationOwnsHighlight.ts +4 -2
  112. package/utils/asciidocParser.ts +570 -0
  113. package/utils/autoUpdateNotice.ts +24 -0
  114. package/utils/belowTitleSlot.ts +174 -0
  115. package/utils/codeLineScroll.ts +100 -0
  116. package/utils/commentSubmitKey.ts +48 -0
  117. package/utils/cssFontFamily.ts +65 -0
  118. package/utils/decisionSpec.ts +190 -2
  119. package/utils/defaultNotesApp.ts +1 -1
  120. package/utils/diffFonts.ts +4 -1
  121. package/utils/documentFont.ts +68 -0
  122. package/utils/documentTextSize.ts +54 -0
  123. package/utils/fontDetect.ts +63 -0
  124. package/utils/inboxAnnouncement.ts +78 -0
  125. package/utils/lookAndFeelAnnouncement.ts +82 -5
  126. package/utils/markdownExtensions.ts +2 -2
  127. package/utils/mentions.ts +34 -5
  128. package/utils/mermaid.ts +20 -3
  129. package/utils/mermaidTheme.ts +3 -51
  130. package/utils/notion.ts +58 -0
  131. package/utils/parser.ts +28 -1
  132. package/utils/platform.ts +46 -0
  133. package/utils/refCard.ts +82 -0
  134. package/utils/richClipboard.ts +380 -0
  135. package/utils/richClipboardLists.ts +57 -0
  136. package/utils/richCopyPolicy.ts +22 -0
  137. package/utils/scrollAnchor.ts +82 -0
  138. package/utils/sectionRefs.ts +123 -0
  139. package/utils/selectionBoundary.ts +40 -0
  140. package/utils/sendFailure.ts +85 -0
  141. package/utils/sharing.ts +5 -2
  142. package/utils/themeRegistry.ts +10 -0
  143. package/utils/themeTokens.ts +74 -0
  144. package/utils/updateNotice.ts +30 -8
  145. package/utils/vscodePanel.ts +9 -0
@@ -1,4 +1,4 @@
1
- import React, { useState, useRef, useLayoutEffect, useEffect } from 'react';
1
+ import React, { useState, useEffect } from 'react';
2
2
  import type { EditorMode, InputMethod } from '../types';
3
3
  import { TaterSpritePullup } from './TaterSpritePullup';
4
4
 
@@ -291,10 +291,26 @@ const ICON_SIZE = 28; // collapsed button width (px)
291
291
  const H_PAD = 10; // horizontal padding when expanded (px) — matches px-2.5
292
292
  const GAP = 6; // gap between icon and label (px) — matches gap-1.5
293
293
  const ICON_INNER = 14; // icon element width (px)
294
+ const COLLAPSED_PAD = (ICON_SIZE - ICON_INNER) / 2;
294
295
  const DURATION = 180; // transition ms
296
+ const EASE = 'cubic-bezier(0.25, 0.46, 0.45, 0.94)';
295
297
 
296
298
  /* ─── Button ─── */
297
299
 
300
+ /**
301
+ * One toolstrip button: the icon, and a label that slides out beside it.
302
+ *
303
+ * The width comes from layout, never from a JS measurement. A hidden copy of
304
+ * the label sits in a one-column grid whose column animates `0fr` -> `1fr`, so
305
+ * the button's shrink-to-fit width runs from the icon-only 28 px to the icon,
306
+ * the gap and the label; the side padding animates 7 -> 10 px with it. The
307
+ * width at every frame matches the explicit-width version this replaced
308
+ * (28 + (12 + label) x progress, same 180 ms curve). The visible label is
309
+ * positioned beside the icon and clipped by the button's own edge, as before:
310
+ * a grid track laid out at a fraction of its width reveals its content more
311
+ * slowly than the button grows, so the label is not drawn inside it.
312
+ * Reading `offsetWidth` per button forced a layout on every document open.
313
+ */
298
314
  const ToolstripButton: React.FC<{
299
315
  active: boolean;
300
316
  onClick: () => void;
@@ -306,18 +322,9 @@ const ToolstripButton: React.FC<{
306
322
  iconOnly?: boolean;
307
323
  }> = ({ active, onClick, icon, label, color, mounted, compact = false, iconOnly = false }) => {
308
324
  const [hovered, setHovered] = useState(false);
309
- const [labelWidth, setLabelWidth] = useState(0);
310
- const measureRef = useRef<HTMLSpanElement>(null);
311
325
  const styles = colorStyles[color];
312
326
  const [isTouchDevice] = useState(() => 'ontouchstart' in window || navigator.maxTouchPoints > 0);
313
327
 
314
- // Measure label text width synchronously before first paint
315
- useLayoutEffect(() => {
316
- if (measureRef.current) {
317
- setLabelWidth(measureRef.current.offsetWidth);
318
- }
319
- }, [label]);
320
-
321
328
  // iconOnly: never expand (mobile sticky lane).
322
329
  // compact: only active expands (sm+ sticky lane — shows current mode).
323
330
  // default: active or hovered expands (top-of-doc full toolstrip).
@@ -326,8 +333,7 @@ const ToolstripButton: React.FC<{
326
333
  : compact
327
334
  ? active
328
335
  : (active || hovered || isTouchDevice);
329
- const expandedWidth = H_PAD + ICON_INNER + GAP + labelWidth + H_PAD;
330
- const currentWidth = expanded ? expandedWidth : ICON_SIZE;
336
+ const pad = expanded ? H_PAD : COLLAPSED_PAD;
331
337
 
332
338
  const colorClass = active
333
339
  ? styles.active
@@ -335,12 +341,9 @@ const ToolstripButton: React.FC<{
335
341
  ? styles.hover
336
342
  : styles.inactive;
337
343
 
344
+ const slide = (property: string) => `${property} ${DURATION}ms ${EASE}`;
338
345
  const transition = mounted
339
- ? `width ${DURATION}ms cubic-bezier(0.25, 0.46, 0.45, 0.94), background-color ${DURATION}ms ease, color ${DURATION}ms ease, box-shadow ${DURATION}ms ease`
340
- : 'none';
341
-
342
- const innerTransition = mounted
343
- ? `padding-left ${DURATION}ms cubic-bezier(0.25, 0.46, 0.45, 0.94)`
346
+ ? `${slide('padding-inline-start')}, ${slide('padding-inline-end')}, background-color ${DURATION}ms ease, color ${DURATION}ms ease, box-shadow ${DURATION}ms ease`
344
347
  : 'none';
345
348
 
346
349
  return (
@@ -349,34 +352,36 @@ const ToolstripButton: React.FC<{
349
352
  onMouseEnter={() => setHovered(true)}
350
353
  onMouseLeave={() => setHovered(false)}
351
354
  aria-pressed={active}
352
- className={`relative flex items-center h-7 rounded-md overflow-hidden ${colorClass}`}
353
- style={{ width: currentWidth, transition }}
355
+ className={`relative flex items-center h-7 rounded-md overflow-hidden whitespace-nowrap ${colorClass}`}
356
+ style={{ paddingInlineStart: pad, paddingInlineEnd: pad, transition }}
354
357
  >
355
- {/* Inner flex container — fixed layout, no layout-shifting properties animated */}
356
- <div
357
- className="flex items-center whitespace-nowrap"
358
- style={{ paddingLeft: expanded ? H_PAD : (ICON_SIZE - ICON_INNER) / 2, gap: GAP, transition: innerTransition }}
358
+ {icon}
359
+ {/* The label people see, beside the icon, clipped at the button's edge. */}
360
+ <span
361
+ className="absolute inset-y-0 flex items-center text-xs font-medium"
362
+ style={{
363
+ insetInlineStart: pad + ICON_INNER + GAP,
364
+ opacity: expanded ? 1 : 0,
365
+ transition: mounted
366
+ ? `${slide('inset-inline-start')}, opacity ${expanded ? DURATION : DURATION * 0.6}ms ease ${expanded ? '60ms' : '0ms'}`
367
+ : 'none',
368
+ }}
359
369
  >
360
- {icon}
361
- <span
362
- className="text-xs font-medium"
363
- style={{
364
- opacity: expanded ? 1 : 0,
365
- transition: mounted ? `opacity ${expanded ? DURATION : DURATION * 0.6}ms ease ${expanded ? '60ms' : '0ms'}` : 'none',
366
- }}
367
- >
368
- {label}
369
- </span>
370
- </div>
371
-
372
- {/* Hidden measurement span — rendered offscreen to get label pixel width */}
370
+ {label}
371
+ </span>
372
+ {/* The width: a hidden copy of the gap and label in a column that
373
+ animates 0fr -> 1fr. min-width 0 and overflow hidden let it reach 0. */}
373
374
  <span
374
- ref={measureRef}
375
- className="text-xs font-medium absolute pointer-events-none"
376
- style={{ visibility: 'hidden', position: 'absolute', left: -9999 }}
377
375
  aria-hidden
376
+ style={{
377
+ display: 'grid',
378
+ gridTemplateColumns: expanded ? '1fr' : '0fr',
379
+ transition: mounted ? slide('grid-template-columns') : 'none',
380
+ }}
378
381
  >
379
- {label}
382
+ <span style={{ minWidth: 0, overflow: 'hidden', visibility: 'hidden' }}>
383
+ <span className="block text-xs font-medium" style={{ paddingInlineStart: GAP }}>{label}</span>
384
+ </span>
380
385
  </span>
381
386
  </button>
382
387
  );
@@ -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 ?? '');
@@ -180,8 +188,18 @@ const CodeInlineAnnotation: React.FC<{
180
188
  <span className="rounded bg-muted px-1.5 py-0.5 font-mono normal-case text-foreground">
181
189
  {lineLabel(annotation.lineStart, annotation.lineEnd)}
182
190
  </span>
183
- {annotation.author && <span className="truncate normal-case">by {annotation.author}</span>}
184
- <div className="ml-auto flex items-center gap-1 opacity-0 group-hover:opacity-100 [@media(hover:none)]:opacity-100">
191
+ {annotation.author && <span className="truncate normal-case">by <bdi>{annotation.author}</bdi></span>}
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}