@plannotator/ui 0.52.1 → 0.53.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 (67) hide show
  1. package/HANDOFF.md +231 -27
  2. package/README.md +29 -4
  3. package/components/AnnotationPanel.tsx +159 -107
  4. package/components/AnnotationToolbar.tsx +17 -14
  5. package/components/AnnotationToolstrip.tsx +9 -0
  6. package/components/BlockRenderer.tsx +20 -14
  7. package/components/CodeFilePopout.tsx +131 -68
  8. package/components/CommentPopover.tsx +6 -349
  9. package/components/ComposerTokenTextarea.tsx +379 -0
  10. package/components/ExportModal.tsx +6 -1
  11. package/components/HostTitle.tsx +108 -0
  12. package/components/ImageLightbox.tsx +288 -17
  13. package/components/ImagePeek.tsx +255 -0
  14. package/components/ImageResolverContext.tsx +31 -0
  15. package/components/InlineMarkdown.tsx +123 -15
  16. package/components/LocalLinkContext.tsx +49 -0
  17. package/components/MenuVersionSection.tsx +51 -25
  18. package/components/OpenInAppButton.tsx +18 -49
  19. package/components/PlanHeaderMenu.tsx +2 -8
  20. package/components/ProviderIcons.tsx +10 -4
  21. package/components/RenderedMarkdown.tsx +40 -4
  22. package/components/Settings.tsx +33 -5
  23. package/components/SkillReferenceMenu.tsx +28 -6
  24. package/components/Tooltip.tsx +51 -4
  25. package/components/Viewer.tsx +189 -48
  26. package/components/ai/AIProviderBar.tsx +11 -0
  27. package/components/ai/AskSessionSetupHint.tsx +12 -0
  28. package/components/ai/DocumentAIChatPanel.tsx +17 -5
  29. package/components/ai/SessionAskNotice.tsx +4 -3
  30. package/components/blocks/BlockquoteBlock.tsx +159 -0
  31. package/components/blocks/HtmlBlock.tsx +121 -9
  32. package/components/blocks/QuestionBlock.tsx +183 -96
  33. package/components/diagram/DiagramViewer.tsx +41 -4
  34. package/components/diagram/index.ts +1 -1
  35. package/components/html-viewer/HtmlViewer.tsx +73 -3
  36. package/components/html-viewer/bridge-script.asset.js +86 -0
  37. package/components/html-viewer/bridge-script.ts +86 -0
  38. package/components/html-viewer/useHtmlAnnotation.ts +51 -3
  39. package/components/icons/AgentIcons.tsx +9 -7
  40. package/components/plan-diff/PlanCleanDiffView.tsx +239 -36
  41. package/components/plan-diff/PlanDiffViewer.tsx +6 -1
  42. package/configure.ts +27 -1
  43. package/hooks/useAgentJobs.ts +8 -3
  44. package/hooks/useAnnotationHighlighter.ts +128 -23
  45. package/hooks/useExternalAnnotations.ts +6 -0
  46. package/hooks/useOpenInApps.ts +88 -0
  47. package/hooks/useUpdateCheck.ts +38 -98
  48. package/hooks/useUpdateCheckSetting.ts +43 -0
  49. package/hooks/useUpdateToast.ts +101 -0
  50. package/package.json +2 -2
  51. package/shortcuts/code-review/markdownPreview.shortcuts.ts +18 -0
  52. package/shortcuts/index.ts +1 -0
  53. package/styles.css +1 -1
  54. package/types.ts +48 -0
  55. package/utils/agentClosed.ts +26 -0
  56. package/utils/agentToolSetting.ts +16 -9
  57. package/utils/aiProvider.ts +7 -0
  58. package/utils/askSessionSetupHint.ts +27 -0
  59. package/utils/blockTargeting.ts +14 -3
  60. package/utils/parser.ts +65 -51
  61. package/utils/pasteInsert.ts +234 -0
  62. package/utils/planDiffEngine.ts +272 -2
  63. package/utils/skillCatalog.ts +15 -2
  64. package/utils/skillReferences.ts +50 -13
  65. package/utils/storage.ts +5 -0
  66. package/utils/textCut.ts +41 -0
  67. package/utils/updateNotice.ts +120 -0
@@ -1,8 +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 { ImageLightbox } from './ImageLightbox';
5
- import { AnnotationType, type Block, type Annotation, type EditorMode, type InputMethod, type ImageAttachment, type ActionsLabelMode } from '../types';
4
+ import { collectDocumentImages, ImageLightbox, type LightboxImage } from './ImageLightbox';
5
+ import { AnnotationType, type Block, type Annotation, type EditorMode, type InputMethod, type ImageAttachment, type ActionsLabelMode, type HostDraft } from '../types';
6
6
  import { applyHighlight, codeBlockClassName, onCodeHighlightSwap } from '../utils/codeHighlight';
7
7
  import { paintCodeBlockMark } from '../utils/codeBlockMark';
8
8
  import { useFenceTheme } from '../hooks/useFenceTheme';
@@ -11,12 +11,14 @@ import { buildHeadingSlugMap } from '../utils/slugify';
11
11
  import { copyTextToClipboard } from '../utils/clipboard';
12
12
  import { BlockRenderer } from './BlockRenderer';
