@plannotator/ui 0.47.0 → 0.50.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/HANDOFF.md +147 -0
  2. package/LICENSE +191 -0
  3. package/README.md +56 -1
  4. package/components/AISettingsTab.tsx +1 -1
  5. package/components/AnnotationPanel.tsx +44 -3
  6. package/components/BlockRenderer.tsx +55 -1
  7. package/components/CompletionOverlay.tsx +23 -1
  8. package/components/DiffFileTree.tsx +363 -0
  9. package/components/ImageLightbox.tsx +66 -0
  10. package/components/PRPlatformIcon.tsx +22 -0
  11. package/components/ProviderIcons.tsx +15 -3
  12. package/components/QuestionProgressChip.tsx +66 -0
  13. package/components/QuestionsPanelSection.tsx +121 -0
  14. package/components/Settings.tsx +25 -1
  15. package/components/Viewer.tsx +170 -34
  16. package/components/ai/AIProviderBar.tsx +5 -5
  17. package/components/ai/DocumentAIChatPanel.tsx +28 -3
  18. package/components/ai/SessionAskNotice.tsx +89 -0
  19. package/components/blocks/QuestionBlock.tsx +591 -0
  20. package/components/html-viewer/bridge-script.asset.js +85 -8
  21. package/components/html-viewer/bridge-script.ts +85 -8
  22. package/config/settings.ts +17 -0
  23. package/configure.ts +12 -0
  24. package/hooks/useAIChat.ts +74 -9
  25. package/hooks/useAIProviderConfig.ts +32 -14
  26. package/hooks/useAnnotationHighlighter.ts +4 -0
  27. package/hooks/useDiffFileTreeExpansion.ts +92 -0
  28. package/hooks/usePinpoint.ts +10 -0
  29. package/hooks/useScrollKeyRouting.ts +157 -0
  30. package/hooks/useUndoHistory.ts +17 -0
  31. package/hooks/useVimDocumentFocus.ts +2 -1
  32. package/package.json +3 -2
  33. package/styles.css +1 -1
  34. package/types.ts +7 -0
  35. package/utils/aiPrompt.ts +31 -0
  36. package/utils/aiProvider.ts +68 -0
  37. package/utils/autoUpdateNotice.ts +59 -0
  38. package/utils/diffFileTree.ts +203 -0
  39. package/utils/htmlLinkNavigation.ts +87 -3
  40. package/utils/parser.ts +125 -563
  41. package/utils/questionAnswers.ts +227 -0
  42. package/utils/syntaxTheme.ts +33 -0
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Glue between `:::question` answers (`@plannotator/core/question-block`)
3
+ * and the UI's `Annotation` list. An answer is ONE annotation carrying
4
+ * `questionAnswer`, id `ann-question-<key>`, so drafts, reload, the panel and
5
+ * host persistence carry it with no second store. These helpers are pure; a
6
+ * host (or Plannotator's editor) calls `upsertQuestionAnswerAnnotation` from
7
+ * the Viewer's `onAnswerQuestion` and stores the returned list.
8
+ */
9
+ import {
10
+ buildQuestionAnswerAnnotation,
11
+ indexQuestionBlocks,
12
+ isQuestionAnswerEmpty,
13
+ isQuestionAnswered,
14
+ parseQuestionAnswer,
15
+ questionAnswerAnnotationId,
16
+ questionStatus,
17
+ type QuestionAnswer,
18
+ type QuestionSourceBlock,
19
+ type QuestionStatus,
20
+ } from '@plannotator/core/question-block';
21
+ import { AnnotationType, type Annotation } from '../types';
22
+
23
+ /** The answer annotation for a question block, typed for the UI. */
24
+ export const questionAnswerToAnnotation = (
25
+ blockId: string,
26
+ answer: QuestionAnswer,
27
+ createdA?: number,
28
+ ): Annotation => {
29
+ const record = buildQuestionAnswerAnnotation(blockId, answer, createdA);
30
+ return { ...record, type: AnnotationType.COMMENT };
31
+ };
32
+
33
+ /** Valid answers in an annotation list, keyed by question key (first wins).
34
+ * Malformed `questionAnswer` values are skipped, never thrown on. */
35
+ export const collectQuestionAnswers = (annotations: ReadonlyArray<Annotation>): Map<string, QuestionAnswer> => {
36
+ const out = new Map<string, QuestionAnswer>();
37
+ for (const ann of annotations) {
38
+ if (ann.questionAnswer == null) continue;
39
+ const answer = parseQuestionAnswer(ann.questionAnswer);
40
+ if (answer && !out.has(answer.key)) out.set(answer.key, answer);
41
+ }
42
+ return out;
43
+ };
44
+
45
+ /** The answers the cards draw, keyed by question key. A host that keeps its
46
+ * answers itself passes them (a Map or a plain object keyed by the
47
+ * de-duplicated question key) and they replace the annotation-derived ones
48
+ * outright; each entry is validated like any other reader, so a malformed
49
+ * one is dropped. Without host answers this is `collectQuestionAnswers`. */
50
+ export const resolveQuestionAnswers = (
51
+ annotations: ReadonlyArray<Annotation>,
52
+ hostAnswers?: ReadonlyMap<string, QuestionAnswer> | Readonly<Record<string, QuestionAnswer>> | null,
53
+ ): Map<string, QuestionAnswer> => {
54
+ if (!hostAnswers) return collectQuestionAnswers(annotations);
55
+ const entries: Array<[string, unknown]> = hostAnswers instanceof Map
56
+ ? [...hostAnswers.entries()]
57
+ : Object.entries(hostAnswers);
58
+ const out = new Map<string, QuestionAnswer>();
59
+ for (const [key, value] of entries) {
60
+ const answer = parseQuestionAnswer(value);
61
+ if (answer) out.set(key, answer);
62
+ }
63
+ return out;
64
+ };
65
+
66
+ /** Whether an annotation is a question answer (validated). */
67
+ export const isQuestionAnswerAnnotation = (ann: Pick<Annotation, 'questionAnswer'>): boolean =>
68
+ ann.questionAnswer != null && parseQuestionAnswer(ann.questionAnswer) !== null;
69
+
70
+ /**
71
+ * Apply one `onAnswerQuestion(blockId, answer, key)` call to an annotation
72
+ * list: replace the question's answer annotation in place (keeping its
73
+ * `createdA` and position), append it when new, or remove it when `answer`
74
+ * is null or empty. Returns the same array when nothing changed.
75
+ */
76
+ export const upsertQuestionAnswerAnnotation = (
77
+ annotations: ReadonlyArray<Annotation>,
78
+ blockId: string,
79
+ answer: QuestionAnswer | null,
80
+ key: string,
81
+ ): Annotation[] => {
82
+ const id = questionAnswerAnnotationId(key);
83
+ const index = annotations.findIndex((a) => a.id === id);
84
+ const valid = answer ? parseQuestionAnswer(answer) : null;
85
+ if (!valid || isQuestionAnswerEmpty(valid)) {
86
+ if (index === -1) return annotations as Annotation[];
87
+ return annotations.filter((_, i) => i !== index);
88
+ }
89
+ const existing = index === -1 ? undefined : annotations[index];
90
+ const next = questionAnswerToAnnotation(blockId, valid, existing?.createdA);
91
+ if (existing?.author) next.author = existing.author;
92
+ if (index === -1) return [...annotations, next];
93
+ const copy = annotations.slice();
94
+ copy[index] = next;
95
+ return copy;
96
+ };
97
+
98
+ // ─────────────────────────── Panel + progress ───────────────────────────
99
+
100
+ /** One row of the annotations panel's Questions section. */
101
+ export interface QuestionPanelRow {
102
+ key: string;
103
+ /** "Question N of M" number; absent for an answer whose question is gone. */
104
+ number?: number;
105
+ prompt: string;
106
+ /** Document line of the prompt, when the question is in the document. */
107
+ line?: number;
108
+ /** The question block, when the question is in the document. */
109
+ blockId?: string;
110
+ status: QuestionStatus;
111
+ /** One line describing the answer: the picks, Other or free text; the
112
+ * settled choice; `Skipped`. Absent while the question is open. */
113
+ answerText?: string;
114
+ hasNote: boolean;
115
+ /** The answer's annotation, when there is one. */
116
+ annotationId?: string;
117
+ /** The answer's question is no longer in the document (the prompt was
118
+ * edited or the block removed). The answer still exports. */
119
+ orphaned: boolean;
120
+ }
121
+
122
+ const oneLine = (value: string): string => value.replace(/\s+/g, ' ').trim();
123
+
124
+ const answerSummary = (answer: QuestionAnswer): string => {
125
+ const parts = [...answer.selected.map(oneLine)];
126
+ if (answer.other?.trim()) parts.push(`Other: ${oneLine(answer.other)}`);
127
+ if (answer.text?.trim()) parts.push(oneLine(answer.text));
128
+ return parts.join('; ');
129
+ };
130
+
131
+ /**
132
+ * The Questions section rows for a document: every question block in
133
+ * document order, then one row per answer whose question is no longer in the
134
+ * document (`orphaned`). Pure; the status rule is core's `questionStatus`, the
135
+ * same one the card draws.
136
+ */
137
+ export const buildQuestionPanelRows = (
138
+ blocks: ReadonlyArray<QuestionSourceBlock>,
139
+ annotations: ReadonlyArray<Annotation>,
140
+ ): QuestionPanelRow[] => {
141
+ const index = indexQuestionBlocks(blocks);
142
+ const answers = collectQuestionAnswers(annotations);
143
+ const rows: QuestionPanelRow[] = [];
144
+ const seen = new Set<string>();
145
+ for (const { blockId, number, line, question } of index) {
146
+ seen.add(question.key);
147
+ const answer = answers.get(question.key);
148
+ const status = questionStatus(question, answer);
149
+ const settledLabels = question.choices.filter((c) => c.settled).map((c) => oneLine(c.label));
150
+ const answerText = status === 'answered' && answer
151
+ ? answerSummary(answer)
152
+ : status === 'skipped'
153
+ ? 'Skipped'
154
+ : status === 'settled'
155
+ ? `${settledLabels.join('; ')} (settled)`
156
+ : undefined;
157
+ rows.push({
158
+ key: question.key,
159
+ number,
160
+ prompt: question.prompt,
161
+ line,
162
+ blockId,
163
+ status,
164
+ ...(answerText ? { answerText } : {}),
165
+ hasNote: !!answer?.note?.trim(),
166
+ ...(answer ? { annotationId: questionAnswerAnnotationId(question.key) } : {}),
167
+ orphaned: false,
168
+ });
169
+ }
170
+ for (const answer of answers.values()) {
171
+ if (seen.has(answer.key)) continue;
172
+ const status: QuestionStatus = isQuestionAnswered(answer) ? 'answered' : answer.skipped ? 'skipped' : 'open';
173
+ const answerText = status === 'answered' ? answerSummary(answer) : status === 'skipped' ? 'Skipped' : undefined;
174
+ rows.push({
175
+ key: answer.key,
176
+ prompt: answer.prompt,
177
+ status,
178
+ ...(answerText ? { answerText } : {}),
179
+ hasNote: !!answer.note?.trim(),
180
+ annotationId: questionAnswerAnnotationId(answer.key),
181
+ orphaned: true,
182
+ });
183
+ }
184
+ return rows;
185
+ };
186
+
187
+ /** Progress over the document's questions (orphaned answers excluded):
188
+ * `done` counts answered and settled questions, `total` every question. */
189
+ export const questionProgress = (rows: ReadonlyArray<QuestionPanelRow>): { done: number; total: number } => {
190
+ const live = rows.filter((r) => !r.orphaned);
191
+ return {
192
+ done: live.filter((r) => r.status === 'answered' || r.status === 'settled').length,
193
+ total: live.length,
194
+ };
195
+ };
196
+
197
+ /**
198
+ * The key of the next open question after `afterKey` in document order,
199
+ * wrapping around; the first open question when `afterKey` is absent or not
200
+ * a question. Null when no question is open (answered, settled and skipped
201
+ * questions are not open).
202
+ */
203
+ export const nextOpenQuestionKey = (
204
+ rows: ReadonlyArray<QuestionPanelRow>,
205
+ afterKey?: string | null,
206
+ ): string | null => {
207
+ const live = rows.filter((r) => !r.orphaned);
208
+ const start = afterKey ? live.findIndex((r) => r.key === afterKey) : -1;
209
+ for (let step = 1; step <= live.length; step++) {
210
+ const row = live[(start + step) % live.length];
211
+ if (row.status === 'open') return row.key;
212
+ }
213
+ return null;
214
+ };
215
+
216
+ /** Scroll a question card into view and focus its first control (the
217
+ * first choice, or the text box). Returns false when the card is not in
218
+ * the DOM. The focus does not scroll; the card scrolls smoothly to the
219
+ * centre. */
220
+ export const focusQuestionCard = (key: string, root: ParentNode = document): boolean => {
221
+ const card = root.querySelector<HTMLElement>(`fieldset[data-question-key="${key.replace(/"/g, '')}"]`);
222
+ if (!card) return false;
223
+ card.scrollIntoView?.({ behavior: 'smooth', block: 'center' });
224
+ const control = card.querySelector<HTMLElement>('input:not([disabled]), textarea:not([disabled])');
225
+ control?.focus({ preventScroll: true });
226
+ return true;
227
+ };
@@ -73,11 +73,44 @@ export function resolveSyntaxTheme(colorTheme: string, mode: 'dark' | 'light'):
73
73
  return { dark: map.dark || DEFAULT_SYNTAX_THEME.dark, light: map.light || DEFAULT_SYNTAX_THEME.light };
74
74
  }
