diff --git a/CHANGELOG.md b/CHANGELOG.md index 54c34aa5a7..fd3e66ca28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -169,26 +169,37 @@ file is the human-readable summary of what changed and why. auto-upload stay quiet. No sessions are lost while stalled — everything pending ships on the first upload after signing in. ([#312](https://github.com/quickbeard/codev-code/pull/312)) -- **CoDev Desktop uploads sessions too** — the background session upload used - to exist only in the terminal app; the desktop's local sessions never +- **CoDev Desktop uploads sessions too** — the background session upload + used to exist only in the terminal app; the desktop's local sessions never reached the analysis backend. The desktop's embedded server now runs the same uploader at every app start, under the same rules: signed-in machines only, deduplicated, shared lock with the terminal app and the hub so nothing double-uploads, and `CODEV_DISABLE_AUTO_UPLOAD=1` turns it off. Signing in from the desktop's gateway setup uploads the pending backlog immediately, and when uploads stall because the CoDev sign-in expired, the - desktop shows a system notification at startup — its version of the - terminal app's "Session upload is paused" warning. Work (sandbox) sessions - are covered too: each sandbox keeps its sessions in its own guest database - that the host cannot read as a file, so whenever a sandbox comes up, the - desktop reads its sessions over the sandbox's own authenticated API and - uploads them like any others — filed under the project folder the sandbox - belongs to, alongside that project's local sessions. A sandbox that is off - simply uploads on its next start; nothing is lost in between. An **Upload - Session Logs** menu item (under "Get a new API Key..." in the CoDev menu on - macOS, the File menu on Windows/Linux) runs the same sweep on demand — for - when you want a session analyzed now rather than at the next launch. + desktop shows a "Session upload is paused" toast at startup — its version + of the terminal app's warning. Work (sandbox) sessions are covered too: + each sandbox keeps its sessions in its own guest database that the host + cannot read as a file, so whenever a sandbox comes up, the desktop reads + its sessions over the sandbox's own authenticated API and uploads them + like any others — filed under the project folder the sandbox belongs to, + alongside that project's local sessions. A sandbox that is off simply + uploads on its next start; nothing is lost in between. An **Upload Session + Logs** menu item (under **Log in/Configure** in the CoDev menu on macOS, + the File menu on Windows/Linux) runs the same sweep on demand — for when + you want a session analyzed now rather than at the next launch. ([#313](https://github.com/quickbeard/codev-code/pull/313)) +- **The "Session upload is paused" warning can now sign you back in** — the + warning that your sessions stopped uploading arrived as a system + notification with nothing to click: you had to go find the sign-in yourself, + and on Linux the notification often never appeared at all. It is now a toast + inside the app with a **Log in** button that opens the same sign-in dialog + the menu item does, and it stays put until you dismiss it instead of fading + while you are reading code. That menu item is now called **Log + in/Configure** — in the **CoDev** menu on macOS, under **File** on Windows + and Linux, and in the command palette everywhere — since signing back in, + rather than minting a key, is what it is usually reached for. + ([#320](https://github.com/quickbeard/codev-code/pull/320)) - **A dropped model connection retries instead of silently stopping the agent** — when the connection to the model died mid-response (corporate proxies are fond of cutting long-lived streams), the session just went idle: no error, no @@ -501,7 +512,7 @@ already in use` and no way forward short of finding the file in a temporary 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 + And there is now a **Log in/Configure** 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 diff --git a/DIVERGENCES.md b/DIVERGENCES.md index d0ad1ce1a5..14275003c0 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -95,7 +95,7 @@ from the squash message first, or use a plain merge. | `package.json` (root) | `name` `opencode` → `codev-code`; `repository.url` → `github.com/quickbeard/codev-code`; added `release` script delegating to packages/opencode; added `bump` script pointing at the fork-only `script/bump-version.ts`; removed the `sso`, `dev:stats`, and `random` scripts; added the `lint-staged` devDependency and a `lint-staged` config block (`"*": "prettier --write --ignore-unknown"`) driving the fork-only `.husky/pre-commit` | The root manifest carries the fork's name (nothing reads it — root is private and unpublished; `packages/script` reads only `packageManager` — so this is cosmetic and also removes the name collision with the `packages/opencode` workspace member, whose name MUST stay `opencode`: build.ts derives bun compile targets and dist dir names from it). `bun run release` and `bun run bump` work from the repo root. `sso` logs into upstream's AWS org (`--sso-session=opencode`) and `dev:stats` runs inside an sst shell against upstream's production stage — neither works without anomalyco credentials; `random` is inert cruft (`echo 'Random script'`). `lint-staged` keeps staged files at Prettier's fixed point on commit, so the formatting drift PR #261 had to clean up cannot silently reaccumulate; `--ignore-unknown` skips files Prettier has no parser for (`bun.lock`, `.husky/*`, binaries) and `.prettierignore` still exempts `DIVERGENCES.md` and the docs merge base. | Keep our name + release/bump lines and the removals (drop any new upstream-infra-only scripts too); take upstream's other fields. | | `packages/opencode/package.json` | bin key `opencode` → `codev` (shim path `./bin/opencode` unchanged); added `release` script; added dependencies `"@codev/brand": "workspace:*"`, `"@codev/gateway": "workspace:*"` (the latter for the gateway-key migration's shared-auth-file read, see the config.ts migration row), and `"@codev/upload": "workspace:*"` (the session uploader — see the session-upload wiring row) | Installed command is `codev` — matches the CLI's `scriptName` and help text (the hub's command is `codevhub`; it relinquished the `codev` bin in codev-ai ≥ 0.4.0). `bun run release` publishes the version in this file (upstream-synced; script/release.ts is fork-only). | Keep our bin key, release line, and dependency lines; take upstream's other fields including `version`. | | Session-upload wiring: `packages/opencode/src/cli/cmd/tui.ts`, `packages/opencode/src/cli/cmd/run.ts`, `packages/opencode/src/cli/cmd/debug/index.ts` | tui.ts + run.ts: `spawnUploadDaemon()` (from the fork-only `@codev/upload` package) fire-and-forgotten at handler start — spawns a detached, unref'd, stdio-ignored `codev debug upload` child. debug/index.ts: registered the fork-only `UploadCommand` (debug/upload.ts, new file), the entry that child runs. | Standalone parity with the hub's launch-time upload daemon: every agent launch ships prior codev sessions to the analysis backend in the background. All gating (opt-out env `CODEV_DISABLE_AUTO_UPLOAD`, signed-in check, the hub-shared lockfile) lives inside `@codev/upload` — the wiring is one guarded call per entry point that can never block or fail the launch. | Keep the import + `spawnUploadDaemon()` line in both handlers and the debug registration; take upstream's surrounding changes. | -| Desktop session upload: `packages/desktop/src/main/sidecar.ts`, `packages/desktop/src/main/server.ts`, `packages/desktop/src/main/index.ts`, `packages/desktop/package.json` | sidecar.ts: after `Server.listen` succeeds, fire `runStartupUpload()` (from the fork-only `@codev/upload`, statically imported so electron-vite bundles it), and handle a `{type: "upload", servers?}` sidecar command that re-runs it — runs are chained on an in-process promise (the hub-shared lockfile makes a concurrent run skip, not queue, so an unchained sandbox run posted right after startup's local run would silently drop its work sessions). server.ts: `SidecarListener` gains `upload(servers?)` (posts that command with structured-clone-safe `ServerSource` records; no-op once exited). index.ts: `sandboxUploadSources()` maps every `ready` sandbox to a source (`runtime.url`/`username`/`password` + `config.projectDir`, the HOST dir, so a project's local and sandboxed sessions land in one analysis bucket); a `sandboxes.subscribe` diff fires `server?.upload(sources)` whenever a sandbox newly reaches ready (its whole history becomes readable at that moment), with a post-`initialize` catch-up for sandboxes ready before the sidecar listened; the gateway-sso `onConfigured` hook also calls `server?.upload(sandboxUploadSources())` so a fresh sign-in ships the backlog (work sessions included) immediately; an "Upload Session Logs" menu item (desktop-menu.ts: the fork-only `upload.sessions` `DesktopMenuAction`, placed under "Get a new API Key..." in the macOS app menu and the Windows/Linux File menu; threaded through desktop-menu-actions.ts, menu.ts and ipc.ts's handler deps, generic renderer dispatch already covers it) runs the same sweep on demand with an "Uploading session logs" Notification as feedback; and startup shows an OS `Notification` ("Session upload is paused") when `@codev/upload/nudge`'s `shouldNudgeSignIn()` reports the stall — the desktop counterpart of the TUI toast, every launch until sign-in. package.json: `"@codev/upload": "workspace:*"` under devDependencies — deliberately, like `@codev/gateway`: electron-vite externalizes `dependencies` into the (unshipped) runtime node_modules, and only devDependency workspace packages get bundled. | The desktop runs the server in-process under Electron's Node with no `codev` CLI in the bundle, so the CLI's detached `codev debug upload` spawn cannot cover it; running the uploader inside the sidecar utility process (never Electron main, which is UI-latency sensitive) reuses the whole pipeline — same store (the sidecar only overrides `XDG_STATE_HOME`), same shared auth file, same hub-shared lockfile, so desktop, CLI, and hub can never upload concurrently. The `#sqlite` bun/node conditional import inside `@codev/upload` (same pattern as the server core's `#db`) is what lets the identical package run on `bun:sqlite` in the CLI binary and `node:sqlite` in the sidecar. | Keep the sidecar `upload` command + startup trigger, the listener's `upload()`, the `onConfigured` call + startup Notification, and the devDependency placement; take upstream's surrounding changes. | +| Desktop session upload: `packages/desktop/src/main/sidecar.ts`, `packages/desktop/src/main/server.ts`, `packages/desktop/src/main/index.ts`, `packages/desktop/package.json` | sidecar.ts: after `Server.listen` succeeds, fire `runStartupUpload()` (from the fork-only `@codev/upload`, statically imported so electron-vite bundles it), and handle a `{type: "upload", servers?}` sidecar command that re-runs it — runs are chained on an in-process promise (the hub-shared lockfile makes a concurrent run skip, not queue, so an unchained sandbox run posted right after startup's local run would silently drop its work sessions). server.ts: `SidecarListener` gains `upload(servers?)` (posts that command with structured-clone-safe `ServerSource` records; no-op once exited). index.ts: `sandboxUploadSources()` maps every `ready` sandbox to a source (`runtime.url`/`username`/`password` + `config.projectDir`, the HOST dir, so a project's local and sandboxed sessions land in one analysis bucket); a `sandboxes.subscribe` diff fires `server?.upload(sources)` whenever a sandbox newly reaches ready (its whole history becomes readable at that moment), with a post-`initialize` catch-up for sandboxes ready before the sidecar listened; the gateway-sso `onConfigured` hook also calls `server?.upload(sandboxUploadSources())` so a fresh sign-in ships the backlog (work sessions included) immediately; an "Upload Session Logs" menu item (desktop-menu.ts: the fork-only `upload.sessions` `DesktopMenuAction`, placed under "Get a new API Key..." in the macOS app menu and the Windows/Linux File menu; threaded through desktop-menu-actions.ts, menu.ts and ipc.ts's handler deps, generic renderer dispatch already covers it) runs the same sweep on demand with an "Uploading session logs" Notification as feedback; and `@codev/upload/nudge`'s `shouldNudgeSignIn()` is read once at startup into a one-shot `let` that `registerGatewaySsoIpcHandlers`' second argument hands to the first renderer that asks (see the gateway key recovery row), which raises the "Session upload is paused" toast — the desktop counterpart of the TUI toast, every launch until sign-in. Consumed rather than re-read per request so the answer cannot flip mid-session and so a second window does not repeat a toast the user is already looking at; it replaced an OS `Notification`, which carries no action button outside macOS and on Linux depends on a notification daemon that may drop it entirely. package.json: `"@codev/upload": "workspace:*"` under devDependencies — deliberately, like `@codev/gateway`: electron-vite externalizes `dependencies` into the (unshipped) runtime node_modules, and only devDependency workspace packages get bundled. | The desktop runs the server in-process under Electron's Node with no `codev` CLI in the bundle, so the CLI's detached `codev debug upload` spawn cannot cover it; running the uploader inside the sidecar utility process (never Electron main, which is UI-latency sensitive) reuses the whole pipeline — same store (the sidecar only overrides `XDG_STATE_HOME`), same shared auth file, same hub-shared lockfile, so desktop, CLI, and hub can never upload concurrently. The `#sqlite` bun/node conditional import inside `@codev/upload` (same pattern as the server core's `#db`) is what lets the identical package run on `bun:sqlite` in the CLI binary and `node:sqlite` in the sidecar. | Keep the sidecar `upload` command + startup trigger, the listener's `upload()`, the `onConfigured` call + the startup nudge flag, and the devDependency placement; take upstream's surrounding changes. | | `bun.lock` | Mirrors the bin key rename above, the fork-only workspace packages/deps (`@codev/gateway`, `@codev/brand`, `@codev/upload`), and the fork-only `lint-staged` devDependency | Lockfile records workspace bin mappings and workspace deps. Known quirk: the root workspace stanza still says `"name": "opencode"` — bun never syncs the root manifest's name into an existing lock (verified: neither `bun install` nor `--lockfile-only` rewrites it); it self-corrects only on a from-scratch regeneration and is harmless meanwhile. Don't hand-edit it. | Regenerate with `bun install`; never hand-merge. | | `.prettierignore` | Appended `DIVERGENCES.md` and `packages/web/src/content/docs/` (each with a comment saying why) | Prettier realigns every row of this file's changed-files table, so a one-row edit churns all ~120 rows — noise in the one file that records upstream-conflict guidance, and a guaranteed conflict on the next sync. Nothing in the repo formatted it before (the pre-push hook only runs `bun typecheck`), so this pins the status quo rather than changing it. The docs directory is the upstream merge base (see `packages/web/FORK-DOCS.md`): nothing builds it, its whole job is to surface upstream doc diffs during a sync, and formatting it only adds conflict surface — plus Prettier's MDX printer mangles `index.mdx`, collapsing the fenced code blocks inside `` into inline text. | Keep our lines; take upstream's other entries. | | `packages/tui/package.json` | Added dependencies `"@codev/gateway": "workspace:*"`, `"@codev/brand": "workspace:*"`, and `"@codev/upload": "workspace:*"` (the latter's `/nudge` + `/spawn` subpaths only — sqlite-free modules for the upload sign-in toast and the post-configure daemon respawn) | The fork's gateway configure dialog imports SSO/backend/config-block logic from the fork-only `packages/codev-gateway`; the "Open docs" command takes its URL from `DOCS_URL` in the fork-only `packages/codev-brand` instead of hardcoding the host. | Keep our dependency lines; take upstream's other fields. | @@ -295,7 +295,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`. `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. | +| Gateway key recovery: `packages/app/src/desktop-menu.ts`, `packages/app/src/i18n/{en,es}.ts`, `packages/desktop/src/preload/{index,types}.ts` | A "Log in/Configure" 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. The label names the sign-in, not the key: re-authenticating is what the item is reached for, minting a key is the side effect, and the session-upload toast's **Log in** button dispatches the same command. The command id stays `gateway.apiKey.new` — it is the wire name the desktop menus dispatch. i18n gains `command.gateway.apiKey.new` and `toast.sessionUpload.paused.{title,description,action.login}`. Preload gains the `gateway-sso-ensure-key` and `gateway-sso-consume-upload-nudge` channels on the existing `gatewaySso` namespace (the nudge rides this namespace because signing back in is its entire remedy, and `GatewaySsoPlatform` in the fork-only `packages/app/src/gateway/types.ts` is where it can be typed without touching upstream's `platform.tsx`). 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`; on mount it also consumes the session-upload sign-in nudge (see the desktop session upload row) and raises a persistent toast whose **Log in** action is that same `openConfigure`. `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. Keep the "Log in/Configure" label paired with the toast action label — they name one flow. | | `packages/core/src/filesystem/search.ts`, `packages/app/e2e/utils/mock-server.ts` | `fffLayer`'s `find` drops the worktree-root entry: fff ranks the root itself into an empty-query result, where it leads every list as a bare `/` — a mention naming no file — while the ripgrep fallback never emits it. The e2e mock server gains an `agents` config field (defaulting to the previous single primary agent) so a spec can supply subagents; the `@` menu hides primary agents, so the default list left nothing to rank files against. | Empty-query `@` menus (see the `@` autocomplete rows) made the root visible for the first time, at the top of the list. Both search layers should answer the same way. | Keep the root filter and the `agents` default; take upstream's surrounding changes. | diff --git a/packages/app/src/components/gateway/gateway-configure-gate.tsx b/packages/app/src/components/gateway/gateway-configure-gate.tsx index 7e30d2fda1..2551a31600 100644 --- a/packages/app/src/components/gateway/gateway-configure-gate.tsx +++ b/packages/app/src/components/gateway/gateway-configure-gate.tsx @@ -1,4 +1,4 @@ -import { createEffect, createSignal, onCleanup } from "solid-js" +import { createEffect, createSignal, onCleanup, onMount } from "solid-js" import { useDialog } from "@opencode-ai/ui/context/dialog" import { useCommand } from "@/context/command" import { useLanguage } from "@/context/language" @@ -7,6 +7,7 @@ import { useServer } from "@/context/server" import { useServerSDK } from "@/context/server-sdk" import { useServerSync } from "@/context/server-sync" import { shouldOpenConfigureGate } from "@/gateway/configure-gate" +import { showToast } from "@/utils/toast" import { DialogConfigure } from "./dialog-configure" // ServerScopedProviders can be mounted by the legacy and new shells at the @@ -93,6 +94,25 @@ export function GatewayConfigureGate() { ], ) + // The background session upload stalls when the CoDev SSO session dies, and + // nothing else notices — the gateway api key it runs on keeps working. Main + // reports that stall once per launch (index.ts consumes the flag), so this + // fires in exactly one window even though the gate mounts in each. Persistent + // rather than timed: a toast that auto-dismisses while the user is reading + // code is the same silence the nudge exists to break. + onMount(() => { + void platform.gatewaySso?.consumeUploadSignInNudge().then((stalled) => { + if (!stalled) return + showToast({ + persistent: true, + icon: "cloud-upload", + title: language.t("toast.sessionUpload.paused.title"), + description: language.t("toast.sessionUpload.paused.description"), + actions: [{ label: language.t("toast.sessionUpload.paused.action.login"), onClick: 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 diff --git a/packages/app/src/desktop-menu.ts b/packages/app/src/desktop-menu.ts index be61f1140d..4602053b7a 100644 --- a/packages/app/src/desktop-menu.ts +++ b/packages/app/src/desktop-menu.ts @@ -82,7 +82,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: "Log in/Configure", command: "gateway.apiKey.new" }, { type: "item", label: "Upload Session Logs", action: "upload.sessions" }, { type: "item", label: "Reload Webview", action: "view.reload" }, { type: "item", label: "Restart", action: "app.relaunch" }, @@ -117,11 +117,11 @@ export const DESKTOP_MENU: DesktopMenu[] = [ // Windows/Linux menu does not render — same split Settings itself uses. { type: "item", - label: "Get a new API Key...", + label: "Log in/Configure", command: "gateway.apiKey.new", platforms: ["windows"], }, - // macOS carries this in the app menu below its API-key sibling. + // macOS carries this in the app menu below its sign-in sibling. { type: "item", label: "Upload Session Logs", diff --git a/packages/app/src/gateway/types.ts b/packages/app/src/gateway/types.ts index c340783b0d..9001641c3d 100644 --- a/packages/app/src/gateway/types.ts +++ b/packages/app/src/gateway/types.ts @@ -72,4 +72,9 @@ export type GatewaySsoPlatform = { // Validates the stored key, refreshing it silently when it is rejected and // the SSO session still allows it. Never prompts. ensureKey(): Promise + // Whether the background session upload stalled because the CoDev SSO + // session died, and this launch should say so. Lives on the SSO namespace + // because signing back in is the whole remedy. Consumed: the main process + // hands the stall to the first asker, so a second window stays quiet. + consumeUploadSignInNudge(): Promise } diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index e977a6e60b..c57687d0e5 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -28,7 +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.gateway.apiKey.new": "Log in/Configure", "command.settings.open": "Open settings", "command.session.previous": "Previous session", "command.session.next": "Next session", @@ -658,6 +658,11 @@ export const dict = { "toast.update.action.installRestart": "Install and restart", "toast.update.action.notYet": "Not yet", + "toast.sessionUpload.paused.title": "Session upload is paused", + "toast.sessionUpload.paused.description": + "Your CoDev sign-in expired, so sessions are no longer uploaded for analysis. Log in again to resume — pending sessions upload automatically afterwards.", + "toast.sessionUpload.paused.action.login": "Log in", + "error.page.title": "Something went wrong", "error.page.description": "An error occurred while loading the application.", "error.page.description.localServerStartup": "An error occurred while starting the local server.", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index a39b68e8a3..ad692477b0 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -28,7 +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.gateway.apiKey.new": "Iniciar sesión/Configurar", "command.settings.open": "Abrir ajustes", "command.session.previous": "Sesión anterior", "command.session.next": "Siguiente sesión", @@ -648,6 +648,11 @@ export const dict = { "toast.update.action.installRestart": "Instalar y reiniciar", "toast.update.action.notYet": "Todavía no", + "toast.sessionUpload.paused.title": "La subida de sesiones está pausada", + "toast.sessionUpload.paused.description": + "Tu sesión de CoDev caducó, así que las sesiones ya no se suben para su análisis. Vuelve a iniciar sesión para reanudarla: las sesiones pendientes se subirán automáticamente después.", + "toast.sessionUpload.paused.action.login": "Iniciar sesión", + "error.page.title": "Algo salió mal", "error.page.description": "Ocurrió un error al cargar la aplicación.", "error.page.description.localServerStartup": "Ocurrió un error al iniciar el servidor local.", diff --git a/packages/app/src/i18n/vi.ts b/packages/app/src/i18n/vi.ts index dc5ea718cc..6da1d3ad36 100644 --- a/packages/app/src/i18n/vi.ts +++ b/packages/app/src/i18n/vi.ts @@ -28,7 +28,7 @@ export const dict = { "command.project.index": "Chuyển đến dự án {{index}}", "command.provider.connect": "Kết nối provider", "command.server.switch": "Chuyển máy chủ", - "command.gateway.apiKey.new": "Lấy API key mới", + "command.gateway.apiKey.new": "Đăng nhập/Cấu hình", "command.settings.open": "Mở cài đặt", "command.session.previous": "Session trước", "command.session.next": "Session tiếp theo", @@ -654,6 +654,11 @@ export const dict = { "toast.update.action.installRestart": "Cài đặt và khởi động lại", "toast.update.action.notYet": "Để sau", + "toast.sessionUpload.paused.title": "Việc tải phiên lên đang tạm dừng", + "toast.sessionUpload.paused.description": + "Phiên đăng nhập CoDev của bạn đã hết hạn nên các phiên làm việc không còn được tải lên để phân tích. Hãy đăng nhập lại để tiếp tục — các phiên đang chờ sẽ tự động được tải lên sau đó.", + "toast.sessionUpload.paused.action.login": "Đăng nhập", + "error.page.title": "Đã xảy ra sự cố", "error.page.description": "Đã xảy ra lỗi khi tải ứng dụng.", "error.page.description.localServerStartup": "Đã xảy ra lỗi khi khởi động server cục bộ.", diff --git a/packages/desktop/src/main/gateway-sso/ipc.ts b/packages/desktop/src/main/gateway-sso/ipc.ts index 4015372fc8..271265d3a7 100644 --- a/packages/desktop/src/main/gateway-sso/ipc.ts +++ b/packages/desktop/src/main/gateway-sso/ipc.ts @@ -2,7 +2,13 @@ import { app, ipcMain } from "electron" import type { IpcMainInvokeEvent } from "electron" import type { GatewaySsoController } from "./controller" -export function registerGatewaySsoIpcHandlers(controller: GatewaySsoController) { +// consumeUploadSignInNudge reports whether the background session upload +// stalled for lack of an SSO session, once per app launch (see index.ts) — the +// renderer turns it into the "Session upload is paused" toast. +export function registerGatewaySsoIpcHandlers( + controller: GatewaySsoController, + consumeUploadSignInNudge: () => boolean, +) { const subscriptions = new Map void>() const unsubscribe = (id: number) => { const off = subscriptions.get(id) @@ -44,6 +50,7 @@ export function registerGatewaySsoIpcHandlers(controller: GatewaySsoController) ) ipcMain.handle("gateway-sso-fetch-models", () => controller.fetchModels()) ipcMain.handle("gateway-sso-ensure-key", () => controller.ensureKey()) + ipcMain.handle("gateway-sso-consume-upload-nudge", () => consumeUploadSignInNudge()) ipcMain.handle("gateway-sso-complete", (_event: IpcMainInvokeEvent, model: unknown) => controller.complete(requireGatewayIpcString("model", model)), ) diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts index 2ab632fc24..dc4a25b935 100644 --- a/packages/desktop/src/main/index.ts +++ b/packages/desktop/src/main/index.ts @@ -232,6 +232,19 @@ const main = Effect.gen(function* () { body: "New and changed sessions are being uploaded for analysis in the background.", }).show() } + // Fork: the background session upload needs a live CoDev SSO session, but + // the app itself runs on the gateway api key alone — so when the session + // dies, uploads stop silently while everything else keeps working. The + // sidecar's runner records that stall; the renderer turns it into a toast + // whose action opens the same sign-in dialog the Log in/Configure menu item + // does (the desktop counterpart of the TUI's startup toast, and like it, + // every launch until the sign-in happens). Read once here rather than per + // request, so a sign-in mid-session cannot flip the answer between windows, + // and handed to the first asker only — a second window would otherwise + // repeat a toast the user is already looking at. shouldNudgeSignIn is + // sqlite-free and cheap, so it is safe on the main process. + let uploadSignInNudge = shouldNudgeSignIn() + const stopSidecars = async () => { await killSidecar() wslServers.stopAll() @@ -409,21 +422,13 @@ const main = Effect.gen(function* () { server?.upload(sandboxUploadSources()) }, }), + () => { + const stalled = uploadSignInNudge + uploadSignInNudge = false + return stalled + }, ) void updater.start() - // Fork: the background session upload needs a live CoDev SSO session, but - // the app itself runs on the gateway api key alone — so when the session - // dies, uploads stop silently while everything else keeps working. The - // sidecar's runner records that stall; surface it as an OS notification - // (the desktop's counterpart of the TUI's startup toast, and like it, - // every launch until the sign-in happens). shouldNudgeSignIn is sqlite-free - // and cheap, so it is safe on the main process. - if (shouldNudgeSignIn()) { - new Notification({ - title: "Session upload is paused", - body: "Your CoDev sign-in expired, so sessions are no longer uploaded for analysis. Sign in again from the gateway setup to resume — pending sessions upload automatically afterwards.", - }).show() - } // Checked at fire time rather than captured: turning automatic checks off // has to stop the timer that is already running, not the next app launch. const updateTimer = setInterval( diff --git a/packages/desktop/src/preload/index.ts b/packages/desktop/src/preload/index.ts index 40d41be1f2..b8ea71996c 100644 --- a/packages/desktop/src/preload/index.ts +++ b/packages/desktop/src/preload/index.ts @@ -92,6 +92,7 @@ const api: ElectronAPI = { 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"), + consumeUploadSignInNudge: () => ipcRenderer.invoke("gateway-sso-consume-upload-nudge"), complete: (model) => ipcRenderer.invoke("gateway-sso-complete", model), }, updater: {