13
13
  import { indexQuestionBlocks, type IndexedQuestion, type ParsedQuestion, type QuestionAnswer } from '@plannotator/core/question-block';
14
+ import type { QuestionBlockProps } from './blocks/QuestionBlock';
14
15
  import { resolveQuestionAnswers } from '../utils/questionAnswers';
15
16
  import { CodeBlock } from './blocks/CodeBlock';
16
17
  import { TableBlock } from './blocks/TableBlock';
17
18
  import { TableToolbar } from './blocks/TableToolbar';
18
19
  import { TablePopout } from './blocks/TablePopout';
19
20
  import { CodePathValidationContext } from './CodePathValidationContext';
21
+ import { LocalLinkContext, type LocalLinkResolver } from './LocalLinkContext';
20
22
  import { useValidatedCodePaths } from '../hooks/useValidatedCodePaths';
21
23
  import { AnnotationToolbar } from './AnnotationToolbar';
22
24
  import { FloatingQuickLabelPicker } from './FloatingQuickLabelPicker';
@@ -132,6 +134,13 @@ export interface ViewerProps {
132
134
  onAddGlobalAttachment?: (image: ImageAttachment) => void;
133
135
  onRemoveGlobalAttachment?: (path: string) => void;
134
136
  repoInfo?: { display: string; branch?: string; host?: string } | null;
137
+ /**
138
+ * The repository bare `#123` / `@user` refs link into, WITHOUT the repo
139
+ * badge `repoInfo` also draws (a host that renders many small documents,
140
+ * such as the Inbox's messages). Wins over `repoInfo` for links only;
141
+ * `null` and absent both fall back to `repoInfo`, as before.
142
+ */
143
+ refRepo?: { display: string; host?: string } | null;
135
144
  stickyActions?: boolean;
136
145
  /**
137
146
  * Render compact annotation controls and the existing document actions in
@@ -145,7 +154,27 @@ export interface ViewerProps {
145
154
  gridEnabled?: boolean;
146
155
  onOpenLinkedDoc?: (path: string) => void;
147
156
  onOpenCodeFile?: (path: string) => void;
157
+ /**
158
+ * Opt-in host capability: decide what a link to a local path does
159
+ * (`[the mock](mock.html)`, markdown and raw-HTML `<a href>` alike; never
160
+ * `#anchors`, `//host` or a URL with a scheme). Return `{ kind: 'open' }`
161
+ * to open it yourself, `{ kind: 'unavailable', title }` to draw it as
162
+ * inert text, or null for the default. Absent → unchanged.
163
+ */
164
+ resolveLocalLink?: LocalLinkResolver;
148
165
  imageBaseDir?: string;
166
+ /**
167
+ * A click on an image in the document. Given: the host handles it (the
168
+ * Inbox opens a message's image in its own viewer) and no lightbox opens.
169
+ * Absent: the lightbox, as always.
170
+ */
171
+ onImageClick?: (src: string, alt: string) => void;
172
+ /**
173
+ * The lightbox steps through every image the document shows (← →, the
174
+ * peek; approval "inbox-image-viewer": plan review and annotate's light
175
+ * version). Absent or false: the single image overlay, as always.
176
+ */
177
+ imageLightboxStepping?: boolean;
149
178
  /** Directory the active document lives in — used by the code-path validator
150
179
  * so out-of-tree relative references (e.g. `../foo.ts` in a linked doc)
151
180
  * resolve against the doc's own directory rather than only cwd. */
@@ -232,6 +261,11 @@ export interface ViewerProps {
232
261
  * `Decision: when answered`. `'any'`: also a question with no decision
233
262
  * line, when `questionDecisionRecording` returns a boolean for it. */
234
263
  questionDecisionScope?: 'when-answered' | 'any';
264
+ /** Whether `:::question` cards read `Decision:` lines (default true). False
265
+ * (a host that records no decisions, such as the Plannotator Inbox): a
266
+ * `Decision:` line is shown as context and no question carries a decision
267
+ * tag or row from it. The prompt, key and numbering are unchanged. */
268
+ questionDecisionLines?: boolean;
235
269
  /** Makes a question's "Records a decision" tag a toggle button
236
270
  * (`aria-pressed`); a click calls this with the question's key and the
237
271
  * next state, which the host stores and returns from
@@ -243,10 +277,35 @@ export interface ViewerProps {
243
277
  * (anchor your popover to it). Offered on and off. Ignored when
244
278
  * `readOnly`. */
245
279
  onOpenQuestionDecision?: (key: string, anchor: HTMLElement) => void;
280
+ /** Per question, true draws its decision tag as a plain tag: no switch and
281
+ * no opener, as under `readOnly`, while the other questions keep theirs
282
+ * (a host whose message holds one sent and one open question). Absent:
283
+ * every question follows `readOnly`. */
284
+ questionDecisionLocked?: (question: IndexedQuestion) => boolean;
285
+ /** Per question, true draws that card read-only, as under `readOnly` (no
286
+ * picks, no Other, no note, no Skip, no Accept recommended, and its
287
+ * decision tag a plain tag), while the other questions stay answerable (a
288
+ * host whose message holds one sent and one open question). Absent: every
289
+ * question follows `readOnly`. */
290
+ questionReadOnly?: (question: IndexedQuestion) => boolean;
246
291
  /** `'none'` hides each question card's own status tag (Open / Answered /
247
292
  * Settled / Skipped) so the host can draw its own; the decision tags stay.
248
293
  * Default `'card'`. */
249
294
  questionStatusTag?: 'card' | 'none';
295
+ /** The host's own words for a question's status tag (ui seam, see
296
+ * `QuestionBlock` `statusLabel`): the Plannotator Inbox says "Not sent" for
297
+ * a saved, unsent pick. Absent: the card's own tags. */
298
+ questionStatusLabel?: QuestionBlockProps['statusLabel'];
299
+ /** A host's own card in place of a question's (ui seam for a question the
300
+ * host no longer lets anyone answer, such as the Plannotator Inbox's
301
+ * replaced questions). Called per question; a returned node is drawn
302
+ * instead of the answer card, inside a wrapper carrying the block's
303
+ * `data-block-id`; `undefined` keeps the answer card. Absent: every
304
+ * question draws its answer card (today's behaviour). */
305
+ renderQuestionCard?: (question: IndexedQuestion, answer: QuestionAnswer | undefined) => React.ReactNode | undefined;
306
+ /** Host content right under a question card's prompt, per question (e.g.
307
+ * "Replaces <an earlier question>"); null for none. Absent: nothing there. */
308
+ renderQuestionBelowPrompt?: (question: IndexedQuestion) => React.ReactNode;
250
309
  onAskAI?: CommentAskAIHandler;
251
310
  /** Whether comment popovers offer image attachments. Hosts without an
252
311
  * uploadTransport pass false so the attach affordance never dead-ends.
@@ -257,6 +316,16 @@ export interface ViewerProps {
257
316
  * comment, attachments, checkbox toggles). Existing annotations still
258
317
  * render and remain selectable. Default false — today's behavior. */
259
318
  readOnly?: boolean;
319
+ /**
320
+ * Opt-in host capability: the document is read, not annotated. Every
321
+ * annotation affordance is off exactly as under `readOnly` (selection and
322
+ * code-block toolbars, comment composers, pinpoint, the annotation header,
323
+ * global comment and attachments, Vim), the document actions (Global
324
+ * comment, Copy) are not drawn, but question cards and checkboxes keep their
325
+ * handlers. For a host that shows a message with answerable `:::question`
326
+ * blocks and no comments on its text. Absent → unchanged.
327
+ */
328
+ answerOnly?: boolean;
260
329
  /** Opt-in Vim-style keyboard selection. Default false for compatibility. */
261
330
  vimModeEnabled?: boolean;
262
331
  /** Replace the compact Vim badge with the live video-style key HUD. */
@@ -268,12 +337,36 @@ export interface ViewerProps {
268
337
  /** Fires once per highlight-restore pass with what it tried and what it could
269
338
  * not anchor, so a host can mark the leftovers in its annotation panel. */
270
339
  onRestoreReport?: (report: AnnotationRestoreReport) => void;
340
+ /**
341
+ * Opt-in host capability: the composer handed to the host (see
342
+ * `HostDraft`). A selection settling, a pinpoint tap or Comment mode reports
343
+ * a draft here instead of opening the toolbar or a composer (a pinpoint tap
344
+ * reports `intent: 'compose'`), and null when it closes. The document
345
+ * actions (Global comment, Copy) and the code-block and table hover
346
+ * toolbars are not drawn, and a pinpoint tap on a code block does nothing.
347
+ * The host saves the comment and draws it through `annotations` /
348
+ * `applySharedAnnotations`. Ignored when authoring is off. Absent →
349
+ * unchanged.
350
+ */
351
+ onHostDraft?: (draft: HostDraft | null) => void;
271
352
  }
272
353
 
273
354
  export interface ViewerHandle {
274
355
  removeHighlight: (id: string) => void;
275
356
  clearAllHighlights: () => void;
276
357
  applySharedAnnotations: (annotations: Annotation[]) => void;
358
+ /** `HtmlViewer` only: move a pinned draft to the element around it
359
+ * (`'parent'`) or back in (`'child'`), for a host that composes
360
+ * (`onHostDraft`). The markdown `Viewer` does not implement it. */
361
+ stepPin?: (direction: 'parent' | 'child') => void;
362
+ /** `HtmlViewer` only: scroll the pinned draft into the middle of the
363
+ * frame, for a host whose panel covers part of it. */
364
+ revealPin?: () => void;
365
+ /** Markdown `Viewer` only, for a host that composes (`onHostDraft`) on a
366
+ * touch screen: make the page's text selection a draft now (the host's own
367
+ * Comment action), reported through `onHostDraft` as `'selection'`.
368
+ * Returns false when nothing in the document is selected. */
369
+ takeSelection?: () => boolean;
277
370
  }
278
371
 
279
372
  interface CodeBlockToolbarTarget {
@@ -588,6 +681,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
588
681
  onAddGlobalAttachment,
589
682
  onRemoveGlobalAttachment,
590
683
  repoInfo,
684
+ refRepo,
591
685
  stickyActions = true,
592
686
  annotationHeader,
593
687
  gridEnabled = false,
@@ -601,8 +695,11 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
601
695
  maxWidth,
602
696
  onOpenLinkedDoc,
603
697
  onOpenCodeFile,
698
+ resolveLocalLink,
604
699
  linkedDocInfo,
605
700
  imageBaseDir,
701
+ onImageClick: hostImageClick,
702
+ imageLightboxStepping = false,
606
703
  codePathBaseDir,
607
704
  disableCodePathValidation,
608
705
  onRestoreReport,
@@ -621,21 +718,44 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
621
718
  renderQuestionFooter,
622
719
  questionDecisionRecording,
623
720
  questionDecisionScope,
721
+ questionDecisionLines = true,
624
722
  onToggleQuestionDecisionRecording,
625
723
  onOpenQuestionDecision,
724
+ questionDecisionLocked,
725
+ questionReadOnly,
626
726
  questionStatusTag,
727
+ questionStatusLabel,
728
+ renderQuestionCard,
729
+ renderQuestionBelowPrompt,
627
730
  onAskAI,
628
731
  allowImages = true,
629
732
  readOnly = false,
733
+ answerOnly = false,
630
734
  vimModeEnabled = false,
631
735
  vimHudEnabled = false,
632
736
  vimHudKeyPanelEnabled = true,
633
737
  onVimHudKeyPanelChange,
738
+ onHostDraft,
634
739
  }, ref) => {
635
- const viewerAnnotationHeader = readOnly ? undefined : annotationHeader;
740
+ // Annotation authoring is off when read-only, and when the host only wants
741
+ // answers (`answerOnly`); question cards and checkboxes follow `readOnly`.
742
+ const authoringOff = readOnly || answerOnly;
743
+ const hostComposes = onHostDraft !== undefined && !authoringOff;
744
+ const viewerAnnotationHeader = authoringOff ? undefined : annotationHeader;
636
745
  const hasViewerAnnotationHeader = viewerAnnotationHeader !== undefined;
746
+ // Where `#123` / `@user` link: `refRepo` when the host gave one, else `repoInfo`.
747
+ const linkRepo = refRepo ?? repoInfo;
637
748
  const [copied, setCopied] = useState(false);
638
- const [lightbox, setLightbox] = useState<{ src: string; alt: string } | null>(null);
749
+ const [lightbox, setLightboxState] = useState<{ src: string; alt: string; images?: LightboxImage[] } | null>(null);
750
+ // A host that handles image clicks (the Inbox) gets them; otherwise the lightbox opens, stepping through
751
+ // the document's images when the host asked for it.
752
+ const setLightbox = (image: { src: string; alt: string }) => {
753
+ if (hostImageClick) {
754
+ hostImageClick(image.src, image.alt);
755
+ return;
756
+ }
757
+ setLightboxState(imageLightboxStepping ? { ...image, images: collectDocumentImages(containerRef.current) } : image);
758
+ };
639
759
  const [locationHash, setLocationHash] = useState(() => window.location.hash);
640
760
  const globalCommentButtonRef = useRef<HTMLButtonElement>(null);
641
761
  // Read through a ref: only the imperative removeHighlight path below needs
@@ -690,9 +810,9 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
690
810
  // annotations that carry `questionAnswer` (matched by question key, so an
691
811
  // answer follows its question across re-parses).
692
812
  const questionIndex = useMemo(() => {
693
- const list = indexQuestionBlocks(blocks);
813
+ const list = indexQuestionBlocks(blocks, { decisionLines: questionDecisionLines });
694
814
  return { byBlock: new Map(list.map((q) => [q.blockId, q])), total: list.length };
695
- }, [blocks]);
815
+ }, [blocks, questionDecisionLines]);
696
816
  // A host that keeps answers itself (`questionAnswers`) replaces the
