@plannotator/ui 0.52.0 → 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 +317 -29
  2. package/README.md +35 -3
  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 +26 -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 +214 -55
  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 +253 -104
  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. */
@@ -218,21 +247,65 @@ export interface ViewerProps {
218
247
  * decision"), given the indexed question and its saved answer. Rendered in
219
248
  * read-only cards too; return null for none. */
220
249
  renderQuestionFooter?: (question: IndexedQuestion, answer: QuestionAnswer | undefined) => React.ReactNode;
221
- /** Whether answering a `Decision: when answered` question records a
222
- * decision, per question: return false to draw its "Records a decision"
223
- * tag dimmed with a dotted outline and hide the "Answering this records a
224
- * decision" row; true or undefined keeps today's card. Not consulted for a
225
- * question whose decision is already recorded. */
250
+ /** Whether answering a question records a decision, per question. On a
251
+ * `Decision: when answered` question: false draws its "Records a
252
+ * decision" tag dimmed with a dotted outline and hides the "Answering this
253
+ * records a decision" row; true or undefined keeps today's card. On a
254
+ * question with no decision line it is ignored unless
255
+ * `questionDecisionScope` is `'any'`; then a boolean adds the tag in that
256
+ * state (true also shows the row) and undefined adds nothing. Has no
257
+ * effect on a question whose decision is already recorded. */
226
258
  questionDecisionRecording?: (question: IndexedQuestion) => boolean | undefined;
227
- /** Makes a `Decision: when answered` question's "Records a decision" tag a
228
- * toggle button (`aria-pressed`); a click calls this with the question's
229
- * key and the next state, which the host stores and returns from
259
+ /** Which questions can carry the "Records a decision" tag (ui 0.52.1).
260
+ * `'when-answered'` (default, the 0.52.0 behaviour): only questions with
261
+ * `Decision: when answered`. `'any'`: also a question with no decision
262
+ * line, when `questionDecisionRecording` returns a boolean for it. */
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;
269
+ /** Makes a question's "Records a decision" tag a toggle button
270
+ * (`aria-pressed`); a click calls this with the question's key and the
271
+ * next state, which the host stores and returns from
230
272
  * `questionDecisionRecording`. Ignored when `readOnly`. */
231
273
  onToggleQuestionDecisionRecording?: (key: string, next: boolean) => void;
274
+ /** Opens the host's decision card (ui 0.52.1): the tag splits into a
275
+ * diamond switch (the toggle above) and the words "Records a decision" as
276
+ * a button that calls this with the question's key and the words' element
277
+ * (anchor your popover to it). Offered on and off. Ignored when
278
+ * `readOnly`. */
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;
232
291
  /** `'none'` hides each question card's own status tag (Open / Answered /
233
292
  * Settled / Skipped) so the host can draw its own; the decision tags stay.
234
293
  * Default `'card'`. */
235
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;
236
309
  onAskAI?: CommentAskAIHandler;
237
310
  /** Whether comment popovers offer image attachments. Hosts without an
238
311
  * uploadTransport pass false so the attach affordance never dead-ends.
@@ -243,6 +316,16 @@ export interface ViewerProps {
243
316
  * comment, attachments, checkbox toggles). Existing annotations still
244
317
  * render and remain selectable. Default false — today's behavior. */
245
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;
246
329
  /** Opt-in Vim-style keyboard selection. Default false for compatibility. */
247
330
  vimModeEnabled?: boolean;
248
331
  /** Replace the compact Vim badge with the live video-style key HUD. */
@@ -254,12 +337,36 @@ export interface ViewerProps {
254
337
  /** Fires once per highlight-restore pass with what it tried and what it could
255
338
  * not anchor, so a host can mark the leftovers in its annotation panel. */
256
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;
257
352
  }
258
353
 
259
354
  export interface ViewerHandle {
260
355
  removeHighlight: (id: string) => void;
261
356
  clearAllHighlights: () => void;
262
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;
263
370
  }
264
371
 
265
372
  interface CodeBlockToolbarTarget {
@@ -574,6 +681,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
574
681
  onAddGlobalAttachment,
575
682
  onRemoveGlobalAttachment,
576
683
  repoInfo,
684
+ refRepo,
577
685
  stickyActions = true,
578
686
  annotationHeader,
579
687
  gridEnabled = false,
@@ -587,8 +695,11 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
587
695
  maxWidth,
588
696
  onOpenLinkedDoc,
589
697
  onOpenCodeFile,
698
+ resolveLocalLink,
590
699
  linkedDocInfo,
591
700
  imageBaseDir,
701
+ onImageClick: hostImageClick,
702
+ imageLightboxStepping = false,
592
703
  codePathBaseDir,
593
704
  disableCodePathValidation,
594
705
  onRestoreReport,
@@ -606,20 +717,45 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
606
717
  saveQuestionAnswerLabel,
607
718
  renderQuestionFooter,
608
719
  questionDecisionRecording,
720
+ questionDecisionScope,
721
+ questionDecisionLines = true,
609
722
  onToggleQuestionDecisionRecording,
723
+ onOpenQuestionDecision,
724
+ questionDecisionLocked,
725
+ questionReadOnly,
610
726
  questionStatusTag,
727
+ questionStatusLabel,
728
+ renderQuestionCard,
729
+ renderQuestionBelowPrompt,
611
730
  onAskAI,
612
731
  allowImages = true,
613
732
  readOnly = false,
733
+ answerOnly = false,
614
734
  vimModeEnabled = false,
615
735
  vimHudEnabled = false,
616
736
  vimHudKeyPanelEnabled = true,
617
737
  onVimHudKeyPanelChange,
738
+ onHostDraft,
618
739
  }, ref) => {
619
- 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;
620
745
  const hasViewerAnnotationHeader = viewerAnnotationHeader !== undefined;
746
+ // Where `#123` / `@user` link: `refRepo` when the host gave one, else `repoInfo`.
747
+ const linkRepo = refRepo ?? repoInfo;
621
748
  const [copied, setCopied] = useState(false);
622
- 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
+ };
623
759
  const [locationHash, setLocationHash] = useState(() => window.location.hash);
