@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.
Files changed (36) hide show
  1. package/HANDOFF.md +52 -1
  2. package/README.md +1 -1
  3. package/components/AgentsTab.tsx +108 -108
  4. package/components/BlockRenderer.tsx +9 -5
  5. package/components/ExportModal.tsx +8 -4
  6. package/components/ImageAnnotator/Toolbar.tsx +13 -3
  7. package/components/ImageAnnotator/index.tsx +6 -2
  8. package/components/InlineMarkdown.tsx +14 -7
  9. package/components/RenderedMarkdown.tsx +3 -1
  10. package/components/Settings.tsx +38 -15
  11. package/components/Viewer.tsx +76 -18
  12. package/components/blocks/AlertBlock.tsx +3 -2
  13. package/components/blocks/Callout.tsx +3 -0
  14. package/components/blocks/CodeBlock.tsx +6 -2
  15. package/components/blocks/TableBlock.tsx +4 -0
  16. package/components/blocks/TablePopout.tsx +6 -2
  17. package/components/blocks/proseBody.tsx +3 -0
  18. package/components/plan-diff/PlanCleanDiffView.tsx +6 -2
  19. package/config/reviewView.ts +8 -3
  20. package/config/settings.ts +10 -4
  21. package/hooks/useAgentSettings.ts +108 -123
  22. package/hooks/useAnnotationHighlighter.ts +109 -1
  23. package/hooks/useCodeAnnotationDraft.ts +196 -3
  24. package/hooks/useModelCatalogs.ts +116 -0
  25. package/hooks/useVimSelection.ts +28 -7
  26. package/package.json +2 -2
  27. package/styles.css +1 -1
  28. package/types.ts +31 -0
  29. package/utils/aiProvider.ts +8 -16
  30. package/utils/annotateAgentInstructions.ts +162 -0
  31. package/utils/codeHighlight.ts +18 -0
  32. package/utils/parser.ts +141 -31
  33. package/utils/terminalToolsAnnouncement.ts +2 -2
  34. package/assets/review-sections.png +0 -0
  35. package/assets/review-tree.png +0 -0
  36. package/utils/codexModels.ts +0 -79
