@plannotator/ui 0.52.1 → 0.53.1

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 (76) hide show
  1. package/HANDOFF.md +284 -29
  2. package/README.md +45 -5
  3. package/components/AnnotationPanel.tsx +195 -118
  4. package/components/AnnotationToolbar.tsx +17 -14
  5. package/components/AnnotationToolstrip.tsx +54 -40
  6. package/components/BlockRenderer.tsx +20 -14
  7. package/components/CodeFilePopout.tsx +132 -69
  8. package/components/CommentPopover.tsx +8 -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/HtmlSurfaceControls.tsx +2 -1
  13. package/components/ImageLightbox.tsx +288 -17
  14. package/components/ImagePeek.tsx +255 -0
  15. package/components/ImageResolverContext.tsx +31 -0
  16. package/components/InlineMarkdown.tsx +123 -15
  17. package/components/LocalLinkContext.tsx +49 -0
  18. package/components/MentionPicker.tsx +51 -0
  19. package/components/MenuVersionSection.tsx +51 -25
  20. package/components/OpenInAppButton.tsx +18 -49
  21. package/components/PlanHeaderMenu.tsx +2 -8
  22. package/components/ProviderIcons.tsx +10 -4
  23. package/components/RenderedMarkdown.tsx +40 -4
  24. package/components/Settings.tsx +33 -5
  25. package/components/SkillReferenceMenu.tsx +67 -18
  26. package/components/Tooltip.tsx +51 -4
  27. package/components/Viewer.tsx +220 -49
  28. package/components/ai/AIProviderBar.tsx +11 -0
  29. package/components/ai/AskSessionSetupHint.tsx +12 -0
  30. package/components/ai/DocumentAIChatPanel.tsx +17 -5
  31. package/components/ai/SessionAskNotice.tsx +4 -3
  32. package/components/blocks/BlockquoteBlock.tsx +159 -0
  33. package/components/blocks/HtmlBlock.tsx +121 -9
  34. package/components/blocks/QuestionBlock.tsx +183 -96
  35. package/components/blocks/QuestionContext.tsx +3 -0
  36. package/components/diagram/DiagramViewer.tsx +41 -4
  37. package/components/diagram/index.ts +1 -1
  38. package/components/html-viewer/HtmlViewer.tsx +95 -3
  39. package/components/html-viewer/bridge-script.asset.js +413 -7
  40. package/components/html-viewer/bridge-script.lite.ts +1 -1
  41. package/components/html-viewer/bridge-script.ts +454 -7
  42. package/components/html-viewer/useHtmlAnnotation.ts +51 -3
  43. package/components/icons/AgentIcons.tsx +9 -7
  44. package/components/plan-diff/PlanCleanDiffView.tsx +239 -36
  45. package/components/plan-diff/PlanDiffViewer.tsx +6 -1
  46. package/configure.ts +27 -1
  47. package/hooks/useAgentJobs.ts +8 -3
  48. package/hooks/useAnnotationHighlighter.ts +128 -23
  49. package/hooks/useExternalAnnotations.ts +6 -0
  50. package/hooks/useMentionAutocomplete.ts +1 -1
  51. package/hooks/useOpenInApps.ts +88 -0
  52. package/hooks/useSkillReferenceAutocomplete.ts +7 -3
  53. package/hooks/useUpdateCheck.ts +38 -98
  54. package/hooks/useUpdateCheckSetting.ts +43 -0
  55. package/hooks/useUpdateToast.ts +101 -0
  56. package/hooks/useVimSelection.ts +9 -0
  57. package/package.json +2 -2
  58. package/shortcuts/code-review/markdownPreview.shortcuts.ts +18 -0
  59. package/shortcuts/index.ts +1 -0
  60. package/shortcuts/runtime.ts +10 -3
  61. package/styles.css +1 -1
  62. package/types.ts +48 -0
  63. package/utils/agentClosed.ts +26 -0
  64. package/utils/agentToolSetting.ts +16 -9
  65. package/utils/aiProvider.ts +7 -0
  66. package/utils/askSessionSetupHint.ts +27 -0
  67. package/utils/blockTargeting.ts +14 -3
  68. package/utils/mentions.ts +34 -5
  69. package/utils/parser.ts +65 -51
  70. package/utils/pasteInsert.ts +234 -0
  71. package/utils/planDiffEngine.ts +272 -2
  72. package/utils/skillCatalog.ts +15 -2
  73. package/utils/skillReferences.ts +50 -13
  74. package/utils/storage.ts +5 -0
  75. package/utils/textCut.ts +41 -0
  76. 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,28 @@ 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;
