@plannotator/ui 0.46.1 → 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 +114 -3
- package/README.md +41 -2
- package/components/AISettingsTab.tsx +6 -0
- package/components/AgentsTab.tsx +11 -1
- package/components/AnnotationPanel.tsx +44 -3
- package/components/BlockRenderer.tsx +55 -1
- package/components/CompletionOverlay.tsx +23 -1
- package/components/MarkdownEditor.tsx +9 -0
- package/components/ModelSourceHint.tsx +49 -0
- 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 +11 -3
- package/components/blocks/QuestionBlock.tsx +591 -0
- package/config/settings.ts +17 -0
- package/hooks/useAgentSettings.ts +8 -1
- package/hooks/useAnnotationHighlighter.ts +4 -0
- package/hooks/useCodeAnnotationDraft.ts +24 -7
- package/hooks/useModelCatalogs.ts +28 -11
- 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 +4 -4
- package/styles.css +1 -1
- package/types.ts +3 -0
- package/utils/aiProvider.ts +4 -0
- package/utils/autoUpdateNotice.ts +59 -0
- package/utils/decisionSpec.ts +36 -2
- 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: () => {
|
|
@@ -36,6 +36,13 @@ export const DEFAULT_GUIDE_CLAUDE_MODEL = 'sonnet';
|
|
|
36
36
|
// low effort chapter a diff well. Guide-scoped only — tour/review keep medium.
|
|
37
37
|
export const DEFAULT_GUIDE_CLAUDE_EFFORT = 'low';
|
|
38
38
|
export const DEFAULT_GUIDE_CODEX_MODEL = '';
|
|
39
|
+
/**
|
|
40
|
+
* The Codex model a guide uses for a user who has not picked one (saved ''),
|
|
41
|
+
* when the resolved catalog offers it; otherwise Codex's own marked default.
|
|
42
|
+
* A resolution preference only, never written to the cookie, so a saved pick
|
|
43
|
+
* always wins. Guide-scoped: review and tour keep Codex's default.
|
|
44
|
+
*/
|
|
45
|
+
export const PREFERRED_GUIDE_CODEX_MODEL = 'gpt-6-luna';
|
|
39
46
|
export const DEFAULT_GUIDE_CODEX_REASONING = 'low';
|
|
40
47
|
// No DEFAULT_GUIDE_CODEX_FAST: fast mode is deliberately not offered for
|
|
41
48
|
// guide (product decision — see AgentsTab's guide codex config block), so
|
|
@@ -463,7 +470,7 @@ export function useAgentSettings(catalogs?: ModelCatalogs) {
|
|
|
463
470
|
const tourCodexFast = effectiveFast(codexCatalog, state.tourCodex.model, tourCodexModel, state.tourCodex.perModel[state.tourCodex.model]?.fast ?? DEFAULT_TOUR_CODEX_FAST);
|
|
464
471
|
const guideClaudeModel = effectiveModel(claudeCatalog, state.guideClaude.model, DEFAULT_GUIDE_CLAUDE_MODEL);
|
|
465
472
|
const guideClaudeEffort = effectiveEffort(claudeCatalog, guideClaudeModel, state.guideClaude.perModel[state.guideClaude.model]?.effort ?? DEFAULT_GUIDE_CLAUDE_EFFORT);
|
|
466
|
-
const guideCodexModel = effectiveModel(codexCatalog, state.guideCodex.model,
|
|
473
|
+
const guideCodexModel = effectiveModel(codexCatalog, state.guideCodex.model, PREFERRED_GUIDE_CODEX_MODEL);
|
|
467
474
|
const guideCodexReasoning = effectiveEffort(codexCatalog, guideCodexModel, state.guideCodex.perModel[state.guideCodex.model]?.reasoning ?? DEFAULT_GUIDE_CODEX_REASONING);
|
|
468
475
|
// Guide offers no fast toggle; a re-key carries whatever is stored.
|
|
469
476
|
const guideCodexFast = state.guideCodex.perModel[state.guideCodex.model]?.fast ?? DEFAULT_CODEX_FAST;
|
|
@@ -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
|