@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.
- package/HANDOFF.md +147 -0
- package/LICENSE +191 -0
- package/README.md +56 -1
- package/components/AISettingsTab.tsx +1 -1
- package/components/AnnotationPanel.tsx +44 -3
- package/components/BlockRenderer.tsx +55 -1
- package/components/CompletionOverlay.tsx +23 -1
- package/components/DiffFileTree.tsx +363 -0
- package/components/ImageLightbox.tsx +66 -0
- package/components/PRPlatformIcon.tsx +22 -0
- package/components/ProviderIcons.tsx +15 -3
- package/components/QuestionProgressChip.tsx +66 -0
- package/components/QuestionsPanelSection.tsx +121 -0
- package/components/Settings.tsx +25 -1
- package/components/Viewer.tsx +170 -34
- package/components/ai/AIProviderBar.tsx +5 -5
- package/components/ai/DocumentAIChatPanel.tsx +28 -3
- package/components/ai/SessionAskNotice.tsx +89 -0
- package/components/blocks/QuestionBlock.tsx +591 -0
- package/components/html-viewer/bridge-script.asset.js +85 -8
- package/components/html-viewer/bridge-script.ts +85 -8
- package/config/settings.ts +17 -0
- package/configure.ts +12 -0
- package/hooks/useAIChat.ts +74 -9
- package/hooks/useAIProviderConfig.ts +32 -14
- package/hooks/useAnnotationHighlighter.ts +4 -0
- package/hooks/useDiffFileTreeExpansion.ts +92 -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 +3 -2
- package/styles.css +1 -1
- package/types.ts +7 -0
- package/utils/aiPrompt.ts +31 -0
- package/utils/aiProvider.ts +68 -0
- package/utils/autoUpdateNotice.ts +59 -0
- package/utils/diffFileTree.ts +203 -0
- package/utils/htmlLinkNavigation.ts +87 -3
- package/utils/parser.ts +125 -563
- package/utils/questionAnswers.ts +227 -0
- 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
|
+
};
|
package/utils/syntaxTheme.ts
CHANGED
|
@@ -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
|
}
|