@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,16 +1,19 @@
1
- import React, { useId, useMemo, useRef, useState } from 'react';
1
+ import React, { useEffect, useId, useMemo, useRef, useState } from 'react';
2
2
  import {
3
3
  canonicalQuestionAnswer,
4
4
  emptyQuestionAnswer,
5
5
  isQuestionAnswerEmpty,
6
+ isRecommendedQuestionAnswer,
6
7
  questionStatus,
7
8
  recommendedQuestionAnswer,
8
9
  type IndexedQuestion,
9
10
  type ParsedQuestion,
10
11
  type QuestionAnswer,
12
+ type QuestionStatus,
11
13
  } from '@plannotator/core/question-block';
12
14
  import { InlineMarkdown } from '../InlineMarkdown';
13
15
  import { QuestionContext } from './QuestionContext';
16
+ import { HostTitle } from '../HostTitle';
14
17
 
15
18
  /**
16
19
  * A `:::question` block rendered as an answer card (the approved "Card"
@@ -56,14 +59,25 @@ const now = (): number => (typeof performance !== 'undefined' ? performance.now(
56
59
 
57
60
  const cx = (...parts: Array<string | false | null | undefined>) => parts.filter(Boolean).join(' ');
58
61
 
62
+ /** The box every pill in the eyebrow shares (the status tags, the decision
63
+ * tags, the toggle and the split tag): one height, one line-height, and a
64
+ * 1px border on all of them (transparent unless the tone draws it), so the
65
+ * dashed "Open" tag is no taller than the rest. The pill group itself is a
66
+ * flex row centred on its axis, so no pill rides its own baseline (the split
67
+ * tag's baseline came from its icon-only switch, which lifted it).
68
+ * `align-[1px]` does nothing inside that flex row; it stays because it is
69
+ * the only use of the class, and dropping it would change the stylesheet
70
+ * the guides.show viewer pins (a deploy for no visible change). */
71
+ const PILL = 'annotation-exclude select-none inline-flex h-[18px] items-center gap-1 whitespace-nowrap rounded border px-1.5 align-[1px] text-[10.5px] font-semibold leading-4 tracking-[0.03em]';
72
+
59
73
  const Tag: React.FC<{ tone: 'rec' | 'ok' | 'skip' | 'open'; children: React.ReactNode; className?: string; style?: React.CSSProperties }> = ({ tone, children, className, style }) => (
60
74
  <span
61
75
  className={cx(
62
- 'annotation-exclude select-none inline-flex items-center gap-1 whitespace-nowrap rounded px-1.5 py-px align-[1px] text-[10.5px] font-semibold tracking-[0.03em]',
63
- tone === 'rec' && 'bg-primary/15 text-primary',
64
- tone === 'ok' && 'bg-success/15 text-success',
65
- tone === 'skip' && 'bg-muted text-muted-foreground',
66
- tone === 'open' && 'border border-dashed border-border text-muted-foreground',
76
+ PILL,
77
+ tone === 'rec' && 'border-transparent bg-primary/15 text-primary',
78
+ tone === 'ok' && 'border-transparent bg-success/15 text-success',
79
+ tone === 'skip' && 'border-transparent bg-muted text-muted-foreground',
80
+ tone === 'open' && 'border-dashed border-border text-muted-foreground',
67
81
  className,
68
82
  )}
69
83
  style={style}
@@ -85,15 +99,18 @@ const DiamondGlyph = () => (
85
99
  </svg>
86
100
  );
87
101
 
88
- /** Draws a decision tag in its "off" state: the same words, dimmed, with a
89
- * dotted outline instead of the tinted fill. Dimmed means the theme's
90
- * muted-foreground, not opacity: 60% opacity on primary text dropped the
91
- * default light theme under 3:1, while muted-foreground is the token every
92
- * theme already tunes for secondary text. Inline so it adds no utility
93
- * classes to the shared stylesheet and wins over the tag's `text-primary`. */
102
+ /** Draws a decision tag in its "off" state: the same words with a dotted
103
+ * outline instead of the tinted fill, faded. The theme's muted-foreground
104
+ * alone is exactly the "Open" tag's color beside it, so the off tag read as
105
+ * barely dimmer than an on one. Text, icon and outline (all `currentColor`)
106
+ * are muted-foreground mixed 55% with transparent: the look of
107
+ * `opacity: 0.55` on the tag, without `opacity`, which would also fade the
108
+ * keyboard focus ring (a `ring-primary` box-shadow on the tag's buttons)
109
+ * that must stay at full strength. Inline so it adds no utility classes to
110
+ * the shared stylesheet and wins over the tag's `text-primary`. */
94
111
  const DECISION_OFF_STYLE: React.CSSProperties = {
95
112
  background: 'transparent',
96
- color: 'var(--muted-foreground)',
113
+ color: 'color-mix(in oklab, var(--muted-foreground) 55%, transparent)',
97
114
  outline: '1px dotted currentColor',
98
115
  outlineOffset: '-1px',
99
116
  };
@@ -164,6 +181,15 @@ export interface QuestionBlockProps {
164
181
  * Skipped) so the host can draw its own; the decision tags stay. Default
165
182
  * `'card'`. */
166
183
  statusTag?: 'card' | 'none';
184
+ /** The host's own words for the card's status tag, per question (ui seam;
185
+ * the Plannotator Inbox says "Not sent" for a pick it has saved but not
186
+ * sent, where the card would say "Answered"). Return undefined for the
187
+ * card's own tag. Absent: today's tags. Ignored with `statusTag: 'none'`. */
188
+ statusLabel?: (question: IndexedQuestion, status: QuestionStatus) => QuestionStatusLabel | undefined;
189
+ /** Host content drawn right under the prompt (a host note such as "Replaces
190
+ * <an earlier question>"), given the question. Return null for none.
191
+ * Absent: nothing is drawn there (today's card). */
192
+ renderBelowPrompt?: (question: IndexedQuestion) => React.ReactNode;
167
193
  onOpenLinkedDoc?: (path: string) => void;
168
194
  onOpenCodeFile?: (path: string) => void;
169
195
  imageBaseDir?: string;
@@ -173,6 +199,12 @@ export interface QuestionBlockProps {
173
199
  onNavigateAnchor?: (hash: string) => void;
174
200
  }
175
201
 
202
+ /** A host's status tag (`statusLabel`): its words, and the tone of one of the card's own tags (default `open`). */
203
+ export interface QuestionStatusLabel {
204
+ label: string;
205
+ tone?: 'ok' | 'open' | 'skip';
206
+ }
207
+
176
208
  export const QuestionBlock: React.FC<QuestionBlockProps> = ({
177
209
  blockId,
178
210
  indexed,
@@ -187,6 +219,8 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
187
219
  onToggleDecisionRecording,
188
220
  onOpenDecision,
189
221
  statusTag = 'card',
222
+ statusLabel,
223
+ renderBelowPrompt,
190
224
  onOpenLinkedDoc,
191
225
  onOpenCodeFile,
192
226
  imageBaseDir,
@@ -256,6 +290,10 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
256
290
  const noteOpen = notePinned ?? !!answer?.note?.trim();
257
291
  const pressRef = useRef<{ x: number; y: number; touch: boolean } | null>(null);
258
292
  const noteRef = useRef<HTMLTextAreaElement>(null);
293
+ const cardRef = useRef<HTMLFieldSetElement>(null);
294
+ // Accept recommended (and Use) go away once clicked: focus moves to what
295
+ // the click filled in, never to the page.
296
+ const focusAfterAcceptRef = useRef(false);
259
297
  const lastPickRef = useRef<{ label: string; before: QuestionAnswer | undefined; at: number } | null>(null);
260
298
 
261
299
  const inline = (text: string) => (
@@ -274,11 +312,30 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
274
312
  // A note alone does not change a settled choice: the [x] stays drawn (and
275
313
  // the status stays Settled) until the reviewer picks, fills Other or skips.
276
314
  const status = questionStatus(question, answer);
315
+ const hostStatus = statusTag === 'none' ? undefined : statusLabel?.(indexed, status);
277
316
  const answered = status === 'answered';
278
317
  const skipped = status === 'skipped';
279
318
  const settled = status === 'settled';
280
319
  const settledLabels = question.choices.filter((c) => c.settled).map((c) => c.label);
281
320
  const hasRecommendation = question.choices.some((c) => c.recommended) || !!question.suggestedText;
321
+ // "Accept recommended" stays offered on a pick-any question whose
322
+ // recommendation names choices until the answer is exactly those choices:
323
+ // ticking one box must not take away the one click that ticks the rest. A
324
+ // pick-one or text question answered any way, a question with a settled
325
+ // choice, and a pick-any question recommending only a suggestion (whose
326
+ // Suggested line goes once the card is answered, so Accept would clear the
327
+ // ticks for something no longer shown) offer it no more.
328
+ const offerAccept = !readOnly && hasRecommendation && !settled && (
329
+ !answered
330
+ || (question.kind === 'multi'
331
+ && question.choices.some((c) => c.recommended)
332
+ && !question.choices.some((c) => c.settled)
333
+ && !isRecommendedQuestionAnswer(
334
+ answer!,
335
+ question.choices.filter((c) => c.recommended).map((c) => c.label),
336
+ question.suggestedText,
337
+ ))
338
+ );
282
339
  const selected = answered || skipped ? answer!.selected : settledLabels;
283
340
  const base = answer ?? emptyQuestionAnswer(indexed);
284
341
 
@@ -332,8 +389,25 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
332
389
 
333
390
  const acceptRecommended = () => {
334
391
  const next = recommendedQuestionAnswer(indexed, base);
335
- if (next) commit(next);
392
+ if (!next) return;
393
+ focusAfterAcceptRef.current = true;
394
+ commit(next);
336
395
  };
396
+ // Runs after the render that drew the accepted answer (and once more at
397
+ // most, should the host not redraw it): the first checked choice, else the
398
+ // field holding the suggestion.
399
+ useEffect(() => {
400
+ if (!focusAfterAcceptRef.current) return;
401
+ focusAfterAcceptRef.current = false;
402
+ const card = cardRef.current;
403
+ if (!card) return;
404
+ // Focus still on something in the card (the button was not removed): leave it.
405
+ const active = document.activeElement;
406
+ if (active && active !== document.body && card.contains(active)) return;
407
+ const target = card.querySelector<HTMLElement>('input:checked:not(:disabled)')
408
+ ?? card.querySelector<HTMLElement>('textarea:not(:disabled), input[type="text"]:not(:disabled)');
409
+ target?.focus({ preventScroll: true });
410
+ });
337
411
 
338
412
  const toggleNote = () => {
339
413
  const opening = !noteOpen;
@@ -394,12 +468,33 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
394
468
  ? (recordingOn ? 'on' : 'off')
395
469
  : undefined;
396
470
 
397
- const describedBy = [question.context ? contextId : null, question.kind === 'text' && question.suggestedText && !answered ? suggestId : null]
471
+ const describedBy = [question.context ? contextId : null, question.suggestedText && !answered ? suggestId : null]
398
472
  .filter(Boolean)
399
473
  .join(' ') || undefined;
400
474
 
475
+ // The suggestion the agent wrote that is not one of the choices (a choice
476
+ // question's `Recommended:` naming none of them; a text question's whole
477
+ // suggestion): "Accept recommended" fills it in, so the card shows it.
478
+ const suggestedLine = question.suggestedText && !answered && (
479
+ <div id={suggestId} className="mt-2 flex items-start gap-2 text-[12.5px] text-muted-foreground" data-question-suggested="">
480
+ <span className="min-w-0 flex-1">
481
+ <Tag tone="rec">Suggested</Tag> {inline(question.suggestedText)}
482
+ </span>
483
+ {!readOnly && (
484
+ <button
485
+ type="button"
486
+ onClick={acceptRecommended}
487
+ className="shrink-0 rounded-md bg-primary/15 px-2.5 py-[3px] text-xs font-medium text-primary hover:bg-primary/25 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
488
+ >
489
+ Use
490
+ </button>
491
+ )}
492
+ </div>
493
+ );
494
+
401
495
  return (
402
496
  <fieldset
497
+ ref={cardRef}
403
498
  className={cx(
404
499
  'question-block my-5 min-w-0 rounded-[10px] border px-3.5 pb-2.5 pt-3',
405
500
  // Native radios/checkboxes follow the theme's mode. ThemeProvider marks
@@ -422,7 +517,7 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
422
517
  >
423
518
  <div className="annotation-exclude select-none mb-1.5 flex items-center gap-2" data-pinpoint-ignore="">
424
519
  <span className="text-[10.5px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{eyebrow}</span>
425
- <span className="ml-auto">
520
+ <span className="ml-auto inline-flex items-center gap-1">
426
521
  {question.decision && <><Tag tone="rec"><DiamondGlyph />Decision</Tag>{' '}</>}
427
522
  {decisionCapable && (decisionOpenable ? (
428
523
  // Two targets in one tag: the diamond switches recording, the
@@ -435,27 +530,26 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
435
530
  // meet the spacing exception: nothing else to click within 12 px
436
531
  // of their centre line.
437
532
  <><span
438
- className="annotation-exclude select-none inline-flex items-stretch whitespace-nowrap rounded align-[1px] text-[10.5px] font-semibold tracking-[0.03em] bg-primary/15 text-primary"
533
+ className="annotation-exclude select-none inline-flex h-[18px] items-stretch whitespace-nowrap rounded border border-transparent text-[10.5px] font-semibold leading-4 tracking-[0.03em] bg-primary/15 text-primary"
439
534
  style={recordingOn ? undefined : DECISION_OFF_STYLE}
440
535
  data-pinpoint-ignore=""
441
536
  data-question-decision-tag=""
442
537
  >{decisionToggleable ? (
443
- <button
538
+ <HostTitle title="Record as a decision"><button
444
539
  type="button"
445
540
  aria-pressed={recordingOn}
446
541
  aria-label="Record as a decision"
447
- title="Record as a decision"
448
542
  onClick={() => onToggleDecisionRecording!(question.key, !recordingOn)}
449
543
  className="relative inline-flex cursor-pointer items-center rounded pl-1.5 pr-0.5 before:absolute before:-inset-1.5 before:content-[''] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
450
544
  data-question-decision-toggle=""
451
- ><DiamondGlyph /></button>
545
+ ><DiamondGlyph /></button></HostTitle>
452
546
  ) : (
453
547
  <span className="inline-flex items-center pl-1.5 pr-0.5"><DiamondGlyph /></span>
454
548
  )}<button
455
549
  type="button"
456
550
  aria-haspopup="dialog"
457
551
  onClick={(event) => onOpenDecision!(question.key, event.currentTarget)}
458
- className="relative cursor-pointer rounded py-px pl-0.5 pr-1.5 hover:underline underline-offset-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
552
+ className="relative cursor-pointer rounded pl-0.5 pr-1.5 hover:underline underline-offset-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
459
553
  data-question-decision-open=""
460
554
  >Records a decision</button></span>{' '}</>
461
555
  ) : decisionToggleable ? (
@@ -465,7 +559,7 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
465
559
  type="button"
466
560
  aria-pressed={recordingOn}
467
561
  onClick={() => onToggleDecisionRecording!(question.key, !recordingOn)}
468
- className="annotation-exclude select-none inline-flex cursor-pointer items-center gap-1 whitespace-nowrap rounded px-1.5 py-px align-[1px] text-[10.5px] font-semibold tracking-[0.03em] bg-primary/15 text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
562
+ className={cx(PILL, 'cursor-pointer border-transparent bg-primary/15 text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary')}
469
563
  style={recordingOn ? undefined : DECISION_OFF_STYLE}
470
564
  data-pinpoint-ignore=""
471
565
  data-question-decision-toggle=""
@@ -475,7 +569,8 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
475
569
  ) : (
476
570
  <><Tag tone="rec" style={DECISION_OFF_STYLE}><DiamondGlyph />Records a decision</Tag>{' '}</>
477
571
  ))}
478
- {statusTag !== 'none' && (
572
+ {statusTag !== 'none' && hostStatus && <Tag tone={hostStatus.tone ?? 'open'}>{hostStatus.label}</Tag>}
573
+ {statusTag !== 'none' && !hostStatus && (
479
574
  <>
480
575
  {status === 'answered' && <Tag tone="ok"><CheckGlyph />Answered</Tag>}
481
576
  {status === 'settled' && <Tag tone="ok"><CheckGlyph />Settled</Tag>}
@@ -489,6 +584,7 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
489
584
  <p id={promptId} className="m-0 text-[15px] font-semibold leading-[1.45] text-foreground" data-question-part="prompt">
490
585
  {inline(question.prompt)}
491
586
  </p>
587
+ {renderBelowPrompt?.(indexed)}
492
588
  {(question.decision || (decisionCapable && recordingOn)) && (
493
589
  <div
494
590
  className="annotation-exclude mt-2 mb-0.5 flex items-center gap-2 rounded-lg bg-primary/8 px-[9px] py-1.5 text-[12.5px] leading-[18px] text-foreground [&>svg]:text-primary"
@@ -530,22 +626,7 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
530
626
 
531
627
  {question.kind === 'text' ? (
532
628
  <>
533
- {question.suggestedText && !answered && (
534
- <div id={suggestId} className="mt-2 flex items-start gap-2 text-[12.5px] text-muted-foreground">
535
- <span className="min-w-0 flex-1">
536
- <Tag tone="rec">Suggested</Tag> {inline(question.suggestedText)}
537
- </span>
538
- {!readOnly && (
539
- <button
540
- type="button"
541
- onClick={acceptRecommended}
542
- className="shrink-0 rounded-md bg-primary/15 px-2.5 py-[3px] text-xs font-medium text-primary hover:bg-primary/25 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
543
- >
544
- Use
545
- </button>
546
- )}
547
- </div>
548
- )}
629
+ {suggestedLine}
549
630
  <textarea
550
631
  className={cx(
551
632
  // annotation-exclude: typed text is not document text, so a
@@ -561,65 +642,71 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
561
642
  />
562
643
  </>
563
644
  ) : (
564
- <div
565
- className={cx('mt-2.5 grid gap-0.5', skipped && 'opacity-55')}
566
- role={inputType === 'radio' ? 'radiogroup' : 'group'}
567
- aria-labelledby={promptId}
568
- >
569
- {question.choices.map((choice) => {
570
- const checked = selected.includes(choice.label);
571
- return (
572
- <label
573
- key={choice.label}
574
- className={cx(
575
- 'flex items-start gap-2.5 rounded-[7px] px-[9px] py-1.5 transition-colors',
576
- readOnly ? 'cursor-default' : 'cursor-pointer hover:bg-muted/45',
577
- checked && 'bg-primary/12 hover:bg-primary/12',
578
- )}
579
- data-question-option=""
580
- onPointerDown={onRowPointerDown}
581
- onClickCapture={(event) => onRowClick(event, choice.label)}
582
- >
645
+ <>
646
+ {suggestedLine}
647
+ <div
648
+ className={cx('mt-2.5 grid gap-0.5', skipped && 'opacity-55')}
649
+ role={inputType === 'radio' ? 'radiogroup' : 'group'}
650
+ aria-labelledby={promptId}
651
+ >
652
+ {question.choices.map((choice) => {
653
+ const checked = selected.includes(choice.label);
654
+ return (
655
+ <label
656
+ key={choice.label}
657
+ className={cx(
658
+ 'flex items-start gap-2.5 rounded-[7px] px-[9px] py-1.5 transition-colors',
659
+ readOnly ? 'cursor-default' : 'cursor-pointer hover:bg-muted/45',
660
+ checked && 'bg-primary/12 hover:bg-primary/12',
661
+ )}
662
+ data-question-option=""
663
+ onPointerDown={onRowPointerDown}
664
+ onClickCapture={(event) => onRowClick(event, choice.label)}
665
+ >
666
+ <input
667
+ type={inputType}
668
+ name={groupName}
669
+ checked={checked}
670
+ onChange={(e) => pick(choice.label, e.target.checked)}
671
+ className="mt-[3px] h-[15px] w-[15px] shrink-0 cursor-pointer accent-primary disabled:cursor-default"
672
+ disabled={readOnly}
673
+ />
674
+ <span className="min-w-0 flex-1 select-text text-[13.5px] leading-[1.45] text-foreground" data-question-part="choice">
675
+ <span className="font-[550]">{inline(choice.label)}</span>
676
+ {choice.description && (
677
+ <span className="text-muted-foreground"> — {inline(choice.description)}</span>
678
+ )}
679
+ {choice.recommended && <> <Tag tone="rec">Recommended</Tag></>}
680
+ </span>
681
+ </label>
682
+ );
683
+ })}
684
+ {/* A read-only card with no Other answer has nothing to show there (it drew a bare "+"). */}
685
+ {(!readOnly || otherOn) && (
686
+ <div className="flex items-center gap-2.5 rounded-[7px] px-[9px] py-[5px]">
687
+ <span
688
+ aria-hidden="true"
689
+ className={cx(
690
+ 'annotation-exclude select-none grid h-[15px] w-[15px] shrink-0 place-items-center border-[1.5px] text-[11px] leading-none',
691
+ question.kind === 'multi' ? 'rounded-[3px]' : 'rounded-full',
692
+ otherOn ? 'border-solid border-primary bg-primary text-primary-foreground' : 'border-dashed border-border text-muted-foreground',
693
+ )}
694
+ >
695
+ {otherOn ? <CheckGlyph /> : '+'}
696
+ </span>
583
697
  <input
584
- type={inputType}
585
- name={groupName}
586
- checked={checked}
587
- onChange={(e) => pick(choice.label, e.target.checked)}
588
- className="mt-[3px] h-[15px] w-[15px] shrink-0 cursor-pointer accent-primary disabled:cursor-default"
698
+ type="text"
699
+ value={other}
700
+ onChange={(e) => setOther(e.target.value)}
589
701
  disabled={readOnly}
702
+ placeholder={readOnly ? '' : 'Other…'}
703
+ aria-label={number > 0 ? `Other answer to question ${number}` : 'Other answer'}
704
+ className="annotation-exclude min-w-0 flex-1 border-0 border-b border-transparent bg-transparent py-[3px] text-[13.5px] text-foreground outline-none placeholder:text-muted-foreground/70 focus:border-ring"
590
705
  />
591
- <span className="min-w-0 flex-1 select-text text-[13.5px] leading-[1.45] text-foreground" data-question-part="choice">
592
- <span className="font-[550]">{inline(choice.label)}</span>
593
- {choice.description && (
594
- <span className="text-muted-foreground"> — {inline(choice.description)}</span>
595
- )}
596
- {choice.recommended && <> <Tag tone="rec">Recommended</Tag></>}
597
- </span>
598
- </label>
599
- );
600
- })}
601
- <div className="flex items-center gap-2.5 rounded-[7px] px-[9px] py-[5px]">
602
- <span
603
- aria-hidden="true"
604
- className={cx(
605
- 'annotation-exclude select-none grid h-[15px] w-[15px] shrink-0 place-items-center border-[1.5px] text-[11px] leading-none',
606
- question.kind === 'multi' ? 'rounded-[3px]' : 'rounded-full',
607
- otherOn ? 'border-solid border-primary bg-primary text-primary-foreground' : 'border-dashed border-border text-muted-foreground',
608
- )}
609
- >
610
- {otherOn ? <CheckGlyph /> : '+'}
611
- </span>
612
- <input
613
- type="text"
614
- value={other}
615
- onChange={(e) => setOther(e.target.value)}
616
- disabled={readOnly}
617
- placeholder={readOnly ? '' : 'Other…'}
618
- aria-label={number > 0 ? `Other answer to question ${number}` : 'Other answer'}
619
- className="annotation-exclude min-w-0 flex-1 border-0 border-b border-transparent bg-transparent py-[3px] text-[13.5px] text-foreground outline-none placeholder:text-muted-foreground/70 focus:border-ring"
620
- />
706
+ </div>
707
+ )}
621
708
  </div>
622
- </div>
709
+ </>
623
710
  )}
624
711
 
625
712
  {noteOpen && (!readOnly || !!answer?.note?.trim()) && (
@@ -674,7 +761,7 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
674
761
  {/* Plannotator's own footer (no host actions, no save bar) keeps the
675
762
  Accept button as the one right-aligned item. */}
676
763
  {!hasHostFooter && !dirty ? (
677
- !readOnly && !answered && !settled && hasRecommendation && (
764
+ offerAccept && (
678
765
  <button
679
766
  type="button"
680
767
  onClick={acceptRecommended}
@@ -685,7 +772,7 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
685
772
  )
686
773
  ) : (
687
774
  <div className="ml-auto flex items-center gap-1">
688
- {!readOnly && !answered && !settled && hasRecommendation && (
775
+ {offerAccept && (
689
776
  <button
690
777
  type="button"
691
778
  onClick={acceptRecommended}
@@ -18,6 +18,9 @@ const QUESTION_CONTEXT_CSS = `
18
18
  .question-context > :first-child { margin-top: 0; }
19
19
  .question-context > :last-child { margin-bottom: 0; }
20
20
  .question-context p, .question-context .text-sm { font-size: 13px; line-height: 1.5; }
21
+ /* Context lines stay lines ("Stopped: …" then "Holds up: …"), as the
22
+ authoring guide writes them; the parser keeps them as soft breaks. */
23
+ .question-context p { white-space: pre-line; }
21
24
  .question-context p, .question-context [data-question-context-list] span { color: inherit; }
22
25
  .question-context h1, .question-context h2, .question-context h3 { margin: 0.75rem 0 0.25rem; font-size: 14px; }
23
26
  .question-context h3 { font-size: 13.5px; }
@@ -1,4 +1,4 @@
1
- import { lazy, Suspense, useCallback, useEffect, useMemo, useState, type ReactNode } from 'react';
1
+ import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
2
2
  import { buildDiagramAnchorValue, type DiagramAnchor, type DiagramKind, type DiagramTarget } from '@plannotator/core/diagram-anchor';
3
3
  import { cn } from '../../lib/utils';
4
4
  import { diagramFamilyOf } from '../../utils/diagram-anchor';
@@ -43,6 +43,14 @@ export type DiagramAskAI = (
43
43
  additionalTargets: readonly DiagramTarget[],
44
44
  ) => boolean | void | Promise<boolean | void>;
45
45
 
46
+ /** A part chosen for a comment the host composes (`onHostDraft`). */
47
+ export interface DiagramHostDraft {
48
+ /** The anchor a comment there carries (its `sourceLine` in document lines). */
49
+ readonly anchor: DiagramAnchor;
50
+ /** Drop the draft: the part's ring goes. A draft already replaced ignores it. */
51
+ readonly cancel: () => void;
52
+ }
53
+
46
54
  export interface DiagramViewerProps {
47
55
  readonly kind: DiagramKind;
48
56
  /** The diagram text. With `onSave` this is the saved baseline the pane's
@@ -52,6 +60,11 @@ export interface DiagramViewerProps {
52
60
  readonly comments: readonly DiagramComment[];
53
61
  /** A comment composed on a part. Absent: clicks open nothing. */
54
62
  readonly onCreateComment?: DiagramCreateComment;
63
+ /** Opt-in host capability: the composer handed to the host. A click on a
64
+ * part reports it here (null when the draft closes) and no composer is
65
+ * drawn; the host saves the comment and passes it back in `comments`.
66
+ * Absent: unchanged. */
67
+ readonly onHostDraft?: (draft: DiagramHostDraft | null) => void;
55
68
  /** Ask AI from the composer. Absent: the composer offers no Ask AI. */
56
69
  readonly onAskAI?: DiagramAskAI;
57
70
  /** Save the pane's text. Absent: there is no Source pane. */
@@ -104,6 +117,7 @@ export function DiagramViewer({
104
117
  theme,
105
118
  comments,
106
119
  onCreateComment,
120
+ onHostDraft,
107
121
  onAskAI,
108
122
  onSave,
109
123
  readOnlySource = false,
@@ -142,7 +156,7 @@ export function DiagramViewer({
142
156
  const [svgRoot, setSvgRoot] = useState<SVGSVGElement | null>(null);
143
157
  const onSvgRoot = useCallback((root: SVGSVGElement | null) => setSvgRoot(root), []);
144
158
 
145
- const canCreate = onCreateComment !== undefined || commentingDisabledReason !== undefined;
159
+ const canCreate = onCreateComment !== undefined || commentingDisabledReason !== undefined || onHostDraft !== undefined;
146
160
  const commentsState = useDiagramComments({
147
161
  finder,
148
162
  svgRoot,
@@ -190,6 +204,29 @@ export function DiagramViewer({
190
204
  if (!sourceOpen) cancelComposer();
191
205
  }, [cancelComposer, sourceOpen]);
192
206
 
207
+ // The composer handed to the host: the draft's part goes out, and the
208
+ // host's cancel closes it only while it is still the open one.
209
+ const onHostDraftRef = useRef(onHostDraft);
210
+ onHostDraftRef.current = onHostDraft;
211
+ const openComposer = commentsState.composer;
212
+ const openComposerRef = useRef(openComposer);
213
+ openComposerRef.current = openComposer;
214
+ const hostComposes = onHostDraft !== undefined;
215
+ useEffect(() => {
216
+ const report = onHostDraftRef.current;
217
+ if (!report) return;
218
+ if (openComposer === null) {
219
+ report(null);
220
+ return;
221
+ }
222
+ report({
223
+ anchor: buildDiagramAnchorValue(openComposer.primary.target, openComposer.sourceLine),
224
+ cancel: () => {
225
+ if (openComposerRef.current === openComposer) cancelComposer();
226
+ },
227
+ });
228
+ }, [cancelComposer, hostComposes, openComposer]);
229
+
193
230
  // Ask AI asks about the part the draft is on; the host's handler decides
194
231
  // whether the question was taken, and a taken question closes the draft
195
232
  // exactly as the markdown composer closes.
@@ -215,7 +252,7 @@ export function DiagramViewer({
215
252
  selectedCommentId={selectedCommentId}
216
253
  onSelectComment={onSelectComment}
217
254
  renderComposer={(anchorRect) =>
218
- commentsState.composer === null ? null : (
255
+ commentsState.composer === null || hostComposes ? null : (
219
256
  <DiagramComposer
220
257
  key={`${commentsState.composer.primary.target.id ?? ''}:${commentsState.composer.primary.target.from ?? ''}:${commentsState.composer.primary.target.to ?? ''}`}
221
258
  draft={commentsState.composer}
@@ -233,7 +270,7 @@ export function DiagramViewer({
233
270
  }
234
271
  />
235
272
  ),
236
- [askFromComposer, commentingDisabledReason, commentsState, onSelectComment, selectedCommentId, sourceDirty],
273
+ [askFromComposer, commentingDisabledReason, commentsState, hostComposes, onSelectComment, selectedCommentId, sourceDirty],
237
274
  );
238
275
 
239
276
  const showFallback = render.svgNode === null;
@@ -6,7 +6,7 @@
6
6
  * `graphviz`, `mermaid`, `diagram-anchor`, `diagram-anchor-graphviz`,
7
7
  * `diagram-projection`).
8
8
  */
9
- export { DiagramViewer, type DiagramAskAI, type DiagramViewerProps } from './DiagramViewer';
9
+ export { DiagramViewer, type DiagramAskAI, type DiagramHostDraft, type DiagramViewerProps } from './DiagramViewer';
10
10
  export { DiagramPopout } from './DiagramPopout';
11
11
  export { DiagramPending, DiagramInlineSource, DiagramBlockPending } from './DiagramPending';
12
12
  export { isDiagramControlEvent, DIAGRAM_CONTROL_SELECTOR } from './diagramControls';