@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
package/config/settings.ts
CHANGED
|
@@ -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
|
|
240
|
-
//
|
|
241
|
-
//
|
|
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: '
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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 = '
|
|
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 = '
|
|
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
|
-
//
|
|
179
|
-
//
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
):
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
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
|
-
|
|
303
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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) =>
|
|
470
|
-
|
|
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) =>
|
|
474
|
-
|
|
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) =>
|
|
492
|
-
|
|
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) =>
|
|
496
|
-
|
|
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) =>
|
|
514
|
-
|
|
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
|
|
551
|
+
claudeModel,
|
|
567
552
|
claudeEffort,
|
|
568
|
-
codexModel
|
|
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
|
|
561
|
+
tourClaudeModel,
|
|
577
562
|
tourClaudeEffort,
|
|
578
|
-
tourCodexModel
|
|
563
|
+
tourCodexModel,
|
|
579
564
|
tourCodexReasoning,
|
|
580
565
|
tourCodexFast,
|
|
581
|
-
guideClaudeModel
|
|
566
|
+
guideClaudeModel,
|
|
582
567
|
guideClaudeEffort,
|
|
583
|
-
guideCodexModel
|
|
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
|
-
|
|
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();
|