@plannotator/ui 0.47.0 → 0.49.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 +79 -0
- package/README.md +33 -1
- package/components/AnnotationPanel.tsx +44 -3
- package/components/BlockRenderer.tsx +55 -1
- package/components/CompletionOverlay.tsx +23 -1
- package/components/PRPlatformIcon.tsx +22 -0
- package/components/QuestionProgressChip.tsx +66 -0
- package/components/QuestionsPanelSection.tsx +121 -0
- package/components/Settings.tsx +25 -1
- package/components/Viewer.tsx +169 -6
- package/components/ai/AIProviderBar.tsx +3 -3
- package/components/blocks/QuestionBlock.tsx +591 -0
- package/config/settings.ts +17 -0
- package/hooks/useAnnotationHighlighter.ts +4 -0
- package/hooks/usePinpoint.ts +10 -0
- package/hooks/useScrollKeyRouting.ts +157 -0
- package/hooks/useUndoHistory.ts +17 -0
- package/hooks/useVimDocumentFocus.ts +2 -1
- package/package.json +2 -2
- package/styles.css +1 -1
- package/types.ts +3 -0
- package/utils/autoUpdateNotice.ts +59 -0
- package/utils/parser.ts +125 -563
- package/utils/questionAnswers.ts +227 -0
|
@@ -0,0 +1,591 @@
|
|
|
1
|
+
import React, { useId, useRef, useState } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
emptyQuestionAnswer,
|
|
4
|
+
isQuestionAnswerEmpty,
|
|
5
|
+
questionStatus,
|
|
6
|
+
recommendedQuestionAnswer,
|
|
7
|
+
type IndexedQuestion,
|
|
8
|
+
type ParsedQuestion,
|
|
9
|
+
type QuestionAnswer,
|
|
10
|
+
} from '@plannotator/core/question-block';
|
|
11
|
+
import { InlineMarkdown } from '../InlineMarkdown';
|
|
12
|
+
import { renderProseBody } from './proseBody';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A `:::question` block rendered as an answer card (the approved "Card"
|
|
16
|
+
* variant): an eyebrow with "Question N of M" and a status tag, the prompt,
|
|
17
|
+
* its context, then the choices (native radios / checkboxes plus an always
|
|
18
|
+
* present "Other…"), or a text box for a free-text question, and a footer
|
|
19
|
+
* with Add note · Skip · Accept recommended.
|
|
20
|
+
*
|
|
21
|
+
* The prompt, context and choice text stay ordinary document prose, so a
|
|
22
|
+
* reviewer can select and annotate any of it; only the chrome (eyebrow, tags,
|
|
23
|
+
* footer) is `annotation-exclude`. A click on a choice row picks it only when
|
|
24
|
+
* the pointer barely moved and no text is selected, so a drag across option
|
|
25
|
+
* text makes an annotation, never a pick.
|
|
26
|
+
*
|
|
27
|
+
* State is derived: `answer` comes from the host (Plannotator: the document's
|
|
28
|
+
* annotation carrying `questionAnswer` for this key). Two ways to write:
|
|
29
|
+
* - live (`onAnswer`): every change calls it with the next answer, or null
|
|
30
|
+
* when it became empty; the host upserts or removes the annotation.
|
|
31
|
+
* - explicit save (`onSaveAnswer`): changes stay a local draft; the footer
|
|
32
|
+
* shows Save answer and Cancel (and no Skip), Save calls `onSaveAnswer`
|
|
33
|
+
* once, Cancel returns to the host's answer.
|
|
34
|
+
* With neither the block is read-only.
|
|
35
|
+
*
|
|
36
|
+
* A host can add its own actions at the right of the footer
|
|
37
|
+
* (`renderFooter`), and a question carrying `Decision: when answered` or a
|
|
38
|
+
* `Decision: [statement](url)` link shows that beside the prompt.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/** Pointer travel (px) above which a press on a choice row is a drag. */
|
|
42
|
+
const DRAG_THRESHOLD_MOUSE = 4;
|
|
43
|
+
const DRAG_THRESHOLD_TOUCH = 10;
|
|
44
|
+
/** A second click on the same choice within this window is a double-click:
|
|
45
|
+
* it selects a word, so the pick its first click made is taken back. */
|
|
46
|
+
const DOUBLE_CLICK_REVERT_MS = 800;
|
|
47
|
+
|
|
48
|
+
const now = (): number => (typeof performance !== 'undefined' ? performance.now() : Date.now());
|
|
49
|
+
|
|
50
|
+
const cx = (...parts: Array<string | false | null | undefined>) => parts.filter(Boolean).join(' ');
|
|
51
|
+
|
|
52
|
+
const Tag: React.FC<{ tone: 'rec' | 'ok' | 'skip' | 'open'; children: React.ReactNode; className?: string }> = ({ tone, children, className }) => (
|
|
53
|
+
<span
|
|
54
|
+
className={cx(
|
|
55
|
+
'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]',
|
|
56
|
+
tone === 'rec' && 'bg-primary/15 text-primary',
|
|
57
|
+
tone === 'ok' && 'bg-success/15 text-success',
|
|
58
|
+
tone === 'skip' && 'bg-muted text-muted-foreground',
|
|
59
|
+
tone === 'open' && 'border border-dashed border-border text-muted-foreground',
|
|
60
|
+
className,
|
|
61
|
+
)}
|
|
62
|
+
data-pinpoint-ignore=""
|
|
63
|
+
>
|
|
64
|
+
{children}
|
|
65
|
+
</span>
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
const CheckGlyph = () => (
|
|
69
|
+
<svg viewBox="0 0 16 16" width="10" height="10" fill="none" stroke="currentColor" strokeWidth="2.2" aria-hidden="true">
|
|
70
|
+
<path d="m3.5 8.5 3 3 6-7" strokeLinecap="round" strokeLinejoin="round" />
|
|
71
|
+
</svg>
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
const DiamondGlyph = () => (
|
|
75
|
+
<svg viewBox="0 0 24 24" width="10" height="10" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true" className="shrink-0">
|
|
76
|
+
<path d="M2.7 10.3a2.41 2.41 0 0 0 0 3.41l7.59 7.59a2.41 2.41 0 0 0 3.41 0l7.59-7.59a2.41 2.41 0 0 0 0-3.41l-7.59-7.59a2.41 2.41 0 0 0-3.41 0Z" strokeLinejoin="round" />
|
|
77
|
+
</svg>
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
/** Two answers say the same thing (identity and line ignored). */
|
|
81
|
+
const sameAnswer = (a: QuestionAnswer | null | undefined, b: QuestionAnswer | null | undefined): boolean => {
|
|
82
|
+
const norm = (x: QuestionAnswer | null | undefined) =>
|
|
83
|
+
!x || isQuestionAnswerEmpty(x)
|
|
84
|
+
? ''
|
|
85
|
+
: JSON.stringify([x.selected, x.other ?? '', x.text ?? '', x.note ?? '', !!x.skipped]);
|
|
86
|
+
return norm(a) === norm(b);
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/** What `onSaveAnswer` may return: nothing (the save is done), or a promise
|
|
90
|
+
* the card waits on (rejecting keeps the draft so the reviewer can retry). */
|
|
91
|
+
export type QuestionSaveResult = void | Promise<unknown>;
|
|
92
|
+
|
|
93
|
+
export interface QuestionBlockProps {
|
|
94
|
+
blockId: string;
|
|
95
|
+
indexed: IndexedQuestion;
|
|
96
|
+
/** How many question blocks the document has (for "Question N of M"). */
|
|
97
|
+
total: number;
|
|
98
|
+
answer?: QuestionAnswer;
|
|
99
|
+
onAnswer?: (blockId: string, answer: QuestionAnswer | null, key: string) => void;
|
|
100
|
+
/** Explicit save mode (takes precedence over `onAnswer`): edits stay a
|
|
101
|
+
* draft in the card until Save, which calls this with the question's key
|
|
102
|
+
* and the answer (null when the draft is empty). The host stores it and
|
|
103
|
+
* passes it back as `answer`. */
|
|
104
|
+
onSaveAnswer?: (key: string, answer: QuestionAnswer | null) => QuestionSaveResult;
|
|
105
|
+
/** Label of the explicit-save-mode Save button (default "Save answer"),
|
|
106
|
+
* or a function of the question that returns it. */
|
|
107
|
+
saveLabel?: string | ((question: ParsedQuestion) => string);
|
|
108
|
+
/** Host actions at the right end of the footer, given the question and its
|
|
109
|
+
* saved answer (never the unsaved draft). Rendered in read-only cards too;
|
|
110
|
+
* return null for nothing. */
|
|
111
|
+
renderFooter?: (question: IndexedQuestion, answer: QuestionAnswer | undefined) => React.ReactNode;
|
|
112
|
+
onOpenLinkedDoc?: (path: string) => void;
|
|
113
|
+
onOpenCodeFile?: (path: string) => void;
|
|
114
|
+
imageBaseDir?: string;
|
|
115
|
+
onImageClick?: (src: string, alt: string) => void;
|
|
116
|
+
githubRepo?: string;
|
|
117
|
+
repoHost?: string;
|
|
118
|
+
onNavigateAnchor?: (hash: string) => void;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export const QuestionBlock: React.FC<QuestionBlockProps> = ({
|
|
122
|
+
blockId,
|
|
123
|
+
indexed,
|
|
124
|
+
total,
|
|
125
|
+
answer: savedAnswer,
|
|
126
|
+
onAnswer: onLiveAnswer,
|
|
127
|
+
onSaveAnswer,
|
|
128
|
+
saveLabel,
|
|
129
|
+
renderFooter,
|
|
130
|
+
onOpenLinkedDoc,
|
|
131
|
+
onOpenCodeFile,
|
|
132
|
+
imageBaseDir,
|
|
133
|
+
onImageClick,
|
|
134
|
+
githubRepo,
|
|
135
|
+
repoHost,
|
|
136
|
+
onNavigateAnchor,
|
|
137
|
+
}) => {
|
|
138
|
+
const { question, number } = indexed;
|
|
139
|
+
const saveMode = !!onSaveAnswer;
|
|
140
|
+
// Save mode: the unsaved draft (null = the reviewer emptied it). Absent
|
|
141
|
+
// means the card shows the host's answer.
|
|
142
|
+
const [draft, setDraft] = useState<{ answer: QuestionAnswer | null } | null>(null);
|
|
143
|
+
const [saving, setSaving] = useState(false);
|
|
144
|
+
const answer: QuestionAnswer | undefined = saveMode && draft ? draft.answer ?? undefined : savedAnswer;
|
|
145
|
+
const dirty = saveMode && !!draft && !sameAnswer(draft.answer, savedAnswer);
|
|
146
|
+
// Every edit goes through here: straight to the host in live mode, into the
|
|
147
|
+
// draft in save mode.
|
|
148
|
+
// A draft edited back to the saved answer is dropped, so a later host
|
|
149
|
+
// update to `answer` shows instead of a stale copy.
|
|
150
|
+
const onAnswer = onSaveAnswer
|
|
151
|
+
? (_blockId: string, next: QuestionAnswer | null) =>
|
|
152
|
+
setDraft(sameAnswer(next, savedAnswer) ? null : { answer: next })
|
|
153
|
+
: onLiveAnswer;
|
|
154
|
+
const readOnly = !onAnswer;
|
|
155
|
+
|
|
156
|
+
const save = () => {
|
|
157
|
+
if (!onSaveAnswer || !draft || saving) return;
|
|
158
|
+
const next = draft.answer && !isQuestionAnswerEmpty(draft.answer) ? draft.answer : null;
|
|
159
|
+
const submitted = draft;
|
|
160
|
+
const result = onSaveAnswer(question.key, next);
|
|
161
|
+
if (result && typeof (result as Promise<unknown>).then === 'function') {
|
|
162
|
+
setSaving(true);
|
|
163
|
+
(result as Promise<unknown>).then(
|
|
164
|
+
() => {
|
|
165
|
+
setSaving(false);
|
|
166
|
+
// An edit made while the save was in flight stays a draft.
|
|
167
|
+
setDraft((current) => (current === submitted ? null : current));
|
|
168
|
+
},
|
|
169
|
+
// A failed save keeps the draft; the host reports the error.
|
|
170
|
+
() => setSaving(false),
|
|
171
|
+
);
|
|
172
|
+
} else {
|
|
173
|
+
setDraft(null);
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
const cancel = () => {
|
|
177
|
+
setDraft(null);
|
|
178
|
+
setNotePinned(null);
|
|
179
|
+
};
|
|
180
|
+
const hostFooter = renderFooter?.(indexed, savedAnswer);
|
|
181
|
+
const hasHostFooter = hostFooter !== null && hostFooter !== undefined && hostFooter !== false;
|
|
182
|
+
const uid = useId().replace(/:/g, '');
|
|
183
|
+
const promptId = `q-prompt-${uid}`;
|
|
184
|
+
const contextId = `q-context-${uid}`;
|
|
185
|
+
const suggestId = `q-suggest-${uid}`;
|
|
186
|
+
const noteLabelId = `q-note-${uid}`;
|
|
187
|
+
// null = follow the answer (open while a note exists); a click pins it.
|
|
188
|
+
const [notePinned, setNotePinned] = useState<boolean | null>(null);
|
|
189
|
+
const noteOpen = notePinned ?? !!answer?.note?.trim();
|
|
190
|
+
const pressRef = useRef<{ x: number; y: number; touch: boolean } | null>(null);
|
|
191
|
+
const noteRef = useRef<HTMLTextAreaElement>(null);
|
|
192
|
+
const lastPickRef = useRef<{ label: string; before: QuestionAnswer | undefined; at: number } | null>(null);
|
|
193
|
+
|
|
194
|
+
const inline = (text: string) => (
|
|
195
|
+
<InlineMarkdown
|
|
196
|
+
text={text}
|
|
197
|
+
imageBaseDir={imageBaseDir}
|
|
198
|
+
onImageClick={onImageClick}
|
|
199
|
+
onOpenLinkedDoc={onOpenLinkedDoc}
|
|
200
|
+
onOpenCodeFile={onOpenCodeFile}
|
|
201
|
+
githubRepo={githubRepo}
|
|
202
|
+
repoHost={repoHost}
|
|
203
|
+
onNavigateAnchor={onNavigateAnchor}
|
|
204
|
+
/>
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
// A note alone does not change a settled choice: the [x] stays drawn (and
|
|
208
|
+
// the status stays Settled) until the reviewer picks, fills Other or skips.
|
|
209
|
+
const status = questionStatus(question, answer);
|
|
210
|
+
const answered = status === 'answered';
|
|
211
|
+
const skipped = status === 'skipped';
|
|
212
|
+
const settled = status === 'settled';
|
|
213
|
+
const settledLabels = question.choices.filter((c) => c.settled).map((c) => c.label);
|
|
214
|
+
const hasRecommendation = question.choices.some((c) => c.recommended) || !!question.suggestedText;
|
|
215
|
+
const selected = answered || skipped ? answer!.selected : settledLabels;
|
|
216
|
+
const base = answer ?? emptyQuestionAnswer(indexed);
|
|
217
|
+
|
|
218
|
+
const commit = (next: QuestionAnswer) => {
|
|
219
|
+
if (!onAnswer) return;
|
|
220
|
+
onAnswer(blockId, isQuestionAnswerEmpty(next) ? null : next, question.key);
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
const pick = (label: string, checked: boolean) => {
|
|
224
|
+
lastPickRef.current = { label, before: answer, at: now() };
|
|
225
|
+
const { skipped: _s, ...rest } = base;
|
|
226
|
+
if (question.kind === 'multi') {
|
|
227
|
+
const nextSelected = checked
|
|
228
|
+
? [...selected.filter((l) => l !== label), label]
|
|
229
|
+
: selected.filter((l) => l !== label);
|
|
230
|
+
// Keep document order, not click order.
|
|
231
|
+
const order = question.choices.map((c) => c.label);
|
|
232
|
+
nextSelected.sort((a, b) => order.indexOf(a) - order.indexOf(b));
|
|
233
|
+
commit({ ...rest, selected: nextSelected });
|
|
234
|
+
} else if (checked) {
|
|
235
|
+
const { other: _o, ...single } = rest;
|
|
236
|
+
commit({ ...single, selected: [label] });
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
const setOther = (value: string) => {
|
|
241
|
+
const { skipped: _s, other: _o, ...rest } = base;
|
|
242
|
+
const keepSelected = question.kind === 'multi' ? selected : value.trim() ? [] : rest.selected;
|
|
243
|
+
commit({ ...rest, selected: keepSelected, ...(value ? { other: value } : {}) });
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
const setText = (value: string) => {
|
|
247
|
+
const { skipped: _s, text: _t, ...rest } = base;
|
|
248
|
+
commit({ ...rest, ...(value ? { text: value } : {}) });
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
const setNote = (value: string) => {
|
|
252
|
+
const { note: _n, ...rest } = base;
|
|
253
|
+
commit({ ...rest, ...(value ? { note: value } : {}) });
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
const toggleSkip = () => {
|
|
257
|
+
if (base.skipped) {
|
|
258
|
+
const { skipped: _s, ...rest } = base;
|
|
259
|
+
commit(rest);
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
const { other: _o, text: _t, ...rest } = base;
|
|
263
|
+
commit({ ...rest, selected: [], skipped: true });
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
const acceptRecommended = () => {
|
|
267
|
+
const next = recommendedQuestionAnswer(indexed, base);
|
|
268
|
+
if (next) commit(next);
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
const toggleNote = () => {
|
|
272
|
+
const opening = !noteOpen;
|
|
273
|
+
setNotePinned(opening);
|
|
274
|
+
if (opening) requestAnimationFrame(() => noteRef.current?.focus());
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
// Drag-vs-pick guard. A native <label> forwards its click to the input, so
|
|
278
|
+
// cancelling the click is what keeps a text drag from also picking. It runs
|
|
279
|
+
// in the capture phase so the cancel lands before any label activation.
|
|
280
|
+
const onRowPointerDown = (event: React.PointerEvent) => {
|
|
281
|
+
pressRef.current = { x: event.clientX, y: event.clientY, touch: event.pointerType === 'touch' };
|
|
282
|
+
};
|
|
283
|
+
const onRowClick = (event: React.MouseEvent, label: string) => {
|
|
284
|
+
const press = pressRef.current;
|
|
285
|
+
pressRef.current = null;
|
|
286
|
+
if (event.target instanceof HTMLInputElement) return;
|
|
287
|
+
// A double-click selects a word to annotate, never picks: the second
|
|
288
|
+
// click is cancelled, and the pick the first click made (if any, on this
|
|
289
|
+
// choice, just now) is taken back.
|
|
290
|
+
if (event.detail >= 2) {
|
|
291
|
+
event.preventDefault();
|
|
292
|
+
const last = lastPickRef.current;
|
|
293
|
+
lastPickRef.current = null;
|
|
294
|
+
if (onAnswer && last && last.label === label && now() - last.at <= DOUBLE_CLICK_REVERT_MS) {
|
|
295
|
+
const before = last.before;
|
|
296
|
+
onAnswer(blockId, before && !isQuestionAnswerEmpty(before) ? before : null, question.key);
|
|
297
|
+
}
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
const limit = press?.touch ? DRAG_THRESHOLD_TOUCH : DRAG_THRESHOLD_MOUSE;
|
|
301
|
+
const moved = !!press && Math.hypot(event.clientX - press.x, event.clientY - press.y) > limit;
|
|
302
|
+
const selection = typeof window !== 'undefined' ? window.getSelection() : null;
|
|
303
|
+
const selecting = !!selection && !selection.isCollapsed && selection.toString().trim() !== '';
|
|
304
|
+
if (moved || selecting) event.preventDefault();
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
const eyebrow = number > 0
|
|
308
|
+
? `Question ${number} of ${total}${question.kind === 'multi' ? ' · pick any' : ''}`
|
|
309
|
+
: `Question${question.kind === 'multi' ? ' · pick any' : ''}`;
|
|
310
|
+
const groupName = `question-${uid}`;
|
|
311
|
+
const inputType = question.kind === 'multi' ? 'checkbox' : 'radio';
|
|
312
|
+
const other = answer?.other ?? '';
|
|
313
|
+
const otherOn = other.trim() !== '';
|
|
314
|
+
|
|
315
|
+
const describedBy = [question.context ? contextId : null, question.kind === 'text' && question.suggestedText && !answered ? suggestId : null]
|
|
316
|
+
.filter(Boolean)
|
|
317
|
+
.join(' ') || undefined;
|
|
318
|
+
|
|
319
|
+
return (
|
|
320
|
+
<fieldset
|
|
321
|
+
className={cx(
|
|
322
|
+
'question-block my-5 min-w-0 rounded-[10px] border px-3.5 pb-2.5 pt-3',
|
|
323
|
+
// Native radios/checkboxes follow the theme's mode. ThemeProvider marks
|
|
324
|
+
// light mode with `.light` on the root; a host without theme classes
|
|
325
|
+
// keeps the page's own color-scheme.
|
|
326
|
+
'[:root[class*=theme-]:not(.light)_&]:[color-scheme:dark]',
|
|
327
|
+
'bg-[color-mix(in_oklab,var(--background)_35%,var(--card))]',
|
|
328
|
+
answered ? 'border-primary/45' : 'border-border/85',
|
|
329
|
+
)}
|
|
330
|
+
data-block-id={blockId}
|
|
331
|
+
data-block-type="directive"
|
|
332
|
+
data-directive-kind={question.directiveKind}
|
|
333
|
+
data-question-key={question.key}
|
|
334
|
+
data-question-status={status}
|
|
335
|
+
aria-labelledby={promptId}
|
|
336
|
+
aria-describedby={describedBy}
|
|
337
|
+
aria-disabled={readOnly || undefined}
|
|
338
|
+
data-question-decision={question.decision ? 'recorded' : question.decisionOnAnswer ? 'on-answer' : undefined}
|
|
339
|
+
>
|
|
340
|
+
<div className="annotation-exclude select-none mb-1.5 flex items-center gap-2" data-pinpoint-ignore="">
|
|
341
|
+
<span className="text-[10.5px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{eyebrow}</span>
|
|
342
|
+
<span className="ml-auto">
|
|
343
|
+
{question.decision && <><Tag tone="rec"><DiamondGlyph />Decision</Tag>{' '}</>}
|
|
344
|
+
{!question.decision && question.decisionOnAnswer && <><Tag tone="rec"><DiamondGlyph />Records a decision</Tag>{' '}</>}
|
|
345
|
+
{status === 'answered' && <Tag tone="ok"><CheckGlyph />Answered</Tag>}
|
|
346
|
+
{status === 'settled' && <Tag tone="ok"><CheckGlyph />Settled</Tag>}
|
|
347
|
+
{status === 'skipped' && <Tag tone="skip">Skipped</Tag>}
|
|
348
|
+
{status === 'open' && <Tag tone="open">Open</Tag>}
|
|
349
|
+
</span>
|
|
350
|
+
</div>
|
|
351
|
+
|
|
352
|
+
<p id={promptId} className="m-0 text-[15px] font-semibold leading-[1.45] text-foreground">
|
|
353
|
+
{inline(question.prompt)}
|
|
354
|
+
</p>
|
|
355
|
+
{(question.decision || question.decisionOnAnswer) && (
|
|
356
|
+
<div
|
|
357
|
+
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"
|
|
358
|
+
data-pinpoint-ignore=""
|
|
359
|
+
data-question-decision-row=""
|
|
360
|
+
>
|
|
361
|
+
<DiamondGlyph />
|
|
362
|
+
{question.decision ? (
|
|
363
|
+
<span className="min-w-0">
|
|
364
|
+
<span className="select-none text-muted-foreground">Decision: </span>
|
|
365
|
+
<a
|
|
366
|
+
href={question.decision.url}
|
|
367
|
+
target="_blank"
|
|
368
|
+
rel="noopener noreferrer"
|
|
369
|
+
className="text-primary underline underline-offset-2 hover:text-primary/80"
|
|
370
|
+
>
|
|
371
|
+
{/* Plain text: inline markdown here could nest an autolink in the link. */}
|
|
372
|
+
{question.decision.statement}
|
|
373
|
+
</a>
|
|
374
|
+
</span>
|
|
375
|
+
) : (
|
|
376
|
+
<span className="select-none">Answering this records a decision</span>
|
|
377
|
+
)}
|
|
378
|
+
</div>
|
|
379
|
+
)}
|
|
380
|
+
{question.context && (
|
|
381
|
+
<div id={contextId} className="mt-1 text-[13px] leading-normal text-muted-foreground">
|
|
382
|
+
{renderProseBody({
|
|
383
|
+
body: question.context,
|
|
384
|
+
paragraphClassName: 'text-[13px] leading-normal',
|
|
385
|
+
listClassName: 'text-[13px] leading-normal',
|
|
386
|
+
imageBaseDir,
|
|
387
|
+
onImageClick,
|
|
388
|
+
onOpenLinkedDoc,
|
|
389
|
+
onOpenCodeFile,
|
|
390
|
+
onNavigateAnchor,
|
|
391
|
+
githubRepo,
|
|
392
|
+
repoHost,
|
|
393
|
+
})}
|
|
394
|
+
</div>
|
|
395
|
+
)}
|
|
396
|
+
|
|
397
|
+
{question.kind === 'text' ? (
|
|
398
|
+
<>
|
|
399
|
+
{question.suggestedText && !answered && (
|
|
400
|
+
<div id={suggestId} className="mt-2 flex items-start gap-2 text-[12.5px] text-muted-foreground">
|
|
401
|
+
<span className="min-w-0 flex-1">
|
|
402
|
+
<Tag tone="rec">Suggested</Tag> {inline(question.suggestedText)}
|
|
403
|
+
</span>
|
|
404
|
+
{!readOnly && (
|
|
405
|
+
<button
|
|
406
|
+
type="button"
|
|
407
|
+
onClick={acceptRecommended}
|
|
408
|
+
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"
|
|
409
|
+
>
|
|
410
|
+
Use
|
|
411
|
+
</button>
|
|
412
|
+
)}
|
|
413
|
+
</div>
|
|
414
|
+
)}
|
|
415
|
+
<textarea
|
|
416
|
+
className={cx(
|
|
417
|
+
// annotation-exclude: typed text is not document text, so a
|
|
418
|
+
// text-search restore of another annotation never lands in it.
|
|
419
|
+
'annotation-exclude mt-2.5 block min-h-[74px] w-full resize-y rounded-[7px] border border-border bg-background px-2.5 py-2 text-[13.5px] leading-normal text-foreground outline-none placeholder:text-muted-foreground/70 focus:border-ring focus:ring-1 focus:ring-ring disabled:cursor-default',
|
|
420
|
+
skipped && 'opacity-55',
|
|
421
|
+
)}
|
|
422
|
+
aria-labelledby={promptId}
|
|
423
|
+
placeholder={readOnly ? '' : 'Type your answer…'}
|
|
424
|
+
value={answer?.text ?? ''}
|
|
425
|
+
onChange={(e) => setText(e.target.value)}
|
|
426
|
+
disabled={readOnly}
|
|
427
|
+
/>
|
|
428
|
+
</>
|
|
429
|
+
) : (
|
|
430
|
+
<div
|
|
431
|
+
className={cx('mt-2.5 grid gap-0.5', skipped && 'opacity-55')}
|
|
432
|
+
role={inputType === 'radio' ? 'radiogroup' : 'group'}
|
|
433
|
+
aria-labelledby={promptId}
|
|
434
|
+
>
|
|
435
|
+
{question.choices.map((choice) => {
|
|
436
|
+
const checked = selected.includes(choice.label);
|
|
437
|
+
return (
|
|
438
|
+
<label
|
|
439
|
+
key={choice.label}
|
|
440
|
+
className={cx(
|
|
441
|
+
'flex items-start gap-2.5 rounded-[7px] px-[9px] py-1.5 transition-colors',
|
|
442
|
+
readOnly ? 'cursor-default' : 'cursor-pointer hover:bg-muted/45',
|
|
443
|
+
checked && 'bg-primary/12 hover:bg-primary/12',
|
|
444
|
+
)}
|
|
445
|
+
data-question-option=""
|
|
446
|
+
onPointerDown={onRowPointerDown}
|
|
447
|
+
onClickCapture={(event) => onRowClick(event, choice.label)}
|
|
448
|
+
>
|
|
449
|
+
<input
|
|
450
|
+
type={inputType}
|
|
451
|
+
name={groupName}
|
|
452
|
+
checked={checked}
|
|
453
|
+
onChange={(e) => pick(choice.label, e.target.checked)}
|
|
454
|
+
className="mt-[3px] h-[15px] w-[15px] shrink-0 cursor-pointer accent-primary disabled:cursor-default"
|
|
455
|
+
disabled={readOnly}
|
|
456
|
+
/>
|
|
457
|
+
<span className="min-w-0 flex-1 select-text text-[13.5px] leading-[1.45] text-foreground">
|
|
458
|
+
<span className="font-[550]">{inline(choice.label)}</span>
|
|
459
|
+
{choice.description && (
|
|
460
|
+
<span className="text-muted-foreground"> — {inline(choice.description)}</span>
|
|
461
|
+
)}
|
|
462
|
+
{choice.recommended && <> <Tag tone="rec">Recommended</Tag></>}
|
|
463
|
+
</span>
|
|
464
|
+
</label>
|
|
465
|
+
);
|
|
466
|
+
})}
|
|
467
|
+
<div className="flex items-center gap-2.5 rounded-[7px] px-[9px] py-[5px]">
|
|
468
|
+
<span
|
|
469
|
+
aria-hidden="true"
|
|
470
|
+
className={cx(
|
|
471
|
+
'annotation-exclude select-none grid h-[15px] w-[15px] shrink-0 place-items-center border-[1.5px] text-[11px] leading-none',
|
|
472
|
+
question.kind === 'multi' ? 'rounded-[3px]' : 'rounded-full',
|
|
473
|
+
otherOn ? 'border-solid border-primary bg-primary text-primary-foreground' : 'border-dashed border-border text-muted-foreground',
|
|
474
|
+
)}
|
|
475
|
+
>
|
|
476
|
+
{otherOn ? <CheckGlyph /> : '+'}
|
|
477
|
+
</span>
|
|
478
|
+
<input
|
|
479
|
+
type="text"
|
|
480
|
+
value={other}
|
|
481
|
+
onChange={(e) => setOther(e.target.value)}
|
|
482
|
+
disabled={readOnly}
|
|
483
|
+
placeholder={readOnly ? '' : 'Other…'}
|
|
484
|
+
aria-label={number > 0 ? `Other answer to question ${number}` : 'Other answer'}
|
|
485
|
+
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"
|
|
486
|
+
/>
|
|
487
|
+
</div>
|
|
488
|
+
</div>
|
|
489
|
+
)}
|
|
490
|
+
|
|
491
|
+
{noteOpen && (!readOnly || !!answer?.note?.trim()) && (
|
|
492
|
+
<div className="mt-2">
|
|
493
|
+
<p id={noteLabelId} className="annotation-exclude select-none mb-1 ml-0.5 text-[10.5px] font-semibold uppercase tracking-[0.07em] text-muted-foreground">
|
|
494
|
+
Note
|
|
495
|
+
</p>
|
|
496
|
+
<textarea
|
|
497
|
+
ref={noteRef}
|
|
498
|
+
className="annotation-exclude block min-h-[54px] w-full resize-y rounded-[7px] border border-border bg-background px-2.5 py-2 text-[13.5px] leading-normal text-foreground outline-none placeholder:text-muted-foreground/70 focus:border-ring focus:ring-1 focus:ring-ring"
|
|
499
|
+
aria-labelledby={noteLabelId}
|
|
500
|
+
placeholder="Context for the agent…"
|
|
501
|
+
value={answer?.note ?? ''}
|
|
502
|
+
onChange={(e) => setNote(e.target.value)}
|
|
503
|
+
disabled={readOnly}
|
|
504
|
+
/>
|
|
505
|
+
</div>
|
|
506
|
+
)}
|
|
507
|
+
|
|
508
|
+
{(!readOnly || hasHostFooter) && (
|
|
509
|
+
<div
|
|
510
|
+
className="annotation-exclude select-none mt-2 flex items-center gap-1 border-t border-border/55 pt-1.5"
|
|
511
|
+
data-pinpoint-ignore=""
|
|
512
|
+
>
|
|
513
|
+
{!readOnly && (
|
|
514
|
+
<button
|
|
515
|
+
type="button"
|
|
516
|
+
onClick={toggleNote}
|
|
517
|
+
aria-expanded={noteOpen}
|
|
518
|
+
className={cx(
|
|
519
|
+
'rounded-[5px] px-[7px] py-[3px] text-xs hover:bg-muted hover:text-foreground',
|
|
520
|
+
noteOpen ? 'text-foreground' : 'text-muted-foreground',
|
|
521
|
+
)}
|
|
522
|
+
>
|
|
523
|
+
{noteOpen ? 'Hide note' : 'Add note'}
|
|
524
|
+
</button>
|
|
525
|
+
)}
|
|
526
|
+
{/* Skip is a live-mode draft state; an explicit save never writes it. */}
|
|
527
|
+
{!readOnly && !saveMode && (
|
|
528
|
+
<button
|
|
529
|
+
type="button"
|
|
530
|
+
onClick={toggleSkip}
|
|
531
|
+
aria-pressed={skipped}
|
|
532
|
+
className={cx(
|
|
533
|
+
'rounded-[5px] px-[7px] py-[3px] text-xs hover:bg-muted hover:text-foreground',
|
|
534
|
+
skipped ? 'text-foreground' : 'text-muted-foreground',
|
|
535
|
+
)}
|
|
536
|
+
>
|
|
537
|
+
{skipped ? 'Unskip' : 'Skip'}
|
|
538
|
+
</button>
|
|
539
|
+
)}
|
|
540
|
+
{/* Plannotator's own footer (no host actions, no save bar) keeps the
|
|
541
|
+
Accept button as the one right-aligned item. */}
|
|
542
|
+
{!hasHostFooter && !dirty ? (
|
|
543
|
+
!readOnly && !answered && !settled && hasRecommendation && (
|
|
544
|
+
<button
|
|
545
|
+
type="button"
|
|
546
|
+
onClick={acceptRecommended}
|
|
547
|
+
className="ml-auto rounded-md border border-primary/45 bg-primary/10 px-2.5 py-[3px] text-[12.5px] font-medium text-primary hover:bg-primary/20 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
|
|
548
|
+
>
|
|
549
|
+
Accept recommended
|
|
550
|
+
</button>
|
|
551
|
+
)
|
|
552
|
+
) : (
|
|
553
|
+
<div className="ml-auto flex items-center gap-1">
|
|
554
|
+
{!readOnly && !answered && !settled && hasRecommendation && (
|
|
555
|
+
<button
|
|
556
|
+
type="button"
|
|
557
|
+
onClick={acceptRecommended}
|
|
558
|
+
className="rounded-md border border-primary/45 bg-primary/10 px-2.5 py-[3px] text-[12.5px] font-medium text-primary hover:bg-primary/20 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
|
|
559
|
+
>
|
|
560
|
+
Accept recommended
|
|
561
|
+
</button>
|
|
562
|
+
)}
|
|
563
|
+
{hasHostFooter && <span className="inline-flex items-center gap-1" data-question-host-footer="">{hostFooter}</span>}
|
|
564
|
+
{dirty && (
|
|
565
|
+
<>
|
|
566
|
+
<button
|
|
567
|
+
type="button"
|
|
568
|
+
onClick={cancel}
|
|
569
|
+
disabled={saving}
|
|
570
|
+
className="rounded-[5px] px-[7px] py-[3px] text-xs text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-60"
|
|
571
|
+
>
|
|
572
|
+
Cancel
|
|
573
|
+
</button>
|
|
574
|
+
<button
|
|
575
|
+
type="button"
|
|
576
|
+
onClick={save}
|
|
577
|
+
disabled={saving}
|
|
578
|
+
aria-busy={saving || undefined}
|
|
579
|
+
className="rounded-md bg-primary px-2.5 py-[3px] text-[12.5px] font-medium text-primary-foreground hover:bg-primary/90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:opacity-60"
|
|
580
|
+
>
|
|
581
|
+
{(typeof saveLabel === 'function' ? saveLabel(question) : saveLabel) || 'Save answer'}
|
|
582
|
+
</button>
|
|
583
|
+
</>
|
|
584
|
+
)}
|
|
585
|
+
</div>
|
|
586
|
+
)}
|
|
587
|
+
</div>
|
|
588
|
+
)}
|
|
589
|
+
</fieldset>
|
|
590
|
+
);
|
|
591
|
+
};
|
package/config/settings.ts
CHANGED
|
@@ -599,6 +599,23 @@ export const SETTINGS = {
|
|
|
599
599
|
},
|
|
600
600
|
toServer: (value: boolean) => ({ reviewAnalysis: { callFlow: value } }),
|
|
601
601
|
},
|
|
602
|
+
/**
|
|
603
|
+
* Opt-in background auto-update of the compiled CLI (#1634). The server
|
|
604
|
+
* always sends the config-file value (default false), so the cookie is only
|
|
605
|
+
* a mirror and can never turn this on by itself.
|
|
606
|
+
*/
|
|
607
|
+
autoUpdate: {
|
|
608
|
+
defaultValue: false as boolean,
|
|
609
|
+
fromCookie: () => {
|
|
610
|
+
const v = storage.getItem('plannotator-auto-update');
|
|
611
|
+
return v === 'true' ? true : v === 'false' ? false : undefined;
|
|
612
|
+
},
|
|
613
|
+
toCookie: (v: boolean) => storage.setItem('plannotator-auto-update', String(v)),
|
|
614
|
+
serverKey: 'autoUpdate',
|
|
615
|
+
fromServer: (sc: Record<string, unknown>) =>
|
|
616
|
+
typeof sc.autoUpdate === 'boolean' ? sc.autoUpdate : undefined,
|
|
617
|
+
toServer: (v: boolean) => ({ autoUpdate: v }),
|
|
618
|
+
},
|
|
602
619
|
conventionalComments: {
|
|
603
620
|
defaultValue: false as boolean,
|
|
604
621
|
fromCookie: () => {
|
|
@@ -1105,6 +1105,10 @@ export function useAnnotationHighlighter({
|
|
|
1105
1105
|
// verdict, so it is neither attempted nor unanchored here (the same
|
|
1106
1106
|
// rule the raw-HTML pinpoints follow on their surface).
|
|
1107
1107
|
if (ann.diagramAnchor) return;
|
|
1108
|
+
// An answer to a `:::question` block quotes the prompt but is not a
|
|
1109
|
+
// selection: the question block draws it from the annotation, so it is
|
|
1110
|
+
// never painted as a highlight (and never reported as unanchored here).
|
|
1111
|
+
if (ann.questionAnswer) return;
|
|
1108
1112
|
// #881: a row whose quote is only whitespace has nothing to anchor to.
|
|
1109
1113
|
// Older drafts carry such rows (a double-click at a block boundary used
|
|
1110
1114
|
// to create one), and the stored positions still resolve, so restoring
|
package/hooks/usePinpoint.ts
CHANGED
|
@@ -137,6 +137,10 @@ export function usePinpoint({
|
|
|
137
137
|
|
|
138
138
|
const handleClick = (e: MouseEvent) => {
|
|
139
139
|
const target = e.target as HTMLElement;
|
|
140
|
+
// A `:::question` card's form controls are the answer UI, never a pin
|
|
141
|
+
// target: a click on a radio, a checkbox or a text box answers.
|
|
142
|
+
const inQuestion = !!target.closest?.('fieldset.question-block');
|
|
143
|
+
if (inQuestion && target.closest('input, textarea, select')) return;
|
|
140
144
|
const resolved = resolveSemanticTargetAtPoint(
|
|
141
145
|
buildSemanticTargetGraph(container),
|
|
142
146
|
target,
|
|
@@ -144,6 +148,12 @@ export function usePinpoint({
|
|
|
144
148
|
);
|
|
145
149
|
if (!resolved) return;
|
|
146
150
|
|
|
151
|
+
// Pinning a choice's text must not also pick the choice: the row is a
|
|
152
|
+
// <label>, which would forward this click to its radio or checkbox.
|
|
153
|
+
if (inQuestion && target.closest('[data-question-option]')) {
|
|
154
|
+
e.preventDefault();
|
|
155
|
+
}
|
|
156
|
+
|
|
147
157
|
// Prevent link navigation in pinpoint mode
|
|
148
158
|
const link = (target.closest('a') as HTMLAnchorElement | null);
|
|
149
159
|
if (link && container.contains(link)) {
|