From 72d37cda810dd9f9b1b3085a08f2b4f0ae467f25 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Fri, 14 Aug 2026 13:40:18 +1000 Subject: [PATCH] feat(desktop): show migration progress (#42495) --- packages/app/src/i18n/en.ts | 1 + packages/desktop/src/renderer/index.tsx | 8 +- .../desktop/src/renderer/migration-status.tsx | 106 ++++++++++++++++++ 3 files changed, 113 insertions(+), 2 deletions(-) create mode 100644 packages/desktop/src/renderer/migration-status.tsx diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 72dee3584fc..56338342161 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -579,6 +579,7 @@ export const dict = { "toast.session.listFailed.title": "Failed to load sessions for {{project}}", "toast.project.reloadFailed.title": "Failed to reload {{project}}", + "toast.migration.failed.title": "Data migration failed", "toast.update.title": "Update available", "toast.update.description": "A new version of OpenCode ({{version}}) is now available to install.", diff --git a/packages/desktop/src/renderer/index.tsx b/packages/desktop/src/renderer/index.tsx index 586e82703d7..a99e8305ac8 100644 --- a/packages/desktop/src/renderer/index.tsx +++ b/packages/desktop/src/renderer/index.tsx @@ -29,6 +29,7 @@ import { DesktopFirstLaunchOnboarding } from "./onboarding" import { resetZoom, setPinchZoomEnabled, webviewZoom, zoomIn, zoomOut } from "./webview-zoom" import { windowFullscreen } from "./window-fullscreen" import { availableStartupServer, readyWslConnections } from "./wsl/connections" +import { MigrationStatus } from "./migration-status" import "./styles.css" import { Splash } from "@opencode-ai/ui/logo" import { useTheme } from "@opencode-ai/ui/theme/context" @@ -357,7 +358,7 @@ function DesktopRoot(props: { windowState: DesktopWindowState }) { ) const onboarding = Promise.withResolvers() - function Inner() { + function DesktopEffects() { const cmd = useCommand() menuTrigger = (id) => cmd.trigger(id) @@ -412,7 +413,10 @@ function DesktopRoot(props: { windowState: DesktopWindowState }) { onLoaded={onboarding.resolve} serverKey={key} /> - + + + {(server) => } + )} diff --git a/packages/desktop/src/renderer/migration-status.tsx b/packages/desktop/src/renderer/migration-status.tsx new file mode 100644 index 00000000000..128356290a0 --- /dev/null +++ b/packages/desktop/src/renderer/migration-status.tsx @@ -0,0 +1,106 @@ +import { OpenCode, type MigrationV1StatusOutput } from "@opencode-ai/client/promise" +import { useLanguage } from "@opencode-ai/app" +import { LoaderV2 } from "@opencode-ai/ui/v2/loader-v2" +import { showToastV2, toasterV2, ToastV2 } from "@opencode-ai/ui/v2/toast-v2" +import { createRoot, createSignal, onCleanup, onMount } from "solid-js" +import type { ServerReadyData } from "../preload/types" + +type Progress = Extract["progress"] + +export function MigrationStatus(props: { server: ServerReadyData }) { + const language = useLanguage() + const [progress, setProgress] = createSignal() + const abort = new AbortController() + let toastID: number | undefined + let disposeToast: (() => void) | undefined + + const hide = () => { + if (toastID !== undefined) toasterV2.dismiss(toastID) + toastID = undefined + disposeToast?.() + disposeToast = undefined + } + + const show = () => { + if (toastID !== undefined) return + toastID = toasterV2.show( + ({ toastId }) => + createRoot((dispose) => { + disposeToast?.() + disposeToast = dispose + return ( + +
+ + + + + {format(progress())} + +
+
+ ) + }), + { persistent: true }, + ) + } + + onMount(async () => { + await wait(1_000, abort.signal) + if (abort.signal.aborted) return + + const client = OpenCode.make({ + baseUrl: props.server.url, + headers: props.server.password + ? { Authorization: `Basic ${btoa(`${props.server.username ?? "opencode"}:${props.server.password}`)}` } + : undefined, + }) + + void (async () => { + while (true) { + const status = await client.migration.v1.status({ signal: abort.signal }) + setProgress(status.status === "running" ? status.progress : undefined) + if (status.status === "running") show() + else hide() + if (status.status === "completed") return + if (status.status === "error") throw new Error(status.error) + await wait(1_000, abort.signal) + } + })().catch((error) => { + if (abort.signal.aborted) return + hide() + showToastV2({ + variant: "error", + title: language.t("toast.migration.failed.title"), + description: error instanceof Error ? error.message : String(error), + duration: 10_000, + }) + }) + }) + + onCleanup(() => { + abort.abort() + hide() + }) + + return null +} + +function format(progress: Progress | undefined) { + if (!progress) return "" + if (progress.numerator === undefined) return progress.label + if (progress.denominator === undefined) return `${progress.label} ${progress.numerator}` + return `${progress.label} ${progress.numerator}/${progress.denominator}` +} + +function wait(delay: number, signal: AbortSignal) { + return new Promise((resolve) => { + const timer = setTimeout(done, delay) + signal.addEventListener("abort", done, { once: true }) + function done() { + clearTimeout(timer) + signal.removeEventListener("abort", done) + resolve() + } + }) +}