697
817
  // annotation-derived map; each entry is validated like any other reader.
698
818
  const answersByKey = useMemo(
@@ -722,8 +842,8 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
722
842
  codeBlock: { block: Block; element: HTMLElement };
723
843
  } | null>(null);
724
844
  const hoverTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
725
- const readOnlyRef = useRef(readOnly);
726
- readOnlyRef.current = readOnly;
845
+ const readOnlyRef = useRef(authoringOff);
846
+ readOnlyRef.current = authoringOff;
727
847
  const stickySentinelRef = useRef<HTMLDivElement>(null);
728
848
  const lastAutoScrolledHashRef = useRef<string | null>(null);
729
849
  const [isStuck, setIsStuck] = useState(false);
@@ -755,6 +875,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
755
875
  removeHighlight: hookRemoveHighlight,
756
876
  clearAllHighlights,
757
877
  applyAnnotations,
878
+ takeSelection,
758
879
  } = useAnnotationHighlighter({
759
880
  containerRef,
760
881
  annotations,
@@ -762,7 +883,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
762
883
  onSelectAnnotation,
763
884
  selectedAnnotationId,
764
885
  mode,
765
- enabled: !readOnly,
886
+ enabled: !authoringOff,
766
887
  // Markdown documents drift between the moment a draft/share is written and
767
888
  // the moment it is restored (a plan revision, a re-rendered block, a
768
889
  // renderer change that adds or drops elements — #1509 hoisted an alert's
@@ -774,6 +895,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
774
895
  verifyRestoredContent: true,
775
896
  onRestoreMismatch: handleRestoreMismatch,
776
897
  onRestoreReport,
898
+ onHostDraft: hostComposes ? onHostDraft : undefined,
777
899
  });
