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
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
([#305](https://github.com/quickbeard/codev-code/pull/305))
- **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.
([#305](https://github.com/quickbeard/codev-code/pull/305))
- **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
Expand Down
4 changes: 4 additions & 0 deletions DIVERGENCES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<App>`, 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 `<UpdatePrompt />` 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
Expand Down
95 changes: 95 additions & 0 deletions packages/app/src/components/dialog-update.tsx
Original file line number Diff line number Diff line change
@@ -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(() => <DialogUpdate />)
})

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 (
<Dialog fit>
<DialogHeader>
<DialogTitle>{language.t("toast.update.title")}</DialogTitle>
</DialogHeader>
<DividerV2 />
<DialogBody class="flex max-w-[440px] flex-col gap-3 px-4 pt-3 pb-1">
{/* Downloaded and downloadable are different asks — one restarts the
app, the other spends a few hundred megabytes — so they get
different sentences. */}
<p class="text-14">
{language.t(state()?.status === "ready" ? "toast.update.description" : "toast.update.description.available", {
version: version(),
})}
</p>
<Show when={error()}>
{(message) => (
<p role="alert" class="text-12 text-v2-state-fg-danger">
{message()}
</p>
)}
</Show>
</DialogBody>
<DialogFooter>
<ButtonV2 variant="neutral" onClick={() => dialog.close()}>
{language.t("toast.update.action.notYet")}
</ButtonV2>
<ButtonV2 variant={busy() ? "loading" : "contrast"} disabled={busy()} onClick={() => void updater.run()}>
<Show when={!busy()} fallback={<LoaderV2 />}>
{language.t(updater.action().label)}
</Show>
</ButtonV2>
</DialogFooter>
</Dialog>
)
}
4 changes: 4 additions & 0 deletions packages/app/src/pages/layout-new.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -64,6 +65,9 @@ function NewLayoutShell(props: ParentProps) {
across navigations. It is hidden below lg, where the home screen keeps
its stacked arrangement instead. */}
<LaunchNewSession />
{/* Renders nothing: it watches the updater and opens the update dialog
the first time a check finds one. */}
<UpdatePrompt />
<div class="flex-1 min-h-0 min-w-0 flex flex-row">
<AppSidebar
class="hidden lg:flex"
Expand Down
12 changes: 8 additions & 4 deletions packages/opencode/src/cli/cmd/tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,10 +262,6 @@ export const TuiThreadCommand = cmd({
return
}

setTimeout(() => {
client.call("checkUpgrade", { directory: cwd }).catch(() => {})
}, 1000).unref?.()

try {
const { Effect } = await import("effect")
const { run } = await import("../tui/layer")
Expand All @@ -279,6 +275,14 @@ export const TuiThreadCommand = cmd({
return [tui, server]
},
config,
// Fork: the check announces what it finds with an event, and events
// reach whoever is subscribed when they are emitted. Run on a timer
// it landed while the TUI was still rendering, so "a new version is
// available" was delivered to nobody; onReady fires once the app's
// handlers exist.
onReady: () => {
client.call("checkUpgrade", { directory: cwd }).catch(() => {})
},
pluginHost: createLegacyTuiPluginHost(),
directory: cwd,
fetch: transport.fetch,
Expand Down
9 changes: 7 additions & 2 deletions packages/opencode/src/cli/upgrade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,14 @@ export async function upgrade() {

if (InstallationVersion === latest) return

const kind = Installation.getReleaseType(InstallationVersion, latest)
// Fork: only an explicit `autoupdate: true` installs anything by itself, and
// still only a patch. Upstream installs patches under an unset autoupdate
// too — a metered download and a version change nobody agreed to, on a fleet
// that pays for both. Everything else asks, which is what the TUI's
// installation.update-available dialog is for.
const silent = config.autoupdate === true && Installation.getReleaseType(InstallationVersion, latest) === "patch"

if (config.autoupdate === "notify" || kind !== "patch") {
if (!silent) {
GlobalBus.emit("event", {
directory: "global",
payload: {
Expand Down
13 changes: 12 additions & 1 deletion packages/tui/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,12 @@ export type TuiInput = {
// Fork: lets tests inject a deterministic clipboard for the dialog paste command.
clipboard?: ClipboardService
pluginHost: TuiPluginHost
// Fork: called once the app has registered its event handlers. Work that
// announces itself through an event — the startup update check — has to wait
// for this: events are delivered to whoever is subscribed at the time and
// kept for nobody, so anything emitted while the app is still rendering is
// simply lost.
onReady?: () => void
}

function errorMessage(error: unknown) {
Expand Down Expand Up @@ -348,6 +354,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
<App
onSnapshot={input.onSnapshot}
pluginHost={input.pluginHost}
onReady={input.onReady}
/>
</LocationProvider>
</EditorLabelProvider>
Expand Down Expand Up @@ -393,7 +400,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
})
})

function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPluginHost }) {
function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPluginHost; onReady?: () => void }) {
const startup = useTuiStartup()
const tuiConfig = useTuiConfig()
const route = useRoute()
Expand Down Expand Up @@ -1248,6 +1255,10 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
void exit()
})

// Fork: every event handler above is registered now, so whatever the host
// wants to announce through one has somewhere to land.
props.onReady?.()

const plugin = createMemo(() => {
if (!ready()) return
if (route.data.type !== "plugin") return
Expand Down
Loading