@@ -236,15 +236,21 @@ export const SETTINGS = {
236
236
 
237
237
  // Which left-panel view a code review OPENS in. 'sections' = the git-status
238
238
  // view (Committed/Changes/Untracked); 'tree' = the classic file tree.
239
- // Cookie-only. Written ONLY by Settings and the first-run setup dialog —
240
- // the in-review header toggle is session-scoped and never writes this
241
- // (looking at another view mid-review must not silently change the default).
239
+ // Cookie-only. Written ONLY by Settings — the in-review header toggle is
240
+ // session-scoped and never writes this (looking at another view mid-review
241
+ // must not silently change the default).
242
+ //
243
+ // The default is 'tree': the classic file tree renders every diff type, so
244
+ // the (reviewPanelView, defaultDiffType) pair is trivially consistent for a
245
+ // reviewer who has chosen neither. 'sections' is reachable from the panel's
246
+ // Tree | Git status | Commits toggle and settable as the default here in
247
+ // Settings. Anyone with a persisted cookie keeps whatever they chose.
242
248
  //
243
249
  // Deliberately NOT a value here: 'commits'. The Commits view is session-only
244
250
  // and never the opening view — a review always opens on files. A
245
251
  // previously-persisted 'commits' cookie is treated as unset.
246
252
  reviewPanelView: {
247
- defaultValue: 'sections' as 'sections' | 'tree',
253
+ defaultValue: 'tree' as 'sections' | 'tree',
248
254
  fromCookie: () => {
249
255
  const v = storage.getItem('plannotator-review-panel-view');
250
256
  return v === 'tree' || v === 'sections' ? v : undefined;
@@ -1,6 +1,7 @@
1
1
  import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import { resolveEffortChoice, resolveModelChoice } from '@plannotator/core/model-catalog';
2
3
  import { getItem, setItem } from '../utils/storage';
3
- import { clampCodexReasoning } from '../utils/codexModels';
4
+ import type { ModelCatalog, ModelCatalogs } from './useModelCatalogs';
4
5
 
5
6
  const COOKIE_KEY = 'plannotator.agents';
6
7
 
@@ -16,16 +17,17 @@ const COOKIE_KEY = 'plannotator.agents';
16
17
  // would clobber writes exactly like before.
17
18
  const settingsListeners = new Set<(s: AgentSettingsState) => void>();
18
19
 
19
- export const DEFAULT_CLAUDE_MODEL = 'claude-opus-5';
20
+ // Claude defaults are the CLI's latest-resolving aliases, so they never go
21
+ // stale. Codex defaults are '' — "the model Codex's own list marks default",
22
+ // resolved against the discovered catalog (see useModelCatalogs).
23
+ export const DEFAULT_CLAUDE_MODEL = 'opus';
20
24
  export const DEFAULT_CLAUDE_EFFORT = 'high';
21
- // gpt-5.3-codex is deprecated (ChatGPT-account Codex rejects it outright) —
22
- // default to the current flagship everywhere.
23
- export const DEFAULT_CODEX_MODEL = 'gpt-5.5';
25
+ export const DEFAULT_CODEX_MODEL = '';
24
26
  export const DEFAULT_CODEX_REASONING = 'high';
25
27
  export const DEFAULT_CODEX_FAST = false;
26
28
  export const DEFAULT_TOUR_CLAUDE_MODEL = 'sonnet';
27
29
  export const DEFAULT_TOUR_CLAUDE_EFFORT = 'medium';
28
- export const DEFAULT_TOUR_CODEX_MODEL = 'gpt-5.5';
30
+ export const DEFAULT_TOUR_CODEX_MODEL = '';
29
31
  export const DEFAULT_TOUR_CODEX_REASONING = 'medium';
30
32
  export const DEFAULT_TOUR_CODEX_FAST = false;
31
33
  export const DEFAULT_GUIDE_CLAUDE_MODEL = 'sonnet';
@@ -33,7 +35,7 @@ export const DEFAULT_GUIDE_CLAUDE_MODEL = 'sonnet';
33
35
  // orientation doc the reviewer is actively waiting on, and newer models at
34
36
  // low effort chapter a diff well. Guide-scoped only — tour/review keep medium.
35
37
  export const DEFAULT_GUIDE_CLAUDE_EFFORT = 'low';
36
- export const DEFAULT_GUIDE_CODEX_MODEL = 'gpt-5.5';
38
+ export const DEFAULT_GUIDE_CODEX_MODEL = '';
37
39
  export const DEFAULT_GUIDE_CODEX_REASONING = 'low';
38
40
  // No DEFAULT_GUIDE_CODEX_FAST: fast mode is deliberately not offered for
39
41
  // guide (product decision — see AgentsTab's guide codex config block), so
@@ -175,66 +177,16 @@ const initialState: AgentSettingsState = {
175
177
  guideCopilot: { model: DEFAULT_GUIDE_COPILOT_MODEL },
176
178
  };
177
179
 
178
- // One-shot migration: drop any cached "none" codex reasoning entries. The
179
- // dropdown no longer offers "None" (codex-rs rejects it as a config value);
180
- // fall back to the default instead of shipping an invalid flag. Saved
181
- // "minimal" entries migrate to "low": no current Codex model supports
182
- // minimal, and low is the nearest effort that every model does.
183
- export function sanitizeCodexPerModel(
184
- perModel: Record<string, { reasoning: string; fast: boolean }> | undefined,
185
- ): Record<string, { reasoning: string; fast: boolean }> {
186
- if (!perModel) return {};
187
- const out: Record<string, { reasoning: string; fast: boolean }> = {};
188
- for (const [model, entry] of Object.entries(perModel)) {
189
- if (!entry || typeof entry !== 'object') continue;
190
- if (entry.reasoning === 'none') {
191
- if (entry.fast) out[model] = { reasoning: DEFAULT_CODEX_REASONING, fast: true };
192
- continue;
193
- }
194
- if (entry.reasoning === 'minimal') {
195
- out[model] = { ...entry, reasoning: 'low' };
196
- continue;
197
- }
198
- out[model] = entry;
199
- }
200
- return out;
201
- }
202
-
203
- // Saved model IDs that migrate to a direct replacement: the stale gpt-5.6
204
- // slug (renamed to -sol when the tiered family shipped) and gpt-5.1-codex-mini
205
- // (API shutdown 2026-07-23; gpt-5.4-mini is OpenAI's recommended replacement).
206
- const RENAMED_CODEX_MODELS: Record<string, string> = {
207
- 'gpt-5.6': 'gpt-5.6-sol',
208
- 'gpt-5.1-codex-mini': 'gpt-5.4-mini',
209
- };
210
-
211
- // Saved model IDs with no direct replacement — migrate to the surface's
212
- // fallback. gpt-5.3-codex is rejected outright by ChatGPT-account Codex;
213
- // gpt-5.2-codex and gpt-5.1-codex-max hit the API-level shutdown on
214
- // 2026-07-23 (OpenAI recommends gpt-5.5, which every fallback already is).
215
- const RETIRED_CODEX_MODELS = new Set(['gpt-5.3-codex', 'gpt-5.2-codex', 'gpt-5.1-codex-max']);
216
-
217
- // One-shot model migrations for a saved Codex section. Keep its per-model
218
- // preferences aligned with the canonical model ID while sanitizing them.
219
- export function migrateCodexSection(
220
- section: { model?: unknown; perModel?: Record<string, { reasoning: string; fast: boolean }> } | undefined,
180
+ // A saved Claude/Codex section. Stale model ids are NOT migrated here: they
181
+ // are resolved against the discovered catalog at read time (effectiveModel).
182
+ function parseModelSection<P>(
183
+ section: { model?: unknown; perModel?: unknown } | undefined,
221
184
  fallback: string,
222
- ): CodexSection {
223
- const perModel = sanitizeCodexPerModel(section?.perModel);
224
- for (const [legacy, replacement] of Object.entries(RENAMED_CODEX_MODELS)) {
225
- const legacyPreference = perModel[legacy];
226
- if (legacyPreference) {
227
- perModel[replacement] ??= legacyPreference;
228
- delete perModel[legacy];
229
- }
230
- }
231
-
232
- const savedModel = section?.model;
233
- const model =
234
- typeof savedModel !== 'string' || RETIRED_CODEX_MODELS.has(savedModel)
235
- ? fallback
236
- : (RENAMED_CODEX_MODELS[savedModel] ?? savedModel);
237
- return { model, perModel };
185
+ ): { model: string; perModel: Record<string, P> } {
186
+ return {
187
+ model: typeof section?.model === 'string' ? section.model : fallback,
188
+ perModel: (section?.perModel as Record<string, P> | undefined) ?? {},
189
+ };
238
190
  }
239
191
 
240
192
  function parseEngine(value: unknown): AgentEngine {
@@ -280,11 +232,8 @@ function readCookie(): AgentSettingsState {
280
232
  reviewProfileByEngine: parseReviewProfileByEngine(parsed),
281
233
  tourEngine: parseEngine(parsed.tourEngine),
282
234
  guideEngine: parseReviewEngine(parsed.guideEngine),
283
- claude: {
284
- model: typeof parsed.claude?.model === 'string' ? parsed.claude.model : DEFAULT_CLAUDE_MODEL,
285
- perModel: parsed.claude?.perModel ?? {},
286
- },
287
- codex: migrateCodexSection(parsed.codex, DEFAULT_CODEX_MODEL),
235
+ claude: parseModelSection(parsed.claude, DEFAULT_CLAUDE_MODEL),
236
+ codex: parseModelSection(parsed.codex, DEFAULT_CODEX_MODEL),
288
237
  cursor: {
289
238
  model: typeof parsed.cursor?.model === 'string' ? parsed.cursor.model : DEFAULT_CURSOR_MODEL,
290
239
  },
@@ -298,16 +247,10 @@ function readCookie(): AgentSettingsState {
298
247
  copilot: {
299
248
  model: typeof parsed.copilot?.model === 'string' ? parsed.copilot.model : DEFAULT_COPILOT_MODEL,
300
249
  },
301
- tourClaude: {
302
- model: typeof parsed.tourClaude?.model === 'string' ? parsed.tourClaude.model : DEFAULT_TOUR_CLAUDE_MODEL,
303
- perModel: parsed.tourClaude?.perModel ?? {},
304
- },
305
- tourCodex: migrateCodexSection(parsed.tourCodex, DEFAULT_TOUR_CODEX_MODEL),
306
- guideClaude: {
307
- model: typeof parsed.guideClaude?.model === 'string' ? parsed.guideClaude.model : DEFAULT_GUIDE_CLAUDE_MODEL,
308
- perModel: parsed.guideClaude?.perModel ?? {},
309
- },
310
- guideCodex: migrateCodexSection(parsed.guideCodex, DEFAULT_GUIDE_CODEX_MODEL),
250
+ tourClaude: parseModelSection(parsed.tourClaude, DEFAULT_TOUR_CLAUDE_MODEL),
251
+ tourCodex: parseModelSection(parsed.tourCodex, DEFAULT_TOUR_CODEX_MODEL),
252
+ guideClaude: parseModelSection(parsed.guideClaude, DEFAULT_GUIDE_CLAUDE_MODEL),
253
+ guideCodex: parseModelSection(parsed.guideCodex, DEFAULT_GUIDE_CODEX_MODEL),
311
254
  guideCursor: {
312
255
  model: typeof parsed.guideCursor?.model === 'string' ? parsed.guideCursor.model : DEFAULT_GUIDE_CURSOR_MODEL,
313
256
  },
@@ -327,7 +270,34 @@ function readCookie(): AgentSettingsState {
327
270
  }
328
271
  }
329
272
 
330
- export function useAgentSettings() {
273
+ /**
274
+ * The model a launch actually uses: the saved pick when the catalog offers it,
275
+ * else the surface default, else the catalog's own default. Resolved at read
276
+ * time (the saved cookie is never rewritten), and only once the catalog has
277
+ * settled — before that the saved pick is used as-is.
278
+ */
279
+ export function effectiveModel(catalog: ModelCatalog | undefined, saved: string, surfaceDefault: string): string {
280
+ return catalog?.settled ? resolveModelChoice(saved, catalog.models, surfaceDefault) : saved;
281
+ }
282
+
283
+ /** The effort a launch uses: clamped to what the effective model accepts ('' = none). */
284
+ export function effectiveEffort(catalog: ModelCatalog | undefined, model: string, effort: string): string {
285
+ return catalog?.settled ? resolveEffortChoice(effort, catalog.models, model) : effort;
286
+ }
287
+
288
+ /**
289
+ * Fast mode only for a model that offers it, and never carried over to a
290
+ * DIFFERENT model that replaced a stale saved pick (the fast choice was made
291
+ * for the old model). A saved '' is "the tool's default", not a replacement.
292
+ */
293
+ export function effectiveFast(catalog: ModelCatalog | undefined, savedModel: string, model: string, fast: boolean): boolean {
294
+ if (!fast || !catalog?.settled) return fast;
295
+ if (savedModel && savedModel !== model) return false;
296
+ const entry = catalog.models.find((m) => m.id === model);
297
+ return entry ? entry.fastMode === true : fast;
298
+ }
299
+
300
+ export function useAgentSettings(catalogs?: ModelCatalogs) {
331
301
  const [state, setState] = useState<AgentSettingsState>(readCookie);
332
302
  // Serialized form of the state this instance knows is already persisted
333
303
  // and broadcast. The persist effect compares VALUES against this instead
@@ -444,20 +414,32 @@ export function useAgentSettings() {
444
414
  setState((s) => ({ ...s, copilot: { ...s.copilot, model } }));
445
415
  }, []);
446
416
 
417
+ /**
418
+ * Write a Codex setting for the model the picker SHOWS. When resolution
419
+ * replaced a stale saved model with a different one, the section is re-keyed
420
+ * to the shown model (seeded with the values shown for it), since a setting
421
+ * stored under the stale id would never apply (effectiveFast drops fast
422
+ * across a replacement). A saved '' ("the tool's default") is kept.
423
+ */
447
424
  const patchCodex = useCallback(
448
425
  (
449
426
  section: 'codex' | 'tourCodex' | 'guideCodex',
450
427
  patch: Partial<{ reasoning: string; fast: boolean }>,
451
428
  defaults: { reasoning: string; fast: boolean },
429
+ shown: { model: string; reasoning: string; fast: boolean },
452
430
  ) => {
453
431
  setState((s) => {
454
432
  const cur = s[section];
455
- const prev = cur.perModel[cur.model] ?? defaults;
433
+ const replaced = !!cur.model && !!shown.model && shown.model !== cur.model;
434
+ const model = replaced ? shown.model : cur.model;
435
+ const prev =
436
+ cur.perModel[model] ?? (replaced ? { reasoning: shown.reasoning, fast: shown.fast } : defaults);
456
437
  return {
457
438
  ...s,
458
439
  [section]: {
459
440
  ...cur,
460
- perModel: { ...cur.perModel, [cur.model]: { ...prev, ...patch } },
441
+ model,
442
+ perModel: { ...cur.perModel, [model]: { ...prev, ...patch } },
461
443
  },
462
444
  };
463
445
  });
@@ -465,13 +447,36 @@ export function useAgentSettings() {
465
447
  [],
466
448
  );
467
449
 
450
+ // Effective (launchable) values, resolved against the discovered catalogs.
451
+ // Pickers and launch payloads both read these, so what is shown is what runs.
452
+ const claudeCatalog = catalogs?.claude;
453
+ const codexCatalog = catalogs?.codex;
454
+ const claudeModel = effectiveModel(claudeCatalog, state.claude.model, DEFAULT_CLAUDE_MODEL);
455
+ const claudeEffort = effectiveEffort(claudeCatalog, claudeModel, state.claude.perModel[state.claude.model]?.effort ?? DEFAULT_CLAUDE_EFFORT);
456
+ const codexModel = effectiveModel(codexCatalog, state.codex.model, DEFAULT_CODEX_MODEL);
457
+ const codexReasoning = effectiveEffort(codexCatalog, codexModel, state.codex.perModel[state.codex.model]?.reasoning ?? DEFAULT_CODEX_REASONING);
458
+ const codexFast = effectiveFast(codexCatalog, state.codex.model, codexModel, state.codex.perModel[state.codex.model]?.fast ?? DEFAULT_CODEX_FAST);
459
+ const tourClaudeModel = effectiveModel(claudeCatalog, state.tourClaude.model, DEFAULT_TOUR_CLAUDE_MODEL);
460
+ const tourClaudeEffort = effectiveEffort(claudeCatalog, tourClaudeModel, state.tourClaude.perModel[state.tourClaude.model]?.effort ?? DEFAULT_TOUR_CLAUDE_EFFORT);
461
+ const tourCodexModel = effectiveModel(codexCatalog, state.tourCodex.model, DEFAULT_TOUR_CODEX_MODEL);
462
+ const tourCodexReasoning = effectiveEffort(codexCatalog, tourCodexModel, state.tourCodex.perModel[state.tourCodex.model]?.reasoning ?? DEFAULT_TOUR_CODEX_REASONING);
463
+ const tourCodexFast = effectiveFast(codexCatalog, state.tourCodex.model, tourCodexModel, state.tourCodex.perModel[state.tourCodex.model]?.fast ?? DEFAULT_TOUR_CODEX_FAST);
464
+ const guideClaudeModel = effectiveModel(claudeCatalog, state.guideClaude.model, DEFAULT_GUIDE_CLAUDE_MODEL);
465
+ const guideClaudeEffort = effectiveEffort(claudeCatalog, guideClaudeModel, state.guideClaude.perModel[state.guideClaude.model]?.effort ?? DEFAULT_GUIDE_CLAUDE_EFFORT);
466
+ const guideCodexModel = effectiveModel(codexCatalog, state.guideCodex.model, DEFAULT_GUIDE_CODEX_MODEL);
467
+ const guideCodexReasoning = effectiveEffort(codexCatalog, guideCodexModel, state.guideCodex.perModel[state.guideCodex.model]?.reasoning ?? DEFAULT_GUIDE_CODEX_REASONING);
468
+ // Guide offers no fast toggle; a re-key carries whatever is stored.
469
+ const guideCodexFast = state.guideCodex.perModel[state.guideCodex.model]?.fast ?? DEFAULT_CODEX_FAST;
470
+
468
471
  const setCodexReasoning = useCallback(
469
- (reasoning: string) => patchCodex('codex', { reasoning }, { reasoning: DEFAULT_CODEX_REASONING, fast: DEFAULT_CODEX_FAST }),
470
- [patchCodex],
472
+ (reasoning: string) =>
473
+ patchCodex('codex', { reasoning }, { reasoning: DEFAULT_CODEX_REASONING, fast: DEFAULT_CODEX_FAST }, { model: codexModel, reasoning: codexReasoning, fast: codexFast }),
474
+ [patchCodex, codexModel, codexReasoning, codexFast],
471
475
  );
472
476
  const setCodexFast = useCallback(
473
- (fast: boolean) => patchCodex('codex', { fast }, { reasoning: DEFAULT_CODEX_REASONING, fast: DEFAULT_CODEX_FAST }),
474
- [patchCodex],
477
+ (fast: boolean) =>
478
+ patchCodex('codex', { fast }, { reasoning: DEFAULT_CODEX_REASONING, fast: DEFAULT_CODEX_FAST }, { model: codexModel, reasoning: codexReasoning, fast: codexFast }),
479
+ [patchCodex, codexModel, codexReasoning, codexFast],
475
480
  );
476
481
 
477
482
  const setTourClaudeModel = useCallback((model: string) => {
@@ -488,12 +493,14 @@ export function useAgentSettings() {
488
493
  }, []);
489
494
 
490
495
  const setTourCodexReasoning = useCallback(
491
- (reasoning: string) => patchCodex('tourCodex', { reasoning }, { reasoning: DEFAULT_TOUR_CODEX_REASONING, fast: DEFAULT_TOUR_CODEX_FAST }),
492
- [patchCodex],
496
+ (reasoning: string) =>
497
+ patchCodex('tourCodex', { reasoning }, { reasoning: DEFAULT_TOUR_CODEX_REASONING, fast: DEFAULT_TOUR_CODEX_FAST }, { model: tourCodexModel, reasoning: tourCodexReasoning, fast: tourCodexFast }),
498
+ [patchCodex, tourCodexModel, tourCodexReasoning, tourCodexFast],
493
499
  );
494
500
  const setTourCodexFast = useCallback(
495
- (fast: boolean) => patchCodex('tourCodex', { fast }, { reasoning: DEFAULT_TOUR_CODEX_REASONING, fast: DEFAULT_TOUR_CODEX_FAST }),
496
- [patchCodex],
501
+ (fast: boolean) =>
502
+ patchCodex('tourCodex', { fast }, { reasoning: DEFAULT_TOUR_CODEX_REASONING, fast: DEFAULT_TOUR_CODEX_FAST }, { model: tourCodexModel, reasoning: tourCodexReasoning, fast: tourCodexFast }),
503
+ [patchCodex, tourCodexModel, tourCodexReasoning, tourCodexFast],
497
504
  );
498
505
 
499
506
  const setGuideClaudeModel = useCallback((model: string) => {
@@ -510,8 +517,9 @@ export function useAgentSettings() {
510
517
  }, []);
511
518
 
512
519
  const setGuideCodexReasoning = useCallback(
513
- (reasoning: string) => patchCodex('guideCodex', { reasoning }, { reasoning: DEFAULT_GUIDE_CODEX_REASONING, fast: DEFAULT_CODEX_FAST }),
514
- [patchCodex],
520
+ (reasoning: string) =>
521
+ patchCodex('guideCodex', { reasoning }, { reasoning: DEFAULT_GUIDE_CODEX_REASONING, fast: DEFAULT_CODEX_FAST }, { model: guideCodexModel, reasoning: guideCodexReasoning, fast: guideCodexFast }),
522
+ [patchCodex, guideCodexModel, guideCodexReasoning, guideCodexFast],
515
523
  );
516
524
 
517
525
  const setGuideCursorModel = useCallback((model: string) => {
@@ -534,38 +542,15 @@ export function useAgentSettings() {
534
542
  setState((s) => ({ ...s, guideCopilot: { ...s.guideCopilot, model } }));
535
543
  }, []);
536
544
 
537
- const claudeEffort = state.claude.perModel[state.claude.model]?.effort ?? DEFAULT_CLAUDE_EFFORT;
538
- // Codex reasoning is clamped through the model's supported-effort set: a
539
- // saved (or surface-default) effort the selected model doesn't support
540
- // snaps to that model's catalog default. Every consumer — the pickers AND
541
- // the launch payloads — reads these derived values, so an unsupported
542
- // effort can never reach `-c model_reasoning_effort=`.
543
- const codexReasoning = clampCodexReasoning(
544
- state.codex.model,
545
- state.codex.perModel[state.codex.model]?.reasoning ?? DEFAULT_CODEX_REASONING,
546
- );
547
- const codexFast = state.codex.perModel[state.codex.model]?.fast ?? DEFAULT_CODEX_FAST;
548
- const tourClaudeEffort = state.tourClaude.perModel[state.tourClaude.model]?.effort ?? DEFAULT_TOUR_CLAUDE_EFFORT;
549
- const tourCodexReasoning = clampCodexReasoning(
550
- state.tourCodex.model,
551
- state.tourCodex.perModel[state.tourCodex.model]?.reasoning ?? DEFAULT_TOUR_CODEX_REASONING,
552
- );
553
- const tourCodexFast = state.tourCodex.perModel[state.tourCodex.model]?.fast ?? DEFAULT_TOUR_CODEX_FAST;
554
- const guideClaudeEffort = state.guideClaude.perModel[state.guideClaude.model]?.effort ?? DEFAULT_GUIDE_CLAUDE_EFFORT;
555
- const guideCodexReasoning = clampCodexReasoning(
556
- state.guideCodex.model,
557
- state.guideCodex.perModel[state.guideCodex.model]?.reasoning ?? DEFAULT_GUIDE_CODEX_REASONING,
558
- );
559
-
560
545
  return {
561
546
  selectedMode: state.selectedMode,
562
547
  reviewEngine: state.reviewEngine,
563
548
  reviewProfileId: state.reviewProfileByEngine[state.reviewEngine] ?? BUILTIN_DEFAULT_PROFILE,
564
549
  tourEngine: state.tourEngine,
565
550
  guideEngine: state.guideEngine,
566
- claudeModel: state.claude.model,
551
+ claudeModel,
567
552
  claudeEffort,
568
- codexModel: state.codex.model,
553
+ codexModel,
569
554
  codexReasoning,
570
555
  codexFast,
571
556
  cursorModel: state.cursor.model,
@@ -573,14 +558,14 @@ export function useAgentSettings() {
573
558
  piModel: state.pi.model,
574
559
  piThinking: state.pi.thinking,
575
560
  copilotModel: state.copilot.model,
576
- tourClaudeModel: state.tourClaude.model,
561
+ tourClaudeModel,
577
562
  tourClaudeEffort,
578
- tourCodexModel: state.tourCodex.model,
563
+ tourCodexModel,
579
564
  tourCodexReasoning,
580
565
  tourCodexFast,
581
- guideClaudeModel: state.guideClaude.model,
566
+ guideClaudeModel,
582
567
  guideClaudeEffort,
583
- guideCodexModel: state.guideCodex.model,
568
+ guideCodexModel,
584
569
  guideCodexReasoning,
585
570
  guideCursorModel: state.guideCursor.model,
586
571
  guideOpencodeModel: state.guideOpencode.model,
@@ -212,6 +212,53 @@ const isAnnotationExcludedTextNode = (node: Node): boolean =>
212
212
  */
213
213
  const compactText = (value: string): string => value.replace(/\s+/g, '');
214
214
 
215
+ /**
216
+ * A quote with nothing but whitespace in it is not an annotation (#881).
217
+ *
218
+ * A double-click at a block boundary — just past the end of a heading, a
219
+ * paragraph, a list item, a fence — leaves a non-collapsed selection whose
220
+ * whole string is the break BETWEEN two blocks ("\n"). Acting on it produces
221
+ * an invisible highlight and an `originalText: "\n"` annotation that no
222
+ * reload can re-anchor, and which is still counted and exported.
223
+ *
224
+ * `compactText` rather than `trim`: it also removes the non-breaking spaces
225
+ * and other Unicode whitespace a rendered document is full of.
226
+ */
227
+ const isBlankQuote = (value: string | null | undefined): boolean =>
228
+ compactText(value ?? '') === '';
229
+
230
+ /**
231
+ * Whether web-highlighter can serialize a range boundary (#881).
232
+ *
233
+ * A non-text boundary is resolved by the library as `childNodes[offset]` and
234
+ * that node is then serialized. The DOM lets `offset` equal `childNodes.length`
235
+ * (one past the last child) and browsers really do report that for a
236
+ * double-click at the end of the last block, which leaves the library holding
237
+ * `undefined` and throwing `Cannot read properties of undefined (reading
238
+ * 'parentNode')` out of its own pointer-end listener — an uncaught error, with
239
+ * no annotation created either way.
240
+ */
241
+ const boundaryResolves = (node: Node, offset: number): boolean => {
242
+ // Text, CDATASection and Comment boundaries are offsets INTO the node.
243
+ if (node.nodeType === 3 || node.nodeType === 4 || node.nodeType === 8) return true;
244
+ return offset >= 0 && offset < node.childNodes.length && node.childNodes[offset] != null;
245
+ };
246
+
247
+ const isSerializableRange = (range: Range): boolean =>
248
+ boundaryResolves(range.startContainer, range.startOffset)
249
+ && boundaryResolves(range.endContainer, range.endOffset);
250
+
251
+ /**
252
+ * One line per page load: a highlighter failure is degraded UX (the selection
253
+ * is ignored), not something to flood the console — or the user — over.
254
+ */
255
+ let warnedHighlighterFailure = false;
256
+ const warnHighlighterFailure = (context: string, error: unknown): void => {
257
+ if (warnedHighlighterFailure) return;
258
+ warnedHighlighterFailure = true;
259
+ console.warn(`[plannotator] highlighter ${context} failed; the selection was ignored`, error);
260
+ };
261
+
215
262
  /**
216
263
  * The text a set of painted highlight wrappers shows the reader.
217
264
  *
@@ -878,6 +925,11 @@ export function useAnnotationHighlighter({
878
925
  quickLabelTip?: string,
879
926
  mentions?: readonly string[],
880
927
  ) => {
928
+ // #881: the last line before an annotation exists. A quote of pure
929
+ // whitespace cannot be re-anchored on the next load, so it would come back
930
+ // as an invisible row that is still counted and still exported.
931
+ if (isBlankQuote(source?.text)) return;
932
+
881
933
  const doms = highlighter.getDoms(source.id);
882
934
  let blockId = '';
883
935
  let startOffset = 0;
@@ -1053,6 +1105,14 @@ export function useAnnotationHighlighter({
1053
1105
  // verdict, so it is neither attempted nor unanchored here (the same
1054
1106
  // rule the raw-HTML pinpoints follow on their surface).
1055
1107
  if (ann.diagramAnchor) return;
1108
+ // #881: a row whose quote is only whitespace has nothing to anchor to.
1109
+ // Older drafts carry such rows (a double-click at a block boundary used
1110
+ // to create one), and the stored positions still resolve, so restoring
1111
+ // it paints an invisible mark on a spot the reviewer never chose. Skip
1112
+ // it outright — neither attempted nor unanchored, the same treatment a
1113
+ // diagram row gets, so a legacy draft does not raise an "Unanchored"
1114
+ // chip and a toast on every load.
1115
+ if (isBlankQuote(ann.originalText)) return;
1056
1116
  attempted.push(ann.id);
1057
1117
 
1058
1118
  // Skip if already highlighted
@@ -1292,6 +1352,21 @@ export function useAnnotationHighlighter({
1292
1352
  source.text,
1293
1353
  doms.map((dom: HTMLElement) => dom.textContent ?? '').join(''),
1294
1354
  );
1355
+ // #881, belt to the capture-phase guard's braces: every producer of
1356
+ // a selection highlight lands here (the library's own pointer-end
1357
+ // listener, the touch selectionchange bridge, vim and pinpoint
1358
+ // ranges), and the quote repair above can itself leave nothing but
1359
+ // whitespace. A blank quote opens no toolbar, no composer and
1360
+ // creates no annotation; the painted-but-invisible highlight goes
1361
+ // with it, and the pending selection already on screen is untouched.
1362
+ if (isBlankQuote(source.text)) {
1363
+ try { highlighter.remove(source.id); } catch (error) {
1364
+ warnHighlighterFailure('cleanup of a blank selection', error);
1365
+ }
1366
+ pendingRangeRunsRef.current = null;
1367
+ window.getSelection()?.removeAllRanges();
1368
+ return;
1369
+ }
1295
1370
  // Clean up previous pending
1296
1371
  if (pendingSourceRef.current) {
1297
1372
  highlighter.remove(pendingSourceRef.current.id);
@@ -1352,6 +1427,24 @@ export function useAnnotationHighlighter({
1352
1427
  return;
1353
1428
  }
1354
1429
  const range = selection.getRangeAt(0).cloneRange();
1430
+ // #881: fail closed on a selection there is nothing to annotate in, and
1431
+ // on one the library cannot serialize. Collapsing it HERE — on the
1432
+ // capture phase, before the library's own pointer-end listener reads the
1433
+ // live selection — is what makes its handler a no-op: it bails on a
1434
+ // collapsed selection rather than painting an empty highlight or
1435
+ // throwing out of an event listener we do not own.
1436
+ //
1437
+ // Ahead of the containment check on purpose: the library's handler does
1438
+ // not check containment either. A double-click past the end of the LAST
1439
+ // block leaves a selection whose common ancestor is the scroll container
1440
+ // AROUND the document and whose end boundary is an empty <div> — the
1441
+ // exact shape that throws — so a containment bail here would hand that
1442
+ // selection straight to the serializer.
1443
+ if (isBlankQuote(selection.toString()) || !isSerializableRange(range)) {
1444
+ pendingRangeRunsRef.current = null;
1445
+ selection.removeAllRanges();
1446
+ return;
1447
+ }
1355
1448
  if (!container.contains(range.commonAncestorContainer)) {
1356
1449
  pendingRangeRunsRef.current = null;
1357
1450
  return;
@@ -1466,9 +1559,17 @@ export function useAnnotationHighlighter({
1466
1559
  if (!sel || sel.isCollapsed || sel.rangeCount === 0) return;
1467
1560
  if (!containerRef.current?.contains(sel.anchorNode)) return;
1468
1561
  const range = sel.getRangeAt(0).cloneRange();
1562
+ // Same fail-closed rule as the pointer-end guard (#881): touch
1563
+ // selections settle on block boundaries just as often.
1564
+ if (isBlankQuote(sel.toString()) || !isSerializableRange(range)) return;
1469
1565
  snapRangeStartPastExcluded(range);
1470
1566
  pendingRangeRunsRef.current = rangeTextPieces(range);
1471
- highlighter.fromRange(range);
1567
+ try {
1568
+ highlighter.fromRange(range);
1569
+ } catch (error) {
1570
+ pendingRangeRunsRef.current = null;
1571
+ warnHighlighterFailure('painting a touch selection', error);
1572
+ }
1472
1573
  }, 400);
1473
1574
  }
1474
1575
  : null;
@@ -1495,6 +1596,10 @@ export function useAnnotationHighlighter({
1495
1596
  const container = containerRef.current;
1496
1597
  if (!highlighter || !container || range.collapsed) return;
1497
1598
  if (!container.contains(range.commonAncestorContainer)) return;
1599
+ // A caller-built range (vim, pinpoint) can name a boundary the library
1600
+ // cannot serialize just as a pointer selection can (#881); refuse it
1601
+ // rather than let the serializer throw through this call.
1602
+ if (!isSerializableRange(range)) return;
1498
1603
 
1499
1604
  // Pinpoint clicks and vim visual selections anchor on the first
1500
1605
  // annotatable text node of a block, which for a titled GitHub alert is the
@@ -1511,6 +1616,9 @@ export function useAnnotationHighlighter({
1511
1616
 
1512
1617
  try {
1513
1618
  highlighter.fromRange(painted);
1619
+ } catch (error) {
1620
+ pendingRangeRunsRef.current = null;
1621
+ warnHighlighterFailure('painting a range', error);
1514
1622
  } finally {
1515
1623
  pendingModeOverrideRef.current = null;
1516
1624
  selection?.removeAllRanges();