778
900
 
779
901
  // Refs for code block annotation path
@@ -945,7 +1067,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
945
1067
  });
946
1068
  }, [applyCodeBlockAnnotation, blocks]);
947
1069
 
948
- const vimModeActive = vimModeEnabled && !readOnly;
1070
+ const vimModeActive = vimModeEnabled && !authoringOff;
949
1071
  const keyboardCodeBlockToolbarOpen = codeBlockToolbar?.activation === 'keyboard';
950
1072
  const vimBlocked = !!toolbarState
951
1073
  || !!hookCommentPopover
@@ -974,12 +1096,16 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
974
1096
  onHandledCommand: handleVimCommand,
975
1097
  });
976
1098
 
1099
+ // With the composer handed to the host, a pinpoint tap opens it at once.
1100
+ const hostPinpointRange = useCallback((range: Range) => highlightRange(range, 'comment'), [highlightRange]);
1101
+ const noHostCodeBlockClick = useCallback(() => {}, []);
977
1102
  const { hoverTarget, clearHover: clearPinpointHover } = usePinpoint({
978
1103
  containerRef,
979
1104
  inputMethod,
980
- enabled: !readOnly && !toolbarState && !hookCommentPopover && !viewerCommentPopover && !hookQuickLabelPicker && !codeBlockQuickLabelPicker && !(isPlanDiffActive ?? false) && !vim.helpOpen,
981
- onSelectRange: highlightRange,
982
- onCodeBlockClick: handlePinpointCodeBlockClick,
1105
+ // A host composer's draft stays open while the host composes; the next tap replaces it.
1106
+ enabled: !authoringOff && (hostComposes || (!toolbarState && !hookCommentPopover)) && !viewerCommentPopover && !hookQuickLabelPicker && !codeBlockQuickLabelPicker && !(isPlanDiffActive ?? false) && !vim.helpOpen,
1107
+ onSelectRange: hostComposes ? hostPinpointRange : highlightRange,
1108
+ onCodeBlockClick: hostComposes ? noHostCodeBlockClick : handlePinpointCodeBlockClick,
983
1109
  });
