fix(desktop): derive layout eligibility from install state

This commit is contained in:
Brendan Allan 2026-07-14 14:06:26 +00:00
commit 9ecfb847fa
15 changed files with 85 additions and 258 deletions

View file

@ -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>

View file

@ -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({

View file

@ -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
},

View file

@ -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)
})
})

View file

@ -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),

View file

@ -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"