From 9a16319cdbcc220b618f75d3184f559bd82caed0 Mon Sep 17 00:00:00 2001 From: ljluestc <63439129+ljluestc@users.noreply.github.com> Date: Sat, 24 Oct 2026 23:03:31 -0700 Subject: [PATCH 1/6] feat(claude-desktop): per-client model visibility for the Desktop list Claude Desktop's gateway list carried every available model, and the only dashboard levers (tier moves, family defaults) did not hide anything. Add claudeCode.desktopModelVisibility, an include or exclude list of routes that narrows only the Desktop 3P inferenceModels list. - The generator filters emitted rows by route after building the full alias registry, so a running session holding a just-hidden alias still routes. A hidden family default hands its role to a remaining member; a selection that would empty the list falls back to the full list. - Every writer reads it through desktop3pModelOptions: local apply, catalog refresh, and the hub's Desktop export. - PUT /api/claude-desktop accepts visibility (omit keeps, null clears) and refuses a selection with no visible available model. GET returns it with per-model visible flags and the list the next apply writes; status returns the applied list. - Dashboard: per-model checkboxes, search, provider filter, bulk select, new-model policy, selected/available/applied counts, an added/removed preview, and a "saved, not applied" status. The two status strings that read as contradictory now name their scope. - Invalid hand-edited values fail validation at their own path and salvage drops only that field. Closes #7702 --- .../src/content/docs/guides/claude-code.md | 21 ++ .../src/content/docs/guides/web-dashboard.md | 2 +- .../docs/reference/configuration/server.md | 1 + .../content/docs/reference/management-api.md | 4 +- .../components/ClaudeDesktopVisibility.tsx | 172 +++++++++++++++ gui/src/i18n/de.ts | 28 ++- gui/src/i18n/en.ts | 28 ++- gui/src/i18n/fr.ts | 28 ++- gui/src/i18n/ja.ts | 28 ++- gui/src/i18n/ko.ts | 28 ++- gui/src/i18n/pt.ts | 28 ++- gui/src/i18n/ru.ts | 28 ++- gui/src/i18n/tr.ts | 28 ++- gui/src/i18n/vi.ts | 28 ++- gui/src/i18n/zh-TW.ts | 28 ++- gui/src/i18n/zh.ts | 28 ++- gui/src/pages/ClaudeDesktop.tsx | 92 +++++--- gui/src/pages/claude-desktop-visibility.ts | 157 ++++++++++++++ gui/src/styles/claude-page.css | 54 +++++ .../claude-desktop-visibility-card.test.tsx | 148 +++++++++++++ gui/tests/claude-desktop-visibility.test.ts | 66 ++++++ scripts/test-layout/layout.json | 2 +- src/claude/desktop-3p.ts | 33 ++- src/claude/desktop-model-visibility.ts | 141 ++++++++++++ src/config/salvage.ts | 1 + src/config/schema/config-schema.ts | 14 +- .../management/agent-settings-routes.ts | 16 +- src/server/management/shared.ts | 16 +- src/types.ts | 1 + src/types/config.ts | 12 ++ structure/INDEX.md | 5 +- .../clients/claude-desktop-config-reads.md | 3 +- .../claude-desktop-model-visibility.md | 54 +++++ structure/manifest.json | 10 + .../claude-desktop-model-visibility.test.ts | 200 ++++++++++++++++++ tests/fixtures/test-layout-expected.json | 2 +- 36 files changed, 1452 insertions(+), 83 deletions(-) create mode 100644 gui/src/components/ClaudeDesktopVisibility.tsx create mode 100644 gui/src/pages/claude-desktop-visibility.ts create mode 100644 gui/tests/claude-desktop-visibility-card.test.tsx create mode 100644 gui/tests/claude-desktop-visibility.test.ts create mode 100644 src/claude/desktop-model-visibility.ts create mode 100644 structure/clients/claude-desktop-model-visibility.md create mode 100644 tests/claude-integration/claude-desktop-model-visibility.test.ts diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index 4a815b3c6d1..9ba9eb2295d 100644 --- a/docs-site/src/content/docs/guides/claude-code.md +++ b/docs-site/src/content/docs/guides/claude-code.md @@ -286,6 +286,27 @@ backup and replacement; if it detects a concurrent edit, it refuses with `deskto Review the edit and reapply. If restoring the profile also fails after a metadata error, the reported reason includes both failures; reconcile the profile and metadata before retrying. +#### Choose which models Claude Desktop lists + +**Connect → Claude Desktop → Desktop model list** has a checkbox for each model. Search by name or +filter by provider, then use **Select shown** or **Clear shown** to change many rows at once. Click +**Save & apply** to write the list to Desktop. Only Claude Desktop's list changes: provider model +settings, Claude Code, Codex and other clients keep every model. + +**Newly discovered models** sets what happens when a provider later adds a model. **List +automatically** adds it to Desktop. **Keep hidden until selected** leaves it out until you check it. +The selection is saved in `claudeCode.desktopModelVisibility` and survives catalog refreshes and +reapplies. Tier families under **Advanced** stay optional and do not affect which models are shown. +A hidden model keeps its Desktop id, so a conversation that is already using it keeps working. + +The card shows the selected, available and applied counts separately. A selected model that is no +longer available is counted on its own and is not written. Before you apply, the card previews the +models Desktop will gain and lose. The status bar shows **Saved — apply to update Desktop** until +the saved list reaches Desktop. Applying replaces the model list in OpenCodex's Desktop profile, +including a list edited there by hand. Discovery-only mode (`--discovery-only`) writes no list, so +it does not use this selection. The dashboard will not save a selection that hides every +available model. + ### First-party (opt-in) :::caution[Account risk] diff --git a/docs-site/src/content/docs/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md index 6cdb10f4d2f..1179d6dda00 100644 --- a/docs-site/src/content/docs/guides/web-dashboard.md +++ b/docs-site/src/content/docs/guides/web-dashboard.md @@ -475,7 +475,7 @@ Usage heatmap days have one Tab entry point. Use Up/Down for adjacent days and L shows **No changes** or **Unsaved changes**, with **Revert** to discard edits and **Save** to commit them. Both switches apply immediately; **Save** never changes them. When interception is stopped, the page shows why and offers **Start interception**, which starts it in place without restarting OpenCodex. The former Code, Settings, and Account sub-tabs are gone; everything the read-only Settings view showed except the intercept port number is on this page. -**Claude Desktop** is its own Connect tab at `#claude/desktop`. It contains the Desktop mode picker and model families. One status row shows whether Claude Desktop runs the profile and whether it is saved, with **Save** and **Save & apply** beside it. +**Claude Desktop** is its own Connect tab at `#claude/desktop`. It contains the Desktop mode picker, a per-model list of what Claude Desktop shows (see [Choose which models Claude Desktop lists](/guides/claude-code/#choose-which-models-claude-desktop-lists)), and model families under **Advanced**. One status row shows whether Claude Desktop runs the profile, with a separate chip for whether dashboard edits are saved, and **Save** and **Save & apply** beside it. Claude/Anthropic accounts are managed only on **Providers > Anthropic > Accounts**: login and the browser option, the account roster with switch, pause, remove, and reauthentication, the paste-code field, account pool settings, and quota. Claude no longer has an Account sub-tab. Provider-level controls such as the default provider, removal, and the enabled switch stay on Providers. diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index ae044e3faa9..6dac47acac7 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -675,6 +675,7 @@ These settings govern `/v1/messages`, `/v1/messages/count_tokens`, the `ocx clau | `claudeCode.authModeMigratedAt?` | `string` | unset | Internal one-time upgrade marker. Do not set manually. | | `claudeCode.classifierModel?` | `string` | unset | Explicit target for Claude Code Auto Mode classifier turns, as a qualified `provider/model` (for example `RelayA/claude-opus-5`). Auto Mode sends bare safety checks such as `claude-opus-5` with no provider, so without this they fall through to `defaultProvider` — which may not speak Anthropic at all. Nothing is inferred automatically: only a target you declare here is used. | | `claudeCode.classifierFallbacks?` | `string[]` | unset | Ordered classifier targets used when `classifierModel` is not set. Same qualified `provider/model` form; the first usable entry wins. An explicit `modelMap` entry for the classifier model still outranks both. | +| `claudeCode.desktopModelVisibility?` | `{ mode: "include" \| "exclude"; routes: string[] }` | unset | Which `provider/model` routes the Claude Desktop gateway list carries. `include` lists only `routes` (new models stay hidden); `exclude` lists all but `routes` (new models appear). Unset lists every available model. Affects only Claude Desktop's static and hybrid lists; set it from **Connect → Claude Desktop**. | | `claudeCode.subagentEffort?` | `"low" \| "medium" \| "high" \| "xhigh" \| "max"` | inherit | Effort written to generated `~/.claude/agents/ocx-*.md`; separate from Codex guidance and proxy caps. Restart through `ocx claude` to regenerate. | The compatibility policy applies to Claude Code, Desktop and other clients using translated diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index 6c810c21dac..041d1753d07 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -86,12 +86,12 @@ route-specific results rather than repeating this table. | `POST /api/grok/reset-coupons/consume` | Redeem an eligible reset coupon. Body `{ accountId?, tokenId?, operationId? }`. Optional `operationId` (UUIDv4) makes redemption idempotent: repeating the same ID replays the durable result without double-redemption. | 400 invalid JSON/UUID; 401 unauthenticated; 409 `operation_id_owned_by_another_account` / `coupon_unavailable` / `operation_token_mismatch` / `operation_state_changed` / `attempt_in_progress` / `attempt_unresolved`; 502 upstream error; 503 ledger capacity / `ledger_unavailable`; 500 `attempt_mark_failed` | | `GET /api/anthropic/reset-grants?accountId=...` | Read the Claude usage-limit reset grants of one Anthropic OAuth account: eligibility, each grant's resets left, validity window, and the windows it clears, plus any unconfirmed attempt still retryable | 400 no matching account; 401 re-authentication needed; 502 upstream unavailable | | `POST /api/anthropic/reset-grants/consume` | Spend one reset grant. Body `{ accountId, grantId, operationId }`; `operationId` is a UUIDv4 sent upstream as the request ID, so repeating it retries the same claim. Requires a dashboard session. A settled `reset` lifts the account's local cooldown only when it is a quota refusal recorded before the claim was first journaled and the windows the grant reported cleared cover every window that refusal named (both windows when it named none). A refusal recorded while the claim was in flight, an entitlement refusal, or an uncovered window stays, including on a same-id replay. The cached quota is dropped and usage is reread with a fresh request, so the pool can select the account before the old deadline. Model-family exclusions stay until that reread proves a family is no longer rejected. | 400 invalid body; 401 re-authentication needed; 403 `session_required`; 409 `grant_not_usable`, `in_flight`, `unresolved_prior_operation`, `unknown_outcome_expired`, `operation_identity_mismatch`; 500 `journal_write_failed`; 502 `unknown_outcome`; 503 journal busy, unavailable, or full | -| `GET, PUT /api/claude-desktop` | Read or persist the Claude Desktop routed/native profile | 400 invalid or unavailable assignment | +| `GET, PUT /api/claude-desktop` | Read or persist the Claude Desktop routed/native profile and its model visibility (`visibility`: omit to keep, `null` to clear) | 400 invalid or unavailable assignment, invalid visibility, or a visibility that hides every available model | | `POST /api/claude-desktop/apply` | Write the saved profile to Claude Desktop's managed config | 400/500 write failure | | `GET, POST /api/codex/desktop-compatibility/certificate` | Inspect or explicitly prepare Windows compatibility certificate trust | 403 local dashboard required for POST; 409 stale, busy, or incomplete state | | `GET, POST /api/codex/desktop-compatibility/runtime` | Inspect or explicitly start, stop, launch, or run a bounded native compatibility trial | 403 local dashboard required for POST; 409 unsupported build, untrusted certificate, or incomplete state | | `GET, POST /api/codex/desktop-compatibility/settings` | Read or save the next-start observation preference with its current revision | 403 local dashboard required for POST; 409 stale, invalid, or unconfirmed configuration | -| `GET /api/claude-desktop/status` | Inspect saved-versus-applied profile and Desktop health | 400 status read failure | +| `GET /api/claude-desktop/status` | Inspect saved-versus-applied profile, the applied model list (`appliedModels`), and Desktop health | 400 status read failure | | `GET, PUT /api/claude-code` | Read or update Claude Code gateway, auth-mode, model-map, context, agent, and sidecar settings | 400 invalid field or shape | `tokenId`, when supplied, must be a non-empty string; invalid values return `400 invalid_token_id` before credentials are read or the ledger is opened. Within the 30-day retention window, a same-`operationId` retry that omits `accountId` uses the recorded account, even if the active account changed. An executable `open` retry that omits `tokenId` uses its recorded token. An explicit different account or token is still refused. A definitive failure clears a matching speculative pending hold in the dashboard, allowing a new explicitly confirmed attempt; actual unknown outcomes remain held. diff --git a/gui/src/components/ClaudeDesktopVisibility.tsx b/gui/src/components/ClaudeDesktopVisibility.tsx new file mode 100644 index 00000000000..a066a11727d --- /dev/null +++ b/gui/src/components/ClaudeDesktopVisibility.tsx @@ -0,0 +1,172 @@ +import { useMemo, useState, type ReactNode } from "react"; +import { useI18n } from "../i18n/shared"; +import { Notice } from "../ui"; +import { LANE_PAGE } from "../pages/claude-desktop-lane"; +import { + applyPreview, + desiredDesktopList, + filterVisibilityModels, + providerOf, + providersOf, + routeVisible, + setNewModelPolicy, + setRoutesVisible, + visibilityCounts, + type AppliedDesktopModel, + type DesktopVisibility, + type ListedDesktopModel, + type VisibilityModel, +} from "../pages/claude-desktop-visibility"; + +/** How many labels the apply preview names before summarizing the rest as a count. */ +const PREVIEW_NAMES = 6; + +function previewText(labels: string[]): string { + const shown = labels.slice(0, PREVIEW_NAMES).join(", "); + return labels.length > PREVIEW_NAMES ? `${shown} +${labels.length - PREVIEW_NAMES}` : shown; +} + +/** + * Per-model Claude Desktop visibility (#7702): which rows the Desktop 3P list carries. + * Separate from the tier lanes under Advanced; the parent owns the draft and saves it with + * the profile, so Save and Save & apply cover both. + */ +export default function ClaudeDesktopVisibility({ + models, + visibility, + onChange, + rendered, + appliedModels, + disabled, + badges, +}: { + /** Rows in display order (the page puts the role holders first). */ + models: readonly T[]; + visibility: DesktopVisibility | null; + onChange: (next: DesktopVisibility | null) => void; + /** The full list the next apply would write before visibility, in written order. */ + rendered: readonly ListedDesktopModel[]; + /** What Desktop runs now; null when there is no static list to compare against. */ + appliedModels: readonly AppliedDesktopModel[] | null; + disabled: boolean; + badges: (model: T) => ReactNode; +}) { + const { t } = useI18n(); + const [query, setQuery] = useState(""); + const [provider, setProvider] = useState(""); + const [limit, setLimit] = useState(LANE_PAGE); + const providers = useMemo(() => providersOf(models), [models]); + const shown = useMemo(() => filterVisibilityModels(models, query, provider), [models, query, provider]); + const counts = visibilityCounts(models, visibility); + const preview = applyPreview(desiredDesktopList(rendered, visibility), appliedModels); + const shownRoutes = shown.map(model => model.route); + const policy = visibility?.mode ?? "exclude"; + + return ( +
+
+ {t("claudeDesktop.visibility.selected", { n: counts.selected })} + {t("claudeDesktop.visibility.available", { n: counts.available })} + + {appliedModels ? t("claudeDesktop.visibility.applied", { n: appliedModels.length }) : t("claudeDesktop.visibility.appliedUnknown")} + + {counts.selectedUnavailable > 0 && ( + {t("claudeDesktop.visibility.selectedUnavailable", { n: counts.selectedUnavailable })} + )} +
+ +
+ {t("claudeDesktop.visibility.newModels")} + {(["exclude", "include"] as const).map(mode => ( + + ))} +
+ +
+ { setQuery(event.target.value); setLimit(LANE_PAGE); }} + /> + + + +
+ + {counts.selected === 0 && counts.available > 0 && {t("claudeDesktop.visibility.noneSelected")}} + + {shown.length === 0 ? ( +

{t("claudeDesktop.visibility.noMatch")}

+ ) : ( + + )} + {shown.length > limit && ( + + )} + + {preview && ( +
+ {t("claudeDesktop.visibility.previewTitle")} + {preview.added.length === 0 && preview.removed.length === 0 + ? {t("claudeDesktop.visibility.previewSame")} + : ( + <> + {preview.added.length > 0 && {t("claudeDesktop.visibility.previewAdds", { models: previewText(preview.added) })}} + {preview.removed.length > 0 && {t("claudeDesktop.visibility.previewRemoves", { models: previewText(preview.removed) })}} + + )} +
+ )} +

{t("claudeDesktop.visibility.replaceNote")}