329
+ /**
330
+ * Opt-in host capability, with `answerOnly`: the reader may comment on the
331
+ * text. A text selection opens the selection toolbar, comment-only
332
+ * (Comment and the one-click 👍; no Delete, no label picker), and its
333
+ * comment composer; `annotations` are drawn as highlights. Question cards
334
+ * are left out of selection (their text is not commented on), and nothing
335
+ * else of authoring comes back (no pinpoint, code-block toolbar, Global
336
+ * comment or Copy, no Vim). For a host that shows a message whose words
337
+ * can be commented on (the Plannotator Inbox's agent messages). Ignored
338
+ * without `answerOnly` and under `readOnly`. Absent → unchanged.
339
+ */
340
+ textComments?: boolean;
260
341
  /** Opt-in Vim-style keyboard selection. Default false for compatibility. */
261
342
  vimModeEnabled?: boolean;
262
343
  /** Replace the compact Vim badge with the live video-style key HUD. */
@@ -268,12 +349,36 @@ export interface ViewerProps {
268
349
  /** Fires once per highlight-restore pass with what it tried and what it could
269
350
  * not anchor, so a host can mark the leftovers in its annotation panel. */
270
351
  onRestoreReport?: (report: AnnotationRestoreReport) => void;
352
+ /**
353
+ * Opt-in host capability: the composer handed to the host (see
354
+ * `HostDraft`). A selection settling, a pinpoint tap or Comment mode reports
355
+ * a draft here instead of opening the toolbar or a composer (a pinpoint tap
356
+ * reports `intent: 'compose'`), and null when it closes. The document
357
+ * actions (Global comment, Copy) and the code-block and table hover
358
+ * toolbars are not drawn, and a pinpoint tap on a code block does nothing.
359
+ * The host saves the comment and draws it through `annotations` /
360
+ * `applySharedAnnotations`. Ignored when authoring is off. Absent →
361
+ * unchanged.
362
+ */
363
+ onHostDraft?: (draft: HostDraft | null) => void;
271
364
  }
272
365
 
273
366
  export interface ViewerHandle {
274
367
  removeHighlight: (id: string) => void;
275
368
  clearAllHighlights: () => void;
276
369
  applySharedAnnotations: (annotations: Annotation[]) => void;
370
+ /** `HtmlViewer` only: move a pinned draft to the element around it
371
+ * (`'parent'`) or back in (`'child'`), for a host that composes
372
+ * (`onHostDraft`). The markdown `Viewer` does not implement it. */
373
+ stepPin?: (direction: 'parent' | 'child') => void;
374
+ /** `HtmlViewer` only: scroll the pinned draft into the middle of the
375
+ * frame, for a host whose panel covers part of it. */
376
+ revealPin?: () => void;
377
+ /** Markdown `Viewer` only, for a host that composes (`onHostDraft`) on a
378
+ * touch screen: make the page's text selection a draft now (the host's own
379
+ * Comment action), reported through `onHostDraft` as `'selection'`.
380
+ * Returns false when nothing in the document is selected. */
381
+ takeSelection?: () => boolean;
277
382
  }
278
383
 
