@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
@@ -1,7 +1,8 @@
1
1
  import { generateId } from '../utils/generateId';
2
2
  import React, { useRef, useState, useEffect, useLayoutEffect, useMemo, forwardRef, useImperativeHandle, useCallback, lazy, Suspense } from 'react';
3
3
  import { createPortal } from 'react-dom';
4
- import { collectDocumentImages, ImageLightbox, type LightboxImage } from './ImageLightbox';
4
+ import { collectDocumentImages, ImageLightbox, type LightboxImage, type LightboxRegionComments } from './ImageLightbox';
5
+ import { imageReferenceIndex, imageSourceLine, regionOf } from './ImageRegions';
5
6
  import { AnnotationType, type Block, type Annotation, type EditorMode, type InputMethod, type ImageAttachment, type ActionsLabelMode, type HostDraft } from '../types';
6
7
  import { applyHighlight, codeBlockClassName, onCodeHighlightSwap } from '../utils/codeHighlight';
7
8
  import { paintCodeBlockMark } from '../utils/codeBlockMark';
@@ -9,7 +10,9 @@ import { useFenceTheme } from '../hooks/useFenceTheme';
9
10
  import { computeListIndices, groupBlocks, selectionSourceLines, type Frontmatter, type FrontmatterValue } from '../utils/parser';
10
11
  import { buildHeadingSlugMap } from '../utils/slugify';
11
12
  import { copyTextToClipboard } from '../utils/clipboard';
13
+ import { getRichCopyEnabled } from '../utils/richCopyPolicy';
12
14
  import { BlockRenderer } from './BlockRenderer';
15
+ import { BELOW_TITLE_SLOT_MARKER } from '../utils/belowTitleSlot';
13
16
  import { indexQuestionBlocks, type IndexedQuestion, type ParsedQuestion, type QuestionAnswer } from '@plannotator/core/question-block';
14
17
  import type { QuestionBlockProps } from './blocks/QuestionBlock';
15
18
  import { resolveQuestionAnswers } from '../utils/questionAnswers';
@@ -36,6 +39,19 @@ import { FloatingQuickLabelPicker } from './FloatingQuickLabelPicker';
36
39
  */
37
40
  const MermaidBlock = lazy(async () => ({ default: (await import('./MermaidBlock')).MermaidBlock }));
38
41
  const GraphvizBlock = lazy(async () => ({ default: (await import('./GraphvizBlock')).GraphvizBlock }));
42
+ /** ```html and ```svg fences drawn, for a host that opts in (`htmlEmbeds`); loaded only then. */
43
+ const HtmlEmbedBlock = lazy(async () => ({ default: (await import('./html-embed/HtmlEmbedBlock')).HtmlEmbedBlock }));
44
+ const SvgFenceBlock = lazy(async () => ({ default: (await import('./html-embed/SvgFenceBlock')).SvgFenceBlock }));
45
+ /**
46
+ * Each lazy block sits in a `LazyBlockBoundary`: when its chunk cannot load,
47
+ * the block shows its source and a Reload notice, and the document stays.
48
+ */
49
+ const embedFallback = (block: Block) => (notice: React.ReactNode) => (
50
+ <div className="my-5 [&>[data-block-id]]:my-0">
51
+ {notice}
52
+ <CodeBlock block={block} isHovered={false} />
53
+ </div>
54
+ );
39
55
 
40
56
  // Debug error boundary to catch silent toolbar crashes
41
57
  class ToolbarErrorBoundary extends React.Component<
@@ -61,7 +77,10 @@ import { AttachmentsButton } from './AttachmentsButton';
61
77
  import { MessagesIcon } from './icons/MessagesIcon';
62
78
  import { DiagramAnchorClaims, DiagramAnchorClaimsContext } from './diagram/anchorClaims';
63
79
  import { DiagramBlockPending } from './diagram/DiagramPending';
80
+ import { LazyBlockBoundary } from './LazyBlockBoundary';
64
81
  import { isGraphvizLanguage, isMermaidLanguage } from './diagramLanguages';
82
+ import { isHtmlEmbedLanguage, isSvgFenceLanguage } from './html-embed/languages';
83
+ import type { HtmlEmbedHost } from './html-embed/HtmlEmbedBlock';
65
84
  import { getIdentity } from '../utils/identity';
66
85
  import { type QuickLabel } from '../utils/quickLabels';
67
86
  import type { SelectionAction } from '../utils/selectionActions';
