Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
([#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
Expand Down
2 changes: 2 additions & 0 deletions DIVERGENCES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`. `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

- **UI branding**: TUI banner, default-theme accent, desktop/web app names, favicon/PWA
Expand Down
12 changes: 9 additions & 3 deletions packages/app/src/components/gateway/dialog-configure.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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))
})
Expand Down Expand Up @@ -173,7 +179,7 @@ export function DialogConfigure() {
}

return (
<Dialog class="h-full" transition hideClose>
<Dialog class="h-full" transition hideClose={!props.dismissable}>
<div class="flex flex-col gap-6 px-5 py-4 w-[440px] max-w-full">
<Switch>
<Match when={step().kind === "menu"}>
Expand Down
78 changes: 78 additions & 0 deletions packages/app/src/components/gateway/gateway-configure-gate.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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 —
Expand Down Expand Up @@ -60,5 +68,75 @@ 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.
// 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(() => <DialogConfigure dismissable />)
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
// 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(
() => <DialogConfigure dismissable />,
() => {
gateDialogOpen = false
},
)
}

return null
}
9 changes: 9 additions & 0 deletions packages/app/src/desktop-menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down Expand Up @@ -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",
Expand Down
13 changes: 13 additions & 0 deletions packages/app/src/gateway/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,16 @@ export type GatewayCompleteResult = {
config: Record<string, unknown>
}

// 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<GatewaySsoState>
subscribe(cb: (state: GatewaySsoState) => void): () => void
Expand All @@ -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<GatewayCompleteResult>
// Validates the stored key, refreshing it silently when it is rejected and
// the SSO session still allows it. Never prompts.
ensureKey(): Promise<GatewayKeyCheck>
}
1 change: 1 addition & 0 deletions packages/app/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/app/src/i18n/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading