Ver Fonte

fix(desktop): derive layout eligibility from install state

Brendan Allan há 1 mês atrás
pai
commit
9ecfb847fa

+ 19 - 149
packages/app/src/app.tsx

@@ -28,7 +28,6 @@ import {
   type ParentProps,
   Show,
 } from "solid-js"
-import { createStore } from "solid-js/store"
 import { Dynamic } from "solid-js/web"
 import { CommandProvider, useCommand, type CommandOption } from "@/context/command"
 import { CommentsProvider } from "@/context/comments"
@@ -45,7 +44,7 @@ import { PermissionProvider } from "@/context/permission"
 import { usePlatform } from "@/context/platform"
 import { PromptProvider } from "@/context/prompt"
 import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
-import { hasMeaningfulLayoutData, SettingsProvider, useSettings } from "@/context/settings"
+import { SettingsProvider, useSettings } from "@/context/settings"
 import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs"
 import { SDKProvider, useSDK } from "@/context/sdk"
 import { WslServersProvider } from "@/wsl/context"
@@ -270,133 +269,6 @@ function BodyDesignClass() {
   return null
 }
 
-function layoutClassificationRequest<T>(promise: Promise<T>, onTimeout: () => void, timeoutMs = 10_000) {
-  return new Promise<T>((resolve, reject) => {
-    const timeout = setTimeout(() => {
-      onTimeout()
-      reject(new Error("Layout classification timed out"))
-    }, timeoutMs)
-    promise.then(
-      (value) => {
-        clearTimeout(timeout)
-        resolve(value)
-      },
-      (error) => {
-        clearTimeout(timeout)
-        reject(error)
-      },
-    )
-  })
-}
-
-function LayoutTransitionGate(props: ParentProps) {
-  const settings = useSettings()
-  const server = useServer()
-  const global = useGlobal()
-  const platform = usePlatform()
-  const [state, setState] = createStore({ started: false, retry: 0 })
-  const retry = { current: undefined as ReturnType<typeof setTimeout> | undefined }
-  const wslState = {
-    current: undefined as ReturnType<NonNullable<typeof platform.wslServers>["getState"]> | undefined,
-  }
-
-  const readWslState = () => {
-    if (!platform.wslServers) return Promise.resolve(undefined)
-    if (wslState.current) return wslState.current
-    const request = platform.wslServers.getState()
-    wslState.current = request
-    void request.catch(() => {
-      if (wslState.current === request) wslState.current = undefined
-    })
-    return request
-  }
-
-  const scheduleRetry = () => {
-    if (retry.current !== undefined) return
-    retry.current = setTimeout(() => {
-      retry.current = undefined
-      setState({ started: false, retry: state.retry + 1 })
-    }, 10_000)
-  }
-
-  onCleanup(() => {
-    if (retry.current !== undefined) clearTimeout(retry.current)
-  })
-
-  createEffect(() => {
-    void state.retry
-    if (state.started || settings.general.layoutTransitionClassified()) return
-    if (!settings.ready() || !server.ready()) return
-
-    const input = {
-      settings: settings.general.layoutTransitionSettingsPresent(),
-      server: server.hasPersistedData(),
-      wsl: false,
-      projects: false,
-      sessions: false,
-    }
-    if (hasMeaningfulLayoutData(input)) {
-      setState("started", true)
-      settings.general.classifyLayoutTransition(true)
-      return
-    }
-    const conn = server.current
-    if (!conn) return
-    setState("started", true)
-    const client = global.ensureServerCtx(conn).sdk.client
-    const abort = new AbortController()
-    const pendingWsl = readWslState()
-    void pendingWsl.then(
-      (wsl) => {
-        if ((wsl?.servers.length ?? 0) > 0) settings.general.classifyLayoutTransition(true)
-      },
-      () => undefined,
-    )
-    const wsl = layoutClassificationRequest(pendingWsl, () => {}, 5_000).then(
-      (value) => ({ known: true as const, value }),
-      () => ({ known: false as const, value: undefined }),
-    )
-    void layoutClassificationRequest(
-      Promise.all([
-        client.project.list(undefined, { signal: abort.signal, throwOnError: true }),
-        client.session.list({ limit: 1 }, { signal: abort.signal, throwOnError: true }),
-        wsl,
-      ]),
-      () => abort.abort(),
-    )
-      .then(([projects, sessions, wsl]) => {
-        const existing = hasMeaningfulLayoutData({
-          ...input,
-          wsl: (wsl.value?.servers.length ?? 0) > 0,
-          projects: (projects.data?.length ?? 0) > 0,
-          sessions: (sessions.data?.length ?? 0) > 0,
-        })
-        if (!existing && !wsl.known) {
-          scheduleRetry()
-          return
-        }
-        settings.general.classifyLayoutTransition(existing)
-      })
-      .catch((error) => {
-        console.error("[layout-transition] failed to classify local data", error)
-        scheduleRetry()
-      })
-  })
-
-  return (
-    <Show
-      when={settings.general.layoutTransitionClassified()}
-      fallback={
-        <div class="fixed inset-0 z-[9999] flex flex-col items-center justify-center bg-background-base">
-          <Splash class="w-16 h-20 opacity-50 animate-pulse" />
-        </div>
-      }
-    >
-      {props.children}
-    </Show>
-  )
-}
-
 // Server-agnostic providers shared across every route. These live in the shared
 // shell (router root) so they stay mounted regardless of the active server/route.
 function SharedProviders(props: ParentProps) {
@@ -682,26 +554,24 @@ export function AppInterface(props: {
       <GlobalProvider>
         <SettingsProvider>
           <ConnectionGate disableHealthCheck={props.disableHealthCheck} startup={props.startup}>
-            <LayoutTransitionGate>
-              <Show when={useSettings().general.newLayoutDesigns().toString()} keyed>
-                <Dynamic
-                  component={props.router ?? Router}
-                  root={(routerProps) => (
-                    <TabsProvider>
-                      <NotificationProvider>
-                        <ServerShell>
-                          <Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}>
-                            <NewAppLayout serverScoped={props.serverScoped}>{routerProps.children}</NewAppLayout>
-                          </Show>
-                        </ServerShell>
-                      </NotificationProvider>
-                    </TabsProvider>
-                  )}
-                >
-                  <Routes serverScoped={props.serverScoped} />
-                </Dynamic>
-              </Show>
-            </LayoutTransitionGate>
+            <Show when={useSettings().general.newLayoutDesigns().toString()} keyed>
+              <Dynamic
+                component={props.router ?? Router}
+                root={(routerProps) => (
+                  <TabsProvider>
+                    <NotificationProvider>
+                      <ServerShell>
+                        <Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}>
+                          <NewAppLayout serverScoped={props.serverScoped}>{routerProps.children}</NewAppLayout>
+                        </Show>
+                      </ServerShell>
+                    </NotificationProvider>
+                  </TabsProvider>
+                )}
+              >
+                <Routes serverScoped={props.serverScoped} />
+              </Dynamic>
+            </Show>
           </ConnectionGate>
         </SettingsProvider>
       </GlobalProvider>

+ 0 - 18
packages/app/src/context/server.test.ts

@@ -3,7 +3,6 @@ import { createRoot, createSignal } from "solid-js"
 import { createStore } from "solid-js/store"
 import {
   createServerProjects,
-  hasPersistedServerData,
   migrateCanonicalLocalServerState,
   nextServerAfterRemoval,
   resolveServerList,
@@ -62,23 +61,6 @@ describe("resolveServerList", () => {
   })
 })
 