279
384
  interface CodeBlockToolbarTarget {
@@ -588,6 +693,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
588
693
  onAddGlobalAttachment,
589
694
  onRemoveGlobalAttachment,
590
695
  repoInfo,
696
+ refRepo,
591
697
  stickyActions = true,
592
698
  annotationHeader,
593
699
  gridEnabled = false,
@@ -601,8 +707,11 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
601
707
  maxWidth,
602
708
  onOpenLinkedDoc,
603
709
  onOpenCodeFile,
710
+ resolveLocalLink,
604
711
  linkedDocInfo,
605
712
  imageBaseDir,
713
+ onImageClick: hostImageClick,
714
+ imageLightboxStepping = false,
606
715
  codePathBaseDir,
607
716
  disableCodePathValidation,
608
717
  onRestoreReport,
@@ -621,21 +730,50 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
621
730
  renderQuestionFooter,
622
731
  questionDecisionRecording,
623
732
  questionDecisionScope,
733
+ questionDecisionLines = true,
624
734
  onToggleQuestionDecisionRecording,
625
735
  onOpenQuestionDecision,
736
+ questionDecisionLocked,
737
+ questionReadOnly,
626
738
  questionStatusTag,
739
+ questionStatusLabel,
740
+ renderQuestionCard,
741
+ renderQuestionBelowPrompt,
627
742
  onAskAI,
628
743
  allowImages = true,
629
744
  readOnly = false,
745
+ answerOnly = false,
746
+ textComments = false,
630
747
  vimModeEnabled = false,
631
748
  vimHudEnabled = false,
632
749
  vimHudKeyPanelEnabled = true,
633
750
  onVimHudKeyPanelChange,
751
+ onHostDraft,
634
752
  }, ref) => {
635
- const viewerAnnotationHeader = readOnly ? undefined : annotationHeader;
753
+ // Annotation authoring is off when read-only, and when the host only wants
754
+ // answers (`answerOnly`); question cards and checkboxes follow `readOnly`.
755
+ const authoringOff = readOnly || answerOnly;
756
+ // `textComments`: an answer-only document whose words still take comments
757
+ // (the selection toolbar, comment-only, and its composer); everything else
758
+ // of authoring stays off. Without it this is exactly `!authoringOff`.
759
+ const textCommentsOnly = answerOnly && textComments && !readOnly;
760
+ const selectionCommentsOn = !authoringOff || textCommentsOnly;
761
+ const hostComposes = onHostDraft !== undefined && !authoringOff;
762
+ const viewerAnnotationHeader = authoringOff ? undefined : annotationHeader;
636
763
  const hasViewerAnnotationHeader = viewerAnnotationHeader !== undefined;
764
+ // Where `#123` / `@user` link: `refRepo` when the host gave one, else `repoInfo`.
765
+ const linkRepo = refRepo ?? repoInfo;
637
766
  const [copied, setCopied] = useState(false);
638
- const [lightbox, setLightbox] = useState<{ src: string; alt: string } | null>(null);
767
+ const [lightbox, setLightboxState] = useState<{ src: string; alt: string; images?: LightboxImage[] } | null>(null);
768
+ // 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.
770
+ const setLightbox = (image: { src: string; alt: string }) => {
771
+ if (hostImageClick) {
772
+ hostImageClick(image.src, image.alt);
773
+ return;
774
+ }
775
+ setLightboxState(imageLightboxStepping ? { ...image, images: collectDocumentImages(containerRef.current) } : image);
776
+ };
639
777
  const [locationHash, setLocationHash] = useState(() => window.location.hash);
640
778
  const globalCommentButtonRef = useRef<HTMLButtonElement>(null);
641
779
  // Read through a ref: only the imperative removeHighlight path below needs
@@ -690,9 +828,9 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
690
828
  // annotations that carry `questionAnswer` (matched by question key, so an
691
829
  // answer follows its question across re-parses).
692
830
  const questionIndex = useMemo(() => {
693
- const list = indexQuestionBlocks(blocks);
831
+ const list = indexQuestionBlocks(blocks, { decisionLines: questionDecisionLines });
694
832
  return { byBlock: new Map(list.map((q) => [q.blockId, q])), total: list.length };
695
- }, [blocks]);
833
+ }, [blocks, questionDecisionLines]);
696
834
  // A host that keeps answers itself (`questionAnswers`) replaces the
