From db2282baa3d88f488a5f4593eb8b15d293691d4f Mon Sep 17 00:00:00 2001 From: minhn4 Date: Sun, 23 Aug 2026 09:53:38 +0700 Subject: [PATCH 1/2] feat: ask about updates at startup, in the app and the terminal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The desktop app has always checked at launch, but the only sign of an update was a titlebar arrow, and only once the download had finished. A fork-only UpdatePrompt now opens a dialog the first time a check reports an available or ready update: Install and restart, or Not yet. The terminal app had the same feature and none of the effect. Its check ran on a one-second timer and announced itself with installation.update-available, which the TUI has a confirm dialog for — but Rpc.emit is a bare postMessage and the TUI registers its handlers while it renders, a few hundred milliseconds after the check resolved. The event was delivered into an empty handler set every launch. The check now starts from a new TuiInput.onReady, called once those handlers exist. While there: only an explicit `autoupdate: true` installs a patch release by itself. The default asks, like every other release type. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 21 ++++ DIVERGENCES.md | 4 + packages/app/src/components/dialog-update.tsx | 95 +++++++++++++++++++ packages/app/src/pages/layout-new.tsx | 4 + packages/opencode/src/cli/cmd/tui.ts | 12 ++- packages/opencode/src/cli/upgrade.ts | 9 +- packages/tui/src/app.tsx | 13 ++- 7 files changed, 151 insertions(+), 7 deletions(-) create mode 100644 packages/app/src/components/dialog-update.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index e0de5c5509..01f4fd33af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,27 @@ file is the human-readable summary of what changed and why. ### Updates +- **CoDev tells you when there is an update, at launch** — the desktop app has + always checked for updates when it starts, but the only sign of one was a + small arrow in the titlebar, and only after the download had already + finished. It now asks: a dialog naming the new version, with **Install and + restart** and **Not yet**. "Not yet" leaves the titlebar arrow where it was, + so a dismissed update is still one click away, and the dialog appears once + per run rather than every time a check comes back. If automatic downloading + is off, the dialog offers **Download** first and switches to **Install and + restart** when it lands. + ([#PR](https://github.com/quickbeard/codev-code/pull/PR)) +- **The terminal app asks about updates instead of ignoring them** — `codev` + checked for a new version a second after launch and announced it with an + internal event that the interface subscribes to as it starts up. The check + usually won that race, and an event nobody is subscribed to yet is not + queued — it is dropped. The "a new release is available, update now?" dialog + the terminal app has always had could therefore never appear. The check now + starts once the interface is listening, so it does. A new version asks first, + including a patch release: only `"autoupdate": true` in your config installs + patches by itself, `"notify"` and the default both ask, and `false` (or + `CODEV_DISABLE_AUTOUPDATE`) still skips the check entirely. + ([#PR](https://github.com/quickbeard/codev-code/pull/PR)) - **You can now decide when CoDev updates itself** — it checked for updates at launch and every ten minutes after, and downloaded whatever it found straight away, with no way to say otherwise. Settings → General → Updates now has two diff --git a/DIVERGENCES.md b/DIVERGENCES.md index fbdf6f3887..267f88b633 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -296,6 +296,10 @@ from the squash message first, or use a plain merge. | Sidebar scoping: `packages/app/src/pages/home-model.tsx`, `packages/app/e2e/utils/mock-server.ts` | `projectDirectories` returns `[]` when no project is selected instead of the union of every project's directories, so Pinned and Recents list one project's sessions or none; and a `homeSelectionSeed` helper plus a once-per-model effect opens a window with no stored selection on the server's last project (`projects.last()`), but only while that server still lists it. The seeding is guarded so a deliberate deselection — clicking the selected project — keeps showing nothing. The e2e mock server gains a `projects` config field (defaulting to `[config.project]`) for specs that need more than one. | A fresh window has a selection with a server but no directory, so the union listed every project on the server at once: sessions from folders you were not in, and — because a row is marked open when its session has a tab anywhere while tab strips are per-project — rows in two different projects looking active at the same time. | Keep the empty return and the seeding guard together: seeding without the empty return changes nothing, and the empty return without seeding opens every fresh window on a blank sidebar. Take upstream's surrounding changes. | +| Startup update prompt (terminal): `packages/opencode/src/cli/cmd/tui.ts`, `packages/opencode/src/cli/upgrade.ts`, `packages/tui/src/app.tsx` | `TuiInput` gains `onReady?: () => void`; `run` forwards it to ``, which calls it once its `event.on(...)` handlers are registered. `cmd/tui.ts` drops the `setTimeout(..., 1000)` around `client.call("checkUpgrade")` and passes that call as `onReady` instead. `upgrade()` replaces `const kind = getReleaseType(...)` / `if (config.autoupdate === "notify" || kind !== "patch")` with `const silent = config.autoupdate === true && getReleaseType(...) === "patch"` / `if (!silent)`, so a newer version notifies unless the user opted into automatic patch installs. `autoupdate: false` and `CODEV_DISABLE_AUTOUPDATE` still skip the check, and `CODEV_ALWAYS_NOTIFY_UPDATE` is untouched. | The startup check announced its result with the `installation.update-available` event, which the TUI has had a confirm dialog for all along — but nobody ever saw it. `Rpc.emit` is a bare `postMessage`, the TUI's handlers are registered while it renders, and the check on a one-second timer resolved a few hundred milliseconds before that: the event was delivered into an empty handler set and lost, every launch. Ordering the check behind the TUI instead of behind a timer is the only version of this that cannot race. The second change is about consent: upstream's default installs a patch release in place, unasked, which on this fork's fleet is a metered download and a version change nobody agreed to. | Keep `onReady` and the `onReady`-triggered `checkUpgrade`; take upstream's other changes to all three files. Do not restore the timer — it is the bug. If upstream moves the App's event handlers, keep the `props.onReady?.()` call after the last of them. | + +| `packages/app/src/pages/layout-new.tsx` (update prompt) | One import and one element: mounts the fork-only `src/components/dialog-update.tsx`, whose `` renders nothing and opens the update dialog the first time the desktop updater reports `available` or `ready`. | The main process has always checked for updates at launch (`updater.start()`), but on the new layout the only thing that ever said so was a small Update button in the titlebar, and only once the download had finished. Users went weeks without noticing a waiting update. The legacy layout's `UpdateAvailableToast` in `pages/layout.tsx` is unreachable — `newLayoutDesigns` is always true. | Keep the import and the element; take upstream's other changes. If upstream reworks this file's layout, the element can sit anywhere inside the shell: it renders nothing. | + ## Known rebrand gaps - **UI branding**: TUI banner, default-theme accent, desktop/web app names, favicon/PWA diff --git a/packages/app/src/components/dialog-update.tsx b/packages/app/src/components/dialog-update.tsx new file mode 100644 index 0000000000..ac88797e45 --- /dev/null +++ b/packages/app/src/components/dialog-update.tsx @@ -0,0 +1,95 @@ +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" +import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitle } from "@opencode-ai/ui/v2/dialog-v2" +import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2" +import { LoaderV2 } from "@opencode-ai/ui/v2/loader-v2" +import { createEffect, Show } from "solid-js" +import { useLanguage } from "@/context/language" +import { usePlatform } from "@/context/platform" +import { useUpdaterAction } from "./updater-action" + +// Once per app run rather than once per mount: the layout remounts when the +// server changes, and an update the user has already answered for must not +// come back at them. +let prompted = false + +// The launch check the main process runs (updater.start) had nowhere to +// surface: the titlebar grows an Update button once an update is downloaded, +// which is easy to spend a week not noticing. This asks instead, once, the +// first time a check finds something — and "Not yet" leaves the titlebar +// button as the way back to it. +export function UpdatePrompt() { + const platform = usePlatform() + const dialog = useDialog() + + createEffect(() => { + const status = platform.updater?.state().status + // "available" is a check that stopped before downloading because automatic + // downloading is off; "ready" is one that went all the way. Either way + // there is an update the user has not been told about. + if (status !== "available" && status !== "ready") return + if (prompted) return + prompted = true + // push, not show: a dialog the user opened themselves must survive a check + // that happens to land while they are in it. + dialog.push(() => ) + }) + + return null +} + +export function DialogUpdate() { + const language = useLanguage() + const platform = usePlatform() + const dialog = useDialog() + const updater = useUpdaterAction() + + const state = () => platform.updater?.state() + const version = () => { + const value = state() + return value && "version" in value ? value.version : "" + } + const error = () => { + const value = state() + return value?.status === "error" ? value.message : undefined + } + // The two steps that carry on by themselves once started; the button reports + // them rather than offering to start them again. + const busy = () => state()?.status === "downloading" || state()?.status === "installing" + + return ( + + + {language.t("toast.update.title")} + + + + {/* Downloaded and downloadable are different asks — one restarts the + app, the other spends a few hundred megabytes — so they get + different sentences. */} +

