@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,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"
@@ -37,9 +40,12 @@ import { QuestionContext } from './QuestionContext';
37
40
  * A host can add its own actions at the right of the footer
38
41
  * (`renderFooter`), and a question carrying `Decision: when answered` or a
39
42
  * `Decision: [statement](url)` link shows that beside the prompt. A host can
40
- * also let the reviewer switch a `Decision: when answered` question's
41
- * recording off and on (`decisionRecording` + `onToggleDecisionRecording`),
42
- * and hide the card's own status tag to draw its own (`statusTag: 'none'`).
43
+ * also let the reviewer switch a question's decision recording off and on
44
+ * (`decisionRecording` + `onToggleDecisionRecording`; with
45
+ * `decisionScope: 'any'`, any question without a recorded decision once the
46
+ * host supplies a state), open its own decision
47
+ * card from the tag's words (`onOpenDecision`), and hide the card's own
48
+ * status tag to draw its own (`statusTag: 'none'`).
43
49
  */
44
50
 
45
51
  /** Pointer travel (px) above which a press on a choice row is a drag. */
@@ -53,14 +59,25 @@ const now = (): number => (typeof performance !== 'undefined' ? performance.now(
53
59
 
54
60
  const cx = (...parts: Array<string | false | null | undefined>) => parts.filter(Boolean).join(' ');
55
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
+
56
73
  const Tag: React.FC<{ tone: 'rec' | 'ok' | 'skip' | 'open'; children: React.ReactNode; className?: string; style?: React.CSSProperties }> = ({ tone, children, className, style }) => (
57
74
  <span
58
75
  className={cx(
59
- '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]',
60
- tone === 'rec' && 'bg-primary/15 text-primary',
61
- tone === 'ok' && 'bg-success/15 text-success',
62
- tone === 'skip' && 'bg-muted text-muted-foreground',
63
- 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',
64
81
  className,
65
82
  )}
66
83
  style={style}
@@ -82,15 +99,18 @@ const DiamondGlyph = () => (
82
99
  </svg>
83
100
  );
84
101
 
85
- /** Draws a decision tag in its "off" state: the same words, dimmed, with a
86
- * dotted outline instead of the tinted fill. Dimmed means the theme's
87
- * muted-foreground, not opacity: 60% opacity on primary text dropped the
88
- * default light theme under 3:1, while muted-foreground is the token every
89
- * theme already tunes for secondary text. Inline so it adds no utility
90
- * 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`. */
91
111
  const DECISION_OFF_STYLE: React.CSSProperties = {
92
112
  background: 'transparent',
93
- color: 'var(--muted-foreground)',
113
+ color: 'color-mix(in oklab, var(--muted-foreground) 55%, transparent)',
94
114
  outline: '1px dotted currentColor',
95
115
  outlineOffset: '-1px',
96
116
  };
@@ -127,21 +147,49 @@ export interface QuestionBlockProps {
127
147
  * saved answer (never the unsaved draft). Rendered in read-only cards too;
128
148
  * return null for nothing. */
129
149
  renderFooter?: (question: IndexedQuestion, answer: QuestionAnswer | undefined) => React.ReactNode;
130
- /** Whether answering a `Decision: when answered` question records a
131
- * decision (default true). False draws the "Records a decision" tag dimmed
132
- * with a dotted outline and hides the "Answering this records a decision"
133
- * row. Has no effect on a question whose decision is already recorded
134
- * (`Decision: [statement](url)`) or that carries no decision line. */
150
+ /** Whether answering this question records a decision. On a question
151
+ * carrying `Decision: when answered` it defaults to true; false draws the
152
+ * "Records a decision" tag dimmed with a dotted outline and hides the
153
+ * "Answering this records a decision" row. On a question WITHOUT a
154
+ * decision line it is ignored unless `decisionScope` is `'any'` (ui
155
+ * 0.52.1); then a defined value adds the tag in that state (false: off;
156
+ * true: on, with the row), so a host can let any answer become a
157
+ * decision, and undefined leaves the question with no tag. Has no effect
158
+ * on a question whose decision is already recorded
159
+ * (`Decision: [statement](url)`). */
135
160
  decisionRecording?: boolean;
161
+ /** Which questions can carry the "Records a decision" tag (ui 0.52.1).
162
+ * `'when-answered'` (default, the 0.52.0 behaviour): only a question that
163
+ * carries `Decision: when answered`. `'any'`: also a question with no
164
+ * decision line, once the host passes a `decisionRecording` for it. */
165
+ decisionScope?: 'when-answered' | 'any';
136
166
  /** Makes the "Records a decision" tag a toggle button (`aria-pressed`):
137
167
  * a click calls this with the question's key and the next state. The host
138
168
  * stores it and passes it back as `decisionRecording`. Not offered in a
139
169
  * read-only card (no answer handler), which draws the tag as it stands. */
140
170
  onToggleDecisionRecording?: (key: string, next: boolean) => void;
171
+ /** Opens the host's decision card (ui 0.52.1). Given, the tag splits into
172
+ * two targets inside one visual tag: the diamond is the switch
173
+ * (`onToggleDecisionRecording`, labelled "Record as a decision") and the
174
+ * words "Records a decision" are a button (`aria-haspopup="dialog"`) that
175
+ * calls this with the question's key and the words' own element, to
176
+ * anchor a popover. Offered whether recording is on or off (the host
177
+ * decides what an off question's card does). Not offered in a read-only
178
+ * card. */
179
+ onOpenDecision?: (key: string, anchor: HTMLElement) => void;
141
180
  /** `'none'` hides the card's own status tag (Open / Answered / Settled /
142
181
  * Skipped) so the host can draw its own; the decision tags stay. Default
143
182
  * `'card'`. */
144
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;
145
193
  onOpenLinkedDoc?: (path: string) => void;
146
194
  onOpenCodeFile?: (path: string) => void;
147
195
  imageBaseDir?: string;
@@ -151,6 +199,12 @@ export interface QuestionBlockProps {
151
199
  onNavigateAnchor?: (hash: string) => void;
152
200
  }
153
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
+
154
208
  export const QuestionBlock: React.FC<QuestionBlockProps> = ({
155
209
  blockId,
156
210
  indexed,
@@ -161,8 +215,12 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
161
215
  saveLabel,
162
216
  renderFooter,
163
217
  decisionRecording,
218
+ decisionScope = 'when-answered',
164
219
  onToggleDecisionRecording,
220
+ onOpenDecision,
165
221
  statusTag = 'card',
222
+ statusLabel,
223
+ renderBelowPrompt,
166
224
  onOpenLinkedDoc,
167
225
  onOpenCodeFile,
168
226
  imageBaseDir,
@@ -232,6 +290,10 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
232
290
  const noteOpen = notePinned ?? !!answer?.note?.trim();
233
291
  const pressRef = useRef<{ x: number; y: number; touch: boolean } | null>(null);
234
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);
235
297
  const lastPickRef = useRef<{ label: string; before: QuestionAnswer | undefined; at: number } | null>(null);
236
298
 
237
299
  const inline = (text: string) => (
@@ -250,11 +312,30 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
250
312
  // A note alone does not change a settled choice: the [x] stays drawn (and
251
313
  // the status stays Settled) until the reviewer picks, fills Other or skips.
252
314
  const status = questionStatus(question, answer);
315
+ const hostStatus = statusTag === 'none' ? undefined : statusLabel?.(indexed, status);
253
316
  const answered = status === 'answered';
254
317
  const skipped = status === 'skipped';
255
318
  const settled = status === 'settled';
256
319
  const settledLabels = question.choices.filter((c) => c.settled).map((c) => c.label);
257
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
+ );
258
339
  const selected = answered || skipped ? answer!.selected : settledLabels;
259
340
  const base = answer ?? emptyQuestionAnswer(indexed);
260
341
 
@@ -308,8 +389,25 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
308
389
 
309
390
  const acceptRecommended = () => {
310
391
  const next = recommendedQuestionAnswer(indexed, base);
311
- if (next) commit(next);
392
+ if (!next) return;
393
+ focusAfterAcceptRef.current = true;
394
+ commit(next);
312
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
+ });
313
411
 
314
412
  const toggleNote = () => {
315
413
  const opening = !noteOpen;
@@ -355,23 +453,48 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
355
453
  const other = answer?.other ?? '';
356
454
  const otherOn = other.trim() !== '';
357
455
 
358
- // A `Decision: when answered` question the host may switch off. A recorded
359
- // decision (`question.decision`) is never affected.
360
- const decisionCapable = !question.decision && !!question.decisionOnAnswer;
456
+ // A `Decision: when answered` question the host may switch off, or (with
457
+ // `decisionScope: 'any'`) any other question once the host supplies a
458
+ // recording state for it. A recorded decision (`question.decision`) is
459
+ // never affected.
460
+ const decisionCapable = !question.decision
461
+ && (!!question.decisionOnAnswer || (decisionScope === 'any' && decisionRecording !== undefined));
361
462
  const recordingOn = decisionRecording ?? true;
362
463
  const decisionToggleable = decisionCapable && !readOnly && !!onToggleDecisionRecording;
464
+ const decisionOpenable = decisionCapable && !readOnly && !!onOpenDecision;
363
465
  // Present only when the host speaks to recording, so a card without these
364
466
  // props carries exactly the attributes it always did.
365
467
  const recordingAttr = decisionCapable && (decisionRecording !== undefined || !!onToggleDecisionRecording)
366
468
  ? (recordingOn ? 'on' : 'off')
367
469
  : undefined;
368
470
 
369
- 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]
370
472
  .filter(Boolean)
371
473
  .join(' ') || undefined;
372
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
+
373
495
  return (
374
496
  <fieldset
497
+ ref={cardRef}
375
498
  className={cx(
376
499
  'question-block my-5 min-w-0 rounded-[10px] border px-3.5 pb-2.5 pt-3',
377
500
  // Native radios/checkboxes follow the theme's mode. ThemeProvider marks
@@ -394,16 +517,49 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
394
517
  >
395
518
  <div className="annotation-exclude select-none mb-1.5 flex items-center gap-2" data-pinpoint-ignore="">
396
519
  <span className="text-[10.5px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{eyebrow}</span>
397
- <span className="ml-auto">
520
+ <span className="ml-auto inline-flex items-center gap-1">
398
521
  {question.decision && <><Tag tone="rec"><DiamondGlyph />Decision</Tag>{' '}</>}
399
- {decisionCapable && (decisionToggleable ? (
522
+ {decisionCapable && (decisionOpenable ? (
523
+ // Two targets in one tag: the diamond switches recording, the
524
+ // words open the host's decision card. Siblings, never nested.
525
+ // Target size (WCAG 2.5.8) without changing the drawn tag: the
526
+ // diamond's invisible `before:` box reaches 6 px past it on every
527
+ // side (24 px wide, about 30 px tall), and the words, positioned
528
+ // and later in the tree, paint over its right-hand part, so a
529
+ // click on the words is always the words. The words (110 px wide)
530
+ // meet the spacing exception: nothing else to click within 12 px
531
+ // of their centre line.
532
+ <><span
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"
534
+ style={recordingOn ? undefined : DECISION_OFF_STYLE}
535
+ data-pinpoint-ignore=""
536
+ data-question-decision-tag=""
537
+ >{decisionToggleable ? (
538
+ <HostTitle title="Record as a decision"><button
539
+ type="button"
540
+ aria-pressed={recordingOn}
541
+ aria-label="Record as a decision"
542
+ onClick={() => onToggleDecisionRecording!(question.key, !recordingOn)}
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"
544
+ data-question-decision-toggle=""
545
+ ><DiamondGlyph /></button></HostTitle>
546
+ ) : (
547
+ <span className="inline-flex items-center pl-1.5 pr-0.5"><DiamondGlyph /></span>
548
+ )}<button
549
+ type="button"
550
+ aria-haspopup="dialog"
551
+ onClick={(event) => onOpenDecision!(question.key, event.currentTarget)}
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"
553
+ data-question-decision-open=""
554
+ >Records a decision</button></span>{' '}</>
555
+ ) : decisionToggleable ? (
400
556
  // Focus shows as a ring, not an outline: the dotted outline is
401
557
  // what draws the off state.
402
558
  <><button
403
559
  type="button"
404
560
  aria-pressed={recordingOn}
405
561
  onClick={() => onToggleDecisionRecording!(question.key, !recordingOn)}
406
- 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')}
407
563
  style={recordingOn ? undefined : DECISION_OFF_STYLE}
408
564
  data-pinpoint-ignore=""
409
565
  data-question-decision-toggle=""
@@ -413,7 +569,8 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
413
569
  ) : (
414
570
  <><Tag tone="rec" style={DECISION_OFF_STYLE}><DiamondGlyph />Records a decision</Tag>{' '}</>
415
571
  ))}
416
- {statusTag !== 'none' && (
572
+ {statusTag !== 'none' && hostStatus && <Tag tone={hostStatus.tone ?? 'open'}>{hostStatus.label}</Tag>}
573
+ {statusTag !== 'none' && !hostStatus && (
417
574
  <>
418
575
  {status === 'answered' && <Tag tone="ok"><CheckGlyph />Answered</Tag>}
419
576
  {status === 'settled' && <Tag tone="ok"><CheckGlyph />Settled</Tag>}
@@ -427,7 +584,8 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
427
584
  <p id={promptId} className="m-0 text-[15px] font-semibold leading-[1.45] text-foreground" data-question-part="prompt">
428
585
  {inline(question.prompt)}
429
586
  </p>
430
- {(question.decision || (question.decisionOnAnswer && recordingOn)) && (
587
+ {renderBelowPrompt?.(indexed)}
588
+ {(question.decision || (decisionCapable && recordingOn)) && (
431
589
  <div
432
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"
433
591
  data-pinpoint-ignore=""
@@ -468,22 +626,7 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
468
626
 
469
627
  {question.kind === 'text' ? (
470
628
  <>
471
- {question.suggestedText && !answered && (
472
- <div id={suggestId} className="mt-2 flex items-start gap-2 text-[12.5px] text-muted-foreground">
473
- <span className="min-w-0 flex-1">
474
- <Tag tone="rec">Suggested</Tag> {inline(question.suggestedText)}
475
- </span>
476
- {!readOnly && (
477
- <button
478
- type="button"
479
- onClick={acceptRecommended}
480
- 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"
481
- >
482
- Use
483
- </button>
484
- )}
485
- </div>
486
- )}
629
+ {suggestedLine}
487
630
  <textarea
488
631
  className={cx(
489
632
  // annotation-exclude: typed text is not document text, so a
@@ -499,65 +642,71 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
499
642
  />
500
643
  </>
501
644
  ) : (
502
- <div
503
- className={cx('mt-2.5 grid gap-0.5', skipped && 'opacity-55')}
504
- role={inputType === 'radio' ? 'radiogroup' : 'group'}
505
- aria-labelledby={promptId}
506
- >
507
- {question.choices.map((choice) => {
508
- const checked = selected.includes(choice.label);
509
- return (
510
- <label
511
- key={choice.label}
512
- className={cx(
513
- 'flex items-start gap-2.5 rounded-[7px] px-[9px] py-1.5 transition-colors',
514
- readOnly ? 'cursor-default' : 'cursor-pointer hover:bg-muted/45',
515
- checked && 'bg-primary/12 hover:bg-primary/12',
516
- )}
517
- data-question-option=""
518
- onPointerDown={onRowPointerDown}
519
- onClickCapture={(event) => onRowClick(event, choice.label)}
520
- >
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>
521
697
  <input
522
- type={inputType}
523
- name={groupName}
524
- checked={checked}
525
- onChange={(e) => pick(choice.label, e.target.checked)}
526
- 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)}
527
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"
528
705
  />
529
- <span className="min-w-0 flex-1 select-text text-[13.5px] leading-[1.45] text-foreground" data-question-part="choice">
530
- <span className="font-[550]">{inline(choice.label)}</span>
531
- {choice.description && (
532
- <span className="text-muted-foreground"> — {inline(choice.description)}</span>
533
- )}
534
- {choice.recommended && <> <Tag tone="rec">Recommended</Tag></>}
535
- </span>
536
- </label>
537
- );
538
- })}
539
- <div className="flex items-center gap-2.5 rounded-[7px] px-[9px] py-[5px]">
540
- <span
541
- aria-hidden="true"
542
- className={cx(
543
- 'annotation-exclude select-none grid h-[15px] w-[15px] shrink-0 place-items-center border-[1.5px] text-[11px] leading-none',
544
- question.kind === 'multi' ? 'rounded-[3px]' : 'rounded-full',
545
- otherOn ? 'border-solid border-primary bg-primary text-primary-foreground' : 'border-dashed border-border text-muted-foreground',
546
- )}
547
- >
548
- {otherOn ? <CheckGlyph /> : '+'}
549
- </span>
550
- <input
551
- type="text"
552
- value={other}
553
- onChange={(e) => setOther(e.target.value)}
554
- disabled={readOnly}
555
- placeholder={readOnly ? '' : 'Other…'}
556
- aria-label={number > 0 ? `Other answer to question ${number}` : 'Other answer'}
557
- 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"
558
- />
706
+ </div>
707
+ )}
559
708
  </div>
560
- </div>
709
+ </>
561
710
  )}
562
711
 
563
712
  {noteOpen && (!readOnly || !!answer?.note?.trim()) && (
@@ -612,7 +761,7 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
612
761
  {/* Plannotator's own footer (no host actions, no save bar) keeps the
613
762
  Accept button as the one right-aligned item. */}
614
763
  {!hasHostFooter && !dirty ? (
615
- !readOnly && !answered && !settled && hasRecommendation && (
764
+ offerAccept && (
616
765
  <button
617
766
  type="button"
618
767
  onClick={acceptRecommended}
@@ -623,7 +772,7 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
623
772
  )
624
773
  ) : (
625
774
  <div className="ml-auto flex items-center gap-1">
626
- {!readOnly && !answered && !settled && hasRecommendation && (
775
+ {offerAccept && (
627
776
  <button
628
777
  type="button"
629
778
  onClick={acceptRecommended}
@@ -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';