@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.
- package/HANDOFF.md +317 -29
- package/README.md +35 -3
- package/components/AnnotationPanel.tsx +159 -107
- package/components/AnnotationToolbar.tsx +17 -14
- package/components/AnnotationToolstrip.tsx +9 -0
- package/components/BlockRenderer.tsx +26 -14
- package/components/CodeFilePopout.tsx +131 -68
- package/components/CommentPopover.tsx +6 -349
- package/components/ComposerTokenTextarea.tsx +379 -0
- package/components/ExportModal.tsx +6 -1
- package/components/HostTitle.tsx +108 -0
- package/components/ImageLightbox.tsx +288 -17
- package/components/ImagePeek.tsx +255 -0
- package/components/ImageResolverContext.tsx +31 -0
- package/components/InlineMarkdown.tsx +123 -15
- package/components/LocalLinkContext.tsx +49 -0
- package/components/MenuVersionSection.tsx +51 -25
- package/components/OpenInAppButton.tsx +18 -49
- package/components/PlanHeaderMenu.tsx +2 -8
- package/components/ProviderIcons.tsx +10 -4
- package/components/RenderedMarkdown.tsx +40 -4
- package/components/Settings.tsx +33 -5
- package/components/SkillReferenceMenu.tsx +28 -6
- package/components/Tooltip.tsx +51 -4
- package/components/Viewer.tsx +214 -55
- package/components/ai/AIProviderBar.tsx +11 -0
- package/components/ai/AskSessionSetupHint.tsx +12 -0
- package/components/ai/DocumentAIChatPanel.tsx +17 -5
- package/components/ai/SessionAskNotice.tsx +4 -3
- package/components/blocks/BlockquoteBlock.tsx +159 -0
- package/components/blocks/HtmlBlock.tsx +121 -9
- package/components/blocks/QuestionBlock.tsx +253 -104
- package/components/diagram/DiagramViewer.tsx +41 -4
- package/components/diagram/index.ts +1 -1
- package/components/html-viewer/HtmlViewer.tsx +73 -3
- package/components/html-viewer/bridge-script.asset.js +86 -0
- package/components/html-viewer/bridge-script.ts +86 -0
- package/components/html-viewer/useHtmlAnnotation.ts +51 -3
- package/components/icons/AgentIcons.tsx +9 -7
- package/components/plan-diff/PlanCleanDiffView.tsx +239 -36
- package/components/plan-diff/PlanDiffViewer.tsx +6 -1
- package/configure.ts +27 -1
- package/hooks/useAgentJobs.ts +8 -3
- package/hooks/useAnnotationHighlighter.ts +128 -23
- package/hooks/useExternalAnnotations.ts +6 -0
- package/hooks/useOpenInApps.ts +88 -0
- package/hooks/useUpdateCheck.ts +38 -98
- package/hooks/useUpdateCheckSetting.ts +43 -0
- package/hooks/useUpdateToast.ts +101 -0
- package/package.json +2 -2
- package/shortcuts/code-review/markdownPreview.shortcuts.ts +18 -0
- package/shortcuts/index.ts +1 -0
- package/styles.css +1 -1
- package/types.ts +48 -0
- package/utils/agentClosed.ts +26 -0
- package/utils/agentToolSetting.ts +16 -9
- package/utils/aiProvider.ts +7 -0
- package/utils/askSessionSetupHint.ts +27 -0
- package/utils/blockTargeting.ts +14 -3
- package/utils/parser.ts +65 -51
- package/utils/pasteInsert.ts +234 -0
- package/utils/planDiffEngine.ts +272 -2
- package/utils/skillCatalog.ts +15 -2
- package/utils/skillReferences.ts +50 -13
- package/utils/storage.ts +5 -0
- package/utils/textCut.ts +41 -0
- 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
|
|
41
|
-
*
|
|
42
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
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
|
|
131
|
-
*
|
|
132
|
-
* with a dotted outline and hides the
|
|
133
|
-
*
|
|
134
|
-
*
|
|
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)
|
|
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
|
|
359
|
-
//
|
|
360
|
-
|
|
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.
|
|
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 && (
|
|
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=
|
|
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
|
-
{(
|
|
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
|
-
{
|
|
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
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
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=
|
|
523
|
-
|
|
524
|
-
|
|
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
|
-
|
|
530
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
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';
|