-describe("hasPersistedServerData", () => {
-  const empty = () => ({ list: [], projects: {}, lastProject: {}, recentlyClosed: {} })
-
-  test("ignores a blank persisted server store", () => {
-    expect(hasPersistedServerData(empty())).toBe(false)
-  })
-
-  test("recognizes configured servers and remembered project activity", () => {
-    expect(hasPersistedServerData({ ...empty(), list: ["https://example.com"] })).toBe(true)
-    expect(hasPersistedServerData({ ...empty(), projects: { local: [{ worktree: "/code", expanded: true }] } })).toBe(
-      true,
-    )
-    expect(hasPersistedServerData({ ...empty(), lastProject: { local: "/code" } })).toBe(true)
-    expect(hasPersistedServerData({ ...empty(), recentlyClosed: { local: ["/code"] } })).toBe(true)
-  })
-})
-
 test("treats WSL sidecars as remote server connections", () => {
   expect(
     ServerConnection.local({

+ 0 - 10
packages/app/src/context/server.tsx

@@ -12,7 +12,6 @@ type ServerProjectState = {
   lastProject: Record<string, string>
   recentlyClosed: Record<string, string[]>
 }
-type PersistedServerState = ServerProjectState & { list: StoredServer[] }
 const HEALTH_POLL_INTERVAL_MS = 10_000
 // The store retains more history than is displayed. Consumers filter recently closed entries
 // against the live project list (dropping deleted projects) and then cap the visible count via
@@ -179,13 +178,6 @@ export function resolveServerList(input: {
   return [...deduped.values()]
 }
 
-export function hasPersistedServerData(store: PersistedServerState) {
-  if (store.list.length > 0) return true
-  if (Object.values(store.projects).some((projects) => projects.length > 0)) return true
-  if (Object.values(store.lastProject).some(Boolean)) return true
-  return Object.values(store.recentlyClosed).some((projects) => projects.length > 0)
-}
-
 export namespace ServerConnection {
   type Base = { displayName?: string; label?: string }
 
@@ -339,12 +331,10 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
       () => allServers().find((s) => ServerConnection.key(s) === state.active) ?? allServers()[0],
     )
     const isLocal = createMemo(() => ServerConnection.local(current()))
-    const hasPersistedData = createMemo(() => hasPersistedServerData(store))
 
     return {
       ready: isReady,
       isLocal,
-      hasPersistedData,
       get key() {
         return state.active
       },

+ 0 - 29
packages/app/src/context/settings.test.ts

@@ -1,36 +1,15 @@
 import { describe, expect, test } from "bun:test"
 import {
-  hasMeaningfulLayoutData,
   layoutTransitionState,
   maximumSunsetTimeout,
-  migrateSettings,
   newLayoutDesignsDefault,
   nextSunsetCheckDelay,
-  resolveLayoutTransitionClassification,
   resolveNewLayoutDesigns,
 } from "./settings"
 
 describe("layout transition", () => {
   test("blank profiles default to the new layout", () => {
     expect(newLayoutDesignsDefault).toBe(true)
-    expect(
-      hasMeaningfulLayoutData({ settings: false, server: false, wsl: false, projects: false, sessions: false }),
-    ).toBe(false)
-  })
-
-  test("recognizes each source of meaningful prior use", () => {
-    const blank = { settings: false, server: false, wsl: false, projects: false, sessions: false }
-    expect(hasMeaningfulLayoutData({ ...blank, settings: true })).toBe(true)
-    expect(hasMeaningfulLayoutData({ ...blank, server: true })).toBe(true)
-    expect(hasMeaningfulLayoutData({ ...blank, wsl: true })).toBe(true)
-    expect(hasMeaningfulLayoutData({ ...blank, projects: true })).toBe(true)
-    expect(hasMeaningfulLayoutData({ ...blank, sessions: true })).toBe(true)
-  })
-
-  test("allows late evidence to promote but never downgrade a cohort", () => {
-    expect(resolveLayoutTransitionClassification(undefined, false)).toBe(false)
-    expect(resolveLayoutTransitionClassification(false, true)).toBe(true)
-    expect(resolveLayoutTransitionClassification(true, false)).toBe(true)
   })
 
   test("hides the transition until a sunset is scheduled", () => {
@@ -38,9 +17,6 @@ describe("layout transition", () => {
   })
 
   test("existing profiles can switch before sunset", () => {
-    expect(migrateSettings({ general: { newLayoutDesigns: false } })).toEqual({
-      general: { newLayoutDesigns: false, layoutTransitionSettingsPresent: true },
-    })
     expect(layoutTransitionState(true, true, false, false)).toEqual({ available: true, notice: false })
   })
 
@@ -61,9 +37,4 @@ describe("layout transition", () => {
     expect(nextSunsetCheckDelay(10_000, 9_000)).toBe(1_000)
     expect(nextSunsetCheckDelay(9_000, 10_000)).toBe(0)
   })
-
-  test("migration does not reclassify fresh profiles", () => {
-    const settings = { general: { newLayoutDesigns: true, layoutTransitionEligible: false } }
-    expect(migrateSettings(settings)).toBe(settings)
-  })
 })

+ 4 - 50
packages/app/src/context/settings.tsx

@@ -35,7 +35,6 @@ export interface Settings {
     mobileTitlebarPosition: "top" | "bottom"
     newLayoutDesigns?: boolean
     layoutTransitionEligible?: boolean
-    layoutTransitionSettingsPresent?: boolean
     newInterfaceNoticeDismissed?: boolean
   }
   appearance: {
@@ -60,27 +59,6 @@ export const newLayoutDesignsDefault = true
 // Existing users can switch layouts until local midnight on this date. Set new Date(YYYY, M-1, D) to show.
 export const oldInterfaceSunset = null as Date | null
 
-function isRecord(value: unknown): value is Record<string, unknown> {
-  return typeof value === "object" && value !== null && !Array.isArray(value)
-}
-
-export function migrateSettings(value: unknown) {
-  if (!isRecord(value)) return value
-  const general = isRecord(value.general) ? value.general : {}
-  if (
-    typeof general.layoutTransitionEligible === "boolean" ||
-    typeof general.layoutTransitionSettingsPresent === "boolean"
-  )
-    return value
-  return {
-    ...value,
-    general: {
-      ...general,
-      layoutTransitionSettingsPresent: true,
-    },
-  }
-}
-
 export function layoutTransitionState(scheduled: boolean, eligible: boolean, retired: boolean, dismissed: boolean) {
   return {
     available: scheduled && eligible && !retired,
@@ -99,21 +77,6 @@ export function resolveNewLayoutDesigns(retired: boolean, preference: boolean |
   return preference ?? fallback
 }
 
-export function hasMeaningfulLayoutData(input: {
-  settings: boolean
-  server: boolean
-  wsl: boolean
-  projects: boolean
-  sessions: boolean
-}) {
-  return input.settings || input.server || input.wsl || input.projects || input.sessions
-}
-
-export function resolveLayoutTransitionClassification(current: boolean | undefined, existing: boolean) {
-  if (current === true || existing) return true
-  return false
-}
-
 const monoFallback =
   'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
 const sansFallback = 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
@@ -212,10 +175,7 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
   name: "Settings",
   gate: false,
   init: () => {
-    const [store, setStore, _, ready] = persisted(
-      { key: "settings.v3", migrate: migrateSettings },
-      createStore<Settings>(defaultSettings),
-    )
+    const [store, setStore, _, ready] = persisted("settings.v3", createStore<Settings>(defaultSettings))
     const showFileTree = withFallback(() => store.general?.showFileTree, defaultSettings.general.showFileTree)
     const showSearch = withFallback(() => store.general?.showSearch, defaultSettings.general.showSearch)
     const showStatus = withFallback(() => store.general?.showStatus, defaultSettings.general.showStatus)
@@ -229,10 +189,6 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
       () => typeof store.general?.layoutTransitionEligible === "boolean",
     )
     const layoutTransitionEligible = withFallback(() => store.general?.layoutTransitionEligible, false)
-    const layoutTransitionSettingsPresent = withFallback(
-      () => store.general?.layoutTransitionSettingsPresent,
-      false,
-    )
     const newInterfaceNoticeDismissed = withFallback(() => store.general?.newInterfaceNoticeDismissed, false)
     const layoutTransition = createMemo(() =>
       layoutTransitionState(!!sunset, layoutTransitionEligible(), oldInterfaceRetired(), newInterfaceNoticeDismissed()),
@@ -361,12 +317,10 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
           setStore("general", "newLayoutDesigns", oldInterfaceRetired() ? true : value)
         },
         layoutTransitionClassified,
-        layoutTransitionSettingsPresent,
-        classifyLayoutTransition(existing: boolean) {
+        setOldLayoutEligible(eligible: boolean) {
           const current = store.general?.layoutTransitionEligible
-          const next = resolveLayoutTransitionClassification(current, existing)
-          if (current === next) return
-          setStore("general", "layoutTransitionEligible", next)
+          if (typeof current === "boolean") return
+          setStore("general", "layoutTransitionEligible", eligible)
         },
         layoutTransitionAvailable: createMemo(() => ready() && layoutTransition().available),
         newInterfaceNoticeVisible: createMemo(() => ready() && layoutTransition().notice),

+ 1 - 0
packages/app/src/index.ts

@@ -3,6 +3,7 @@ export { useLayout } from "./context/layout"
 export { useServerSDK } from "./context/server-sdk"
 export { useServerSync } from "./context/server-sync"
 export { useServer } from "./context/server"
+export { useSettings } from "./context/settings"
 export { useTabs } from "./context/tabs"
 export { useProviders } from "./hooks/use-providers"
 export { ACCEPTED_FILE_EXTENSIONS, ACCEPTED_FILE_TYPES, filePickerFilters } from "./constants/file-picker"

+ 8 - 1
packages/desktop/src/main/index.ts

@@ -19,7 +19,12 @@ import { forwardInitializationFailure } from "./initialization"
 import { exportDebugLogs, initCrashReporter, initLogging, startNetLog, write as writeLog } from "./logging"
 import { parseMarkdown } from "./markdown"
 import { createMenu } from "./menu"
-import { finishFirstLaunchOnboarding, isFirstLaunchOnboardingPending } from "./onboarding"
+import {
+  finishFirstLaunchOnboarding,
+  initializeOldLayoutEligibility,
+  isFirstLaunchOnboardingPending,
+  isOldLayoutEligible,
+} from "./onboarding"
 import {
   getDefaultServerUrl,
   preferAppEnv,
@@ -137,6 +142,7 @@ const main = Effect.gen(function* () {
     onboardingTestRoot ? join(onboardingTestRoot, "desktop") : join(app.getPath("appData"), appId),
   )
   if (onboardingTestRoot) app.setPath("sessionData", join(onboardingTestRoot, "session"))
+  initializeOldLayoutEligibility(app.getPath("userData"))
   logger = initLogging()
   initCrashReporter()
 
@@ -280,6 +286,7 @@ const main = Effect.gen(function* () {
     setDefaultServerUrl: (url) => setDefaultServerUrl(url),
     isFirstLaunchOnboardingPending,
     finishFirstLaunchOnboarding,
+    isOldLayoutEligible,
     getDisplayBackend: async () => null,
     setDisplayBackend: async () => undefined,
     parseMarkdown: async (markdown) => parseMarkdown(markdown),

+ 19 - 0
packages/desktop/src/main/install-state.test.ts

@@ -0,0 +1,19 @@
+import { describe, expect, test } from "bun:test"
+import { hasExistingAppState } from "./install-state"
+
+const file = (name: string) => ({ name, isDirectory: () => false })
+const directory = (name: string) => ({ name, isDirectory: () => true })
+
+describe("hasExistingAppState", () => {
+  test("ignores files Electron may create on a fresh install", () => {
+    expect(hasExistingAppState([])).toBe(false)
+    expect(hasExistingAppState([file("Local State"), directory("Crashpad")])).toBe(false)
+  })
+
+  test("recognizes state written by an earlier OpenCode launch", () => {
+    expect(hasExistingAppState([file("opencode.settings")])).toBe(true)
+    expect(hasExistingAppState([file("opencode.global.dat")])).toBe(true)
+    expect(hasExistingAppState([file("window-state-abc.json")])).toBe(true)
+    expect(hasExistingAppState([directory("opencode")])).toBe(true)
+  })
+})

+ 8 - 0
packages/desktop/src/main/install-state.ts

@@ -0,0 +1,8 @@
+export function hasExistingAppState(entries: Array<{ name: string; isDirectory: () => boolean }>) {
+  return entries.some((entry) => {
+    if (entry.name === "opencode.settings") return true
+    if (entry.name.endsWith(".dat")) return true
+    if (/^window-state-.+\.json$/.test(entry.name)) return true
+    return entry.isDirectory() && entry.name === "opencode"
+  })
+}

+ 2 - 0
packages/desktop/src/main/ipc.ts

@@ -29,6 +29,7 @@ type Deps = {
   setDefaultServerUrl: (url: string | null) => Promise<void> | void
   isFirstLaunchOnboardingPending: () => Promise<boolean> | boolean
   finishFirstLaunchOnboarding: (createDefaultProject: boolean) => Promise<string | null> | string | null
+  isOldLayoutEligible: () => Promise<boolean> | boolean
   getDisplayBackend: () => Promise<string | null>
   setDisplayBackend: (backend: string | null) => Promise<void> | void
   parseMarkdown: (markdown: string) => Promise<string> | string
@@ -56,6 +57,7 @@ export function registerIpcHandlers(deps: Deps) {
   ipcMain.handle("finish-first-launch-onboarding", (_event: IpcMainInvokeEvent, createDefaultProject: boolean) =>
     deps.finishFirstLaunchOnboarding(createDefaultProject),
   )
+  ipcMain.handle("is-old-layout-eligible", () => deps.isOldLayoutEligible())
   ipcMain.handle("get-display-backend", () => deps.getDisplayBackend())
   ipcMain.handle("set-display-backend", (_event: IpcMainInvokeEvent, backend: string | null) =>
     deps.setDisplayBackend(backend),

+ 18 - 1
packages/desktop/src/main/onboarding.ts

@@ -1,12 +1,29 @@
+import { existsSync, readdirSync } from "node:fs"
 import { mkdir } from "node:fs/promises"
 import { join } from "node:path"
 import { app } from "electron"
 import { getStore } from "./store"
-import { FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY } from "./store-keys"
+import { FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY, OLD_LAYOUT_ELIGIBLE_KEY } from "./store-keys"
 import { write as writeLog } from "./logging"
+import { hasExistingAppState } from "./install-state"
 
 const DEFAULT_PROJECT_DIR = "New OpenCode Project"
 
+export function initializeOldLayoutEligibility(userDataPath: string) {
+  const entries = existsSync(userDataPath) ? readdirSync(userDataPath, { withFileTypes: true }) : []
+  const store = getStore()
+  const current = store.get(OLD_LAYOUT_ELIGIBLE_KEY)
+  if (typeof current === "boolean") return current
+
+  const eligible = hasExistingAppState(entries)
+  store.set(OLD_LAYOUT_ELIGIBLE_KEY, eligible)
+  return eligible
+}
+
+export function isOldLayoutEligible() {
+  return getStore().get(OLD_LAYOUT_ELIGIBLE_KEY) === true
+}
+
 export function isFirstLaunchOnboardingPending() {
   const pending = getStore().get(FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY) !== true
   writeLog("onboarding", "first launch onboarding pending checked", { pending })

+ 1 - 0
packages/desktop/src/main/store-keys.ts

@@ -1,6 +1,7 @@
 export const SETTINGS_STORE = "opencode.settings"
 export const DEFAULT_SERVER_URL_KEY = "defaultServerUrl"
 export const FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY = "firstLaunchOnboardingComplete"
+export const OLD_LAYOUT_ELIGIBLE_KEY = "oldLayoutEligible"
 export const WSL_SERVERS_KEY = "wslServers"
 export const PINCH_ZOOM_ENABLED_KEY = "pinchZoomEnabled"
 export const WINDOW_IDS_KEY = "windowIds"

+ 1 - 0
packages/desktop/src/preload/index.ts

@@ -62,6 +62,7 @@ const api: ElectronAPI = {
   isFirstLaunchOnboardingPending: () => ipcRenderer.invoke("is-first-launch-onboarding-pending"),
   finishFirstLaunchOnboarding: (createDefaultProject) =>
     ipcRenderer.invoke("finish-first-launch-onboarding", createDefaultProject),
+  isOldLayoutEligible: () => ipcRenderer.invoke("is-old-layout-eligible"),
   getDisplayBackend: () => ipcRenderer.invoke("get-display-backend"),
   setDisplayBackend: (backend) => ipcRenderer.invoke("set-display-backend", backend),
   parseMarkdownCommand: (markdown) => ipcRenderer.invoke("parse-markdown", markdown),

+ 1 - 0
packages/desktop/src/preload/types.ts

@@ -52,6 +52,7 @@ export type ElectronAPI = {
   setDefaultServerUrl: (url: string | null) => Promise<void>
   isFirstLaunchOnboardingPending: () => Promise<boolean>
   finishFirstLaunchOnboarding: (createDefaultProject: boolean) => Promise<string | null>
+  isOldLayoutEligible: () => Promise<boolean>
   getDisplayBackend: () => Promise<LinuxDisplayBackend | null>
   setDisplayBackend: (backend: LinuxDisplayBackend | null) => Promise<void>
   parseMarkdownCommand: (markdown: string) => Promise<string>

+ 3 - 0
packages/desktop/src/renderer/onboarding.tsx

@@ -5,6 +5,7 @@ import {
   useServer,
   useServerSDK,
   useServerSync,
+  useSettings,
   useTabs,
 } from "@opencode-ai/app"
 import { onMount, startTransition } from "solid-js"
@@ -13,6 +14,7 @@ export function DesktopFirstLaunchOnboarding(props: { initialUrl: string; onLoad
   const server = useServer()
   const serverSDK = useServerSDK()
   const serverSync = useServerSync()
+  const settings = useSettings()
   const layout = useLayout()
   const providers = useProviders()
   const tabs = useTabs()
@@ -28,6 +30,7 @@ export function DesktopFirstLaunchOnboarding(props: { initialUrl: string; onLoad
           (p) => p ?? Promise.resolve(),
         ),
       )
+      settings.general.setOldLayoutEligible(await window.api.isOldLayoutEligible())
       if (!server.isLocal()) return
 
       const pending = await window.api.isFirstLaunchOnboardingPending()