@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.
@@ -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
- restoreDraft: () => { annotations: CodeAnnotation[]; descriptionAnnotations: Annotation[]; commentAnnotations: CommentAnnotation[]; viewedFiles: string[]; autoViewSuppressed: string[] };
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
- return { draftBanner, restoreDraft, getDraftGeneration, dismissDraft };
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.45.1",
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.5",
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",