984
1110
  clearPinpointHoverRef.current = clearPinpointHover;
985
1111
  const vimOwnsHudTarget = vimHudEnabled
@@ -998,7 +1124,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
998
1124
  : (inputMethod === 'pinpoint' ? hoverTarget : null) ?? legacyVimTarget;
999
1125
 
1000
1126
  useEffect(() => {
1001
- if (!readOnly) return;
1127
+ if (!authoringOff) return;
1002
1128
  if (hoverTimeoutRef.current) {
1003
1129
  clearTimeout(hoverTimeoutRef.current);
1004
1130
  hoverTimeoutRef.current = null;
@@ -1007,7 +1133,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1007
1133
  setIsCodeBlockToolbarExiting(false);
1008
1134
  setViewerCommentPopover(null);
1009
1135
  setCodeBlockQuickLabelPicker(null);
1010
- }, [readOnly]);
1136
+ }, [authoringOff]);
1011
1137
 
1012
1138
  useEffect(() => {
1013
1139
  if (!vimOwnsDocumentNavigation) return;
@@ -1153,7 +1279,8 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1153
1279
  },
1154
1280
  clearAllHighlights,
1155
1281
  applySharedAnnotations: applyAnnotations,
1156
- }), [hookRemoveHighlight, clearAllHighlights, applyAnnotations, blocks]);
1282
+ takeSelection,
1283
+ }), [hookRemoveHighlight, clearAllHighlights, applyAnnotations, blocks, takeSelection]);
1157
1284
 
1158
1285
  // --- Viewer-specific: code block annotation ---
1159
1286
 
@@ -1266,7 +1393,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1266
1393
  </button>
1267
1394
  )}
1268
1395
 