697
835
  // annotation-derived map; each entry is validated like any other reader.
698
836
  const answersByKey = useMemo(
@@ -722,8 +860,8 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
722
860
  codeBlock: { block: Block; element: HTMLElement };
723
861
  } | null>(null);
724
862
  const hoverTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
725
- const readOnlyRef = useRef(readOnly);
726
- readOnlyRef.current = readOnly;
863
+ const readOnlyRef = useRef(authoringOff);
864
+ readOnlyRef.current = authoringOff;
727
865
  const stickySentinelRef = useRef<HTMLDivElement>(null);
728
866
  const lastAutoScrolledHashRef = useRef<string | null>(null);
729
867
  const [isStuck, setIsStuck] = useState(false);
@@ -755,6 +893,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
755
893
  removeHighlight: hookRemoveHighlight,
756
894
  clearAllHighlights,
757
895
  applyAnnotations,
896
+ takeSelection,
758
897
  } = useAnnotationHighlighter({
759
898
  containerRef,
760
899
  annotations,
@@ -762,7 +901,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
762
901
  onSelectAnnotation,
763
902
  selectedAnnotationId,
764
903
  mode,
765
- enabled: !readOnly,
904
+ enabled: selectionCommentsOn,
766
905
  // Markdown documents drift between the moment a draft/share is written and
767
906
  // the moment it is restored (a plan revision, a re-rendered block, a
768
907
  // renderer change that adds or drops elements — #1509 hoisted an alert's
@@ -774,6 +913,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
774
913
  verifyRestoredContent: true,
775
914
  onRestoreMismatch: handleRestoreMismatch,
776
915
  onRestoreReport,
916
+ onHostDraft: hostComposes ? onHostDraft : undefined,
777
917
  });
778
918
 
779
919
  // Refs for code block annotation path
@@ -945,7 +1085,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
945
1085
  });
946
1086
  }, [applyCodeBlockAnnotation, blocks]);
947
1087
 
948
- const vimModeActive = vimModeEnabled && !readOnly;
1088
+ const vimModeActive = vimModeEnabled && !authoringOff;
949
1089
  const keyboardCodeBlockToolbarOpen = codeBlockToolbar?.activation === 'keyboard';
950
1090
  const vimBlocked = !!toolbarState
951
1091
  || !!hookCommentPopover
@@ -974,12 +1114,16 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
974
1114
  onHandledCommand: handleVimCommand,
975
1115
  });
976
1116
 
1117
+ // With the composer handed to the host, a pinpoint tap opens it at once.
1118
+ const hostPinpointRange = useCallback((range: Range) => highlightRange(range, 'comment'), [highlightRange]);
1119
+ const noHostCodeBlockClick = useCallback(() => {}, []);
977
1120
  const { hoverTarget, clearHover: clearPinpointHover } = usePinpoint({
978
1121
  containerRef,
979
1122
  inputMethod,
980
- enabled: !readOnly && !toolbarState && !hookCommentPopover && !viewerCommentPopover && !hookQuickLabelPicker && !codeBlockQuickLabelPicker && !(isPlanDiffActive ?? false) && !vim.helpOpen,
981
- onSelectRange: highlightRange,
982
- onCodeBlockClick: handlePinpointCodeBlockClick,
1123
+ // A host composer's draft stays open while the host composes; the next tap replaces it.
1124
+ enabled: !authoringOff && (hostComposes || (!toolbarState && !hookCommentPopover)) && !viewerCommentPopover && !hookQuickLabelPicker && !codeBlockQuickLabelPicker && !(isPlanDiffActive ?? false) && !vim.helpOpen,
1125
+ onSelectRange: hostComposes ? hostPinpointRange : highlightRange,
1126
+ onCodeBlockClick: hostComposes ? noHostCodeBlockClick : handlePinpointCodeBlockClick,
983
1127
  });
984
1128
  clearPinpointHoverRef.current = clearPinpointHover;
985
1129
  const vimOwnsHudTarget = vimHudEnabled
