@plannotator/ui 0.45.1 → 0.46.1
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 +52 -1
- package/README.md +1 -1
- package/components/AgentsTab.tsx +108 -108
- package/components/BlockRenderer.tsx +9 -5
- package/components/ExportModal.tsx +8 -4
- package/components/ImageAnnotator/Toolbar.tsx +13 -3
- package/components/ImageAnnotator/index.tsx +6 -2
- package/components/InlineMarkdown.tsx +14 -7
- package/components/RenderedMarkdown.tsx +3 -1
- package/components/Settings.tsx +38 -15
- package/components/Viewer.tsx +76 -18
- package/components/blocks/AlertBlock.tsx +3 -2
- package/components/blocks/Callout.tsx +3 -0
- package/components/blocks/CodeBlock.tsx +6 -2
- package/components/blocks/TableBlock.tsx +4 -0
- package/components/blocks/TablePopout.tsx +6 -2
- package/components/blocks/proseBody.tsx +3 -0
- package/components/plan-diff/PlanCleanDiffView.tsx +6 -2
- package/config/reviewView.ts +8 -3
- package/config/settings.ts +10 -4
- package/hooks/useAgentSettings.ts +108 -123
- package/hooks/useAnnotationHighlighter.ts +109 -1
- package/hooks/useCodeAnnotationDraft.ts +196 -3
- package/hooks/useModelCatalogs.ts +116 -0
- package/hooks/useVimSelection.ts +28 -7
- package/package.json +2 -2
- package/styles.css +1 -1
- package/types.ts +31 -0
- package/utils/aiProvider.ts +8 -16
- package/utils/annotateAgentInstructions.ts +162 -0
- package/utils/codeHighlight.ts +18 -0
- package/utils/parser.ts +141 -31
- package/utils/terminalToolsAnnouncement.ts +2 -2
- package/assets/review-sections.png +0 -0
- package/assets/review-tree.png +0 -0
- package/utils/codexModels.ts +0 -79
|
@@ -10,6 +10,18 @@ import type { CodeAnnotation, Annotation, CommentAnnotation } from '../types';
|
|
|
10
10
|
import { getDraftTransport } from './useAnnotationDraft';
|
|
11
11
|
|
|
12
12
|
const DEBOUNCE_MS = 500;
|
|
13
|
+
/** Bound on one read of a switched-onto target's draft (#1590). */
|
|
14
|
+
export const TARGET_LOAD_TIMEOUT_MS = 5000;
|
|
15
|
+
|
|
16
|
+
function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
|
|
17
|
+
return new Promise<T>((resolve, reject) => {
|
|
18
|
+
const timer = setTimeout(() => reject(new Error('draft load timed out')), ms);
|
|
19
|
+
promise.then(
|
|
20
|
+
(value) => { clearTimeout(timer); resolve(value); },
|
|
21
|
+
(error) => { clearTimeout(timer); reject(error); },
|
|
22
|
+
);
|
|
23
|
+
});
|
|
24
|
+
}
|
|
13
25
|
|
|
14
26
|
interface DraftData {
|
|
15
27
|
codeAnnotations: CodeAnnotation[];
|
|
@@ -28,6 +40,12 @@ interface DraftData {
|
|
|
28
40
|
*/
|
|
29
41
|
autoViewSuppressed?: string[];
|
|
30
42
|
draftGeneration?: number;
|
|
43
|
+
/**
|
|
44
|
+
* Set by the review server (never by the client) when a PR draft is served
|
|
45
|
+
* through the PR's target key for a patch different from the one it was
|
|
46
|
+
* saved on (#1590). The host re-checks line comments' anchors on restore.
|
|
47
|
+
*/
|
|
48
|
+
patchChanged?: boolean;
|
|
31
49
|
ts: number;
|
|
32
50
|
}
|
|
33
51
|
|
|
@@ -54,13 +72,47 @@ interface UseCodeAnnotationDraftOptions {
|
|
|
54
72
|
autoViewSuppressed?: Set<string>;
|
|
55
73
|
isApiMode: boolean;
|
|
56
74
|
submitted: boolean;
|
|
75
|
+
/** Receives the unsent items found on a target the session switched onto
|
|
76
|
+
* in place (#1590), already filtered to ids the session neither holds nor
|
|
77
|
+
* deleted. The host adds them to its state; autosave then saves the merge.
|
|
78
|
+
* Called synchronously before autosave resumes. */
|
|
79
|
+
onDraftTargetMerge?: (items: CodeDraftMergeItems) => void;
|
|
80
|
+
/** Bound on one read of a switched-onto target's draft. Default
|
|
81
|
+
* `TARGET_LOAD_TIMEOUT_MS`; exposed for tests. */
|
|
82
|
+
targetLoadTimeoutMs?: number;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Items auto-merged from a switched-onto target's draft. */
|
|
86
|
+
export interface CodeDraftMergeItems {
|
|
87
|
+
annotations: CodeAnnotation[];
|
|
88
|
+
descriptionAnnotations: Annotation[];
|
|
89
|
+
commentAnnotations: CommentAnnotation[];
|
|
90
|
+
/** The server served the draft for a patch other than it was saved on. */
|
|
91
|
+
patchChanged: boolean;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Draft state a review server reports after an in-place target switch
|
|
95
|
+
* (PR switch / PR diff-scope switch, #1590). */
|
|
96
|
+
export interface CodeDraftTargetState {
|
|
97
|
+
found: boolean;
|
|
98
|
+
draftGeneration: number | null;
|
|
57
99
|
}
|
|
58
100
|
|
|
59
101
|
interface UseCodeAnnotationDraftResult {
|
|
60
102
|
draftBanner: { count: number; viewedCount: number; timeAgo: string } | null;
|
|
61
|
-
|
|
103
|
+
/** `patchChanged` is true when the server served the draft for a patch other
|
|
104
|
+
* than the one it was saved on (the host re-checks line anchors). */
|
|
105
|
+
restoreDraft: () => { annotations: CodeAnnotation[]; descriptionAnnotations: Annotation[]; commentAnnotations: CommentAnnotation[]; viewedFiles: string[]; autoViewSuppressed: string[]; patchChanged: boolean };
|
|
62
106
|
getDraftGeneration: () => number;
|
|
63
107
|
dismissDraft: () => void;
|
|
108
|
+
/** Call with the server's `draftState` after an in-place target switch.
|
|
109
|
+
* Raises the generation counter to the new target's floor (so saves are
|
|
110
|
+
* not rejected against an old tombstone there) and, when that target holds
|
|
111
|
+
* a draft, loads it and hands its new items to `onDraftTargetMerge`. Until
|
|
112
|
+
* that one load settles (success, failure, or a newer switch) autosave does
|
|
113
|
+
* not write under the new target, so the switch cannot overwrite a draft
|
|
114
|
+
* it has not read yet; nothing else ever waits. */
|
|
115
|
+
adoptDraftTarget: (state: CodeDraftTargetState | undefined) => void;
|
|
64
116
|
}
|
|
65
117
|
|
|
66
118
|
export function useCodeAnnotationDraft({
|
|
@@ -71,6 +123,8 @@ export function useCodeAnnotationDraft({
|
|
|
71
123
|
autoViewSuppressed,
|
|
72
124
|
isApiMode,
|
|
73
125
|
submitted,
|
|
126
|
+
onDraftTargetMerge,
|
|
127
|
+
targetLoadTimeoutMs = TARGET_LOAD_TIMEOUT_MS,
|
|
74
128
|
}: UseCodeAnnotationDraftOptions): UseCodeAnnotationDraftResult {
|
|
75
129
|
const [draftBanner, setDraftBanner] = useState<{ count: number; viewedCount: number; timeAgo: string } | null>(null);
|
|
76
130
|
const draftDataRef = useRef<DraftData | null>(null);
|
|
@@ -82,6 +136,43 @@ export function useCodeAnnotationDraft({
|
|
|
82
136
|
// fresh/unengaged session (leave the server alone). Keyed on annotations only —
|
|
83
137
|
// see the autosave effect for why viewedFiles must not count.
|
|
84
138
|
const hasHadAnnotationsRef = useRef(false);
|
|
139
|
+
// In-place switch bookkeeping (#1590). Every adoptDraftTarget call starts a
|
|
140
|
+
// new switch id; a load that resolves for an older id is ignored. While the
|
|
141
|
+
// current switch's draft load is in flight, autosave skips writing (it
|
|
142
|
+
// would overwrite a draft it has not merged yet) and remembers that it did,
|
|
143
|
+
// so settling — success, failure, or supersede — always resumes saving.
|
|
144
|
+
const switchSeqRef = useRef(0);
|
|
145
|
+
const awaitingTargetLoadRef = useRef(false);
|
|
146
|
+
const skippedWhileAwaitingRef = useRef(false);
|
|
147
|
+
// The switched-onto target's draft could not be read (both attempts failed
|
|
148
|
+
// or timed out). Autosave then does not write under that target — it would
|
|
149
|
+
// overwrite a draft nobody has seen — and instead retries the read on each
|
|
150
|
+
// save attempt; a successful read (or the next switch) clears it.
|
|
151
|
+
const targetUnreadableRef = useRef(false);
|
|
152
|
+
// Kicks a read of the current target's draft; set by adoptDraftTarget.
|
|
153
|
+
const retryTargetLoadRef = useRef<(() => void) | null>(null);
|
|
154
|
+
const [saveNudge, setSaveNudge] = useState(0);
|
|
155
|
+
const loadTimeoutRef = useRef(targetLoadTimeoutMs);
|
|
156
|
+
loadTimeoutRef.current = targetLoadTimeoutMs;
|
|
157
|
+
const onMergeRef = useRef(onDraftTargetMerge);
|
|
158
|
+
onMergeRef.current = onDraftTargetMerge;
|
|
159
|
+
const latestRef = useRef({ annotations, descriptionAnnotations, commentAnnotations });
|
|
160
|
+
latestRef.current = { annotations, descriptionAnnotations, commentAnnotations };
|
|
161
|
+
// Ids the reviewer removed during this session, so a later merge offer
|
|
162
|
+
// (switching back onto a target saved before the removal) never brings a
|
|
163
|
+
// deleted comment back. Re-adding an id (undo) clears it again.
|
|
164
|
+
const removedIdsRef = useRef(new Set<string>());
|
|
165
|
+
const seenIdsRef = useRef(new Set<string>());
|
|
166
|
+
{
|
|
167
|
+
const nowIds = new Set<string>([
|
|
168
|
+
...annotations.map((a) => a.id),
|
|
169
|
+
...descriptionAnnotations.map((a) => a.id),
|
|
170
|
+
...commentAnnotations.map((a) => a.id),
|
|
171
|
+
]);
|
|
172
|
+
for (const id of seenIdsRef.current) if (!nowIds.has(id)) removedIdsRef.current.add(id);
|
|
173
|
+
for (const id of nowIds) removedIdsRef.current.delete(id);
|
|
174
|
+
seenIdsRef.current = nowIds;
|
|
175
|
+
}
|
|
85
176
|
|
|
86
177
|
// Load draft on mount
|
|
87
178
|
useEffect(() => {
|
|
@@ -141,6 +232,17 @@ export function useCodeAnnotationDraft({
|
|
|
141
232
|
if (timerRef.current) clearTimeout(timerRef.current);
|
|
142
233
|
|
|
143
234
|
timerRef.current = setTimeout(() => {
|
|
235
|
+
// The switched-onto target's draft is still loading: writing now would
|
|
236
|
+
// overwrite it unread. Settling the load re-runs this effect.
|
|
237
|
+
if (awaitingTargetLoadRef.current) {
|
|
238
|
+
skippedWhileAwaitingRef.current = true;
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
if (targetUnreadableRef.current) {
|
|
242
|
+
skippedWhileAwaitingRef.current = true;
|
|
243
|
+
retryTargetLoadRef.current?.();
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
144
246
|
const draftGeneration = draftGenerationRef.current + 1;
|
|
145
247
|
draftGenerationRef.current = draftGeneration;
|
|
146
248
|
|
|
@@ -171,7 +273,7 @@ export function useCodeAnnotationDraft({
|
|
|
171
273
|
return () => {
|
|
172
274
|
if (timerRef.current) clearTimeout(timerRef.current);
|
|
173
275
|
};
|
|
174
|
-
}, [annotations, descriptionAnnotations, commentAnnotations, viewedFiles, autoViewSuppressed, isApiMode, submitted]);
|
|
276
|
+
}, [annotations, descriptionAnnotations, commentAnnotations, viewedFiles, autoViewSuppressed, isApiMode, submitted, saveNudge]);
|
|
175
277
|
|
|
176
278
|
const restoreDraft = useCallback(() => {
|
|
177
279
|
// Cancel any pending autosave so it can't fire with pre-restore state and
|
|
@@ -186,6 +288,7 @@ export function useCodeAnnotationDraft({
|
|
|
186
288
|
commentAnnotations: data?.commentAnnotations ?? [],
|
|
187
289
|
viewedFiles: data?.viewedFiles ?? [],
|
|
188
290
|
autoViewSuppressed: data?.autoViewSuppressed ?? [],
|
|
291
|
+
patchChanged: data?.patchChanged === true,
|
|
189
292
|
};
|
|
190
293
|
}, []);
|
|
191
294
|
|
|
@@ -202,5 +305,95 @@ export function useCodeAnnotationDraft({
|
|
|
202
305
|
getDraftTransport().remove(deletedGeneration, { keepalive: false }).catch(() => {});
|
|
203
306
|
}, []);
|
|
204
307
|
|
|
205
|
-
|
|
308
|
+
// Unmount supersedes any in-flight target read, so a retry never fires
|
|
309
|
+
// after the page (or a test host) is gone.
|
|
310
|
+
useEffect(() => () => { switchSeqRef.current += 1; }, []);
|
|
311
|
+
|
|
312
|
+
const adoptDraftTarget = useCallback((state: CodeDraftTargetState | undefined) => {
|
|
313
|
+
if (!isApiMode) return;
|
|
314
|
+
// Every switch starts clean: a previous switch's load (if still in
|
|
315
|
+
// flight) is superseded, an unreadable-target block is lifted, and any
|
|
316
|
+
// write either of them held back resumes (under the new target).
|
|
317
|
+
const seq = ++switchSeqRef.current;
|
|
318
|
+
const resumeHeldWrite = () => {
|
|
319
|
+
if (skippedWhileAwaitingRef.current) {
|
|
320
|
+
skippedWhileAwaitingRef.current = false;
|
|
321
|
+
setSaveNudge((n) => n + 1);
|
|
322
|
+
}
|
|
323
|
+
};
|
|
324
|
+
awaitingTargetLoadRef.current = false;
|
|
325
|
+
targetUnreadableRef.current = false;
|
|
326
|
+
retryTargetLoadRef.current = null;
|
|
327
|
+
if (!state) { resumeHeldWrite(); return; }
|
|
328
|
+
const floor = readDraftGeneration(state.draftGeneration);
|
|
329
|
+
if (floor !== null) draftGenerationRef.current = Math.max(draftGenerationRef.current, floor);
|
|
330
|
+
if (!state.found) { resumeHeldWrite(); return; }
|
|
331
|
+
|
|
332
|
+
const applyDraft = (data: unknown, generation: number | null) => {
|
|
333
|
+
if (generation !== null) draftGenerationRef.current = Math.max(draftGenerationRef.current, generation);
|
|
334
|
+
const draft = data as DraftData | null;
|
|
335
|
+
if (!draft) return;
|
|
336
|
+
const loadedGeneration = readDraftGeneration(draft.draftGeneration);
|
|
337
|
+
if (loadedGeneration !== null) draftGenerationRef.current = Math.max(draftGenerationRef.current, loadedGeneration);
|
|
338
|
+
// Only what the session neither holds nor deleted: the target may
|
|
339
|
+
// carry this session's own blob from before a switch away and back.
|
|
340
|
+
const current = latestRef.current;
|
|
341
|
+
const skip = new Set<string>([
|
|
342
|
+
...current.annotations.map((a) => a.id),
|
|
343
|
+
...current.descriptionAnnotations.map((a) => a.id),
|
|
344
|
+
...current.commentAnnotations.map((a) => a.id),
|
|
345
|
+
...removedIdsRef.current,
|
|
346
|
+
]);
|
|
347
|
+
const fresh = <T extends { id: string }>(items: T[] | undefined) =>
|
|
348
|
+
(Array.isArray(items) ? items : []).filter((item) => !skip.has(item.id));
|
|
349
|
+
const items: CodeDraftMergeItems = {
|
|
350
|
+
annotations: fresh(draft.codeAnnotations),
|
|
351
|
+
descriptionAnnotations: fresh(draft.descriptionAnnotations),
|
|
352
|
+
commentAnnotations: fresh(draft.commentAnnotations),
|
|
353
|
+
patchChanged: draft.patchChanged === true,
|
|
354
|
+
};
|
|
355
|
+
if (items.annotations.length + items.descriptionAnnotations.length + items.commentAnnotations.length > 0) {
|
|
356
|
+
onMergeRef.current?.(items);
|
|
357
|
+
}
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
// One read = up to two attempts, each bounded by TARGET_LOAD_TIMEOUT_MS so
|
|
361
|
+
// a hung GET takes the failure path instead of pausing autosave.
|
|
362
|
+
const read = () => {
|
|
363
|
+
if (switchSeqRef.current !== seq || awaitingTargetLoadRef.current) return;
|
|
364
|
+
awaitingTargetLoadRef.current = true;
|
|
365
|
+
const attempt = (remaining: number): void => {
|
|
366
|
+
if (switchSeqRef.current !== seq) return; // superseded or unmounted
|
|
367
|
+
withTimeout(getDraftTransport().load(), loadTimeoutRef.current)
|
|
368
|
+
.then(({ data, generation }) => {
|
|
369
|
+
if (switchSeqRef.current !== seq) return; // a newer switch owns the keys now
|
|
370
|
+
// A debounced save armed before the read settled captured the
|
|
371
|
+
// pre-merge state; drop it and save again from the merged render.
|
|
372
|
+
if (timerRef.current) {
|
|
373
|
+
clearTimeout(timerRef.current);
|
|
374
|
+
timerRef.current = null;
|
|
375
|
+
skippedWhileAwaitingRef.current = true;
|
|
376
|
+
}
|
|
377
|
+
applyDraft(data, generation);
|
|
378
|
+
awaitingTargetLoadRef.current = false;
|
|
379
|
+
targetUnreadableRef.current = false;
|
|
380
|
+
resumeHeldWrite();
|
|
381
|
+
})
|
|
382
|
+
.catch(() => {
|
|
383
|
+
if (switchSeqRef.current !== seq) return;
|
|
384
|
+
if (remaining > 0) { attempt(remaining - 1); return; }
|
|
385
|
+
// Still unreadable: never overwrite it blind. Autosave keeps
|
|
386
|
+
// running for the session and retries this read on its next
|
|
387
|
+
// save attempt; the next switch lifts the block.
|
|
388
|
+
awaitingTargetLoadRef.current = false;
|
|
389
|
+
targetUnreadableRef.current = true;
|
|
390
|
+
});
|
|
391
|
+
};
|
|
392
|
+
attempt(1);
|
|
393
|
+
};
|
|
394
|
+
retryTargetLoadRef.current = read;
|
|
395
|
+
read();
|
|
396
|
+
}, [isApiMode]);
|
|
397
|
+
|
|
398
|
+
return { draftBanner, restoreDraft, getDraftGeneration, dismissDraft, adoptDraftTarget };
|
|
206
399
|
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
CLAUDE_FALLBACK_MODELS,
|
|
4
|
+
CODEX_FALLBACK_MODELS,
|
|
5
|
+
type CatalogModel,
|
|
6
|
+
} from '@plannotator/core/model-catalog';
|
|
7
|
+
import type { AgentCapabilities } from '../types';
|
|
8
|
+
|
|
9
|
+
// Picker helpers, re-exported for launch surfaces outside @plannotator/ui.
|
|
10
|
+
export { effortSelectOptions, modelSelectOptions } from '@plannotator/core/model-catalog';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Claude / Codex model catalogs for the agent launchers (review, Code Tour,
|
|
14
|
+
* Guided Review) — the SAME lists Ask AI shows. Each is the Ask AI provider's
|
|
15
|
+
* discovered list, fetched through `/api/ai/capabilities?activate=<id>` (which
|
|
16
|
+
* runs the provider's server-cached discovery). Nothing is fetched until a
|
|
17
|
+
* surface asks for one engine with `useModelCatalogs(...).load(engine)`, and
|
|
18
|
+
* only when that engine is installed, so opening a launcher spawns only the
|
|
19
|
+
* selected CLI. While a fetch is in flight the static fallback is shown with
|
|
20
|
+
* `settled: false`, so callers never reconcile saved picks against a list that
|
|
21
|
+
* is merely loading; a failed fetch settles on the fallback and is retried the
|
|
22
|
+
* next time a surface asks.
|
|
23
|
+
*/
|
|
24
|
+
export type CatalogEngine = 'claude' | 'codex';
|
|
25
|
+
|
|
26
|
+
export interface ModelCatalog {
|
|
27
|
+
models: CatalogModel[];
|
|
28
|
+
settled: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ModelCatalogs extends Record<CatalogEngine, ModelCatalog> {
|
|
32
|
+
/** Fetch one engine's catalog (no-op for other engines or when not installed). */
|
|
33
|
+
load: (engine: string | null | undefined) => void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const AI_PROVIDER_ID: Record<CatalogEngine, string> = {
|
|
37
|
+
claude: 'claude-agent-sdk',
|
|
38
|
+
codex: 'codex-sdk',
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export const FALLBACK_MODELS: Record<CatalogEngine, CatalogModel[]> = {
|
|
42
|
+
claude: CLAUDE_FALLBACK_MODELS,
|
|
43
|
+
codex: CODEX_FALLBACK_MODELS,
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const isCatalogEngine = (engine: unknown): engine is CatalogEngine => engine === 'claude' || engine === 'codex';
|
|
47
|
+
|
|
48
|
+
// One request per engine per page: every launcher surface shares the answer.
|
|
49
|
+
// A failed request is forgotten so the next load retries it.
|
|
50
|
+
const loads = new Map<CatalogEngine, Promise<CatalogModel[]>>();
|
|
51
|
+
|
|
52
|
+
export function loadModelCatalog(engine: CatalogEngine): Promise<CatalogModel[]> {
|
|
53
|
+
let load = loads.get(engine);
|
|
54
|
+
if (!load) {
|
|
55
|
+
const id = AI_PROVIDER_ID[engine];
|
|
56
|
+
const failed = () => {
|
|
57
|
+
loads.delete(engine);
|
|
58
|
+
return FALLBACK_MODELS[engine];
|
|
59
|
+
};
|
|
60
|
+
load = fetch(`/api/ai/capabilities?activate=${encodeURIComponent(id)}`)
|
|
61
|
+
.then((res) => (res.ok ? res.json() : null))
|
|
62
|
+
.then((data) => {
|
|
63
|
+
const provider = data?.providers?.find((p: { id?: string; name?: string }) => p.id === id || p.name === id);
|
|
64
|
+
const models = provider?.models;
|
|
65
|
+
if (!Array.isArray(models) || models.length === 0) return failed();
|
|
66
|
+
// The server answers 200 with its static fallback when discovery
|
|
67
|
+
// failed; show it, but forget it so the next load retries (the
|
|
68
|
+
// server's own cooldown bounds how often that spawns the CLI).
|
|
69
|
+
if (provider.modelsSource === 'fallback') loads.delete(engine);
|
|
70
|
+
return models as CatalogModel[];
|
|
71
|
+
})
|
|
72
|
+
.catch(failed);
|
|
73
|
+
loads.set(engine, load);
|
|
74
|
+
}
|
|
75
|
+
return load;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Test seam: forget cached catalogs. */
|
|
79
|
+
export function __resetModelCatalogsForTests(): void {
|
|
80
|
+
loads.clear();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function useModelCatalogs(capabilities: AgentCapabilities | null): ModelCatalogs {
|
|
84
|
+
const available = (id: CatalogEngine) =>
|
|
85
|
+
capabilities?.providers.some((p) => p.id === id && p.available) ?? false;
|
|
86
|
+
const claudeOn = available('claude');
|
|
87
|
+
const codexOn = available('codex');
|
|
88
|
+
const [loaded, setLoaded] = useState<Partial<Record<CatalogEngine, CatalogModel[]>>>({});
|
|
89
|
+
const mounted = useRef(true);
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
mounted.current = true;
|
|
92
|
+
return () => {
|
|
93
|
+
mounted.current = false;
|
|
94
|
+
};
|
|
95
|
+
}, []);
|
|
96
|
+
|
|
97
|
+
const load = useCallback(
|
|
98
|
+
(engine: string | null | undefined) => {
|
|
99
|
+
if (!isCatalogEngine(engine)) return;
|
|
100
|
+
if (!(engine === 'claude' ? claudeOn : codexOn)) return;
|
|
101
|
+
void loadModelCatalog(engine).then((models) => {
|
|
102
|
+
if (mounted.current) setLoaded((prev) => (prev[engine] === models ? prev : { ...prev, [engine]: models }));
|
|
103
|
+
});
|
|
104
|
+
},
|
|
105
|
+
[claudeOn, codexOn],
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
return useMemo(
|
|
109
|
+
() => ({
|
|
110
|
+
claude: { models: loaded.claude ?? FALLBACK_MODELS.claude, settled: !!loaded.claude },
|
|
111
|
+
codex: { models: loaded.codex ?? FALLBACK_MODELS.codex, settled: !!loaded.codex },
|
|
112
|
+
load,
|
|
113
|
+
}),
|
|
114
|
+
[loaded, load],
|
|
115
|
+
);
|
|
116
|
+
}
|
package/hooks/useVimSelection.ts
CHANGED
|
@@ -227,6 +227,27 @@ function applyVisualBlockSelection(
|
|
|
227
227
|
if (points) applyNativeTextSelection(graph.container, points.cursor, points.anchor);
|
|
228
228
|
}
|
|
229
229
|
|
|
230
|
+
/**
|
|
231
|
+
* Clear the page selection only when it touches the viewer's own document.
|
|
232
|
+
*
|
|
233
|
+
* A page has ONE selection, so an unconditional `removeAllRanges()` also kills
|
|
234
|
+
* a selection the user just made somewhere else (another panel of a host app,
|
|
235
|
+
* the annotation sidebar). A selection belongs to the viewer when its anchor or
|
|
236
|
+
* focus lies inside the container; with no container nothing is cleared.
|
|
237
|
+
*/
|
|
238
|
+
function clearSelectionWithin(container: HTMLElement | null | undefined): void {
|
|
239
|
+
if (!container) return;
|
|
240
|
+
const selection = window.getSelection();
|
|
241
|
+
if (!selection || selection.rangeCount === 0) return;
|
|
242
|
+
const { anchorNode, focusNode } = selection;
|
|
243
|
+
if (
|
|
244
|
+
(anchorNode && container.contains(anchorNode))
|
|
245
|
+
|| (focusNode && container.contains(focusNode))
|
|
246
|
+
) {
|
|
247
|
+
selection.removeAllRanges();
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
230
251
|
/**
|
|
231
252
|
* Own semantic block navigation and precise text selection while the rendered
|
|
232
253
|
* document has focus.
|
|
@@ -291,7 +312,7 @@ export function useVimSelection({
|
|
|
291
312
|
if (!initial) return null;
|
|
292
313
|
const next: VimBlockState = { phase: 'block', targetKey: initial.key };
|
|
293
314
|
setState(next);
|
|
294
|
-
|
|
315
|
+
clearSelectionWithin(container);
|
|
295
316
|
scrollVimTargetIntoView(initial.element, scrollViewportRef.current);
|
|
296
317
|
return next;
|
|
297
318
|
}, [containerRef, setState]);
|
|
@@ -302,7 +323,7 @@ export function useVimSelection({
|
|
|
302
323
|
setFocused(false);
|
|
303
324
|
setHelpOpen(false);
|
|
304
325
|
setHudCommand(null);
|
|
305
|
-
|
|
326
|
+
clearSelectionWithin(containerRef.current);
|
|
306
327
|
return;
|
|
307
328
|
}
|
|
308
329
|
if (!focused) return;
|
|
@@ -366,9 +387,9 @@ export function useVimSelection({
|
|
|
366
387
|
|
|
367
388
|
const setSemanticTarget = useCallback((target: SemanticTarget) => {
|
|
368
389
|
setState(semanticStateForTarget(target));
|
|
369
|
-
|
|
390
|
+
clearSelectionWithin(containerRef.current);
|
|
370
391
|
scrollVimTargetIntoView(target.element, scrollViewportRef.current);
|
|
371
|
-
}, [setState]);
|
|
392
|
+
}, [containerRef, setState]);
|
|
372
393
|
|
|
373
394
|
const updateTextState = useCallback((
|
|
374
395
|
graph: SemanticTargetGraph,
|
|
@@ -816,13 +837,13 @@ export function useVimSelection({
|
|
|
816
837
|
setSemanticTarget(parent);
|
|
817
838
|
} else {
|
|
818
839
|
setState(createInitialVimSelectionState());
|
|
819
|
-
|
|
840
|
+
clearSelectionWithin(graph.container);
|
|
820
841
|
}
|
|
821
842
|
return;
|
|
822
843
|
}
|
|
823
844
|
case 'block':
|
|
824
845
|
setState(createInitialVimSelectionState());
|
|
825
|
-
|
|
846
|
+
clearSelectionWithin(graph.container);
|
|
826
847
|
}
|
|
827
848
|
}, [setSemanticTarget, setState, updateTextState]);
|
|
828
849
|
|
|
@@ -1041,7 +1062,7 @@ export function useVimSelection({
|
|
|
1041
1062
|
const onMouseDown = useCallback((event: ReactMouseEvent<HTMLElement>) => {
|
|
1042
1063
|
if (!enabled || isDocumentKeyboardControl(event.target)) return;
|
|
1043
1064
|
setState(createInitialVimSelectionState());
|
|
1044
|
-
|
|
1065
|
+
clearSelectionWithin(event.currentTarget);
|
|
1045
1066
|
if (document.activeElement !== event.currentTarget) {
|
|
1046
1067
|
pointerFocusRef.current = true;
|
|
1047
1068
|
event.currentTarget.focus({ preventScroll: true });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@plannotator/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.46.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
"./components/*": "./components/*.tsx",
|
|
@@ -76,7 +76,7 @@
|
|
|
76
76
|
"@lezer/highlight": "^1.2.3",
|
|
77
77
|
"@pierre/diffs": "1.3.6",
|
|
78
78
|
"@plannotator/atomic-editor": "^0.8.0",
|
|
79
|
-
"@plannotator/core": "0.25.
|
|
79
|
+
"@plannotator/core": "0.25.6",
|
|
80
80
|
"@plannotator/markdown-editor": "^0.4.0",
|
|
81
81
|
"@plannotator/web-highlighter": "^0.8.1",
|
|
82
82
|
"@tanstack/react-table": "^8.21.3",
|