624
760
  const globalCommentButtonRef = useRef<HTMLButtonElement>(null);
625
761
  // Read through a ref: only the imperative removeHighlight path below needs
@@ -674,9 +810,9 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
674
810
  // annotations that carry `questionAnswer` (matched by question key, so an
675
811
  // answer follows its question across re-parses).
676
812
  const questionIndex = useMemo(() => {
677
- const list = indexQuestionBlocks(blocks);
813
+ const list = indexQuestionBlocks(blocks, { decisionLines: questionDecisionLines });
678
814
  return { byBlock: new Map(list.map((q) => [q.blockId, q])), total: list.length };
679
- }, [blocks]);
815
+ }, [blocks, questionDecisionLines]);
680
816
  // A host that keeps answers itself (`questionAnswers`) replaces the
681
817
  // annotation-derived map; each entry is validated like any other reader.
682
818
  const answersByKey = useMemo(
@@ -706,8 +842,8 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
706
842
  codeBlock: { block: Block; element: HTMLElement };
707
843
  } | null>(null);
708
844
  const hoverTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
709
- const readOnlyRef = useRef(readOnly);
710
- readOnlyRef.current = readOnly;
845
+ const readOnlyRef = useRef(authoringOff);
846
+ readOnlyRef.current = authoringOff;
711
847
  const stickySentinelRef = useRef<HTMLDivElement>(null);
712
848
  const lastAutoScrolledHashRef = useRef<string | null>(null);
713
849
  const [isStuck, setIsStuck] = useState(false);
@@ -739,6 +875,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
739
875
  removeHighlight: hookRemoveHighlight,
740
876
  clearAllHighlights,
741
877
  applyAnnotations,
