From 7fc8060ed387541f199a82bbbae3b9244ef01c01 Mon Sep 17 00:00:00 2001 From: minhn4 Date: Sat, 22 Aug 2026 16:01:07 +0700 Subject: [PATCH 1/4] feat(desktop): add Get a new API Key and refresh stale keys at startup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DialogConfigure had one caller, the configure gate, which fires only when no provider is connected — so a gateway key the backend had since rejected left the sign-in flow unreachable while every message 401'd. Add ensureKey() to the gateway SSO controller, the desktop twin of codev-hub's ensureFreshGatewayKey: validate against /key/info bounded by a 2.5s race, silently re-mint via silentSso when rejected, and ask for a login only when that is impossible. Add a Get a new API Key... menu item (macOS app menu / Windows File menu, beside Settings) dispatching a new gateway.apiKey.new command, which also lands in the command palette. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 15 +++ DIVERGENCES.md | 2 + .../gateway/gateway-configure-gate.tsx | 73 +++++++++++++++ packages/app/src/desktop-menu.ts | 9 ++ packages/app/src/gateway/types.ts | 13 +++ packages/app/src/i18n/en.ts | 1 + packages/app/src/i18n/es.ts | 1 + .../src/main/gateway-sso/controller.test.ts | 93 ++++++++++++++++++- .../src/main/gateway-sso/controller.ts | 66 ++++++++++++- packages/desktop/src/main/gateway-sso/ipc.ts | 1 + packages/desktop/src/preload/index.ts | 1 + packages/desktop/src/preload/types.ts | 7 +- 12 files changed, 279 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d21973803e..5f77c764f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -293,6 +293,21 @@ already in use` and no way forward short of finding the file in a temporary visible way out; Escape still works everywhere it did. The one exception is the provider setup screen the app shows when no provider is configured, which has never been dismissable. +- **You can get a new API key without reinstalling** — the app fetched an API + key once, when you first signed in, and had no way to ever fetch another. Keys + expire on a schedule CoDev does not set, and when yours did, every message + failed while the app still considered itself signed in — the sign-in screen + only ever appeared when no provider was connected at all, and an expired key + still counts as connected. Two things change. The app now checks the key when + it starts: if it has been rejected, it quietly gets a new one using your + existing session, and you never see it happen. Only when that is not possible + — no key at all, or a session too old to renew — does it ask you to sign in. + And there is now a **Get a new API Key...** item to do it yourself at any + time: in the **CoDev** menu on macOS, under **File** on Windows and Linux, + both next to Settings, and in the command palette on every platform. A + gateway that cannot be reached is never treated as a bad key, so a flaky + network will not sign you out. + ([#295](https://github.com/quickbeard/codev-code/pull/295)) - **The Work tabs stay out of the way until you use sandboxes** — every desktop install showed a **Code** / **Work** switch above the projects list, even though Work was empty for anyone who had never made a sandbox: switching to it diff --git a/DIVERGENCES.md b/DIVERGENCES.md index 8bcc132c54..6af7879c64 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -288,6 +288,8 @@ from the squash message first, or use a plain merge. | Dialogs close on a button, never on an outside click: `packages/ui/src/components/dialog.tsx`, `packages/ui/src/components/dialog.css`, `packages/ui/src/v2/components/dialog-v2.tsx`, `packages/ui/src/v2/components/dialog-v2.css`, `packages/ui/src/components/image-preview.tsx`, plus the `*.stories.tsx` beside the two dialogs | All three `Kobalte.Content`s take `onInteractOutside={(e) => e.preventDefault()}`, which is what actually blocks Kobalte's dismissable layer (the context row above only removes the explicit overlay click). Both `Dialog`s gained `closeLabel?`/`hideClose?` and now render the `Kobalte.CloseButton` themselves, as the last child of their content, absolutely positioned at the content's top-right — v1 at 16px/20px (12px/12px when `data-no-header`), v2 at 14px/14px, both at `z-index: 30` so they clear the settings tab header's sticky `z-index: 10`. The button was removed from v2's `DialogHeader` (which loses its `hideClose`/`closeLabel` props and its `data-hide-close` attribute) and from v1's header `Switch`, whose `action` branch used to *replace* it; `action` now simply renders. Headers reserve the corner in `padding-right` (v1 52px, v2 42px) and v1's title takes `flex: 1`; both containers take a `min-height` (v1 52px, v2 48px) so a short `fit` dialog cannot clip the button. `aria-label` defaults to `ui.common.close` through `useI18n` — the first v2 component to use the UI i18n context, which has an English fallback. | Every dialog needed a visible way out once the backdrop stopped being one, including the dozen whose call sites passed `hideClose`. Pinning the button to the content corner rather than laying it out in the header is what lets header-less dialogs (settings, release notes, the command palettes) have one at all; keeping it inside `Kobalte.Content` keeps it inside the focus trap and the modal's pointer-events layer. | Keep the corner button and the `onInteractOutside` guards. Upstream owns the header/body/footer layout — take its changes and re-apply the `padding-right` reserve and the close button on top. If upstream adds its own close button to `DialogHeader`, drop ours from `Dialog` rather than shipping two. | | Dialog close buttons at the call sites: `packages/app/src/sandbox/{dialog-add,dialog-add-default,dialog-delete,dialog-mounts}.tsx`, `packages/app/src/wsl/{dialog-add-server,dialog-delete}.tsx`, `packages/app/src/components/{dialog-manage-models,dialog-connect-provider,dialog-select-model-unpaid-v2,session-actions-menu,settings-v2/dialog-server-v2}.tsx`, `packages/app/src/components/gateway/dialog-configure.tsx`, `packages/app/src/components/dialog-select-file.tsx`, `packages/app/src/components/dialog-command-palette-v2.css` | Dropped every `hideClose`/`closeLabel` prop from `DialogHeader` (the button moved to `Dialog`, and hiding it is no longer wanted anywhere it was hidden). `dialog-configure` passes `hideClose` on its `Dialog` instead — it is the configure gate, which the dialog stack also marks `persistent`, so a close button there would be inert. The two search-first dialogs reserve the corner the button now occupies: `dialog-select-file` via `[&_[data-slot=list-search-wrapper]]:pr-10`, the v2 command palette via `padding-right` on `.command-palette-v2-search`. | Fork-only sandbox/WSL/gateway dialogs aside, these are upstream files whose only change is a removed prop. | Take upstream's version of these call sites and re-drop `hideClose`. The `hideClose` on `dialog-configure` is fork-only and must stay. | +| Gateway key recovery: `packages/app/src/desktop-menu.ts`, `packages/app/src/i18n/{en,es}.ts`, `packages/desktop/src/preload/{index,types}.ts` | A "Get a new API Key..." item dispatching the fork-only `gateway.apiKey.new` command — in the macOS app menu under Settings, and in the Windows/Linux **File** menu (`platforms: ["windows"]`) under its Settings, the same split Settings itself uses since those builds render no app menu. i18n gains `command.gateway.apiKey.new`. Preload gains the `gateway-sso-ensure-key` channel on the existing `gatewaySso` namespace. The logic is fork-only: `packages/desktop/src/main/gateway-sso/controller.ts` gains `ensureKey()`, the desktop twin of codev-hub's `ensureFreshGatewayKey` — validate the stored key against the gateway's `/key/info` (`@codev/gateway`'s already-exported `validateApiKey`, previously with no production caller), bounded by a 2.5s race so a slow gateway cannot delay startup; a definite 401/403 tries `silentSso` + `fetchApiKey` and re-saves the record with every other field intact, returning the credential for the renderer to merge into the local server's auth registry (only the renderer writes to the server); anything else is left alone. `packages/app/src/components/gateway/gateway-configure-gate.tsx` (fork-only) registers the command behind its existing driver election and runs the pre-flight once per window after sync settles, opening `DialogConfigure` only on `needs-login`. | Gateway keys expire on a schedule CoDev does not control, and nothing in the desktop app noticed: `DialogConfigure` had exactly one caller, the gate, which fires only when no provider is connected — and a dead key is still a connected provider, so every message 401'd with the sign-in flow unreachable. The silent re-mint mirrors what the hub already does for CLI launches, so the common case costs the user nothing. | Keep the menu item on both platforms (dropping the `platforms: ["windows"]` twin loses it entirely on Windows/Linux, which render no app menu) and keep the pre-flight's "only a definite rejection acts" rule — treating an unreachable gateway as a dead key would lock people out on a flaky network. Take upstream's surrounding menu/i18n/preload changes. | + ## Known rebrand gaps - **UI branding**: TUI banner, default-theme accent, desktop/web app names, favicon/PWA diff --git a/packages/app/src/components/gateway/gateway-configure-gate.tsx b/packages/app/src/components/gateway/gateway-configure-gate.tsx index c40f79cca0..f11fa7250b 100644 --- a/packages/app/src/components/gateway/gateway-configure-gate.tsx +++ b/packages/app/src/components/gateway/gateway-configure-gate.tsx @@ -1,7 +1,10 @@ import { createEffect, createSignal, onCleanup } from "solid-js" import { useDialog } from "@opencode-ai/ui/context/dialog" +import { useCommand } from "@/context/command" +import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" import { useServer } from "@/context/server" +import { useServerSDK } from "@/context/server-sdk" import { useServerSync } from "@/context/server-sync" import { shouldOpenConfigureGate } from "@/gateway/configure-gate" import { DialogConfigure } from "./dialog-configure" @@ -23,11 +26,16 @@ export function GatewayConfigureGate() { const server = useServer() const serverSync = useServerSync() + const serverSDK = useServerSDK() const dialog = useDialog() + const command = useCommand() + const language = useLanguage() // Plain flag, deliberately non-reactive: both effects are (re)triggered by // their reactive reads; this only records whether the active dialog is ours. let gateDialogOpen = false + // One-shot guard for the key pre-flight below. + let checked = false // Stateless invariant (twin of the TUI gate in packages/tui/src/app.tsx): // whenever the dialog stack empties while unconfigured, reopen the gate — @@ -60,5 +68,70 @@ export function GatewayConfigureGate() { dialog.close() }) + // The only manual way into the sign-in flow: the gate below opens + // DialogConfigure on its own only while no provider is connected, and a key + // the gateway has rejected still counts as connected. Registered as a command + // so the desktop menus (desktop-menu.ts) can dispatch it and the command + // palette lists it on every platform. + // + // Options are empty for a non-driver instance: both shells can mount this gate + // on /:dir routes, and a second registration of the same id is a duplicate the + // command registry warns about. + const openConfigure = () => void dialog.show(() => ) + command.register("gateway-api-key", () => + instances()[0] !== self + ? [] + : [ + { + id: "gateway.apiKey.new", + title: language.t("command.gateway.apiKey.new"), + category: language.t("command.category.settings"), + onSelect: openConfigure, + }, + ], + ) + + // Startup key pre-flight. The gate above only fires when no provider is + // connected, so a stored key that the gateway has since rejected left the app + // configured on paper and 401ing on every message, with no way back to the + // sign-in flow. ensureKey re-mints silently where it can; the dialog is for + // the case it cannot. + // + // Deliberately once per window and after sync settles: it reads the same + // local-server auth registry the refresh writes to, and re-running it on + // every provider change would re-check a key nothing has touched. + createEffect(() => { + if (instances()[0] !== self) return + if (!server.isLocal()) return + if (!serverSync().data.ready) return + if (checked) return + checked = true + void ensureKey() + }) + + async function ensureKey() { + const result = await platform.gatewaySso?.ensureKey().catch(() => undefined) + if (!result || result.status === "ok") return + if (result.status === "refreshed") { + // Only the credential changed — the keyless config block still points at + // the same gateway and model — so merge it into the auth registry and + // reload the server, skipping the config patch the full flow does. + const client = serverSDK().client + await client.auth + .set({ providerID: result.providerID, auth: { type: "api", key: result.apiKey } }) + .then(() => client.global.dispose()) + .catch(() => undefined) + return + } + if (dialog.active !== undefined) return + gateDialogOpen = true + void dialog.show( + () => , + () => { + gateDialogOpen = false + }, + ) + } + return null } diff --git a/packages/app/src/desktop-menu.ts b/packages/app/src/desktop-menu.ts index 49e50482be..ee60811f95 100644 --- a/packages/app/src/desktop-menu.ts +++ b/packages/app/src/desktop-menu.ts @@ -81,6 +81,7 @@ export const DESKTOP_MENU: DesktopMenu[] = [ { type: "item", role: "about" }, { type: "item", label: "Check for Updates...", action: "app.checkForUpdates", enabled: "updater" }, { type: "item", label: "Settings", command: "settings.open", accelerator: { macos: "Cmd+," } }, + { type: "item", label: "Get a new API Key...", command: "gateway.apiKey.new" }, { type: "item", label: "Reload Webview", action: "view.reload" }, { type: "item", label: "Restart", action: "app.relaunch" }, { type: "item", label: "Export Logs...", command: "logs.export" }, @@ -110,6 +111,14 @@ export const DESKTOP_MENU: DesktopMenu[] = [ accelerator: { windows: "Ctrl+," }, platforms: ["windows"], }, + // macOS carries this in the app menu beside its own Settings, which the + // Windows/Linux menu does not render — same split Settings itself uses. + { + type: "item", + label: "Get a new API Key...", + command: "gateway.apiKey.new", + platforms: ["windows"], + }, { type: "item", label: "New Window", diff --git a/packages/app/src/gateway/types.ts b/packages/app/src/gateway/types.ts index f214d666e3..c340783b0d 100644 --- a/packages/app/src/gateway/types.ts +++ b/packages/app/src/gateway/types.ts @@ -42,6 +42,16 @@ export type GatewayCompleteResult = { config: Record } +// Outcome of the startup key pre-flight (controller.ensureKey). +export type GatewayKeyCheck = + // Key validated, or the gateway could not be reached to say otherwise. + | { status: "ok" } + // A rejected key was re-minted silently; the renderer merges the credential + // into the local server's auth registry, since only it writes to the server. + | { status: "refreshed"; providerID: string; apiKey: string } + // No key, or one that cannot be replaced without a browser sign-in. + | { status: "needs-login" } + export type GatewaySsoPlatform = { getState(): Promise subscribe(cb: (state: GatewaySsoState) => void): () => void @@ -59,4 +69,7 @@ export type GatewaySsoPlatform = { // Persists credentials to the shared auth file and returns the provider // config fragment for the chosen default model. complete(model: string): Promise + // Validates the stored key, refreshing it silently when it is rejected and + // the SSO session still allows it. Never prompts. + ensureKey(): Promise } diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 7553a329aa..d33ed9dd0d 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -28,6 +28,7 @@ export const dict = { "command.project.index": "Switch to project {{index}}", "command.provider.connect": "Connect provider", "command.server.switch": "Switch server", + "command.gateway.apiKey.new": "Get a new API key", "command.settings.open": "Open settings", "command.session.previous": "Previous session", "command.session.next": "Next session", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index 86856713d7..5631fcba99 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -28,6 +28,7 @@ export const dict = { "command.project.index": "Cambiar al proyecto {{index}}", "command.provider.connect": "Conectar proveedor", "command.server.switch": "Cambiar servidor", + "command.gateway.apiKey.new": "Obtener una nueva clave de API", "command.settings.open": "Abrir ajustes", "command.session.previous": "Sesión anterior", "command.session.next": "Siguiente sesión", diff --git a/packages/desktop/src/main/gateway-sso/controller.test.ts b/packages/desktop/src/main/gateway-sso/controller.test.ts index bac6aa4a79..3c318c35de 100644 --- a/packages/desktop/src/main/gateway-sso/controller.test.ts +++ b/packages/desktop/src/main/gateway-sso/controller.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import type { AuthData } from "@codev/gateway" +import { GATEWAY_PROVIDER, type AuthData } from "@codev/gateway" import { createGatewaySsoController } from "./controller" const auth: AuthData = { @@ -195,4 +195,95 @@ describe("gateway sso controller", () => { controller.complete("model-a") expect(savedKeys[0]?.baseUrl).toBe("https://gw.example.test") }) + + // The startup pre-flight, the desktop twin of codev-hub's + // ensureFreshGatewayKey. Only a definite rejection acts, and it prompts only + // when a silent re-mint is impossible. + describe("ensureKey", () => { + const creds = { apiKey: "sk-old", model: "m", baseUrl: "https://gw.test" } + + test("leaves a valid key alone", async () => { + const controller = controllerWith({ + loadApiKey: () => creds, + validateApiKey: async () => true, + }) + + expect(await controller.ensureKey()).toEqual({ status: "ok" }) + }) + + // A gateway that cannot be reached is not evidence the key is dead, and + // must never cost someone their session. + test("leaves the key alone when validation cannot answer", async () => { + let refreshed = false + const controller = controllerWith({ + loadApiKey: () => creds, + validateApiKey: async () => { + throw new Error("network down") + }, + silentSso: async () => { + refreshed = true + return auth + }, + }) + + expect(await controller.ensureKey()).toEqual({ status: "ok" }) + expect(refreshed).toBe(false) + }) + + test("re-mints a rejected key silently and hands back the credential", async () => { + let saved: { apiKey: string; model?: string } | undefined + const controller = controllerWith({ + loadApiKey: () => creds, + validateApiKey: async () => false, + silentSso: async () => auth, + fetchApiKey: async () => "sk-new", + saveApiKey: (next) => { + saved = next + }, + }) + + expect(await controller.ensureKey()).toEqual({ + status: "refreshed", + providerID: GATEWAY_PROVIDER.id, + apiKey: "sk-new", + }) + expect(saved?.apiKey).toBe("sk-new") + // The rest of the record survives: model and provider identity are what a + // manually named provider is rewritten from. + expect(saved?.model).toBe("m") + }) + + test("asks for a login when the session cannot re-mint", async () => { + const controller = controllerWith({ + loadApiKey: () => creds, + validateApiKey: async () => false, + silentSso: async () => null, + }) + + expect(await controller.ensureKey()).toEqual({ status: "needs-login" }) + }) + + test("asks for a login when there is no key at all", async () => { + const controller = controllerWith({ loadApiKey: () => null }) + + expect(await controller.ensureKey()).toEqual({ status: "needs-login" }) + }) + + // SSO-minted keys record no base_url, so the saved gateway URL is the only + // thing left to validate against. + test("falls back to the saved gateway url when the key records none", async () => { + let seen = "" + const controller = controllerWith({ + loadApiKey: () => ({ apiKey: "sk-old", model: "m" }), + loadGatewayUrl: () => "https://saved.test", + validateApiKey: async (_key, baseUrl) => { + seen = baseUrl + return true + }, + }) + + expect(await controller.ensureKey()).toEqual({ status: "ok" }) + expect(seen).toBe("https://saved.test") + }) + }) }) diff --git a/packages/desktop/src/main/gateway-sso/controller.ts b/packages/desktop/src/main/gateway-sso/controller.ts index d32691f6b2..e74da6a773 100644 --- a/packages/desktop/src/main/gateway-sso/controller.ts +++ b/packages/desktop/src/main/gateway-sso/controller.ts @@ -4,16 +4,29 @@ import { fetchCodevConfig, fetchModels, GATEWAY_PROVIDER, + loadApiKey, + loadGatewayUrl, login, saveApiKey, saveCodevConfig, silentSso, + validateApiKey, type AuthData, } from "@codev/gateway" -import type { GatewayCompleteResult, GatewaySetupResult, GatewaySsoState } from "@opencode-ai/app/gateway/types" +import type { + GatewayCompleteResult, + GatewayKeyCheck, + GatewaySetupResult, + GatewaySsoState, +} from "@opencode-ai/app/gateway/types" const RETRIES = 3 +// Cap the key check so a slow or hung gateway never delays startup. A real +// /key/info round-trip is far under this; past it we cannot prove the key is +// dead, so we leave it alone (mirrors codev-hub's launch pre-flight). +const KEY_CHECK_TIMEOUT_MS = 2_500 + type Deps = { openUrl: (url: string) => Promise // Electron's net.fetch so enterprise proxies and PAC files apply. @@ -35,6 +48,9 @@ type GatewayFns = { fetchModels: typeof fetchModels saveCodevConfig: typeof saveCodevConfig saveApiKey: typeof saveApiKey + loadApiKey: typeof loadApiKey + loadGatewayUrl: typeof loadGatewayUrl + validateApiKey: typeof validateApiKey } export type GatewaySsoController = ReturnType @@ -48,6 +64,9 @@ export function createGatewaySsoController(deps: Deps) { fetchModels, saveCodevConfig, saveApiKey, + loadApiKey, + loadGatewayUrl, + validateApiKey, ...deps.gateway, } const fetchOpts = { fetchImpl: deps.fetchImpl } @@ -167,6 +186,51 @@ export function createGatewaySsoController(deps: Deps) { setState({ phase: "ready", status: "" }) return { gatewayUrl: config.gatewayUrl, hasApiKey: apiKey.length > 0 } }, + // Startup pre-flight, the desktop twin of codev-hub's ensureFreshGatewayKey. + // Gateway keys have a lifetime CoDev does not control, and nothing here + // noticed a dead one: the provider stays "connected" with a credential that + // 401s on every message, and the configure gate only fires when no provider + // is connected at all — so the sign-in flow was unreachable exactly when it + // was needed. + // + // A rejected key is refreshed silently when the SSO session still allows it, + // which is the common case and costs the user nothing. Only a key that + // cannot be refreshed without a browser round-trip asks for one. + async ensureKey(): Promise { + const creds = gateway.loadApiKey() + if (!creds?.apiKey) return { status: "needs-login" } + // SSO-minted keys record no base_url (see complete()), so fall back to the + // gateway URL saved with the config block. + const baseUrl = creds.baseUrl ?? gateway.loadGatewayUrl() + if (!baseUrl) return { status: "needs-login" } + + // true = valid, false = rejected, null = could not tell. Only a definite + // rejection acts: a gateway that is slow or unreachable must never cost + // someone their session. + const valid = await Promise.race([ + gateway.validateApiKey(creds.apiKey, baseUrl, fetchOpts).catch(() => null), + new Promise((resolve) => setTimeout(resolve, KEY_CHECK_TIMEOUT_MS, null)), + ]) + if (valid !== false) return { status: "ok" } + + const auth = await gateway.silentSso().catch(() => null) + if (!auth) return { status: "needs-login" } + const refreshed = await gateway.fetchApiKey(auth.access_token, fetchOpts).catch(() => "") + if (!refreshed) return { status: "needs-login" } + + // Keep every other field: the provider identity and model are what a + // manually named provider is rewritten from, and dropping them would + // silently re-label it. + gateway.saveApiKey({ ...creds, apiKey: refreshed }) + session = auth + apiKey = refreshed + gatewayUrl = baseUrl + manualKey = false + deps.onConfigured?.() + // The renderer owns writes to the local server, so hand it the credential + // to merge into the auth registry the same way the configure flow does. + return { status: "refreshed", providerID: GATEWAY_PROVIDER.id, apiKey: refreshed } + }, setManualApiKey(key: string) { if (!key.length) throw new Error("empty API key") apiKey = key diff --git a/packages/desktop/src/main/gateway-sso/ipc.ts b/packages/desktop/src/main/gateway-sso/ipc.ts index 967d417c6e..4015372fc8 100644 --- a/packages/desktop/src/main/gateway-sso/ipc.ts +++ b/packages/desktop/src/main/gateway-sso/ipc.ts @@ -43,6 +43,7 @@ export function registerGatewaySsoIpcHandlers(controller: GatewaySsoController) controller.setManualApiKey(requireGatewayIpcString("API key", key)), ) ipcMain.handle("gateway-sso-fetch-models", () => controller.fetchModels()) + ipcMain.handle("gateway-sso-ensure-key", () => controller.ensureKey()) ipcMain.handle("gateway-sso-complete", (_event: IpcMainInvokeEvent, model: unknown) => controller.complete(requireGatewayIpcString("model", model)), ) diff --git a/packages/desktop/src/preload/index.ts b/packages/desktop/src/preload/index.ts index a451cbc63c..40d41be1f2 100644 --- a/packages/desktop/src/preload/index.ts +++ b/packages/desktop/src/preload/index.ts @@ -91,6 +91,7 @@ const api: ElectronAPI = { fetchSetup: () => ipcRenderer.invoke("gateway-sso-fetch-setup"), setManualApiKey: (apiKey) => ipcRenderer.invoke("gateway-sso-set-manual-api-key", apiKey), fetchModels: () => ipcRenderer.invoke("gateway-sso-fetch-models"), + ensureKey: () => ipcRenderer.invoke("gateway-sso-ensure-key"), complete: (model) => ipcRenderer.invoke("gateway-sso-complete", model), }, updater: { diff --git a/packages/desktop/src/preload/types.ts b/packages/desktop/src/preload/types.ts index 69a8f42b59..7c09afddfd 100644 --- a/packages/desktop/src/preload/types.ts +++ b/packages/desktop/src/preload/types.ts @@ -26,7 +26,12 @@ export type { SandboxesState, WslInstallState, } from "@opencode-ai/app/sandbox/types" -export type { GatewayCompleteResult, GatewaySetupResult, GatewaySsoState } from "@opencode-ai/app/gateway/types" +export type { + GatewayCompleteResult, + GatewayKeyCheck, + GatewaySetupResult, + GatewaySsoState, +} from "@opencode-ai/app/gateway/types" export type ServerReadyData = { url: string From 37ca4f1d5ab1e747e3af029af284a7d0862620b7 Mon Sep 17 00:00:00 2001 From: minhn4 Date: Sat, 22 Aug 2026 16:01:42 +0700 Subject: [PATCH 2/4] docs: correct the PR link in the API key changelog entry Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f77c764f6..1c9fd24e61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -307,7 +307,7 @@ already in use` and no way forward short of finding the file in a temporary both next to Settings, and in the command palette on every platform. A gateway that cannot be reached is never treated as a bad key, so a flaky network will not sign you out. - ([#295](https://github.com/quickbeard/codev-code/pull/295)) + ([#295](https://github.com/quickbeard/codev-code/pull/296)) - **The Work tabs stay out of the way until you use sandboxes** — every desktop install showed a **Code** / **Work** switch above the projects list, even though Work was empty for anyone who had never made a sandbox: switching to it From 3bc3a62e07c2e2940d5b42b8b346281b4ab2166d Mon Sep 17 00:00:00 2001 From: minhn4 Date: Sat, 22 Aug 2026 16:01:51 +0700 Subject: [PATCH 3/4] docs: fix the API key changelog entry label Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c9fd24e61..409b0cb660 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -307,7 +307,7 @@ already in use` and no way forward short of finding the file in a temporary both next to Settings, and in the command palette on every platform. A gateway that cannot be reached is never treated as a bad key, so a flaky network will not sign you out. - ([#295](https://github.com/quickbeard/codev-code/pull/296)) + ([#296](https://github.com/quickbeard/codev-code/pull/296)) - **The Work tabs stay out of the way until you use sandboxes** — every desktop install showed a **Code** / **Work** switch above the projects list, even though Work was empty for anyone who had never made a sandbox: switching to it From 3cef4b77af73811ee017c175d706d1ba0f2f5402 Mon Sep 17 00:00:00 2001 From: minhn4 Date: Sat, 22 Aug 2026 16:16:47 +0700 Subject: [PATCH 4/4] fix(app): keep the manually opened configure dialog dismissable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DialogConfigure renders hideClose and marks itself persistent, which is right for the configure gate but traps anyone who opens it from the new menu command or the startup key check — a backdrop click no longer closes anything since the dialog rework. Gate dismissability on a prop. Co-Authored-By: Claude Fable 5 --- DIVERGENCES.md | 2 +- .../app/src/components/gateway/dialog-configure.tsx | 12 +++++++++--- .../components/gateway/gateway-configure-gate.tsx | 9 +++++++-- 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/DIVERGENCES.md b/DIVERGENCES.md index 6af7879c64..2f81d62080 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -288,7 +288,7 @@ from the squash message first, or use a plain merge. | Dialogs close on a button, never on an outside click: `packages/ui/src/components/dialog.tsx`, `packages/ui/src/components/dialog.css`, `packages/ui/src/v2/components/dialog-v2.tsx`, `packages/ui/src/v2/components/dialog-v2.css`, `packages/ui/src/components/image-preview.tsx`, plus the `*.stories.tsx` beside the two dialogs | All three `Kobalte.Content`s take `onInteractOutside={(e) => e.preventDefault()}`, which is what actually blocks Kobalte's dismissable layer (the context row above only removes the explicit overlay click). Both `Dialog`s gained `closeLabel?`/`hideClose?` and now render the `Kobalte.CloseButton` themselves, as the last child of their content, absolutely positioned at the content's top-right — v1 at 16px/20px (12px/12px when `data-no-header`), v2 at 14px/14px, both at `z-index: 30` so they clear the settings tab header's sticky `z-index: 10`. The button was removed from v2's `DialogHeader` (which loses its `hideClose`/`closeLabel` props and its `data-hide-close` attribute) and from v1's header `Switch`, whose `action` branch used to *replace* it; `action` now simply renders. Headers reserve the corner in `padding-right` (v1 52px, v2 42px) and v1's title takes `flex: 1`; both containers take a `min-height` (v1 52px, v2 48px) so a short `fit` dialog cannot clip the button. `aria-label` defaults to `ui.common.close` through `useI18n` — the first v2 component to use the UI i18n context, which has an English fallback. | Every dialog needed a visible way out once the backdrop stopped being one, including the dozen whose call sites passed `hideClose`. Pinning the button to the content corner rather than laying it out in the header is what lets header-less dialogs (settings, release notes, the command palettes) have one at all; keeping it inside `Kobalte.Content` keeps it inside the focus trap and the modal's pointer-events layer. | Keep the corner button and the `onInteractOutside` guards. Upstream owns the header/body/footer layout — take its changes and re-apply the `padding-right` reserve and the close button on top. If upstream adds its own close button to `DialogHeader`, drop ours from `Dialog` rather than shipping two. | | Dialog close buttons at the call sites: `packages/app/src/sandbox/{dialog-add,dialog-add-default,dialog-delete,dialog-mounts}.tsx`, `packages/app/src/wsl/{dialog-add-server,dialog-delete}.tsx`, `packages/app/src/components/{dialog-manage-models,dialog-connect-provider,dialog-select-model-unpaid-v2,session-actions-menu,settings-v2/dialog-server-v2}.tsx`, `packages/app/src/components/gateway/dialog-configure.tsx`, `packages/app/src/components/dialog-select-file.tsx`, `packages/app/src/components/dialog-command-palette-v2.css` | Dropped every `hideClose`/`closeLabel` prop from `DialogHeader` (the button moved to `Dialog`, and hiding it is no longer wanted anywhere it was hidden). `dialog-configure` passes `hideClose` on its `Dialog` instead — it is the configure gate, which the dialog stack also marks `persistent`, so a close button there would be inert. The two search-first dialogs reserve the corner the button now occupies: `dialog-select-file` via `[&_[data-slot=list-search-wrapper]]:pr-10`, the v2 command palette via `padding-right` on `.command-palette-v2-search`. | Fork-only sandbox/WSL/gateway dialogs aside, these are upstream files whose only change is a removed prop. | Take upstream's version of these call sites and re-drop `hideClose`. The `hideClose` on `dialog-configure` is fork-only and must stay. | -| Gateway key recovery: `packages/app/src/desktop-menu.ts`, `packages/app/src/i18n/{en,es}.ts`, `packages/desktop/src/preload/{index,types}.ts` | A "Get a new API Key..." item dispatching the fork-only `gateway.apiKey.new` command — in the macOS app menu under Settings, and in the Windows/Linux **File** menu (`platforms: ["windows"]`) under its Settings, the same split Settings itself uses since those builds render no app menu. i18n gains `command.gateway.apiKey.new`. Preload gains the `gateway-sso-ensure-key` channel on the existing `gatewaySso` namespace. The logic is fork-only: `packages/desktop/src/main/gateway-sso/controller.ts` gains `ensureKey()`, the desktop twin of codev-hub's `ensureFreshGatewayKey` — validate the stored key against the gateway's `/key/info` (`@codev/gateway`'s already-exported `validateApiKey`, previously with no production caller), bounded by a 2.5s race so a slow gateway cannot delay startup; a definite 401/403 tries `silentSso` + `fetchApiKey` and re-saves the record with every other field intact, returning the credential for the renderer to merge into the local server's auth registry (only the renderer writes to the server); anything else is left alone. `packages/app/src/components/gateway/gateway-configure-gate.tsx` (fork-only) registers the command behind its existing driver election and runs the pre-flight once per window after sync settles, opening `DialogConfigure` only on `needs-login`. | Gateway keys expire on a schedule CoDev does not control, and nothing in the desktop app noticed: `DialogConfigure` had exactly one caller, the gate, which fires only when no provider is connected — and a dead key is still a connected provider, so every message 401'd with the sign-in flow unreachable. The silent re-mint mirrors what the hub already does for CLI launches, so the common case costs the user nothing. | Keep the menu item on both platforms (dropping the `platforms: ["windows"]` twin loses it entirely on Windows/Linux, which render no app menu) and keep the pre-flight's "only a definite rejection acts" rule — treating an unreachable gateway as a dead key would lock people out on a flaky network. Take upstream's surrounding menu/i18n/preload changes. | +| Gateway key recovery: `packages/app/src/desktop-menu.ts`, `packages/app/src/i18n/{en,es}.ts`, `packages/desktop/src/preload/{index,types}.ts` | A "Get a new API Key..." item dispatching the fork-only `gateway.apiKey.new` command — in the macOS app menu under Settings, and in the Windows/Linux **File** menu (`platforms: ["windows"]`) under its Settings, the same split Settings itself uses since those builds render no app menu. i18n gains `command.gateway.apiKey.new`. Preload gains the `gateway-sso-ensure-key` channel on the existing `gatewaySso` namespace. The logic is fork-only: `packages/desktop/src/main/gateway-sso/controller.ts` gains `ensureKey()`, the desktop twin of codev-hub's `ensureFreshGatewayKey` — validate the stored key against the gateway's `/key/info` (`@codev/gateway`'s already-exported `validateApiKey`, previously with no production caller), bounded by a 2.5s race so a slow gateway cannot delay startup; a definite 401/403 tries `silentSso` + `fetchApiKey` and re-saves the record with every other field intact, returning the credential for the renderer to merge into the local server's auth registry (only the renderer writes to the server); anything else is left alone. `packages/app/src/components/gateway/gateway-configure-gate.tsx` (fork-only) registers the command behind its existing driver election and runs the pre-flight once per window after sync settles, opening `DialogConfigure` only on `needs-login`. `DialogConfigure` gains a `dismissable` prop driving both `setPersistent` and `hideClose`: the gate's copy stays trapped (the provider-setup screen the dialog rework deliberately exempts), while the two new entry points — the menu command and the pre-flight — are escapable, since a provider is configured in both cases and the dialog otherwise has no close button, no backdrop dismissal and no Escape. | Gateway keys expire on a schedule CoDev does not control, and nothing in the desktop app noticed: `DialogConfigure` had exactly one caller, the gate, which fires only when no provider is connected — and a dead key is still a connected provider, so every message 401'd with the sign-in flow unreachable. The silent re-mint mirrors what the hub already does for CLI launches, so the common case costs the user nothing. | Keep the menu item on both platforms (dropping the `platforms: ["windows"]` twin loses it entirely on Windows/Linux, which render no app menu) and keep the pre-flight's "only a definite rejection acts" rule — treating an unreachable gateway as a dead key would lock people out on a flaky network. Take upstream's surrounding menu/i18n/preload changes. | ## Known rebrand gaps diff --git a/packages/app/src/components/gateway/dialog-configure.tsx b/packages/app/src/components/gateway/dialog-configure.tsx index ee89ffc180..a5ee62fa66 100644 --- a/packages/app/src/components/gateway/dialog-configure.tsx +++ b/packages/app/src/components/gateway/dialog-configure.tsx @@ -27,7 +27,13 @@ type Step = // configure gate keeps open while no provider is configured. Unlike the TUI // (whose dialog system swaps element factories per step), the whole flow lives // in this one component as internal steps, so persistence is armed once. -export function DialogConfigure() { +// +// `dismissable` is for the entry points the user chose to open — the "Get a new +// API Key" command. The gate's copy must stay trapped (there is no configured +// provider to go back to), but a dialog opened on purpose needs a way out, and +// since this one hides its close button and a backdrop click no longer closes +// anything, leaving it persistent would strand whoever changed their mind. +export function DialogConfigure(props: { dismissable?: boolean } = {}) { const dialog = useDialog() const language = useLanguage() const platform = usePlatform() @@ -43,7 +49,7 @@ export function DialogConfigure() { const [copied, setCopied] = createSignal(false) onMount(() => { - dialog.setPersistent(true) + dialog.setPersistent(!props.dismissable) void sso.getState().then(setSsoState) onCleanup(sso.subscribe(setSsoState)) }) @@ -173,7 +179,7 @@ export function DialogConfigure() { } return ( - +
diff --git a/packages/app/src/components/gateway/gateway-configure-gate.tsx b/packages/app/src/components/gateway/gateway-configure-gate.tsx index f11fa7250b..7e30d2fda1 100644 --- a/packages/app/src/components/gateway/gateway-configure-gate.tsx +++ b/packages/app/src/components/gateway/gateway-configure-gate.tsx @@ -77,7 +77,9 @@ export function GatewayConfigureGate() { // Options are empty for a non-driver instance: both shells can mount this gate // on /:dir routes, and a second registration of the same id is a duplicate the // command registry warns about. - const openConfigure = () => void dialog.show(() => ) + // Dismissable, unlike the gate's copy below: this one was opened on purpose, + // so changing your mind has to be possible. + const openConfigure = () => void dialog.show(() => ) command.register("gateway-api-key", () => instances()[0] !== self ? [] @@ -125,8 +127,11 @@ export function GatewayConfigureGate() { } if (dialog.active !== undefined) return gateDialogOpen = true + // Dismissable: unlike the gate above, a provider *is* configured here — its + // key is just dead — so this is not the setup screen the app traps you on. + // The menu item is still there for whoever waves it away. void dialog.show( - () => , + () => , () => { gateDialogOpen = false },