@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 CHANGED
@@ -1032,7 +1032,8 @@ the people and what a mention means.
1032
1032
 
1033
1033
  ### Threading points
1034
1034
 
1035
- - `AnnotationToolbar` (`selectionActions`, `selectionActionsIcon`, `quickLabels`) — the props live here. `selectionActionsIcon?: React.ReactNode` (0.43.1) is the glyph on the wand button, forwarded by `Viewer` (both toolbars) and `HtmlViewer`; absent → the package's own wand, which 0.43.1 also simplified to one thick diagonal with a single star (the six-spark glyph read as noise at 16px). Name, `data-selection-actions`, size and behavior of the button are untouched either way.
1035
+ - `AnnotationToolbar` (`selectionActions`, `selectionActionsIcon`, `quickLabels`) — the props live here.
1036
+ - **ui 0.45.2 (registry default):** `SETTINGS.reviewPanelView.defaultValue` is now `'tree'` (was `'sections'`). `configStore.ensureLoaded` seeds registry defaults into the host's `storageBackend`, so a host that never persisted a panel view now seeds `tree`; a persisted value still wins. No API change. Plannotator removed its first-run review setup dialog in the same change (#1587). `selectionActionsIcon?: React.ReactNode` (0.43.1) is the glyph on the wand button, forwarded by `Viewer` (both toolbars) and `HtmlViewer`; absent → the package's own wand, which 0.43.1 also simplified to one thick diagonal with a single star (the six-spark glyph read as noise at 16px). Name, `data-selection-actions`, size and behavior of the button are untouched either way.
1036
1037
  - `Viewer` forwards both to BOTH of its toolbars (the text-selection toolbar
1037
1038
  and the code-block hover toolbar).
1038
1039
  - `HtmlViewer` forwards `selectionActions` to its selection toolbar. It does