878
+ takeSelection,
742
879
  } = useAnnotationHighlighter({
743
880
  containerRef,
744
881
  annotations,
@@ -746,7 +883,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
746
883
  onSelectAnnotation,
747
884
  selectedAnnotationId,
748
885
  mode,
749
- enabled: !readOnly,
886
+ enabled: !authoringOff,
750
887
  // Markdown documents drift between the moment a draft/share is written and
751
888
  // the moment it is restored (a plan revision, a re-rendered block, a
752
889
  // renderer change that adds or drops elements — #1509 hoisted an alert's
@@ -758,6 +895,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
758
895
  verifyRestoredContent: true,
759
896
  onRestoreMismatch: handleRestoreMismatch,
760
897
  onRestoreReport,
898
+ onHostDraft: hostComposes ? onHostDraft : undefined,
761
899
  });
762
900
 
763
901
  // Refs for code block annotation path
@@ -929,7 +1067,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
929
1067
  });
930
1068
  }, [applyCodeBlockAnnotation, blocks]);
931
1069
 
932
- const vimModeActive = vimModeEnabled && !readOnly;
1070
+ const vimModeActive = vimModeEnabled && !authoringOff;
933
1071
  const keyboardCodeBlockToolbarOpen = codeBlockToolbar?.activation === 'keyboard';
934
1072
  const vimBlocked = !!toolbarState
935
1073
  || !!hookCommentPopover
@@ -958,12 +1096,16 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
958
1096
  onHandledCommand: handleVimCommand,
959
1097
  });
960
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(() => {}, []);
961
1102
  const { hoverTarget, clearHover: clearPinpointHover } = usePinpoint({
962
1103
  containerRef,
963
1104
  inputMethod,
964
- enabled: !readOnly && !toolbarState && !hookCommentPopover && !viewerCommentPopover && !hookQuickLabelPicker && !codeBlockQuickLabelPicker && !(isPlanDiffActive ?? false) && !vim.helpOpen,
965
- onSelectRange: highlightRange,
966
- 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,
967
1109
  });
968
1110
  clearPinpointHoverRef.current = clearPinpointHover;
969
1111
  const vimOwnsHudTarget = vimHudEnabled
@@ -982,7 +1124,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
982
1124
  : (inputMethod === 'pinpoint' ? hoverTarget : null) ?? legacyVimTarget;
983
1125
 
984
1126
  useEffect(() => {
985
- if (!readOnly) return;
1127
+ if (!authoringOff) return;
986
1128
  if (hoverTimeoutRef.current) {
987
1129
  clearTimeout(hoverTimeoutRef.current);
988
1130
  hoverTimeoutRef.current = null;
@@ -991,7 +1133,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
991
1133
  setIsCodeBlockToolbarExiting(false);
992
1134
  setViewerCommentPopover(null);
993
1135
  setCodeBlockQuickLabelPicker(null);
994
- }, [readOnly]);
1136
+ }, [authoringOff]);
995
1137
 
996
1138
  useEffect(() => {
997
1139
  if (!vimOwnsDocumentNavigation) return;
@@ -1137,7 +1279,8 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1137
1279
  },
1138
1280
  clearAllHighlights,
1139
1281
  applySharedAnnotations: applyAnnotations,
1140
- }), [hookRemoveHighlight, clearAllHighlights, applyAnnotations, blocks]);
1282
+ takeSelection,
1283
+ }), [hookRemoveHighlight, clearAllHighlights, applyAnnotations, blocks, takeSelection]);
1141
1284
 
1142
1285
  // --- Viewer-specific: code block annotation ---
1143
1286
 
@@ -1250,7 +1393,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1250
1393
  </button>
1251
1394
  )}
1252
1395
 