75
75
 
76
+ /**
77
+ * Host override for the fence theme (`configurePlannotatorUI({ fenceTheme })`).
78
+ * Return a Shiki theme name to use it, or `undefined` to fall through to the
79
+ * default resolution below. A name Shiki does not know renders the block as
80
+ * plain text, exactly like an unknown fence language.
81
+ */
82
+ export type FenceThemeResolver = (colorTheme: string, mode: 'light' | 'dark') => string | undefined;
83
+
84
+ let fenceThemeResolver: FenceThemeResolver | null = null;
85
+
86
+ /** Install (or with `null`, remove) the host's fence theme override. */
87
+ export function setFenceThemeResolver(resolver: FenceThemeResolver | null): void {
88
+ fenceThemeResolver = resolver;
89
+ }
90
+
91
+ export function resetFenceThemeResolver(): void {
92
+ fenceThemeResolver = null;
93
+ }
94
+
76
95
  /**
77
96
  * The single concrete Shiki theme name for the palette currently on screen.
78
97
  * Markdown fences render one mode at a time, so unlike the diff pane (which
79
98
  * hands Pierre a dark/light pair and lets CSS pick) they want a resolved name.
99
+ *
100
+ * Every fence-style consumer resolves through here (`useFenceTheme`: Viewer
101
+ * fences, `CodeBlock`, the code-file hover preview, the plan diff view, code
102
+ * review's suggestion snippets), so a host override applies to all of them.
103
+ * The diff pane's dark/light pair (`resolveSyntaxTheme`) is not affected.
80
104
  */
81
105
  export function resolveFenceTheme(colorTheme: string, mode: 'dark' | 'light'): string {
106
+ if (fenceThemeResolver) {
107
+ let override: string | undefined;
108
+ try {
109
+ override = fenceThemeResolver(colorTheme, mode);
110
+ } catch {
111
+ override = undefined; // a throwing host resolver must not break rendering
112
+ }
113
+ if (typeof override === 'string' && override) return override;
114
+ }
82
115
  return resolveSyntaxTheme(colorTheme, mode)?.[mode] ?? DEFAULT_SYNTAX_THEME[mode];
83
116
  }