@@ -1494,6 +1495,36 @@ Tests (both DOM-gated, both added to the workflow's DOM_TESTS step):
1494
1495
  `components/AnnotationPanel.cardHeader.test.tsx` (6) and
1495
1496
  `components/AnnotationPanel.editMentions.test.tsx` (10).
1496
1497
 
1498
+ ## Discovered model catalogs (0.46.0, core 0.25.6) — BREAKING for hosts
1499
+
1500
+ Claude and Codex model lists are no longer hand-maintained in `@plannotator/ui`. They come from the installed CLIs through the Ask AI provider (`/api/ai/capabilities?activate=<id>`), in the shared `CatalogModel` shape from the new `@plannotator/core/model-catalog` subpath, with one small static fallback per provider (`CLAUDE_FALLBACK_MODELS`, `CODEX_FALLBACK_MODELS`). A host that imported any of the following must switch:
1501
+
1502
+ | Removed | Use instead |
1503
+ | --- | --- |
1504
+ | `@plannotator/ui/utils/codexModels` (the whole module: `CODEX_MODELS`, `CODEX_EFFORT_LABELS`, `codexReasoningOptions`, `clampCodexReasoning`) | `useModelCatalogs(capabilities)` for the live list, `effortSelectOptions` / `resolveEffortChoice` / `EFFORT_LABELS` from `@plannotator/core/model-catalog` |
1505
+ | `CLAUDE_MODELS`, `CLAUDE_EFFORT`, `TOUR_CLAUDE_MODELS`, `CODEX_MODELS`, `codexReasoningOptions` exports of `components/AgentsTab` | the same catalog helpers (`modelSelectOptions`, `effortSelectOptions`) |
1506
+ | `sanitizeCodexPerModel`, `migrateCodexSection` from `hooks/useAgentSettings` | nothing: saved picks now resolve at read time with `resolveModelChoice` |
1507
+
1508
+ Behaviour changes a host may notice:
1509
+
1510
+ - `DEFAULT_CODEX_MODEL` (and the tour/guide Codex defaults) are now `''`, meaning "the model the Codex list marks default"; `DEFAULT_CLAUDE_MODEL` is `'opus'` (was `'claude-opus-5'`).
1511
+ - `useAgentSettings(catalogs?)` takes the catalogs and returns EFFECTIVE (resolved) model/effort values; called without catalogs it returns the saved values unchanged, as before.
1512
+ - `AIProviderModel` is now an alias of `CatalogModel` (adds optional `resolvedId`, `fastMode`); `resolveAIModelForProvider` uses the shared resolver, so a stale pinned id keeps its model family instead of snapping to the provider default.
1513
+
1514
+ ## Forge-aware `#123` / `@user` links (unreleased, additive; #1596)
1515
+
1516
+ Bare `#123` and `@user` in a document used to link to github.com whenever `githubRepo` held a slash, so GitLab and GitHub Enterprise repos got links to the wrong forge. `InlineMarkdown` (and every block component that forwards `githubRepo`: `BlockRenderer`, `RenderedMarkdown`, `TableBlock`, `TablePopout`, `AlertBlock`, `Callout`, `proseBody`) gains an optional `repoHost?: string` beside it; `Viewer` passes `repoInfo.host`. The rule is the new `@plannotator/core/forge-refs` subpath (`forgeRefLinks`, `classifyForgeHost`, `normalizeForgeHost`). The host is normalized first: lowercased, port dropped, `www.github.com` / `ssh.github.com` / SSH aliases starting `github.com-` folded to `github.com` and `altssh.gitlab.com` to `gitlab.com`; a host that is not a plausible DNS name (no dot, brackets, `@`, a dotless SSH alias) counts as absent. Then a github host (`github.com`, `github.*`) links to `https://<host>/<path>/issues/N`, a gitlab host (`gitlab.com`, `gitlab.*`, `*.gitlab.*`) to `https://<host>/<path>/-/issues/N`, users to `https://<host>/<user>`, and any other host renders the ref as an unlinked span. **A host that passes no `repoHost` (or an implausible one) keeps the old github.com links**, so nothing changes until it opts in. Because ui imports a new core subpath and `@plannotator/ui` pins `@plannotator/core` EXACTLY (currently `"0.25.6"`), the core version bump and ui's pin must move together in the same release: bump core, set ui's `@plannotator/core` dependency to that same version, `bun install`, then publish `core` first and `ui` second. A ui built against this code on the old core pin would import a `forge-refs` subpath the pinned core does not ship.
1517
+
1518
+ ## Guided Review generation in core (core 0.25.6, unreleased, additive)
1519
+
1520
+ Core-only; `@plannotator/ui` imports none of it, so ui's exact `0.25.6` pin is unchanged. The pure half of Guided Review generation moved verbatim from the private `@plannotator/server` (`packages/server/guide/guide-review.ts`, which re-exports every name) so a host such as Workspaces generates guides with Plannotator's exact prompt and holds the output to the same coverage rule. All three subpaths are browser-safe and zero-dependency:
1521
+
1522
+ - `@plannotator/core/guide-prompt`: `GUIDE_REVIEW_PROMPT` (the organizer methodology), `GUIDE_SCHEMA_JSON` (the output JSON schema, as a string), `buildGuideUserMessage(patch, diffType, options?, prMetadata?, changedFiles?)`, `validateGuideOutput(raw, changedFiles)`, `GUIDE_NO_SECTIONS_ERROR`, the sanitizers `sanitizeGuideSection` / `sanitizeGuideSections` / `sanitizeUnplacedFiles`, and the types `GuideChangedFile` and `GuidePromptPRMetadata` (`{ url, baseBranch }`, structural, so a full GitHub/GitLab `PRMetadata` is accepted).
1523
+ - `@plannotator/core/review-prompt` (what `buildGuideUserMessage` frames a local diff with): `getLocalDiffInstruction`, `buildWorkspacePromptContextLines`, and the types `LocalDiffInstruction`, `WorkspaceReviewPromptContext`, `WorkspacePromptRepoContext`, `WorkspaceChildVcsType`.
1524
+ - `@plannotator/core/diff-type`: the `DiffType` union (including jj's `jj-commit:<id>`) plus `parseCommitDiffType`, `parseJjCommitDiffType`, `jjCommitRevset`, `commitFamilyId`, `parseWorktreeDiffType`, `JJ_TRUNK_REVSET`, `jjLineBaseRevset`, `jjCompareTargetRevset`, `parseRemoteBookmark`, `BARE_HEX_SHA_RE` (moved from `packages/shared/review-core.ts`, which re-exports them).
1525
+
1526
+ The guide chain's engine plumbing (Claude/Codex/marker commands, output parsing, `composeGuideMethodology`, sessions) stays in the server. It ships in core 0.25.6 alongside what that unpublished version already carries; ui needs no change for it.
1527
+
1497
1528
  ## Publishing & versioning
1498
1529
 
1499
1530
  - **ui 0.45.0 (annotation card header slot + mentions on the card's edit box): `@plannotator/ui` only — `@plannotator/core` is UNCHANGED at `0.25.5`, so this publishes alone** (core 0.25.5 must already be published). Purely additive over 0.44.0, both props on `AnnotationPanel`: `renderCardHeader` (the header-row twin of `renderCardFooter`, wrapper `[data-annotation-card-header]`, renders under `readOnly`, open-document cards only in the All-files view) and `mentionSource` (the 0.43.0 type, applied to the card's EDIT box, saving `onEdit(id, { text, mentions })` only when a source was supplied and a pick survived). Nothing is removed, no new supported imports (`components/MentionAutocomplete` is internal glue), no export-, share- or archive-visible change, and Plannotator passes neither — `packages/editor` and `packages/review-editor` have zero source diff, and the panel is byte-identical to 0.44.0. Known difference from `CommentPopover`: no chips in the card's edit box (follow-up named in the section). See "Annotation card header slot and mentions on the edit box (0.45.0)".
@@ -1517,6 +1548,25 @@ Tests (both DOM-gated, both added to the workflow's DOM_TESTS step):
1517
1548
 
1518
1549
  ---
1519
1550
 
1551
+ ## `Settings` `annotateParity` (unreleased, after 0.46.0)
1552
+
1553
+ `<Settings mode="annotate">` takes a new optional `annotateParity?: boolean`,
1554
+ default `false`. **A host that omits it sees exactly the annotate Settings it
1555
+ had**: General, Theme, Vim, Shortcuts, Files, plus the OpenCode agent-switch
1556
+ row when `origin="opencode"`. Plannotator's own annotate app passes `true`,
1557
+ which adds the plan-review document tabs (Display, Saving, Labels, Obsidian,
1558
+ Bear, Octarine) and hides the agent-switch row, which only ever applied to
1559
+ plan approval.
1560
+
1561
+ Within those tabs the plan-decision rows stay plan-only: Save Plans and the
1562
+ three "Auto-save on Plan Arrival" switches. The Obsidian / Bear / Octarine
1563
+ enable switches are the SAME cookies plan review reads on approve, so turning
1564
+ one on from annotate also makes plan review save every approved plan there;
1565
+ the annotate description says so. `mode="plan"` and `mode="review"` are
1566
+ unaffected by the prop. Purely additive: no export, share or archive change.
1567
+
1568
+ ---
1569
+
1520
1570
  ## The law (guardrails for anyone editing `@plannotator/ui`)
1521
1571
 
1522
1572
  These are enforced socially and, where possible, by CI. They exist because a prior from-scratch reimplementation of this UI broke the app and was reverted.
package/README.md CHANGED
@@ -367,7 +367,7 @@ npm install @plannotator/ui @plannotator/core
367
367
  - `@plannotator/core` — pure utils + types, zero deps, browser-safe (CI enforces no `node:` imports). Published.
368
368
  - `@plannotator/ui` — React components/hooks + theme + `configure()`. Depends on an exact published `@plannotator/core` version. Published.
369
369
  - `@plannotator/shared`, `@plannotator/ai` — stay private to the monorepo; `shared` re-exports `core`'s modules via shims so Plannotator's internals are untouched.
370
- - Currently `@plannotator/ui` 0.43.0 depends exactly on `@plannotator/core` 0.25.5. `core` is bumped only when something under `packages/core` changes, so `ui` can advance alone. Keep the published core version exact in `packages/ui/package.json`; do not use a `workspace:` protocol there, because a directly published manifest must remain installable outside this monorepo. Bun still links the matching local workspace during development. When both packages change, publish `core` first, then build and publish the UI tarball. See HANDOFF.md "Publishing & versioning" for the verification command.
370
+ - Currently `@plannotator/ui` 0.46.0 depends exactly on `@plannotator/core` 0.25.6. `core` is bumped only when something under `packages/core` changes, so `ui` can advance alone. Keep the published core version exact in `packages/ui/package.json`; do not use a `workspace:` protocol there, because a directly published manifest must remain installable outside this monorepo. Bun still links the matching local workspace during development. When both packages change, publish `core` first, then build and publish the UI tarball. See HANDOFF.md "Publishing & versioning" for the verification command.
371
371
 
372
372
  ## The one rule
373
373
 
@@ -23,51 +23,27 @@ import { useAgentSettings } from '../hooks/useAgentSettings';
23
23
  import type { AgentEngine, AgentMode, ReviewEngine } from '../hooks/useAgentSettings';
24
24
  import type { AgentLaunchParams } from '../hooks/useAgentJobs';
25
25
  import { ConfigRow, SegmentedPicker, Toggle, SelectMenu } from './AgentControls';
26
- import { CODEX_MODELS, CODEX_EFFORT_LABELS, codexReasoningOptions } from '../utils/codexModels';
26
+ import {
27
+ CLAUDE_FALLBACK_MODELS,
28
+ CODEX_FALLBACK_MODELS,
29
+ EFFORT_LABELS,
30
+ effortSelectOptions,
31
+ modelLabel,
32
+ modelSelectOptions,
33
+ type CatalogModel,
34
+ } from '@plannotator/core/model-catalog';
35
+ import { useModelCatalogs, type ModelCatalogs } from '../hooks/useModelCatalogs';
27
36
 
28
37
  export type { AgentLaunchParams } from '../hooks/useAgentJobs';
29
38
 
30
- // --- Agent option catalogs (shared across review + tour engine dropdowns) ---
31
-
32
- export const CLAUDE_MODELS: Array<{ value: string; label: string }> = [
33
- { value: 'claude-fable-5', label: 'Fable 5' },
34
- { value: 'claude-opus-5', label: 'Opus 5' },
35
- { value: 'claude-opus-4-8', label: 'Opus 4.8' },
36
- { value: 'claude-opus-4-8[1m]', label: 'Opus 4.8 (1M)' },
37
- { value: 'claude-sonnet-5', label: 'Sonnet 5' },
38
- { value: 'claude-sonnet-4-6', label: 'Sonnet 4.6' },
39
- { value: 'claude-sonnet-4-6[1m]', label: 'Sonnet 4.6 (1M)' },
40
- { value: 'claude-opus-4-7', label: 'Opus 4.7' },
41
- { value: 'claude-opus-4-7[1m]', label: 'Opus 4.7 (1M)' },
42
- { value: 'claude-opus-4-6', label: 'Opus 4.6' },
43
- { value: 'claude-opus-4-6[1m]', label: 'Opus 4.6 (1M)' },
44
- { value: 'claude-haiku-4-5', label: 'Haiku 4.5' },
45
- ];
39
+ // --- Agent option catalogs ---
40
+ // Claude and Codex models come from the installed CLIs (useModelCatalogs —
41
+ // the same lists Ask AI shows); only the marker-engine fallbacks live here.
46
42
 
47
- export const CLAUDE_EFFORT: Array<{ value: string; label: string }> = [
48
- { value: 'low', label: 'Low' },
49
- { value: 'medium', label: 'Medium' },
50
- { value: 'high', label: 'High' },
51
- { value: 'xhigh', label: 'XHigh' },
52
- { value: 'max', label: 'Max' },
53
- ];
54
-
55
- // Codex model catalog + per-model reasoning efforts live in
56
- // utils/codexModels (useAgentSettings needs them too, for effort clamping);
57
- // re-exported here so both launch surfaces keep one import site.
58
- export { CODEX_MODELS, codexReasoningOptions } from '../utils/codexModels';
59
-
60
- // Tour/guide Claude catalog: the CLI's latest-resolving aliases on top
61
- // (verified against `claude --help`: "Provide an alias for the latest model
62
- // (e.g. 'fable', 'opus', or 'sonnet') or a model's full name"), then every
63
- // pinned version from the review catalog.
64
- // Also reused by GuideEmptyState (packages/review-editor).
65
- export const TOUR_CLAUDE_MODELS: Array<{ value: string; label: string }> = [
66
- { value: 'sonnet', label: 'Sonnet (latest)' },
67
- { value: 'opus', label: 'Opus (latest)' },
68
- { value: 'fable', label: 'Fable (latest)' },
69
- ...CLAUDE_MODELS,
70
- ];
43
+ /** Whether a model offers a fast tier (Codex's fast mode toggle). */
44
+ export function modelSupportsFast(models: readonly CatalogModel[], modelId: string): boolean {
45
+ return models.find((m) => m.id === modelId)?.fastMode === true;
46
+ }
71
47
 
72
48
  // Fallback Cursor model catalog (just `auto`). The real, account-specific list
73
49
  // is discovered server-side via `agent models` and delivered on the cursor
@@ -227,18 +203,24 @@ function catalogLabel(list: Array<{ value: string; label: string }>, value: stri
227
203
  return list.find((o) => o.value === value)?.label ?? value;
228
204
  }
229
205
 
230
- function formatModel(provider: string, engine: string | undefined, model: string): string {
206
+ type ModelLabelCatalogs = Pick<ModelCatalogs, 'claude' | 'codex'>;
207
+
208
+ function formatModel(provider: string, engine: string | undefined, model: string, catalogs: ModelLabelCatalogs): string {
209
+ // Claude/Codex: the discovered catalog's label (fallback list, then alias
210
+ // naming, then the raw id for anything neither knows).
211
+ const claudeLabel = () => modelLabel([...catalogs.claude.models, ...CLAUDE_FALLBACK_MODELS], model);
212
+ const codexLabel = () => modelLabel([...catalogs.codex.models, ...CODEX_FALLBACK_MODELS], model);
231
213
  if (provider === 'cursor') return catalogLabel(CURSOR_MODELS, model);
232
214
  if (provider === 'opencode') return model ? model : 'Default';
233
215
  if (provider === 'pi') return model || 'Default';
234
216
  if (provider === 'copilot') return model || 'Default';
235
- if (provider === 'codex' || engine === 'codex') return catalogLabel(CODEX_MODELS, model);
236
- if ((provider === 'tour' || provider === 'guide') && engine === 'claude') return catalogLabel(TOUR_CLAUDE_MODELS, model);
217
+ if (provider === 'codex' || engine === 'codex') return codexLabel();
218
+ if ((provider === 'tour' || provider === 'guide') && engine === 'claude') return claudeLabel();
237
219
  if (provider === 'tour' || provider === 'guide') {
238
220
  if (engine === 'cursor') return catalogLabel(CURSOR_MODELS, model);
239
221
  if (engine === 'opencode' || engine === 'pi' || engine === 'copilot') return model || 'Default';
240
222
  }
241
- return catalogLabel(CLAUDE_MODELS, model);
223
+ return claudeLabel();
242
224
  }
243
225
 
244
226
  function formatThinking(value: string): string {
@@ -247,11 +229,7 @@ function formatThinking(value: string): string {
247
229
 
248
230
 
249
231
  function formatEffort(value: string): string {
250
- return catalogLabel(CLAUDE_EFFORT, value);
251
- }
252
-
253
- function formatReasoning(value: string): string {
254
- return CODEX_EFFORT_LABELS[value] ?? value;
232
+ return EFFORT_LABELS[value] ?? value;
255
233
  }
256
234
 
257
235
  // --- Add-a-review dialog: a type-ahead picker over every discovered skill ---
@@ -383,8 +361,10 @@ function JobCard({
383
361
  onToggle,
384
362
  onViewDetails,
385
363
  onOpenGuide,
364
+ catalogs,
386
365
  }: {
387
366
  job: AgentJobInfo;
367
+ catalogs: ModelLabelCatalogs;
388
368
  annotationCount: number;
389
369
  onKill: () => void;
390
370
  expanded: boolean;
@@ -412,10 +392,10 @@ function JobCard({
412
392
  <div className="mt-0.5 flex flex-wrap items-center gap-1 text-[9px] text-muted-foreground/50">
413
393
  <span className="rounded bg-surface-1 px-1 py-px">{job.provider}</span>
414
394
  {job.model && (
415
- <span className="rounded bg-surface-1 px-1 py-px font-mono">{formatModel(job.provider, job.engine, job.model)}</span>
395
+ <span className="rounded bg-surface-1 px-1 py-px font-mono">{formatModel(job.provider, job.engine, job.model, catalogs)}</span>
416
396
  )}
417
397
  {job.effort && <span className="rounded bg-surface-1 px-1 py-px">{formatEffort(job.effort)}</span>}
418
- {job.reasoningEffort && <span className="rounded bg-surface-1 px-1 py-px">{formatReasoning(job.reasoningEffort)}</span>}
398
+ {job.reasoningEffort && <span className="rounded bg-surface-1 px-1 py-px">{formatEffort(job.reasoningEffort)}</span>}
419
399
  {job.thinking && <span className="rounded bg-surface-1 px-1 py-px">{formatThinking(job.thinking)}</span>}
420
400
  {job.fastMode && (
421
401
  <span className="rounded bg-amber-500/10 px-1 py-px text-amber-600 dark:text-amber-400">
@@ -521,7 +501,11 @@ export const AgentsTab: React.FC<AgentsTabProps> = ({
521
501
  const [pendingLaunch, setPendingLaunch] = useState<{ label: string; provider?: string; startedAt: number } | null>(null);
522
502
  const [launchError, setLaunchError] = useState<string | null>(null);
523
503
  const launchingRef = useRef(false);
524
- const settings = useAgentSettings();
504
+ // Claude/Codex catalogs are shared with Ask AI; only the engine this panel
505
+ // is set to is fetched (never on page load), and saved picks resolve
506
+ // against it.
507
+ const catalogs = useModelCatalogs(capabilities);
508
+ const settings = useAgentSettings(catalogs);
525
509
  const {
526
510
  selectedMode,
527
511
  reviewEngine,
@@ -582,6 +566,9 @@ export const AgentsTab: React.FC<AgentsTabProps> = ({
582
566
  setGuidePiThinking,
583
567
  setGuideCopilotModel,
584
568
  } = settings;
569
+ const activeEngine = selectedMode === 'review' ? reviewEngine : selectedMode === 'tour' ? tourEngine : guideEngine;
570
+ const loadCatalog = catalogs.load;
571
+ useEffect(() => loadCatalog(activeEngine), [loadCatalog, activeEngine]);
585
572
 
586
573
  // Review profiles (built-in default plus the user's enabled skills). Loaded
587
574
  // from the discovery endpoint and refreshed after a skill is added.
@@ -923,12 +910,16 @@ export const AgentsTab: React.FC<AgentsTabProps> = ({
923
910
  // the saved id can't be found yet and the launch would quietly run Default. A
924
911
  // Default pick has nothing to resolve, so it never waits.
925
912
  const reviewReady = profilesLoaded || reviewProfileId === 'builtin:default';
913
+ // Claude/Codex launches wait for the model catalog to settle (it always
914
+ // does — the fallback on failure), so a launch never uses an unresolved pick.
915
+ const modelsReady = (engine: ReviewEngine) =>
916
+ engine === 'claude' || engine === 'codex' ? catalogs[engine].settled : true;
926
917
  const canLaunch = selectedMode === 'review'
927
- ? reviewEngineAvailable(reviewEngine) && reviewReady
918
+ ? reviewEngineAvailable(reviewEngine) && reviewReady && modelsReady(reviewEngine)
928
919
  : selectedMode === 'tour'
929
- ? tourAvailable && engineAvailable(tourEngine)
920
+ ? tourAvailable && engineAvailable(tourEngine) && modelsReady(tourEngine)
930
921
  : selectedMode === 'guide'
931
- ? guideAvailable && reviewEngineAvailable(guideEngine) && guideLaunchable
922
+ ? guideAvailable && reviewEngineAvailable(guideEngine) && guideLaunchable && modelsReady(guideEngine)
932
923
  : false;
933
924
 
934
925
  const handleLaunch = async () => {
@@ -1015,6 +1006,40 @@ export const AgentsTab: React.FC<AgentsTabProps> = ({
1015
1006
  );
1016
1007
  }
1017
1008
 
1009
+ // Model picker for Claude and Codex, or a quiet loading row while that
1010
+ // engine's catalog is still settling (launch is held until it does).
1011
+ const renderCatalogModelPicker = (
1012
+ engine: 'claude' | 'codex',
1013
+ value: string,
1014
+ onChange: (value: string) => void,
1015
+ ) => (
1016
+ <ConfigRow label="Model" stacked>
1017
+ {catalogs[engine].settled ? (
1018
+ <SelectMenu value={value} options={modelSelectOptions(catalogs[engine].models, value)} onChange={onChange} />
1019
+ ) : (
1020
+ renderStaticChoice('Loading models…', <Loader2 className="animate-spin text-muted-foreground" size={11} />)
1021
+ )}
1022
+ </ConfigRow>
1023
+ );
1024
+
1025
+ // Effort / reasoning picker for Claude and Codex: only the levels the
1026
+ // selected model accepts, and no picker at all for a model that takes none.
1027
+ const renderEffortPicker = (
1028
+ label: string,
1029
+ models: readonly CatalogModel[],
1030
+ model: string,
1031
+ value: string,
1032
+ onChange: (value: string) => void,
1033
+ ) => {
1034
+ const options = effortSelectOptions(models, model);
1035
+ if (options.length === 0) return null;
1036
+ return (
1037
+ <ConfigRow label={label} stacked>
1038
+ <SegmentedPicker options={options} value={value} onChange={onChange} />
1039
+ </ConfigRow>
1040
+ );
1041
+ };
1042
+
1018
1043
  // Cursor and OpenCode share the same review config: an "experimental" note and
1019
1044
  // a single model picker driven by their live (or fallback) catalog.
1020
1045
  const renderMarkerEngineConfig = (
@@ -1084,25 +1109,19 @@ export const AgentsTab: React.FC<AgentsTabProps> = ({
1084
1109
  </ConfigRow>
1085
1110
  {reviewEngine === 'claude' && (
1086
1111
  <>
1087
- <ConfigRow label="Model" stacked>
1088
- <SelectMenu value={claudeModel} options={CLAUDE_MODELS} onChange={setClaudeModel} />
1089
- </ConfigRow>
1090
- <ConfigRow label="Effort" stacked>
1091
- <SegmentedPicker options={CLAUDE_EFFORT} value={claudeEffort} onChange={setClaudeEffort} />
1092
- </ConfigRow>
1112
+ {renderCatalogModelPicker('claude', claudeModel, setClaudeModel)}
1113
+ {renderEffortPicker('Effort', catalogs.claude.models, claudeModel, claudeEffort, setClaudeEffort)}
1093
1114
  </>
1094
1115
  )}
1095
1116
  {reviewEngine === 'codex' && (
1096
1117
  <>
1097
- <ConfigRow label="Model" stacked>
1098
- <SelectMenu value={codexModel} options={CODEX_MODELS} onChange={setCodexModel} />
1099
- </ConfigRow>
1100
- <ConfigRow label="Reasoning" stacked>
1101
- <SegmentedPicker options={codexReasoningOptions(codexModel)} value={codexReasoning} onChange={setCodexReasoning} />
1102
- </ConfigRow>
1103
- <ConfigRow label="Fast mode">
1104
- <Toggle checked={codexFast} onChange={setCodexFast} />
1105
- </ConfigRow>
1118
+ {renderCatalogModelPicker('codex', codexModel, setCodexModel)}
1119
+ {renderEffortPicker('Reasoning', catalogs.codex.models, codexModel, codexReasoning, setCodexReasoning)}
1120
+ {modelSupportsFast(catalogs.codex.models, codexModel) && (
1121
+ <ConfigRow label="Fast mode">
1122
+ <Toggle checked={codexFast} onChange={setCodexFast} />
1123
+ </ConfigRow>
1124
+ )}
1106
1125
  </>
1107
1126
  )}
1108
1127
  {reviewEngine === 'cursor' && renderMarkerEngineConfig(cursorModel, cursorModels, setCursorModel)}
@@ -1122,30 +1141,23 @@ export const AgentsTab: React.FC<AgentsTabProps> = ({
1122
1141
  {selectedMode === 'tour' && (
1123
1142
  <>
1124
1143
  {renderEngineSelect(tourEngine, setTourEngine, availableEngines, ENGINE_ICON, ENGINE_LABEL)}
1125
- <ConfigRow label="Model" stacked>
1126
- <SelectMenu
1127
- value={tourEngine === 'claude' ? tourClaudeModel : tourCodexModel}
1128
- options={tourEngine === 'claude' ? TOUR_CLAUDE_MODELS : CODEX_MODELS}
1129
- onChange={tourEngine === 'claude' ? setTourClaudeModel : setTourCodexModel}
1130
- />
1131
- </ConfigRow>
1144
+ {tourEngine === 'claude'
1145
+ ? renderCatalogModelPicker('claude', tourClaudeModel, setTourClaudeModel)
1146
+ : renderCatalogModelPicker('codex', tourCodexModel, setTourCodexModel)}
1132
1147
 
1133
1148
  {/* Claude-only: effort level */}
1134
- {tourEngine === 'claude' && (
1135
- <ConfigRow label="Effort" stacked>
1136
- <SegmentedPicker options={CLAUDE_EFFORT} value={tourClaudeEffort} onChange={setTourClaudeEffort} />
1137
- </ConfigRow>
1138
- )}
1149
+ {tourEngine === 'claude' &&
1150
+ renderEffortPicker('Effort', catalogs.claude.models, tourClaudeModel, tourClaudeEffort, setTourClaudeEffort)}
1139
1151
 
1140
1152
  {/* Codex-only: reasoning effort + fast mode */}
1141
1153
  {tourEngine === 'codex' && (
1142
1154
  <>
1143
- <ConfigRow label="Reasoning" stacked>
1144
- <SegmentedPicker options={codexReasoningOptions(tourCodexModel)} value={tourCodexReasoning} onChange={setTourCodexReasoning} />
1145
- </ConfigRow>
1146
- <ConfigRow label="Fast mode">
1147
- <Toggle checked={tourCodexFast} onChange={setTourCodexFast} />
1148
- </ConfigRow>
1155
+ {renderEffortPicker('Reasoning', catalogs.codex.models, tourCodexModel, tourCodexReasoning, setTourCodexReasoning)}
1156
+ {modelSupportsFast(catalogs.codex.models, tourCodexModel) && (
1157
+ <ConfigRow label="Fast mode">
1158
+ <Toggle checked={tourCodexFast} onChange={setTourCodexFast} />
1159
+ </ConfigRow>
1160
+ )}
1149
1161
  </>
1150
1162
  )}
1151
1163
  </>
@@ -1154,31 +1166,18 @@ export const AgentsTab: React.FC<AgentsTabProps> = ({
1154
1166
  {selectedMode === 'guide' && (
1155
1167
  <>
1156
1168
  {renderEngineSelect(guideEngine, setGuideEngine, availableReviewEngines, REVIEW_ENGINE_ICON, REVIEW_ENGINE_LABEL)}
1157
- {(guideEngine === 'claude' || guideEngine === 'codex') && (
1158
- <ConfigRow label="Model" stacked>
1159
- <SelectMenu
1160
- value={guideEngine === 'claude' ? guideClaudeModel : guideCodexModel}
1161
- options={guideEngine === 'claude' ? TOUR_CLAUDE_MODELS : CODEX_MODELS}
1162
- onChange={guideEngine === 'claude' ? setGuideClaudeModel : setGuideCodexModel}
1163
- />
1164
- </ConfigRow>
1165
- )}
1169
+ {guideEngine === 'claude' && renderCatalogModelPicker('claude', guideClaudeModel, setGuideClaudeModel)}
1170
+ {guideEngine === 'codex' && renderCatalogModelPicker('codex', guideCodexModel, setGuideCodexModel)}
1166
1171
 
1167
1172
  {/* Claude-only: effort level */}
1168
- {guideEngine === 'claude' && (
1169
- <ConfigRow label="Effort" stacked>
1170
- <SegmentedPicker options={CLAUDE_EFFORT} value={guideClaudeEffort} onChange={setGuideClaudeEffort} />
1171
- </ConfigRow>
1172
- )}
1173
+ {guideEngine === 'claude' &&
1174
+ renderEffortPicker('Effort', catalogs.claude.models, guideClaudeModel, guideClaudeEffort, setGuideClaudeEffort)}
1173
1175
 
1174
1176
  {/* Codex-only: reasoning effort. No "Fast mode" toggle here
1175
1177
  (unlike review/tour's codex blocks above) — fast mode is
1176
1178
  deliberately not offered for guide. */}
1177
- {guideEngine === 'codex' && (
1178
- <ConfigRow label="Reasoning" stacked>
1179
- <SegmentedPicker options={codexReasoningOptions(guideCodexModel)} value={guideCodexReasoning} onChange={setGuideCodexReasoning} />
1180
- </ConfigRow>
1181
- )}
1179
+ {guideEngine === 'codex' &&
1180
+ renderEffortPicker('Reasoning', catalogs.codex.models, guideCodexModel, guideCodexReasoning, setGuideCodexReasoning)}
1182
1181
 
1183
1182
  {/* Marker engines: same live-catalog model picker as review mode,
1184
1183
  but bound to the guide-scoped settings (see useAgentSettings) so
@@ -1237,6 +1236,7 @@ export const AgentsTab: React.FC<AgentsTabProps> = ({
1237
1236
  <JobCard
1238
1237
  key={job.id}
1239
1238
  job={job}
1239
+ catalogs={catalogs}
1240
1240
  annotationCount={annotationCounts.get(job.source) ?? 0}
1241
1241
  onKill={() => onKillJob(job.id)}
1242
1242
  expanded={expandedJobId === job.id}
@@ -19,9 +19,10 @@ export const BlockRenderer: React.FC<{
19
19
  checkboxOverrides?: Map<string, boolean>;
20
20
  orderedIndex?: number | null;
21
21
  githubRepo?: string;
22
+ repoHost?: string;
22
23
  headingAnchorId?: string;
23
24
  onNavigateAnchor?: (hash: string) => void;
24
- }> = ({ block, onOpenLinkedDoc, onOpenCodeFile, imageBaseDir, onImageClick, onToggleCheckbox, checkboxOverrides, orderedIndex, githubRepo, headingAnchorId, onNavigateAnchor }) => {
25
+ }> = ({ block, onOpenLinkedDoc, onOpenCodeFile, imageBaseDir, onImageClick, onToggleCheckbox, checkboxOverrides, orderedIndex, githubRepo, repoHost, headingAnchorId, onNavigateAnchor }) => {
25
26
  switch (block.type) {
26
27
  case 'heading': {
27
28
  const Tag = `h${block.level || 1}` as React.ElementType;
@@ -37,7 +38,7 @@ export const BlockRenderer: React.FC<{
37
38
  data-block-id={block.id}
38
39
  data-block-type="heading"
39
40
  >
40
- <InlineMarkdown imageBaseDir={imageBaseDir} onImageClick={onImageClick} text={block.content} onOpenLinkedDoc={onOpenLinkedDoc} onOpenCodeFile={onOpenCodeFile} githubRepo={githubRepo} onNavigateAnchor={onNavigateAnchor} />
41
+ <InlineMarkdown imageBaseDir={imageBaseDir} onImageClick={onImageClick} text={block.content} onOpenLinkedDoc={onOpenLinkedDoc} onOpenCodeFile={onOpenCodeFile} githubRepo={githubRepo} repoHost={repoHost} onNavigateAnchor={onNavigateAnchor} />
41
42
  </Tag>
42
43
  );
43
44
  }
@@ -54,6 +55,7 @@ export const BlockRenderer: React.FC<{
54
55
  imageBaseDir={imageBaseDir}
55
56
  onImageClick={onImageClick}
56
57
  githubRepo={githubRepo}
58
+ repoHost={repoHost}
57
59
  onNavigateAnchor={onNavigateAnchor}
58
60
  />
59
61
  );
@@ -68,7 +70,7 @@ export const BlockRenderer: React.FC<{
68
70
  >
69
71
  {paragraphs.map((para, i) => (
70
72
  <p key={i} className={i > 0 ? 'mt-2' : ''}>
71
- <InlineMarkdown imageBaseDir={imageBaseDir} onImageClick={onImageClick} text={para} onOpenLinkedDoc={onOpenLinkedDoc} onOpenCodeFile={onOpenCodeFile} githubRepo={githubRepo} onNavigateAnchor={onNavigateAnchor} />
73
+ <InlineMarkdown imageBaseDir={imageBaseDir} onImageClick={onImageClick} text={para} onOpenLinkedDoc={onOpenLinkedDoc} onOpenCodeFile={onOpenCodeFile} githubRepo={githubRepo} repoHost={repoHost} onNavigateAnchor={onNavigateAnchor} />
72
74
  </p>
73
75
  ))}
74
76
  </blockquote>
@@ -83,7 +85,7 @@ export const BlockRenderer: React.FC<{
83
85
  : block.checked;
84
86
  const isInteractive = isCheckbox && !!onToggleCheckbox;
85
87
  const textClass = `text-sm leading-relaxed ${isCheckbox && isChecked ? 'text-muted-foreground line-through' : 'text-foreground/90'}`;
86
- const inlineProps = { imageBaseDir, onImageClick, onOpenLinkedDoc, onOpenCodeFile, githubRepo, onNavigateAnchor };
88
+ const inlineProps = { imageBaseDir, onImageClick, onOpenLinkedDoc, onOpenCodeFile, githubRepo, repoHost, onNavigateAnchor };
87
89
  return (
88
90
  <div
89
91
  className="flex items-start gap-3 my-1.5"
@@ -117,6 +119,7 @@ export const BlockRenderer: React.FC<{
117
119
  onOpenLinkedDoc={onOpenLinkedDoc}
118
120
  onOpenCodeFile={onOpenCodeFile}
119
121
  githubRepo={githubRepo}
122
+ repoHost={repoHost}
120
123
  onNavigateAnchor={onNavigateAnchor}
121
124
  />
122
125
  );
@@ -145,6 +148,7 @@ export const BlockRenderer: React.FC<{
145
148
  imageBaseDir={imageBaseDir}
146
149
  onImageClick={onImageClick}
147
150
  githubRepo={githubRepo}
151
+ repoHost={repoHost}
148
152
  onNavigateAnchor={onNavigateAnchor}
149
153
  />
150
154
  );
@@ -156,7 +160,7 @@ export const BlockRenderer: React.FC<{
156
160
  className="mb-4 leading-relaxed text-foreground/90 text-[15px]"
157
161
  data-block-id={block.id}
158
162
  >
159
- <InlineMarkdown imageBaseDir={imageBaseDir} onImageClick={onImageClick} text={block.content} onOpenLinkedDoc={onOpenLinkedDoc} onOpenCodeFile={onOpenCodeFile} githubRepo={githubRepo} onNavigateAnchor={onNavigateAnchor} />
163
+ <InlineMarkdown imageBaseDir={imageBaseDir} onImageClick={onImageClick} text={block.content} onOpenLinkedDoc={onOpenLinkedDoc} onOpenCodeFile={onOpenCodeFile} githubRepo={githubRepo} repoHost={repoHost} onNavigateAnchor={onNavigateAnchor} />
160
164
  </p>
161
165
  );
162
166
  }
@@ -136,7 +136,7 @@ export const ExportModal: React.FC<ExportModalProps> = ({
136
136
  await handleCopy(wrapCopiedAnnotations(annotationsOutput), 'annotations');
137
137
  };
138
138
 
139
- // Whether the hash URL is large enough to warrant a short URL option
139
+ // Warn when the hash URL may be too long for messaging apps
140
140
  const urlIsLarge = shareUrl.length > 2048;
141
141
  // Hash-based sharing unavailable (e.g. HTML render mode) — show only short link
142
142
  const hashUnavailable = !shareUrl && !!onGenerateShortUrl;
@@ -327,9 +327,13 @@ export const ExportModal: React.FC<ExportModalProps> = ({
327
327
  </svg>
328
328
  Generating short link...
329
329
  </div>
330
- ) : (urlIsLarge || hashUnavailable) && onGenerateShortUrl ? (
331
- <div className="p-3 bg-amber-500/10 border border-amber-500/20 rounded-lg">
332
- {!hashUnavailable && (
330
+ ) : onGenerateShortUrl ? (
331
+ <div className={`p-3 rounded-lg border ${
332
+ urlIsLarge
333
+ ? 'bg-amber-500/10 border-amber-500/20'
334
+ : 'bg-muted/50 border-border'
335
+ }`}>
336
+ {urlIsLarge && (
333
337
  <p className="text-xs text-amber-600 dark:text-amber-400 mb-2">
334
338
  This URL may be too long for some messaging apps.
335
339
  </p>
@@ -104,7 +104,15 @@ export const Toolbar: React.FC<ToolbarProps> = ({
104
104
  const canIncrease = currentSizeIndex < STROKE_SIZES.length - 1;
105
105
 
106
106
  return (
107
- <div className="flex items-center gap-2 px-3 py-2 bg-popover border border-border rounded-lg shadow-xl">
107
+ <div
108
+ data-pn-image-annotator-toolbar
109
+ className="flex w-fit max-w-full items-center gap-2 px-3 py-2 bg-popover border border-border rounded-lg shadow-xl"
110
+ >
111
+ <div
112
+ data-pn-image-toolbar-scroll
113
+ className="min-w-0 flex-1 overflow-x-auto overscroll-x-contain"
114
+ >
115
+ <div className="flex w-max items-center gap-2">
108
116
  {/* Tools */}
109
117
  <div className="flex items-center gap-1">
110
118
  {TOOLS.map(({ id, icon: Icon, label }) => (
@@ -228,15 +236,17 @@ export const Toolbar: React.FC<ToolbarProps> = ({
228
236
  >
229
237
  <ClearIcon />
230
238
  </button>
239
+ </div>
240
+ </div>
231
241
 
232
- <div className="w-px h-5 bg-border" />
242
+ <div className="w-px h-5 shrink-0 bg-border" />
233
243
 
234
244
  {/* Save */}
235
245
  <button
236
246
  type="button"
237
247
  onClick={onSave}
238
248
  title="Save (Esc)"
239
- className="p-1.5 rounded transition-colors bg-success text-success-foreground hover:opacity-90"
249
+ className="shrink-0 p-1.5 rounded transition-colors bg-success text-success-foreground hover:opacity-90"
240
250
  >
241
251
  <CheckIcon />
242
252
  </button>
@@ -185,12 +185,16 @@ export const ImageAnnotator: React.FC<ImageAnnotatorProps> = ({
185
185
 
186
186
  return (
187
187
  <div
188
- className="fixed inset-0 z-[200] flex items-center justify-center bg-background/90 backdrop-blur-sm"
188
+ className="pn-visible-viewport-overlay z-[200] flex items-center justify-center bg-background/90 backdrop-blur-sm"
189
189
  data-popover-layer
190
190
  onClick={handleBackdropClick}
191
191
  >
192
192
  {/* Canvas with image and toolbar */}
193
- <div className="relative flex flex-col items-center gap-3" onClick={(e) => e.stopPropagation()}>
193
+ <div
194
+ data-pn-image-annotator-stage
195
+ className="relative flex max-w-full flex-col items-center gap-3"
196
+ onClick={(e) => e.stopPropagation()}
197
+ >
194
198
  {/* Toolbar - above image */}
195
199
  <Toolbar
196
200
  tool={state.tool}