@@ -998,7 +1142,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
998
1142
  : (inputMethod === 'pinpoint' ? hoverTarget : null) ?? legacyVimTarget;
999
1143
 
1000
1144
  useEffect(() => {
1001
- if (!readOnly) return;
1145
+ if (!authoringOff) return;
1002
1146
  if (hoverTimeoutRef.current) {
1003
1147
  clearTimeout(hoverTimeoutRef.current);
1004
1148
  hoverTimeoutRef.current = null;
@@ -1007,7 +1151,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1007
1151
  setIsCodeBlockToolbarExiting(false);
1008
1152
  setViewerCommentPopover(null);
1009
1153
  setCodeBlockQuickLabelPicker(null);
1010
- }, [readOnly]);
1154
+ }, [authoringOff]);
1011
1155
 
1012
1156
  useEffect(() => {
1013
1157
  if (!vimOwnsDocumentNavigation) return;
@@ -1153,7 +1297,8 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1153
1297
  },
1154
1298
  clearAllHighlights,
1155
1299
  applySharedAnnotations: applyAnnotations,
1156
- }), [hookRemoveHighlight, clearAllHighlights, applyAnnotations, blocks]);
1300
+ takeSelection,
1301
+ }), [hookRemoveHighlight, clearAllHighlights, applyAnnotations, blocks, takeSelection]);
1157
1302
 
1158
1303
  // --- Viewer-specific: code block annotation ---
1159
1304
 
@@ -1266,7 +1411,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1266
1411
  </button>
1267
1412
  )}
1268
1413
 