+ {language.t(state()?.status === "ready" ? "toast.update.description" : "toast.update.description.available", { + version: version(), + })} +

+ + {(message) => ( + + )} + +
+ + dialog.close()}> + {language.t("toast.update.action.notYet")} + + void updater.run()}> + }> + {language.t(updater.action().label)} + + + +
+ ) +} diff --git a/packages/app/src/pages/layout-new.tsx b/packages/app/src/pages/layout-new.tsx index 754370ff98..44dd327046 100644 --- a/packages/app/src/pages/layout-new.tsx +++ b/packages/app/src/pages/layout-new.tsx @@ -4,6 +4,7 @@ import { useNavigate } from "@solidjs/router" import { AppSidebar } from "@/components/app-sidebar" import { DebugBar } from "@/components/debug-bar" import { Titlebar, type TitlebarUpdate } from "@/components/titlebar" +import { UpdatePrompt } from "@/components/dialog-update" import { AppSidebarProvider } from "@/context/app-sidebar" import { PinnedSessionsProvider } from "@/context/pinned-sessions" import { usePlatform } from "@/context/platform" @@ -64,6 +65,9 @@ function NewLayoutShell(props: ParentProps) { across navigations. It is hidden below lg, where the home screen keeps its stacked arrangement instead. */} + {/* Renders nothing: it watches the updater and opens the update dialog + the first time a check finds one. */} +