@plannotator/ui 0.45.1 → 0.46.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 +51 -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/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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@plannotator/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.46.0",
|
|
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",
|