+
+ ); +} diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 59e943eddbe..360231cbc19 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -3399,7 +3399,7 @@ export const de: Record = { "claudeDesktop.importFailed": "Import fehlgeschlagen. {error}", "claudeDesktop.moved": "{route} wurde nach {family} verschoben.", "claudeDesktop.unsaved": "Ungespeicherte Änderungen", - "claudeDesktop.upToDate": "Profil ist aktuell", + "claudeDesktop.upToDate": "Alle Änderungen gespeichert", "claudeDesktop.saving": "Speichert…", "claudeDesktop.applying": "Wird angewendet…", "claudeDesktop.saveApply": "Speichern & anwenden", @@ -3453,7 +3453,29 @@ export const de: Record = { "claudeDesktop.roles.unavailableOption": "{route} (nicht verfügbar)", "claudeDesktop.roles.gatewayScope": "Diese Auswahl gilt für den Gateway-Modus, der die Modellliste von Claude Desktop schreibt.", "claudeDesktop.roles.listTitle": "Desktop-Modellliste", - "claudeDesktop.roles.listHint": "Wird automatisch mit Modellen deiner verbundenen Anbieter gefüllt.", + "claudeDesktop.roles.listHint": "Wähle, welche Modelle Claude Desktop auflistet. Nur Claude Desktop ändert sich: andere Clients und Anbietereinstellungen behalten ihre Modelle.", + "claudeDesktop.status.pendingApply": "Gespeichert — anwenden, um Desktop zu aktualisieren", + "claudeDesktop.visibility.search": "Modelle suchen", + "claudeDesktop.visibility.provider": "Anbieter", + "claudeDesktop.visibility.allProviders": "Alle Anbieter", + "claudeDesktop.visibility.selectShown": "Angezeigte auswählen", + "claudeDesktop.visibility.clearShown": "Angezeigte abwählen", + "claudeDesktop.visibility.toggle": "{model} in Claude Desktop anzeigen", + "claudeDesktop.visibility.selected": "{n} ausgewählt", + "claudeDesktop.visibility.available": "{n} verfügbar", + "claudeDesktop.visibility.applied": "{n} angewendet", + "claudeDesktop.visibility.appliedUnknown": "Angewendete Liste unbekannt", + "claudeDesktop.visibility.selectedUnavailable": "{n} ausgewählt, aber nicht verfügbar", + "claudeDesktop.visibility.newModels": "Neu erkannte Modelle", + "claudeDesktop.visibility.newShow": "Automatisch auflisten", + "claudeDesktop.visibility.newHide": "Ausgeblendet lassen, bis ausgewählt", + "claudeDesktop.visibility.noneSelected": "Wähle mindestens ein verfügbares Modell.", + "claudeDesktop.visibility.noMatch": "Keine Modelle passen zu diesem Filter.", + "claudeDesktop.visibility.previewTitle": "Nächstes Anwenden", + "claudeDesktop.visibility.previewAdds": "Fügt hinzu: {models}", + "claudeDesktop.visibility.previewRemoves": "Entfernt: {models}", + "claudeDesktop.visibility.previewSame": "Desktop listet bereits genau diese Modelle.", + "claudeDesktop.visibility.replaceNote": "Anwenden ersetzt die Modellliste im Claude-Desktop-Profil von OpenCodex, auch eine dort von Hand bearbeitete Liste. Die Stufenzuordnung unter Erweitert bleibt optional. Der reine Discovery-Modus nutzt diese Auswahl nicht.", "claudeDesktop.roles.quickBadge": "Schnelle Aufgaben", "claudeDesktop.advanced.title": "Erweitert: Claude-Stufen zuweisen", "claudeDesktop.advanced.desc": "Wähle, welcher Claude-Stufe (Opus, Fable, Sonnet, Haiku) Claude Desktop jedes Modell zuordnet.", @@ -3468,7 +3490,7 @@ export const de: Record = { "claudeDesktop.moveTo": "Verschieben nach", "claudeDesktop.move": "Verschieben", "claudeDesktop.status.applied": "Auf Desktop angewendet", - "claudeDesktop.status.stale": "Konfiguration veraltet — erneut anwenden", + "claudeDesktop.status.stale": "Desktop-Konfigurationsdatei wurde außerhalb von OpenCodex geändert — erneut anwenden", "claudeDesktop.status.notApplied": "Nicht angewendet", "claudeDesktop.status.notActiveProfile": "Desktop nutzt ein anderes Profil — erneut anwenden", "claudeDesktop.status.disabled": "Die Claude-Desktop-Integration ist deaktiviert. Beende Desktop nach dem Aktivieren vollständig und öffne es erneut.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 987b5a610dc..ee39bd64087 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -3513,7 +3513,7 @@ export const en = { "claudeDesktop.importFailed": "Import failed. {error}", "claudeDesktop.moved": "{route} moved to {family}.", "claudeDesktop.unsaved": "Unsaved changes", - "claudeDesktop.upToDate": "Profile is up to date", + "claudeDesktop.upToDate": "All changes saved", "claudeDesktop.saving": "Saving…", "claudeDesktop.applying": "Applying…", "claudeDesktop.saveApply": "Save & apply", @@ -3567,7 +3567,29 @@ export const en = { "claudeDesktop.roles.unavailableOption": "{route} (unavailable)", "claudeDesktop.roles.gatewayScope": "These choices apply to gateway mode, which writes Claude Desktop's model list.", "claudeDesktop.roles.listTitle": "Desktop model list", - "claudeDesktop.roles.listHint": "Filled automatically from your connected providers.", + "claudeDesktop.roles.listHint": "Choose which models Claude Desktop lists. Only Claude Desktop changes: other clients and provider settings keep their models.", + "claudeDesktop.status.pendingApply": "Saved — apply to update Desktop", + "claudeDesktop.visibility.search": "Search models", + "claudeDesktop.visibility.provider": "Provider", + "claudeDesktop.visibility.allProviders": "All providers", + "claudeDesktop.visibility.selectShown": "Select shown", + "claudeDesktop.visibility.clearShown": "Clear shown", + "claudeDesktop.visibility.toggle": "Show {model} in Claude Desktop", + "claudeDesktop.visibility.selected": "{n} selected", + "claudeDesktop.visibility.available": "{n} available", + "claudeDesktop.visibility.applied": "{n} applied", + "claudeDesktop.visibility.appliedUnknown": "Applied list unknown", + "claudeDesktop.visibility.selectedUnavailable": "{n} selected but unavailable", + "claudeDesktop.visibility.newModels": "Newly discovered models", + "claudeDesktop.visibility.newShow": "List automatically", + "claudeDesktop.visibility.newHide": "Keep hidden until selected", + "claudeDesktop.visibility.noneSelected": "Select at least one available model.", + "claudeDesktop.visibility.noMatch": "No models match this filter.", + "claudeDesktop.visibility.previewTitle": "Next apply", + "claudeDesktop.visibility.previewAdds": "Adds: {models}", + "claudeDesktop.visibility.previewRemoves": "Removes: {models}", + "claudeDesktop.visibility.previewSame": "Desktop already lists exactly these models.", + "claudeDesktop.visibility.replaceNote": "Applying replaces the model list in OpenCodex's Claude Desktop profile, including any list edited there by hand. Tier mapping under Advanced stays optional. Discovery-only mode does not use this selection.", "claudeDesktop.roles.quickBadge": "Quick tasks", "claudeDesktop.advanced.title": "Advanced: Claude tier assignment", "claudeDesktop.advanced.desc": "Choose which Claude tier (Opus, Fable, Sonnet, Haiku) Claude Desktop treats each model as.", @@ -3582,7 +3604,7 @@ export const en = { "claudeDesktop.moveTo": "Move to", "claudeDesktop.move": "Move", "claudeDesktop.status.applied": "Applied to Desktop", - "claudeDesktop.status.stale": "Config stale — re-apply", + "claudeDesktop.status.stale": "Desktop's config file changed outside OpenCodex — re-apply", "claudeDesktop.status.notApplied": "Not applied", "claudeDesktop.status.notActiveProfile": "Desktop is serving another profile — re-apply", "claudeDesktop.status.disabled": "Claude Desktop integration is off. Fully quit and reopen Desktop after enabling it.", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 87f0a4ce75c..3dfd8d76fb1 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -3426,7 +3426,7 @@ export const fr: Record = { "claudeDesktop.importFailed": "Échec de l’importation. {error}", "claudeDesktop.moved": "{route} déplacé vers {family}.", "claudeDesktop.unsaved": "Modifications non enregistrées", - "claudeDesktop.upToDate": "Le profil est à jour", + "claudeDesktop.upToDate": "Toutes les modifications sont enregistrées", "claudeDesktop.saving": "Enregistrement…", "claudeDesktop.applying": "Application…", "claudeDesktop.saveApply": "Enregistrer et appliquer", @@ -3480,7 +3480,29 @@ export const fr: Record = { "claudeDesktop.roles.unavailableOption": "{route} (indisponible)", "claudeDesktop.roles.gatewayScope": "Ces choix s’appliquent au mode passerelle, qui écrit la liste des modèles de Claude Desktop.", "claudeDesktop.roles.listTitle": "Liste des modèles Desktop", - "claudeDesktop.roles.listHint": "Remplie automatiquement à partir de vos fournisseurs connectés.", + "claudeDesktop.roles.listHint": "Choisissez les modèles listés par Claude Desktop. Seul Claude Desktop change : les autres clients et les réglages des fournisseurs gardent leurs modèles.", + "claudeDesktop.status.pendingApply": "Enregistré — appliquer pour mettre Desktop à jour", + "claudeDesktop.visibility.search": "Rechercher des modèles", + "claudeDesktop.visibility.provider": "Fournisseur", + "claudeDesktop.visibility.allProviders": "Tous les fournisseurs", + "claudeDesktop.visibility.selectShown": "Sélectionner l'affichage", + "claudeDesktop.visibility.clearShown": "Désélectionner l'affichage", + "claudeDesktop.visibility.toggle": "Afficher {model} dans Claude Desktop", + "claudeDesktop.visibility.selected": "{n} sélectionnés", + "claudeDesktop.visibility.available": "{n} disponibles", + "claudeDesktop.visibility.applied": "{n} appliqués", + "claudeDesktop.visibility.appliedUnknown": "Liste appliquée inconnue", + "claudeDesktop.visibility.selectedUnavailable": "{n} sélectionnés mais indisponibles", + "claudeDesktop.visibility.newModels": "Modèles découverts ensuite", + "claudeDesktop.visibility.newShow": "Lister automatiquement", + "claudeDesktop.visibility.newHide": "Masquer jusqu'à sélection", + "claudeDesktop.visibility.noneSelected": "Sélectionnez au moins un modèle disponible.", + "claudeDesktop.visibility.noMatch": "Aucun modèle ne correspond à ce filtre.", + "claudeDesktop.visibility.previewTitle": "Prochaine application", + "claudeDesktop.visibility.previewAdds": "Ajoute : {models}", + "claudeDesktop.visibility.previewRemoves": "Retire : {models}", + "claudeDesktop.visibility.previewSame": "Desktop liste déjà exactement ces modèles.", + "claudeDesktop.visibility.replaceNote": "Appliquer remplace la liste de modèles du profil Claude Desktop d'OpenCodex, y compris une liste modifiée à la main. Le mappage des niveaux dans Avancé reste facultatif. Le mode découverte seule n'utilise pas cette sélection.", "claudeDesktop.roles.quickBadge": "Tâches rapides", "claudeDesktop.advanced.title": "Avancé : attribution des niveaux Claude", "claudeDesktop.advanced.desc": "Choisissez à quel niveau Claude (Opus, Fable, Sonnet, Haiku) Claude Desktop associe chaque modèle.", @@ -3495,7 +3517,7 @@ export const fr: Record = { "claudeDesktop.moveTo": "Déplacer vers", "claudeDesktop.move": "Déplacer", "claudeDesktop.status.applied": "Appliqué à Desktop", - "claudeDesktop.status.stale": "Configuration obsolète — appliquer de nouveau", + "claudeDesktop.status.stale": "Le fichier de configuration de Desktop a été modifié hors d'OpenCodex — appliquer de nouveau", "claudeDesktop.status.notApplied": "Non appliqué", "claudeDesktop.status.notActiveProfile": "Desktop utilise un autre profil — appliquer de nouveau", "claudeDesktop.status.disabled": "L’intégration Claude Desktop est désactivée. Quittez complètement Desktop et rouvrez-le après l’avoir activée.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 9be16c80e47..5554a8fe83f 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -3108,7 +3108,7 @@ export const ja: Record = { "claudeDesktop.importFailed": "インポートに失敗しました。{error}", "claudeDesktop.moved": "{route} を {family} に移動しました。", "claudeDesktop.unsaved": "未保存の変更", - "claudeDesktop.upToDate": "プロファイルは最新です", + "claudeDesktop.upToDate": "すべての変更を保存済み", "claudeDesktop.saving": "保存中…", "claudeDesktop.applying": "適用中…", "claudeDesktop.saveApply": "保存して適用", @@ -3162,7 +3162,29 @@ export const ja: Record = { "claudeDesktop.roles.unavailableOption": "{route}(利用不可)", "claudeDesktop.roles.gatewayScope": "これらの選択は、Claude Desktop のモデル一覧を書き込むゲートウェイモードに適用されます。", "claudeDesktop.roles.listTitle": "Desktop モデル一覧", - "claudeDesktop.roles.listHint": "接続済みのプロバイダーから自動的に追加されます。", + "claudeDesktop.roles.listHint": "Claude Desktop に表示するモデルを選びます。変わるのは Claude Desktop だけで、他のクライアントやプロバイダー設定のモデルはそのままです。", + "claudeDesktop.status.pendingApply": "保存済み — 適用すると Desktop に反映されます", + "claudeDesktop.visibility.search": "モデルを検索", + "claudeDesktop.visibility.provider": "プロバイダー", + "claudeDesktop.visibility.allProviders": "すべてのプロバイダー", + "claudeDesktop.visibility.selectShown": "表示中を選択", + "claudeDesktop.visibility.clearShown": "表示中を解除", + "claudeDesktop.visibility.toggle": "{model} を Claude Desktop に表示", + "claudeDesktop.visibility.selected": "{n} 件選択", + "claudeDesktop.visibility.available": "{n} 件利用可能", + "claudeDesktop.visibility.applied": "{n} 件適用済み", + "claudeDesktop.visibility.appliedUnknown": "適用済みリスト不明", + "claudeDesktop.visibility.selectedUnavailable": "{n} 件選択済み・利用不可", + "claudeDesktop.visibility.newModels": "新しく見つかったモデル", + "claudeDesktop.visibility.newShow": "自動で表示", + "claudeDesktop.visibility.newHide": "選択するまで非表示", + "claudeDesktop.visibility.noneSelected": "利用可能なモデルを 1 つ以上選んでください。", + "claudeDesktop.visibility.noMatch": "このフィルターに一致するモデルはありません。", + "claudeDesktop.visibility.previewTitle": "次回の適用", + "claudeDesktop.visibility.previewAdds": "追加: {models}", + "claudeDesktop.visibility.previewRemoves": "削除: {models}", + "claudeDesktop.visibility.previewSame": "Desktop はすでにこれらのモデルだけを表示しています。", + "claudeDesktop.visibility.replaceNote": "適用すると OpenCodex の Claude Desktop プロファイルのモデルリストが置き換わります(手動で編集したリストも含む)。詳細設定のティア割り当ては任意です。ディスカバリー専用モードではこの選択は使われません。", "claudeDesktop.roles.quickBadge": "クイックタスク", "claudeDesktop.advanced.title": "詳細設定:Claude ティアの割り当て", "claudeDesktop.advanced.desc": "Claude Desktop が各モデルをどの Claude ティア(Opus、Fable、Sonnet、Haiku)として扱うかを選択します。", @@ -3177,7 +3199,7 @@ export const ja: Record = { "claudeDesktop.moveTo": "移動先", "claudeDesktop.move": "移動", "claudeDesktop.status.applied": "Desktop に適用済み", - "claudeDesktop.status.stale": "設定が古くなっています — 再適用してください", + "claudeDesktop.status.stale": "Desktop の設定ファイルが OpenCodex 外で変更されました — 再適用してください", "claudeDesktop.status.notApplied": "未適用", "claudeDesktop.status.notActiveProfile": "Desktop は別のプロファイルを使用中 — 再適用してください", "claudeDesktop.status.disabled": "Claude Desktop 連携はオフです。有効にした後、Desktop を完全に終了して再起動してください。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 355e8ea768f..386e1c7178b 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -3446,7 +3446,7 @@ export const ko: Record = { "claudeDesktop.importFailed": "가져오기에 실패했습니다. {error}", "claudeDesktop.moved": "{route} 모델을 {family}(으)로 옮겼습니다.", "claudeDesktop.unsaved": "저장하지 않은 변경 사항", - "claudeDesktop.upToDate": "프로필이 최신 상태입니다", + "claudeDesktop.upToDate": "모든 변경 사항 저장됨", "claudeDesktop.saving": "저장 중…", "claudeDesktop.applying": "적용 중…", "claudeDesktop.saveApply": "저장 및 적용", @@ -3500,7 +3500,29 @@ export const ko: Record = { "claudeDesktop.roles.unavailableOption": "{route} (사용 불가)", "claudeDesktop.roles.gatewayScope": "이 선택은 Claude Desktop 모델 목록을 쓰는 게이트웨이 모드에 적용됩니다.", "claudeDesktop.roles.listTitle": "Desktop 모델 목록", - "claudeDesktop.roles.listHint": "연결한 프로바이더에서 자동으로 채워집니다.", + "claudeDesktop.roles.listHint": "Claude Desktop에 표시할 모델을 고릅니다. Claude Desktop만 바뀌며, 다른 클라이언트와 프로바이더 설정의 모델은 그대로입니다.", + "claudeDesktop.status.pendingApply": "저장됨 — 적용하면 Desktop에 반영됩니다", + "claudeDesktop.visibility.search": "모델 검색", + "claudeDesktop.visibility.provider": "프로바이더", + "claudeDesktop.visibility.allProviders": "모든 프로바이더", + "claudeDesktop.visibility.selectShown": "표시된 항목 선택", + "claudeDesktop.visibility.clearShown": "표시된 항목 해제", + "claudeDesktop.visibility.toggle": "Claude Desktop에 {model} 표시", + "claudeDesktop.visibility.selected": "{n}개 선택", + "claudeDesktop.visibility.available": "{n}개 사용 가능", + "claudeDesktop.visibility.applied": "{n}개 적용됨", + "claudeDesktop.visibility.appliedUnknown": "적용된 목록 알 수 없음", + "claudeDesktop.visibility.selectedUnavailable": "{n}개 선택했지만 사용 불가", + "claudeDesktop.visibility.newModels": "새로 발견된 모델", + "claudeDesktop.visibility.newShow": "자동으로 표시", + "claudeDesktop.visibility.newHide": "선택할 때까지 숨김", + "claudeDesktop.visibility.noneSelected": "사용 가능한 모델을 하나 이상 선택하세요.", + "claudeDesktop.visibility.noMatch": "이 필터와 일치하는 모델이 없습니다.", + "claudeDesktop.visibility.previewTitle": "다음 적용", + "claudeDesktop.visibility.previewAdds": "추가: {models}", + "claudeDesktop.visibility.previewRemoves": "제거: {models}", + "claudeDesktop.visibility.previewSame": "Desktop이 이미 정확히 이 모델들을 표시합니다.", + "claudeDesktop.visibility.replaceNote": "적용하면 OpenCodex의 Claude Desktop 프로필 모델 목록이 교체되며, 직접 편집한 목록도 덮어씁니다. 고급의 티어 매핑은 선택 사항입니다. 디스커버리 전용 모드는 이 선택을 사용하지 않습니다.", "claudeDesktop.roles.quickBadge": "빠른 작업", "claudeDesktop.advanced.title": "고급: Claude 등급 배치", "claudeDesktop.advanced.desc": "Claude Desktop이 각 모델을 어느 Claude 등급(Opus, Fable, Sonnet, Haiku)으로 볼지 직접 정합니다.", @@ -3515,7 +3537,7 @@ export const ko: Record = { "claudeDesktop.moveTo": "이동 위치", "claudeDesktop.move": "이동", "claudeDesktop.status.applied": "Desktop에 적용됨", - "claudeDesktop.status.stale": "설정 변경됨 — 재적용 필요", + "claudeDesktop.status.stale": "Desktop 설정 파일이 OpenCodex 밖에서 변경됨 — 재적용 필요", "claudeDesktop.status.notApplied": "미적용", "claudeDesktop.status.notActiveProfile": "Desktop이 다른 프로필을 사용 중 — 재적용 필요", "claudeDesktop.status.disabled": "Claude Desktop 통합이 꺼져 있습니다. 켠 뒤 Desktop을 완전히 종료하고 다시 여세요.", diff --git a/gui/src/i18n/pt.ts b/gui/src/i18n/pt.ts index 3c1bc4f6be8..317d66765cf 100644 --- a/gui/src/i18n/pt.ts +++ b/gui/src/i18n/pt.ts @@ -3469,7 +3469,7 @@ export const pt: Record = { "claudeDesktop.importFailed": "Falha na importação. {error}", "claudeDesktop.moved": "{route} movido para {family}.", "claudeDesktop.unsaved": "Alterações não salvas", - "claudeDesktop.upToDate": "O perfil está atualizado", + "claudeDesktop.upToDate": "Todas as alterações salvas", "claudeDesktop.saving": "Salvando…", "claudeDesktop.applying": "Aplicando…", "claudeDesktop.saveApply": "Salvar e aplicar", @@ -3523,7 +3523,29 @@ export const pt: Record = { "claudeDesktop.roles.unavailableOption": "{route} (indisponível)", "claudeDesktop.roles.gatewayScope": "Essas escolhas se aplicam ao modo gateway, que grava a lista de modelos do Claude Desktop.", "claudeDesktop.roles.listTitle": "Lista de modelos Desktop", - "claudeDesktop.roles.listHint": "Preenchida automaticamente a partir dos seus provedores conectados.", + "claudeDesktop.roles.listHint": "Escolha quais modelos o Claude Desktop lista. Só o Claude Desktop muda: outros clientes e as configurações dos provedores mantêm seus modelos.", + "claudeDesktop.status.pendingApply": "Salvo — aplique para atualizar o Desktop", + "claudeDesktop.visibility.search": "Buscar modelos", + "claudeDesktop.visibility.provider": "Provedor", + "claudeDesktop.visibility.allProviders": "Todos os provedores", + "claudeDesktop.visibility.selectShown": "Selecionar exibidos", + "claudeDesktop.visibility.clearShown": "Limpar exibidos", + "claudeDesktop.visibility.toggle": "Mostrar {model} no Claude Desktop", + "claudeDesktop.visibility.selected": "{n} selecionados", + "claudeDesktop.visibility.available": "{n} disponíveis", + "claudeDesktop.visibility.applied": "{n} aplicados", + "claudeDesktop.visibility.appliedUnknown": "Lista aplicada desconhecida", + "claudeDesktop.visibility.selectedUnavailable": "{n} selecionados, mas indisponíveis", + "claudeDesktop.visibility.newModels": "Modelos descobertos depois", + "claudeDesktop.visibility.newShow": "Listar automaticamente", + "claudeDesktop.visibility.newHide": "Manter ocultos até selecionar", + "claudeDesktop.visibility.noneSelected": "Selecione pelo menos um modelo disponível.", + "claudeDesktop.visibility.noMatch": "Nenhum modelo corresponde a este filtro.", + "claudeDesktop.visibility.previewTitle": "Próxima aplicação", + "claudeDesktop.visibility.previewAdds": "Adiciona: {models}", + "claudeDesktop.visibility.previewRemoves": "Remove: {models}", + "claudeDesktop.visibility.previewSame": "O Desktop já lista exatamente estes modelos.", + "claudeDesktop.visibility.replaceNote": "Aplicar substitui a lista de modelos do perfil Claude Desktop do OpenCodex, inclusive uma lista editada à mão. O mapeamento de níveis em Avançado continua opcional. O modo somente descoberta não usa esta seleção.", "claudeDesktop.roles.quickBadge": "Tarefas rápidas", "claudeDesktop.advanced.title": "Avançado: atribuição de níveis Claude", "claudeDesktop.advanced.desc": "Escolha em qual nível Claude (Opus, Fable, Sonnet, Haiku) o Claude Desktop classifica cada modelo.", @@ -3538,7 +3560,7 @@ export const pt: Record = { "claudeDesktop.moveTo": "Mover para", "claudeDesktop.move": "Mover", "claudeDesktop.status.applied": "Aplicado ao Desktop", - "claudeDesktop.status.stale": "Configuração desatualizada — aplique novamente", + "claudeDesktop.status.stale": "O arquivo de configuração do Desktop mudou fora do OpenCodex — aplique novamente", "claudeDesktop.status.notApplied": "Não aplicado", "claudeDesktop.status.notActiveProfile": "O Desktop está usando outro perfil — aplique novamente", "claudeDesktop.status.disabled": "A integração com o Claude Desktop está desativada. Feche completamente e reabra o Desktop após ativá-la.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 5aa22ec95eb..2e3ec5f9c87 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -3198,7 +3198,7 @@ export const ru: Record = { "claudeDesktop.importFailed": "Импорт не удался. {error}", "claudeDesktop.moved": "{route} перемещён в {family}.", "claudeDesktop.unsaved": "Несохранённые изменения", - "claudeDesktop.upToDate": "Профиль актуален", + "claudeDesktop.upToDate": "Все изменения сохранены", "claudeDesktop.saving": "Сохранение…", "claudeDesktop.applying": "Применение…", "claudeDesktop.saveApply": "Сохранить и применить", @@ -3252,7 +3252,29 @@ export const ru: Record = { "claudeDesktop.roles.unavailableOption": "{route} (недоступно)", "claudeDesktop.roles.gatewayScope": "Эти настройки применяются в режиме шлюза, который записывает список моделей Claude Desktop.", "claudeDesktop.roles.listTitle": "Список моделей Desktop", - "claudeDesktop.roles.listHint": "Автоматически заполняется моделями подключённых провайдеров.", + "claudeDesktop.roles.listHint": "Выберите, какие модели показывает Claude Desktop. Меняется только Claude Desktop: другие клиенты и настройки провайдеров сохраняют свои модели.", + "claudeDesktop.status.pendingApply": "Сохранено — примените, чтобы обновить Desktop", + "claudeDesktop.visibility.search": "Поиск моделей", + "claudeDesktop.visibility.provider": "Провайдер", + "claudeDesktop.visibility.allProviders": "Все провайдеры", + "claudeDesktop.visibility.selectShown": "Выбрать показанные", + "claudeDesktop.visibility.clearShown": "Снять показанные", + "claudeDesktop.visibility.toggle": "Показывать {model} в Claude Desktop", + "claudeDesktop.visibility.selected": "Выбрано: {n}", + "claudeDesktop.visibility.available": "Доступно: {n}", + "claudeDesktop.visibility.applied": "Применено: {n}", + "claudeDesktop.visibility.appliedUnknown": "Применённый список неизвестен", + "claudeDesktop.visibility.selectedUnavailable": "Выбрано, но недоступно: {n}", + "claudeDesktop.visibility.newModels": "Новые найденные модели", + "claudeDesktop.visibility.newShow": "Показывать автоматически", + "claudeDesktop.visibility.newHide": "Скрывать, пока не выбраны", + "claudeDesktop.visibility.noneSelected": "Выберите хотя бы одну доступную модель.", + "claudeDesktop.visibility.noMatch": "Нет моделей, подходящих под фильтр.", + "claudeDesktop.visibility.previewTitle": "Следующее применение", + "claudeDesktop.visibility.previewAdds": "Добавит: {models}", + "claudeDesktop.visibility.previewRemoves": "Уберёт: {models}", + "claudeDesktop.visibility.previewSame": "Desktop уже показывает ровно эти модели.", + "claudeDesktop.visibility.replaceNote": "Применение заменяет список моделей в профиле Claude Desktop от OpenCodex, включая список, отредактированный вручную. Привязка уровней в разделе «Дополнительно» необязательна. Режим только обнаружения не использует этот выбор.", "claudeDesktop.roles.quickBadge": "Быстрые задачи", "claudeDesktop.advanced.title": "Дополнительно: назначение уровней Claude", "claudeDesktop.advanced.desc": "Выберите, к какому уровню Claude (Opus, Fable, Sonnet, Haiku) Claude Desktop относит каждую модель.", @@ -3267,7 +3289,7 @@ export const ru: Record = { "claudeDesktop.moveTo": "Переместить в", "claudeDesktop.move": "Переместить", "claudeDesktop.status.applied": "Применено к Desktop", - "claudeDesktop.status.stale": "Конфигурация устарела — примените заново", + "claudeDesktop.status.stale": "Файл конфигурации Desktop изменён вне OpenCodex — примените заново", "claudeDesktop.status.notApplied": "Не применено", "claudeDesktop.status.notActiveProfile": "Desktop использует другой профиль — примените заново", "claudeDesktop.status.disabled": "Интеграция Claude Desktop отключена. После включения полностью закройте и снова откройте Desktop.", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index a6653056a52..2f6601c78ee 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -3449,7 +3449,7 @@ export const tr: Record = { "claudeDesktop.importFailed": "İçe aktarma başarısız oldu. {error}", "claudeDesktop.moved": "{route}, {family} ailesine taşındı.", "claudeDesktop.unsaved": "Kaydedilmemiş değişiklikler", - "claudeDesktop.upToDate": "Profil güncel", + "claudeDesktop.upToDate": "Tüm değişiklikler kaydedildi", "claudeDesktop.saving": "Kaydediliyor…", "claudeDesktop.applying": "Uygulanıyor…", "claudeDesktop.saveApply": "Kaydet & uygula", @@ -3503,7 +3503,29 @@ export const tr: Record = { "claudeDesktop.roles.unavailableOption": "{route} (kullanılamıyor)", "claudeDesktop.roles.gatewayScope": "Bu seçimler, Claude Desktop’ın model listesini yazan ağ geçidi modunda geçerlidir.", "claudeDesktop.roles.listTitle": "Desktop model listesi", - "claudeDesktop.roles.listHint": "Bağlı sağlayıcılarınızdaki modellerle otomatik olarak doldurulur.", + "claudeDesktop.roles.listHint": "Claude Desktop'un listeleyeceği modelleri seçin. Yalnızca Claude Desktop değişir: diğer istemciler ve sağlayıcı ayarları modellerini korur.", + "claudeDesktop.status.pendingApply": "Kaydedildi — Desktop'u güncellemek için uygulayın", + "claudeDesktop.visibility.search": "Model ara", + "claudeDesktop.visibility.provider": "Sağlayıcı", + "claudeDesktop.visibility.allProviders": "Tüm sağlayıcılar", + "claudeDesktop.visibility.selectShown": "Gösterilenleri seç", + "claudeDesktop.visibility.clearShown": "Gösterilenleri temizle", + "claudeDesktop.visibility.toggle": "{model} modelini Claude Desktop'ta göster", + "claudeDesktop.visibility.selected": "{n} seçili", + "claudeDesktop.visibility.available": "{n} kullanılabilir", + "claudeDesktop.visibility.applied": "{n} uygulandı", + "claudeDesktop.visibility.appliedUnknown": "Uygulanan liste bilinmiyor", + "claudeDesktop.visibility.selectedUnavailable": "{n} seçili ama kullanılamıyor", + "claudeDesktop.visibility.newModels": "Yeni keşfedilen modeller", + "claudeDesktop.visibility.newShow": "Otomatik listele", + "claudeDesktop.visibility.newHide": "Seçilene kadar gizle", + "claudeDesktop.visibility.noneSelected": "En az bir kullanılabilir model seçin.", + "claudeDesktop.visibility.noMatch": "Bu filtreyle eşleşen model yok.", + "claudeDesktop.visibility.previewTitle": "Sonraki uygulama", + "claudeDesktop.visibility.previewAdds": "Ekler: {models}", + "claudeDesktop.visibility.previewRemoves": "Kaldırır: {models}", + "claudeDesktop.visibility.previewSame": "Desktop zaten tam olarak bu modelleri listeliyor.", + "claudeDesktop.visibility.replaceNote": "Uygulamak, OpenCodex'in Claude Desktop profilindeki model listesini (elle düzenlenmiş liste dahil) değiştirir. Gelişmiş altındaki katman eşlemesi isteğe bağlıdır. Yalnızca keşif modu bu seçimi kullanmaz.", "claudeDesktop.roles.quickBadge": "Hızlı görevler", "claudeDesktop.advanced.title": "Gelişmiş: Claude düzeyi ataması", "claudeDesktop.advanced.desc": "Claude Desktop’ın her modeli hangi Claude düzeyi (Opus, Fable, Sonnet, Haiku) olarak değerlendireceğini seçin.", @@ -3518,7 +3540,7 @@ export const tr: Record = { "claudeDesktop.moveTo": "Şuraya taşı", "claudeDesktop.move": "Taşı", "claudeDesktop.status.applied": "Desktop'a Uygulandı", - "claudeDesktop.status.stale": "Konfigürasyon eski — tekrar uygulayın", + "claudeDesktop.status.stale": "Desktop yapılandırma dosyası OpenCodex dışında değişti — tekrar uygulayın", "claudeDesktop.status.notApplied": "Uygulanmadı", "claudeDesktop.status.notActiveProfile": "Desktop başka bir profil kullanıyor", "claudeDesktop.status.disabled": "Claude Desktop entegrasyonu kapalı.", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index d705ff8a539..7a5b8f68932 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -3437,7 +3437,7 @@ export const vi: Record = { "claudeDesktop.importFailed": "Nhập thất bại. {error}", "claudeDesktop.moved": "{route} đã được chuyển đến {family}.", "claudeDesktop.unsaved": "Các thay đổi chưa được lưu", - "claudeDesktop.upToDate": "Hồ sơ đã được cập nhật", + "claudeDesktop.upToDate": "Đã lưu mọi thay đổi", "claudeDesktop.saving": "Đang lưu…", "claudeDesktop.applying": "Đang áp dụng…", "claudeDesktop.saveApply": "Lưu & áp dụng", @@ -3491,7 +3491,29 @@ export const vi: Record = { "claudeDesktop.roles.unavailableOption": "{route} (không khả dụng)", "claudeDesktop.roles.gatewayScope": "Các lựa chọn này áp dụng cho chế độ gateway, chế độ ghi danh sách model của Claude Desktop.", "claudeDesktop.roles.listTitle": "Danh sách model Desktop", - "claudeDesktop.roles.listHint": "Tự động điền từ các provider đã kết nối.", + "claudeDesktop.roles.listHint": "Chọn những model Claude Desktop sẽ liệt kê. Chỉ Claude Desktop thay đổi: các client khác và cài đặt provider giữ nguyên model.", + "claudeDesktop.status.pendingApply": "Đã lưu — hãy áp dụng để cập nhật Desktop", + "claudeDesktop.visibility.search": "Tìm model", + "claudeDesktop.visibility.provider": "Provider", + "claudeDesktop.visibility.allProviders": "Tất cả provider", + "claudeDesktop.visibility.selectShown": "Chọn mục đang hiện", + "claudeDesktop.visibility.clearShown": "Bỏ chọn mục đang hiện", + "claudeDesktop.visibility.toggle": "Hiện {model} trong Claude Desktop", + "claudeDesktop.visibility.selected": "Đã chọn {n}", + "claudeDesktop.visibility.available": "{n} khả dụng", + "claudeDesktop.visibility.applied": "Đã áp dụng {n}", + "claudeDesktop.visibility.appliedUnknown": "Không rõ danh sách đã áp dụng", + "claudeDesktop.visibility.selectedUnavailable": "{n} đã chọn nhưng không khả dụng", + "claudeDesktop.visibility.newModels": "Model mới phát hiện", + "claudeDesktop.visibility.newShow": "Tự động liệt kê", + "claudeDesktop.visibility.newHide": "Ẩn cho đến khi được chọn", + "claudeDesktop.visibility.noneSelected": "Hãy chọn ít nhất một model khả dụng.", + "claudeDesktop.visibility.noMatch": "Không có model nào khớp bộ lọc.", + "claudeDesktop.visibility.previewTitle": "Lần áp dụng tới", + "claudeDesktop.visibility.previewAdds": "Thêm: {models}", + "claudeDesktop.visibility.previewRemoves": "Bỏ: {models}", + "claudeDesktop.visibility.previewSame": "Desktop đã liệt kê đúng các model này.", + "claudeDesktop.visibility.replaceNote": "Áp dụng sẽ thay danh sách model trong hồ sơ Claude Desktop của OpenCodex, kể cả danh sách đã sửa tay. Ánh xạ tier trong Nâng cao vẫn là tùy chọn. Chế độ chỉ discovery không dùng lựa chọn này.", "claudeDesktop.roles.quickBadge": "Tác vụ nhanh", "claudeDesktop.advanced.title": "Nâng cao: chỉ định cấp Claude", "claudeDesktop.advanced.desc": "Chọn cấp Claude (Opus, Fable, Sonnet, Haiku) mà Claude Desktop dùng để phân loại từng model.", @@ -3506,7 +3528,7 @@ export const vi: Record = { "claudeDesktop.moveTo": "Chuyển đến", "claudeDesktop.move": "Di chuyển", "claudeDesktop.status.applied": "Đã áp dụng cho Desktop", - "claudeDesktop.status.stale": "Cấu hình đã cũ — hãy áp dụng lại", + "claudeDesktop.status.stale": "Tệp cấu hình của Desktop đã đổi bên ngoài OpenCodex — hãy áp dụng lại", "claudeDesktop.status.notApplied": "Chưa được áp dụng", "claudeDesktop.status.notActiveProfile": "Desktop đang phục vụ một hồ sơ khác — hãy áp dụng lại", "claudeDesktop.status.disabled": "Tiện ích tích hợp Claude Desktop đang tắt. Hãy thoát hoàn toàn và mở lại Desktop sau khi bật nó.", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 23570edbe09..c0852b78c27 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -2623,7 +2623,7 @@ export const zhTW: Record = { "claudeDesktop.importFailed": "匯入失敗。{error}", "claudeDesktop.moved": "已將 {route} 移動到 {family}。", "claudeDesktop.unsaved": "有未儲存的更改", - "claudeDesktop.upToDate": "配置已是最新", + "claudeDesktop.upToDate": "所有變更已儲存", "claudeDesktop.saving": "正在儲存…", "claudeDesktop.applying": "正在套用…", "claudeDesktop.saveApply": "儲存並套用", @@ -2677,7 +2677,29 @@ export const zhTW: Record = { "claudeDesktop.roles.unavailableOption": "{route}(無法使用)", "claudeDesktop.roles.gatewayScope": "這些選擇適用於閘道模式,此模式會寫入 Claude Desktop 的模型清單。", "claudeDesktop.roles.listTitle": "Desktop 模型清單", - "claudeDesktop.roles.listHint": "自動從已連線的供應商填入。", + "claudeDesktop.roles.listHint": "選擇 Claude Desktop 列出的模型。只影響 Claude Desktop:其他用戶端與供應商設定的模型保持不變。", + "claudeDesktop.status.pendingApply": "已儲存 — 套用後更新 Desktop", + "claudeDesktop.visibility.search": "搜尋模型", + "claudeDesktop.visibility.provider": "供應商", + "claudeDesktop.visibility.allProviders": "所有供應商", + "claudeDesktop.visibility.selectShown": "選取顯示項", + "claudeDesktop.visibility.clearShown": "取消顯示項", + "claudeDesktop.visibility.toggle": "在 Claude Desktop 中顯示 {model}", + "claudeDesktop.visibility.selected": "已選 {n}", + "claudeDesktop.visibility.available": "可用 {n}", + "claudeDesktop.visibility.applied": "已套用 {n}", + "claudeDesktop.visibility.appliedUnknown": "已套用清單未知", + "claudeDesktop.visibility.selectedUnavailable": "已選但不可用 {n}", + "claudeDesktop.visibility.newModels": "新發現的模型", + "claudeDesktop.visibility.newShow": "自動列出", + "claudeDesktop.visibility.newHide": "選取前保持隱藏", + "claudeDesktop.visibility.noneSelected": "請至少選擇一個可用模型。", + "claudeDesktop.visibility.noMatch": "沒有符合此篩選的模型。", + "claudeDesktop.visibility.previewTitle": "下次套用", + "claudeDesktop.visibility.previewAdds": "新增:{models}", + "claudeDesktop.visibility.previewRemoves": "移除:{models}", + "claudeDesktop.visibility.previewSame": "Desktop 已恰好列出這些模型。", + "claudeDesktop.visibility.replaceNote": "套用會取代 OpenCodex 的 Claude Desktop 設定中的模型清單,包括手動編輯過的清單。進階中的層級對應仍為選用。僅探索模式不使用此選擇。", "claudeDesktop.roles.quickBadge": "快速任務", "claudeDesktop.advanced.title": "進階:Claude 等級分配", "claudeDesktop.advanced.desc": "選擇 Claude Desktop 將各模型視為哪個 Claude 等級(Opus、Fable、Sonnet、Haiku)。", @@ -2692,7 +2714,7 @@ export const zhTW: Record = { "claudeDesktop.moveTo": "移動到", "claudeDesktop.move": "移動", "claudeDesktop.status.applied": "已套用到 Desktop", - "claudeDesktop.status.stale": "配置已更改 — 需重新套用", + "claudeDesktop.status.stale": "Desktop 設定檔在 OpenCodex 之外被修改 — 需重新套用", "claudeDesktop.status.notApplied": "未套用", "claudeDesktop.status.notActiveProfile": "Desktop 正在使用其他配置 — 請重新套用", "claudeDesktop.health.lastRequest": "最後請求", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index a765ec77f8e..5a9a24f7f76 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -3419,7 +3419,7 @@ export const zh: Record = { "claudeDesktop.importFailed": "导入失败。{error}", "claudeDesktop.moved": "已将 {route} 移动到 {family}。", "claudeDesktop.unsaved": "有未保存的更改", - "claudeDesktop.upToDate": "配置已是最新", + "claudeDesktop.upToDate": "所有更改已保存", "claudeDesktop.saving": "正在保存…", "claudeDesktop.applying": "正在应用…", "claudeDesktop.saveApply": "保存并应用", @@ -3473,7 +3473,29 @@ export const zh: Record = { "claudeDesktop.roles.unavailableOption": "{route}(不可用)", "claudeDesktop.roles.gatewayScope": "这些选择适用于网关模式,该模式会写入 Claude Desktop 的模型列表。", "claudeDesktop.roles.listTitle": "Desktop 模型列表", - "claudeDesktop.roles.listHint": "自动从已连接的提供商填充。", + "claudeDesktop.roles.listHint": "选择 Claude Desktop 列出的模型。只影响 Claude Desktop:其他客户端和提供商设置的模型保持不变。", + "claudeDesktop.status.pendingApply": "已保存 — 应用后更新 Desktop", + "claudeDesktop.visibility.search": "搜索模型", + "claudeDesktop.visibility.provider": "提供商", + "claudeDesktop.visibility.allProviders": "所有提供商", + "claudeDesktop.visibility.selectShown": "选中显示项", + "claudeDesktop.visibility.clearShown": "取消显示项", + "claudeDesktop.visibility.toggle": "在 Claude Desktop 中显示 {model}", + "claudeDesktop.visibility.selected": "已选 {n}", + "claudeDesktop.visibility.available": "可用 {n}", + "claudeDesktop.visibility.applied": "已应用 {n}", + "claudeDesktop.visibility.appliedUnknown": "已应用列表未知", + "claudeDesktop.visibility.selectedUnavailable": "已选但不可用 {n}", + "claudeDesktop.visibility.newModels": "新发现的模型", + "claudeDesktop.visibility.newShow": "自动列出", + "claudeDesktop.visibility.newHide": "选中前保持隐藏", + "claudeDesktop.visibility.noneSelected": "请至少选择一个可用模型。", + "claudeDesktop.visibility.noMatch": "没有符合此筛选的模型。", + "claudeDesktop.visibility.previewTitle": "下次应用", + "claudeDesktop.visibility.previewAdds": "新增:{models}", + "claudeDesktop.visibility.previewRemoves": "移除:{models}", + "claudeDesktop.visibility.previewSame": "Desktop 已恰好列出这些模型。", + "claudeDesktop.visibility.replaceNote": "应用会替换 OpenCodex 的 Claude Desktop 配置中的模型列表,包括手动编辑过的列表。高级中的层级映射仍为可选。仅发现模式不使用此选择。", "claudeDesktop.roles.quickBadge": "快速任务", "claudeDesktop.advanced.title": "高级:Claude 等级分配", "claudeDesktop.advanced.desc": "选择 Claude Desktop 将各模型视为哪个 Claude 等级(Opus、Fable、Sonnet、Haiku)。", @@ -3488,7 +3510,7 @@ export const zh: Record = { "claudeDesktop.moveTo": "移动到", "claudeDesktop.move": "移动", "claudeDesktop.status.applied": "已应用到 Desktop", - "claudeDesktop.status.stale": "配置已更改 — 需重新应用", + "claudeDesktop.status.stale": "Desktop 配置文件在 OpenCodex 之外被修改 — 需重新应用", "claudeDesktop.status.notApplied": "未应用", "claudeDesktop.status.notActiveProfile": "Desktop 正在使用其他配置 — 请重新应用", "claudeDesktop.status.disabled": "Claude Desktop 集成已关闭。开启后请完全退出并重新打开 Desktop。", diff --git a/gui/src/pages/ClaudeDesktop.tsx b/gui/src/pages/ClaudeDesktop.tsx index c71e9b0deb1..03f41bed73a 100644 --- a/gui/src/pages/ClaudeDesktop.tsx +++ b/gui/src/pages/ClaudeDesktop.tsx @@ -11,6 +11,11 @@ import { useDataSurface } from "../data-surface"; import { DataSurfaceSkeleton } from "../components/data-surface"; import ClaudeFirstPartyBindings from "../components/ClaudeFirstPartyBindings"; import ClaudeDesktopPicker, { type DesktopPickerStatus } from "../components/ClaudeDesktopPicker"; +import ClaudeDesktopVisibility from "../components/ClaudeDesktopVisibility"; +import { + applyPreview, desiredDesktopList, isDesktopVisibility, normalizeVisibility, sameVisibility, visibilityCounts, + type AppliedDesktopModel, type DesktopVisibility, type ListedDesktopModel, +} from "./claude-desktop-visibility"; import { FAMILIES, assignFamily, @@ -91,6 +96,8 @@ interface DesktopStatus { * `applied` (our saved fingerprint) says otherwise. null = undeterminable. */ activeProfile?: boolean | null; + /** The static list Desktop runs now; null/absent when there is none to read. */ + appliedModels?: AppliedDesktopModel[] | null; health: { lastRequestAt: string | null; requestCount: number; errorCount: number }; } @@ -123,6 +130,8 @@ function isDesktopStatus(value: unknown): value is DesktopStatus { if (fp.pickerSuggestions !== undefined && (!Array.isArray(fp.pickerSuggestions) || fp.pickerSuggestions.some(id => typeof id !== "string"))) return false; } + if (v.appliedModels !== undefined && v.appliedModels !== null && (!Array.isArray(v.appliedModels) + || v.appliedModels.some(row => typeof row?.name !== "string" || typeof row?.label !== "string"))) return false; return typeof v.desiredEnabled === "boolean" && typeof v.applied === "boolean" && (v.appliedAt === null || typeof v.appliedAt === "string") @@ -136,7 +145,9 @@ function isDesktopStatus(value: unknown): value is DesktopStatus { interface DesktopResponse { profile: DesktopProfile; models: DesktopModel[]; - rendered: unknown[]; + rendered: ListedDesktopModel[]; + /** Absent on servers that predate #7702; null lists every model. */ + visibility?: DesktopVisibility | null; port: number; } @@ -220,6 +231,10 @@ function formatContextWindow(value: number | undefined, t: TFn): string | null { type CachedDesktop = { data: DesktopResponse; profile: DesktopProfile }; +function responseVisibility(data: DesktopResponse | null | undefined): DesktopVisibility | null { + return isDesktopVisibility(data?.visibility) ? normalizeVisibility(data.visibility) : null; +} + function readDesktopCache(cacheKey: string): CachedDesktop | null { return readSessionListCacheEntry(cacheKey)?.data ?? null; } @@ -258,6 +273,9 @@ export default function ClaudeDesktop({ const [draftProfile, setProfile] = useState(() => cached.profile); const [savedDraftProfile, setSavedProfile] = useState(() => cached.savedProfile); const [draftDestinations, setDestinations] = useState>(() => cached.destinations); + // Desktop list membership (#7702). undefined = no draft yet, follow the loaded response. + const [draftVisibility, setVisibility] = useState(undefined); + const [savedDraftVisibility, setSavedVisibility] = useState(undefined); const [message, setMessage] = useState<{ tone: "ok" | "err" | "warn"; text: string } | null>(null); const [announcement, setAnnouncement] = useState(""); const [pending, setPending] = useState(null); @@ -274,9 +292,8 @@ export default function ClaudeDesktop({ // a family's fold is a durable preference, but which single model you were inspecting // is not, and restoring five open rows on reload would rebuild the wall this removes. const [openRows, setOpenRows] = useState>({}); - // The Advanced disclosure and the compact list's page are view state only. + // The Advanced disclosure is view state only. const [advancedOpen, setAdvancedOpen] = useState(false); - const [listLimit, setListLimit] = useState(LANE_PAGE); // The mode the user wants the next apply to use. null = follow whatever /status reports // (gateway by default), so a page load never silently changes an existing install. const [chosenMode, setChosenMode] = useState(null); @@ -297,6 +314,8 @@ export default function ClaudeDesktop({ // the established save→reload contract without synchronizing resource data in an effect. setProfile(normalized); setSavedProfile(cloneProfile(normalized)); + setVisibility(responseVisibility(payload)); + setSavedVisibility(responseVisibility(payload)); setDestinations(Object.fromEntries(payload.models.map(model => [model.route, normalized.assignments[model.route]?.family ?? "opus"]))); // Fold empty families on load, but only while the user has no stored preference. // Doing it here rather than per render means a later move or import can never @@ -308,7 +327,7 @@ export default function ClaudeDesktop({ } writeSessionListCacheEntry(cacheKey, next); return next; - }, [apiBase, cacheKey, t, setDestinations, setProfile, setSavedProfile]); + }, [apiBase, cacheKey, t, setDestinations, setProfile, setSavedProfile, setVisibility, setSavedVisibility]); const desktopResource = useDataSurface( resourceKey, @@ -332,9 +351,12 @@ export default function ClaudeDesktop({ : {} as Record; const destinations = Object.keys(draftDestinations).length > 0 ? draftDestinations : resourceDestinations; + const visibility = draftVisibility === undefined ? responseVisibility(data) : draftVisibility; + const savedVisibility = savedDraftVisibility === undefined ? responseVisibility(data) : savedDraftVisibility; const dirty = useMemo( - () => profile !== null && savedProfile !== null && JSON.stringify(profile) !== JSON.stringify(savedProfile), - [profile, savedProfile], + () => (profile !== null && savedProfile !== null && JSON.stringify(profile) !== JSON.stringify(savedProfile)) + || !sameVisibility(visibility, savedVisibility), + [profile, savedProfile, visibility, savedVisibility], ); const modelsByFamily = useMemo(() => { @@ -428,10 +450,11 @@ export default function ClaudeDesktop({ const response = await fetch(`${apiBase}/api/claude-desktop`, { method: "PUT", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ profile }), + body: JSON.stringify({ profile, visibility: normalizeVisibility(visibility) }), }); await readJsonOrThrow<{ error?: string }>(response, t("claudeDesktop.saveFailed")); setSavedProfile(cloneProfile(profile)); + setSavedVisibility(normalizeVisibility(visibility)); if (applyAfter) { setPending("apply"); @@ -547,6 +570,10 @@ export default function ClaudeDesktop({ }; const listed = roleListOrder(data.models, defaultRoute || null, quickRoute || null); const haikuEmpty = familySize(data.models, profile, "haiku") === 0; + const noneVisible = visibilityCounts(data.models, visibility).selected === 0 && data.models.some(model => model.available); + // Saved but not yet written: Desktop's list differs from what the saved selection produces. + const savedPreview = Array.isArray(data.rendered) ? applyPreview(desiredDesktopList(data.rendered, savedVisibility), status?.appliedModels ?? null) : null; + const pendingApply = !dirty && effectiveMode === "gateway" && !!savedPreview && (savedPreview.added.length > 0 || savedPreview.removed.length > 0); return ( <> @@ -591,7 +618,7 @@ export default function ClaudeDesktop({ ? "not-applied" : effectiveMode === "gateway" && status.activeProfile === false ? "not-applied" - : status.stale + : status.stale || (status.applied && pendingApply) ? "stale" : status.applied ? "applied" @@ -614,6 +641,8 @@ export default function ClaudeDesktop({ ? t("claudeDesktop.status.notActiveProfile") : status.stale ? t("claudeDesktop.status.stale") + : status.applied && pendingApply + ? t("claudeDesktop.status.pendingApply") : status.applied ? effectiveMode === "first-party" ? t("claudeDesktop.status.appliedFirstParty") : t("claudeDesktop.status.applied") : t("claudeDesktop.status.notApplied")} @@ -641,10 +670,10 @@ export default function ClaudeDesktop({ {dirty ? t("claudeDesktop.unsaved") : t("claudeDesktop.upToDate")} - - @@ -747,29 +776,26 @@ export default function ClaudeDesktop({ {data.models.length > 0 &&

{t("claudeDesktop.roles.listHint")}

} {data.models.length > 0 && ( -
    - {listed.slice(0, listLimit).map(model => { - const context = formatContextWindow(model.contextWindow, t); - return ( -
  • - - {model.label} - {model.label !== model.route && {model.route}} - - {model.route === defaultRoute && {t("claudeDesktop.defaultBadge")}} - {model.route === quickRoute && {t("claudeDesktop.roles.quickBadge")}} - {context && {context}} - {model.supports1m === true && {t("claudeDesktop.supports1m")}} - {!model.available && {t("claudeDesktop.unavailable")}} -
  • - ); - })} -
- )} - {listed.length > listLimit && ( - + { + const context = formatContextWindow(model.contextWindow, t); + return ( + <> + {model.route === defaultRoute && {t("claudeDesktop.defaultBadge")}} + {model.route === quickRoute && {t("claudeDesktop.roles.quickBadge")}} + {context && {context}} + {model.supports1m === true && {t("claudeDesktop.supports1m")}} + {!model.available && {t("claudeDesktop.unavailable")}} + + ); + }} + /> )} diff --git a/gui/src/pages/claude-desktop-visibility.ts b/gui/src/pages/claude-desktop-visibility.ts new file mode 100644 index 00000000000..ecb0cd58942 --- /dev/null +++ b/gui/src/pages/claude-desktop-visibility.ts @@ -0,0 +1,157 @@ +/** + * Claude Desktop model visibility (#7702), dashboard side. Mirrors + * src/claude/desktop-model-visibility.ts: `null` lists every model, `exclude` lists all but + * `routes` (new models appear), `include` lists only `routes` (new models stay hidden). + * Pure so the selection, the new-model policy switch and the apply preview are testable + * without mounting the page. + */ + +export interface DesktopVisibility { + mode: "include" | "exclude"; + routes: string[]; +} + +export interface VisibilityModel { + route: string; + label: string; + available: boolean; +} + +/** A row the next apply writes (`listed`/`rendered` on GET /api/claude-desktop). */ +export interface ListedDesktopModel { + route: string; + name: string; + label: string; +} + +/** A row Desktop runs now (`appliedModels` on GET /api/claude-desktop/status). */ +export interface AppliedDesktopModel { + name: string; + label: string; +} + +export function isDesktopVisibility(value: unknown): value is DesktopVisibility { + if (typeof value !== "object" || value === null) return false; + const v = value as Record; + return (v.mode === "include" || v.mode === "exclude") + && Array.isArray(v.routes) && v.routes.every(route => typeof route === "string"); +} + +/** Canonical form: sorted unique routes, and "exclude nothing" is the same as unset. */ +export function normalizeVisibility(visibility: DesktopVisibility | null): DesktopVisibility | null { + if (!visibility) return null; + const routes = [...new Set(visibility.routes)].sort(); + if (visibility.mode === "exclude" && routes.length === 0) return null; + return { mode: visibility.mode, routes }; +} + +export function sameVisibility(left: DesktopVisibility | null, right: DesktopVisibility | null): boolean { + return JSON.stringify(normalizeVisibility(left)) === JSON.stringify(normalizeVisibility(right)); +} + +export function routeVisible(visibility: DesktopVisibility | null, route: string): boolean { + if (!visibility) return true; + const listed = visibility.routes.includes(route); + return visibility.mode === "include" ? listed : !listed; +} + +/** Show or hide `routes`, keeping the new-model policy the selection already has. */ +export function setRoutesVisible( + visibility: DesktopVisibility | null, + routes: readonly string[], + visible: boolean, +): DesktopVisibility | null { + const current = visibility ?? { mode: "exclude" as const, routes: [] }; + const members = new Set(current.routes); + // include lists the shown routes; exclude lists the hidden ones. + const addToList = current.mode === "include" ? visible : !visible; + for (const route of routes) { + if (addToList) members.add(route); + else members.delete(route); + } + return normalizeVisibility({ mode: current.mode, routes: [...members] }); +} + +/** + * Switch what happens to models discovered later, without changing which known models are + * shown today: the list is rewritten from the include form to the exclude form or back. + */ +export function setNewModelPolicy( + visibility: DesktopVisibility | null, + knownRoutes: readonly string[], + mode: DesktopVisibility["mode"], +): DesktopVisibility | null { + if ((visibility?.mode ?? "exclude") === mode) return visibility; + const routes = knownRoutes.filter(route => routeVisible(visibility, route) === (mode === "include")); + // An include list must keep its entries even when it names nothing yet; only an empty + // exclude list collapses to unset. + return mode === "include" ? { mode, routes: [...new Set(routes)].sort() } : normalizeVisibility({ mode, routes }); +} + +export function providerOf(route: string): string { + const slash = route.indexOf("/"); + return slash > 0 ? route.slice(0, slash) : route; +} + +export function providersOf(models: readonly VisibilityModel[]): string[] { + return [...new Set(models.map(model => providerOf(model.route)))].sort(); +} + +/** Search matches route or label, case-insensitively; an empty provider means every provider. */ +export function filterVisibilityModels(models: readonly T[], query: string, provider: string): T[] { + const needle = query.trim().toLowerCase(); + return models.filter(model => (!provider || providerOf(model.route) === provider) + && (!needle || model.route.toLowerCase().includes(needle) || model.label.toLowerCase().includes(needle))); +} + +export interface VisibilityCounts { + /** Available models the selection lists. */ + selected: number; + /** Models currently available from providers. */ + available: number; + /** Selected models that are not available now; never written, so counted apart. */ + selectedUnavailable: number; +} + +export function visibilityCounts(models: readonly VisibilityModel[], visibility: DesktopVisibility | null): VisibilityCounts { + let selected = 0; + let available = 0; + let selectedUnavailable = 0; + for (const model of models) { + const visible = routeVisible(visibility, model.route); + if (model.available) { + available += 1; + if (visible) selected += 1; + } else if (visible && visibility?.mode === "include") { + // Only an explicit include names an unavailable model; "everything" does not. + selectedUnavailable += 1; + } + } + return { selected, available, selectedUnavailable }; +} + +/** The rows the next apply writes under a draft selection, in written order. */ +export function desiredDesktopList(rendered: readonly ListedDesktopModel[], visibility: DesktopVisibility | null): ListedDesktopModel[] { + const kept = rendered.filter(model => routeVisible(visibility, model.route)); + // Mirrors the writer's guard: a selection hiding everything falls back to the full list. + return kept.length > 0 ? kept : [...rendered]; +} + +export interface ApplyPreview { + added: string[]; + removed: string[]; +} + +/** Labels Desktop gains and loses on the next apply; null when the applied list is unknown. */ +export function applyPreview( + desired: readonly ListedDesktopModel[], + applied: readonly AppliedDesktopModel[] | null, +): ApplyPreview | null { + if (!applied) return null; + const appliedNames = new Set(applied.map(model => model.name)); + const desiredNames = new Set(desired.map(model => model.name)); + return { + added: desired.filter(model => !appliedNames.has(model.name)).map(model => model.label), + removed: applied.filter(model => !desiredNames.has(model.name)).map(model => model.label), + }; +} diff --git a/gui/src/styles/claude-page.css b/gui/src/styles/claude-page.css index 30987498847..940de1e6440 100644 --- a/gui/src/styles/claude-page.css +++ b/gui/src/styles/claude-page.css @@ -258,3 +258,57 @@ .claude-desktop-advanced > .ocx-group-stack { padding: 0 10px 10px; } +/* Claude Desktop model visibility (#7702). */ +.claude-visibility { + display: flex; + flex-direction: column; + gap: var(--space-2); +} +.claude-visibility-counts, +.claude-visibility-filters, +.claude-visibility-preview { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} +.claude-visibility-count { + font-size: var(--text-label); + font-weight: var(--weight-medium); +} +.claude-visibility-count.muted, +.claude-visibility-preview { + color: var(--muted); + font-size: var(--text-label); +} +.claude-visibility-filters .input[type="search"] { + flex: 1; + min-width: 160px; +} +.claude-visibility-policy { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + margin: 0; + padding: 0; + border: none; + font-size: var(--text-label); +} +.claude-visibility-policy legend { + float: left; + margin-right: 4px; + font-weight: var(--weight-medium); +} +.claude-visibility-policy label { + display: inline-flex; + align-items: center; + gap: 4px; +} +.claude-desktop-list-row.hidden-row .claude-desktop-list-names { + opacity: 0.55; +} +.claude-visibility-preview-title { + font-weight: var(--weight-medium); + color: var(--text); +} diff --git a/gui/tests/claude-desktop-visibility-card.test.tsx b/gui/tests/claude-desktop-visibility-card.test.tsx new file mode 100644 index 00000000000..cde87ee568d --- /dev/null +++ b/gui/tests/claude-desktop-visibility-card.test.tsx @@ -0,0 +1,148 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import ClaudeDesktop from "../src/pages/ClaudeDesktop"; +import { LanguageProvider } from "../src/i18n/provider"; +import { clearClientResourceStoresForTests } from "../src/client-resource"; + +/** + * #7702: the Desktop model list is a per-model selection. Mounted, because the contract is + * the save body the page sends and the saved/applied state it shows. + */ + +const globals = ["document", "window", "navigator", "localStorage", "fetch", "IS_REACT_ACT_ENVIRONMENT"] as const; +let previousGlobals: Record<(typeof globals)[number], unknown>; +let testWindow: Window; +let container: HTMLElement; +let root: Root | null = null; + +function model(route: string, label: string, family: string, available = true) { + return { route, label, available, contextWindow: 200_000, effortSupported: true, assignment: { family, alias: `alias-${label}` } }; +} + +// Eight models so the lane pager and the search input are both in play (LANE_PAGE = 6, +// LANE_SEARCH_MIN = 4). +const MODELS = [ + ...Array.from({ length: 7 }, (_, i) => model(`prov/opus-${i}`, `Opus Model ${i}`, "opus")), + model("prov/only-sonnet", "Sonnet Model", "sonnet"), +]; + +// A stored default whose long route went unavailable (R4 audit: it overflowed the Models card). +const STALE_ROUTE = "openrouter/anthropic/claude-sonnet-4.5-thinking-extended-context-preview-20260930"; +let withStaleDefault = false; +let puts: Array> = []; +// Desktop currently runs every model, so a saved narrower selection is pending apply. +let appliedModels: Array<{ name: string; label: string }> = []; + +function payload() { + const models = withStaleDefault ? [...MODELS, model(STALE_ROUTE, STALE_ROUTE, "opus", false)] : MODELS; + return { + profile: { + version: 1, + assignments: Object.fromEntries(models.map(m => [m.route, m.assignment])), + defaults: { opus: withStaleDefault ? STALE_ROUTE : "prov/opus-0", fable: null, sonnet: "prov/only-sonnet", haiku: null }, + }, + models, + rendered: models.map(m => ({ route: m.route, name: `claude-${m.route.replace(/\W/g, "-")}`, label: m.label })), + visibility: savedVisibility, + port: 10100, + }; +} + +let savedVisibility: unknown = null; + +beforeEach(() => { + withStaleDefault = false; + puts = []; + savedVisibility = null; + appliedModels = MODELS.map(m => ({ name: `claude-${m.route.replace(/\W/g, "-")}`, label: m.label })); + clearClientResourceStoresForTests(); + previousGlobals = Object.fromEntries(globals.map(k => [k, Reflect.get(globalThis, k)])) as typeof previousGlobals; + testWindow = new Window({ url: "http://localhost/" }); + Object.defineProperty(testWindow.navigator, "language", { configurable: true, value: "en-US" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: testWindow.document }, + window: { configurable: true, value: testWindow }, + navigator: { configurable: true, value: testWindow.navigator }, + localStorage: { configurable: true, value: testWindow.localStorage }, + }); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + + Object.defineProperty(globalThis, "fetch", { + configurable: true, + value: async (url: string, init?: RequestInit) => { + if (init?.method === "PUT") puts.push(JSON.parse(String(init.body))); + const body = String(url).includes("/status") + ? { desiredEnabled: true, applied: true, appliedAt: null, stale: false, appliedModels, health: { lastRequestAt: null, requestCount: 0, errorCount: 0 } } + : payload(); + return { ok: true, status: 200, json: async () => body, text: async () => JSON.stringify(body) } as unknown as Response; + }, + }); + + container = testWindow.document.createElement("div") as unknown as HTMLElement; + testWindow.document.body.appendChild(container as never); +}); + +afterEach(async () => { + if (root) { + const current = root; + await act(async () => { current.unmount(); }); + root = null; + } + clearClientResourceStoresForTests(); + for (const key of globals) { + Object.defineProperty(globalThis, key, { configurable: true, value: previousGlobals[key] }); + } +}); + +async function mount() { + await act(async () => { + root = createRoot(container); + root.render(); + }); + await act(async () => { await new Promise(r => setTimeout(r, 50)); }); +} + +function checkbox(label: string): HTMLInputElement { + const found = container.querySelector(`input[type="checkbox"][aria-label="Show ${label} in Claude Desktop"]`); + if (!found) throw new Error(`checkbox not found: ${label}`); + return found as unknown as HTMLInputElement; +} + +function button(text: string): HTMLButtonElement { + const found = Array.from(container.querySelectorAll("button")).find(b => (b.textContent ?? "").trim() === text); + if (!found) throw new Error(`button not found: ${text}`); + return found as unknown as HTMLButtonElement; +} + +test("unchecking a model marks the page unsaved and saves an exclude selection with the profile", async () => { + await mount(); + expect(container.querySelector(".claude-visibility-counts")?.textContent).toContain("8 selected"); + expect(checkbox("Opus Model 1").checked).toBe(true); + await act(async () => { checkbox("Opus Model 1").click(); }); + expect(checkbox("Opus Model 1").checked).toBe(false); + expect(container.querySelector(".claude-dirty")?.textContent).toBe("Unsaved changes"); + expect(container.querySelector(".claude-visibility-preview")?.textContent).toContain("Removes: Opus Model 1"); + await act(async () => { button("Save").click(); }); + await act(async () => { await new Promise(r => setTimeout(r, 20)); }); + expect(puts).toHaveLength(1); + expect(puts[0]!.visibility).toEqual({ mode: "exclude", routes: ["prov/opus-1"] }); + expect(puts[0]!.profile).toBeDefined(); +}); + +test("a saved selection Desktop has not received yet reads as pending apply, not up to date", async () => { + savedVisibility = { mode: "include", routes: ["prov/opus-0"] }; + await mount(); + expect(checkbox("Opus Model 1").checked).toBe(false); + expect(container.querySelector(".claude-status-bar")?.className).toContain("stale"); + expect(container.querySelector(".claude-status-bar")?.textContent).toContain("Saved — apply to update Desktop"); + expect(container.querySelector(".claude-dirty")?.textContent).toBe("All changes saved"); +}); + +test("clearing every shown model blocks saving", async () => { + await mount(); + await act(async () => { button("Clear shown").click(); }); + expect(container.textContent).toContain("Select at least one available model."); + expect(button("Save").disabled).toBe(true); +}); diff --git a/gui/tests/claude-desktop-visibility.test.ts b/gui/tests/claude-desktop-visibility.test.ts new file mode 100644 index 00000000000..5af95c69f54 --- /dev/null +++ b/gui/tests/claude-desktop-visibility.test.ts @@ -0,0 +1,66 @@ +import { expect, test } from "bun:test"; +import { + applyPreview, + desiredDesktopList, + filterVisibilityModels, + normalizeVisibility, + routeVisible, + sameVisibility, + setNewModelPolicy, + setRoutesVisible, + visibilityCounts, +} from "../src/pages/claude-desktop-visibility"; + +/** #7702: the dashboard's Desktop selection math, kept pure so it is testable unmounted. */ + +const MODELS = [ + { route: "anthropic/claude-sonnet-5", label: "Sonnet 5", available: true }, + { route: "google/gemini-3-pro", label: "Gemini 3 Pro", available: true }, + { route: "xai/grok-5", label: "Grok 5", available: true }, + { route: "deepseek/deepseek-v4", label: "DeepSeek V4", available: false }, +]; +const ROUTES = MODELS.map(model => model.route); + +test("unset lists everything; toggling builds an exclude list that keeps new models listed", () => { + expect(ROUTES.every(route => routeVisible(null, route))).toBe(true); + const hidden = setRoutesVisible(null, ["xai/grok-5"], false); + expect(hidden).toEqual({ mode: "exclude", routes: ["xai/grok-5"] }); + expect(routeVisible(hidden, "new/model")).toBe(true); + // Showing it again is the same as never having hidden it. + expect(setRoutesVisible(hidden, ["xai/grok-5"], true)).toBeNull(); +}); + +test("switching the new-model policy rewrites the list without changing today's selection", () => { + const hidden = setRoutesVisible(null, ["xai/grok-5"], false); + const include = setNewModelPolicy(hidden, ROUTES, "include"); + expect(include).toEqual({ mode: "include", routes: ["anthropic/claude-sonnet-5", "deepseek/deepseek-v4", "google/gemini-3-pro"] }); + for (const route of ROUTES) expect(routeVisible(include, route)).toBe(routeVisible(hidden, route)); + expect(routeVisible(include, "new/model")).toBe(false); + expect(sameVisibility(setNewModelPolicy(include, ROUTES, "exclude"), hidden)).toBe(true); +}); + +test("bulk select acts on the filtered rows only", () => { + const shown = filterVisibilityModels(MODELS, "", "google").map(model => model.route); + const include = setRoutesVisible({ mode: "include", routes: [] }, shown, true); + expect(include).toEqual({ mode: "include", routes: ["google/gemini-3-pro"] }); + expect(filterVisibilityModels(MODELS, "GROK", "").map(model => model.route)).toEqual(["xai/grok-5"]); +}); + +test("counts keep unavailable selections apart from the selected count", () => { + expect(visibilityCounts(MODELS, null)).toEqual({ selected: 3, available: 3, selectedUnavailable: 0 }); + expect(visibilityCounts(MODELS, { mode: "include", routes: ["google/gemini-3-pro", "deepseek/deepseek-v4"] })) + .toEqual({ selected: 1, available: 3, selectedUnavailable: 1 }); +}); + +test("the apply preview diffs the desired list against what Desktop runs", () => { + const rendered = [ + { route: "google/gemini-3-pro", name: "claude-a", label: "Gemini 3 Pro" }, + { route: "xai/grok-5", name: "claude-b", label: "Grok 5" }, + ]; + const desired = desiredDesktopList(rendered, { mode: "exclude", routes: ["xai/grok-5"] }); + expect(applyPreview(desired, [{ name: "claude-b", label: "Grok 5" }])).toEqual({ added: ["Gemini 3 Pro"], removed: ["Grok 5"] }); + expect(applyPreview(desired, null)).toBeNull(); + // Hiding everything previews the writer's fallback, not an empty list. + expect(desiredDesktopList(rendered, { mode: "include", routes: [] })).toHaveLength(2); + expect(normalizeVisibility({ mode: "exclude", routes: [] })).toBeNull(); +}); diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 2fdded5a374..6919bb6030f 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -408,7 +408,7 @@ "claude-compatibility.test.ts": "claude-integration", "claude-context-windows.test.ts": "claude-integration", "claude-desktop-1m.test.ts": "claude-integration", - "claude-desktop-cli.test.ts": "claude-integration", + "claude-desktop-cli.test.ts": "claude-integration", "claude-desktop-model-visibility.test.ts": "claude-integration", "claude-desktop-config-path.test.ts": "claude-integration", "claude-desktop-discovery.test.ts": "claude-integration", "claude-desktop-first-party.test.ts": "claude-integration", diff --git a/src/claude/desktop-3p.ts b/src/claude/desktop-3p.ts index 46ff2e77843..25d0f85e509 100644 --- a/src/claude/desktop-3p.ts +++ b/src/claude/desktop-3p.ts @@ -27,6 +27,7 @@ import { import { nativeOpenAiContextWindow, type NativeContextLimitsInput } from "../codex/catalog"; import { localAdmissionToken, localInferenceDestination } from "../lib/local-destinations"; import { assertDesktop3pModelsValid } from "./desktop-3p-guard"; +import { filterDesktopModelsByVisibility, readDesktopModelVisibility, type DesktopModelVisibility } from "./desktop-model-visibility"; import { claudeSurfaceSupportsOneMillion, ONE_MILLION } from "./long-context"; export interface Desktop3pModelEntry { @@ -170,9 +171,11 @@ function collectDesktop3pModels( routedModels: Array, profile?: OcxClaudeDesktopProfile, nativeContextCap?: NativeContextLimitsInput, -): { models: Desktop3pModelEntry[]; registry: Map; realAnthropicIds: Set } { +): { models: Desktop3pModelEntry[]; routes: string[]; registry: Map; realAnthropicIds: Set } { const registry = new Map(); const models: Desktop3pModelEntry[] = []; + // routes[i] is the route models[i] serves; visibility filters by route, never by alias. + const routes: string[] = []; const candidates: Desktop3pRoutedModel[] = [ // Native candidates carry their real context window from the same accessor the // Desktop DTO uses, so a native 1M/372k model resolves identically in the written @@ -224,6 +227,7 @@ function collectDesktop3pModels( ...(model.isFamilyDefault ? { isFamilyDefault: true } : {}), ...(model.supports1m ? { supports1m: true, prefer1m: true } : {}), }); + routes.push(model.route); } // Previous profile IDs and both hash generations are compatibility-only and can // collide. Bind them in stable route order so changing a family default or @@ -248,7 +252,7 @@ function collectDesktop3pModels( } } desktop3pAliasesByRoute = aliasesByRoute; - return { models, registry, realAnthropicIds }; + return { models, routes, registry, realAnthropicIds }; } const aliasesByRoute = new Map(); @@ -272,6 +276,7 @@ function collectDesktop3pModels( ...(supports1m.supports1m ? { prefer1m: true as const } : {}), }); emittedNames.add(alias); + routes.push(route); aliasesByRoute.set(route, alias); continue; } @@ -300,12 +305,13 @@ function collectDesktop3pModels( ...supports1m, ...(supports1m.supports1m ? { prefer1m: true as const } : {}), }); + routes.push(route); aliasesByRoute.set(route, alias); } if (models[0]) models[0].isFamilyDefault = true; desktop3pAliasesByRoute = aliasesByRoute; - return { models, registry, realAnthropicIds }; + return { models, routes, registry, realAnthropicIds }; } /** Build and install the registry used to decode Desktop aliases. */ @@ -328,6 +334,11 @@ export interface Desktop3pModelOptions { * (`supports1m`) but no longer defaults to it (`prefer1m` dropped). */ preferOneMillion?: boolean; + /** + * Rows Desktop lists (#7702). Filters only the emitted list: the decode registry keeps every + * route so a running session holding a just-hidden alias still routes. + */ + visibility?: DesktopModelVisibility; } export function generateDesktop3pModels( @@ -337,15 +348,25 @@ export function generateDesktop3pModels( nativeContextCap?: NativeContextLimitsInput, options: Desktop3pModelOptions = {}, ): Desktop3pModelEntry[] { - const { models, registry, realAnthropicIds } = collectDesktop3pModels(nativeSlugs, routedModels, profile, nativeContextCap); + const { models: all, routes, registry, realAnthropicIds } = collectDesktop3pModels(nativeSlugs, routedModels, profile, nativeContextCap); desktop3pRegistry = registry; desktop3pRealAnthropicIds = realAnthropicIds; + let models = filterDesktopModelsByVisibility(all, routes, options.visibility); + if (models.length === 0 && all.length > 0) { + // An empty static list leaves Desktop with no usable picker. The dashboard refuses to save + // such a selection; this covers selections whose every model has since disappeared. + console.warn("[opencodex] Claude Desktop model visibility hides every available model; listing all models instead"); + models = all; + } return options.preferOneMillion === false ? models.map(({ prefer1m: _prefer1m, ...model }) => model) : models; } /** The Desktop model options a config asks for (one place, so writer and export agree). */ -export function desktop3pModelOptions(claudeCode: { contextAccounting?: string } | undefined): Desktop3pModelOptions { - return { preferOneMillion: claudeCode?.contextAccounting !== "200k" }; +export function desktop3pModelOptions( + claudeCode: { contextAccounting?: string; desktopModelVisibility?: unknown } | undefined, +): Desktop3pModelOptions { + const visibility = readDesktopModelVisibility(claudeCode); + return { preferOneMillion: claudeCode?.contextAccounting !== "200k", ...(visibility ? { visibility } : {}) }; } /** Resolve an alias using the most recently generated Desktop model registry. */ diff --git a/src/claude/desktop-model-visibility.ts b/src/claude/desktop-model-visibility.ts new file mode 100644 index 00000000000..42edb3b7e2e --- /dev/null +++ b/src/claude/desktop-model-visibility.ts @@ -0,0 +1,141 @@ +/** + * Claude Desktop model visibility (#7702): which rows the Desktop 3P `inferenceModels` + * list carries, persisted as `claudeCode.desktopModelVisibility`. + * + * Deliberately separate from the Desktop profile (tier/family mapping and defaults) and + * from provider-wide `disabledModels`: hiding a row here changes only what Claude Desktop + * lists. The alias decode registry keeps every route, so a running Desktop session that + * still sends a just-hidden alias keeps routing instead of failing mid-conversation. + * + * - unset: every available model is listed (the pre-#7702 behaviour). + * - `exclude`: every model except `routes`; newly discovered models are listed. + * - `include`: only `routes`; newly discovered models stay hidden until selected. + * + * Discovery-only mode emits no static list, so visibility does not apply there. + */ +import type { OcxClaudeDesktopModelVisibility } from "../types"; +import { readDesktopLibraryText } from "./desktop-3p-library"; + +export type DesktopModelVisibility = OcxClaudeDesktopModelVisibility; + +export const DESKTOP_VISIBILITY_MODES = ["include", "exclude"] as const; +/** Bounded so a hand-edited config cannot grow the per-write set without limit. */ +export const MAX_DESKTOP_VISIBILITY_ROUTES = 5000; + +export class DesktopModelVisibilityError extends Error { + constructor(message: string, readonly path = "desktopModelVisibility") { + super(`${path}: ${message}`); + this.name = "DesktopModelVisibilityError"; + } +} + +function isPlainObject(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +export function parseDesktopModelVisibility(value: unknown): DesktopModelVisibility { + if (!isPlainObject(value)) throw new DesktopModelVisibilityError("must be an object"); + for (const key of Object.keys(value)) { + if (key !== "mode" && key !== "routes") throw new DesktopModelVisibilityError(`unknown field "${key}"`); + } + if (value.mode !== "include" && value.mode !== "exclude") { + throw new DesktopModelVisibilityError("must be \"include\" or \"exclude\"", "desktopModelVisibility.mode"); + } + if (!Array.isArray(value.routes)) throw new DesktopModelVisibilityError("must be an array", "desktopModelVisibility.routes"); + if (value.routes.length > MAX_DESKTOP_VISIBILITY_ROUTES) { + throw new DesktopModelVisibilityError(`must list at most ${MAX_DESKTOP_VISIBILITY_ROUTES} routes`, "desktopModelVisibility.routes"); + } + const routes = new Set(); + for (const route of value.routes) { + if (typeof route !== "string" || !route.includes("/") || /\s/.test(route) || route.startsWith("/") || route.endsWith("/")) { + throw new DesktopModelVisibilityError("routes must be provider/model strings", "desktopModelVisibility.routes"); + } + routes.add(route); + } + return { mode: value.mode, routes: [...routes].sort() }; +} + +/** Whether Desktop lists this route under the given visibility (unset lists everything). */ +export function desktopRouteVisible(visibility: DesktopModelVisibility | undefined, route: string): boolean { + if (!visibility) return true; + const listed = visibility.routes.includes(route); + return visibility.mode === "include" ? listed : !listed; +} + +/** Same desired list, compared order-insensitively (routes are stored sorted). */ +export function sameDesktopModelVisibility( + left: DesktopModelVisibility | undefined, + right: DesktopModelVisibility | undefined, +): boolean { + if (!left || !right) return !left && !right; + return left.mode === right.mode && left.routes.join("\n") === right.routes.join("\n"); +} + +interface FamilyRow { + anthropicFamilyTier: string; + isFamilyDefault?: boolean; +} + +/** + * Drop hidden rows from a generated Desktop list (`routes[i]` is the route of `models[i]`). + * + * A hidden family default hands its role to the first remaining member of that family, and + * the defaults are moved to the front so the written list keeps the shape + * `renderDesktopProfile` produces: defaults first, in family order, then everything else. + */ +export function filterDesktopModelsByVisibility( + models: readonly T[], + routes: readonly string[], + visibility: DesktopModelVisibility | undefined, +): T[] { + if (!visibility) return [...models]; + const kept = models.filter((_, index) => desktopRouteVisible(visibility, routes[index] ?? "")); + if (kept.length === models.length) return kept; + const families: string[] = []; + for (const model of models) if (!families.includes(model.anthropicFamilyTier)) families.push(model.anthropicFamilyTier); + const defaults: T[] = []; + for (const family of families) { + const members = kept.filter(model => model.anthropicFamilyTier === family); + const chosen = members.find(model => model.isFamilyDefault) ?? members[0]; + if (chosen) defaults.push(chosen); + } + const defaultSet = new Set(defaults); + return [ + ...defaults.map(model => ({ ...model, isFamilyDefault: true })), + ...kept.filter(model => !defaultSet.has(model)).map(({ isFamilyDefault: _drop, ...model }) => model as T), + ]; +} + +/** The persisted selection, or undefined when unset or unreadable (unreadable lists everything). */ +export function readDesktopModelVisibility(claudeCode: { desktopModelVisibility?: unknown } | undefined): DesktopModelVisibility | undefined { + if (claudeCode?.desktopModelVisibility === undefined) return undefined; + try { + return parseDesktopModelVisibility(claudeCode.desktopModelVisibility); + } catch { + return undefined; + } +} + +export interface AppliedDesktopModel { + name: string; + label: string; +} + +/** + * The `inferenceModels` rows of the profile Desktop has selected, for the dashboard's + * applied count and added/removed preview. `null` when there is no static list to read + * (no profile, discovery-only, or unreadable): unknown, not empty. Names and labels only. + */ +export function readAppliedDesktopModels(selectedProfilePath: string | null): AppliedDesktopModel[] | null { + if (!selectedProfilePath) return null; + try { + const parsed = JSON.parse(readDesktopLibraryText(selectedProfilePath)) as { inferenceModels?: unknown }; + if (!Array.isArray(parsed.inferenceModels)) return null; + return parsed.inferenceModels.flatMap(row => { + if (!isPlainObject(row) || typeof row.name !== "string") return []; + return [{ name: row.name, label: typeof row.labelOverride === "string" ? row.labelOverride : row.name }]; + }); + } catch { + return null; + } +} diff --git a/src/config/salvage.ts b/src/config/salvage.ts index 624e67ed58a..7ab64a336ef 100644 --- a/src/config/salvage.ts +++ b/src/config/salvage.ts @@ -31,6 +31,7 @@ const SALVAGEABLE_CONFIG_SECTIONS = ["routingProfiles", "combos"] as const; /** Optional nested fields that can be dropped whole without changing the rest of the document. */ const SALVAGEABLE_OPTIONAL_FIELDS: ReadonlyArray = [ ["claudeCode", "desktopProfile"], + ["claudeCode", "desktopModelVisibility"], ]; function isSalvageableConfigPath(section: string, id: string): boolean { diff --git a/src/config/schema/config-schema.ts b/src/config/schema/config-schema.ts index d4a561836f3..9fdb717c8e1 100644 --- a/src/config/schema/config-schema.ts +++ b/src/config/schema/config-schema.ts @@ -81,6 +81,7 @@ import { OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers"; import { modelAutoCompactTokenLimitsConfigError } from "../../providers/auto-compact-budget"; import { hasFastWireCapabilityConflict } from "../../providers/fastwire"; import { parseDesktopProfile } from "../../claude/desktop-profile"; +import { parseDesktopModelVisibility } from "../../claude/desktop-model-visibility"; import { isInterceptBindingId, isInterceptBindingRoute } from "../../claude/intercept/model-bindings"; import { DEFAULT_APP_OWNED_MEMORY_BUDGET_BYTES, MAX_APP_OWNED_MEMORY_BUDGET_MB, MIN_APP_OWNED_MEMORY_BUDGET_MB } from "../../lib/app-owned-memory"; @@ -408,7 +409,7 @@ export const configSchema = z.object({ if (claudeCode !== undefined && (!claudeCode || typeof claudeCode !== "object" || Array.isArray(claudeCode))) { ctx.addIssue({ code: "custom", path: ["claudeCode"], message: "claudeCode must be an object" }); } else if (claudeCode) { - const claude = claudeCode as { desktopProfile?: unknown; desktopMode?: unknown; intercept?: unknown }; + const claude = claudeCode as { desktopProfile?: unknown; desktopModelVisibility?: unknown; desktopMode?: unknown; intercept?: unknown }; if (claude.desktopMode !== undefined && claude.desktopMode !== "first-party" && claude.desktopMode !== "gateway") { ctx.addIssue({ code: "custom", path: ["claudeCode", "desktopMode"], message: "desktopMode must be \"first-party\" or \"gateway\"" }); } @@ -453,6 +454,17 @@ export const configSchema = z.object({ }); } } + if (claude.desktopModelVisibility !== undefined) { + try { + parseDesktopModelVisibility(claude.desktopModelVisibility); + } catch (error) { + ctx.addIssue({ + code: "custom", + path: ["claudeCode", "desktopModelVisibility"], + message: error instanceof Error ? error.message : String(error), + }); + } + } } const accountNamespaces = config.codexAccountNamespaces; diff --git a/src/server/management/agent-settings-routes.ts b/src/server/management/agent-settings-routes.ts index 5cfd3133c98..9ca7df76e7d 100644 --- a/src/server/management/agent-settings-routes.ts +++ b/src/server/management/agent-settings-routes.ts @@ -981,13 +981,19 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise } } if (url.pathname === "/api/claude-desktop" && req.method === "PUT") { - let body: { profile?: unknown }; + let body: { profile?: unknown; visibility?: unknown }; try { body = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); } try { const { parseDesktopProfile, preserveDesktopAppliedState, reconcileDesktopProfile, sameProfileContent } = await import("../../claude/desktop-profile"); const parsed = parseDesktopProfile(body.profile); const initial = loadConfig(); const current = await buildClaudeDesktopState(initial); + // #7702: absent keeps the stored selection, null clears it (list every model). + const { parseDesktopModelVisibility, desktopRouteVisible } = await import("../../claude/desktop-model-visibility"); + const visibility = body.visibility === undefined || body.visibility === null ? body.visibility : parseDesktopModelVisibility(body.visibility); + if (visibility && !current.models.some(model => model.available && desktopRouteVisible(visibility, model.route))) { + throw new Error("Claude Desktop needs at least one visible available model"); + } const availableRoutes = new Set(current.models.filter(item => item.available).map(item => item.route)); for (const route of Object.keys(parsed.assignments)) { if (!current.profile.assignments[route] && !availableRoutes.has(route)) { @@ -1024,6 +1030,8 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise ...(persisted.claudeCode ?? {}), desktopProfile: preserveDesktopAppliedState(latest ?? current.profile, rebuilt), }; + if (visibility) persisted.claudeCode.desktopModelVisibility = visibility; + else if (visibility === null) delete persisted.claudeCode.desktopModelVisibility; return { changed: true, value: structuredClone(persisted.claudeCode) }; }); if (outcome.status === "unavailable" && outcome.reason !== "conflict") { @@ -1041,6 +1049,10 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise desktopProfile: structuredClone(outcome.value.desktopProfile), }; } + if (visibility !== undefined && config.claudeCode) { + if (outcome.value?.desktopModelVisibility) config.claudeCode.desktopModelVisibility = structuredClone(outcome.value.desktopModelVisibility); + else delete config.claudeCode.desktopModelVisibility; + } const saved = await buildClaudeDesktopState(config); const runtimePort = managementInferencePort(ctx); return jsonResponse({ ok: true, ...saved, port: runtimePort }); @@ -1335,6 +1347,8 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise savedFingerprint, onDiskFingerprint: observed.fingerprint ?? null, configPath: observed.selectedProfilePath, + // The list Desktop runs now (#7702), so the dashboard can count and diff against it. + appliedModels: gatewayApplied ? (await import("../../claude/desktop-model-visibility")).readAppliedDesktopModels(observed.selectedProfilePath) : null, stale, // Tri-state by ID match, independent of profile health: null = // undeterminable (no/unreadable metadata or no appliedId); a readable diff --git a/src/server/management/shared.ts b/src/server/management/shared.ts index 965f5b8f12c..2c22e5751d3 100644 --- a/src/server/management/shared.ts +++ b/src/server/management/shared.ts @@ -409,10 +409,14 @@ export async function buildClaudeDesktopState(config: OcxConfig, stored?: OcxCla for (const id of desktopVisibleNativeSlugs(config)) { effortByRoute.set(`native/${id}`, true); } + const { desktopRouteVisible, filterDesktopModelsByVisibility, readDesktopModelVisibility } = await import("../../claude/desktop-model-visibility"); + const visibility = readDesktopModelVisibility(config.claudeCode); const models = Object.keys(profile.assignments).sort().map(route => ({ route, label: modelByRoute.get(route)?.label ?? route, available: available.has(route), + // Desktop list membership (#7702), separate from the tier assignment below. + visible: desktopRouteVisible(visibility, route), ...(modelByRoute.get(route)?.contextWindow ? { contextWindow: modelByRoute.get(route)!.contextWindow } : {}), effortSupported: effortByRoute.get(route) ?? false, // Read-only view of the 1M capability the written Desktop config already emits, @@ -420,10 +424,20 @@ export async function buildClaudeDesktopState(config: OcxConfig, stored?: OcxCla supports1m: routeSupportsOneMillion(route, modelByRoute.get(route)?.contextWindow), assignment: profile.assignments[route]!, })); + const rendered = renderDesktopProfile(profile, profileModels); + const listed = filterDesktopModelsByVisibility( + rendered.map(model => ({ ...model, anthropicFamilyTier: model.family })), + rendered.map(model => model.route), + visibility, + ); return { profile, models, - rendered: renderDesktopProfile(profile, profileModels), + rendered, + visibility: visibility ?? null, + // What the next apply writes, in order: `rendered` narrowed by visibility. Empty means the + // writer would fall back to the full list (see generateDesktop3pModels). + listed: listed.map(({ route, name, label, family, isFamilyDefault }) => ({ route, name, label, family, isFamilyDefault: !!isFamilyDefault })), port: config.port, }; } diff --git a/src/types.ts b/src/types.ts index 5e97032e752..d2d307322be 100644 --- a/src/types.ts +++ b/src/types.ts @@ -74,6 +74,7 @@ export type { OcxClaudeCodeConfig, OcxClaudeDesktopFamily, OcxClaudeDesktopAssignment, + OcxClaudeDesktopModelVisibility, OcxClaudeDesktopProfile, StorageCleanupPolicy, OcxCustomModel, diff --git a/src/types/config.ts b/src/types/config.ts index 479895aa2c4..81ccaa72df7 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -256,6 +256,18 @@ export interface OcxClaudeCodeConfig { * Routing-sidecar alias decoding is unchanged — only the Desktop model list writer. */ desktopNativeModels?: boolean; + /** + * Which models the Claude Desktop 3P list carries (src/claude/desktop-model-visibility.ts). + * Unset lists every available model. Independent of desktopProfile tiers and of + * provider-wide disabledModels; other clients are unaffected. + */ + desktopModelVisibility?: OcxClaudeDesktopModelVisibility; +} + +/** `include`: only `routes` (new models hidden). `exclude`: all but `routes` (new models listed). */ +export interface OcxClaudeDesktopModelVisibility { + mode: "include" | "exclude"; + routes: string[]; } export type OcxClaudeDesktopFamily = "opus" | "fable" | "sonnet" | "haiku"; diff --git a/structure/INDEX.md b/structure/INDEX.md index f33039a1cfd..cf9a260e0e6 100644 --- a/structure/INDEX.md +++ b/structure/INDEX.md @@ -119,6 +119,7 @@ The dashboard, the management API, and third-party client config ownership. | [`clients/chatgpt-desktop.md`](clients/chatgpt-desktop.md) | Experimental macOS app-server stdout shim, opt-in launch, restore and failure boundaries. | | [`clients/claude-desktop.md`](clients/claude-desktop.md) | Claude Desktop profile ownership and config-library resolution. | | [`clients/claude-desktop-config-reads.md`](clients/claude-desktop-config-reads.md) | Verified regular-file reads of Claude Desktop config-library metadata, profiles and snapshots. | +| [`clients/claude-desktop-model-visibility.md`](clients/claude-desktop-model-visibility.md) | Per-client selection of the rows the Claude Desktop 3P model list carries. | | [`clients/qoder.md`](clients/qoder.md) | Qoder settings.json provider and custom-model ownership, export and loopback placeholder rules. | | [`companion.md`](companion.md) | Shared timeline filtering, usage/quotas, native and web tray title, and WidgetKit display contracts. | | [`codex-account-controls.md`](codex-account-controls.md) | Account selection order, custom usage thresholds, and stable account-card editing. | @@ -187,7 +188,7 @@ A source area can be described by more than one doc, because these docs are orga | `src/chat/` | [`runtime.md`](runtime.md)
[`transports/byte-accounting.md`](transports/byte-accounting.md)
[`transports/inventory.md`](transports/inventory.md)
[`data-planes/inbound-compat.md`](data-planes/inbound-compat.md)
[`providers-and-adapters.md`](providers-and-adapters.md)
[`providers/chat-compat.md`](providers/chat-compat.md) | | `src/chat/reasoning-details.ts` | [`data-planes/chat-reasoning-details.md`](data-planes/chat-reasoning-details.md) | | `src/chatgpt/` | [`clients/chatgpt-desktop.md`](clients/chatgpt-desktop.md) | -| `src/claude/` | [`runtime.md`](runtime.md)
[`providers/anthropic-progress.md`](providers/anthropic-progress.md)
[`clients/claude-desktop.md`](clients/claude-desktop.md)
[`clients/claude-desktop-config-reads.md`](clients/claude-desktop-config-reads.md) | +| `src/claude/` | [`runtime.md`](runtime.md)
[`providers/anthropic-progress.md`](providers/anthropic-progress.md)
[`clients/claude-desktop.md`](clients/claude-desktop.md)
[`clients/claude-desktop-config-reads.md`](clients/claude-desktop-config-reads.md)
[`clients/claude-desktop-model-visibility.md`](clients/claude-desktop-model-visibility.md) | | `src/cli.ts` | [`runtime.md`](runtime.md)
[`ops/docs-and-release.md`](ops/docs-and-release.md) | | `src/cli/` | [`runtime.md`](runtime.md)
[`config.md`](config.md)
[`remote-messaging.md`](remote-messaging.md)
[`local-messaging.md`](local-messaging.md)
[`cli-management.md`](cli-management.md)
[`provider-completion-diagnostic.md`](provider-completion-diagnostic.md)
[`fallback-policy.md`](fallback-policy.md)
[`clients/integrations.md`](clients/integrations.md)
[`clients/codex-desktop.md`](clients/codex-desktop.md)
[`clients/chatgpt-desktop.md`](clients/chatgpt-desktop.md)
[`clients/claude-desktop.md`](clients/claude-desktop.md)
[`ops/docs-and-release.md`](ops/docs-and-release.md) | | `src/cli/account-preservation.ts` | [`providers/openai-signed-in-preservation.md`](providers/openai-signed-in-preservation.md) | @@ -281,7 +282,7 @@ A source area can be described by more than one doc, because these docs are orga | `src/router.ts` | [`runtime.md`](runtime.md) | | `src/routing/` | [`catalog.md`](catalog.md)
[`fallback-policy.md`](fallback-policy.md) | | `src/routing/history/` | [`dashboard-and-usage.md`](dashboard-and-usage.md) | -| `src/server/` | [`providers/generic-account-pool.md`](providers/generic-account-pool.md)
[`runtime.md`](runtime.md)
[`codex-prompts.md`](codex-prompts.md)
[`catalog.md`](catalog.md)
[`subagents.md`](subagents.md)
[`transports/byte-accounting.md`](transports/byte-accounting.md)
[`transports/responses.md`](transports/responses.md)
[`transports/native-reasoning-retention.md`](transports/native-reasoning-retention.md)
[`transports/responses-wire-shapes.md`](transports/responses-wire-shapes.md)
[`transports/responses-failover.md`](transports/responses-failover.md)
[`transports/responses-combo-boundaries.md`](transports/responses-combo-boundaries.md)
[`transports/policy-fallback.md`](transports/policy-fallback.md)
[`transports/streaming-health.md`](transports/streaming-health.md)
[`transports/inventory.md`](transports/inventory.md)
[`data-planes/images.md`](data-planes/images.md)
[`data-planes/inbound-compat.md`](data-planes/inbound-compat.md)
[`providers/anthropic-progress.md`](providers/anthropic-progress.md)
[`providers-and-adapters.md`](providers-and-adapters.md)
[`providers/jev-decision.md`](providers/jev-decision.md)
[`providers/xai-grok.md`](providers/xai-grok.md)
[`adapters/registry.md`](adapters/registry.md)
[`gui-and-management-api.md`](gui-and-management-api.md)
[`provider-completion-diagnostic.md`](provider-completion-diagnostic.md)
[`dashboard-and-usage.md`](dashboard-and-usage.md)
[`fallback-policy.md`](fallback-policy.md)
[`clients/claude-desktop.md`](clients/claude-desktop.md)
[`ops/service-and-sidecars.md`](ops/service-and-sidecars.md) | +| `src/server/` | [`providers/generic-account-pool.md`](providers/generic-account-pool.md)
[`runtime.md`](runtime.md)
[`codex-prompts.md`](codex-prompts.md)
[`catalog.md`](catalog.md)
[`subagents.md`](subagents.md)
[`transports/byte-accounting.md`](transports/byte-accounting.md)
[`transports/responses.md`](transports/responses.md)
[`transports/native-reasoning-retention.md`](transports/native-reasoning-retention.md)
[`transports/responses-wire-shapes.md`](transports/responses-wire-shapes.md)
[`transports/responses-failover.md`](transports/responses-failover.md)
[`transports/responses-combo-boundaries.md`](transports/responses-combo-boundaries.md)
[`transports/policy-fallback.md`](transports/policy-fallback.md)
[`transports/streaming-health.md`](transports/streaming-health.md)
[`transports/inventory.md`](transports/inventory.md)
[`data-planes/images.md`](data-planes/images.md)
[`data-planes/inbound-compat.md`](data-planes/inbound-compat.md)
[`providers/anthropic-progress.md`](providers/anthropic-progress.md)
[`providers-and-adapters.md`](providers-and-adapters.md)
[`providers/jev-decision.md`](providers/jev-decision.md)
[`providers/xai-grok.md`](providers/xai-grok.md)
[`adapters/registry.md`](adapters/registry.md)
[`gui-and-management-api.md`](gui-and-management-api.md)
[`provider-completion-diagnostic.md`](provider-completion-diagnostic.md)
[`dashboard-and-usage.md`](dashboard-and-usage.md)
[`fallback-policy.md`](fallback-policy.md)
[`clients/claude-desktop.md`](clients/claude-desktop.md)
[`clients/claude-desktop-model-visibility.md`](clients/claude-desktop-model-visibility.md)
[`ops/service-and-sidecars.md`](ops/service-and-sidecars.md) | | `src/server/gui-pair-delivery.ts` | [`remote-link.md`](remote-link.md) | | `src/server/index.ts` | [`adapters/compatibility-lab.md`](adapters/compatibility-lab.md) | | `src/server/index/link-management.ts` | [`remote-link.md`](remote-link.md) | diff --git a/structure/clients/claude-desktop-config-reads.md b/structure/clients/claude-desktop-config-reads.md index db1cd7ab2aa..45ca5634ac0 100644 --- a/structure/clients/claude-desktop-config-reads.md +++ b/structure/clients/claude-desktop-config-reads.md @@ -2,7 +2,8 @@ Every read of Desktop library configuration — selected-profile inspection, `_meta.json` parsing, foreign-key preservation during writes, backup creation and rollback, refresh mode re-resolution, the refresh fingerprint hash, -the picker profile's inspection and apply snapshots, and the connection-owned store's metadata and +the picker profile's inspection and apply snapshots, the status route's applied model list +(`readAppliedDesktopModels`, names and labels only), and the connection-owned store's metadata and profile reads — goes through the shared verified regular-file reader in `src/claude/desktop-3p-library.ts` (`readDesktopLibraryBytes`/`readDesktopLibraryText`). The path `lstat` must already name a regular file; on POSIX the open is nonblocking and no-follow, so a path diff --git a/structure/clients/claude-desktop-model-visibility.md b/structure/clients/claude-desktop-model-visibility.md new file mode 100644 index 00000000000..73f34a8a636 --- /dev/null +++ b/structure/clients/claude-desktop-model-visibility.md @@ -0,0 +1,54 @@ +# Claude Desktop Model Visibility + +`claudeCode.desktopModelVisibility` chooses which rows the Claude Desktop 3P `inferenceModels` +list carries (#7702). It is parsed and applied in `src/claude/desktop-model-visibility.ts`. + +The selection has two shapes. `include` lists only its `routes`, so a model discovered later stays +hidden until someone selects it. `exclude` lists everything except its `routes`, so a new model is +listed. Unset means every available model is listed, which is what installs did before the +setting existed. Routes are stored sorted and unique, capped at `MAX_DESKTOP_VISIBILITY_ROUTES`. + +The selection is kept apart from three other settings: + +- **The Desktop profile.** `claudeCode.desktopProfile` holds tier families, aliases and family + defaults. A hidden model keeps its assignment and alias, so showing it again restores the same + Desktop id. Profile saves never rewrite the selection, and the selection never rewrites the + profile. +- **Provider-wide visibility.** `disabledModels` and provider model lists still decide what is + available at all. The selection can only narrow what Desktop lists from that set. +- **Other clients.** Only the static and hybrid Desktop writers read the selection. Claude Code, + Codex and `/v1/models` discovery are unchanged. Discovery-only mode emits no static list, so the + selection does not apply to it. + +## Where it applies + +`generateDesktop3pModels` builds the full list and the alias registry first, then filters only +the emitted rows by route. Every writer gets the selection through `desktop3pModelOptions(claudeCode)`: +the local gateway writer (`writeDesktop3pConfig`, which reads the latest config under its locks), +catalog-driven refreshes, and the hub's `wantsDesktopConfig` export, which connected clients +write verbatim. The decode registry keeps hidden routes, so a running Desktop session that still +sends a just-hidden alias keeps routing. + +When a hidden row was a family default, the first remaining member of that family takes the role. +Defaults are moved back to the front, which keeps the order `renderDesktopProfile` writes. A +selection that would leave the list empty writes the full list and logs a warning, because Desktop +with an empty static list has no usable picker. The management API refuses to save such a selection. + +## Persistence and the dashboard + +`PUT /api/claude-desktop` (`src/server/management/agent-settings-routes.ts`) accepts `visibility` +alongside `profile`. An absent field keeps the +stored selection, `null` clears it, and an object replaces it. The selection is written in the +same field-scoped config mutation as the profile. `GET /api/claude-desktop` (`buildClaudeDesktopState` in +`src/server/management/shared.ts`) returns the selection, +a per-model `visible` flag, and `listed`, the rows the next apply writes. `GET +/api/claude-desktop/status` returns `appliedModels`, the names and labels Desktop runs now, read +through the verified library reader. The dashboard derives its selected, available and applied +counts and its added/removed preview from these fields. When the saved selection differs from the +applied list, the status bar reports a pending apply rather than an up-to-date config. + +A malformed hand-edited selection fails config validation at +`claudeCode.desktopModelVisibility`. Salvage drops that one field, so the rest of the config loads. + +Tests: `tests/claude-integration/claude-desktop-model-visibility.test.ts`, +`gui/tests/claude-desktop-visibility.test.ts`, and `gui/tests/claude-desktop-visibility-card.test.tsx`. diff --git a/structure/manifest.json b/structure/manifest.json index 7b0d19d4260..1c2c055e943 100644 --- a/structure/manifest.json +++ b/structure/manifest.json @@ -839,6 +839,16 @@ "src/claude/" ] }, + { + "path": "clients/claude-desktop-model-visibility.md", + "tier": 5, + "title": "Claude Desktop Model Visibility", + "scope": "Per-client selection of the rows the Claude Desktop 3P model list carries.", + "documents": [ + "src/claude/", + "src/server/" + ] + }, { "path": "clients/qoder.md", "tier": 5, diff --git a/tests/claude-integration/claude-desktop-model-visibility.test.ts b/tests/claude-integration/claude-desktop-model-visibility.test.ts new file mode 100644 index 00000000000..502801ccc2f --- /dev/null +++ b/tests/claude-integration/claude-desktop-model-visibility.test.ts @@ -0,0 +1,200 @@ +import { afterEach, beforeEach, expect, setDefaultTimeout, test } from "bun:test"; +import { managementFetch as fetch } from "../helpers/management-auth"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { loadConfig, saveConfig } from "../../src/config"; +import { startServer as startServerImpl } from "../../src/server"; +import { + desktop3pAlias, + desktop3pModelOptions, + generateDesktop3pConfig, + generateDesktop3pModels, + removeDesktop3pStandardPivot, + resolveDesktop3pAlias, + writeDesktop3pConfig, +} from "../../src/claude/desktop-3p"; +import { reconcileDesktopProfile, moveDesktopRoute } from "../../src/claude/desktop-profile"; +import { + desktopRouteVisible, + filterDesktopModelsByVisibility, + parseDesktopModelVisibility, +} from "../../src/claude/desktop-model-visibility"; +import type { OcxConfig } from "../../src/types"; +import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +/* + * #7702: a per-client Claude Desktop model selection. It narrows only the written Desktop + * list; the alias registry, the tier profile and every other client stay as they were. + */ + +setDefaultTimeout(30_000); + +const ROUTED = [ + { provider: "google", id: "gemini-3-pro" }, + { provider: "deepseek", id: "deepseek-v4" }, + { provider: "xai", id: "grok-5" }, +]; + +test("parse accepts include/exclude, sorts and dedupes routes, and rejects malformed input", () => { + expect(parseDesktopModelVisibility({ mode: "include", routes: ["xai/grok-5", "google/gemini-3-pro", "xai/grok-5"] })) + .toEqual({ mode: "include", routes: ["google/gemini-3-pro", "xai/grok-5"] }); + expect(() => parseDesktopModelVisibility({ mode: "all", routes: [] })).toThrow("mode"); + expect(() => parseDesktopModelVisibility({ mode: "include", routes: ["no-slash"] })).toThrow("provider/model"); + expect(() => parseDesktopModelVisibility({ mode: "include", routes: [], extra: 1 })).toThrow("unknown field"); + expect(() => parseDesktopModelVisibility(["google/gemini-3-pro"])).toThrow("object"); +}); + +test("include hides newly discovered routes, exclude lists them", () => { + expect(desktopRouteVisible(undefined, "new/model")).toBe(true); + expect(desktopRouteVisible({ mode: "include", routes: ["google/gemini-3-pro"] }, "new/model")).toBe(false); + expect(desktopRouteVisible({ mode: "exclude", routes: ["xai/grok-5"] }, "new/model")).toBe(true); + expect(desktopRouteVisible({ mode: "exclude", routes: ["xai/grok-5"] }, "xai/grok-5")).toBe(false); +}); + +test("the written list drops hidden rows while the registry still decodes them", () => { + const visibility = { mode: "exclude" as const, routes: ["google/gemini-3-pro"] }; + const models = generateDesktop3pModels([], ROUTED, undefined, undefined, { visibility }); + const hiddenAlias = desktop3pAlias("google", "gemini-3-pro"); + expect(models.map(model => model.name)).not.toContain(hiddenAlias); + expect(models).toHaveLength(2); + // A running Desktop session that still sends the hidden alias keeps routing. + expect(resolveDesktop3pAlias(hiddenAlias)).toBe("google/gemini-3-pro"); + // The hidden row was the family default; exactly one remaining row takes the role. + expect(models.filter(model => model.isFamilyDefault)).toHaveLength(1); + expect(models[0]!.isFamilyDefault).toBe(true); +}); + +test("a hidden profile default hands the family role to a remaining member", () => { + const profileModels = ROUTED.map(model => ({ route: `${model.provider}/${model.id}`, label: model.id })); + let profile = reconcileDesktopProfile(undefined, profileModels); + profile = moveDesktopRoute(profile, "deepseek/deepseek-v4", "sonnet", true); + const all = generateDesktop3pModels([], ROUTED, profile); + const hidden = generateDesktop3pModels([], ROUTED, profile, undefined, { + visibility: { mode: "exclude", routes: [profile.defaults.opus!] }, + }); + expect(all).toHaveLength(3); + expect(hidden).toHaveLength(2); + for (const family of ["opus", "sonnet"] as const) { + expect(hidden.filter(model => model.anthropicFamilyTier === family && model.isFamilyDefault)).toHaveLength(1); + } + // Defaults still lead the list, as renderDesktopProfile writes it. + expect(hidden.slice(0, 2).every(model => model.isFamilyDefault)).toBe(true); +}); + +test("a selection that hides every model falls back to the full list instead of an empty picker", () => { + const models = generateDesktop3pModels([], ROUTED, undefined, undefined, { visibility: { mode: "include", routes: ["gone/model"] } }); + expect(models).toHaveLength(3); +}); + +test("filter keeps the original list when nothing is hidden", () => { + const rows = [{ anthropicFamilyTier: "opus", isFamilyDefault: true }, { anthropicFamilyTier: "opus" }]; + expect(filterDesktopModelsByVisibility(rows, ["a/x", "b/y"], { mode: "exclude", routes: [] })).toEqual(rows); +}); + +test("the generated config and model options carry the persisted selection; an unreadable one lists everything", () => { + const options = desktop3pModelOptions({ desktopModelVisibility: { mode: "include", routes: ["xai/grok-5"] } }); + const config = generateDesktop3pConfig(10100, [], ROUTED, "ocx", "static", undefined, undefined, options) as { inferenceModels: Array<{ name: string }> }; + expect(config.inferenceModels.map(model => model.name)).toEqual([desktop3pAlias("xai", "grok-5")]); + expect(desktop3pModelOptions({ desktopModelVisibility: { mode: "bogus" } }).visibility).toBeUndefined(); +}); + +let testDir = ""; +let previousHome: string | undefined; +let previousDesktopConfigDir: string | undefined; +let isolatedCodexHome: IsolatedCodexHome | null = null; + +function startServer() { + return startServerImpl(0, { + managementApi: { + writeDesktop3pConfig: (port, slugs, models, key, mode, profile, cap) => + writeDesktop3pConfig(port, slugs, models, key, mode, profile, cap, { lockPath: join(testDir, "lifecycle.sqlite") }), + removeDesktop3pStandardPivot: options => removeDesktop3pStandardPivot({ ...options, lifecycleLockDeps: { lockPath: join(testDir, "lifecycle.sqlite") } }), + }, + }); +} + +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + previousDesktopConfigDir = process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR; + isolatedCodexHome = installIsolatedCodexHome("ocx-desktop-visibility-"); + testDir = mkdtempSync(join(tmpdir(), "ocx-desktop-visibility-")); + process.env.OPENCODEX_HOME = testDir; + process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR = join(testDir, "claude-desktop"); + saveConfig({ + port: 0, + defaultProvider: "mock", + providers: { + mock: { adapter: "openai-chat", baseUrl: "http://127.0.0.1:1/v1", apiKey: "k", allowPrivateNetwork: true, liveModels: false, models: ["keep-model", "hide-model"] }, + }, + claudeCode: { desktopNativeModels: false }, + } as OcxConfig); +}); + +afterEach(() => { + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + if (previousDesktopConfigDir === undefined) delete process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR; + else process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR = previousDesktopConfigDir; + isolatedCodexHome?.restore(); + isolatedCodexHome = null; + if (testDir) removeTreeWithRetry(testDir); +}); + +test("an invalid hand-edited selection is dropped alone; providers survive", () => { + const path = join(testDir, "config.json"); + const raw = JSON.parse(readFileSync(path, "utf8")) as Record; + raw.claudeCode = { ...raw.claudeCode, desktopModelVisibility: { mode: "include", routes: "mock/keep-model" } }; + writeFileSync(path, JSON.stringify(raw)); + const loaded = loadConfig(); + expect(Object.keys(loaded.providers)).toContain("mock"); + expect(loaded.claudeCode?.desktopNativeModels).toBe(false); + expect(loaded.claudeCode?.desktopModelVisibility).toBeUndefined(); +}); + +test("dashboard save persists the selection, apply writes only selected rows, and status reports them", async () => { + const server = startServer(); + const api = (path: string, init?: RequestInit) => fetch(new URL(path, server.url), init); + const put = (body: unknown) => api("/api/claude-desktop", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); + try { + const initial = await api("/api/claude-desktop").then(r => r.json()) as Record; + expect(initial.visibility).toBeNull(); + expect(initial.listed.map((row: { route: string }) => row.route).sort()).toEqual(["mock/hide-model", "mock/keep-model"]); + + // Hiding everything is refused: Desktop would have nothing to pick. + const refused = await put({ profile: initial.profile, visibility: { mode: "include", routes: [] } }); + expect(refused.status).toBe(400); + expect(loadConfig().claudeCode?.desktopModelVisibility).toBeUndefined(); + + const saved = await put({ profile: initial.profile, visibility: { mode: "include", routes: ["mock/keep-model"] } }); + expect(saved.status).toBe(200); + const savedBody = await saved.json() as Record; + expect(savedBody.visibility).toEqual({ mode: "include", routes: ["mock/keep-model"] }); + expect(savedBody.models.find((model: { route: string }) => model.route === "mock/hide-model").visible).toBe(false); + expect(savedBody.listed.map((row: { route: string }) => row.route)).toEqual(["mock/keep-model"]); + // Tier mapping is untouched: the hidden model keeps its assignment. + expect(loadConfig().claudeCode?.desktopProfile?.assignments["mock/hide-model"]).toBeDefined(); + expect(loadConfig().claudeCode?.desktopModelVisibility).toEqual({ mode: "include", routes: ["mock/keep-model"] }); + + // A later profile-only save keeps the selection. + expect((await put({ profile: savedBody.profile })).status).toBe(200); + expect(loadConfig().claudeCode?.desktopModelVisibility?.mode).toBe("include"); + + const apply = await api("/api/claude-desktop/apply", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ mode: "gateway" }) }); + expect(apply.status).toBe(200); + const { path } = await apply.json() as { path: string }; + const written = JSON.parse(readFileSync(path, "utf8")) as { inferenceModels: Array<{ name: string }> }; + const keepName = savedBody.listed[0].name as string; + expect(written.inferenceModels.map(model => model.name)).toEqual([keepName]); + + const status = await api("/api/claude-desktop/status").then(r => r.json()) as { appliedModels: Array<{ name: string; label: string }> | null }; + expect(status.appliedModels?.map(model => model.name)).toEqual([keepName]); + + // null clears the selection: every model is listed again. + expect((await put({ profile: savedBody.profile, visibility: null })).status).toBe(200); + expect(loadConfig().claudeCode?.desktopModelVisibility).toBeUndefined(); + } finally { + await server.stop(true); + } +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 13553f9b241..0852be2b2cf 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -367,7 +367,7 @@ "claude-cli-tool-capture.test.ts": "providers", "claude-cli-usage-admission.test.ts": "providers", "claude-config-first-party.test.ts": "cli", "claude-code-thought-signature-scope.test.ts": "claude-integration", "claude-compatibility.test.ts": "claude-integration", "claude-context-windows.test.ts": "claude-integration", - "claude-desktop-1m.test.ts": "claude-integration", "claude-desktop-cli.test.ts": "claude-integration", + "claude-desktop-1m.test.ts": "claude-integration", "claude-desktop-cli.test.ts": "claude-integration", "claude-desktop-model-visibility.test.ts": "claude-integration", "claude-desktop-config-path.test.ts": "claude-integration", "claude-desktop-discovery.test.ts": "claude-integration", "claude-desktop-first-party.test.ts": "claude-integration", "claude-desktop-first-party-guards.test.ts": "claude-integration", From ac091b7dd908f4438565a692d4d73b8d132e4604 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:57:53 +0900 Subject: [PATCH 2/6] fix(claude): preserve Desktop visibility intent and projection state Keep saved-versus-applied status independent of draft edits, match the next-apply DTO to the existing writer fallback, and refuse concurrent visibility replacement during discovery. Isolate lifecycle locks and await fixture background work on Windows. --- gui/src/pages/ClaudeDesktop.tsx | 2 +- .../claude-desktop-visibility-card.test.tsx | 8 +++ .../management/agent-settings-routes.ts | 5 +- src/server/management/shared.ts | 7 +- .../claude-desktop-model-visibility.md | 10 ++- .../claude-desktop-model-visibility.test.ts | 66 +++++++++++++++++-- 6 files changed, 86 insertions(+), 12 deletions(-) diff --git a/gui/src/pages/ClaudeDesktop.tsx b/gui/src/pages/ClaudeDesktop.tsx index 03f41bed73a..8971d0566d9 100644 --- a/gui/src/pages/ClaudeDesktop.tsx +++ b/gui/src/pages/ClaudeDesktop.tsx @@ -573,7 +573,7 @@ export default function ClaudeDesktop({ const noneVisible = visibilityCounts(data.models, visibility).selected === 0 && data.models.some(model => model.available); // Saved but not yet written: Desktop's list differs from what the saved selection produces. const savedPreview = Array.isArray(data.rendered) ? applyPreview(desiredDesktopList(data.rendered, savedVisibility), status?.appliedModels ?? null) : null; - const pendingApply = !dirty && effectiveMode === "gateway" && !!savedPreview && (savedPreview.added.length > 0 || savedPreview.removed.length > 0); + const pendingApply = effectiveMode === "gateway" && !!savedPreview && (savedPreview.added.length > 0 || savedPreview.removed.length > 0); return ( <> diff --git a/gui/tests/claude-desktop-visibility-card.test.tsx b/gui/tests/claude-desktop-visibility-card.test.tsx index cde87ee568d..965c79b0f94 100644 --- a/gui/tests/claude-desktop-visibility-card.test.tsx +++ b/gui/tests/claude-desktop-visibility-card.test.tsx @@ -146,3 +146,11 @@ test("clearing every shown model blocks saving", async () => { expect(container.textContent).toContain("Select at least one available model."); expect(button("Save").disabled).toBe(true); }); + +test("editing a draft keeps a newer saved selection pending apply", async () => { + savedVisibility = { mode: "include", routes: ["prov/opus-0"] }; + await mount(); + await act(async () => { checkbox("Opus Model 1").click(); }); + expect(container.querySelector(".claude-dirty")?.textContent).toBe("Unsaved changes"); + expect(container.querySelector(".claude-status-bar")?.textContent).toContain("Saved — apply to update Desktop"); +}); diff --git a/src/server/management/agent-settings-routes.ts b/src/server/management/agent-settings-routes.ts index 1e4c4c62b47..db957a73e72 100644 --- a/src/server/management/agent-settings-routes.ts +++ b/src/server/management/agent-settings-routes.ts @@ -980,7 +980,7 @@ async function handleAgentSettingsRoutesImpl(ctx: ManagementContext): Promise model.available && desktopRouteVisible(visibility, model.route))) { throw new Error("Claude Desktop needs at least one visible available model"); @@ -1018,6 +1018,9 @@ async function handleAgentSettingsRoutesImpl(ctx: ManagementContext): Promise ({ ...model, anthropicFamilyTier: model.family })), rendered.map(model => model.route), visibility, ); + // Match the writer's empty-selection fallback without changing the saved intent. + const listed = selected.length > 0 ? selected : rendered; return { profile, models, rendered, visibility: visibility ?? null, - // What the next apply writes, in order: `rendered` narrowed by visibility. Empty means the - // writer would fall back to the full list (see generateDesktop3pModels). + // What the next apply writes, including generateDesktop3pModels' empty-selection fallback. listed: listed.map(({ route, name, label, family, isFamilyDefault }) => ({ route, name, label, family, isFamilyDefault: !!isFamilyDefault })), port: config.port, }; diff --git a/structure/clients/claude-desktop-model-visibility.md b/structure/clients/claude-desktop-model-visibility.md index 73f34a8a636..1c5983f34aa 100644 --- a/structure/clients/claude-desktop-model-visibility.md +++ b/structure/clients/claude-desktop-model-visibility.md @@ -39,13 +39,17 @@ with an empty static list has no usable picker. The management API refuses to sa `PUT /api/claude-desktop` (`src/server/management/agent-settings-routes.ts`) accepts `visibility` alongside `profile`. An absent field keeps the stored selection, `null` clears it, and an object replaces it. The selection is written in the -same field-scoped config mutation as the profile. `GET /api/claude-desktop` (`buildClaudeDesktopState` in +same field-scoped config mutation as the profile. A supplied replacement or clear compares the +selection again inside that mutation; a concurrent visibility edit during discovery returns 409. +An omitted selection preserves the latest persisted value. `GET /api/claude-desktop` (`buildClaudeDesktopState` in `src/server/management/shared.ts`) returns the selection, a per-model `visible` flag, and `listed`, the rows the next apply writes. `GET /api/claude-desktop/status` returns `appliedModels`, the names and labels Desktop runs now, read through the verified library reader. The dashboard derives its selected, available and applied -counts and its added/removed preview from these fields. When the saved selection differs from the -applied list, the status bar reports a pending apply rather than an up-to-date config. +counts and its added/removed preview from these fields. `listed` includes the writer's full-list +fallback when every selected model is unavailable, without changing the saved selection or default. +When the saved selection differs from the applied list, the status bar reports a pending apply even +while a newer draft has unsaved edits. A malformed hand-edited selection fails config validation at `claudeCode.desktopModelVisibility`. Salvage drops that one field, so the rest of the config loads. diff --git a/tests/claude-integration/claude-desktop-model-visibility.test.ts b/tests/claude-integration/claude-desktop-model-visibility.test.ts index 502801ccc2f..06c5d14b2fa 100644 --- a/tests/claude-integration/claude-desktop-model-visibility.test.ts +++ b/tests/claude-integration/claude-desktop-model-visibility.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, expect, setDefaultTimeout, test } from "bun:test"; +import { afterEach, beforeEach, expect, setDefaultTimeout, spyOn, test } from "bun:test"; import { managementFetch as fetch } from "../helpers/management-auth"; import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -21,8 +21,11 @@ import { parseDesktopModelVisibility, } from "../../src/claude/desktop-model-visibility"; import type { OcxConfig } from "../../src/types"; +import { buildClaudeDesktopState } from "../../src/server/management/shared"; +import * as desktopState from "../../src/server/management/shared"; import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; -import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { removeTreeAfterBackgroundWork } from "../helpers/remove-tree"; +import { isolateClientLifecycleLock } from "../helpers/client-lifecycle-lock"; /* * #7702: a per-client Claude Desktop model selection. It narrows only the written Desktop @@ -104,6 +107,7 @@ let testDir = ""; let previousHome: string | undefined; let previousDesktopConfigDir: string | undefined; let isolatedCodexHome: IsolatedCodexHome | null = null; +let lifecycleLock: ReturnType | null = null; function startServer() { return startServerImpl(0, { @@ -120,6 +124,7 @@ beforeEach(() => { previousDesktopConfigDir = process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR; isolatedCodexHome = installIsolatedCodexHome("ocx-desktop-visibility-"); testDir = mkdtempSync(join(tmpdir(), "ocx-desktop-visibility-")); + lifecycleLock = isolateClientLifecycleLock(testDir); process.env.OPENCODEX_HOME = testDir; process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR = join(testDir, "claude-desktop"); saveConfig({ @@ -132,14 +137,16 @@ beforeEach(() => { } as OcxConfig); }); -afterEach(() => { +afterEach(async () => { + if (testDir) await removeTreeAfterBackgroundWork(testDir); + lifecycleLock?.restore(); + lifecycleLock = null; if (previousHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousHome; if (previousDesktopConfigDir === undefined) delete process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR; else process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR = previousDesktopConfigDir; isolatedCodexHome?.restore(); isolatedCodexHome = null; - if (testDir) removeTreeWithRetry(testDir); }); test("an invalid hand-edited selection is dropped alone; providers survive", () => { @@ -198,3 +205,54 @@ test("dashboard save persists the selection, apply writes only selected rows, an await server.stop(true); } }); + +test("the next-apply projection matches the writer when every selected model disappears", async () => { + const config = loadConfig(); + const initial = await buildClaudeDesktopState(config); + const profile = reconcileDesktopProfile(initial.profile, [{ route: "mock/gone", label: "Gone" }]); + profile.defaults.opus = "mock/gone"; + config.claudeCode = { + ...config.claudeCode, + desktopProfile: profile, + desktopModelVisibility: { mode: "include", routes: ["mock/gone"] }, + }; + const state = await buildClaudeDesktopState(config); + const written = generateDesktop3pModels([], [ + { provider: "mock", id: "keep-model" }, { provider: "mock", id: "hide-model" }, + ], state.profile, undefined, desktop3pModelOptions(config.claudeCode)); + expect(state.models.find(model => model.route === "mock/gone")?.available).toBe(false); + expect(state.visibility).toEqual({ mode: "include", routes: ["mock/gone"] }); + expect(state.profile.defaults.opus).toBe("mock/gone"); + expect(state.listed.map(model => model.name)).toEqual(written.map(model => model.name)); + expect(state.listed).toHaveLength(2); +}); + +test("a visibility change during discovery refuses a stale replacement", async () => { + const server = startServer(); + const endpoint = new URL("/api/claude-desktop", server.url); + const put = (body: unknown) => fetch(endpoint, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); + let discovery: ReturnType | undefined; + try { + const initial = await fetch(endpoint).then(response => response.json()) as { profile: unknown }; + expect((await put({ profile: initial.profile })).status).toBe(200); + const buildState = desktopState.buildClaudeDesktopState; + let changed = false; + discovery = spyOn(desktopState, "buildClaudeDesktopState").mockImplementation(async (...args) => { + const result = await buildState(...args); + if (!changed) { + changed = true; + const concurrent = loadConfig(); + concurrent.claudeCode = { ...concurrent.claudeCode, desktopModelVisibility: { mode: "include", routes: ["mock/keep-model"] } }; + saveConfig(concurrent); + } + return result; + }); + const response = await put({ profile: initial.profile, visibility: { mode: "include", routes: ["mock/hide-model"] } }); + expect(changed).toBe(true); + expect(response.status).toBe(409); + expect(loadConfig().claudeCode?.desktopModelVisibility).toEqual({ mode: "include", routes: ["mock/keep-model"] }); + } finally { + discovery?.mockRestore(); + await server.stop(true); + } +}); From e17dc9166a89ac8f4137122493396b42d99875b0 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:59:50 +0900 Subject: [PATCH 3/6] docs: describe Desktop visibility save conflicts Co-authored-by: ljluestc <63439129+ljluestc@users.noreply.github.com> --- docs-site/src/content/docs/reference/management-api.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index 70c2e398230..9c1b4b53afd 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -86,7 +86,7 @@ route-specific results rather than repeating this table. | `POST /api/grok/reset-coupons/consume` | Redeem an eligible reset coupon. Body `{ accountId?, tokenId?, operationId? }`. Optional `operationId` (UUIDv4) makes redemption idempotent: repeating the same ID replays the durable result without double-redemption. | 400 invalid JSON/UUID; 401 unauthenticated; 409 `operation_id_owned_by_another_account` / `coupon_unavailable` / `operation_token_mismatch` / `operation_state_changed` / `attempt_in_progress` / `attempt_unresolved`; 502 upstream error; 503 ledger capacity / `ledger_unavailable`; 500 `attempt_mark_failed` | | `GET /api/anthropic/reset-grants?accountId=...` | Read the Claude usage-limit reset grants of one Anthropic OAuth account: eligibility, each grant's resets left, validity window, and the windows it clears, plus any unconfirmed attempt still retryable | 400 no matching account; 401 re-authentication needed; 502 upstream unavailable | | `POST /api/anthropic/reset-grants/consume` | Spend one reset grant. Body `{ accountId, grantId, operationId }`; `operationId` is a UUIDv4 sent upstream as the request ID, so repeating it retries the same claim. Requires a dashboard session. A settled `reset` lifts the account's local cooldown only when it is a quota refusal recorded before the claim was first journaled and the windows the grant reported cleared cover every window that refusal named (both windows when it named none). A refusal recorded while the claim was in flight, an entitlement refusal, or an uncovered window stays, including on a same-id replay. The cached quota is dropped and usage is reread with a fresh request, so the pool can select the account before the old deadline. Model-family exclusions stay until that reread proves a family is no longer rejected. | 400 invalid body; 401 re-authentication needed; 403 `session_required`; 409 `grant_not_usable`, `in_flight`, `unresolved_prior_operation`, `unknown_outcome_expired`, `operation_identity_mismatch`; 500 `journal_write_failed`; 502 `unknown_outcome`; 503 journal busy, unavailable, or full | -| `GET, PUT /api/claude-desktop` | Read or persist the Claude Desktop routed/native profile and its model visibility (`visibility`: omit to keep, `null` to clear) | 400 invalid or unavailable assignment, invalid visibility, or a visibility that hides every available model | +| `GET, PUT /api/claude-desktop` | Read or persist the Claude Desktop routed/native profile and its model visibility (`visibility`: omit to keep, `null` to clear) | 400 invalid or unavailable assignment, invalid visibility, or a visibility that hides every available model; 409 profile or supplied visibility changed during discovery/save | | `POST /api/claude-desktop/apply` | Write the saved profile to Claude Desktop's managed config | 400/500 write failure | | `GET, POST /api/codex/desktop-compatibility/certificate` | Inspect or explicitly prepare Windows compatibility certificate trust | 403 paired local dashboard required for POST; 409 stale, busy, or incomplete state | | `GET, POST /api/codex/desktop-compatibility/runtime` | Inspect or explicitly start, stop, launch, or run a bounded native compatibility trial | 403 paired local dashboard required for POST; 409 unsupported build, untrusted certificate, or incomplete state | From f005f7dcb5606c0714ee14274a5aaa60fc8f2391 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Thu, 29 Oct 2026 06:12:05 +0900 Subject: [PATCH 4/6] fix(gui): describe stale Desktop configuration without assuming external edits Cover saving a changed family default without applying, including cleared applied state and unchanged Desktop models. Use neutral wording in every locale. Co-authored-by: ljluestc <63439129+ljluestc@users.noreply.github.com> --- gui/src/i18n/de.ts | 2 +- gui/src/i18n/en.ts | 2 +- gui/src/i18n/fr.ts | 2 +- gui/src/i18n/ja.ts | 2 +- gui/src/i18n/ko.ts | 2 +- gui/src/i18n/pt.ts | 2 +- gui/src/i18n/ru.ts | 2 +- gui/src/i18n/tr.ts | 2 +- gui/src/i18n/vi.ts | 2 +- gui/src/i18n/zh-TW.ts | 2 +- gui/src/i18n/zh.ts | 2 +- .../claude-desktop-visibility-card.test.tsx | 41 +++++++++++++++++-- 12 files changed, 48 insertions(+), 15 deletions(-) diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 47a248fef83..a0e6309b536 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -3529,7 +3529,7 @@ export const de: Record = { "claudeDesktop.moveTo": "Verschieben nach", "claudeDesktop.move": "Verschieben", "claudeDesktop.status.applied": "Auf Desktop angewendet", - "claudeDesktop.status.stale": "Desktop-Konfigurationsdatei wurde außerhalb von OpenCodex geändert — erneut anwenden", + "claudeDesktop.status.stale": "Desktop-Konfiguration ist nicht aktuell — erneut anwenden", "claudeDesktop.status.notApplied": "Nicht angewendet", "claudeDesktop.status.notActiveProfile": "Desktop nutzt ein anderes Profil — erneut anwenden", "claudeDesktop.status.disabled": "Die Claude-Desktop-Integration ist deaktiviert. Beende Desktop nach dem Aktivieren vollständig und öffne es erneut.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index e201ad349c4..7cf4b10f69a 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -3643,7 +3643,7 @@ export const en = { "claudeDesktop.moveTo": "Move to", "claudeDesktop.move": "Move", "claudeDesktop.status.applied": "Applied to Desktop", - "claudeDesktop.status.stale": "Desktop's config file changed outside OpenCodex — re-apply", + "claudeDesktop.status.stale": "Desktop configuration is out of date — re-apply", "claudeDesktop.status.notApplied": "Not applied", "claudeDesktop.status.notActiveProfile": "Desktop is serving another profile — re-apply", "claudeDesktop.status.disabled": "Claude Desktop integration is off. Fully quit and reopen Desktop after enabling it.", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index cf1df772621..43415a629d6 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -3556,7 +3556,7 @@ export const fr: Record = { "claudeDesktop.moveTo": "Déplacer vers", "claudeDesktop.move": "Déplacer", "claudeDesktop.status.applied": "Appliqué à Desktop", - "claudeDesktop.status.stale": "Le fichier de configuration de Desktop a été modifié hors d'OpenCodex — appliquer de nouveau", + "claudeDesktop.status.stale": "La configuration de Desktop n’est pas à jour — appliquer de nouveau", "claudeDesktop.status.notApplied": "Non appliqué", "claudeDesktop.status.notActiveProfile": "Desktop utilise un autre profil — appliquer de nouveau", "claudeDesktop.status.disabled": "L’intégration Claude Desktop est désactivée. Quittez complètement Desktop et rouvrez-le après l’avoir activée.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 138609c3113..846179a217b 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -3238,7 +3238,7 @@ export const ja: Record = { "claudeDesktop.moveTo": "移動先", "claudeDesktop.move": "移動", "claudeDesktop.status.applied": "Desktop に適用済み", - "claudeDesktop.status.stale": "Desktop の設定ファイルが OpenCodex 外で変更されました — 再適用してください", + "claudeDesktop.status.stale": "Desktop の設定が最新ではありません — 再適用してください", "claudeDesktop.status.notApplied": "未適用", "claudeDesktop.status.notActiveProfile": "Desktop は別のプロファイルを使用中 — 再適用してください", "claudeDesktop.status.disabled": "Claude Desktop 連携はオフです。有効にした後、Desktop を完全に終了して再起動してください。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 9500e599ac5..7705e7739ca 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -3576,7 +3576,7 @@ export const ko: Record = { "claudeDesktop.moveTo": "이동 위치", "claudeDesktop.move": "이동", "claudeDesktop.status.applied": "Desktop에 적용됨", - "claudeDesktop.status.stale": "Desktop 설정 파일이 OpenCodex 밖에서 변경됨 — 재적용 필요", + "claudeDesktop.status.stale": "Desktop 설정이 최신 상태가 아님 — 재적용 필요", "claudeDesktop.status.notApplied": "미적용", "claudeDesktop.status.notActiveProfile": "Desktop이 다른 프로필을 사용 중 — 재적용 필요", "claudeDesktop.status.disabled": "Claude Desktop 통합이 꺼져 있습니다. 켠 뒤 Desktop을 완전히 종료하고 다시 여세요.", diff --git a/gui/src/i18n/pt.ts b/gui/src/i18n/pt.ts index 1c35066f47b..f672d417fab 100644 --- a/gui/src/i18n/pt.ts +++ b/gui/src/i18n/pt.ts @@ -3599,7 +3599,7 @@ export const pt: Record = { "claudeDesktop.moveTo": "Mover para", "claudeDesktop.move": "Mover", "claudeDesktop.status.applied": "Aplicado ao Desktop", - "claudeDesktop.status.stale": "O arquivo de configuração do Desktop mudou fora do OpenCodex — aplique novamente", + "claudeDesktop.status.stale": "A configuração do Desktop está desatualizada — aplique novamente", "claudeDesktop.status.notApplied": "Não aplicado", "claudeDesktop.status.notActiveProfile": "O Desktop está usando outro perfil — aplique novamente", "claudeDesktop.status.disabled": "A integração com o Claude Desktop está desativada. Feche completamente e reabra o Desktop após ativá-la.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index c874e5cdee7..b8f59131873 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -3328,7 +3328,7 @@ export const ru: Record = { "claudeDesktop.moveTo": "Переместить в", "claudeDesktop.move": "Переместить", "claudeDesktop.status.applied": "Применено к Desktop", - "claudeDesktop.status.stale": "Файл конфигурации Desktop изменён вне OpenCodex — примените заново", + "claudeDesktop.status.stale": "Конфигурация Desktop устарела — примените заново", "claudeDesktop.status.notApplied": "Не применено", "claudeDesktop.status.notActiveProfile": "Desktop использует другой профиль — примените заново", "claudeDesktop.status.disabled": "Интеграция Claude Desktop отключена. После включения полностью закройте и снова откройте Desktop.", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 77c30b7f12b..9c37630764a 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -3579,7 +3579,7 @@ export const tr: Record = { "claudeDesktop.moveTo": "Şuraya taşı", "claudeDesktop.move": "Taşı", "claudeDesktop.status.applied": "Desktop'a Uygulandı", - "claudeDesktop.status.stale": "Desktop yapılandırma dosyası OpenCodex dışında değişti — tekrar uygulayın", + "claudeDesktop.status.stale": "Desktop yapılandırması güncel değil — tekrar uygulayın", "claudeDesktop.status.notApplied": "Uygulanmadı", "claudeDesktop.status.notActiveProfile": "Desktop başka bir profil kullanıyor", "claudeDesktop.status.disabled": "Claude Desktop entegrasyonu kapalı.", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index 42cf80c9925..8bc83ae301d 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -3567,7 +3567,7 @@ export const vi: Record = { "claudeDesktop.moveTo": "Chuyển đến", "claudeDesktop.move": "Di chuyển", "claudeDesktop.status.applied": "Đã áp dụng cho Desktop", - "claudeDesktop.status.stale": "Tệp cấu hình của Desktop đã đổi bên ngoài OpenCodex — hãy áp dụng lại", + "claudeDesktop.status.stale": "Cấu hình Desktop chưa cập nhật — hãy áp dụng lại", "claudeDesktop.status.notApplied": "Chưa được áp dụng", "claudeDesktop.status.notActiveProfile": "Desktop đang phục vụ một hồ sơ khác — hãy áp dụng lại", "claudeDesktop.status.disabled": "Tiện ích tích hợp Claude Desktop đang tắt. Hãy thoát hoàn toàn và mở lại Desktop sau khi bật nó.", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index cee3e1143c8..50cd2a07862 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -2728,7 +2728,7 @@ export const zhTW: Record = { "claudeDesktop.moveTo": "移動到", "claudeDesktop.move": "移動", "claudeDesktop.status.applied": "已套用到 Desktop", - "claudeDesktop.status.stale": "Desktop 設定檔在 OpenCodex 之外被修改 — 需重新套用", + "claudeDesktop.status.stale": "Desktop 設定尚未更新 — 需重新套用", "claudeDesktop.status.notApplied": "未套用", "claudeDesktop.status.notActiveProfile": "Desktop 正在使用其他配置 — 請重新套用", "claudeDesktop.health.lastRequest": "最後請求", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index fbfe6adee8c..e2b4f33391e 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -3549,7 +3549,7 @@ export const zh: Record = { "claudeDesktop.moveTo": "移动到", "claudeDesktop.move": "移动", "claudeDesktop.status.applied": "已应用到 Desktop", - "claudeDesktop.status.stale": "Desktop 配置文件在 OpenCodex 之外被修改 — 需重新应用", + "claudeDesktop.status.stale": "Desktop 配置尚未更新 — 需重新应用", "claudeDesktop.status.notApplied": "未应用", "claudeDesktop.status.notActiveProfile": "Desktop 正在使用其他配置 — 请重新应用", "claudeDesktop.status.disabled": "Claude Desktop 集成已关闭。开启后请完全退出并重新打开 Desktop。", diff --git a/gui/tests/claude-desktop-visibility-card.test.tsx b/gui/tests/claude-desktop-visibility-card.test.tsx index 965c79b0f94..7a5fa691159 100644 --- a/gui/tests/claude-desktop-visibility-card.test.tsx +++ b/gui/tests/claude-desktop-visibility-card.test.tsx @@ -5,6 +5,7 @@ import { createRoot, type Root } from "react-dom/client"; import ClaudeDesktop from "../src/pages/ClaudeDesktop"; import { LanguageProvider } from "../src/i18n/provider"; import { clearClientResourceStoresForTests } from "../src/client-resource"; +import { preserveDesktopAppliedState, type DesktopProfile } from "../../src/claude/desktop-profile"; /** * #7702: the Desktop model list is a per-model selection. Mounted, because the contract is @@ -34,12 +35,13 @@ let withStaleDefault = false; let puts: Array> = []; // Desktop currently runs every model, so a saved narrower selection is pending apply. let appliedModels: Array<{ name: string; label: string }> = []; +let savedProfile: DesktopProfile | null = null; function payload() { const models = withStaleDefault ? [...MODELS, model(STALE_ROUTE, STALE_ROUTE, "opus", false)] : MODELS; return { - profile: { - version: 1, + profile: savedProfile ?? { + version: 1 as const, assignments: Object.fromEntries(models.map(m => [m.route, m.assignment])), defaults: { opus: withStaleDefault ? STALE_ROUTE : "prov/opus-0", fable: null, sonnet: "prov/only-sonnet", haiku: null }, }, @@ -56,6 +58,7 @@ beforeEach(() => { withStaleDefault = false; puts = []; savedVisibility = null; + savedProfile = null; appliedModels = MODELS.map(m => ({ name: `claude-${m.route.replace(/\W/g, "-")}`, label: m.label })); clearClientResourceStoresForTests(); previousGlobals = Object.fromEntries(globals.map(k => [k, Reflect.get(globalThis, k)])) as typeof previousGlobals; @@ -72,9 +75,13 @@ beforeEach(() => { Object.defineProperty(globalThis, "fetch", { configurable: true, value: async (url: string, init?: RequestInit) => { - if (init?.method === "PUT") puts.push(JSON.parse(String(init.body))); + if (init?.method === "PUT") { + const request = JSON.parse(String(init.body)); + puts.push(request); + if (savedProfile) savedProfile = preserveDesktopAppliedState(savedProfile, request.profile); + } const body = String(url).includes("/status") - ? { desiredEnabled: true, applied: true, appliedAt: null, stale: false, appliedModels, health: { lastRequestAt: null, requestCount: 0, errorCount: 0 } } + ? { desiredEnabled: true, applied: true, appliedAt: null, stale: savedProfile !== null && !savedProfile.appliedFingerprint, appliedModels, health: { lastRequestAt: null, requestCount: 0, errorCount: 0 } } : payload(); return { ok: true, status: 200, json: async () => body, text: async () => JSON.stringify(body) } as unknown as Response; }, @@ -154,3 +161,29 @@ test("editing a draft keeps a newer saved selection pending apply", async () => expect(container.querySelector(".claude-dirty")?.textContent).toBe("Unsaved changes"); expect(container.querySelector(".claude-status-bar")?.textContent).toContain("Saved — apply to update Desktop"); }); + +test("saving a new family default without applying does not claim external file edits", async () => { + savedProfile = { ...payload().profile, appliedFingerprint: "unchanged-desktop-bytes" } as DesktopProfile; + const desktopModels = structuredClone(appliedModels); + await mount(); + const family = container.querySelector("#claude-lane-opus button") as unknown as HTMLButtonElement; + if (family.getAttribute("aria-expanded") === "false") await act(async () => { family.click(); }); + const radio = container.querySelector('input[type="radio"][name="default-opus"]') as unknown as HTMLInputElement; + expect(radio).not.toBeNull(); + // The first model is already the default; open a second model and choose it instead. + const summary = Array.from(container.querySelectorAll("button.claude-model-summary")) + .find(node => node.textContent?.includes("Opus Model 1")) as unknown as HTMLButtonElement; + await act(async () => { summary.click(); }); + const secondDefault = container.querySelectorAll('input[type="radio"][name="default-opus"]')[1] as unknown as HTMLInputElement; + await act(async () => { secondDefault.click(); }); + expect(button("Save").disabled).toBe(false); + await act(async () => { button("Save").click(); }); + await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); }); + expect(puts).toHaveLength(1); + expect(savedProfile!.defaults.opus).toBe("prov/opus-1"); + expect(savedProfile!.appliedFingerprint).toBeUndefined(); + expect(appliedModels).toEqual(desktopModels); + expect(container.querySelector(".claude-dirty")?.textContent).toBe("All changes saved"); + expect(container.querySelector(".claude-status-bar")?.textContent) + .toContain("Desktop configuration is out of date — re-apply"); +}); From c0c10b7e11c103565c2346548bc7187ef8b23176 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Thu, 29 Oct 2026 06:37:31 +0900 Subject: [PATCH 5/6] fix(gui): clarify Desktop visibility scope and profile observations Name all matching rows in bulk actions and describe matching IDs as an on-disk profile observation. Update all locales, regression coverage, fallback docs, required profile writes, and nullable status shape. Co-authored-by: ljluestc <63439129+ljluestc@users.noreply.github.com> --- .../src/content/docs/guides/claude-code.md | 13 ++++++++---- .../docs/reference/configuration/server.md | 2 +- .../content/docs/reference/management-api.md | 4 ++-- gui/src/i18n/de.ts | 6 +++--- gui/src/i18n/en.ts | 6 +++--- gui/src/i18n/fr.ts | 6 +++--- gui/src/i18n/ja.ts | 6 +++--- gui/src/i18n/ko.ts | 6 +++--- gui/src/i18n/pt.ts | 6 +++--- gui/src/i18n/ru.ts | 6 +++--- gui/src/i18n/tr.ts | 6 +++--- gui/src/i18n/vi.ts | 6 +++--- gui/src/i18n/zh-TW.ts | 6 +++--- gui/src/i18n/zh.ts | 6 +++--- gui/src/pages/claude-desktop-visibility.ts | 2 +- .../claude-desktop-visibility-card.test.tsx | 20 ++++++++++++++++++- .../claude-desktop-model-visibility.md | 5 +++-- 17 files changed, 68 insertions(+), 44 deletions(-) diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index ddef5a45b5c..a85af0e02f9 100644 --- a/docs-site/src/content/docs/guides/claude-code.md +++ b/docs-site/src/content/docs/guides/claude-code.md @@ -289,7 +289,8 @@ reported reason includes both failures; reconcile the profile and metadata befor #### Choose which models Claude Desktop lists **Connect → Claude Desktop → Desktop model list** has a checkbox for each model. Search by name or -filter by provider, then use **Select shown** or **Clear shown** to change many rows at once. Click +filter by provider, then use **Select all matching** or **Clear all matching** to change every match, +including rows behind **Show more**. Click **Save & apply** to write the list to Desktop. Only Claude Desktop's list changes: provider model settings, Claude Code, Codex and other clients keep every model. @@ -300,9 +301,13 @@ reapplies. Tier families under **Advanced** stay optional and do not affect whic A hidden model keeps its Desktop id, so a conversation that is already using it keeps working. The card shows the selected, available and applied counts separately. A selected model that is no -longer available is counted on its own and is not written. Before you apply, the card previews the -models Desktop will gain and lose. The status bar shows **Saved — apply to update Desktop** until -the saved list reaches Desktop. Applying replaces the model list in OpenCodex's Desktop profile, +longer available is counted on its own and is not written. If the selection leaves no available +models, the writer falls back to every available model, including normally hidden routes. Update +the selection and apply again to narrow it. Before you apply, the card previews the model IDs the +selected profile file will gain and lose. Its applied count reads that file; it does not verify +which models the Desktop process has loaded. The status bar shows **Saved — apply to update Desktop** +until the saved list reaches the profile. Restart Desktop to load profile changes. +Applying replaces the model list in OpenCodex's Desktop profile, including a list edited there by hand. Discovery-only mode (`--discovery-only`) writes no list, so it does not use this selection. The dashboard will not save a selection that hides every available model. diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index 8cd36831a2f..dea19520ee9 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -676,7 +676,7 @@ These settings govern `/v1/messages`, `/v1/messages/count_tokens`, the `ocx clau | `claudeCode.authModeMigratedAt?` | `string` | unset | Internal one-time upgrade marker. Do not set manually. | | `claudeCode.classifierModel?` | `string` | unset | Explicit target for Claude Code Auto Mode classifier turns, as a qualified `provider/model` (for example `RelayA/claude-opus-5`). Auto Mode sends bare safety checks such as `claude-opus-5` with no provider, so without this they fall through to `defaultProvider` — which may not speak Anthropic at all. Nothing is inferred automatically: only a target you declare here is used. | | `claudeCode.classifierFallbacks?` | `string[]` | unset | Ordered classifier targets used when `classifierModel` is not set. Same qualified `provider/model` form; the first usable entry wins. An explicit `modelMap` entry for the classifier model still outranks both. | -| `claudeCode.desktopModelVisibility?` | `{ mode: "include" \| "exclude"; routes: string[] }` | unset | Which `provider/model` routes the Claude Desktop gateway list carries. `include` lists only `routes` (new models stay hidden); `exclude` lists all but `routes` (new models appear). Unset lists every available model. Affects only Claude Desktop's static and hybrid lists; set it from **Connect → Claude Desktop**. | +| `claudeCode.desktopModelVisibility?` | `{ mode: "include" \| "exclude"; routes: string[] }` | unset | Which `provider/model` routes the Claude Desktop gateway list carries. `include` lists only `routes` (new models stay hidden); `exclude` lists all but `routes` (new models appear). Unset lists every available model. If the selection leaves no available models, the writer falls back to every available model, including normally hidden routes; update the selection to narrow it again. Affects only Claude Desktop's static and hybrid lists; set it from **Connect → Claude Desktop**. | | `claudeCode.subagentEffort?` | `"low" \| "medium" \| "high" \| "xhigh" \| "max"` | inherit | Effort written to generated `~/.claude/agents/ocx-*.md`; separate from Codex guidance and proxy caps. Restart through `ocx claude` to regenerate. | The compatibility policy applies to Claude Code, Desktop and other clients using translated diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index 9c1b4b53afd..7da2cada27a 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -86,12 +86,12 @@ route-specific results rather than repeating this table. | `POST /api/grok/reset-coupons/consume` | Redeem an eligible reset coupon. Body `{ accountId?, tokenId?, operationId? }`. Optional `operationId` (UUIDv4) makes redemption idempotent: repeating the same ID replays the durable result without double-redemption. | 400 invalid JSON/UUID; 401 unauthenticated; 409 `operation_id_owned_by_another_account` / `coupon_unavailable` / `operation_token_mismatch` / `operation_state_changed` / `attempt_in_progress` / `attempt_unresolved`; 502 upstream error; 503 ledger capacity / `ledger_unavailable`; 500 `attempt_mark_failed` | | `GET /api/anthropic/reset-grants?accountId=...` | Read the Claude usage-limit reset grants of one Anthropic OAuth account: eligibility, each grant's resets left, validity window, and the windows it clears, plus any unconfirmed attempt still retryable | 400 no matching account; 401 re-authentication needed; 502 upstream unavailable | | `POST /api/anthropic/reset-grants/consume` | Spend one reset grant. Body `{ accountId, grantId, operationId }`; `operationId` is a UUIDv4 sent upstream as the request ID, so repeating it retries the same claim. Requires a dashboard session. A settled `reset` lifts the account's local cooldown only when it is a quota refusal recorded before the claim was first journaled and the windows the grant reported cleared cover every window that refusal named (both windows when it named none). A refusal recorded while the claim was in flight, an entitlement refusal, or an uncovered window stays, including on a same-id replay. The cached quota is dropped and usage is reread with a fresh request, so the pool can select the account before the old deadline. Model-family exclusions stay until that reread proves a family is no longer rejected. | 400 invalid body; 401 re-authentication needed; 403 `session_required`; 409 `grant_not_usable`, `in_flight`, `unresolved_prior_operation`, `unknown_outcome_expired`, `operation_identity_mismatch`; 500 `journal_write_failed`; 502 `unknown_outcome`; 503 journal busy, unavailable, or full | -| `GET, PUT /api/claude-desktop` | Read or persist the Claude Desktop routed/native profile and its model visibility (`visibility`: omit to keep, `null` to clear) | 400 invalid or unavailable assignment, invalid visibility, or a visibility that hides every available model; 409 profile or supplied visibility changed during discovery/save | +| `GET, PUT /api/claude-desktop` | Read or persist the Claude Desktop routed/native profile and its model visibility. PUT requires a valid `profile`, including when setting or clearing `visibility` (`visibility`: omit to keep, `null` to clear); a visibility-only body returns 400 | 400 missing/invalid profile, invalid or unavailable assignment, invalid visibility, or a visibility that hides every available model; 409 profile or supplied visibility changed during discovery/save | | `POST /api/claude-desktop/apply` | Write the saved profile to Claude Desktop's managed config | 400/500 write failure | | `GET, POST /api/codex/desktop-compatibility/certificate` | Inspect or explicitly prepare Windows compatibility certificate trust | 403 paired local dashboard required for POST; 409 stale, busy, or incomplete state | | `GET, POST /api/codex/desktop-compatibility/runtime` | Inspect or explicitly start, stop, launch, or run a bounded native compatibility trial | 403 paired local dashboard required for POST; 409 unsupported build, untrusted certificate, or incomplete state | | `GET, POST /api/codex/desktop-compatibility/settings` | Read or save the next-start observation preference with its current revision | 403 paired local dashboard required for POST; 409 stale, invalid, or unconfirmed configuration | -| `GET /api/claude-desktop/status` | Inspect saved-versus-applied profile, the applied model list (`appliedModels`), and Desktop health | 400 status read failure | +| `GET /api/claude-desktop/status` | Inspect saved-versus-applied profile, Desktop health, and `appliedModels: { name: string; label: string }[] \| null`. This is the selected profile's on-disk static list, not a verified observation of the running Desktop process; `null` means no applied gateway profile or no readable static list (including discovery-only mode), not an empty list | 400 status read failure | | `GET, PUT /api/claude-code` | Read or update Claude Code gateway, auth-mode, model-map, context, agent, and sidecar settings | 400 invalid field or shape | `tokenId`, when supplied, must be a non-empty string; invalid values return `400 invalid_token_id` before credentials are read or the ledger is opened. Within the 30-day retention window, a same-`operationId` retry that omits `accountId` uses the recorded account, even if the active account changed. An executable `open` retry that omits `tokenId` uses its recorded token. An explicit different account or token is still refused. A definitive failure clears a matching speculative pending hold in the dashboard, allowing a new explicitly confirmed attempt; actual unknown outcomes remain held. diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index a0e6309b536..24091ff8912 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -3497,8 +3497,8 @@ export const de: Record = { "claudeDesktop.visibility.search": "Modelle suchen", "claudeDesktop.visibility.provider": "Anbieter", "claudeDesktop.visibility.allProviders": "Alle Anbieter", - "claudeDesktop.visibility.selectShown": "Angezeigte auswählen", - "claudeDesktop.visibility.clearShown": "Angezeigte abwählen", + "claudeDesktop.visibility.selectShown": "Alle Treffer auswählen", + "claudeDesktop.visibility.clearShown": "Alle Treffer abwählen", "claudeDesktop.visibility.toggle": "{model} in Claude Desktop anzeigen", "claudeDesktop.visibility.selected": "{n} ausgewählt", "claudeDesktop.visibility.available": "{n} verfügbar", @@ -3513,7 +3513,7 @@ export const de: Record = { "claudeDesktop.visibility.previewTitle": "Nächstes Anwenden", "claudeDesktop.visibility.previewAdds": "Fügt hinzu: {models}", "claudeDesktop.visibility.previewRemoves": "Entfernt: {models}", - "claudeDesktop.visibility.previewSame": "Desktop listet bereits genau diese Modelle.", + "claudeDesktop.visibility.previewSame": "Das Profil enthält dieselben Modell-IDs. Starten Sie Desktop neu, um Profiländerungen zu laden.", "claudeDesktop.visibility.replaceNote": "Anwenden ersetzt die Modellliste im Claude-Desktop-Profil von OpenCodex, auch eine dort von Hand bearbeitete Liste. Die Stufenzuordnung unter Erweitert bleibt optional. Der reine Discovery-Modus nutzt diese Auswahl nicht.", "claudeDesktop.roles.quickBadge": "Schnelle Aufgaben", "claudeDesktop.advanced.title": "Erweitert: Claude-Stufen zuweisen", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 7cf4b10f69a..17fec5827ce 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -3611,8 +3611,8 @@ export const en = { "claudeDesktop.visibility.search": "Search models", "claudeDesktop.visibility.provider": "Provider", "claudeDesktop.visibility.allProviders": "All providers", - "claudeDesktop.visibility.selectShown": "Select shown", - "claudeDesktop.visibility.clearShown": "Clear shown", + "claudeDesktop.visibility.selectShown": "Select all matching", + "claudeDesktop.visibility.clearShown": "Clear all matching", "claudeDesktop.visibility.toggle": "Show {model} in Claude Desktop", "claudeDesktop.visibility.selected": "{n} selected", "claudeDesktop.visibility.available": "{n} available", @@ -3627,7 +3627,7 @@ export const en = { "claudeDesktop.visibility.previewTitle": "Next apply", "claudeDesktop.visibility.previewAdds": "Adds: {models}", "claudeDesktop.visibility.previewRemoves": "Removes: {models}", - "claudeDesktop.visibility.previewSame": "Desktop already lists exactly these models.", + "claudeDesktop.visibility.previewSame": "The profile contains the same model IDs. Restart Desktop to load profile changes.", "claudeDesktop.visibility.replaceNote": "Applying replaces the model list in OpenCodex's Claude Desktop profile, including any list edited there by hand. Tier mapping under Advanced stays optional. Discovery-only mode does not use this selection.", "claudeDesktop.roles.quickBadge": "Quick tasks", "claudeDesktop.advanced.title": "Advanced: Claude tier assignment", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 43415a629d6..1bf954c0421 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -3524,8 +3524,8 @@ export const fr: Record = { "claudeDesktop.visibility.search": "Rechercher des modèles", "claudeDesktop.visibility.provider": "Fournisseur", "claudeDesktop.visibility.allProviders": "Tous les fournisseurs", - "claudeDesktop.visibility.selectShown": "Sélectionner l'affichage", - "claudeDesktop.visibility.clearShown": "Désélectionner l'affichage", + "claudeDesktop.visibility.selectShown": "Sélectionner tous les résultats", + "claudeDesktop.visibility.clearShown": "Désélectionner tous les résultats", "claudeDesktop.visibility.toggle": "Afficher {model} dans Claude Desktop", "claudeDesktop.visibility.selected": "{n} sélectionnés", "claudeDesktop.visibility.available": "{n} disponibles", @@ -3540,7 +3540,7 @@ export const fr: Record = { "claudeDesktop.visibility.previewTitle": "Prochaine application", "claudeDesktop.visibility.previewAdds": "Ajoute : {models}", "claudeDesktop.visibility.previewRemoves": "Retire : {models}", - "claudeDesktop.visibility.previewSame": "Desktop liste déjà exactement ces modèles.", + "claudeDesktop.visibility.previewSame": "Le profil contient les mêmes identifiants de modèles. Redémarrez Desktop pour charger les modifications du profil.", "claudeDesktop.visibility.replaceNote": "Appliquer remplace la liste de modèles du profil Claude Desktop d'OpenCodex, y compris une liste modifiée à la main. Le mappage des niveaux dans Avancé reste facultatif. Le mode découverte seule n'utilise pas cette sélection.", "claudeDesktop.roles.quickBadge": "Tâches rapides", "claudeDesktop.advanced.title": "Avancé : attribution des niveaux Claude", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 846179a217b..fceae024686 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -3206,8 +3206,8 @@ export const ja: Record = { "claudeDesktop.visibility.search": "モデルを検索", "claudeDesktop.visibility.provider": "プロバイダー", "claudeDesktop.visibility.allProviders": "すべてのプロバイダー", - "claudeDesktop.visibility.selectShown": "表示中を選択", - "claudeDesktop.visibility.clearShown": "表示中を解除", + "claudeDesktop.visibility.selectShown": "一致するすべてを選択", + "claudeDesktop.visibility.clearShown": "一致するすべてを解除", "claudeDesktop.visibility.toggle": "{model} を Claude Desktop に表示", "claudeDesktop.visibility.selected": "{n} 件選択", "claudeDesktop.visibility.available": "{n} 件利用可能", @@ -3222,7 +3222,7 @@ export const ja: Record = { "claudeDesktop.visibility.previewTitle": "次回の適用", "claudeDesktop.visibility.previewAdds": "追加: {models}", "claudeDesktop.visibility.previewRemoves": "削除: {models}", - "claudeDesktop.visibility.previewSame": "Desktop はすでにこれらのモデルだけを表示しています。", + "claudeDesktop.visibility.previewSame": "プロファイルには同じモデル ID が含まれています。変更を読み込むには Desktop を再起動してください。", "claudeDesktop.visibility.replaceNote": "適用すると OpenCodex の Claude Desktop プロファイルのモデルリストが置き換わります(手動で編集したリストも含む)。詳細設定のティア割り当ては任意です。ディスカバリー専用モードではこの選択は使われません。", "claudeDesktop.roles.quickBadge": "クイックタスク", "claudeDesktop.advanced.title": "詳細設定:Claude ティアの割り当て", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 7705e7739ca..8d41ef114fe 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -3544,8 +3544,8 @@ export const ko: Record = { "claudeDesktop.visibility.search": "모델 검색", "claudeDesktop.visibility.provider": "프로바이더", "claudeDesktop.visibility.allProviders": "모든 프로바이더", - "claudeDesktop.visibility.selectShown": "표시된 항목 선택", - "claudeDesktop.visibility.clearShown": "표시된 항목 해제", + "claudeDesktop.visibility.selectShown": "일치하는 모든 항목 선택", + "claudeDesktop.visibility.clearShown": "일치하는 모든 항목 해제", "claudeDesktop.visibility.toggle": "Claude Desktop에 {model} 표시", "claudeDesktop.visibility.selected": "{n}개 선택", "claudeDesktop.visibility.available": "{n}개 사용 가능", @@ -3560,7 +3560,7 @@ export const ko: Record = { "claudeDesktop.visibility.previewTitle": "다음 적용", "claudeDesktop.visibility.previewAdds": "추가: {models}", "claudeDesktop.visibility.previewRemoves": "제거: {models}", - "claudeDesktop.visibility.previewSame": "Desktop이 이미 정확히 이 모델들을 표시합니다.", + "claudeDesktop.visibility.previewSame": "프로필에 같은 모델 ID가 있습니다. 프로필 변경 사항을 불러오려면 Desktop을 다시 시작하세요.", "claudeDesktop.visibility.replaceNote": "적용하면 OpenCodex의 Claude Desktop 프로필 모델 목록이 교체되며, 직접 편집한 목록도 덮어씁니다. 고급의 티어 매핑은 선택 사항입니다. 디스커버리 전용 모드는 이 선택을 사용하지 않습니다.", "claudeDesktop.roles.quickBadge": "빠른 작업", "claudeDesktop.advanced.title": "고급: Claude 등급 배치", diff --git a/gui/src/i18n/pt.ts b/gui/src/i18n/pt.ts index f672d417fab..93734db647f 100644 --- a/gui/src/i18n/pt.ts +++ b/gui/src/i18n/pt.ts @@ -3567,8 +3567,8 @@ export const pt: Record = { "claudeDesktop.visibility.search": "Buscar modelos", "claudeDesktop.visibility.provider": "Provedor", "claudeDesktop.visibility.allProviders": "Todos os provedores", - "claudeDesktop.visibility.selectShown": "Selecionar exibidos", - "claudeDesktop.visibility.clearShown": "Limpar exibidos", + "claudeDesktop.visibility.selectShown": "Selecionar todos os resultados", + "claudeDesktop.visibility.clearShown": "Limpar todos os resultados", "claudeDesktop.visibility.toggle": "Mostrar {model} no Claude Desktop", "claudeDesktop.visibility.selected": "{n} selecionados", "claudeDesktop.visibility.available": "{n} disponíveis", @@ -3583,7 +3583,7 @@ export const pt: Record = { "claudeDesktop.visibility.previewTitle": "Próxima aplicação", "claudeDesktop.visibility.previewAdds": "Adiciona: {models}", "claudeDesktop.visibility.previewRemoves": "Remove: {models}", - "claudeDesktop.visibility.previewSame": "O Desktop já lista exatamente estes modelos.", + "claudeDesktop.visibility.previewSame": "O perfil contém os mesmos IDs de modelos. Reinicie o Desktop para carregar as alterações do perfil.", "claudeDesktop.visibility.replaceNote": "Aplicar substitui a lista de modelos do perfil Claude Desktop do OpenCodex, inclusive uma lista editada à mão. O mapeamento de níveis em Avançado continua opcional. O modo somente descoberta não usa esta seleção.", "claudeDesktop.roles.quickBadge": "Tarefas rápidas", "claudeDesktop.advanced.title": "Avançado: atribuição de níveis Claude", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index b8f59131873..5626cd2845c 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -3296,8 +3296,8 @@ export const ru: Record = { "claudeDesktop.visibility.search": "Поиск моделей", "claudeDesktop.visibility.provider": "Провайдер", "claudeDesktop.visibility.allProviders": "Все провайдеры", - "claudeDesktop.visibility.selectShown": "Выбрать показанные", - "claudeDesktop.visibility.clearShown": "Снять показанные", + "claudeDesktop.visibility.selectShown": "Выбрать все совпадения", + "claudeDesktop.visibility.clearShown": "Снять все совпадения", "claudeDesktop.visibility.toggle": "Показывать {model} в Claude Desktop", "claudeDesktop.visibility.selected": "Выбрано: {n}", "claudeDesktop.visibility.available": "Доступно: {n}", @@ -3312,7 +3312,7 @@ export const ru: Record = { "claudeDesktop.visibility.previewTitle": "Следующее применение", "claudeDesktop.visibility.previewAdds": "Добавит: {models}", "claudeDesktop.visibility.previewRemoves": "Уберёт: {models}", - "claudeDesktop.visibility.previewSame": "Desktop уже показывает ровно эти модели.", + "claudeDesktop.visibility.previewSame": "Профиль содержит те же ID моделей. Перезапустите Desktop, чтобы загрузить изменения профиля.", "claudeDesktop.visibility.replaceNote": "Применение заменяет список моделей в профиле Claude Desktop от OpenCodex, включая список, отредактированный вручную. Привязка уровней в разделе «Дополнительно» необязательна. Режим только обнаружения не использует этот выбор.", "claudeDesktop.roles.quickBadge": "Быстрые задачи", "claudeDesktop.advanced.title": "Дополнительно: назначение уровней Claude", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 9c37630764a..7d65d1ccd80 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -3547,8 +3547,8 @@ export const tr: Record = { "claudeDesktop.visibility.search": "Model ara", "claudeDesktop.visibility.provider": "Sağlayıcı", "claudeDesktop.visibility.allProviders": "Tüm sağlayıcılar", - "claudeDesktop.visibility.selectShown": "Gösterilenleri seç", - "claudeDesktop.visibility.clearShown": "Gösterilenleri temizle", + "claudeDesktop.visibility.selectShown": "Eşleşenlerin tümünü seç", + "claudeDesktop.visibility.clearShown": "Eşleşenlerin tümünü temizle", "claudeDesktop.visibility.toggle": "{model} modelini Claude Desktop'ta göster", "claudeDesktop.visibility.selected": "{n} seçili", "claudeDesktop.visibility.available": "{n} kullanılabilir", @@ -3563,7 +3563,7 @@ export const tr: Record = { "claudeDesktop.visibility.previewTitle": "Sonraki uygulama", "claudeDesktop.visibility.previewAdds": "Ekler: {models}", "claudeDesktop.visibility.previewRemoves": "Kaldırır: {models}", - "claudeDesktop.visibility.previewSame": "Desktop zaten tam olarak bu modelleri listeliyor.", + "claudeDesktop.visibility.previewSame": "Profil aynı model kimliklerini içeriyor. Profil değişikliklerini yüklemek için Desktop'ı yeniden başlatın.", "claudeDesktop.visibility.replaceNote": "Uygulamak, OpenCodex'in Claude Desktop profilindeki model listesini (elle düzenlenmiş liste dahil) değiştirir. Gelişmiş altındaki katman eşlemesi isteğe bağlıdır. Yalnızca keşif modu bu seçimi kullanmaz.", "claudeDesktop.roles.quickBadge": "Hızlı görevler", "claudeDesktop.advanced.title": "Gelişmiş: Claude düzeyi ataması", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index 8bc83ae301d..2473b352940 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -3535,8 +3535,8 @@ export const vi: Record = { "claudeDesktop.visibility.search": "Tìm model", "claudeDesktop.visibility.provider": "Provider", "claudeDesktop.visibility.allProviders": "Tất cả provider", - "claudeDesktop.visibility.selectShown": "Chọn mục đang hiện", - "claudeDesktop.visibility.clearShown": "Bỏ chọn mục đang hiện", + "claudeDesktop.visibility.selectShown": "Chọn tất cả mục phù hợp", + "claudeDesktop.visibility.clearShown": "Bỏ chọn tất cả mục phù hợp", "claudeDesktop.visibility.toggle": "Hiện {model} trong Claude Desktop", "claudeDesktop.visibility.selected": "Đã chọn {n}", "claudeDesktop.visibility.available": "{n} khả dụng", @@ -3551,7 +3551,7 @@ export const vi: Record = { "claudeDesktop.visibility.previewTitle": "Lần áp dụng tới", "claudeDesktop.visibility.previewAdds": "Thêm: {models}", "claudeDesktop.visibility.previewRemoves": "Bỏ: {models}", - "claudeDesktop.visibility.previewSame": "Desktop đã liệt kê đúng các model này.", + "claudeDesktop.visibility.previewSame": "Hồ sơ chứa cùng các ID model. Khởi động lại Desktop để tải thay đổi hồ sơ.", "claudeDesktop.visibility.replaceNote": "Áp dụng sẽ thay danh sách model trong hồ sơ Claude Desktop của OpenCodex, kể cả danh sách đã sửa tay. Ánh xạ tier trong Nâng cao vẫn là tùy chọn. Chế độ chỉ discovery không dùng lựa chọn này.", "claudeDesktop.roles.quickBadge": "Tác vụ nhanh", "claudeDesktop.advanced.title": "Nâng cao: chỉ định cấp Claude", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 50cd2a07862..172a01492e9 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -2696,8 +2696,8 @@ export const zhTW: Record = { "claudeDesktop.visibility.search": "搜尋模型", "claudeDesktop.visibility.provider": "供應商", "claudeDesktop.visibility.allProviders": "所有供應商", - "claudeDesktop.visibility.selectShown": "選取顯示項", - "claudeDesktop.visibility.clearShown": "取消顯示項", + "claudeDesktop.visibility.selectShown": "選取所有符合項目", + "claudeDesktop.visibility.clearShown": "取消所有符合項目", "claudeDesktop.visibility.toggle": "在 Claude Desktop 中顯示 {model}", "claudeDesktop.visibility.selected": "已選 {n}", "claudeDesktop.visibility.available": "可用 {n}", @@ -2712,7 +2712,7 @@ export const zhTW: Record = { "claudeDesktop.visibility.previewTitle": "下次套用", "claudeDesktop.visibility.previewAdds": "新增:{models}", "claudeDesktop.visibility.previewRemoves": "移除:{models}", - "claudeDesktop.visibility.previewSame": "Desktop 已恰好列出這些模型。", + "claudeDesktop.visibility.previewSame": "設定檔包含相同的模型 ID。請重新啟動 Desktop 以載入設定檔變更。", "claudeDesktop.visibility.replaceNote": "套用會取代 OpenCodex 的 Claude Desktop 設定中的模型清單,包括手動編輯過的清單。進階中的層級對應仍為選用。僅探索模式不使用此選擇。", "claudeDesktop.roles.quickBadge": "快速任務", "claudeDesktop.advanced.title": "進階:Claude 等級分配", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index e2b4f33391e..3a39efea0ce 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -3517,8 +3517,8 @@ export const zh: Record = { "claudeDesktop.visibility.search": "搜索模型", "claudeDesktop.visibility.provider": "提供商", "claudeDesktop.visibility.allProviders": "所有提供商", - "claudeDesktop.visibility.selectShown": "选中显示项", - "claudeDesktop.visibility.clearShown": "取消显示项", + "claudeDesktop.visibility.selectShown": "选中所有匹配项", + "claudeDesktop.visibility.clearShown": "取消所有匹配项", "claudeDesktop.visibility.toggle": "在 Claude Desktop 中显示 {model}", "claudeDesktop.visibility.selected": "已选 {n}", "claudeDesktop.visibility.available": "可用 {n}", @@ -3533,7 +3533,7 @@ export const zh: Record = { "claudeDesktop.visibility.previewTitle": "下次应用", "claudeDesktop.visibility.previewAdds": "新增:{models}", "claudeDesktop.visibility.previewRemoves": "移除:{models}", - "claudeDesktop.visibility.previewSame": "Desktop 已恰好列出这些模型。", + "claudeDesktop.visibility.previewSame": "配置中包含相同的模型 ID。请重启 Desktop 以加载配置更改。", "claudeDesktop.visibility.replaceNote": "应用会替换 OpenCodex 的 Claude Desktop 配置中的模型列表,包括手动编辑过的列表。高级中的层级映射仍为可选。仅发现模式不使用此选择。", "claudeDesktop.roles.quickBadge": "快速任务", "claudeDesktop.advanced.title": "高级:Claude 等级分配", diff --git a/gui/src/pages/claude-desktop-visibility.ts b/gui/src/pages/claude-desktop-visibility.ts index ecb0cd58942..62f8e2c7d89 100644 --- a/gui/src/pages/claude-desktop-visibility.ts +++ b/gui/src/pages/claude-desktop-visibility.ts @@ -24,7 +24,7 @@ export interface ListedDesktopModel { label: string; } -/** A row Desktop runs now (`appliedModels` on GET /api/claude-desktop/status). */ +/** An on-disk selected-profile row (`appliedModels` on GET /api/claude-desktop/status). */ export interface AppliedDesktopModel { name: string; label: string; diff --git a/gui/tests/claude-desktop-visibility-card.test.tsx b/gui/tests/claude-desktop-visibility-card.test.tsx index 7a5fa691159..bac7ddee2ac 100644 --- a/gui/tests/claude-desktop-visibility-card.test.tsx +++ b/gui/tests/claude-desktop-visibility-card.test.tsx @@ -149,7 +149,7 @@ test("a saved selection Desktop has not received yet reads as pending apply, not test("clearing every shown model blocks saving", async () => { await mount(); - await act(async () => { button("Clear shown").click(); }); + await act(async () => { button("Clear all matching").click(); }); expect(container.textContent).toContain("Select at least one available model."); expect(button("Save").disabled).toBe(true); }); @@ -187,3 +187,21 @@ test("saving a new family default without applying does not claim external file expect(container.querySelector(".claude-status-bar")?.textContent) .toContain("Desktop configuration is out of date — re-apply"); }); + +test("bulk labels disclose that matching rows beyond Show more are included", async () => { + await mount(); + const actions = container.querySelectorAll(".claude-visibility-filters button"); + expect(container.querySelectorAll(".claude-desktop-list-row")).toHaveLength(6); + await act(async () => { actions[1]!.click(); }); + expect(container.querySelector(".claude-visibility-counts")?.textContent).toContain("0 selected"); + await act(async () => { actions[0]!.click(); }); + expect(container.querySelector(".claude-visibility-counts")?.textContent).toContain("8 selected"); + expect(actions[0]!.textContent).toBe("Select all matching"); + expect(actions[1]!.textContent).toBe("Clear all matching"); +}); + +test("an equal preview describes the profile file and asks Desktop to reload it", async () => { + await mount(); + expect(container.querySelector(".claude-visibility-preview")?.textContent) + .toContain("The profile contains the same model IDs. Restart Desktop to load profile changes."); +}); diff --git a/structure/clients/claude-desktop-model-visibility.md b/structure/clients/claude-desktop-model-visibility.md index 1c5983f34aa..84f08c9f21a 100644 --- a/structure/clients/claude-desktop-model-visibility.md +++ b/structure/clients/claude-desktop-model-visibility.md @@ -44,8 +44,9 @@ selection again inside that mutation; a concurrent visibility edit during discov An omitted selection preserves the latest persisted value. `GET /api/claude-desktop` (`buildClaudeDesktopState` in `src/server/management/shared.ts`) returns the selection, a per-model `visible` flag, and `listed`, the rows the next apply writes. `GET -/api/claude-desktop/status` returns `appliedModels`, the names and labels Desktop runs now, read -through the verified library reader. The dashboard derives its selected, available and applied +/api/claude-desktop/status` returns `appliedModels`, the selected profile's on-disk names and labels, +read through the verified library reader. This nullable list does not prove which models the +Desktop process has loaded; a restart or managed policy can affect that. The dashboard derives its selected, available and applied counts and its added/removed preview from these fields. `listed` includes the writer's full-list fallback when every selected model is unavailable, without changing the saved selection or default. When the saved selection differs from the applied list, the status bar reports a pending apply even From 1e80051c9721c8db2316a1840bfe5efde91be554 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Thu, 29 Oct 2026 07:04:34 +0900 Subject: [PATCH 6/6] fix(gui): clarify Desktop deselection and apply order Co-authored-by: ljluestc <63439129+ljluestc@users.noreply.github.com> --- gui/src/i18n/de.ts | 4 ++-- gui/src/i18n/en.ts | 4 ++-- gui/src/i18n/fr.ts | 4 ++-- gui/src/i18n/ja.ts | 4 ++-- gui/src/i18n/ko.ts | 4 ++-- gui/src/i18n/pt.ts | 4 ++-- gui/src/i18n/ru.ts | 4 ++-- gui/src/i18n/tr.ts | 4 ++-- gui/src/i18n/vi.ts | 4 ++-- gui/src/i18n/zh-TW.ts | 4 ++-- gui/src/i18n/zh.ts | 4 ++-- gui/tests/claude-desktop-visibility-card.test.tsx | 8 ++++---- 12 files changed, 26 insertions(+), 26 deletions(-) diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 24091ff8912..134ebd70e9b 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -3498,7 +3498,7 @@ export const de: Record = { "claudeDesktop.visibility.provider": "Anbieter", "claudeDesktop.visibility.allProviders": "Alle Anbieter", "claudeDesktop.visibility.selectShown": "Alle Treffer auswählen", - "claudeDesktop.visibility.clearShown": "Alle Treffer abwählen", + "claudeDesktop.visibility.clearShown": "Alle passenden Modelle abwählen", "claudeDesktop.visibility.toggle": "{model} in Claude Desktop anzeigen", "claudeDesktop.visibility.selected": "{n} ausgewählt", "claudeDesktop.visibility.available": "{n} verfügbar", @@ -3513,7 +3513,7 @@ export const de: Record = { "claudeDesktop.visibility.previewTitle": "Nächstes Anwenden", "claudeDesktop.visibility.previewAdds": "Fügt hinzu: {models}", "claudeDesktop.visibility.previewRemoves": "Entfernt: {models}", - "claudeDesktop.visibility.previewSame": "Das Profil enthält dieselben Modell-IDs. Starten Sie Desktop neu, um Profiländerungen zu laden.", + "claudeDesktop.visibility.previewSame": "Das Profil enthält dieselben Modell-IDs. Wenden Sie Profiländerungen an, bevor Sie Desktop neu starten.", "claudeDesktop.visibility.replaceNote": "Anwenden ersetzt die Modellliste im Claude-Desktop-Profil von OpenCodex, auch eine dort von Hand bearbeitete Liste. Die Stufenzuordnung unter Erweitert bleibt optional. Der reine Discovery-Modus nutzt diese Auswahl nicht.", "claudeDesktop.roles.quickBadge": "Schnelle Aufgaben", "claudeDesktop.advanced.title": "Erweitert: Claude-Stufen zuweisen", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 17fec5827ce..e0400c3cf54 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -3612,7 +3612,7 @@ export const en = { "claudeDesktop.visibility.provider": "Provider", "claudeDesktop.visibility.allProviders": "All providers", "claudeDesktop.visibility.selectShown": "Select all matching", - "claudeDesktop.visibility.clearShown": "Clear all matching", + "claudeDesktop.visibility.clearShown": "Deselect all matching models", "claudeDesktop.visibility.toggle": "Show {model} in Claude Desktop", "claudeDesktop.visibility.selected": "{n} selected", "claudeDesktop.visibility.available": "{n} available", @@ -3627,7 +3627,7 @@ export const en = { "claudeDesktop.visibility.previewTitle": "Next apply", "claudeDesktop.visibility.previewAdds": "Adds: {models}", "claudeDesktop.visibility.previewRemoves": "Removes: {models}", - "claudeDesktop.visibility.previewSame": "The profile contains the same model IDs. Restart Desktop to load profile changes.", + "claudeDesktop.visibility.previewSame": "The profile contains the same model IDs. Apply profile changes before restarting Desktop.", "claudeDesktop.visibility.replaceNote": "Applying replaces the model list in OpenCodex's Claude Desktop profile, including any list edited there by hand. Tier mapping under Advanced stays optional. Discovery-only mode does not use this selection.", "claudeDesktop.roles.quickBadge": "Quick tasks", "claudeDesktop.advanced.title": "Advanced: Claude tier assignment", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 1bf954c0421..a5c93fb28ae 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -3525,7 +3525,7 @@ export const fr: Record = { "claudeDesktop.visibility.provider": "Fournisseur", "claudeDesktop.visibility.allProviders": "Tous les fournisseurs", "claudeDesktop.visibility.selectShown": "Sélectionner tous les résultats", - "claudeDesktop.visibility.clearShown": "Désélectionner tous les résultats", + "claudeDesktop.visibility.clearShown": "Désélectionner tous les modèles correspondants", "claudeDesktop.visibility.toggle": "Afficher {model} dans Claude Desktop", "claudeDesktop.visibility.selected": "{n} sélectionnés", "claudeDesktop.visibility.available": "{n} disponibles", @@ -3540,7 +3540,7 @@ export const fr: Record = { "claudeDesktop.visibility.previewTitle": "Prochaine application", "claudeDesktop.visibility.previewAdds": "Ajoute : {models}", "claudeDesktop.visibility.previewRemoves": "Retire : {models}", - "claudeDesktop.visibility.previewSame": "Le profil contient les mêmes identifiants de modèles. Redémarrez Desktop pour charger les modifications du profil.", + "claudeDesktop.visibility.previewSame": "Le profil contient les mêmes identifiants de modèles. Appliquez les modifications du profil avant de redémarrer Desktop.", "claudeDesktop.visibility.replaceNote": "Appliquer remplace la liste de modèles du profil Claude Desktop d'OpenCodex, y compris une liste modifiée à la main. Le mappage des niveaux dans Avancé reste facultatif. Le mode découverte seule n'utilise pas cette sélection.", "claudeDesktop.roles.quickBadge": "Tâches rapides", "claudeDesktop.advanced.title": "Avancé : attribution des niveaux Claude", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index fceae024686..20e615f3487 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -3207,7 +3207,7 @@ export const ja: Record = { "claudeDesktop.visibility.provider": "プロバイダー", "claudeDesktop.visibility.allProviders": "すべてのプロバイダー", "claudeDesktop.visibility.selectShown": "一致するすべてを選択", - "claudeDesktop.visibility.clearShown": "一致するすべてを解除", + "claudeDesktop.visibility.clearShown": "一致するすべてのモデルの選択を解除", "claudeDesktop.visibility.toggle": "{model} を Claude Desktop に表示", "claudeDesktop.visibility.selected": "{n} 件選択", "claudeDesktop.visibility.available": "{n} 件利用可能", @@ -3222,7 +3222,7 @@ export const ja: Record = { "claudeDesktop.visibility.previewTitle": "次回の適用", "claudeDesktop.visibility.previewAdds": "追加: {models}", "claudeDesktop.visibility.previewRemoves": "削除: {models}", - "claudeDesktop.visibility.previewSame": "プロファイルには同じモデル ID が含まれています。変更を読み込むには Desktop を再起動してください。", + "claudeDesktop.visibility.previewSame": "プロファイルには同じモデル ID が含まれています。Desktop を再起動する前にプロファイルの変更を適用してください。", "claudeDesktop.visibility.replaceNote": "適用すると OpenCodex の Claude Desktop プロファイルのモデルリストが置き換わります(手動で編集したリストも含む)。詳細設定のティア割り当ては任意です。ディスカバリー専用モードではこの選択は使われません。", "claudeDesktop.roles.quickBadge": "クイックタスク", "claudeDesktop.advanced.title": "詳細設定:Claude ティアの割り当て", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 8d41ef114fe..996f9935310 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -3545,7 +3545,7 @@ export const ko: Record = { "claudeDesktop.visibility.provider": "프로바이더", "claudeDesktop.visibility.allProviders": "모든 프로바이더", "claudeDesktop.visibility.selectShown": "일치하는 모든 항목 선택", - "claudeDesktop.visibility.clearShown": "일치하는 모든 항목 해제", + "claudeDesktop.visibility.clearShown": "일치하는 모든 모델 선택 해제", "claudeDesktop.visibility.toggle": "Claude Desktop에 {model} 표시", "claudeDesktop.visibility.selected": "{n}개 선택", "claudeDesktop.visibility.available": "{n}개 사용 가능", @@ -3560,7 +3560,7 @@ export const ko: Record = { "claudeDesktop.visibility.previewTitle": "다음 적용", "claudeDesktop.visibility.previewAdds": "추가: {models}", "claudeDesktop.visibility.previewRemoves": "제거: {models}", - "claudeDesktop.visibility.previewSame": "프로필에 같은 모델 ID가 있습니다. 프로필 변경 사항을 불러오려면 Desktop을 다시 시작하세요.", + "claudeDesktop.visibility.previewSame": "프로필에 같은 모델 ID가 있습니다. Desktop을 다시 시작하기 전에 프로필 변경 사항을 적용하세요.", "claudeDesktop.visibility.replaceNote": "적용하면 OpenCodex의 Claude Desktop 프로필 모델 목록이 교체되며, 직접 편집한 목록도 덮어씁니다. 고급의 티어 매핑은 선택 사항입니다. 디스커버리 전용 모드는 이 선택을 사용하지 않습니다.", "claudeDesktop.roles.quickBadge": "빠른 작업", "claudeDesktop.advanced.title": "고급: Claude 등급 배치", diff --git a/gui/src/i18n/pt.ts b/gui/src/i18n/pt.ts index 93734db647f..109f08b05b4 100644 --- a/gui/src/i18n/pt.ts +++ b/gui/src/i18n/pt.ts @@ -3568,7 +3568,7 @@ export const pt: Record = { "claudeDesktop.visibility.provider": "Provedor", "claudeDesktop.visibility.allProviders": "Todos os provedores", "claudeDesktop.visibility.selectShown": "Selecionar todos os resultados", - "claudeDesktop.visibility.clearShown": "Limpar todos os resultados", + "claudeDesktop.visibility.clearShown": "Desmarcar todos os modelos correspondentes", "claudeDesktop.visibility.toggle": "Mostrar {model} no Claude Desktop", "claudeDesktop.visibility.selected": "{n} selecionados", "claudeDesktop.visibility.available": "{n} disponíveis", @@ -3583,7 +3583,7 @@ export const pt: Record = { "claudeDesktop.visibility.previewTitle": "Próxima aplicação", "claudeDesktop.visibility.previewAdds": "Adiciona: {models}", "claudeDesktop.visibility.previewRemoves": "Remove: {models}", - "claudeDesktop.visibility.previewSame": "O perfil contém os mesmos IDs de modelos. Reinicie o Desktop para carregar as alterações do perfil.", + "claudeDesktop.visibility.previewSame": "O perfil contém os mesmos IDs de modelos. Aplique as alterações do perfil antes de reiniciar o Desktop.", "claudeDesktop.visibility.replaceNote": "Aplicar substitui a lista de modelos do perfil Claude Desktop do OpenCodex, inclusive uma lista editada à mão. O mapeamento de níveis em Avançado continua opcional. O modo somente descoberta não usa esta seleção.", "claudeDesktop.roles.quickBadge": "Tarefas rápidas", "claudeDesktop.advanced.title": "Avançado: atribuição de níveis Claude", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 5626cd2845c..0752df890d8 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -3297,7 +3297,7 @@ export const ru: Record = { "claudeDesktop.visibility.provider": "Провайдер", "claudeDesktop.visibility.allProviders": "Все провайдеры", "claudeDesktop.visibility.selectShown": "Выбрать все совпадения", - "claudeDesktop.visibility.clearShown": "Снять все совпадения", + "claudeDesktop.visibility.clearShown": "Снять выбор со всех подходящих моделей", "claudeDesktop.visibility.toggle": "Показывать {model} в Claude Desktop", "claudeDesktop.visibility.selected": "Выбрано: {n}", "claudeDesktop.visibility.available": "Доступно: {n}", @@ -3312,7 +3312,7 @@ export const ru: Record = { "claudeDesktop.visibility.previewTitle": "Следующее применение", "claudeDesktop.visibility.previewAdds": "Добавит: {models}", "claudeDesktop.visibility.previewRemoves": "Уберёт: {models}", - "claudeDesktop.visibility.previewSame": "Профиль содержит те же ID моделей. Перезапустите Desktop, чтобы загрузить изменения профиля.", + "claudeDesktop.visibility.previewSame": "Профиль содержит те же ID моделей. Примените изменения профиля перед перезапуском Desktop.", "claudeDesktop.visibility.replaceNote": "Применение заменяет список моделей в профиле Claude Desktop от OpenCodex, включая список, отредактированный вручную. Привязка уровней в разделе «Дополнительно» необязательна. Режим только обнаружения не использует этот выбор.", "claudeDesktop.roles.quickBadge": "Быстрые задачи", "claudeDesktop.advanced.title": "Дополнительно: назначение уровней Claude", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 7d65d1ccd80..07996f480d4 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -3548,7 +3548,7 @@ export const tr: Record = { "claudeDesktop.visibility.provider": "Sağlayıcı", "claudeDesktop.visibility.allProviders": "Tüm sağlayıcılar", "claudeDesktop.visibility.selectShown": "Eşleşenlerin tümünü seç", - "claudeDesktop.visibility.clearShown": "Eşleşenlerin tümünü temizle", + "claudeDesktop.visibility.clearShown": "Eşleşen tüm modellerin seçimini kaldır", "claudeDesktop.visibility.toggle": "{model} modelini Claude Desktop'ta göster", "claudeDesktop.visibility.selected": "{n} seçili", "claudeDesktop.visibility.available": "{n} kullanılabilir", @@ -3563,7 +3563,7 @@ export const tr: Record = { "claudeDesktop.visibility.previewTitle": "Sonraki uygulama", "claudeDesktop.visibility.previewAdds": "Ekler: {models}", "claudeDesktop.visibility.previewRemoves": "Kaldırır: {models}", - "claudeDesktop.visibility.previewSame": "Profil aynı model kimliklerini içeriyor. Profil değişikliklerini yüklemek için Desktop'ı yeniden başlatın.", + "claudeDesktop.visibility.previewSame": "Profil aynı model kimliklerini içeriyor. Desktop'ı yeniden başlatmadan önce profil değişikliklerini uygulayın.", "claudeDesktop.visibility.replaceNote": "Uygulamak, OpenCodex'in Claude Desktop profilindeki model listesini (elle düzenlenmiş liste dahil) değiştirir. Gelişmiş altındaki katman eşlemesi isteğe bağlıdır. Yalnızca keşif modu bu seçimi kullanmaz.", "claudeDesktop.roles.quickBadge": "Hızlı görevler", "claudeDesktop.advanced.title": "Gelişmiş: Claude düzeyi ataması", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index 2473b352940..b62a7b880e4 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -3536,7 +3536,7 @@ export const vi: Record = { "claudeDesktop.visibility.provider": "Provider", "claudeDesktop.visibility.allProviders": "Tất cả provider", "claudeDesktop.visibility.selectShown": "Chọn tất cả mục phù hợp", - "claudeDesktop.visibility.clearShown": "Bỏ chọn tất cả mục phù hợp", + "claudeDesktop.visibility.clearShown": "Bỏ chọn tất cả model phù hợp", "claudeDesktop.visibility.toggle": "Hiện {model} trong Claude Desktop", "claudeDesktop.visibility.selected": "Đã chọn {n}", "claudeDesktop.visibility.available": "{n} khả dụng", @@ -3551,7 +3551,7 @@ export const vi: Record = { "claudeDesktop.visibility.previewTitle": "Lần áp dụng tới", "claudeDesktop.visibility.previewAdds": "Thêm: {models}", "claudeDesktop.visibility.previewRemoves": "Bỏ: {models}", - "claudeDesktop.visibility.previewSame": "Hồ sơ chứa cùng các ID model. Khởi động lại Desktop để tải thay đổi hồ sơ.", + "claudeDesktop.visibility.previewSame": "Hồ sơ chứa cùng các ID model. Áp dụng thay đổi hồ sơ trước khi khởi động lại Desktop.", "claudeDesktop.visibility.replaceNote": "Áp dụng sẽ thay danh sách model trong hồ sơ Claude Desktop của OpenCodex, kể cả danh sách đã sửa tay. Ánh xạ tier trong Nâng cao vẫn là tùy chọn. Chế độ chỉ discovery không dùng lựa chọn này.", "claudeDesktop.roles.quickBadge": "Tác vụ nhanh", "claudeDesktop.advanced.title": "Nâng cao: chỉ định cấp Claude", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 172a01492e9..11c32851e3a 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -2697,7 +2697,7 @@ export const zhTW: Record = { "claudeDesktop.visibility.provider": "供應商", "claudeDesktop.visibility.allProviders": "所有供應商", "claudeDesktop.visibility.selectShown": "選取所有符合項目", - "claudeDesktop.visibility.clearShown": "取消所有符合項目", + "claudeDesktop.visibility.clearShown": "取消選取所有符合的模型", "claudeDesktop.visibility.toggle": "在 Claude Desktop 中顯示 {model}", "claudeDesktop.visibility.selected": "已選 {n}", "claudeDesktop.visibility.available": "可用 {n}", @@ -2712,7 +2712,7 @@ export const zhTW: Record = { "claudeDesktop.visibility.previewTitle": "下次套用", "claudeDesktop.visibility.previewAdds": "新增:{models}", "claudeDesktop.visibility.previewRemoves": "移除:{models}", - "claudeDesktop.visibility.previewSame": "設定檔包含相同的模型 ID。請重新啟動 Desktop 以載入設定檔變更。", + "claudeDesktop.visibility.previewSame": "設定檔包含相同的模型 ID。請先套用設定檔變更,再重新啟動 Desktop。", "claudeDesktop.visibility.replaceNote": "套用會取代 OpenCodex 的 Claude Desktop 設定中的模型清單,包括手動編輯過的清單。進階中的層級對應仍為選用。僅探索模式不使用此選擇。", "claudeDesktop.roles.quickBadge": "快速任務", "claudeDesktop.advanced.title": "進階:Claude 等級分配", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 3a39efea0ce..9982210a186 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -3518,7 +3518,7 @@ export const zh: Record = { "claudeDesktop.visibility.provider": "提供商", "claudeDesktop.visibility.allProviders": "所有提供商", "claudeDesktop.visibility.selectShown": "选中所有匹配项", - "claudeDesktop.visibility.clearShown": "取消所有匹配项", + "claudeDesktop.visibility.clearShown": "取消选择所有匹配的模型", "claudeDesktop.visibility.toggle": "在 Claude Desktop 中显示 {model}", "claudeDesktop.visibility.selected": "已选 {n}", "claudeDesktop.visibility.available": "可用 {n}", @@ -3533,7 +3533,7 @@ export const zh: Record = { "claudeDesktop.visibility.previewTitle": "下次应用", "claudeDesktop.visibility.previewAdds": "新增:{models}", "claudeDesktop.visibility.previewRemoves": "移除:{models}", - "claudeDesktop.visibility.previewSame": "配置中包含相同的模型 ID。请重启 Desktop 以加载配置更改。", + "claudeDesktop.visibility.previewSame": "配置中包含相同的模型 ID。请先应用配置更改,再重启 Desktop。", "claudeDesktop.visibility.replaceNote": "应用会替换 OpenCodex 的 Claude Desktop 配置中的模型列表,包括手动编辑过的列表。高级中的层级映射仍为可选。仅发现模式不使用此选择。", "claudeDesktop.roles.quickBadge": "快速任务", "claudeDesktop.advanced.title": "高级:Claude 等级分配", diff --git a/gui/tests/claude-desktop-visibility-card.test.tsx b/gui/tests/claude-desktop-visibility-card.test.tsx index bac7ddee2ac..57533d17741 100644 --- a/gui/tests/claude-desktop-visibility-card.test.tsx +++ b/gui/tests/claude-desktop-visibility-card.test.tsx @@ -149,7 +149,7 @@ test("a saved selection Desktop has not received yet reads as pending apply, not test("clearing every shown model blocks saving", async () => { await mount(); - await act(async () => { button("Clear all matching").click(); }); + await act(async () => { button("Deselect all matching models").click(); }); expect(container.textContent).toContain("Select at least one available model."); expect(button("Save").disabled).toBe(true); }); @@ -197,11 +197,11 @@ test("bulk labels disclose that matching rows beyond Show more are included", as await act(async () => { actions[0]!.click(); }); expect(container.querySelector(".claude-visibility-counts")?.textContent).toContain("8 selected"); expect(actions[0]!.textContent).toBe("Select all matching"); - expect(actions[1]!.textContent).toBe("Clear all matching"); + expect(actions[1]!.textContent).toBe("Deselect all matching models"); }); -test("an equal preview describes the profile file and asks Desktop to reload it", async () => { +test("an equal preview asks to apply profile changes before restarting Desktop", async () => { await mount(); expect(container.querySelector(".claude-visibility-preview")?.textContent) - .toContain("The profile contains the same model IDs. Restart Desktop to load profile changes."); + .toContain("The profile contains the same model IDs. Apply profile changes before restarting Desktop."); });