diff --git a/devlog/_plan/260927_release_train_4/gui-ux/000_plan.md b/devlog/_plan/260927_release_train_4/gui-ux/000_plan.md new file mode 100644 index 0000000000..4090ef7f11 --- /dev/null +++ b/devlog/_plan/260927_release_train_4/gui-ux/000_plan.md @@ -0,0 +1,48 @@ +# Release train 4 — GUI UX lane + +The current dashboard exposes routed-model facts that still require editing `config.json`, and a combo with a text-only member still requires a manual Vision Sidecar declaration. This lane reviews those focused GUI changes on the live `dev` shape, verifies their actual browser flows, and lands only changes whose state transitions and CI are proven. `001_inventory.md` records the intake snapshot and PR age; each decade document below gives the file-level implementation or disposition contract. + +## Loop specification + +- **Archetype / trigger / goal:** satisfy the delegated release-train GUI acceptance contract by reviewing seven PRs and three issues, integrating safe lane-owned changes into `dev` with browser, test and CI evidence. +- **Non-goals:** main/preview, release/version changes, live user home/accounts/service, account-pool and picker lane code, a wholesale #5408 adoption, native GitHub stacks, and security findings in tracked files. +- **Tool and write scope:** GitHub reads/writes authorized in the delegation; source edits/Git operations in `/Users/jun/.codex/worktrees/t4-gui-ux/opencodex`; docs in this `gui-ux/` directory; focused tests there, with `test:changed`/full suites in a verification-only worktree at `/private/tmp/t4-gui-ux-verify` after the exact commit; isolated proxy homes and distinct local ports via the direct server entrypoint with client integrations OFF; subagents only `gpt-6-sol`; no other-thread messages. No user-imposed token or wall-clock limit. +- **Verifier:** focused Bun tests for changed behavior, `bun run test:changed`, `bun run typecheck`, `bun run lint:gui`, `bun run build:gui`, `cd gui && bun run lint:i18n` for JSX copy, `cd gui && bun test tests/locale-parity.test.ts` for ten-locale key parity, `bun run privacy:scan`, `bun run structure:check`, `bun run skill:surface:check` when capability maps change, and `bun test tests/ci-workflows/file-size-ratchet.test.ts` for capped files; browser click flows and screenshots for UI states; required PR CI at the exact head and post-merge `dev` CI. Script existence is grounded in root and GUI `package.json`; the locale parity test reads `src/i18n/.ts` directly (`gui/tests/locale-parity.test.ts:6,229`). `lint:i18n` does not itself inspect locale dictionaries, so translation meaning also needs manual review and browser proof. `bun run test:changed` follows imports but misses subprocess/source-oracle edges, so explicit regression files remain necessary. Seven concurrent worktrees justify the documented full-local-suite contention exception; CI supplies the broad suite. +- **Stop / outcomes:** DONE when selected PRs are merged with exact-head/post-merge CI and every assigned PR/issue has a linked disposition; NOOP only if current `dev` already satisfies the whole lane; NEEDS_HUMAN for a genuine product/security decision that cannot be inferred; BLOCKED only on a repeated external blocker; UNSAFE when a change fails the security boundary; BUDGET_EXHAUSTED only on an actual user/host bound. +- **Memory artifact:** this numbered unit, PR Verification bodies, exact-head CI links, and local `.tmp/gui-ux/` screenshots/receipts. +- **Escalation:** a changed PR head, another lane changing a shared owner, auth/security objections, irreconcilable CI or browser environment, or an unowned external contract changes the disposition before publication. + +## Design Read + +```yaml +name: opencodex-dashboard +colors: + primary: "var(--accent)" + accent: "var(--amber)" + background: "var(--bg)" +typography: + heading: { fontFamily: "var(--font-ui)", fontSize: "var(--text-subtitle)" } + body: { fontFamily: "var(--font-ui)", fontSize: "var(--text-body)" } +iconography: + system: "existing OpenCodex controls" + weight: "regular" + domain: "existing components" +``` + +This is an operator dashboard for repeated model and provider work. Preserve compact rows, stable navigation, semantic status color and the existing dialog shell from `gui/design-system/`; add actions where a user is already inspecting the affected model. Never apply an editorial or floating-glass kit. **DESIGN_VARIANCE 3 / MOTION_INTENSITY 1 / density D6.** A save needs a clear committed/no-op/unknown outcome, visible loading state, recovery after timeout, focus restoration, keyboard activation, and readable 400px plus long-locale layout. A restore is a separate explicit action because it discards declarations. + +## Ordered work phases and decisions + +| Phase | Decision | Why / acceptance | +|---|---|---| +| `010` | Carry and repair #6058 on current `dev` | Capability editor is a cohesive lane-owned slice with no merge-tree conflict. Fix remaining fractional-window and persistence-failure concerns, prove model settings save/restore in an isolated proxy and browser, and land after exact-head CI. | +| `020` | Reimplement #4932 on current `dev` after `010` | Its model modality declaration shares the new editor's provider map. Resolve `combo-routes.ts` against current code, add Vietnamese copy, and prove a mixed combo's sidecar enrollment plus sibling-axis preservation. | +| `030` | Assess/reimplement #5617 on current `dev` after model changes | Large stale visibility semantic change intersects `model-routes.ts`, `Models.tsx`, catalog, and provider UI. Reimplement only after the account-pool lane finishes the shared provider UI owner, and if global-vs-provider semantics survive current catalog convergence and security review; otherwise leave open with concrete deferral comment. | +| `040` | Defer auth/config/account PRs and resolve issues | Defer #4649/#4644 pending explicit security review and iOS proof; #5932 overlaps account-pool ownership; #2355 is 2,971 commits old with eight conflicts; #5408 is 7,790 added lines and shares the account-pool domain. Keep #3379 open because selector rename is missing, and clarify #4189's intended provider contract. No auth implementation is in this train. | +| `050` | Integration and disposition | Build selected branches sequentially from the preceding accepted `dev` merge SHA, rebase to the current `dev` tip, validate file caps/union/ten locales, attach screenshots by `pr-assets` SHA, merge only after required exact-head checks and review findings, verify `dev`, then close solved issues/original PRs with links and credit. | + +Each phase is its own PABCD cycle. A later P rechecks this plan against the latest `origin/dev` and may narrow or append a slice with a written reason. `structure/INDEX.md` owns the source-document map; relevant SoT targets are `structure/gui-and-management-api.md`, `structure/dashboard-and-usage.md`, `structure/catalog.md`, and `structure/config.md` when their contracts actually change. User-facing changes update the matching `docs-site/` guide or reference. All new tests are registered in both test-layout inventories. PR screenshots live on `pr-assets`, never in the code PR branch. + +## Consultation + +Architect handle `01a0e33b-a8a7-7ab3-8682-461189c7d1c8` proposed GUI-01→GUI-02→GUI-03, conditional auth/config work, account-pool handoff for #5932, and wholesale #5408 deferral. I accept its dependency order, but reject its suggestion that the current OAuth account alias editor resolves #3379's Codex selector rename. `CodexAccountPickerSetting.tsx` still only toggles picker visibility; `codexAccountNamespaces` keys still lack a rename API. The issue remains open. Same-architect reflections first found a #3379 wording gap, #5617 owner/rollback gaps, and then #4932/#5617 decision wording and conditional-path gaps. Those were folded into this revision, `020_combo_sidecar.md` and `030_visibility.md`. The same handle's final response was **ALIGNED**, with no material gap in those amended passages. The independent A reviewer remains separate. diff --git a/devlog/_plan/260927_release_train_4/gui-ux/001_inventory.md b/devlog/_plan/260927_release_train_4/gui-ux/001_inventory.md new file mode 100644 index 0000000000..b1d0c75d17 --- /dev/null +++ b/devlog/_plan/260927_release_train_4/gui-ux/001_inventory.md @@ -0,0 +1,17 @@ +# GUI UX lane — current intake evidence + +Read at `origin/dev` `24b2f39b77` on 2026-09-27. The seven candidate heads were fetched as `t4/pr` for read-only comparison. `git merge-tree --write-tree origin/dev t4/pr` was used to identify textual conflicts without changing the checkout. All candidates target `dev`. + +| PR | Head | Commits behind current dev | Merge-tree conflicts | Initial ownership judgment | +|---|---|---:|---|---| +| #6058 | `0798999c6f` | 51 | none | Routed model capability editor; lane-owned. Current CodeRabbit finding permits a positive fractional context window that floors to zero. | +| #5617 | `f65144160d` | 369 | `app-routing.ts`, `Providers.tsx`, `structure/config.md` | Global visibility and provider-local visibility semantics; potentially lane-owned, but must reconcile with the current catalog and Models UX. | +| #4932 | `99f3abc39f` | 317 | `combo-routes.ts` | Mixed combo Vision Sidecar enrollment; lane-owned. Branch lacks `vi.ts` although current GUI has ten locales. | +| #4649 | `c9b24c20bf` | 317 | none | Remembered admin token for #4644; lane-owned with an auth/security review gate. | +| #5932 | `e4387ea001` | 126 | provider workspace `types.ts` | Antigravity account plan presentation; overlaps account-pool lane's provider workspace files. | +| #2355 | `ec0c68daca` | 2,971 | eight runtime, CLI and GUI files | Config-versus-running-proxy warning; old architecture and broad shared-file collisions. | +| #5408 | `d4112b3e5d` | 108 | six provider/account files | 7,790 added lines and account-pool overlap; release suitability assessment only per lane assignment. | + +Current issue status: #4644, #3379 and #4189 are open. #4644 tracks standalone Safari AutoFill and depends on the security verdict for #4649. #3379 already had journal deletion and custom usage ranges landed; selector naming remains and overlaps the picker/account lane. #4189 conflates ZCode client integration with Z.AI provider login; the existing issue discussion describes both interpretations, but the current code must be checked before disposition. + +The candidate files, comments and later `dev` movement are point-in-time evidence. Recheck heads and merge bases immediately before any carry or merge. diff --git a/devlog/_plan/260927_release_train_4/gui-ux/002_ux_states.md b/devlog/_plan/260927_release_train_4/gui-ux/002_ux_states.md new file mode 100644 index 0000000000..5c4d68b89b --- /dev/null +++ b/devlog/_plan/260927_release_train_4/gui-ux/002_ux_states.md @@ -0,0 +1,11 @@ +# Dashboard UX state contract + +The lane uses the current OpenCodex design system: one compact row action, the existing dialog/notice primitives, semantic color tokens, no decorative motion. The operator must be able to tell whether a value is inherited, explicitly declared, saved, unsaved, or unknown after a network failure. A default GUI scan cannot prove these states; each is a separate browser or fault-injection activation. + +| Flow | Empty / inherited | Loading | Error / recovery | Keyboard and narrow-width proof | +|---|---|---|---|---| +| Routed model settings | An unticked modality field means no declaration; show the upstream value it follows. No reasoning ladder must be distinct from inherited ladder. | Save/restore disables duplicate submit and exposes busy text; preserve the current fields. | Validation names the field. A confirmed write with failed catalog refresh says the write happened and offers refresh. An unknown save outcome avoids a false “failed” claim and lets the operator reload. | Tab to row Edit, Enter opens; dialog focus is contained, Escape and Close restore focus when idle. At 400px actions stack without horizontal scrolling, and long German/Russian labels wrap. | +| Mixed combo | No target or an unknown catalog row gives an explanation and a next action, not an available Image toggle. Known text-only members are named in the hint before save. | Save shows one in-progress state; the target list remains visible for review. | A rejected sidecar target leaves combo/provider config unchanged and shows the server reason; retry is available. | Toggle and Save work by keyboard. The list and hint wrap at 400px, including long locale/model IDs. | +| Global/provider visibility | A provider with no visible rows distinguishes global hide from local selection and points to the restoring control. | A bounded PUT shows busy state; no switch can become stuck after timeout. | Failed save leaves the previous rendered switch state with retry guidance. | Global scope wording remains clear when rows are grouped by provider; keyboard toggles and 400px filter/action layout remain usable. | + +Screenshots for adopted GUI changes cover the pre-change view and the post-change first action, plus at least one narrow/long-locale and one error/empty/loading state. A screenshot is inspected visually, not merely generated. Text content and focus outcome are checked with browser DOM/keyboard actions. Synthetic credentials and models in an isolated home prevent touching the user's running 10100 proxy. diff --git a/devlog/_plan/260927_release_train_4/gui-ux/003_audit.md b/devlog/_plan/260927_release_train_4/gui-ux/003_audit.md new file mode 100644 index 0000000000..e06d812ab3 --- /dev/null +++ b/devlog/_plan/260927_release_train_4/gui-ux/003_audit.md @@ -0,0 +1,16 @@ +# Roadmap audit and baseline proof + +The independent reviewer (`01a0e349-7a9f-73f3-a3d1-b32cfdf9de9a`, `gpt-6-sol`) audited the docs-first plan at `dev` `24b2f39b77`. Its first verdict was `GO-WITH-FIXES`: the named i18n command was not a root script and did not inspect locale dictionaries, and Phase 030 omitted catalog/GUI consumers of #5617. Both High blockers were folded. Three Medium gaps were also fixed: Phase 040 now explicitly defers auth work, Phase 050 names the real file-size test and treats layout.json as an inventory, and branches are sequential from the previous accepted `dev` merge. A final one-line summary and NEW-vs-MODIFY path correction were rechecked. The last verdict was **PASS** with no blocking issue. + +The same architect (`01a0e33b-a8a7-7ab3-8682-461189c7d1c8`) reflected on the initial plan and later field/consumer and phase-dependency amendments; its final verdict was **ALIGNED**. Main rejected its early inference that an OAuth account alias closes #3379: the requested Codex selector rename remains absent. Explorer findings used for the plan were read-only and tied to PR heads in `001_inventory.md`. + +Baseline command evidence in this worktree: + +| Command | Result | What it actually observes | +|---|---|---| +| `bun run privacy:scan` | pass | Credential/privacy patterns in repository content; rerun after staging docs. | +| `bun test tests/ci-workflows/file-size-ratchet.test.ts` | first attempt failed: missing `zod/v4`; after `bun install --frozen-lockfile`, 9 pass / 0 fail | Repository file-size cap including `gui/src/pages/Models.tsx`. No baseline was updated. | +| `cd gui && bun test tests/locale-parity.test.ts` | after GUI frozen install, 5 pass / 0 fail | Reads locale modules directly and checks key-set parity for ten locales; translation meaning still needs human/browser review. | +| `cd gui && bun run lint:i18n` | not yet run on a changed GUI | Oxlint checks visible JSX copy, not locale dictionary key parity. | + +All installs used the committed lockfiles in the lane worktree. No production home, service, token, account, browser session or `dev` branch was touched in this docs-only phase. The `0.2.39` Codexclaw cache path disappeared during the audit; the now-installed `0.2.36` CLI read the same bound session/source and continued with explicit `--session`. That tool drift is not verification of product behavior. diff --git a/devlog/_plan/260927_release_train_4/gui-ux/004_ui_baseline.md b/devlog/_plan/260927_release_train_4/gui-ux/004_ui_baseline.md new file mode 100644 index 0000000000..2d4d87626a --- /dev/null +++ b/devlog/_plan/260927_release_train_4/gui-ux/004_ui_baseline.md @@ -0,0 +1,5 @@ +# Before-change Models UI observation + +At `a60b078cda` (source code from `dev` `24b2f39b77`), a direct `startServer(18761)` call with synthetic `demo` models and isolated OpenCodex/Codex homes served the packaged dashboard. A separate headless Chrome profile opened Models, dismissed the first-run surface, selected Demo and used Expand all. The rendered two rows had visibility, alias, Name and Price controls. Neither row had capability editing. The inspected desktop screenshot is in ignored `.tmp/gui-ux/before-models-desktop.png`; its eventual PR evidence copy must live on `pr-assets`, not the code branch. + +The baseline browser interaction verified the live proxy reported version 2.69.0 and showed `demo/text-demo` plus `demo/vision-demo`. The synthetic provider used an unreachable loopback upstream and no real key, so no model request was sent. A CLI startup attempted to synchronize an installed local client despite isolated proxy homes; that process was stopped and its one observed real-home managed endpoint was restored. Operational detail and restoration evidence are kept in ignored `.tmp/gui-ux/qa-isolation-incident.md`. Later QA uses only the direct server entrypoint with local-client integrations explicitly OFF. This observation does not prove any changed UI behavior or iOS Safari behavior. diff --git a/devlog/_plan/260927_release_train_4/gui-ux/010_model_settings.md b/devlog/_plan/260927_release_train_4/gui-ux/010_model_settings.md new file mode 100644 index 0000000000..42608a5d14 --- /dev/null +++ b/devlog/_plan/260927_release_train_4/gui-ux/010_model_settings.md @@ -0,0 +1,39 @@ +# Phase 010 — routed-model capability editing (#6058) + +**Decision:** carry the contributor change from `0798999c6f`, preserve attribution with a `Co-authored-by` trailer, and repair it on a `codex/t4-gui-ux-*` branch based on the newest `origin/dev`. Do not push the contributor fork. + +## Diff-level map + +- **NEW** `gui/src/components/ModelSettingsDialog.tsx`: use the existing narrow dialog shell; seed every draft from stored declarations, not effective catalog values; show the effective context window as an inherited hint when no stored declaration exists. Send only changed axes and distinguish confirmed save plus failed refresh from unknown save. The PR currently treats the `unknown` error phase as busy, disabling Cancel and trapping the dialog; busy must mean an in-flight request only. On unknown outcome, disable Save/Restore, keep Close/Cancel/Escape enabled, and move focus to a read-only Reload that refreshes the list before another mutation. (Implemented focus target: Reload; see `011_model_settings_evidence.md`.) Return focus to the row trigger after close. +- **MODIFY** `gui/src/pages/Models.tsx` and `gui/src/pages/models-shared.ts`: show one Edit control only for routed, non-custom, non-combo rows; carry exact stored `contextWindowDeclared` separately from effective `contextWindow`; load the saved row after a successful mutation; stay under the `Models.tsx` 2,792-line cap by moving logic into sibling files if needed. +- **MODIFY** all ten `gui/src/i18n/{en,de,fr,ja,ko,ru,tr,vi,zh-TW,zh}.ts`: exact same keys, translated inherited-context and unknown/confirmed-reload semantics, long-string layout proof. `cd gui && bun test tests/locale-parity.test.ts` checks key sets; `cd gui && bun run lint:i18n` checks visible JSX copy; neither proves translation meaning, so inspect long translations in-browser. +- **MODIFY** `src/server/management/model-routes.ts`, `model-rows.ts`, `route-registry.ts`: validate provider, exact model ID, allowed request fields and all axes at ingress; reject a positive fractional context value that floors to zero or exceeds safe integer range. Compute changed/no-op against copied touched maps, then wrap mutation/persistence in existing `commitProviderPatch` so an unpublished failed save restores live config graph and a published write is not falsely rolled back. On a published write with a post-publication error, return a sanitized saved receipt; do not claim catalog convergence when it is skipped or failed. Clear the provider discovery cache only after publication. Route projection distinguishes stored context/modality/reasoning declarations from effective values. Unreleased input-validation findings are kept in ignored scratch until fixed. +- **MODIFY** `src/cli/models-runtime.ts`, `models-runtime-subcommands.ts`, `capabilities.ts`; **REGENERATE** `skills/ocx/references/01_management_surface.md`: CLI and API parity, safe clear spelling. +- **NEW** `tests/server/model-settings-management-api.test.ts`, `tests/cli/cli-models-set.test.ts`, `gui/tests/model-settings-dialog.test.tsx`; **MODIFY** `tests/codex-integration/codex-convergence-contract.test.ts`, both test-layout inventories. Include unpublished save-throw-then-identical-retry, published-postwrite-error receipt, no-op, null clear, empty ladder, effective default, duplicate modalities, context declaration projection, cache invalidation, unknown outcome read-only Retry, and Close/Escape after a network error. Validate rejected inputs cause no save, cache clear or convergence. +- **MODIFY** `structure/gui-and-management-api.md` and `docs-site/src/content/docs/reference/management-api.md`: explain durable-write semantics, clear/no-reasoning distinction and UI behavior. + +## Activation and proof + +With synthetic provider/model entries in an isolated `OPENCODEX_HOME`, inspect the Models row at desktop and 400px, open Edit by pointer and keyboard, save a single axis, reload, restore all, and capture before/after plus loading/empty/error/long-locale views. Use a direct `startServer` import with explicit client integrations OFF and an isolated `CODEX_HOME`; the CLI startup path auto-syncs installed clients and is unsuitable for this QA boundary. The `dev` baseline screenshot at ignored `.tmp/gui-ux/before-models-desktop.png` shows demo routed rows with Name and Price actions and no capability Edit. API tests force save failure, then identical retry: durable config and live rows must agree after the retry. A `contextWindow: 0.5` request must be 400 without mutation. A failed catalog refresh after a confirmed save must say the save occurred and offer recovery, not invite a duplicate write. Verify CLI no-op and clear output against route receipts. Run focused tests, typecheck, GUI test/lint plus `cd gui && bun test tests/locale-parity.test.ts` and `cd gui && bun run lint:i18n`/build, privacy, structure, skill surface, `bun test tests/ci-workflows/file-size-ratchet.test.ts`, then exact-head CI. Run `test:changed` and any full suite in a verification-only Git worktree at `/private/tmp/t4-gui-ux-verify` at the exact code commit; tests under `~/.codex` can hit protected cleanup paths unrelated to product behavior. Code edits remain in the lane worktree. Security review checks management admission and no secret exposure. + +## Rollback and boundary + +Existing provider per-model maps remain readable if this editor is reverted. Do not change custom-model ownership or native OpenAI rows. This phase does not edit the user's actual config or Codex home. + +## Architect consultation for this work-phase + +Read-only architect `01a0e369-14f4-7d40-b0de-1ddef731fa98` proposed MS58-01 through MS58-05 against contributor head `0798999c6f`. Main accepted MS58-01 atomic write using the repository's `commitProviderPatch` owner and copied touched maps; accepted MS58-02 exact ingress; accepted MS58-03 with a sanitized publication receipt that distinguishes config save from catalog convergence; accepted MS58-04 read-only recovery and focus behavior; and accepted MS58-05 stored-versus-effective context projection. The alternative of a discovery fetch to prove model-row existence is rejected here because it makes a local config mutation depend on a possibly unavailable upstream; exact ID validation is the admission boundary. The same handle reflected on this amended plan and returned **ALIGNED** with no material gap. The independent A reviewer remains separate. + +## Revalidation at resume (2026-09-28) + +The lane resumed in coordinator thread `01a0e37e-639d-7693-a2bc-5e4df49fa656` after the previous thread stopped at A. The previous D (docs-first `wp0`) locked this roadmap and pointed `wp1` at a carry-and-repair of #6058. That direction stands: `origin/dev` is still `24b2f39b77` and #6058 is still `0798999c6f`, so the diff-level map above needs no change. A fresh independent `gpt-6-sol` A verdict replaces the one lost when reviewer `01a0e372-f380-7e11-bf2b-661afd132a50` was shut down. + +## Independent A verdict at resume + +Reviewer `01a0e380-3554-72c3-935d-9f1ec8a1193b` (`gpt-6-sol`) returned **GO-WITH-FIXES** and confirmed `commitProviderPatch(config, mutate, save)` in `src/server/management/provider-patch-transaction.ts:45-61` restores the live graph after an unpublished failure and keeps it after `ConfigWritePublishedError`. Its three findings are folded into this phase: + +1. **CLI unsafe integers (Medium):** `src/cli/models-runtime.ts` in the PR uses `Number.isInteger`; require `Number.isSafeInteger` with a focused CLI case. The server check remains authoritative. +2. **No-op wording (Medium):** `changed: false` means "nothing changed", not "no overrides stored". CLI and dialog no-op copy become neutral ("No settings changed" / nothing to restore only when the receipt shows every axis null), with tests for an identical write that leaves a declaration stored. +3. **Catalog receipt (Low):** the dialog must read the server's `catalogRefresh` result; a confirmed save with a failed or skipped refresh says the settings were saved and offers reload, covered by a GUI test. + +The only capped file grown is `gui/src/pages/Models.tsx` (cap 2,792; dev 2,780; PR 2,788). The merge has no textual conflict, but locale, management-API doc, structure doc and test inventories need a union review after carry. The CLI is kept: it is small, already tested, and gives API parity. diff --git a/devlog/_plan/260927_release_train_4/gui-ux/011_model_settings_evidence.md b/devlog/_plan/260927_release_train_4/gui-ux/011_model_settings_evidence.md new file mode 100644 index 0000000000..d19956129c --- /dev/null +++ b/devlog/_plan/260927_release_train_4/gui-ux/011_model_settings_evidence.md @@ -0,0 +1,42 @@ +# Phase 010 — build and browser evidence + +The #6058 carry is on `codex/t4-gui-ux-model-settings`. The contributor commit is `bb779ff13d` with its original author, followed by lane repairs `0d2d7ff0f7`, `1a61f6773c` and `03928972b5`, each with a `Co-authored-by` trailer. Every audit item in `010_model_settings.md` is implemented. Browser QA also found and fixed a defect that the unit tests missed. + +## What changed beyond the plan + +- **Menus behind the modal (found in browser QA).** The Context window and Default level menus portaled to ``, under the modal's top layer. The dialog's backdrop button took their clicks, so the context window could not be changed by pointer (`08-pre-fix-menu-behind-modal.png`). Both menus now render inside the dialog (`portal={false}`), matching `gui/src/pages/dashboard-dialogs.tsx:53`. A regression test fails without the fix and passes with it. +- **Modality seeding.** On an undeclared row every modality box starts unticked, meaning "follow upstream". Ticking `image` to add image support used to declare the model image-only. The first tick now starts from the modalities the row already follows. +- **Codex catalog versus dashboard list.** `catalogRefresh` describes the Codex app's catalog, while the dashboard reads the saved config directly. A non-retryable skip (no managed catalog) is therefore a clean save. A failed or retryable refresh is a warning after the save and does not trap the dialog. Only a failed dashboard list reload enters the read-only stale state, which uses the warning tone because the save succeeded. + +## UX state results (isolated proxy, direct `startServer`, port 18761) + +Each state below was clicked through in a real Chrome browser, driven by Playwright with the system Chrome. The config file in the isolated `OPENCODEX_HOME` was read back after each write. + +| State | Observation | +|---|---| +| Row entry | `Edit` appears only on routed rows, after Name and Price. Keyboard Enter opens the dialog and focus lands inside it. | +| Save one axis | Ticking `image` sent only `inputModalities`. Config read back as `["text","image"]`. Focus returned to the row's Edit button. | +| Reopen | The declared boxes are ticked. A saved context window of `128000` reopens as 128k. | +| Invalid input | Custom `0.5` shows "Context windows must be positive whole numbers" and makes no write (config unchanged). | +| Restore | The confirm dialog appears; after confirming, the declaration is removed from config. | +| Unknown outcome (PUT aborted) | Apply and Restore are disabled, while Cancel, Close and Escape work. Focus moves to Reload. Config is unchanged. | +| Saved, list reload failed | The warning notice appears with Apply and Restore disabled. Reload then closes the dialog with "reopen" feedback. | +| Keyboard | Tab cycles through the dialog's controls. The only stop outside is ``, which is Chrome's native modal browser UI stop; the page behind stays inert. | +| 400px ru, de and en; 1360px ja | Page `scrollWidth` equals the viewport. The form's `scrollWidth` equals its `clientWidth` (378). The long title and reasoning labels wrap, and the actions stack full width. | + +The real-home sentinel files (`~/.grok/config.toml`, `~/.codex/config.toml`, Claude settings, Claude Desktop config, `~/.zshrc`, `~/.opencodex/config.json`) hashed identically before and after QA. + +Screenshots are on `pr-assets` at [`f3f98330`](https://github.com/lidge-jun/opencodex/blob/f3f98330421483b49050d65e2e09d6ead4467202/260928-t4-gui-model-settings). + +## Commands + +| Command | Where | Result | +|---|---|---| +| `bun test tests/server/model-settings-management-api.test.ts tests/cli/cli-models-set.test.ts tests/codex-integration/codex-convergence-contract.test.ts` | lane worktree | 48 pass, 0 fail | +| `cd gui && bun test tests/model-settings-dialog.test.tsx tests/locale-parity.test.ts` | lane worktree | 22 pass, 0 fail (17 dialog + 5 parity at final head) | +| `bun run typecheck`, `bun run lint:gui`, `cd gui && bun run lint:i18n`, `bun run build:gui` | lane worktree | all exit 0 | +| `bun test tests/ci-workflows/file-size-ratchet.test.ts` | lane worktree | 9 pass (`Models.tsx` 2,788 of 2,792) | +| `bun run privacy:scan`, `bun run structure:check`, `bun run skill:surface:check` | lane worktree | pass | +| `bun run test:changed` | `/private/tmp/t4-gui-ux-verify` at `03928972b5` | 6,011 pass, 2 skip, 0 fail, 297 files | + +The full local suite was not run because seven lane worktrees share this machine; CI runs the full suite on the PR head. diff --git a/devlog/_plan/260927_release_train_4/gui-ux/020_combo_sidecar.md b/devlog/_plan/260927_release_train_4/gui-ux/020_combo_sidecar.md new file mode 100644 index 0000000000..db00ea82ef --- /dev/null +++ b/devlog/_plan/260927_release_train_4/gui-ux/020_combo_sidecar.md @@ -0,0 +1,18 @@ +# Phase 020 — mixed combo Vision Sidecar enrollment (#4932) + +**Decision:** reimplement the contributor's cohesive behavior after Phase 010, with attribution, against current `combo-routes.ts`. The old branch's GUI work is reusable, but its server route has a 200-success hole for a missing provider and can overwrite an image-capable declaration with text-only. + +## Diff-level map + +- **MODIFY** `gui/src/combo-capabilities.ts`, `combo-workspace-data.ts`, `components/combo-workspace-controls.tsx`, `pages/Combos.tsx`: permit image input when every member is known and text-only members can be declared; reject audio-only rows rather than treating every non-image row as text; name exactly which members receive a sidecar declaration; fail closed for unknown catalog entries; keep the primary save action and clear loading/error states. +- **MODIFY** all ten `gui/src/i18n/` locale modules, including the missing Vietnamese translations; no English-only hint in JSX. +- **MODIFY** `src/server/management/combo-routes.ts`: accept request-only `visionSidecarTargets`, validate exact member ownership, configured provider existence, current modality declaration and image-input gate before any mutation. Reject a missing provider or an already-image-capable submitted target instead of returning 200 after silently skipping/overwriting it. Write each eligible text-only `inputModalities` declaration into the provider map while preserving other capabilities, and do not persist the request-only field under `combos`. +- **MODIFY** `structure/catalog.md`, `structure/config.md`, `structure/dashboard-and-usage.md`, `docs-site/src/content/docs/guides/{combos,sidecars}.md`; **MODIFY** `tests/routing/combo-management-api.test.ts` and `tests/gui/combo-workspace-data.test.ts`; register any new files in both test-layout inventories. + +## Activation and proof + +In an isolated proxy, create a mixed combo from one image-capable and one text-only synthetic member; inspect the hint before save, save, then read back the per-model modality patch and combo behavior. Unknown member, disabled image input, non-member target, missing configured provider, already-image-capable submitted sidecar target and audio-only row each fail without config mutation or a false success receipt. Edit that model's capability through Phase 010 afterward and verify the combo state remains truthful. Browser proof includes desktop/400px, keyboard, empty/loading/error, and at least one long locale; screenshot before/after. Run focused regressions, `test:changed`, typecheck, GUI full tests/lint/build plus `cd gui && bun run lint:i18n` and `cd gui && bun test tests/locale-parity.test.ts`, structure/privacy and exact-head CI. + +## Rollback and boundary + +Do not overwrite sibling per-model context/reasoning axes. If the generated modality declaration cannot be distinguished from an operator's existing declaration, the implementation must preserve the existing one and explain that behavior; rollback must never delete operator-owned facts. diff --git a/devlog/_plan/260927_release_train_4/gui-ux/030_visibility.md b/devlog/_plan/260927_release_train_4/gui-ux/030_visibility.md new file mode 100644 index 0000000000..8478c53137 --- /dev/null +++ b/devlog/_plan/260927_release_train_4/gui-ux/030_visibility.md @@ -0,0 +1,18 @@ +# Phase 030 — global and provider visibility assessment (#5617) + +**Decision under audit:** reimplement the useful global/provider distinction on current `dev` after Phase 010, subject to the account-pool lane finishing the shared provider UI owner. The stale draft is 369 commits behind `dev` and conflicts in `app-routing.ts`, `Providers.tsx`, and `structure/config.md`; a mechanical carry could break the current JEV Auto deep link and provider CTA. If that owner is still changing or a stable config migration cannot be shown, defer with evidence. + +## If adopted: diff-level map + +- **MODIFY** `src/types/config.ts`, `src/codex/catalog.ts`, `src/codex/catalog/model-visibility.ts`, `src/codex/catalog/gather-capture.ts`, `src/codex/catalog/metadata.ts`, `src/codex/catalog/retained-sync.ts`, `src/codex/convergence.ts`, `src/codex/subagent-model-fallback.ts`, `src/config/live-reconcile.ts`: create/serialize/revive a global exact-model-ID hide independently of provider-local selected/hidden rules; apply it to discovery, retained sync, metadata, subagent fallback and catalog convergence; newly added providers obey the global rule while direct routing stays distinct from picker visibility. +- **MODIFY** `src/server/management/model-routes.ts`, `model-rows.ts`, `route-registry.ts`, `agent-settings-routes.ts`, `src/server/index/serve-options.ts`, `src/cli/models-runtime.ts`: expose an explicit global scope through the management, CLI and startup option paths while keeping legacy provider requests stable; avoid a silently changed meaning for existing `disabledModels`. +- **MODIFY** `gui/src/pages/Models.tsx`, `models-shared.ts`, `models-tab.ts`, `Providers.tsx`, `components/provider-workspace/ProviderModels.tsx`, `ProviderModelChip.tsx`, `provider-workspace/model-inventory.ts`, `app-routing.ts`, and all ten locales; **NEW if needed** `gui/src/pages/use-models-provider-selection.ts` (the contributor branch has it, current `dev` does not): separate labels and controls where the user sees the distinction; provider-local empty state explains a global hide and links to the Models control. Keep `JEV_AUTO_CREATE_HASH` and its CTA. Use bounded requests for global visibility so a hung PUT cannot lock future switches. Move new logic into siblings before the 2,792-line Models cap is reached. Preserve dense operator layout and keyboard operation. +- **MODIFY** `structure/catalog.md`, `structure/config.md`, `structure/gui-and-management-api.md` and matching `docs-site` model-routing/dashboard guides; **MODIFY** focused catalog/management/GUI tests and both test-layout inventories for any new files. + +## Acceptance or deferral test + +Prove: globally hide one exact model ID; add a new provider with that ID; it stays hidden in catalog while a direct route still works. Locally hide one provider's ID; another provider's same ID stays visible. Reload, sync and restart preserve these states. An empty provider list explains whether global hide or provider selection caused it. Open the existing JEV Auto CTA and `#models/combos/jev-auto` deep link after the new provider link is added; both must still resolve to the Combos action. Force a global visibility PUT to hang until its timeout, then verify the busy state releases and a late response cannot overwrite a newer switch state. Inspect before/after browser flows at desktop/400px and long locale. Run a dedicated management/security review and exact-head CI. If a stable migration from old `disabledModels` cannot be established, leave #5617 open with a detailed English comment rather than changing semantics mid-train. + +## Rollback + +If adopted, the new persisted global hide field needs an explicit rollback/migration before a UI revert. A revert must either translate it back into the existing visibility representation with a documented precedence or remove it only after the operator is shown the affected exact IDs; leaving invisible models behind an absent control is unacceptable. diff --git a/devlog/_plan/260927_release_train_4/gui-ux/040_dispositions.md b/devlog/_plan/260927_release_train_4/gui-ux/040_dispositions.md new file mode 100644 index 0000000000..94a80d33d1 --- /dev/null +++ b/devlog/_plan/260927_release_train_4/gui-ux/040_dispositions.md @@ -0,0 +1,15 @@ +# Phase 040 — auth, config, account and issue disposition + +## PR decisions + +- **#4649 and #4644 — defer:** The draft changes credential persistence and has not passed the explicit security review required by `MAINTAINERS.md`. Keep the PR and issue open; the current screenshot file also violates repository hygiene. The security analysis and any pre-disclosure repair plan stay in ignored `.tmp/gui-ux/4649-security-review.md`, not this public devlog. This workaround cannot be called Safari AutoFill itself. A later adoption is a new scoped phase after security and real iOS standalone proof. +- **#5932 — defer from this lane:** its `ProviderAuthPanel.tsx`, `useProviderAccountPools.ts`, provider workspace types, and OAuth account DTO overlap the account-pool lane. Do not touch those files here. The account-pool lane should determine whether a plan badge is still missing after its work; leave PR open with an ownership and current-conflict comment. +- **#2355 — defer this old branch; consider a new scoped issue later:** eight conflicts across `src/config.ts`, server lifecycle, CLI and dashboard. A safe version would require a resident-vs-disk config identity, low-privilege status read, warning/retry UI and tests for managed save versus external edit. Its `docs/pr-assets/` screenshot cannot be carried. The config/picker lanes may move those owners during this train; leave the PR open with current file evidence. +- **#5408 — defer wholesale:** 7,790 insertions across 67 files, six current conflicts and account-pool overlap. Do no cherry-pick until individual behavior gaps are identified in the current provider workspace; this lane performs suitability judgment only. Leave a concrete English comment and keep it open. + +## Issue decisions + +- **#3379 — hold open:** journal deletion and custom Usage ranges already landed, but the remaining Codex selector rename is absent. `gui/src/components/CodexAccountPickerSetting.tsx` only toggles picker visibility; `src/server/management/config-routes.ts` has no selector-name edit API. Generic OAuth `alias` is a different field. This touches picker/account ownership, so record the gap and avoid a duplicate implementation in this GUI lane. +- **#4189:** current ZCode client integration and Z.AI provider APIs are separate. The report does not identify a ZCode upstream login/API contract. Ask the issue author in English whether they mean the ZCode client using OpenCodex, Z.AI API-key upstream, or a distinct login; leave open pending answer and do not add a fake `zcode` provider card. + +For every deferred PR or issue, post one evidence-backed English comment with the disposition. Do not close a contributor PR merely for being large or stale. An adopted contributor PR closes only after a replacement lands and a credit trailer plus replacement link are present. diff --git a/devlog/_plan/260927_release_train_4/gui-ux/050_integration.md b/devlog/_plan/260927_release_train_4/gui-ux/050_integration.md new file mode 100644 index 0000000000..0023c87886 --- /dev/null +++ b/devlog/_plan/260927_release_train_4/gui-ux/050_integration.md @@ -0,0 +1,5 @@ +# Phase 050 — merge evidence and post-merge cleanup + +Before each selected PR: fetch the latest `origin/dev` and contributor head; compare diff and merge-base; build the next lane branch from the **previous accepted dev merge SHA** (or current `origin/dev` for the first branch), then rebase that branch to the exact current `dev` tip and rerun tests affected by the rebase. Check the union of prior lane changes with the new diff. Prove the `Models.tsx` cap with `bun test tests/ci-workflows/file-size-ratchet.test.ts`; `scripts/test-layout/layout.json` is an inventory to keep synchronized with `tests/fixtures/test-layout-expected.json`, not a file-size-baseline entry. Review union/locale rosters. Write the PR template's Summary, Verification and Checklist with exact commands, results, full-suite contention exception, current screenshot `pr-assets` commit SHA URLs, and contributor `Co-authored-by` trailer. No screenshot files in the code branch. + +Inspect GitHub required checks against the PR's current `headRefOid`; skipped, cancelled, pending and older-head results are not success. Resolve valid Codex/CodeRabbit findings; merge only after all required checks succeed and MAINTAINERS.md security/review rules are satisfied. Record each merge SHA, CI run URL, source PR close/comment and linked issue close/comment. Fetch `dev` after each merge and verify its current CI; repair any regression owned by this lane before the next batch. The final report names merged PRs, exact SHAs, held work, source credit, browser evidence and remaining risk. diff --git a/devlog/_plan/260927_release_train_4/gui-ux/_handoff.md b/devlog/_plan/260927_release_train_4/gui-ux/_handoff.md new file mode 100644 index 0000000000..15588cd294 --- /dev/null +++ b/devlog/_plan/260927_release_train_4/gui-ux/_handoff.md @@ -0,0 +1,9 @@ +# GUI UX lane handoff — 2026-09-28 KST + +Stopped at the coordinator's request. Dedicated checkout: `/Users/jun/.codex/worktrees/t4-gui-ux/opencodex`, branch `codex/t4-gui-ux-model-settings`, latest work commit `05ff0a8372` (parent roadmap commit `a60b078cda`); **local only, not pushed**. No lane PR was created or merged, and no GitHub issue/PR comment or closure was made. The seven source PRs and three issues were open at intake; refresh their states before acting. Last verified `origin/dev` was `24b2f39b77`, #6058 head `0798999c6f`. + +Docs-first `wp0` PABCD cycle is complete. The session `01a0e337-f3a7-7380-82dd-4a66bb2da2fd` is at **A** for `wp1` (#6058). The independent A reviewer `01a0e372-f380-7e11-bf2b-661afd132a50` was shut down before a verdict; do not treat it as approval. The `wp1` architect reflected on the amended `010_model_settings.md` and returned ALIGNED. The roadmap, PR/issue disposition and UX state design are in numbered files here. Product code is still unchanged from `dev`. + +The before-change live GUI screenshot is in ignored `.tmp/gui-ux/before-models-desktop.png`. Its synthetic proxy was run from direct `startServer` with isolated OpenCodex/Codex homes and client integrations OFF. An earlier CLI-start attempt rewrote the real Grok managed endpoint to test port 18761; it was stopped and the endpoint restored to the running user proxy on 10100, with readback verification. Details are in ignored `.tmp/gui-ux/qa-isolation-incident.md`. **Use only the direct server entrypoint for future local QA.** Unreleased #6058 and #4649 security notes are in ignored `.tmp/gui-ux/*-security-review.md`, not tracked docs. + +Next: fetch current `dev` and #6058; obtain a fresh `gpt-6-sol` independent A verdict on `010_model_settings.md` and its owning code. If approved, carry #6058 onto this lane branch with contributor credit, repair the documented validation, persistence and dialog recovery defects, then verify focused tests/typecheck/GUI lint/build and real desktop/narrow/long-locale click flows. Run `test:changed` or a full suite only in a verification-only checkout of the exact code commit at `/private/tmp/t4-gui-ux-verify` because tests under `~/.codex` can hit protected cleanup paths. Screenshots for a code PR go to `pr-assets` by SHA, never the code branch. Required exact-head CI and post-merge `dev` CI are still outstanding. Later phases #4932, #5617 and the deferred PR/issue dispositions remain unstarted. diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index f10c36ba19..ffd0bff385 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -485,6 +485,47 @@ can still fail when a recorded session has no surviving rollout file. | `PUT, DELETE /api/custom-models/{id}` | Edit or delete one custom model | 400 invalid id/fields; 404 not found; 409 duplicate model | | `GET, PUT /api/selected-models` | Read provider allowlists and availability, or replace one allowlist | 400 missing provider/body; 404 unknown provider; PUT 409 `initial_model_selection_pending` | | `GET, PUT /api/model-presets` | Read preset summaries or choose preset/all/custom mode | 400 invalid mode or unsupported preset; 404 unknown provider; PUT 409 `initial_model_selection_pending` | +| `PUT /api/model-settings` | Edit one routed model's capability axes in place | 400 missing provider/modelId, unknown or native/combo provider, malformed field, unknown field, non-exact model id, or an invalid default effort for the resulting ladder; 500 when the config could not be saved (live config left unchanged) | + +`PUT /api/model-settings` takes `{ provider, modelId }` plus any of `contextWindow`, +`inputModalities`, `reasoningEfforts`, and `defaultReasoningEffort`. It writes the +provider-level per-model maps the runtime already reads (`modelContextWindows`, +`modelCapabilities..inputModalities`, `modelReasoningEfforts`, +`modelDefaultReasoningEfforts`), so an edited row keeps its discovery provenance instead of +being replaced by a custom model. `inputModalities` accepts `text`, `image`, and `audio`; +`reasoningEfforts` must be a subset of the efforts the runtime knows. Unknown request fields +are rejected by name. `modelId` must be exact, without surrounding whitespace, control +characters, excessive length, or a reserved object-property name. `contextWindow` must be +`null` or a positive safe integer. + +Every field is optional, and `null` **clears** the declaration rather than writing a default, +which hands the fact back to the registry, the catalog, and the provider. An empty +`inputModalities` array also clears, while an empty `reasoningEfforts` array is stored as an +explicit "this model has no reasoning rungs" override — it does not clear. +Clearing `inputModalities` also removes the exact legacy `modelInputModalities` entry for that +model, so a restore cannot leave an older declaration in force; family keys shared with other +models stay. An emptied per-model map is removed instead of left as `{}`. A request that changes nothing +answers `changed: false` with the stored state. The receipt also reports `saved` (whether this +request published config), `hasOverrides` (whether any of the four axes remains stored), and +`catalogRefresh` (`committed`, `skipped`, or `failed`). These fields distinguish a no-op with +stored declarations from a model with nothing to restore. A changed request persists atomically; +an unpublished save failure leaves the live config unchanged. After publication the server +drops that provider's cached `/models` result and reports the catalog outcome. A published write +followed by a bookkeeping error still returns `saved: true` with a skipped refresh. The gather +bakes resolved hints into the rows it caches, and a cache hit may only lower a configured window, +so a raised or cleared override would otherwise read back the previous answer for the whole TTL. +Only routed providers are addressable: `openai` is the +native passthrough lane and `combo` is a synthetic row, so neither is a provider whose per-model +maps these facts come from. Display name is not part of this route; use +`PUT /api/providers/{provider}/model-display-names`. + +`GET /api/models` reports the exact stored `contextWindowDeclared` separately from the +effective `contextWindow`. The Models editor starts from the declaration and shows the effective +window as an inherited hint when no declaration exists. If a save outcome is unknown, or the +save succeeded but the model list did not reload, the dialog disables further writes and offers +a read-only Reload. After a successful reload, reopen the row to inspect the stored values. +A `catalogRefresh` that failed, or was skipped with `retryable: true`, leaves the save in place and +shows a warning to run Sync; a non-retryable skip means no managed Codex catalog and is a clean save. A manual model replaces the Models dashboard row with the same provider and model ID. For OpenAI, the manual row keeps `openai/` and supports the same visibility controls diff --git a/gui/src/components/ModelSettingsDialog.tsx b/gui/src/components/ModelSettingsDialog.tsx new file mode 100644 index 0000000000..09a9bd6d26 --- /dev/null +++ b/gui/src/components/ModelSettingsDialog.tsx @@ -0,0 +1,460 @@ +import { useEffect, useId, useRef, useState } from "react"; +import { confirmAction } from "../action-dialogs"; +import { createBoundedFetch, type BoundedFetch } from "../bounded-fetch"; +import { readJsonOrThrow } from "../fetch-json"; +import { useT, type TKey } from "../i18n/shared"; +import { Notice, Select } from "../ui"; +import { CUSTOM_OPTION, REASONING_EFFORT_LEVELS, type ModelRow } from "../pages/models-shared"; + +/** + * The per-model overrides on ONE routed row, in the shape the row already reports them. + * + * Three axes, and no fourth. Display name is absent on purpose: the row has its own editor for + * it, with its own provenance display, and a second control writing the same key is two + * statements that can drift apart. This dialog owns what nothing else edits — the context + * window, the modality declaration, and the reasoning ladder — so "restore" means "clear the + * capability overrides", which is a promise it can keep. + * + * Every field is optional and clearing one is a distinct intent from writing a value: an empty + * window sends `null`, which hands the fact back to the registry, the catalog and the provider + * default instead of pinning today's answer as an override that outlives its source. + */ + +/** The modalities the API accepts. `video` is absent: the wire carries text and image parts. */ +const MODALITIES = ["text", "image", "audio"] as const; + +const CONTEXT_PRESETS = ["100000", "128000", "200000", "256000", "352000", "500000", "1000000"]; + +const REQUEST_TIMEOUT_MS = 60_000; + +type Phase = "ready" | "saving" | "restoring" | "unknown" | "saved-stale"; + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +/** What the row reported when the dialog opened: the form's start, and the save diff's baseline. */ +interface SettingsBaseline { + contextWindow: string; + modalities: string[]; + reasoning: boolean; + ladder: string[]; + defaultEffort: string; +} + +function baselineOf(row: ModelRow): SettingsBaseline { + const reasoning = row.reasoningOverridden === true; + return { + contextWindow: row.contextWindowDeclared !== undefined ? String(row.contextWindowDeclared) : "", + // The DECLARATION, never the row's own `inputModalities`. That one is the catalog value: + // pre-filling from it made an undeclared model look declared, and because an untouched field + // is not submitted, every save came back as "nothing changed" and the row never moved. + modalities: Array.isArray(row.inputModalitiesDeclared) ? [...row.inputModalitiesDeclared] : [], + reasoning, + // The ladder only counts as the operator's when the config really overrides it: OpenCodex + // writes the registry ladder into the config itself, so key presence would tick this box on + // every model that merely inherits its levels. + ladder: reasoning && Array.isArray(row.reasoningEfforts) ? [...row.reasoningEfforts] : [], + defaultEffort: reasoning ? (row.defaultReasoningEffort ?? "") : "", + }; +} + +function sortedKey(values: readonly string[]): string { + return values.toSorted().join(","); +} + +/** undefined when the draft is not a usable window, null when the operator cleared it. */ +function parseContextWindow(raw: string): number | null | undefined { + const trimmed = raw.trim(); + if (!trimmed) return null; + const value = Number(trimmed.replace(/[_,\s]/g, "")); + return Number.isSafeInteger(value) && value > 0 ? value : undefined; +} + +interface ModelSettingsDialogProps { + row: ModelRow; + apiBase: string; + /** Reload the catalog. Resolves false when the reload failed, so the dialog can say so. */ + onRefresh: (signal: AbortSignal) => Promise; + onFeedback: (ok: boolean, message: string) => void; + onClose: () => void; +} + +export default function ModelSettingsDialog({ row, apiBase, onRefresh, onFeedback, onClose }: ModelSettingsDialogProps) { + const t = useT(); + const id = useId(); + const dialogRef = useRef(null); + const reloadRef = useRef(null); + const requestRef = useRef(null); + // State rather than a ref: the form's start values are read during render to seed the fields, + // and a ref read in render is exactly what the React Compiler lint refuses. + const [baseline] = useState(() => baselineOf(row)); + const [phase, setPhase] = useState("ready"); + const [contextDraft, setContextDraft] = useState(() => baseline.contextWindow); + const [customContext, setCustomContext] = useState(() => ( + baseline.contextWindow !== "" && !CONTEXT_PRESETS.includes(baseline.contextWindow) + )); + const [modalities, setModalities] = useState(() => [...baseline.modalities]); + const [reasoning, setReasoning] = useState(() => baseline.reasoning); + const [ladder, setLadder] = useState(() => [...baseline.ladder]); + const [defaultEffort, setDefaultEffort] = useState(() => baseline.defaultEffort); + const [errorKey, setErrorKey] = useState(null); + const busy = phase === "saving" || phase === "restoring"; + const terminal = phase === "unknown" || phase === "saved-stale"; + + useEffect(() => { + const dialog = dialogRef.current; + const opener = document.activeElement as HTMLElement | null; + if (dialog && !dialog.open) dialog.showModal(); + return () => { if (dialog?.open) dialog.close(); if (opener?.isConnected && typeof opener.focus === "function") opener.focus(); }; + }, []); + + useEffect(() => { + if (terminal) reloadRef.current?.focus(); + }, [terminal]); + + const reload = async () => { + const bounded = createBoundedFetch(REQUEST_TIMEOUT_MS); + requestRef.current = bounded; + try { + if (!await onRefresh(bounded.signal)) return; + bounded.signal.throwIfAborted(); + if (requestRef.current !== bounded) return; + onFeedback(true, t("models.settingsReloaded", { model: row.namespaced })); + onClose(); + } catch { + // Keep the terminal state: the operator still needs a successful read before another write. + } finally { + bounded.clear(); + if (requestRef.current === bounded) requestRef.current = null; + } + }; + + const toggleReasoning = (next: boolean) => { + setReasoning(next); + if (!next) return; + // First enable seeds from what the model advertises rather than the full shared ladder: a + // provider that accepts only a subset would otherwise be handed levels its API rejects. + if (ladder.length > 0) return; + const advertised = Array.isArray(row.reasoningEfforts) ? row.reasoningEfforts : undefined; + setLadder(advertised && advertised.length > 0 ? [...advertised] : [...REASONING_EFFORT_LEVELS]); + }; + + const toggleLevel = (level: string, on: boolean) => { + setLadder(prev => (on ? [...prev, level] : prev.filter(value => value !== level))); + // The default has to stay inside the ladder, so dropping its level drops it too. + if (!on && defaultEffort === level) setDefaultEffort(""); + }; + + const requestClose = () => { + if (!busy) onClose(); + }; + + const send = async (patch: Record, restoring: boolean) => { + const bounded = createBoundedFetch(REQUEST_TIMEOUT_MS); + requestRef.current = bounded; + setPhase(restoring ? "restoring" : "saving"); + setErrorKey(null); + let confirmed = false; + let rejected = false; + try { + const response = await fetch(apiBase + "/api/model-settings", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ provider: row.provider, modelId: row.id, ...patch }), + signal: bounded.signal, + }); + // A 4xx is answered before anything is written, so the outcome is known: nothing saved. + // The form stays editable, and the server's untranslated text is not shown. + if (response.status >= 400 && response.status < 500) { + rejected = true; + throw new Error("model settings rejected"); + } + const result = await readJsonOrThrow(response); + bounded.signal.throwIfAborted(); + // Validate the receipt against the request: a 200 for another row would otherwise be + // reported as this row's save, and the reload would appear to disagree with the toast. + if (!isRecord(result) || result.ok !== true || result.provider !== row.provider || result.modelId !== row.id + || typeof result.changed !== "boolean" || typeof result.hasOverrides !== "boolean" + || typeof result.saved !== "boolean" || result.saved !== result.changed || !isRecord(result.catalogRefresh) + || !["committed", "skipped", "failed"].includes(String(result.catalogRefresh.status))) { + throw new Error("invalid model-settings receipt"); + } + if (requestRef.current !== bounded) return; + confirmed = true; + if (!result.changed) { + onFeedback(true, t(restoring && !result.hasOverrides ? "models.settingsNothingToRestore" : "models.settingsNoChange", { model: row.namespaced })); + onClose(); + return; + } + // `catalogRefresh` describes the Codex app's catalog, not this list: the dashboard reads the + // saved config directly. A skip the operator cannot act on (no managed catalog) is normal; + // only a failed or retryable refresh is worth a warning, and only after the list reloads. + const refresh = result.catalogRefresh; + const codexStale = refresh.status === "failed" || (refresh.status === "skipped" && refresh.retryable === true); + if (!await onRefresh(bounded.signal)) throw new Error("catalog refresh failed"); + bounded.signal.throwIfAborted(); + if (requestRef.current !== bounded) return; + if (codexStale) { + onFeedback(false, t("models.settingsSavedCodexStale", { model: row.namespaced })); + } else if (restoring) { + onFeedback(true, t("models.settingsRestored", { model: row.namespaced })); + } else { + onFeedback(true, t("models.settingsSaved", { model: row.namespaced })); + } + onClose(); + } catch { + if (requestRef.current !== bounded) return; + if (rejected) { + setPhase("ready"); + setErrorKey("models.settingsRejected"); + return; + } + setPhase(confirmed ? "saved-stale" : "unknown"); + setErrorKey(confirmed ? "models.settingsRefreshFailed" : "models.settingsSaveFailed"); + } finally { + bounded.clear(); + if (requestRef.current === bounded) requestRef.current = null; + } + }; + + const submit = () => { + if (phase !== "ready") return; + const parsedWindow = parseContextWindow(contextDraft); + if (parsedWindow === undefined) { setErrorKey("models.contextInvalid"); return; } + const base = baseline; + const patch: Record = {}; + if (contextDraft.trim() !== base.contextWindow) patch.contextWindow = parsedWindow; + if (sortedKey(modalities) !== sortedKey(base.modalities)) { + patch.inputModalities = modalities.length > 0 ? [...modalities] : null; + } + const ladderKey = reasoning ? sortedKey(ladder) : ""; + if (reasoning !== base.reasoning || ladderKey !== sortedKey(base.ladder)) { + patch.reasoningEfforts = reasoning ? [...ladder] : null; + } + const nextDefault = reasoning && ladder.includes(defaultEffort) ? defaultEffort : ""; + if (nextDefault !== base.defaultEffort) patch.defaultReasoningEffort = nextDefault || null; + // Only what the operator actually touched is submitted. Writing an untouched field would + // convert a fact the row merely inherits into a stored override, which is the failure this + // whole surface exists to avoid. + if (Object.keys(patch).length === 0) { onClose(); return; } + void send(patch, false); + }; + + const restore = async () => { + if (phase !== "ready") return; + const confirmed = await confirmAction({ + message: t("models.settingsRestoreConfirm", { model: row.namespaced }), + confirmLabel: t("models.settingsRestore"), + tone: "danger", + }); + if (!confirmed) return; + // Every axis at once, and it writes immediately rather than resetting the form: a form-level + // reset would still need a save, and that save would re-pin the values it had just restored. + void send({ contextWindow: null, inputModalities: null, reasoningEfforts: null, defaultReasoningEffort: null }, true); + }; + + const inheritedModalities = Array.isArray(row.inputModalities) ? row.inputModalities.join(", ") : ""; + const inheritedModalityList = Array.isArray(row.inputModalities) + ? row.inputModalities.filter((value): value is string => (MODALITIES as readonly string[]).includes(value)) + : []; + + return ( + { event.preventDefault(); requestClose(); }} + > + + + + {errorKey && {t(errorKey)}} + +
+
+ {t("models.customFieldModelId")} + {row.namespaced} +
+ + + +
+ {t("models.customFieldModalities")} +
+ {MODALITIES.map(modality => ( + + ))} +
+ {modalities.length === 0 && ( + // No box ticked is a real state — undeclared — not an empty model. Say what is + // being followed instead, or the blank row reads as "this model accepts nothing". + + {t("models.settingsModalitiesInherit", { + modalities: inheritedModalities || t("models.settingsModalitiesUnknown"), + })} + + )} +
+ +
+ {t("models.customFieldReasoning")} +
+ +
+ {reasoning && ( +
+ {REASONING_EFFORT_LEVELS.map(level => ( + + ))} +
+ )} +
+ + {reasoning && ( +