@@ -152,6 +171,14 @@ export interface ViewerProps {
152
171
  annotationHeader?: ViewerAnnotationHeaderConfig;
153
172
  /** Render the plan as a floating card on a grid background (shadow/border/padding). Default false. */
154
173
  gridEnabled?: boolean;
174
+ /**
175
+ * Grow the document card to the full height the parent gives the Viewer, so
176
+ * a short document's card still runs to the bottom of the view instead of
177
+ * stopping halfway down the grid paper. The parent must give the Viewer a
178
+ * height (a stretched flex item does). Default false: the card sizes to its
179
+ * content.
180
+ */
181
+ fillHeight?: boolean;
155
182
  onOpenLinkedDoc?: (path: string) => void;
156
183
  onOpenCodeFile?: (path: string) => void;
157
184
  /**
@@ -175,6 +202,19 @@ export interface ViewerProps {
175
202
  * version). Absent or false: the single image overlay, as always.
176
203
  */
177
204
  imageLightboxStepping?: boolean;
205
+ /**
206
+ * Comments on part of an image (#2100, approval "image-region-comments"):
207
+ * the lightbox gets a Move | Region switch over each markdown image, a
208
+ * drag in Region mode draws a box, and Save adds an ordinary COMMENT through
209
+ * `onAddAnnotation` carrying `imageRegion`. Selecting such a comment from
210
+ * outside (the document's annotations panel) opens the lightbox zoomed to
211
+ * its box. Absent or false: the lightbox is unchanged.
212
+ */
213
+ imageRegionComments?: boolean;
214
+ /** Delete and edit from the lightbox's drawer of region comments (with
215
+ * `imageRegionComments`). Absent: the drawer's cards are read-only. */
216
+ onDeleteAnnotation?: (id: string) => void;
217
+ onEditAnnotation?: (id: string, updates: Partial<Annotation>) => void;
178
218
  /** Directory the active document lives in — used by the code-path validator
179
219
  * so out-of-tree relative references (e.g. `../foo.ts` in a linked doc)
180
220
  * resolve against the doc's own directory rather than only cwd. */
@@ -266,6 +306,20 @@ export interface ViewerProps {
266
306
  * `Decision:` line is shown as context and no question carries a decision
267
307
  * tag or row from it. The prompt, key and numbering are unchanged. */
268
308
  questionDecisionLines?: boolean;
309
+ /** Whether `:::question` cards read the contract lines (`Stakes:`,
310
+ * `Shows:`, `Why:`, `Reversible:`) into the card's Stakes line, Shows
311
+ * chip, Why line and "Can't be undone" tag. Default false: the lines show
312
+ * as context (plain text), as plan review and annotate draw them. The
313
+ * Plannotator Inbox passes true. The prompt, key, choices and numbering
314
+ * are the same either way. */
315
+ questionContractLines?: boolean;
316
+ /** Whether picture choices (`- [ ] Label — ![alt](path)`) draw as picture
317
+ * tiles (default true; see `QuestionBlock` `pictureChoices`). False: text
318
+ * rows. */
319
+ questionPictureChoices?: boolean;
320
+ /** A small label over each picture tile's picture (see `QuestionBlock`
321
+ * `pictureBadge`); absent: none. */
322
+ questionPictureBadge?: (choice: { label: string; image: { alt: string; src: string } }) => string | null;
269
323
  /** Makes a question's "Records a decision" tag a toggle button
270
324
  * (`aria-pressed`); a click calls this with the question's key and the
271
325
  * next state, which the host stores and returns from
@@ -306,6 +360,13 @@ export interface ViewerProps {
306
360
  /** Host content right under a question card's prompt, per question (e.g.
307
361
  * "Replaces <an earlier question>"); null for none. Absent: nothing there. */
308
362
  renderQuestionBelowPrompt?: (question: IndexedQuestion) => React.ReactNode;
363
+ /** A host's own drawing of a directive block that is not a question (ui
364
+ * seam for a host's own directives, such as the Plannotator Inbox's
365
+ * `:::action` action item card). Called per `:::kind` block; a returned node is
366
+ * drawn in place of the block's callout, inside a wrapper carrying the
367
+ * block's `data-block-id`; `undefined` keeps the callout. Absent: every
368
+ * such block draws as today. */
369
+ renderDirectiveBlock?: (block: Block) => React.ReactNode | undefined;
309
370
  onAskAI?: CommentAskAIHandler;
310
371
  /** Whether comment popovers offer image attachments. Hosts without an
311
372
  * uploadTransport pass false so the attach affordance never dead-ends.
@@ -326,6 +387,14 @@ export interface ViewerProps {
326
387
  * blocks and no comments on its text. Absent → unchanged.
327
388
  */
328
389
  answerOnly?: boolean;
390
+ /**
391
+ * Opt-in host capability (approval "inbox-inline-html"): a host that serves
392
+ * embed documents (see `HtmlEmbedHost`, @plannotator/core/html-embed) draws
393
+ * a ```html fence as a live, sandboxed page and a ```svg fence as an
394
+ * image, each with a Code button for the source. Absent → both stay code,
395
+ * unchanged (plan review, annotate and code review pass nothing).
396
+ */
397
+ htmlEmbeds?: HtmlEmbedHost;
329
398
  /**
330
399
  * Opt-in host capability, with `answerOnly`: the reader may comment on the
331
400
  * text. A text selection opens the selection toolbar, comment-only
@@ -338,6 +407,12 @@ export interface ViewerProps {
338
407
  * without `answerOnly` and under `readOnly`. Absent → unchanged.
339
408
  */
340
409
  textComments?: boolean;
410
+ /**
411
+ * Opt-in: words a text-selection comment box opens with, for the selected
412
+ * text (a host's suggested comment, kept with one press or written over).
413
+ * Returning undefined opens an empty box. Absent → unchanged.
414
+ */
415
+ suggestComment?: (selectedText: string) => string | undefined;
341
416
  /** Opt-in Vim-style keyboard selection. Default false for compatibility. */
342
417
  vimModeEnabled?: boolean;
343
418
  /** Replace the compact Vim badge with the live video-style key HUD. */
@@ -361,12 +436,32 @@ export interface ViewerProps {
361
436
  * unchanged.
362
437
  */
363
438
  onHostDraft?: (draft: HostDraft | null) => void;
439
+ /**
440
+ * Host content drawn right under the document's title (e.g. a host's
441
+ * references rows). The title is the body's first block when that is a
442
+ * level-1 heading; the node is drawn right after that heading. With no
443
+ * leading `# heading` it is drawn at the top of the body, before the first
444
+ * block (after the frontmatter card). The heading itself is unchanged.
445
+ *
446
+ * The wrapper (`[data-viewer-below-title]`, `data-placement` `below-title`
447
+ * or `top`) sits outside everything that addresses the document's text: it
448
+ * is `annotation-exclude` (no comment starts in it and its text is never
449
+ * searched or quoted), `data-pinpoint-ignore` (pinpoint and Vim skip it),
450
+ * `data-print-hide`, and has no `data-block-id`. Stored comment positions
451
+ * are kept as if it were not there, so its contents never renumber a
452
+ * comment's parent element. `null`, `undefined` and booleans count as
453
+ * absent: no wrapper is drawn and the DOM is exactly as without the prop.
454
+ */
455
+ belowTitle?: React.ReactNode;
364
456
  }
365
457
 
366
458
  export interface ViewerHandle {
367
459
  removeHighlight: (id: string) => void;
368
460
  clearAllHighlights: () => void;
369
461
  applySharedAnnotations: (annotations: Annotation[]) => void;
462
+ /** Markdown `Viewer` only: scroll a heading anchor (`#id`) into view.
463
+ * Returns false when it is not in this document. */
464
+ scrollToAnchor?: (hash: string) => boolean;
370
465
  /** `HtmlViewer` only: move a pinned draft to the element around it
371
466
  * (`'parent'`) or back in (`'child'`), for a host that composes
372
467
  * (`onHostDraft`). The markdown `Viewer` does not implement it. */
@@ -465,7 +560,7 @@ const FrontmatterCard: React.FC<{ frontmatter: Frontmatter }> = ({ frontmatter }
465
560
  if (entries.length === 0) return null;
466
561
 
467
562
  return (
468
- <div className="mt-4 mb-6 p-4 bg-muted/30 border border-border/50 rounded-lg">
563
+ <div className="mt-4 mb-6 p-4 bg-muted/30 border border-border/50 rounded-lg" data-doc-text="">
469
564
  <div className="grid gap-2 text-sm">
470
565
  {entries.map(([key, value]) => (
471
566
  <FrontmatterRow key={key} field={key} value={value} />
@@ -697,6 +792,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
697
792
  stickyActions = true,
698
793
  annotationHeader,
699
794
  gridEnabled = false,
795
+ fillHeight = false,
700
796
  planDiffStats,
701
797
  isPlanDiffActive,
702
798
  onPlanDiffToggle,
@@ -712,6 +808,9 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
712
808
  imageBaseDir,
713
809
  onImageClick: hostImageClick,
714
810
  imageLightboxStepping = false,
811
+ imageRegionComments = false,
812
+ onDeleteAnnotation,
813
+ onEditAnnotation,
715
814
  codePathBaseDir,
716
815
  disableCodePathValidation,
717
816
  onRestoreReport,
@@ -731,6 +830,9 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
731
830
  questionDecisionRecording,
732
831
  questionDecisionScope,
733
832
  questionDecisionLines = true,
833
+ questionContractLines = false,
834
+ questionPictureChoices = true,
835
+ questionPictureBadge,
734
836
  onToggleQuestionDecisionRecording,
735
837
  onOpenQuestionDecision,
736
838
  questionDecisionLocked,
@@ -738,17 +840,21 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
738
840
  questionStatusTag,
739
841
  questionStatusLabel,
740
842
  renderQuestionCard,
843
+ renderDirectiveBlock,
741
844
  renderQuestionBelowPrompt,
742
845
  onAskAI,
743
846
  allowImages = true,
744
847
  readOnly = false,
745
848
  answerOnly = false,
849
+ htmlEmbeds,
746
850
  textComments = false,
851
+ suggestComment,
747
852
  vimModeEnabled = false,
748
853
  vimHudEnabled = false,
749
854
  vimHudKeyPanelEnabled = true,
750
855
  onVimHudKeyPanelChange,
751
856
  onHostDraft,
857
+ belowTitle,
752
858
  }, ref) => {
753
859
  // Annotation authoring is off when read-only, and when the host only wants
754
860
  // answers (`answerOnly`); question cards and checkboxes follow `readOnly`.
@@ -764,16 +870,27 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
764
870
  // Where `#123` / `@user` link: `refRepo` when the host gave one, else `repoInfo`.
765
871
  const linkRepo = refRepo ?? repoInfo;
766
872
  const [copied, setCopied] = useState(false);
767
- const [lightbox, setLightboxState] = useState<{ src: string; alt: string; images?: LightboxImage[] } | null>(null);
873
+ const [lightbox, setLightboxState] = useState<{ src: string; alt: string; images?: LightboxImage[]; focusId?: string | null } | null>(null);
874
+ // The document's images for the lightbox: with region comments each knows
875
+ // its src as written, block and line.
876
+ const documentImages = (): LightboxImage[] =>
877
+ collectDocumentImages(containerRef.current).map((image) => {
878
+ if (!imageRegionComments || !image.writtenSrc || !image.blockId) return image;
879
+ const block = blocks.find((b) => b.id === image.blockId);
880
+ return { ...image, line: block ? imageSourceLine(block, image.writtenSrc) : null };
881
+ });
768
882
  // A host that handles image clicks (the Inbox) gets them; otherwise the lightbox opens, stepping through
769
- // the document's images when the host asked for it.
883
+ // the document's images when the host asked for it (and with region comments, always with the list).
770
884
  const setLightbox = (image: { src: string; alt: string }) => {
771
885
  if (hostImageClick) {
772
886
  hostImageClick(image.src, image.alt);
773
887
  return;
774
888
  }
775
- setLightboxState(imageLightboxStepping ? { ...image, images: collectDocumentImages(containerRef.current) } : image);
889
+ setLightboxState(imageLightboxStepping || imageRegionComments ? { ...image, images: documentImages() } : image);
776
890
  };
891
+ // Region comments made in the lightbox are selected by the host as they are
892
+ // added; that selection must not reopen or refocus the lightbox.
893
+ const regionCreatedHere = useRef(new Set<string>());
777
894
  const [locationHash, setLocationHash] = useState(() => window.location.hash);
778
895
  const globalCommentButtonRef = useRef<HTMLButtonElement>(null);
779
896
  // Read through a ref: only the imperative removeHighlight path below needs
@@ -828,9 +945,9 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
828
945
  // annotations that carry `questionAnswer` (matched by question key, so an
829
946
  // answer follows its question across re-parses).
830
947
  const questionIndex = useMemo(() => {
831
- const list = indexQuestionBlocks(blocks, { decisionLines: questionDecisionLines });
948
+ const list = indexQuestionBlocks(blocks, { decisionLines: questionDecisionLines, contractLines: questionContractLines });
832
949
  return { byBlock: new Map(list.map((q) => [q.blockId, q])), total: list.length };
833
- }, [blocks, questionDecisionLines]);
950
+ }, [blocks, questionDecisionLines, questionContractLines]);
834
951
  // A host that keeps answers itself (`questionAnswers`) replaces the
835
952
  // annotation-derived map; each entry is validated like any other reader.
836
953
  const answersByKey = useMemo(
@@ -1271,6 +1388,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1271
1388
 
1272
1389
  // Imperative handle — delegates to hook, extends removeHighlight for code blocks
1273
1390
  useImperativeHandle(ref, () => ({
1391
+ scrollToAnchor,
1274
1392
  removeHighlight: (id: string) => {
1275
1393
  // The re-highlight below notifies the swap listener, which would happily
1276
1394
  // paint this annotation's mark straight back in — the host has not
@@ -1298,7 +1416,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1298
1416
  clearAllHighlights,
1299
1417
  applySharedAnnotations: applyAnnotations,
1300
1418
  takeSelection,
1301
- }), [hookRemoveHighlight, clearAllHighlights, applyAnnotations, blocks, takeSelection]);
1419
+ }), [hookRemoveHighlight, clearAllHighlights, applyAnnotations, blocks, takeSelection, scrollToAnchor]);
1302
1420
 
1303
1421
  // --- Viewer-specific: code block annotation ---
1304
1422
 
@@ -1469,6 +1587,70 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1469
1587
  </>
1470
1588
  );
1471
1589
 
1590
+ // --- Comments on part of an image (#2100) ---------------------------------
1591
+ const regionHost = useMemo<LightboxRegionComments | undefined>(() => {
1592
+ if (!imageRegionComments) return undefined;
1593
+ return {
1594
+ annotations,
1595
+ canCreate: !authoringOff,
1596
+ onAdd: (annotation) => {
1597
+ regionCreatedHere.current.add(annotation.id);
1598
+ onAddAnnotation(annotation);
1599
+ },
1600
+ onDelete: authoringOff ? undefined : onDeleteAnnotation,
1601
+ onEdit: authoringOff ? undefined : onEditAnnotation,
1602
+ focusId: lightbox?.focusId ?? null,
1603
+ onAskAI: authoringOff ? undefined : onAskAI,
1604
+ mentionSource,
1605
+ };
1606
+ }, [annotations, authoringOff, imageRegionComments, lightbox?.focusId, mentionSource, onAddAnnotation, onAskAI, onDeleteAnnotation, onEditAnnotation]);
1607
+
1608
+ const closeLightbox = () => {
1609
+ // Closing the viewer lets go of the region comment selected while it was
1610
+ // open (one it was opened on, or one just made in it), so the same card
1611
+ // in the panel opens it again.
1612
+ const selected = selectedAnnotationId ? annotations.find((a) => a.id === selectedAnnotationId) : undefined;
1613
+ if (selected && regionOf(selected)) onSelectAnnotation(null);
1614
+ regionCreatedHere.current.clear();
1615
+ setLightboxState(null);
1616
+ };
1617
+
1618
+ // A region comment selected from outside (its card in the annotations
1619
+ // panel) opens the lightbox on its image, zoomed to its box.
1620
+ useEffect(() => {
1621
+ if (!imageRegionComments || !selectedAnnotationId || hostImageClick) return;
1622
+ // The host selects what the viewer just added: skip that one selection.
1623
+ if (regionCreatedHere.current.delete(selectedAnnotationId)) return;
1624
+ const ann = annotations.find((a) => a.id === selectedAnnotationId);
1625
+ const region = ann ? regionOf(ann) : null;
1626
+ if (!region) return;
1627
+ if (lightbox) {
1628
+ setLightboxState((lb) => (lb ? { ...lb, focusId: selectedAnnotationId } : lb));
1629
+ return;
1630
+ }
1631
+ const images = documentImages();
1632
+ const target = images.find((image) => image.writtenSrc === region.src);
1633
+ if (!target) return;
1634
+ setLightboxState({ src: target.src, alt: target.alt, images, focusId: selectedAnnotationId });
1635
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1636
+ }, [selectedAnnotationId]);
1637
+
1638
+ // The highlighter skips region comments; one whose image is no longer in
1639
+ // the document (or whose region is malformed) is unanchored, and says so.
1640
+ useEffect(() => {
1641
+ if (onRestoreReport === undefined) return;
1642
+ const rows = annotations.filter((ann) => ann.imageRegion != null);
1643
+ if (rows.length === 0) return;
1644
+ const text = blocks.map((b) => b.content).join('\n');
1645
+ const unanchored = rows
1646
+ .filter((ann) => {
1647
+ const region = regionOf(ann);
1648
+ return region === null || imageReferenceIndex(text, region.src) < 0;
1649
+ })
1650
+ .map((ann) => ann.id);
1651
+ onRestoreReport({ attempted: rows.map((ann) => ann.id), unanchored });
1652
+ }, [annotations, blocks, onRestoreReport]);
1653
+
1472
1654
  // The document's diagram blocks, in order: a diagram comment that names
1473
1655
  // none of them (an external POST, a deleted fence) is resolved by anchor
1474
1656
  // against each, first resolver wins (see diagram/anchorClaims).
@@ -1491,15 +1673,44 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1491
1673
  if (ids.length > 0) onRestoreReport({ attempted: ids, unanchored: ids });
1492
1674
  }, [annotations, diagramBlockKey, onRestoreReport]);
1493
1675
 
1676
+ // The host's `belowTitle` slot (see the prop). Absent → the block list is
1677
+ // returned as is, so the DOM is the one without the prop. The wrapper's
1678
+ // attribute carries utils/belowTitleSlot's per-load marker, which is how
1679
+ // the highlighter tells it from raw HTML an author wrote, to keep stored
1680
+ // positions slot-free.
1681
+ const belowTitleSlot = belowTitle == null || typeof belowTitle === 'boolean' ? null : belowTitle;
1682
+ const titleLeads = blocks[0]?.type === 'heading' && blocks[0].level === 1;
1683
+ const withBelowTitle = (rendered: React.ReactNode[]): React.ReactNode[] => {
1684
+ if (belowTitleSlot === null) return rendered;
1685
+ const slot = (
1686
+ <div
1687
+ key="viewer-below-title"
1688
+ data-viewer-below-title={BELOW_TITLE_SLOT_MARKER}
1689
+ data-placement={titleLeads ? 'below-title' : 'top'}
1690
+ data-pinpoint-ignore=""
1691
+ data-print-hide=""
1692
+ className="annotation-exclude"
1693
+ >
1694
+ {belowTitleSlot}
1695
+ </div>
1696
+ );
1697
+ return titleLeads ? [rendered[0], slot, ...rendered.slice(1)] : [slot, ...rendered];
1698
+ };
1699
+
1494
1700
  return (
1495
1701
  <CodePathValidationContext.Provider value={codePathValidation}>
1496
1702
  <LocalLinkContext.Provider value={resolveLocalLink ?? null}>
1497
1703
  <DiagramAnchorClaimsContext.Provider value={diagramClaims}>
1498
- <div className="relative z-50 w-full" style={maxWidth === null ? undefined : { maxWidth: maxWidth ?? 832 }}>
1704
+ <div
1705
+ className={`relative z-50 w-full${fillHeight ? ' flex flex-col print:block' : ''}`}
1706
+ data-viewer-fill-height={fillHeight ? '' : undefined}
1707
+ style={maxWidth === null ? undefined : { maxWidth: maxWidth ?? 832 }}
1708
+ >
1499
1709
  {taterMode && <TaterSpriteSitting />}
1500
1710
  <article
1501
1711
  ref={containerRef}
1502
1712
  data-print-region="article"
1713
+ data-doc-text-root=""
1503
1714
  data-vim-mode={vimModeActive ? 'enabled' : undefined}
1504
1715
  data-vim-phase={vimModeActive ? vim.state.phase : undefined}
1505
1716
  data-vim-focused={vimModeActive ? String(vim.focused) : undefined}
@@ -1509,7 +1720,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1509
1720
  onFocus={vim.onFocus}
1510
1721
  onBlur={vim.onBlur}
1511
1722
  onMouseDown={vim.onMouseDown}
1512
- className={`w-full bg-card rounded-xl py-5 md:py-8 lg:py-10 xl:py-12 relative ${gridEnabled ? 'px-5 md:px-8 lg:px-10 xl:px-12 shadow-xl border border-border/50' : ''} ${inputMethod === 'pinpoint' ? 'cursor-pointer' : ''}`}
1723
+ className={`w-full bg-card rounded-xl py-5 md:py-8 lg:py-10 xl:py-12 relative ${fillHeight ? 'flex-1 ' : ''}${gridEnabled ? 'px-5 md:px-8 lg:px-10 xl:px-12 shadow-xl border border-border/50' : ''} ${inputMethod === 'pinpoint' ? 'cursor-pointer' : ''}`}
1513
1724
  style={{
1514
1725
  WebkitTouchCallout: 'none',
1515
1726
  ...(vimModeActive ? { outline: 'none' } : {}),
@@ -1578,7 +1789,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1578
1789
  )}
1579
1790
  {frontmatter && <><div className="clear-right md:hidden" /><FrontmatterCard frontmatter={frontmatter} /></>}
1580
1791
  {!frontmatter && blocks.length > 0 && blocks[0].type !== 'heading' && <div className="mt-4" />}
1581
- {groupBlocks(blocks).map(group =>
1792
+ {withBelowTitle(groupBlocks(blocks).map(group =>
1582
1793
  group.type === 'list-group' ? (
1583
1794
  (() => {
1584
1795
  const indices = computeListIndices(group.blocks);
@@ -1604,34 +1815,50 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1604
1815
  </div>
1605
1816
  );
1606
1817
  })()
1818
+ ) : htmlEmbeds && group.block.type === 'code' && isHtmlEmbedLanguage(group.block.language) ? (
1819
+ <LazyBlockBoundary key={group.block.id} fallback={embedFallback(group.block)}>
1820
+ <Suspense fallback={<CodeBlock block={group.block} isHovered={false} />}>
1821
+ <HtmlEmbedBlock block={group.block} host={htmlEmbeds} />
1822
+ </Suspense>
1823
+ </LazyBlockBoundary>
1824
+ ) : htmlEmbeds && group.block.type === 'code' && isSvgFenceLanguage(group.block.language) ? (
1825
+ <LazyBlockBoundary key={group.block.id} fallback={embedFallback(group.block)}>
1826
+ <Suspense fallback={<CodeBlock block={group.block} isHovered={false} />}>
1827
+ <SvgFenceBlock block={group.block} />
1828
+ </Suspense>
1829
+ </LazyBlockBoundary>
1607
1830
  ) : group.block.type === 'code' && isMermaidLanguage(group.block.language) ? (
1608
- <Suspense key={group.block.id} fallback={<DiagramBlockPending block={group.block} kind="mermaid" />}>
1609
- <MermaidBlock
1610
- block={group.block}
1611
- annotations={annotations}
1612
- selectedAnnotationId={selectedAnnotationId}
1613
- onSelectAnnotation={onSelectAnnotation}
1614
- onAddAnnotation={authoringOff ? undefined : onAddAnnotation}
1615
- readOnly={authoringOff}
1616
- onRestoreReport={onRestoreReport}
1617
- onAskAI={authoringOff ? undefined : onAskAI}
1618
- askAISourcePath={linkedDocInfo?.filepath ?? sourceInfo}
1619
- />
1620
- </Suspense>
1831
+ <LazyBlockBoundary key={group.block.id} fallback={(notice) => <DiagramBlockPending block={group.block} kind="mermaid" notice={notice} />}>
1832
+ <Suspense fallback={<DiagramBlockPending block={group.block} kind="mermaid" />}>
1833
+ <MermaidBlock
1834
+ block={group.block}
1835
+ annotations={annotations}
1836
+ selectedAnnotationId={selectedAnnotationId}
1837
+ onSelectAnnotation={onSelectAnnotation}
1838
+ onAddAnnotation={authoringOff ? undefined : onAddAnnotation}
1839
+ readOnly={authoringOff}
1840
+ onRestoreReport={onRestoreReport}
1841
+ onAskAI={authoringOff ? undefined : onAskAI}
1842
+ askAISourcePath={linkedDocInfo?.filepath ?? sourceInfo}
1843
+ />
1844
+ </Suspense>
1845
+ </LazyBlockBoundary>
1621
1846
  ) : group.block.type === 'code' && isGraphvizLanguage(group.block.language) ? (
1622
- <Suspense key={group.block.id} fallback={<DiagramBlockPending block={group.block} kind="graphviz" />}>
1623
- <GraphvizBlock
1624
- block={group.block}
1625
- annotations={annotations}
1626
- selectedAnnotationId={selectedAnnotationId}
1627
- onSelectAnnotation={onSelectAnnotation}
1628
- onAddAnnotation={authoringOff ? undefined : onAddAnnotation}
1629
- readOnly={authoringOff}
1630
- onRestoreReport={onRestoreReport}
1631
- onAskAI={authoringOff ? undefined : onAskAI}
1632
- askAISourcePath={linkedDocInfo?.filepath ?? sourceInfo}
1633
- />
1634
- </Suspense>
1847
+ <LazyBlockBoundary key={group.block.id} fallback={(notice) => <DiagramBlockPending block={group.block} kind="graphviz" notice={notice} />}>
1848
+ <Suspense fallback={<DiagramBlockPending block={group.block} kind="graphviz" />}>
1849
+ <GraphvizBlock
1850
+ block={group.block}
1851
+ annotations={annotations}
1852
+ selectedAnnotationId={selectedAnnotationId}
1853
+ onSelectAnnotation={onSelectAnnotation}
1854
+ onAddAnnotation={authoringOff ? undefined : onAddAnnotation}
1855
+ readOnly={authoringOff}
1856
+ onRestoreReport={onRestoreReport}
1857
+ onAskAI={authoringOff ? undefined : onAskAI}
1858
+ askAISourcePath={linkedDocInfo?.filepath ?? sourceInfo}
1859
+ />
1860
+ </Suspense>
1861
+ </LazyBlockBoundary>
1635
1862
  ) : group.block.type === 'table' ? (
1636
1863
  <TableBlock
1637
1864
  key={group.block.id}
@@ -1712,7 +1939,9 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1712
1939
  const question = questionIndex.byBlock.get(group.block.id);
1713
1940
  const hostCard = question && renderQuestionCard
1714
1941
  ? renderQuestionCard(question, answersByKey.get(question.question.key))
1715
- : undefined;
1942
+ : !question && group.block.type === 'directive' && renderDirectiveBlock
1943
+ ? renderDirectiveBlock(group.block)
1944
+ : undefined;
1716
1945
  if (hostCard !== undefined) {
1717
1946
  return (
1718
1947
  <div key={group.block.id} data-block-id={group.block.id} data-block-type="directive" className={textCommentsOnly ? 'annotation-exclude' : undefined}>
@@ -1748,6 +1977,9 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1748
1977
  questionStatusTag={questionStatusTag}
1749
1978
  questionStatusLabel={questionStatusLabel}
1750
1979
  renderQuestionBelowPrompt={renderQuestionBelowPrompt}
1980
+ questionContractLines={questionContractLines}
1981
+ questionPictureChoices={questionPictureChoices}
1982
+ questionPictureBadge={questionPictureBadge}
1751
1983
  />
1752
1984
  );
1753
1985
  // `textComments`: a question card's words are not commented on
@@ -1763,7 +1995,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1763
1995
  return rendered;
1764
1996
  })()
1765
1997
  )
1766
- )}
1998
+ ))}
1767
1999
 