1269
- {!readOnly && onAddGlobalAttachment && onRemoveGlobalAttachment && (
1414
+ {!authoringOff && onAddGlobalAttachment && onRemoveGlobalAttachment && (
1270
1415
  <AttachmentsButton
1271
1416
  images={globalAttachments}
1272
1417
  onAdd={onAddGlobalAttachment}
@@ -1276,7 +1421,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1276
1421
  />
1277
1422
  )}
1278
1423
 
1279
- {!readOnly && (
1424
+ {!authoringOff && (
1280
1425
  <button
1281
1426
  ref={globalCommentButtonRef}
1282
1427
  onClick={() => {
@@ -1348,6 +1493,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1348
1493
 
1349
1494
  return (
1350
1495
  <CodePathValidationContext.Provider value={codePathValidation}>
1496
+ <LocalLinkContext.Provider value={resolveLocalLink ?? null}>
1351
1497
  <DiagramAnchorClaimsContext.Provider value={diagramClaims}>
1352
1498
  <div className="relative z-50 w-full" style={maxWidth === null ? undefined : { maxWidth: maxWidth ?? 832 }}>
1353
1499
  {taterMode && <TaterSpriteSitting />}
@@ -1419,7 +1565,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1419
1565
  <>
1420
1566
  {badgeClearance > 0 && <div data-print-hide style={{ height: badgeClearance }} aria-hidden="true" />}
1421
1567
  {stickyActions && <div ref={stickySentinelRef} className="h-0 w-0 float-right" aria-hidden="true" />}
1422
- {stickyActions ? (
1568
+ {answerOnly || hostComposes ? null : stickyActions ? (
1423
1569
  <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
1570
  {documentActions}
1425
1571
  </StickyActionsLane>
@@ -1449,8 +1595,8 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1449
1595
  onOpenCodeFile={onOpenCodeFile}
1450
1596
  onToggleCheckbox={readOnly ? undefined : onToggleCheckbox}
1451
1597
  checkboxOverrides={checkboxOverrides}
1452
- githubRepo={repoInfo?.display}
1453
- repoHost={repoInfo?.host}
1598
+ githubRepo={linkRepo?.display}
1599
+ repoHost={linkRepo?.host}
1454
1600
  headingAnchorId={headingSlugMap.get(block.id)}
1455
1601
  onNavigateAnchor={scrollToAnchor}
1456
1602
  />
@@ -1465,10 +1611,10 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1465
1611
  annotations={annotations}
1466
1612
  selectedAnnotationId={selectedAnnotationId}
1467
1613
  onSelectAnnotation={onSelectAnnotation}
1468
- onAddAnnotation={readOnly ? undefined : onAddAnnotation}
1469
- readOnly={readOnly}
1614
+ onAddAnnotation={authoringOff ? undefined : onAddAnnotation}
1615
+ readOnly={authoringOff}
1470
1616
  onRestoreReport={onRestoreReport}
1471
- onAskAI={readOnly ? undefined : onAskAI}
1617
+ onAskAI={authoringOff ? undefined : onAskAI}
1472
1618
  askAISourcePath={linkedDocInfo?.filepath ?? sourceInfo}
1473
1619
  />
1474
1620
  </Suspense>
@@ -1479,10 +1625,10 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1479
1625
  annotations={annotations}
1480
1626
  selectedAnnotationId={selectedAnnotationId}
1481
1627
  onSelectAnnotation={onSelectAnnotation}
1482
- onAddAnnotation={readOnly ? undefined : onAddAnnotation}
1483
- readOnly={readOnly}
1628
+ onAddAnnotation={authoringOff ? undefined : onAddAnnotation}
1629
+ readOnly={authoringOff}
1484
1630
  onRestoreReport={onRestoreReport}
1485
- onAskAI={readOnly ? undefined : onAskAI}
1631
+ onAskAI={authoringOff ? undefined : onAskAI}
1486
1632
  askAISourcePath={linkedDocInfo?.filepath ?? sourceInfo}
1487
1633
  />
1488
1634
  </Suspense>
@@ -1494,8 +1640,8 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1494
1640
  onImageClick={(src, alt) => setLightbox({ src, alt })}
1495
1641
  onOpenLinkedDoc={onOpenLinkedDoc}
1496
1642
  onOpenCodeFile={onOpenCodeFile}
1497
- githubRepo={repoInfo?.display}
1498
- repoHost={repoInfo?.host}
1643
+ githubRepo={linkRepo?.display}
1644
+ repoHost={linkRepo?.host}
1499
1645
  onNavigateAnchor={scrollToAnchor}
1500
1646
  onHover={(element) => {
1501
1647
  if (tableHoverTimeoutRef.current) {
@@ -1503,7 +1649,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1503
1649
  tableHoverTimeoutRef.current = null;
1504
1650
  }
1505
1651
  setIsTableToolbarExiting(false);
1506
- if (!toolbarState) {
1652
+ if (!toolbarState && !hostComposes) {
1507
1653
  setHoveredTable({ block: group.block, element });
1508
1654
  }
1509
1655
  }}
@@ -1521,7 +1667,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1521
1667
  <CodeBlock
1522
1668
  key={group.block.id}
1523
1669
  block={group.block}
1524
- onHover={readOnly || inputMethod === 'pinpoint' ? undefined : (element) => {
1670
+ onHover={authoringOff || hostComposes || inputMethod === 'pinpoint' ? undefined : (element) => {
1525
1671
  // Clear any pending leave timeout
1526
1672
  if (hoverTimeoutRef.current) {
1527
1673
  clearTimeout(hoverTimeoutRef.current);
@@ -1542,7 +1688,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1542
1688
  });
1543
1689
  }
1544
1690
  }}
1545
- onLeave={readOnly || inputMethod === 'pinpoint' ? undefined : () => {
1691
+ onLeave={authoringOff || hostComposes || inputMethod === 'pinpoint' ? undefined : () => {
1546
1692
  if (keyboardCodeBlockToolbarOpen) return;
1547
1693
  // Delay then start exit animation
1548
1694
  hoverTimeoutRef.current = setTimeout(() => {
@@ -1555,7 +1701,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1555
1701
  }, 100);
1556
1702
  }}
1557
1703
  isHovered={
1558
- !readOnly
1704
+ !authoringOff
1559
1705
  && inputMethod !== 'pinpoint'
1560
1706
  && !vimOwnsDocumentNavigation
1561
1707
  && codeBlockToolbar?.block.id === group.block.id
@@ -1564,7 +1710,17 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1564
1710
  ) : (
1565
1711
  (() => {
1566
1712
  const question = questionIndex.byBlock.get(group.block.id);
1567
- return (
1713
+ const hostCard = question && renderQuestionCard
1714
+ ? renderQuestionCard(question, answersByKey.get(question.question.key))
1715
+ : undefined;
1716
+ if (hostCard !== undefined) {
1717
+ return (
1718
+ <div key={group.block.id} data-block-id={group.block.id} data-block-type="directive" className={textCommentsOnly ? 'annotation-exclude' : undefined}>
1719
+ {hostCard}
1720
+ </div>
1721
+ );
1722
+ }
1723
+ const rendered = (
1568
1724
  <BlockRenderer
1569
1725
  imageBaseDir={imageBaseDir}
1570
1726
  onImageClick={(src, alt) => setLightbox({ src, alt })}
@@ -1575,33 +1731,47 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1575
1731
  onNavigateAnchor={scrollToAnchor}
1576
1732
  onToggleCheckbox={readOnly ? undefined : onToggleCheckbox}
1577
1733
  checkboxOverrides={checkboxOverrides}
1578
- githubRepo={repoInfo?.display}
1579
- repoHost={repoInfo?.host}
1734
+ githubRepo={linkRepo?.display}
1735
+ repoHost={linkRepo?.host}
1580
1736
  headingAnchorId={headingSlugMap.get(group.block.id)}
1581
1737
  question={question}
1582
1738
  questionTotal={questionIndex.total}
1583
1739
  questionAnswer={question ? answersByKey.get(question.question.key) : undefined}
1584
- onAnswerQuestion={readOnly ? undefined : onAnswerQuestion}
1585
- onSaveQuestionAnswer={readOnly ? undefined : onSaveQuestionAnswer}
1740
+ onAnswerQuestion={readOnly || (question && questionReadOnly?.(question)) ? undefined : onAnswerQuestion}
1741
+ onSaveQuestionAnswer={readOnly || (question && questionReadOnly?.(question)) ? undefined : onSaveQuestionAnswer}
1586
1742
  saveQuestionAnswerLabel={saveQuestionAnswerLabel}
1587
1743
  renderQuestionFooter={renderQuestionFooter}
1588
1744
  questionDecisionRecording={question ? questionDecisionRecording?.(question) : undefined}
1589
1745
  questionDecisionScope={questionDecisionScope}
1590
- onToggleQuestionDecisionRecording={readOnly ? undefined : onToggleQuestionDecisionRecording}
1591
- onOpenQuestionDecision={readOnly ? undefined : onOpenQuestionDecision}
1746
+ onToggleQuestionDecisionRecording={readOnly || (question && (questionDecisionLocked?.(question) || questionReadOnly?.(question))) ? undefined : onToggleQuestionDecisionRecording}
1747
+ onOpenQuestionDecision={readOnly || (question && (questionDecisionLocked?.(question) || questionReadOnly?.(question))) ? undefined : onOpenQuestionDecision}
1592
1748
  questionStatusTag={questionStatusTag}
1749
+ questionStatusLabel={questionStatusLabel}
1750
+ renderQuestionBelowPrompt={renderQuestionBelowPrompt}
1593
1751
  />
1594
1752
  );
1753
+ // `textComments`: a question card's words are not commented on
1754
+ // (its own controls already are excluded); a box-less wrapper
1755
+ // keeps its layout.
1756
+ if (question && textCommentsOnly) {
1757
+ return (
1758
+ <div key={group.block.id} className="annotation-exclude" style={{ display: 'contents' }}>
1759
+ {rendered}
1760
+ </div>
1761
+ );
1762
+ }
1763
+ return rendered;
1595
1764
  })()
1596
1765
  )
1597
1766
  )}
1598
1767
 
1599
1768
  {/* Text selection toolbar */}
1600
- {!readOnly && toolbarState && (
1769
+ {selectionCommentsOn && !hostComposes && toolbarState && (
1601
1770
  <ToolbarErrorBoundary>
1602
1771
  <AnnotationToolbar
1603
1772
  element={toolbarState.element}
1604
1773
  positionMode="center-above"
1774
+ commentOnly={textCommentsOnly || undefined}
1605
1775
  onAnnotate={handleAnnotate}
1606
1776
  onClose={handleToolbarClose}
1607
1777
  onRequestComment={handleRequestComment}
@@ -1651,7 +1821,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1651
1821
  )}
1652
1822
 
1653
1823
  {/* Code block hover toolbar */}
1654
- {!readOnly
1824
+ {!authoringOff
1655
1825
  && codeBlockToolbar
1656
1826
  && !toolbarState
1657
1827
  && !(vimOwnsDocumentNavigation && codeBlockToolbar.activation === 'pointer')
@@ -1701,8 +1871,8 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1701
1871
  onImageClick={(src, alt) => setLightbox({ src, alt })}
1702
1872
  onOpenLinkedDoc={onOpenLinkedDoc}
1703
1873
  onOpenCodeFile={onOpenCodeFile}
1704
- githubRepo={repoInfo?.display}
1705
- repoHost={repoInfo?.host}
1874
+ githubRepo={linkRepo?.display}
1875
+ repoHost={linkRepo?.host}
1706
1876
  onNavigateAnchor={scrollToAnchor}
1707
1877
  />
1708
1878
  )}
@@ -1736,7 +1906,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1736
1906
  )}
1737
1907
 
1738
1908
  {/* Comment popover — hook handles text selection, Viewer handles global + code block */}
1739
- {!readOnly && hookCommentPopover && (
1909
+ {selectionCommentsOn && !hostComposes && hookCommentPopover && (
1740
1910
  <CommentPopover
1741
1911
  anchorEl={hookCommentPopover.anchorEl}
1742
1912
  contextText={hookCommentPopover.contextText}
@@ -1758,7 +1928,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1758
1928
  }}
1759
1929
  />
1760
1930
  )}
1761
- {!readOnly && viewerCommentPopover && (
1931
+ {!authoringOff && viewerCommentPopover && (
1762
1932
  <CommentPopover
1763
1933
  anchorEl={viewerCommentPopover.anchorEl}
1764
1934
  contextText={viewerCommentPopover.contextText}
@@ -1788,7 +1958,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1788
1958
  )}
1789
1959
 
1790
1960
  {/* Quick Label floating picker — hook handles text selection, Viewer handles code blocks */}
1791
- {!readOnly && hookQuickLabelPicker && (
1961
+ {selectionCommentsOn && !hostComposes && hookQuickLabelPicker && (
1792
1962
  <FloatingQuickLabelPicker
1793
1963
  anchorEl={hookQuickLabelPicker.anchorEl}
1794
1964
  cursorHint={hookQuickLabelPicker.cursorHint}
@@ -1796,7 +1966,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1796
1966
  onDismiss={hookQuickLabelPickerDismiss}
1797
1967
  />
1798
1968
  )}
1799
- {!readOnly && codeBlockQuickLabelPicker && (
1969
+ {!authoringOff && codeBlockQuickLabelPicker && (
1800
1970
  <FloatingQuickLabelPicker
1801
1971
  anchorEl={codeBlockQuickLabelPicker.anchorEl}
1802
1972
  onSelect={(label: QuickLabel) => {
@@ -1820,11 +1990,12 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1820
1990
 
1821
1991
  {/* Image lightbox */}
1822
1992
  {lightbox && createPortal(
1823
- <ImageLightbox src={lightbox.src} alt={lightbox.alt} onClose={() => setLightbox(null)} />,
1993
+ <ImageLightbox src={lightbox.src} alt={lightbox.alt} images={lightbox.images} onClose={() => setLightboxState(null)} />,
1824
1994
  document.body
1825
1995
  )}
1826
1996
  </div>
1827
1997
  </DiagramAnchorClaimsContext.Provider>
1998
+ </LocalLinkContext.Provider>
1828
1999
  </CodePathValidationContext.Provider>
1829
2000
  );
1830
2001
  });
@@ -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
  };