1253
- {!readOnly && onAddGlobalAttachment && onRemoveGlobalAttachment && (
1396
+ {!authoringOff && onAddGlobalAttachment && onRemoveGlobalAttachment && (
1254
1397
  <AttachmentsButton
1255
1398
  images={globalAttachments}
1256
1399
  onAdd={onAddGlobalAttachment}
@@ -1260,7 +1403,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1260
1403
  />
1261
1404
  )}
1262
1405
 
1263
- {!readOnly && (
1406
+ {!authoringOff && (
1264
1407
  <button
1265
1408
  ref={globalCommentButtonRef}
1266
1409
  onClick={() => {
@@ -1332,6 +1475,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1332
1475
 
1333
1476
  return (
1334
1477
  <CodePathValidationContext.Provider value={codePathValidation}>
1478
+ <LocalLinkContext.Provider value={resolveLocalLink ?? null}>
1335
1479
  <DiagramAnchorClaimsContext.Provider value={diagramClaims}>
1336
1480
  <div className="relative z-50 w-full" style={maxWidth === null ? undefined : { maxWidth: maxWidth ?? 832 }}>
1337
1481
  {taterMode && <TaterSpriteSitting />}
@@ -1403,7 +1547,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1403
1547
  <>
1404
1548
  {badgeClearance > 0 && <div data-print-hide style={{ height: badgeClearance }} aria-hidden="true" />}
1405
1549
  {stickyActions && <div ref={stickySentinelRef} className="h-0 w-0 float-right" aria-hidden="true" />}
1406
- {stickyActions ? (
1550
+ {answerOnly || hostComposes ? null : stickyActions ? (
1407
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`}>
1408
1552
  {documentActions}
1409
1553
  </StickyActionsLane>
@@ -1433,8 +1577,8 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1433
1577
  onOpenCodeFile={onOpenCodeFile}
1434
1578
  onToggleCheckbox={readOnly ? undefined : onToggleCheckbox}
1435
1579
  checkboxOverrides={checkboxOverrides}
1436
- githubRepo={repoInfo?.display}
1437
- repoHost={repoInfo?.host}
1580
+ githubRepo={linkRepo?.display}
1581
+ repoHost={linkRepo?.host}
1438
1582
  headingAnchorId={headingSlugMap.get(block.id)}
1439
1583
  onNavigateAnchor={scrollToAnchor}
1440
1584
  />
@@ -1449,10 +1593,10 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1449
1593
  annotations={annotations}
1450
1594
  selectedAnnotationId={selectedAnnotationId}
1451
1595
  onSelectAnnotation={onSelectAnnotation}
1452
- onAddAnnotation={readOnly ? undefined : onAddAnnotation}
1453
- readOnly={readOnly}
1596
+ onAddAnnotation={authoringOff ? undefined : onAddAnnotation}
1597
+ readOnly={authoringOff}
1454
1598
  onRestoreReport={onRestoreReport}
1455
- onAskAI={readOnly ? undefined : onAskAI}
1599
+ onAskAI={authoringOff ? undefined : onAskAI}
1456
1600
  askAISourcePath={linkedDocInfo?.filepath ?? sourceInfo}
1457
1601
  />
1458
1602
  </Suspense>
@@ -1463,10 +1607,10 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1463
1607
  annotations={annotations}
1464
1608
  selectedAnnotationId={selectedAnnotationId}
1465
1609
  onSelectAnnotation={onSelectAnnotation}
1466
- onAddAnnotation={readOnly ? undefined : onAddAnnotation}
1467
- readOnly={readOnly}
1610
+ onAddAnnotation={authoringOff ? undefined : onAddAnnotation}
1611
+ readOnly={authoringOff}
1468
1612
  onRestoreReport={onRestoreReport}
1469
- onAskAI={readOnly ? undefined : onAskAI}
1613
+ onAskAI={authoringOff ? undefined : onAskAI}
1470
1614
  askAISourcePath={linkedDocInfo?.filepath ?? sourceInfo}
1471
1615
  />
1472
1616
  </Suspense>
@@ -1478,8 +1622,8 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1478
1622
  onImageClick={(src, alt) => setLightbox({ src, alt })}
1479
1623
  onOpenLinkedDoc={onOpenLinkedDoc}
1480
1624
  onOpenCodeFile={onOpenCodeFile}
1481
- githubRepo={repoInfo?.display}
1482
- repoHost={repoInfo?.host}
1625
+ githubRepo={linkRepo?.display}
1626
+ repoHost={linkRepo?.host}
1483
1627
  onNavigateAnchor={scrollToAnchor}
1484
1628
  onHover={(element) => {
1485
1629
  if (tableHoverTimeoutRef.current) {
@@ -1487,7 +1631,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1487
1631
  tableHoverTimeoutRef.current = null;
1488
1632
  }
1489
1633
  setIsTableToolbarExiting(false);
1490
- if (!toolbarState) {
1634
+ if (!toolbarState && !hostComposes) {
1491
1635
  setHoveredTable({ block: group.block, element });
1492
1636
  }
1493
1637
  }}
@@ -1505,7 +1649,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1505
1649
  <CodeBlock
1506
1650
  key={group.block.id}
1507
1651
  block={group.block}
1508
- onHover={readOnly || inputMethod === 'pinpoint' ? undefined : (element) => {
1652
+ onHover={authoringOff || hostComposes || inputMethod === 'pinpoint' ? undefined : (element) => {
1509
1653
  // Clear any pending leave timeout
1510
1654
  if (hoverTimeoutRef.current) {
1511
1655
  clearTimeout(hoverTimeoutRef.current);
@@ -1526,7 +1670,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1526
1670
  });
1527
1671
  }
1528
1672
  }}
1529
- onLeave={readOnly || inputMethod === 'pinpoint' ? undefined : () => {
1673
+ onLeave={authoringOff || hostComposes || inputMethod === 'pinpoint' ? undefined : () => {
1530
1674
  if (keyboardCodeBlockToolbarOpen) return;
1531
1675
  // Delay then start exit animation
1532
1676
  hoverTimeoutRef.current = setTimeout(() => {
@@ -1539,7 +1683,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1539
1683
  }, 100);
1540
1684
  }}
1541
1685
  isHovered={
1542
- !readOnly
1686
+ !authoringOff
1543
1687
  && inputMethod !== 'pinpoint'
1544
1688
  && !vimOwnsDocumentNavigation
1545
1689
  && codeBlockToolbar?.block.id === group.block.id
@@ -1548,6 +1692,16 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1548
1692
  ) : (
1549
1693
  (() => {
1550
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
+ }
1551
1705
  return (
1552
1706
  <BlockRenderer
1553
1707
  imageBaseDir={imageBaseDir}
@@ -1559,19 +1713,23 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1559
1713
  onNavigateAnchor={scrollToAnchor}
1560
1714
  onToggleCheckbox={readOnly ? undefined : onToggleCheckbox}
1561
1715
  checkboxOverrides={checkboxOverrides}
1562
- githubRepo={repoInfo?.display}
1563
- repoHost={repoInfo?.host}
1716
+ githubRepo={linkRepo?.display}
1717
+ repoHost={linkRepo?.host}
1564
1718
  headingAnchorId={headingSlugMap.get(group.block.id)}
1565
1719
  question={question}
1566
1720
  questionTotal={questionIndex.total}
1567
1721
  questionAnswer={question ? answersByKey.get(question.question.key) : undefined}
1568
- onAnswerQuestion={readOnly ? undefined : onAnswerQuestion}
1569
- onSaveQuestionAnswer={readOnly ? undefined : onSaveQuestionAnswer}
1722
+ onAnswerQuestion={readOnly || (question && questionReadOnly?.(question)) ? undefined : onAnswerQuestion}
1723
+ onSaveQuestionAnswer={readOnly || (question && questionReadOnly?.(question)) ? undefined : onSaveQuestionAnswer}
1570
1724
  saveQuestionAnswerLabel={saveQuestionAnswerLabel}
1571
1725
  renderQuestionFooter={renderQuestionFooter}
1572
1726
  questionDecisionRecording={question ? questionDecisionRecording?.(question) : undefined}
1573
- onToggleQuestionDecisionRecording={readOnly ? undefined : onToggleQuestionDecisionRecording}
1727
+ questionDecisionScope={questionDecisionScope}
1728
+ onToggleQuestionDecisionRecording={readOnly || (question && (questionDecisionLocked?.(question) || questionReadOnly?.(question))) ? undefined : onToggleQuestionDecisionRecording}
1729
+ onOpenQuestionDecision={readOnly || (question && (questionDecisionLocked?.(question) || questionReadOnly?.(question))) ? undefined : onOpenQuestionDecision}
1574
1730
  questionStatusTag={questionStatusTag}
1731
+ questionStatusLabel={questionStatusLabel}
1732
+ renderQuestionBelowPrompt={renderQuestionBelowPrompt}
1575
1733
  />
1576
1734
  );
1577
1735
  })()
@@ -1579,7 +1737,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1579
1737
  )}
1580
1738
 
1581
1739
  {/* Text selection toolbar */}
1582
- {!readOnly && toolbarState && (
1740
+ {!authoringOff && !hostComposes && toolbarState && (
1583
1741
  <ToolbarErrorBoundary>
1584
1742
  <AnnotationToolbar
1585
1743
  element={toolbarState.element}
@@ -1633,7 +1791,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1633
1791
  )}
1634
1792
 
1635
1793
  {/* Code block hover toolbar */}
1636
- {!readOnly
1794
+ {!authoringOff
1637
1795
  && codeBlockToolbar
1638
1796
  && !toolbarState
1639
1797
  && !(vimOwnsDocumentNavigation && codeBlockToolbar.activation === 'pointer')
@@ -1683,8 +1841,8 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1683
1841
  onImageClick={(src, alt) => setLightbox({ src, alt })}
1684
1842
  onOpenLinkedDoc={onOpenLinkedDoc}
1685
1843
  onOpenCodeFile={onOpenCodeFile}
1686
- githubRepo={repoInfo?.display}
1687
- repoHost={repoInfo?.host}
1844
+ githubRepo={linkRepo?.display}
1845
+ repoHost={linkRepo?.host}
1688
1846
  onNavigateAnchor={scrollToAnchor}
1689
1847
  />
1690
1848
  )}
@@ -1718,7 +1876,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1718
1876
  )}
1719
1877
 
1720
1878
  {/* Comment popover — hook handles text selection, Viewer handles global + code block */}
1721
- {!readOnly && hookCommentPopover && (
1879
+ {!authoringOff && !hostComposes && hookCommentPopover && (
1722
1880
  <CommentPopover
1723
1881
  anchorEl={hookCommentPopover.anchorEl}
1724
1882
  contextText={hookCommentPopover.contextText}
@@ -1740,7 +1898,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1740
1898
  }}
1741
1899
  />
1742
1900
  )}
1743
- {!readOnly && viewerCommentPopover && (
1901
+ {!authoringOff && viewerCommentPopover && (
1744
1902
  <CommentPopover
1745
1903
  anchorEl={viewerCommentPopover.anchorEl}
1746
1904
  contextText={viewerCommentPopover.contextText}
@@ -1770,7 +1928,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1770
1928
  )}
1771
1929
 
1772
1930
  {/* Quick Label floating picker — hook handles text selection, Viewer handles code blocks */}
1773
- {!readOnly && hookQuickLabelPicker && (
1931
+ {!authoringOff && !hostComposes && hookQuickLabelPicker && (
1774
1932
  <FloatingQuickLabelPicker
1775
1933
  anchorEl={hookQuickLabelPicker.anchorEl}
1776
1934
  cursorHint={hookQuickLabelPicker.cursorHint}
@@ -1778,7 +1936,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1778
1936
  onDismiss={hookQuickLabelPickerDismiss}
1779
1937
  />
1780
1938
  )}
1781
- {!readOnly && codeBlockQuickLabelPicker && (
1939
+ {!authoringOff && codeBlockQuickLabelPicker && (
1782
1940
  <FloatingQuickLabelPicker
1783
1941
  anchorEl={codeBlockQuickLabelPicker.anchorEl}
1784
1942
  onSelect={(label: QuickLabel) => {
@@ -1802,11 +1960,12 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1802
1960
 
1803
1961
  {/* Image lightbox */}
1804
1962
  {lightbox && createPortal(
1805
- <ImageLightbox src={lightbox.src} alt={lightbox.alt} onClose={() => setLightbox(null)} />,
1963
+ <ImageLightbox src={lightbox.src} alt={lightbox.alt} images={lightbox.images} onClose={() => setLightboxState(null)} />,
1806
1964
  document.body
1807
1965
  )}
1808
1966
  </div>
1809
1967
  </DiagramAnchorClaimsContext.Provider>
1968
+ </LocalLinkContext.Provider>
1810
1969
  </CodePathValidationContext.Provider>
1811
1970
  );
1812
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;