1768
2000
  {/* Text selection toolbar */}
1769
2001
  {selectionCommentsOn && !hostComposes && toolbarState && (
@@ -1780,6 +2012,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1780
2012
  selectionActionsIcon={selectionActionsIcon}
1781
2013
  quickLabels={quickLabels}
1782
2014
  copyText={toolbarState.selectionText}
2015
+ getRichCopy={getRichCopyEnabled() ? toolbarState.getRichCopy : undefined}
1783
2016
  hideCopyButton={!isTouchDevice}
1784
2017
  closeOnScrollOut
1785
2018
  />
@@ -1911,7 +2144,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1911
2144
  anchorEl={hookCommentPopover.anchorEl}
1912
2145
  contextText={hookCommentPopover.contextText}
1913
2146
  isGlobal={false}
1914
- initialText={hookCommentPopover.initialText}
2147
+ initialText={hookCommentPopover.initialText || suggestComment?.(hookCommentPopover.selectedText ?? hookCommentPopover.contextText) || undefined}
1915
2148
  draftKey={`plan:${commentDraftScope}:${hookCommentPopover.draftKey}`}
1916
2149
  onSubmit={hookCommentSubmit}
1917
2150
  onClose={hookCommentClose}
@@ -1990,7 +2223,13 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1990
2223
 
1991
2224
  {/* Image lightbox */}
1992
2225
  {lightbox && createPortal(
1993
- <ImageLightbox src={lightbox.src} alt={lightbox.alt} images={lightbox.images} onClose={() => setLightboxState(null)} />,
2226
+ <ImageLightbox
2227
+ src={lightbox.src}
2228
+ alt={lightbox.alt}
2229
+ images={lightbox.images}
2230
+ regions={regionHost}
2231
+ onClose={closeLightbox}
2232
+ />,
1994
2233
  document.body
1995
2234
  )}
1996
2235
  </div>
@@ -1,10 +1,11 @@
1
1
  import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
- import type { AIChatEntry, PendingPermission } from '../../hooks/useAIChat';
3
- import { hasNonInterruptibleSession, type AIProviderOption } from '../../utils/aiProvider';
2
+ import { hasPendingBusyChoice, type AIChatEntry, type PendingPermission } from '../../hooks/useAIChat';
3
+ import { findSessionBridge, hasNonInterruptibleSession, type AIProviderOption } from '../../utils/aiProvider';
4
4
  import { formatRelativeTime, renderChatMarkdown } from '../../utils/aiChatFormat';
5
5
  import { OverlayScrollArea } from '../OverlayScrollArea';
6
6
  import { SparklesIcon } from '../SparklesIcon';
7
7
  import { AIProviderBar } from './AIProviderBar';
8
+ import { NewChatButton } from './NewChatButton';
8
9
  import { SessionAskActions, SessionAskNote, SessionAskStatus, sessionAskErrorTone, type SessionAskAction } from './SessionAskNotice';
9
10
  import { submitHint } from '../../utils/platform';
10
11
 
@@ -24,6 +25,12 @@ interface DocumentAIChatPanelProps {
24
25
  onSessionAskAction?: (questionId: string, action: SessionAskAction) => void;
25
26
  /** Optional note under the provider bar on how to get "Ask this session" (askSessionSetupHint). */
26
27
  askSessionSetupHint?: string | null;
28
+ /**
29
+ * Start a new chat: clear the panel so the next question starts a fresh AI
30
+ * session (#1981). Shown above the messages once there are any; disabled
31
+ * while an answer is in flight. Absent: no button, the panel renders as before.
32
+ */
33
+ onNewChat?: () => void;
27
34
  }
28
35
 
29
36
  function truncate(text: string, max = 180): string {
@@ -71,6 +78,7 @@ export const DocumentAIChatPanel: React.FC<DocumentAIChatPanelProps> = ({
71
78
  onAIConfigChange,
72
79
  onSessionAskAction,
73
80
  askSessionSetupHint,
81
+ onNewChat,
74
82
  }) => {
75
83
  // A session that is never interrupted (T3 Code): Stop only stops listening.
76
84
  const listenOnly = hasNonInterruptibleSession(aiProviders);
@@ -85,6 +93,16 @@ export const DocumentAIChatPanel: React.FC<DocumentAIChatPanelProps> = ({
85
93
  last?.scrollIntoView({ behavior: 'smooth', block: 'end' });
86
94
  }, [messages.length, latestResponseText]);
87
95
 
96
+ const rootRef = useRef<HTMLDivElement>(null);
97
+ const handleNewChat = useCallback(() => {
98
+ if (!onNewChat) return;
99
+ onNewChat();
100
+ // The button leaves with the messages; keep focus in the panel.
101
+ requestAnimationFrame(() => {
102
+ rootRef.current?.querySelector<HTMLTextAreaElement>('textarea')?.focus();
103
+ });
104
+ }, [onNewChat]);
105
+
88
106
  const handleGeneralSubmit = useCallback(() => {
89
107
  const question = generalInput.trim();
90
108
  if (!question || !onAskGeneral) return;
@@ -93,7 +111,15 @@ export const DocumentAIChatPanel: React.FC<DocumentAIChatPanelProps> = ({
93
111
  }, [generalInput, onAskGeneral]);
94
112
 
95
113
  return (
96
- <div className="flex flex-col flex-1 min-h-0">
114
+ <div ref={rootRef} className="flex flex-col flex-1 min-h-0">
115
+ {onNewChat && messages.length > 0 && (
116
+ <NewChatButton
117
+ onNewChat={handleNewChat}
118
+ busy={isStreaming || isCreatingSession}
119
+ pendingChoice={hasPendingBusyChoice(messages)}
120
+ sessionBridge={!!findSessionBridge(aiProviders)}
121
+ />
122
+ )}
97
123
  <OverlayScrollArea className="flex-1 min-h-0">
98
124
  <div ref={scrollRef} className="p-2 space-y-3">
99
125
  {messages.length === 0 && !isCreatingSession && (