1269
- {!readOnly && onAddGlobalAttachment && onRemoveGlobalAttachment && (
1396
+ {!authoringOff && onAddGlobalAttachment && onRemoveGlobalAttachment && (
1270
1397
  <AttachmentsButton
1271
1398
  images={globalAttachments}
1272
1399
  onAdd={onAddGlobalAttachment}
@@ -1276,7 +1403,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1276
1403
  />
1277
1404
  )}
1278
1405
 
1279
- {!readOnly && (
1406
+ {!authoringOff && (
1280
1407
  <button
1281
1408
  ref={globalCommentButtonRef}
1282
1409
  onClick={() => {
@@ -1348,6 +1475,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1348
1475
 
1349
1476
  return (
1350
1477
  <CodePathValidationContext.Provider value={codePathValidation}>
1478
+ <LocalLinkContext.Provider value={resolveLocalLink ?? null}>
1351
1479
  <DiagramAnchorClaimsContext.Provider value={diagramClaims}>
1352
1480
  <div className="relative z-50 w-full" style={maxWidth === null ? undefined : { maxWidth: maxWidth ?? 832 }}>
1353
1481
  {taterMode && <TaterSpriteSitting />}
@@ -1419,7 +1547,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1419
1547
  <>
1420
1548
  {badgeClearance > 0 && <div data-print-hide style={{ height: badgeClearance }} aria-hidden="true" />}
1421
1549
  {stickyActions && <div ref={stickySentinelRef} className="h-0 w-0 float-right" aria-hidden="true" />}
1422
- {stickyActions ? (
1550
+ {answerOnly || hostComposes ? null : stickyActions ? (
1423
1551
  <StickyActionsLane className={`flex items-start gap-1 md:gap-2 rounded-lg p-1 md:p-2 transition-colors duration-150 ${isStuck ? 'bg-card/95 backdrop-blur-sm shadow-sm' : ''} ${gridEnabled ? '-mr-3 md:-mr-5 lg:-mr-7 xl:-mr-9' : '-mr-1 md:-mr-2'} mt-6 md:-mt-5 lg:-mt-7 xl:-mt-9`}>
1424
1552
  {documentActions}
1425
1553
  </StickyActionsLane>
@@ -1449,8 +1577,8 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1449
1577
  onOpenCodeFile={onOpenCodeFile}
1450
1578
  onToggleCheckbox={readOnly ? undefined : onToggleCheckbox}
1451
1579
  checkboxOverrides={checkboxOverrides}
1452
- githubRepo={repoInfo?.display}
1453
- repoHost={repoInfo?.host}
1580
+ githubRepo={linkRepo?.display}
1581
+ repoHost={linkRepo?.host}
1454
1582
  headingAnchorId={headingSlugMap.get(block.id)}
1455
1583
  onNavigateAnchor={scrollToAnchor}
1456
1584
  />
@@ -1465,10 +1593,10 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1465
1593
  annotations={annotations}
1466
1594
  selectedAnnotationId={selectedAnnotationId}
1467
1595
  onSelectAnnotation={onSelectAnnotation}
1468
- onAddAnnotation={readOnly ? undefined : onAddAnnotation}
1469
- readOnly={readOnly}
1596
+ onAddAnnotation={authoringOff ? undefined : onAddAnnotation}
1597
+ readOnly={authoringOff}
1470
1598
  onRestoreReport={onRestoreReport}
1471
- onAskAI={readOnly ? undefined : onAskAI}
1599
+ onAskAI={authoringOff ? undefined : onAskAI}
1472
1600
  askAISourcePath={linkedDocInfo?.filepath ?? sourceInfo}
1473
1601
  />
1474
1602
  </Suspense>
@@ -1479,10 +1607,10 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1479
1607
  annotations={annotations}
1480
1608
  selectedAnnotationId={selectedAnnotationId}
1481
1609
  onSelectAnnotation={onSelectAnnotation}
1482
- onAddAnnotation={readOnly ? undefined : onAddAnnotation}
1483
- readOnly={readOnly}
1610
+ onAddAnnotation={authoringOff ? undefined : onAddAnnotation}
1611
+ readOnly={authoringOff}
1484
1612
  onRestoreReport={onRestoreReport}
1485
- onAskAI={readOnly ? undefined : onAskAI}
1613
+ onAskAI={authoringOff ? undefined : onAskAI}
1486
1614
  askAISourcePath={linkedDocInfo?.filepath ?? sourceInfo}
1487
1615
  />
1488
1616
  </Suspense>
@@ -1494,8 +1622,8 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1494
1622
  onImageClick={(src, alt) => setLightbox({ src, alt })}
1495
1623
  onOpenLinkedDoc={onOpenLinkedDoc}
1496
1624
  onOpenCodeFile={onOpenCodeFile}
1497
- githubRepo={repoInfo?.display}
1498
- repoHost={repoInfo?.host}
1625
+ githubRepo={linkRepo?.display}
1626
+ repoHost={linkRepo?.host}
1499
1627
  onNavigateAnchor={scrollToAnchor}
1500
1628
  onHover={(element) => {
1501
1629
  if (tableHoverTimeoutRef.current) {
@@ -1503,7 +1631,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1503
1631
  tableHoverTimeoutRef.current = null;
1504
1632
  }
1505
1633
  setIsTableToolbarExiting(false);
1506
- if (!toolbarState) {
1634
+ if (!toolbarState && !hostComposes) {
1507
1635
  setHoveredTable({ block: group.block, element });
1508
1636
  }
1509
1637
  }}
@@ -1521,7 +1649,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1521
1649
  <CodeBlock
1522
1650
  key={group.block.id}
1523
1651
  block={group.block}
1524
- onHover={readOnly || inputMethod === 'pinpoint' ? undefined : (element) => {
1652
+ onHover={authoringOff || hostComposes || inputMethod === 'pinpoint' ? undefined : (element) => {
1525
1653
  // Clear any pending leave timeout
1526
1654
  if (hoverTimeoutRef.current) {
1527
1655
  clearTimeout(hoverTimeoutRef.current);
@@ -1542,7 +1670,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1542
1670
  });
1543
1671
  }
1544
1672
  }}
1545
- onLeave={readOnly || inputMethod === 'pinpoint' ? undefined : () => {
1673
+ onLeave={authoringOff || hostComposes || inputMethod === 'pinpoint' ? undefined : () => {
1546
1674
  if (keyboardCodeBlockToolbarOpen) return;
1547
1675
  // Delay then start exit animation
1548
1676
  hoverTimeoutRef.current = setTimeout(() => {
@@ -1555,7 +1683,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1555
1683
  }, 100);
1556
1684
  }}
1557
1685
  isHovered={
1558
- !readOnly
1686
+ !authoringOff
1559
1687
  && inputMethod !== 'pinpoint'
1560
1688
  && !vimOwnsDocumentNavigation
1561
1689
  && codeBlockToolbar?.block.id === group.block.id
@@ -1564,6 +1692,16 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1564
1692
  ) : (
1565
1693
  (() => {
1566
1694
  const question = questionIndex.byBlock.get(group.block.id);
1695
+ const hostCard = question && renderQuestionCard
1696
+ ? renderQuestionCard(question, answersByKey.get(question.question.key))
1697
+ : undefined;
1698
+ if (hostCard !== undefined) {
1699
+ return (
1700
+ <div key={group.block.id} data-block-id={group.block.id} data-block-type="directive">
1701
+ {hostCard}
1702
+ </div>
1703
+ );
1704
+ }
1567
1705
  return (
1568
1706
  <BlockRenderer
1569
1707
  imageBaseDir={imageBaseDir}
@@ -1575,21 +1713,23 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1575
1713
  onNavigateAnchor={scrollToAnchor}
1576
1714
  onToggleCheckbox={readOnly ? undefined : onToggleCheckbox}
1577
1715
  checkboxOverrides={checkboxOverrides}
1578
- githubRepo={repoInfo?.display}
1579
- repoHost={repoInfo?.host}
1716
+ githubRepo={linkRepo?.display}
1717
+ repoHost={linkRepo?.host}
1580
1718
  headingAnchorId={headingSlugMap.get(group.block.id)}
1581
1719
  question={question}
1582
1720
  questionTotal={questionIndex.total}
1583
1721
  questionAnswer={question ? answersByKey.get(question.question.key) : undefined}
1584
- onAnswerQuestion={readOnly ? undefined : onAnswerQuestion}
1585
- onSaveQuestionAnswer={readOnly ? undefined : onSaveQuestionAnswer}
1722
+ onAnswerQuestion={readOnly || (question && questionReadOnly?.(question)) ? undefined : onAnswerQuestion}
1723
+ onSaveQuestionAnswer={readOnly || (question && questionReadOnly?.(question)) ? undefined : onSaveQuestionAnswer}
1586
1724
  saveQuestionAnswerLabel={saveQuestionAnswerLabel}
1587
1725
  renderQuestionFooter={renderQuestionFooter}
1588
1726
  questionDecisionRecording={question ? questionDecisionRecording?.(question) : undefined}
1589
1727
  questionDecisionScope={questionDecisionScope}
1590
- onToggleQuestionDecisionRecording={readOnly ? undefined : onToggleQuestionDecisionRecording}
1591
- onOpenQuestionDecision={readOnly ? undefined : onOpenQuestionDecision}
1728
+ onToggleQuestionDecisionRecording={readOnly || (question && (questionDecisionLocked?.(question) || questionReadOnly?.(question))) ? undefined : onToggleQuestionDecisionRecording}
1729
+ onOpenQuestionDecision={readOnly || (question && (questionDecisionLocked?.(question) || questionReadOnly?.(question))) ? undefined : onOpenQuestionDecision}
1592
1730
  questionStatusTag={questionStatusTag}
1731
+ questionStatusLabel={questionStatusLabel}
1732
+ renderQuestionBelowPrompt={renderQuestionBelowPrompt}
1593
1733
  />
1594
1734
  );
1595
1735
  })()
@@ -1597,7 +1737,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1597
1737
  )}
1598
1738
 
1599
1739
  {/* Text selection toolbar */}
1600
- {!readOnly && toolbarState && (
1740
+ {!authoringOff && !hostComposes && toolbarState && (
1601
1741
  <ToolbarErrorBoundary>
1602
1742
  <AnnotationToolbar
1603
1743
  element={toolbarState.element}
@@ -1651,7 +1791,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1651
1791
  )}
1652
1792
 
1653
1793
  {/* Code block hover toolbar */}
1654
- {!readOnly
1794
+ {!authoringOff
1655
1795
  && codeBlockToolbar
1656
1796
  && !toolbarState
1657
1797
  && !(vimOwnsDocumentNavigation && codeBlockToolbar.activation === 'pointer')
@@ -1701,8 +1841,8 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1701
1841
  onImageClick={(src, alt) => setLightbox({ src, alt })}
1702
1842
  onOpenLinkedDoc={onOpenLinkedDoc}
1703
1843
  onOpenCodeFile={onOpenCodeFile}
1704
- githubRepo={repoInfo?.display}
1705
- repoHost={repoInfo?.host}
1844
+ githubRepo={linkRepo?.display}
1845
+ repoHost={linkRepo?.host}
1706
1846
  onNavigateAnchor={scrollToAnchor}
1707
1847
  />
1708
1848
  )}
@@ -1736,7 +1876,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1736
1876
  )}
1737
1877
 
1738
1878
  {/* Comment popover — hook handles text selection, Viewer handles global + code block */}
1739
- {!readOnly && hookCommentPopover && (
1879
+ {!authoringOff && !hostComposes && hookCommentPopover && (
1740
1880
  <CommentPopover
1741
1881
  anchorEl={hookCommentPopover.anchorEl}
1742
1882
  contextText={hookCommentPopover.contextText}
@@ -1758,7 +1898,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1758
1898
  }}
1759
1899
  />
1760
1900
  )}
1761
- {!readOnly && viewerCommentPopover && (
1901
+ {!authoringOff && viewerCommentPopover && (
1762
1902
  <CommentPopover
1763
1903
  anchorEl={viewerCommentPopover.anchorEl}
1764
1904
  contextText={viewerCommentPopover.contextText}
@@ -1788,7 +1928,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1788
1928
  )}
1789
1929
 
1790
1930
  {/* Quick Label floating picker — hook handles text selection, Viewer handles code blocks */}
1791
- {!readOnly && hookQuickLabelPicker && (
1931
+ {!authoringOff && !hostComposes && hookQuickLabelPicker && (
1792
1932
  <FloatingQuickLabelPicker
1793
1933
  anchorEl={hookQuickLabelPicker.anchorEl}
1794
1934
  cursorHint={hookQuickLabelPicker.cursorHint}
@@ -1796,7 +1936,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1796
1936
  onDismiss={hookQuickLabelPickerDismiss}
1797
1937
  />
1798
1938
  )}
1799
- {!readOnly && codeBlockQuickLabelPicker && (
1939
+ {!authoringOff && codeBlockQuickLabelPicker && (
1800
1940
  <FloatingQuickLabelPicker
1801
1941
  anchorEl={codeBlockQuickLabelPicker.anchorEl}
1802
1942
  onSelect={(label: QuickLabel) => {
@@ -1820,11 +1960,12 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1820
1960
 
1821
1961
  {/* Image lightbox */}
1822
1962
  {lightbox && createPortal(
1823
- <ImageLightbox src={lightbox.src} alt={lightbox.alt} onClose={() => setLightbox(null)} />,
1963
+ <ImageLightbox src={lightbox.src} alt={lightbox.alt} images={lightbox.images} onClose={() => setLightboxState(null)} />,
1824
1964
  document.body
1825
1965
  )}
1826
1966
  </div>
1827
1967
  </DiagramAnchorClaimsContext.Provider>
1968
+ </LocalLinkContext.Provider>
1828
1969
  </CodePathValidationContext.Provider>
1829
1970
  );
1830
1971
  });
@@ -2,6 +2,7 @@ import React from 'react';
2
2
  import { getProviderMeta } from '../ProviderIcons';
3
3
  import { findSessionBridge, type AIProviderOption } from '../../utils/aiProvider';
4
4
  import { ModelSourceHint, modelSourceToolForProvider } from '../ModelSourceHint';
5
+ import { AskSessionSetupHint } from './AskSessionSetupHint';
5
6
 
6
7
  interface AIProviderBarProps {
7
8
  providers: AIProviderOption[];
@@ -11,6 +12,11 @@ interface AIProviderBarProps {
11
12
  onProviderChange: (providerId: string) => void;
12
13
  onModelChange: (model: string) => void;
13
14
  onReasoningEffortChange?: (effort: string | null) => void;
15
+ /**
16
+ * Optional muted line under the bar saying how to get "Ask this session"
17
+ * (see askSessionSetupHint). Absent: the bar renders exactly as before.
18
+ */
19
+ setupHint?: string | null;
14
20
  }
15
21
 
16
22
  export const AIProviderBar: React.FC<AIProviderBarProps> = ({
@@ -21,12 +27,16 @@ export const AIProviderBar: React.FC<AIProviderBarProps> = ({
21
27
  onProviderChange,
22
28
  onModelChange,
23
29
  onReasoningEffortChange,
30
+ setupHint,
24
31
  }) => {
25
32
  if (providers.length === 0) {
26
33
  return (
34
+ <>
27
35
  <div className="border-t border-border/50 px-2 py-1.5 text-[11px] text-muted-foreground/50">
28
36
  No AI providers available
29
37
  </div>
38
+ <AskSessionSetupHint text={setupHint} />
39
+ </>
30
40
  );
31
41
  }
32
42
 
@@ -115,6 +125,7 @@ export const AIProviderBar: React.FC<AIProviderBarProps> = ({
115
125
  info={currentProvider}
116
126
  className="-mt-1 px-2 pb-1.5 text-[10px] text-muted-foreground/50"
117
127
  />
128
+ <AskSessionSetupHint text={setupHint} />
118
129
  </>
119
130
  );
120
131
  };
@@ -0,0 +1,12 @@
1
+ import React from 'react';
2
+
3
+ /**
4
+ * The muted line under an Ask AI provider bar that says how to get "Ask this
5
+ * session" (text from askSessionSetupHint). Renders nothing without text.
6
+ */
7
+ export const AskSessionSetupHint: React.FC<{ text?: string | null }> = ({ text }) =>
8
+ text ? (
9
+ <div className="-mt-1 px-2 pb-1.5 text-[10px] text-muted-foreground/70" data-ai-ask-session-setup-hint>
10
+ {text}
11
+ </div>
12
+ ) : null;