feat(desktop): add animated first-launch loading screen (#49408)

This commit is contained in:
David Hill
2026-09-16 18:02:09 -06:00
committed by GitHub
parent acfacede2d
commit f6604cd367
11 changed files with 279 additions and 79 deletions
+1
View File
@@ -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"
@@ -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)
+3
View File
@@ -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",
@@ -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 },
)
@@ -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 = () => {
+61 -46
View File
@@ -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 (
<>
<DesktopWindow
api={props.api}
updater={props.updater}
windowState={windowState}
onReady={() => setStartup("ready", true)}
onRoute={(route) => setStartup("route", route)}
/>
<Show when={startup.visible}>
<div
class="fixed inset-0 z-[100] transition-opacity duration-300 ease-out"
classList={{ "pointer-events-none opacity-0": startup.ready }}
onTransitionEnd={(event) => {
if (event.target !== event.currentTarget || !startup.ready) return
setStartup("visible", false)
}}
>
<LoadingSplash deep={startup.route.type === "draft"} />
</div>
</Show>
</>
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) => (
<DesktopMemoryRouter {...routerProps} windowID={props.windowState.id} />
const [initialRoute] = createResource(
() => !firstLaunch.loading && (firstLaunch() && initialUrl === "/" ? "/new-session" : initialUrl),
preloadRoute,
)
const router = (routerProps: BaseRouterProps) => <DesktopMemoryRouter {...routerProps} windowID={windowState.id} />
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) => (
<AppInterface defaultServer={key} servers={servers()} router={router}>
<DesktopStartupReady
routeReady={() => !initialRoute.loading}
onReady={props.onReady}
onRoute={props.onRoute}
routeReady={!initialRoute.loading && startup.onboardingReady}
onReady={() => setStartup("ready", true)}
onRoute={(route) => setStartup("route", route)}
/>
<DesktopFirstLaunchOnboarding
api={props.api}
initialUrl={getLastActiveUrl(props.windowState.id)}
initialUrl={initialUrl}
serverKey={key}
pending={firstLaunch() ?? false}
onReady={() => setStartup("onboardingReady", true)}
/>
<DesktopEffects api={props.api} />
<Suspense fallback={null}>
@@ -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)
}}
>
<Show when={true}>{(_) => <ReadyApp />}</Show>
<Show when={!firstLaunch.loading && startup.visible}>
<div
data-component="startup-overlay"
class="fixed inset-0 z-[100] transition-opacity duration-300 ease-out"
classList={{ "pointer-events-none opacity-0": readyToReveal() }}
onTransitionEnd={(event) => {
if (event.target !== event.currentTarget || !readyToReveal()) return
setStartup("visible", false)
}}
>
<LoadingSplash
firstLaunch={!!firstLaunch() && initialUrl === "/"}
deep={startup.route.type === "draft"}
platform={platform}
preview={import.meta.env.OPENCODE_TEST_ONBOARDING}
onDrawEnd={() => setStartup("drawingReady", true)}
/>
</div>
</Show>
</AppBaseProviders>
</PlatformProvider>
)
}
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
+4
View File
@@ -1,6 +1,10 @@
import type { ElectronNative } from "../preload/types"
declare global {
interface ImportMetaEnv {
readonly OPENCODE_TEST_ONBOARDING: boolean
}
interface Window {
electron: ElectronNative
__OPENCODE__?: {
@@ -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()}</>
}
@@ -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;
}
}
@@ -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 (
<div
class="h-dvh w-screen flex flex-col items-center justify-center"
classList={{
"bg-v2-background-bg-deep": props.deep,
"bg-v2-background-bg-base": !props.deep,
}}
<Show
when={props.firstLaunch}
fallback={
<div
data-component="startup-splash"
class="h-dvh w-screen flex flex-col items-center justify-center"
classList={{
"bg-v2-background-bg-deep": props.deep,
"bg-v2-background-bg-base": !props.deep,
}}
>
<Splash class="w-16 h-20 opacity-50 animate-pulse" />
</div>
}
>
<Splash class="w-16 h-20 opacity-50 animate-pulse" />
</div>
<div
data-component="first-launch-splash"
data-drawing={props.preview && !drawing.started ? "pending" : "running"}
aria-hidden="true"
class="h-dvh w-screen bg-v2-background-bg-base"
style={{
// Match the default session's titlebar, panel insets, and wordmark position.
"padding-block-start": `calc(${titlebarHeight()} + ${props.platform.os === "windows" ? 1 : 8}px)`,
"padding-block-end": "8px",
"padding-inline": "32px",
}}
onAnimationEnd={(event) => {
if (event.animationName === "first-launch-wordmark-draw") props.onDrawEnd()
}}
>
<div class="relative size-full">
<div class="absolute inset-x-0 top-[25.375%]">
<Wordmark
outline
fade={false}
muted={false}
class="mx-auto block h-auto w-full max-w-[720px] text-v2-icon-icon-faint opacity-50"
/>
</div>
</div>
</div>
</Show>
)
}
@@ -1,6 +1,8 @@
import { createUniqueId, type ComponentProps } from "solid-js"
export function Wordmark(props: Pick<ComponentProps<"svg">, "class"> & { fade?: boolean; muted?: boolean }) {
export function Wordmark(
props: Pick<ComponentProps<"svg">, "class"> & { fade?: boolean; muted?: boolean; outline?: boolean },
) {
const mask = createUniqueId()
const maskGradient = createUniqueId()
@@ -9,19 +11,51 @@ export function Wordmark(props: Pick<ComponentProps<"svg">, "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,
}}
>
<g opacity={props.muted === false ? 1 : 0.6} class="[[data-color-scheme=dark]_&]:opacity-100">
<g mask={props.fade === false ? undefined : `url(#${mask})`}>
<g opacity={props.muted === false ? 1 : 0.16 * 0.7} fill="currentColor">
<path d="M55.3846 36.4286H18.4615V91.7143H55.3846V36.4286ZM73.8462 110.143H0V18H73.8462V110.143Z" />
<path d="M110.462 91.7143H147.385V36.4286H110.462V91.7143ZM165.846 110.143H110.462V128.571H92V18H165.846V110.143Z" />
<path d="M258.846 73.2857H203.462V91.7143H258.846V110.143H185V18H258.846V73.2857ZM203.462 54.8571H240.385V36.4286H203.462V54.8571Z" />
<path d="M332.385 36.4286H295.462V110.143H277V18H332.385V36.4286ZM350.846 110.143H332.385V36.4286H350.846V110.143Z" />
<path d="M442.846 36.4286H387.462V91.7143H442.846V110.143H369V18H442.846V36.4286Z" />
<path d="M517.385 36.4286H480.462V91.7143H517.385V36.4286ZM535.846 110.143H462V18H535.846V110.143Z" />
<path d="M609.385 36.8571H572.462V92.1429H609.385V36.8571ZM627.846 110.571H554V18.4286H609.385V0H627.846V110.571Z" />
<path d="M664.462 36.4286V54.8571H701.385V36.4286H664.462ZM719.846 73.2857H664.462V91.7143H719.846V110.143H646V18H719.846V73.2857Z" />
<g
opacity={props.muted === false ? 1 : 0.16 * 0.7}
fill={props.outline ? "none" : "currentColor"}
stroke={props.outline ? "currentColor" : undefined}
stroke-width={props.outline ? 1 : undefined}
>
<path
pathLength={props.outline ? 1 : undefined}
d="M55.3846 36.4286H18.4615V91.7143H55.3846V36.4286ZM73.8462 110.143H0V18H73.8462V110.143Z"
/>
<path
pathLength={props.outline ? 1 : undefined}
d="M110.462 91.7143H147.385V36.4286H110.462V91.7143ZM165.846 110.143H110.462V128.571H92V18H165.846V110.143Z"
/>
<path
pathLength={props.outline ? 1 : undefined}
d="M258.846 73.2857H203.462V91.7143H258.846V110.143H185V18H258.846V73.2857ZM203.462 54.8571H240.385V36.4286H203.462V54.8571Z"
/>
<path
pathLength={props.outline ? 1 : undefined}
d="M332.385 36.4286H295.462V110.143H277V18H332.385V36.4286ZM350.846 110.143H332.385V36.4286H350.846V110.143Z"
/>
<path
pathLength={props.outline ? 1 : undefined}
d="M442.846 36.4286H387.462V91.7143H442.846V110.143H369V18H442.846V36.4286Z"
/>
<path
pathLength={props.outline ? 1 : undefined}
d="M517.385 36.4286H480.462V91.7143H517.385V36.4286ZM535.846 110.143H462V18H535.846V110.143Z"
/>
<path
pathLength={props.outline ? 1 : undefined}
d="M609.385 36.8571H572.462V92.1429H609.385V36.8571ZM627.846 110.571H554V18.4286H609.385V0H627.846V110.571Z"
/>
<path
pathLength={props.outline ? 1 : undefined}
d="M664.462 36.4286V54.8571H701.385V36.4286H664.462ZM719.846 73.2857H664.462V91.7143H719.846V110.143H646V18H719.846V73.2857Z"
/>
</g>
</g>
</g>