@plannotator/ui 0.46.1 → 0.47.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
@@ -192,7 +192,7 @@ We deliberately did **not** restructure the exports map in this PR (move-don't-r
192
192
  | `components/BlockRenderer` + the block components it renders (`TableBlock`, `HtmlBlock`, `Callout`, `MermaidBlock`, `MathBlock`, …) | Pure rendering. |
193
193
  | `components/InlineMarkdown` | Code-file hover previews route through the `docPreviewFetcher` seam. Wiki-link rendering takes the sync `resolveLinkedDoc` prop (live labels + deleted-doc treatment; see "Wiki-link seams (0.27.0)"). |
194
194
  | `components/Viewer` | The full annotatable document. Required props: `markdown` and `taterMode` (pass `false`). **Pass `disableCodePathValidation` unless you implement `/api/doc/exists`** — code-path validation is a prop-level opt-out, not a `configure` seam. `annotationHeader={{ onInputMethodChange, onModeChange, hideQuickLabel? }}` opts into one Viewer-owned, in-flow header containing the compact annotation controls and existing document actions. It reserves its measured responsive height, preserves all document badges, and follows `stickyActions` as one unit; omit it for the legacy action bar. Compact mode contains no help link. `hideQuickLabel` still requires the host to clamp restored mode state away from `'quickLabel'`. A host-owned scroll element must be supplied through `ScrollViewportProvider` (`hooks/useScrollViewport`) so stuck chrome and anchor clearance use the real scroller. |
195
- | `components/MarkdownEditor` | Theme-bridging wrapper over `@plannotator/markdown-editor`. Takes CM6 extensions via the `extensions` prop (captured ONCE per `documentId` — see "Wiki-link seams (0.27.0)") and re-exports `wikiLinks`, `embedPicker`, `embedSlashItem`, `planEmbedInsert`, and their public types. |
195
+ | `components/MarkdownEditor` | Theme-bridging wrapper over `@plannotator/markdown-editor`. Takes CM6 extensions via the `extensions` prop (captured ONCE per `documentId` — see "Wiki-link seams (0.27.0)") and re-exports `wikiLinks`, `linkWidgets`, `refreshLinkWidgets` (0.47.0), `embedPicker`, `embedSlashItem`, `planEmbedInsert`, and their public types. |
196
196
  | `components/MarkdownDiff` | Theme-bridging wrapper over `@plannotator/markdown-editor`'s frozen two-revision diff. Same shim pattern as `components/MarkdownEditor` (ThemeProvider bridge, `extensions` passthrough, grid card chrome); never editable. See "Frozen markdown diff (0.28.0)". |
197
197
  | `components/CommentPopover` | Anchor capture + comment entry. Ask-AI UI renders only if you pass `onAskAI`. |
198
198
  | `components/AnnotationPanel` | Renders from your annotation state; no fetches of its own. |
@@ -1511,11 +1511,11 @@ Behaviour changes a host may notice:
1511
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
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
1513
 
1514
- ## Forge-aware `#123` / `@user` links (unreleased, additive; #1596)
1514
+ ## Forge-aware `#123` / `@user` links (additive; #1596; published in core 0.25.6 / ui 0.46.x)
1515
1515
 
1516
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
1517
 
1518
- ## Guided Review generation in core (core 0.25.6, unreleased, additive)
1518
+ ## Guided Review generation in core (core 0.25.6, published, additive)
1519
1519
 
1520
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
1521
 
@@ -1525,8 +1525,40 @@ Core-only; `@plannotator/ui` imports none of it, so ui's exact `0.25.6` pin is u
1525
1525
 
1526
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
1527
 
1528
+ ## Host link widgets (0.47.0, core 0.25.7)
1529
+
1530
+ Additive. `components/MarkdownEditor` re-exports the engine's new link seam beside `wikiLinks`: `linkWidgets(...specs: LinkWidgetSpec[]): Extension`, `refreshLinkWidgets: StateEffectType<null>`, and the types `LinkWidgetSpec` (`{ match(link: LinkWidgetLink): WidgetType | null }`) and `LinkWidgetLink` (`{ url; text; title?; from; to }`). They are the engine's own objects (identity pinned by `MarkdownEditor.linkWidgets.test.tsx`); hosts import them from `@plannotator/ui/components/MarkdownEditor`, never from `@plannotator/atomic-editor`.
1531
+
1532
+ - **What it does.** For a single-line `[text](url)` link the inline preview would draw with its syntax hidden, the engine asks each registered spec in order; the first non-null widget replaces the whole link range (no link mark, no hidden `[` / `](url)` replaces, no open-in-new-tab icon beside it). `null` keeps the engine's link. Revealed links, multi-line links, reference / autolinks / wiki links, links inside tables and links wrapping an image are never offered. A spec that throws is logged once and skipped.
1533
+ - **The reveal rule stays the engine's.** Caret inside, overlapping selection, focus, the pointer-press freeze and `MarkdownDiff` changed ranges decide when the raw bytes show, exactly as for a normal link, so a widget comes and goes when the hidden syntax would. The source text is never changed.
1534
+ - **`match` runs on every rebuild** (selection, focus, document change): synchronous, no side effects, and implement `eq` on the widget so an unchanged widget keeps its DOM. Do not `preventDefault` on `mousedown` in the widget: it blocks a drag selection that starts on it.
1535
+ - **Refreshing.** When a spec's answer changes without a document edit (a record arrives after mount), dispatch `view.dispatch({ effects: refreshLinkWidgets.of(null) })`. It changes no text, adds no history entry and sends nothing through collab bindings; during a held pointer press the rebuild waits for the release.
1536
+ - **Composition.** `linkWidgets(spec)` goes in the same stable `extensions` array as `wikiLinks`, captured once per `documentId`. Several `linkWidgets(...)` extensions concatenate in extension order.
1537
+
1538
+ **Dependency note:** 0.47.0 requires `@plannotator/atomic-editor ^0.9.0` (adds the seam) and `@plannotator/markdown-editor ^0.5.0` (widens its engine peer to `^0.8.0 || ^0.9.0`). A host that pins either exactly (e.g. through package-manager overrides) must move both pins with the ui bump. Plannotator's own apps pass no `linkWidgets`, so their editor is unchanged.
1539
+
1540
+ Also new in 0.47.0 (all additive, all no-op for a host that passes nothing):
1541
+
1542
+ - **`useCodeAnnotationDraft` `persistViewedFiles?: boolean`** (default `true`, #1632). `false` leaves viewed-file state out of the draft: autosave omits `viewedFiles` / `autoViewSuppressed`, an empty-annotations session with only viewed files counts as empty, the restore banner ignores viewed counts, and `restoreDraft()` returns empty viewed lists. For hosts that persist review progress on their own (Plannotator's review app now does, server-side).
1543
+ - **Model source hint** (#1616). New module `components/ModelSourceHint` (`ModelSourceHint`, `modelSourceHint`, `modelSourceToolForProvider`, types `ModelSourceTool` / `ModelSourceInfo`): a muted line under a Claude/Codex model picker ("From your installed Codex 0.155.1", or the built-in-list variant). `ModelCatalog` (`hooks/useModelCatalogs`) and `AIProviderConfig` (`utils/aiProvider`) gain optional `modelsSource` and `toolVersion`, read from `/api/ai/capabilities`; the hint renders only when the server sends `toolVersion`, so a host whose backend omits it sees no change. `AgentsTab`, `AISettingsTab` and `AIProviderBar` render it.
1544
+ - **Guided Review Codex default** (#1616): `PREFERRED_GUIDE_CODEX_MODEL` (`'gpt-6-luna'`) from `hooks/useAgentSettings`; an unset guide Codex pick resolves to it when the catalog offers it, else to Codex's own default. Never written to the cookie.
1545
+ - **Agent job warnings** (#1627): `AgentsTab` shows `AgentJobInfo.warning` under a job row when present.
1546
+ - **Request changes on PR reviews** (#1611): `buildDecisionSpec`'s platform input takes optional `requestChangesSupported`. Absent keeps the previous copy; `false` says request-changes posts as a comment; `true` with `selfAuthored` mutes Request changes and adds a live `Comment…` row.
1547
+
1548
+ ### `@plannotator/core` 0.25.7
1549
+
1550
+ Required by ui 0.47.0 (`components/ModelSourceHint` and `hooks/useModelCatalogs` import the new `ModelsSource` type). Changes since the published 0.25.6, all additive:
1551
+
1552
+ - `@plannotator/core/model-catalog`: new `ModelsSource` type (`'fallback' | 'discovered'`) and `cliVersionFrom(text, toolLine?)` (reads a CLI's version out of `claude --version` or the codex app-server `userAgent`). `CODEX_FALLBACK_MODELS` refreshed to what codex 0.155.1 reports (GPT-6 Sol / Astra / Luna, GPT-5.6 Sol / Terra / Luna, GPT-5.5; efforts up to `max` / `ultra`). `claudeCatalogFromSdk` names a model from `displayName` when the SDK `description` is only a tagline (no ` · `), which Claude Code 2.1.282+ emits (#1615).
1553
+ - `@plannotator/core/agent-jobs`: optional `AgentJobInfo.warning` (#1627).
1554
+ - `@plannotator/core/agents`: the `mistral-vibe` origin in `AGENT_CONFIG` (#1480, merged just after 0.25.6 was published).
1555
+ - `@plannotator/core/review-prompt`: workspace review context lines now tell the model the inline diff is the complete combined changeset and to read files by folder-prefixed path, instead of suggesting `git -C` (#1629). Prompt text only.
1556
+
1557
+ Minor rather than patch because of the new exports (`cliVersionFrom`, `ModelsSource`).
1558
+
1528
1559
  ## Publishing & versioning
1529
1560
 
1561
+ - **core 0.25.7 / ui 0.47.0 (host link widgets + model source hint + `persistViewedFiles`): both packages change, and both need unpublished upstream packages first.** Order: `@plannotator/atomic-editor` 0.9.0, then `@plannotator/markdown-editor` 0.5.0, then `bun install` here to refresh `bun.lock`, then publish `core` 0.25.7, then `ui` 0.47.0. ui pins core `0.25.7` exactly, `@plannotator/atomic-editor` `^0.9.0` and `@plannotator/markdown-editor` `^0.5.0`. See "Host link widgets (0.47.0, core 0.25.7)".
1530
1562
  - **ui 0.46.1 (fix, ui only, core pin unchanged at `0.25.6`):** `useVimSelection` (mounted by every `Viewer`) now only clears a page selection whose anchor or focus lies inside the viewer's own container; with vim off it used to clear the WHOLE page's selection on every mount and `contentVersion` change, so a selection in another host panel vanished whenever the document behind it loaded or changed.
1531
1563
  - **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)".
1532
1564
  - **ui 0.44.0 (mention token chips in the composer): `@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.43.2: the `@Label` tokens a `mentionSource` composer inserted render as chips in the composer's existing highlight overlay, `MentionSource.tokenClassName?` lets a host restyle them (under the metric rule), `useMentionAutocomplete` also returns the surviving `mentions`, and `utils/composerTokens` joins the supported-import list. Nothing is removed, no export-, share- or archive-visible change, and Plannotator passes none of it — with neither `mentionSource` nor `skillReferences` the composer is byte-identical to 0.43.2. See "Mention token chips in the composer (0.44.0)".
package/README.md CHANGED
@@ -75,9 +75,16 @@ Plannotator's own entries import the eager math and identity modules (`math-eage
75
75
  <MarkdownEditor markdown={md} documentId={docId} editorHandleRef={ref} extensions={editorExtensions} />
76
76
  ```
77
77
  - **`embedPicker(config)` and `embedSlashItem()` are re-exported from the same surface.** Compose the static item into `slashCommands({ items: [...] })` and pass the picker beside it in the stable `extensions` array. `getTargets`, `buildInsertLine`, optional `uploadTarget`, and optional `getNotice` stay live through callbacks. Optional `labels` (`upload`, `empty`, `noMatch(query)`) reword the three rows that say "HTML"; absent keys keep the built-in text. The host owns embed grammar and upload error UI; the package owns filtering, async anchor mapping, single-flight upload state, and paragraph-safe insertion through the re-exported `planEmbedInsert()`.
78
+ - **`linkWidgets(...specs)` and `refreshLinkWidgets` are re-exported from the same surface** (since 0.47.0, engine `@plannotator/atomic-editor` ^0.9.0), with the types `LinkWidgetSpec` and `LinkWidgetLink`. A spec's `match(link)` gets `{ url, text, title?, from, to }` for a single-line `[text](url)` link whose syntax the engine would hide, and returns a CM6 `WidgetType` to draw in the link's place (first non-null spec wins) or `null` for the engine's normal link. The reveal rule (caret, focus, pointer-press freeze, diff ranges) stays the engine's, so the widget comes and goes exactly when the hidden syntax would; `match` is never asked about table-cell links or links wrapping an image. `match` runs on every rebuild: keep it synchronous and side-effect free and implement `eq` on the widget. When the answer changes without a document edit (data arrived after mount), dispatch `view.dispatch({ effects: refreshLinkWidgets.of(null) })`; it changes no text and adds no history entry.
79
+ ```tsx
80
+ import { linkWidgets, refreshLinkWidgets, type LinkWidgetSpec } from "@plannotator/ui/components/MarkdownEditor";
81
+
82
+ const spec: LinkWidgetSpec = { match: (link) => (isMine(link.url) ? new MyChip(link) : null) };
83
+ const editorExtensions = [linkWidgets(spec)]; // stable reference!
84
+ ```
78
85
  - **The viewer resolves wiki-links synchronously.** `InlineMarkdown` takes `resolveLinkedDoc?: (target) => { label?; status?: 'active' | 'deleted' } | null` — called with the raw stored target (opaque ids like `doc_01XYZ`, no `.md` normalization). Return a `label` to display live titles (stored label is the fallback, target the last resort); return `status: 'deleted'` for a muted non-link ("Document deleted") instead of a live link. Absent or `null` → rendering is unchanged. Sync-only by design: back it with an in-memory cache.
79
86
 
80
- Requires `@plannotator/markdown-editor ^0.3.2` and `@plannotator/atomic-editor ^0.7.0`. See HANDOFF.md § "Wiki-link seams (0.27.0)".
87
+ Wiki links require `@plannotator/markdown-editor ^0.3.2` and `@plannotator/atomic-editor ^0.7.0` (see HANDOFF.md § "Wiki-link seams (0.27.0)"); link widgets require `@plannotator/markdown-editor ^0.5.0` and `@plannotator/atomic-editor ^0.9.0`, which is what 0.47.0 declares (see HANDOFF.md § "Host link widgets (0.47.0)").
81
88
 
82
89
  ### Frozen markdown diff (`MarkdownDiff`)
83
90
 
@@ -367,7 +374,7 @@ npm install @plannotator/ui @plannotator/core
367
374
  - `@plannotator/core` — pure utils + types, zero deps, browser-safe (CI enforces no `node:` imports). Published.
368
375
  - `@plannotator/ui` — React components/hooks + theme + `configure()`. Depends on an exact published `@plannotator/core` version. Published.
369
376
  - `@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.46.1 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.
377
+ - Currently `@plannotator/ui` 0.47.0 depends exactly on `@plannotator/core` 0.25.7. `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
378
 
372
379
  ## The one rule
373
380
 
@@ -1,5 +1,6 @@
1
1
  import type React from 'react';
2
2
  import { getProviderMeta } from './ProviderIcons';
3
+ import { ModelSourceHint, modelSourceToolForProvider } from './ModelSourceHint';
3
4
  import {
4
5
  getAIProviderSettings,
5
6
  resolveAIProviderSelection,
@@ -121,6 +122,11 @@ export const AISettingsTab: React.FC<AISettingsTabProps> = ({
121
122
  ))}
122
123
  </select>
123
124
  </label>
125
+ <ModelSourceHint
126
+ tool={modelSourceToolForProvider(p.name)}
127
+ info={p}
128
+ className="mt-1.5 text-[10px] text-muted-foreground/60"
129
+ />
124
130
  </div>
125
131
  )}
126
132
 
@@ -23,6 +23,7 @@ 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 { ModelSourceHint } from './ModelSourceHint';
26
27
  import {
27
28
  CLAUDE_FALLBACK_MODELS,
28
29
  CODEX_FALLBACK_MODELS,
@@ -429,6 +430,12 @@ function JobCard({
429
430
  </button>
430
431
  )}
431
432
 
433
+ {job.warning && (
434
+ <p role="status" data-agent-job-warning className="mt-1.5 ml-4 text-[10px] leading-snug text-warning">
435
+ {job.warning}
436
+ </p>
437
+ )}
438
+
432
439
  {/* Error details — fallback for when the dockview detail panel is not available */}
433
440
  {!onViewDetails && job.status === 'failed' && job.error && expanded && (
434
441
  <div className="mt-2 rounded bg-destructive/5 border border-destructive/20 p-2">
@@ -1015,7 +1022,10 @@ export const AgentsTab: React.FC<AgentsTabProps> = ({
1015
1022
  ) => (
1016
1023
  <ConfigRow label="Model" stacked>
1017
1024
  {catalogs[engine].settled ? (
1018
- <SelectMenu value={value} options={modelSelectOptions(catalogs[engine].models, value)} onChange={onChange} />
1025
+ <>
1026
+ <SelectMenu value={value} options={modelSelectOptions(catalogs[engine].models, value)} onChange={onChange} />
1027
+ <ModelSourceHint tool={engine} info={catalogs[engine]} className="text-[10px] text-muted-foreground/50" />
1028
+ </>
1019
1029
  ) : (
1020
1030
  renderStaticChoice('Loading models…', <Loader2 className="animate-spin text-muted-foreground" size={11} />)
1021
1031
  )}
@@ -34,6 +34,15 @@ export type { SlashCommandItem, SlashCommandsConfig } from '@plannotator/atomic-
34
34
  export { selectionToolbar } from '@plannotator/atomic-editor';
35
35
  export type { SelectionToolbarConfig, InlineFormat } from '@plannotator/atomic-editor';
36
36
 
37
+ /* Host link widgets (engine ≥0.9.0), re-exported for the same reason as
38
+ wikiLinks. linkWidgets(...specs) lets a host swap an inline markdown link
39
+ for its own WidgetType when a spec's match(link) returns one (null keeps
40
+ the engine's normal link rendering). Compose it through the `extensions`
41
+ prop; dispatch refreshLinkWidgets.of(null) on the view to re-run every
42
+ spec's match after host state the specs read has changed. */
43
+ export { linkWidgets, refreshLinkWidgets } from '@plannotator/atomic-editor';
44
+ export type { LinkWidgetSpec, LinkWidgetLink } from '@plannotator/atomic-editor';
45
+
37
46
  /* Host-configured embed media authoring. The picker is per editor mount so its
38
47
  callbacks can close over live route state; nothing enters configurePlannotatorUI.
39
48
  The package owns paragraph-safe splicing while the host owns embed grammar. */
@@ -0,0 +1,49 @@
1
+ import type React from 'react';
2
+ import type { ModelsSource } from '@plannotator/core/model-catalog';
3
+
4
+ /** The tools whose model lists are discovered from the installed CLI. */
5
+ export type ModelSourceTool = 'claude' | 'codex';
6
+
7
+ export interface ModelSourceInfo {
8
+ modelsSource?: ModelsSource;
9
+ toolVersion?: string;
10
+ }
11
+
12
+ const TOOL_NAME: Record<ModelSourceTool, string> = {
13
+ claude: 'Claude Code',
14
+ codex: 'Codex',
15
+ };
16
+
17
+ /** The hint tool for an Ask AI provider type (`claude-agent-sdk`, `codex-sdk`); null for the rest. */
18
+ export function modelSourceToolForProvider(providerType: string | null | undefined): ModelSourceTool | null {
19
+ if (providerType === 'claude-agent-sdk') return 'claude';
20
+ if (providerType === 'codex-sdk') return 'codex';
21
+ return null;
22
+ }
23
+
24
+ /**
25
+ * Where the model list under a picker came from. Only when the server reports
26
+ * the tool's version: a host that does not send it gets no hint.
27
+ */
28
+ export function modelSourceHint(tool: ModelSourceTool | null | undefined, info: ModelSourceInfo | null | undefined): string | null {
29
+ if (!tool || !info?.toolVersion) return null;
30
+ const name = TOOL_NAME[tool];
31
+ if (info.modelsSource === 'discovered') return `From your installed ${name} ${info.toolVersion}`;
32
+ if (info.modelsSource === 'fallback') return `Using the built-in list — update or sign in to ${name} to see its latest models`;
33
+ return null;
34
+ }
35
+
36
+ /** A muted line under a model picker; renders nothing when there is no hint. */
37
+ export const ModelSourceHint: React.FC<{
38
+ tool: ModelSourceTool | null | undefined;
39
+ info: ModelSourceInfo | null | undefined;
40
+ className?: string;
41
+ }> = ({ tool, info, className }) => {
42
+ const text = modelSourceHint(tool, info);
43
+ if (!text) return null;
44
+ return (
45
+ <p data-model-source-hint={info?.modelsSource} className={className}>
46
+ {text}
47
+ </p>
48
+ );
49
+ };
@@ -1,6 +1,7 @@
1
1
  import React from 'react';
2
2
  import { getProviderMeta } from '../ProviderIcons';
3
3
  import { type AIProviderOption } from '../../utils/aiProvider';
4
+ import { ModelSourceHint, modelSourceToolForProvider } from '../ModelSourceHint';
4
5
 
5
6
  interface AIProviderBarProps {
6
7
  providers: AIProviderOption[];
@@ -42,6 +43,7 @@ export const AIProviderBar: React.FC<AIProviderBarProps> = ({
42
43
  const showReasoningEffort = !!onReasoningEffortChange && reasoningEfforts.length > 0;
43
44
 
44
45
  return (
46
+ <>
45
47
  <div className="border-t border-border/50 px-2 py-1.5 flex items-center gap-1.5 text-[11px] text-muted-foreground">
46
48
  <Icon className="w-3.5 h-3.5 flex-shrink-0" />
47
49
  <select
@@ -91,5 +93,11 @@ export const AIProviderBar: React.FC<AIProviderBarProps> = ({
91
93
  </select>
92
94
  )}
93
95
  </div>
96
+ <ModelSourceHint
97
+ tool={modelSourceToolForProvider(currentProvider?.name)}
98
+ info={currentProvider}
99
+ className="-mt-1 px-2 pb-1.5 text-[10px] text-muted-foreground/50"
100
+ />
101
+ </>
94
102
  );
95
103
  };
@@ -36,6 +36,13 @@ export const DEFAULT_GUIDE_CLAUDE_MODEL = 'sonnet';
36
36
  // low effort chapter a diff well. Guide-scoped only — tour/review keep medium.
37
37
  export const DEFAULT_GUIDE_CLAUDE_EFFORT = 'low';
38
38
  export const DEFAULT_GUIDE_CODEX_MODEL = '';
39
+ /**
40
+ * The Codex model a guide uses for a user who has not picked one (saved ''),
41
+ * when the resolved catalog offers it; otherwise Codex's own marked default.
42
+ * A resolution preference only, never written to the cookie, so a saved pick
43
+ * always wins. Guide-scoped: review and tour keep Codex's default.
44
+ */
45
+ export const PREFERRED_GUIDE_CODEX_MODEL = 'gpt-6-luna';
39
46
  export const DEFAULT_GUIDE_CODEX_REASONING = 'low';
40
47
  // No DEFAULT_GUIDE_CODEX_FAST: fast mode is deliberately not offered for
41
48
  // guide (product decision — see AgentsTab's guide codex config block), so
@@ -463,7 +470,7 @@ export function useAgentSettings(catalogs?: ModelCatalogs) {
463
470
  const tourCodexFast = effectiveFast(codexCatalog, state.tourCodex.model, tourCodexModel, state.tourCodex.perModel[state.tourCodex.model]?.fast ?? DEFAULT_TOUR_CODEX_FAST);
464
471
  const guideClaudeModel = effectiveModel(claudeCatalog, state.guideClaude.model, DEFAULT_GUIDE_CLAUDE_MODEL);
465
472
  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);
473
+ const guideCodexModel = effectiveModel(codexCatalog, state.guideCodex.model, PREFERRED_GUIDE_CODEX_MODEL);
467
474
  const guideCodexReasoning = effectiveEffort(codexCatalog, guideCodexModel, state.guideCodex.perModel[state.guideCodex.model]?.reasoning ?? DEFAULT_GUIDE_CODEX_REASONING);
468
475
  // Guide offers no fast toggle; a re-key carries whatever is stored.
469
476
  const guideCodexFast = state.guideCodex.perModel[state.guideCodex.model]?.fast ?? DEFAULT_CODEX_FAST;
@@ -72,6 +72,8 @@ interface UseCodeAnnotationDraftOptions {
72
72
  autoViewSuppressed?: Set<string>;
73
73
  isApiMode: boolean;
74
74
  submitted: boolean;
75
+ /** Hosts with independent review-progress storage omit viewed state from drafts. */
76
+ persistViewedFiles?: boolean;
75
77
  /** Receives the unsent items found on a target the session switched onto
76
78
  * in place (#1590), already filtered to ids the session neither holds nor
77
79
  * deleted. The host adds them to its state; autosave then saves the merge.
@@ -125,12 +127,15 @@ export function useCodeAnnotationDraft({
125
127
  submitted,
126
128
  onDraftTargetMerge,
127
129
  targetLoadTimeoutMs = TARGET_LOAD_TIMEOUT_MS,
130
+ persistViewedFiles = true,
128
131
  }: UseCodeAnnotationDraftOptions): UseCodeAnnotationDraftResult {
129
132
  const [draftBanner, setDraftBanner] = useState<{ count: number; viewedCount: number; timeAgo: string } | null>(null);
130
133
  const draftDataRef = useRef<DraftData | null>(null);
131
134
  const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
132
135
  const hasMountedRef = useRef(false);
133
136
  const draftGenerationRef = useRef(0);
137
+ const persistViewedRef = useRef(persistViewedFiles);
138
+ persistViewedRef.current = persistViewedFiles;
134
139
  // True once the user has actually had annotations this session. Used to decide
135
140
  // whether an empty state is a real "cleared everything" edit (persist it) vs a
136
141
  // fresh/unengaged session (leave the server alone). Keyed on annotations only —
@@ -186,6 +191,9 @@ export function useCodeAnnotationDraft({
186
191
  return data as DraftData | null;
187
192
  })
188
193
  .then((data: DraftData | null) => {
194
+ // Keep even a viewed-only draft while independent progress support is
195
+ // unresolved; a failed/unsupported load may need to offer it later.
196
+ draftDataRef.current = data;
189
197
  const generation = readDraftGeneration(data?.draftGeneration);
190
198
  if (generation !== null) {
191
199
  draftGenerationRef.current = Math.max(draftGenerationRef.current, generation);
@@ -193,7 +201,7 @@ export function useCodeAnnotationDraft({
193
201
  const annotationCount = (Array.isArray(data?.codeAnnotations) ? data.codeAnnotations.length : 0)
194
202
  + (Array.isArray(data?.descriptionAnnotations) ? data.descriptionAnnotations.length : 0)
195
203
  + (Array.isArray(data?.commentAnnotations) ? data.commentAnnotations.length : 0);
196
- const viewedCount = Array.isArray(data?.viewedFiles) ? data.viewedFiles.length : 0;
204
+ const viewedCount = persistViewedRef.current && Array.isArray(data?.viewedFiles) ? data.viewedFiles.length : 0;
197
205
  if (annotationCount > 0 || viewedCount > 0) {
198
206
  draftDataRef.current = data;
199
207
  setDraftBanner({
@@ -209,6 +217,15 @@ export function useCodeAnnotationDraft({
209
217
  });
210
218
  }, [isApiMode]);
211
219
 
220
+ useEffect(() => {
221
+ const data = draftDataRef.current;
222
+ if (!data) return;
223
+ const count = (data.codeAnnotations?.length ?? 0)
224
+ + (data.descriptionAnnotations?.length ?? 0) + (data.commentAnnotations?.length ?? 0);
225
+ const viewedCount = persistViewedFiles ? data.viewedFiles?.length ?? 0 : 0;
226
+ setDraftBanner(count || viewedCount ? { count, viewedCount, timeAgo: formatTimeAgo(data.ts || 0) } : null);
227
+ }, [persistViewedFiles]);
228
+
212
229
  // Debounced auto-save on annotation/viewed changes
213
230
  useEffect(() => {
214
231
  if (!isApiMode || submitted) return;
@@ -223,7 +240,7 @@ export function useCodeAnnotationDraft({
223
240
  // via `allAnnotations` and have their own lifecycle, separate from the draft.
224
241
  if (annotations.some((a) => !a.source) || descriptionAnnotations.length > 0 || commentAnnotations.length > 0) hasHadAnnotationsRef.current = true;
225
242
 
226
- const isEmpty = annotations.length === 0 && descriptionAnnotations.length === 0 && commentAnnotations.length === 0 && viewedFiles.size === 0;
243
+ const isEmpty = annotations.length === 0 && descriptionAnnotations.length === 0 && commentAnnotations.length === 0 && (!persistViewedFiles || viewedFiles.size === 0);
227
244
  // Leave the server alone for an empty state until the user has actually had
228
245
  // annotations this session. This preserves an unrestored draft sitting on disk
229
246
  // at mount (the draft-recovery banner can still offer it).
@@ -259,8 +276,8 @@ export function useCodeAnnotationDraft({
259
276
  codeAnnotations: annotations,
260
277
  descriptionAnnotations,
261
278
  commentAnnotations,
262
- viewedFiles: [...viewedFiles],
263
- ...(autoViewSuppressed && autoViewSuppressed.size > 0
279
+ ...(persistViewedFiles ? { viewedFiles: [...viewedFiles] } : {}),
280
+ ...(persistViewedFiles && autoViewSuppressed && autoViewSuppressed.size > 0
264
281
  ? { autoViewSuppressed: [...autoViewSuppressed] }
265
282
  : {}),
266
283
  draftGeneration,
@@ -273,7 +290,7 @@ export function useCodeAnnotationDraft({
273
290
  return () => {
274
291
  if (timerRef.current) clearTimeout(timerRef.current);
275
292
  };
276
- }, [annotations, descriptionAnnotations, commentAnnotations, viewedFiles, autoViewSuppressed, isApiMode, submitted, saveNudge]);
293
+ }, [annotations, descriptionAnnotations, commentAnnotations, viewedFiles, autoViewSuppressed, isApiMode, submitted, saveNudge, persistViewedFiles]);
277
294
 
278
295
  const restoreDraft = useCallback(() => {
279
296
  // Cancel any pending autosave so it can't fire with pre-restore state and
@@ -286,9 +303,9 @@ export function useCodeAnnotationDraft({
286
303
  annotations: data?.codeAnnotations ?? [],
287
304
  descriptionAnnotations: data?.descriptionAnnotations ?? [],
288
305
  commentAnnotations: data?.commentAnnotations ?? [],
289
- viewedFiles: data?.viewedFiles ?? [],
290
- autoViewSuppressed: data?.autoViewSuppressed ?? [],
291
306
  patchChanged: data?.patchChanged === true,
307
+ viewedFiles: persistViewedRef.current ? data?.viewedFiles ?? [] : [],
308
+ autoViewSuppressed: persistViewedRef.current ? data?.autoViewSuppressed ?? [] : [],
292
309
  };
293
310
  }, []);
294
311
 
@@ -3,6 +3,7 @@ import {
3
3
  CLAUDE_FALLBACK_MODELS,
4
4
  CODEX_FALLBACK_MODELS,
5
5
  type CatalogModel,
6
+ type ModelsSource,
6
7
  } from '@plannotator/core/model-catalog';
7
8
  import type { AgentCapabilities } from '../types';
8
9
 
@@ -26,8 +27,14 @@ export type CatalogEngine = 'claude' | 'codex';
26
27
  export interface ModelCatalog {
27
28
  models: CatalogModel[];
28
29
  settled: boolean;
30
+ /** Where the server says the list came from; absent when it did not say. */
31
+ modelsSource?: ModelsSource;
32
+ /** The installed CLI's version, when the server reports it. */
33
+ toolVersion?: string;
29
34
  }
30
35
 
36
+ type LoadedCatalog = Pick<ModelCatalog, 'models' | 'modelsSource' | 'toolVersion'>;
37
+
31
38
  export interface ModelCatalogs extends Record<CatalogEngine, ModelCatalog> {
32
39
  /** Fetch one engine's catalog (no-op for other engines or when not installed). */
33
40
  load: (engine: string | null | undefined) => void;
@@ -47,19 +54,19 @@ const isCatalogEngine = (engine: unknown): engine is CatalogEngine => engine ===
47
54
 
48
55
  // One request per engine per page: every launcher surface shares the answer.
49
56
  // A failed request is forgotten so the next load retries it.
50
- const loads = new Map<CatalogEngine, Promise<CatalogModel[]>>();
57
+ const loads = new Map<CatalogEngine, Promise<LoadedCatalog>>();
51
58
 
52
- export function loadModelCatalog(engine: CatalogEngine): Promise<CatalogModel[]> {
59
+ function loadCatalogEntry(engine: CatalogEngine): Promise<LoadedCatalog> {
53
60
  let load = loads.get(engine);
54
61
  if (!load) {
55
62
  const id = AI_PROVIDER_ID[engine];
56
- const failed = () => {
63
+ const failed = (): LoadedCatalog => {
57
64
  loads.delete(engine);
58
- return FALLBACK_MODELS[engine];
65
+ return { models: FALLBACK_MODELS[engine] };
59
66
  };
60
67
  load = fetch(`/api/ai/capabilities?activate=${encodeURIComponent(id)}`)
61
68
  .then((res) => (res.ok ? res.json() : null))
62
- .then((data) => {
69
+ .then((data): LoadedCatalog => {
63
70
  const provider = data?.providers?.find((p: { id?: string; name?: string }) => p.id === id || p.name === id);
64
71
  const models = provider?.models;
65
72
  if (!Array.isArray(models) || models.length === 0) return failed();
@@ -67,7 +74,13 @@ export function loadModelCatalog(engine: CatalogEngine): Promise<CatalogModel[]>
67
74
  // failed; show it, but forget it so the next load retries (the
68
75
  // server's own cooldown bounds how often that spawns the CLI).
69
76
  if (provider.modelsSource === 'fallback') loads.delete(engine);
70
- return models as CatalogModel[];
77
+ return {
78
+ models: models as CatalogModel[],
79
+ ...(provider.modelsSource === 'fallback' || provider.modelsSource === 'discovered'
80
+ ? { modelsSource: provider.modelsSource as ModelsSource }
81
+ : {}),
82
+ ...(typeof provider.toolVersion === 'string' && provider.toolVersion ? { toolVersion: provider.toolVersion } : {}),
83
+ };
71
84
  })
72
85
  .catch(failed);
73
86
  loads.set(engine, load);
@@ -75,6 +88,10 @@ export function loadModelCatalog(engine: CatalogEngine): Promise<CatalogModel[]>
75
88
  return load;
76
89
  }
77
90
 
91
+ export function loadModelCatalog(engine: CatalogEngine): Promise<CatalogModel[]> {
92
+ return loadCatalogEntry(engine).then((entry) => entry.models);
93
+ }
94
+
78
95
  /** Test seam: forget cached catalogs. */
79
96
  export function __resetModelCatalogsForTests(): void {
80
97
  loads.clear();
@@ -85,7 +102,7 @@ export function useModelCatalogs(capabilities: AgentCapabilities | null): ModelC
85
102
  capabilities?.providers.some((p) => p.id === id && p.available) ?? false;
86
103
  const claudeOn = available('claude');
87
104
  const codexOn = available('codex');
88
- const [loaded, setLoaded] = useState<Partial<Record<CatalogEngine, CatalogModel[]>>>({});
105
+ const [loaded, setLoaded] = useState<Partial<Record<CatalogEngine, LoadedCatalog>>>({});
89
106
  const mounted = useRef(true);
90
107
  useEffect(() => {
91
108
  mounted.current = true;
@@ -98,8 +115,8 @@ export function useModelCatalogs(capabilities: AgentCapabilities | null): ModelC
98
115
  (engine: string | null | undefined) => {
99
116
  if (!isCatalogEngine(engine)) return;
100
117
  if (!(engine === 'claude' ? claudeOn : codexOn)) return;
101
- void loadModelCatalog(engine).then((models) => {
102
- if (mounted.current) setLoaded((prev) => (prev[engine] === models ? prev : { ...prev, [engine]: models }));
118
+ void loadCatalogEntry(engine).then((entry) => {
119
+ if (mounted.current) setLoaded((prev) => (prev[engine] === entry ? prev : { ...prev, [engine]: entry }));
103
120
  });
104
121
  },
105
122
  [claudeOn, codexOn],
@@ -107,8 +124,8 @@ export function useModelCatalogs(capabilities: AgentCapabilities | null): ModelC
107
124
 
108
125
  return useMemo(
109
126
  () => ({
110
- claude: { models: loaded.claude ?? FALLBACK_MODELS.claude, settled: !!loaded.claude },
111
- codex: { models: loaded.codex ?? FALLBACK_MODELS.codex, settled: !!loaded.codex },
127
+ claude: loaded.claude ? { ...loaded.claude, settled: true } : { models: FALLBACK_MODELS.claude, settled: false },
128
+ codex: loaded.codex ? { ...loaded.codex, settled: true } : { models: FALLBACK_MODELS.codex, settled: false },
112
129
  load,
113
130
  }),
114
131
  [loaded, load],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plannotator/ui",
3
- "version": "0.46.1",
3
+ "version": "0.47.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./components/*": "./components/*.tsx",
@@ -75,9 +75,9 @@
75
75
  "@lezer/common": "^1.5.2",
76
76
  "@lezer/highlight": "^1.2.3",
77
77
  "@pierre/diffs": "1.3.6",
78
- "@plannotator/atomic-editor": "^0.8.0",
79
- "@plannotator/core": "0.25.6",
80
- "@plannotator/markdown-editor": "^0.4.0",
78
+ "@plannotator/atomic-editor": "^0.9.0",
79
+ "@plannotator/core": "0.25.7",
80
+ "@plannotator/markdown-editor": "^0.5.0",
81
81
  "@plannotator/web-highlighter": "^0.8.1",
82
82
  "@tanstack/react-table": "^8.21.3",
83
83
  "@viz-js/viz": "3.30.0",