From 5ca175ee5a1ab4b30f7ce8594f8d14a9190c25af Mon Sep 17 00:00:00 2001 From: David Hill Date: Wed, 16 Sep 2026 14:05:04 -0600 Subject: [PATCH] feat(desktop): add animated first-launch loading screen --- packages/app/src/desktop.ts | 1 + packages/desktop/electron.vite.config.test.ts | 25 ++++ packages/desktop/electron.vite.config.ts | 3 + .../desktop/src/main/lifecycle/environment.ts | 7 +- packages/desktop/src/main/windows/index.ts | 4 + packages/desktop/src/renderer/desktop-app.tsx | 107 ++++++++++-------- packages/desktop/src/renderer/env.d.ts | 4 + .../desktop/src/renderer/onboarding/index.tsx | 36 ++++-- .../desktop/src/renderer/startup/splash.css | 25 ++++ .../desktop/src/renderer/startup/splash.tsx | 90 +++++++++++++-- .../ui/src/typography/wordmark/wordmark.tsx | 56 +++++++-- 11 files changed, 279 insertions(+), 79 deletions(-) create mode 100644 packages/desktop/src/renderer/startup/splash.css diff --git a/packages/app/src/desktop.ts b/packages/app/src/desktop.ts index ce023807e674..5f2253bc99a4 100644 --- a/packages/app/src/desktop.ts +++ b/packages/app/src/desktop.ts @@ -15,6 +15,7 @@ export type { BrowserPaneTarget, } from "./runtime/platform/browser-pane" export { ServerConnection, useServers } from "./runtime/server/registry" +export { useGlobal } from "./runtime/server/runtime" export { useTabs } from "./shell/tabs/tabs" export { createDraftStore } from "./runtime/persistence/drafts" export { createNamespaceStorage, type NamespaceStorage } from "./runtime/persistence/namespace" diff --git a/packages/desktop/electron.vite.config.test.ts b/packages/desktop/electron.vite.config.test.ts index 5be8a49e073d..ba7131d67963 100644 --- a/packages/desktop/electron.vite.config.test.ts +++ b/packages/desktop/electron.vite.config.test.ts @@ -15,6 +15,31 @@ test.each(["build", "serve"] as const)("configures minification for %s", async ( expect(result?.config.renderer?.build?.sourcemap).toBe(true) }) +test("onboarding preview is enabled only by the development test flag", async () => { + const previous = process.env.OPENCODE_TEST_ONBOARDING + try { + process.env.OPENCODE_TEST_ONBOARDING = "1" + for (const command of ["build", "serve"] as const) { + const result = await loadConfigFromFile( + { command, mode: command === "build" ? "production" : "development" }, + `${import.meta.dirname}/electron.vite.config.ts`, + ) + expect(result?.config.renderer?.define?.["import.meta.env.OPENCODE_TEST_ONBOARDING"]).toBe( + JSON.stringify(command === "serve"), + ) + } + delete process.env.OPENCODE_TEST_ONBOARDING + const result = await loadConfigFromFile( + { command: "serve", mode: "development" }, + `${import.meta.dirname}/electron.vite.config.ts`, + ) + expect(result?.config.renderer?.define?.["import.meta.env.OPENCODE_TEST_ONBOARDING"]).toBe("false") + } finally { + delete process.env.OPENCODE_TEST_ONBOARDING + if (previous !== undefined) process.env.OPENCODE_TEST_ONBOARDING = previous + } +}) + test("does not package external copies of bundled dependencies", () => { for (const name of ["effect", "@effect/platform-node", "@effect/platform-node-shared", "drizzle-orm"]) { expect(Object.keys(pkg.dependencies)).not.toContain(name) diff --git a/packages/desktop/electron.vite.config.ts b/packages/desktop/electron.vite.config.ts index 4d04881c855e..11ecf14353ac 100644 --- a/packages/desktop/electron.vite.config.ts +++ b/packages/desktop/electron.vite.config.ts @@ -96,6 +96,9 @@ const require = __cjs_mod__.createRequire(import.meta.url); define: { "import.meta.env.OPENCODE_VERSION": JSON.stringify(process.env.OPENCODE_VERSION), "import.meta.env.VITE_OPENCODE_CHANNEL": JSON.stringify(channel), + "import.meta.env.OPENCODE_TEST_ONBOARDING": JSON.stringify( + command === "serve" && process.env.OPENCODE_TEST_ONBOARDING === "1", + ), }, plugins: [pickerPlugin(), appPlugin, sentry], publicDir: "../../../app/public", diff --git a/packages/desktop/src/main/lifecycle/environment.ts b/packages/desktop/src/main/lifecycle/environment.ts index e71a707b63ee..ab07ef2a0fa2 100644 --- a/packages/desktop/src/main/lifecycle/environment.ts +++ b/packages/desktop/src/main/lifecycle/environment.ts @@ -42,7 +42,10 @@ export const configureApplication = Effect.fn("Application.configure")(function* const testRoot = yield* createTestRoot() app.setPath("userData", testRoot ? path.join(testRoot, "desktop") : path.join(app.getPath("appData"), appID)) - if (testRoot) app.setPath("sessionData", path.join(testRoot, "session")) + if (testRoot) { + app.setPath("sessionData", path.join(testRoot, "session")) + if (testOnboarding) app.setPath("documents", path.join(testRoot, "documents")) + } }) export function acquireApplicationLock() { @@ -99,7 +102,7 @@ const createTestRoot = Effect.fn("Application.createTestRoot")(function* () { if (!root) return undefined if (testOnboarding) yield* fs.remove(root, { recursive: true, force: true }) yield* Effect.forEach( - ["data", "config", "cache", "state", "desktop", "session"], + ["data", "config", "cache", "state", "desktop", "session", "documents"], (dir) => fs.makeDirectory(path.join(root, dir), { recursive: true }), { discard: true }, ) diff --git a/packages/desktop/src/main/windows/index.ts b/packages/desktop/src/main/windows/index.ts index 07f38a008b89..8f2452a33d5f 100644 --- a/packages/desktop/src/main/windows/index.ts +++ b/packages/desktop/src/main/windows/index.ts @@ -120,6 +120,10 @@ export const makeMainWindows = Effect.fn("Window.make")(function* () { if (!contentReady || !appliedTheme || revealed || win.isDestroyed()) return revealed = true win.show() + if (!app.isPackaged && process.env.OPENCODE_TEST_ONBOARDING === "1") { + if (process.platform === "darwin") app.focus({ steal: true }) + win.focus() + } runFork(Effect.logInfo("main window visible", { window: id })) } const ready = () => { diff --git a/packages/desktop/src/renderer/desktop-app.tsx b/packages/desktop/src/renderer/desktop-app.tsx index 41c592fed971..d334bad95960 100644 --- a/packages/desktop/src/renderer/desktop-app.tsx +++ b/packages/desktop/src/renderer/desktop-app.tsx @@ -23,7 +23,7 @@ import { createEffect, createMemo, createResource, lazy, Show, Suspense } from " import { createStore } from "solid-js/store" import type { ElectronAPI } from "./api-types" import { DesktopFirstLaunchOnboarding } from "./onboarding" -import { createDesktopPlatform, type DesktopWindowState } from "./platform" +import { createDesktopPlatform } from "./platform" import { bindDesktopMenu } from "./platform/menu" import { createSidecarResolver, initializationData, sidecarHttp } from "./startup/initialization" import { preloadStoredLocale } from "./startup/locale" @@ -37,53 +37,41 @@ const MigrationStatus = lazy(() => import("./migration-status").then((module) => export function DesktopApp(props: { api: ElectronAPI; updater: UpdaterPlatform; version: string }) { const windowState = { id: props.api.getWindowID(), version: props.version } - const url = new URL(getLastActiveUrl(windowState.id), "http://localhost") + const initialUrl = getLastActiveUrl(windowState.id) + const url = new URL(initialUrl, "http://localhost") const route = currentRoute(url.pathname, url.search) - const [startup, setStartup] = createStore<{ ready: boolean; visible: boolean; route: LayoutRoute }>({ + const [startup, setStartup] = createStore({ ready: false, visible: true, + themeReady: false, + onboardingReady: false, + drawingReady: false, route, }) - return ( - <> - setStartup("ready", true)} - onRoute={(route) => setStartup("route", route)} - /> - -
{ - if (event.target !== event.currentTarget || !startup.ready) return - setStartup("visible", false) - }} - > - -
-
- + const [firstLaunch] = createResource(() => + props.api.isFirstLaunchOnboardingPending().catch((error) => { + console.error("[desktop-onboarding] first launch check failed", error) + return false + }), ) -} - -function DesktopWindow(props: { - api: ElectronAPI - updater: UpdaterPlatform - windowState: DesktopWindowState - onReady: () => void - onRoute: (route: LayoutRoute) => void -}) { - const platform = createDesktopPlatform(props.api, props.windowState, props.updater) + const platform = createDesktopPlatform(props.api, windowState, props.updater) const [sidecar, { mutate: setSidecar }] = createResource(() => props.api.awaitInitialization()) const [defaultServer] = createResource(() => platform.getDefaultServer?.()) const [locale] = createResource(() => preloadStoredLocale(platform)) - const [initialRoute] = createResource(() => preloadRoute(getLastActiveUrl(props.windowState.id))) - const router = (routerProps: BaseRouterProps) => ( - + const [initialRoute] = createResource( + () => !firstLaunch.loading && (firstLaunch() && initialUrl === "/" ? "/new-session" : initialUrl), + preloadRoute, ) + const router = (routerProps: BaseRouterProps) => + const readyToReveal = () => + startup.ready && + (!import.meta.env.OPENCODE_TEST_ONBOARDING || !firstLaunch() || initialUrl !== "/" || startup.drawingReady) + + // Reveal only after the theme and the first-launch splash choice are both resolved. + createEffect(() => { + if (!startup.themeReady || firstLaunch.loading) return + void props.api.themeReady() + }) function ReadyApp() { const wslServers = useWslServers() @@ -91,7 +79,13 @@ function DesktopWindow(props: { const sshConnections = createSshConnections(props.api.sshServers) const language = useLanguage() const ready = createMemo( - () => !defaultServer.loading && !sidecar.loading && !locale.loading && !wslServers.isLoading && !ssh.loading, + () => + !firstLaunch.loading && + !defaultServer.loading && + !sidecar.loading && + !locale.loading && + !wslServers.isLoading && + !ssh.loading, ) const servers = createMemo(() => { const data = initializationData(sidecar) @@ -119,14 +113,16 @@ function DesktopWindow(props: { {(key) => ( !initialRoute.loading} - onReady={props.onReady} - onRoute={props.onRoute} + routeReady={!initialRoute.loading && startup.onboardingReady} + onReady={() => setStartup("ready", true)} + onRoute={(route) => setStartup("route", route)} /> setStartup("onboardingReady", true)} /> @@ -148,17 +144,36 @@ function DesktopWindow(props: { onNativeTranslations={(bundle) => void props.api.setNativeTranslations(bundle).catch(() => undefined)} onThemeApplied={(mode, scheme) => { void props.api.setTitlebar({ mode, scheme }) - void props.api.themeReady() + setStartup("themeReady", true) }} > {(_) => } + +
{ + if (event.target !== event.currentTarget || !readyToReveal()) return + setStartup("visible", false) + }} + > + setStartup("drawingReady", true)} + /> +
+
) } function DesktopStartupReady(props: { - routeReady: () => boolean + routeReady: boolean onReady: () => void onRoute: (route: LayoutRoute) => void }) { @@ -166,7 +181,7 @@ function DesktopStartupReady(props: { const route = useCurrentRoute() createEffect(() => props.onRoute(route())) createEffect(() => { - if (!props.routeReady() || !tabs.ready() || !tabs.infoReady()) return + if (!props.routeReady || !tabs.ready() || !tabs.infoReady()) return props.onReady() }) return null diff --git a/packages/desktop/src/renderer/env.d.ts b/packages/desktop/src/renderer/env.d.ts index eca4134d9b00..ba7836cbfe30 100644 --- a/packages/desktop/src/renderer/env.d.ts +++ b/packages/desktop/src/renderer/env.d.ts @@ -1,6 +1,10 @@ import type { ElectronNative } from "../preload/types" declare global { + interface ImportMetaEnv { + readonly OPENCODE_TEST_ONBOARDING: boolean + } + interface Window { electron: ElectronNative __OPENCODE__?: { diff --git a/packages/desktop/src/renderer/onboarding/index.tsx b/packages/desktop/src/renderer/onboarding/index.tsx index 869a4d61897a..100d98d21012 100644 --- a/packages/desktop/src/renderer/onboarding/index.tsx +++ b/packages/desktop/src/renderer/onboarding/index.tsx @@ -1,31 +1,38 @@ -import { ServerConnection, useServers, useTabs } from "@opencode/app/desktop" -import { onMount } from "solid-js" +import { ServerConnection, useCurrentRoute, useGlobal, useServers, useTabs } from "@opencode/app/desktop" +import { createResource } from "solid-js" import type { ElectronAPI } from "../api-types" export function DesktopFirstLaunchOnboarding(props: { api: ElectronAPI serverKey: ServerConnection.Key initialUrl: string + pending: boolean + onReady: () => void }) { const server = useServers() + const global = useGlobal() const tabs = useTabs() + const route = useCurrentRoute() - onMount(() => { - void runFirstLaunchOnboarding() + const [completed] = createResource(async () => { + await runFirstLaunchOnboarding() + return null }) async function runFirstLaunchOnboarding() { try { - const pending = await props.api.isFirstLaunchOnboardingPending() - if (!pending) return + if (!props.pending) return await Promise.all([tabs.ready.promise, tabs.recentReady.promise].map((p) => p ?? Promise.resolve())) const shouldTrigger = - props.initialUrl === "/" && tabs.store.length === 0 && server.list.every(ServerConnection.builtin) + props.initialUrl === "/" && + route().type === "home" && + tabs.store.length === 0 && + server.list.every(ServerConnection.builtin) console.info("[desktop-onboarding] first launch onboarding evaluated", { - pending, + pending: props.pending, shouldTrigger, initialUrl: props.initialUrl, tabs: tabs.store.length, @@ -39,11 +46,18 @@ export function DesktopFirstLaunchOnboarding(props: { const projects = server.projects.forServer(props.serverKey) projects.open(directory) projects.touch(directory) + const connection = server.list.find((connection) => ServerConnection.key(connection) === props.serverKey) + if (connection) { + const data = global.ensureServerCtx(connection).data + // Load the initial provider/model state before the draft transition exposes the composer. + await Promise.all([data.location.provider.sync({ directory }), data.location.model.sync({ directory })]) + } tabs.select(await tabs.newDraft({ server: props.serverKey, directory })) - } catch (error) { - console.error("[desktop-onboarding] first launch onboarding failed", error) + } finally { + props.onReady() } } - return null + // Let startup failures reach the app's recovery screen, including its splash boundary. + return <>{completed()} } diff --git a/packages/desktop/src/renderer/startup/splash.css b/packages/desktop/src/renderer/startup/splash.css new file mode 100644 index 000000000000..a0f75f1ce9e4 --- /dev/null +++ b/packages/desktop/src/renderer/startup/splash.css @@ -0,0 +1,25 @@ +[data-component="first-launch-splash"] path { + /* Outline paths use a normalized length so the drawing scales with the wordmark. */ + stroke-dasharray: 1; + animation: first-launch-wordmark-draw 1000ms ease-out both; +} + +[data-component="first-launch-splash"][data-drawing="pending"] path { + animation-play-state: paused; +} + +@keyframes first-launch-wordmark-draw { + from { + stroke-dashoffset: 1; + } + to { + stroke-dashoffset: 0; + } +} + +@media (prefers-reduced-motion: reduce) { + [data-component="first-launch-splash"] path { + animation: none; + stroke-dasharray: none; + } +} diff --git a/packages/desktop/src/renderer/startup/splash.tsx b/packages/desktop/src/renderer/startup/splash.tsx index 49911de2b522..5bfde5aa9348 100644 --- a/packages/desktop/src/renderer/startup/splash.tsx +++ b/packages/desktop/src/renderer/startup/splash.tsx @@ -1,15 +1,87 @@ import { Splash } from "@opencode/ui/logo" +import { Wordmark } from "@opencode/ui/wordmark" +import type { Platform } from "@opencode/app/desktop" +import { onCleanup, onMount, Show } from "solid-js" +import { createStore } from "solid-js/store" +import "./splash.css" + +export function LoadingSplash(props: { + deep: boolean + firstLaunch: boolean + platform: Platform + preview: boolean + onDrawEnd: () => void +}) { + const [drawing, setDrawing] = createStore({ started: false }) + onMount(() => { + if (!props.preview || !props.firstLaunch) return + const motion = window.matchMedia("(prefers-reduced-motion: reduce)") + const start = () => { + if (document.visibilityState !== "visible" || !document.hasFocus()) return + setDrawing("started", true) + if (motion.matches) props.onDrawEnd() + } + window.addEventListener("focus", start) + document.addEventListener("visibilitychange", start) + motion.addEventListener("change", start) + start() + onCleanup(() => { + window.removeEventListener("focus", start) + document.removeEventListener("visibilitychange", start) + motion.removeEventListener("change", start) + }) + }) + + const titlebarHeight = () => { + const zoom = props.platform.webviewZoom?.() ?? 1 + if (props.platform.os === "macos") return `max(36px, ${36 / zoom}px)` + if (props.platform.os === "windows") + return `max(36px, env(titlebar-area-height, ${44 / Math.min(Math.max(zoom, 0.25), 1)}px))` + return "36px" + } -export function LoadingSplash(props: { deep: boolean }) { return ( -
+ +
+ } > - - + + ) } diff --git a/packages/ui/src/typography/wordmark/wordmark.tsx b/packages/ui/src/typography/wordmark/wordmark.tsx index 55dbc0f055a4..be4c98242ce8 100644 --- a/packages/ui/src/typography/wordmark/wordmark.tsx +++ b/packages/ui/src/typography/wordmark/wordmark.tsx @@ -1,6 +1,8 @@ import { createUniqueId, type ComponentProps } from "solid-js" -export function Wordmark(props: Pick, "class"> & { fade?: boolean; muted?: boolean }) { +export function Wordmark( + props: Pick, "class"> & { fade?: boolean; muted?: boolean; outline?: boolean }, +) { const mask = createUniqueId() const maskGradient = createUniqueId() @@ -9,19 +11,51 @@ export function Wordmark(props: Pick, "class"> & { fade?: xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 129" fill="none" - classList={{ [props.class ?? ""]: !!props.class }} + classList={{ + [props.class ?? ""]: !!props.class, + "overflow-visible [&_path]:[vector-effect:non-scaling-stroke]": props.outline, + }} > - - - - - - - - - + + + + + + + + +