diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 86263d172d..a22ee59dcf 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -28,6 +28,7 @@ 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" @@ -44,7 +45,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 { SettingsProvider, useSettings } from "@/context/settings" +import { hasMeaningfulLayoutData, SettingsProvider, useSettings } from "@/context/settings" import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs" import { SDKProvider, useSDK } from "@/context/sdk" import { WslServersProvider } from "@/wsl/context" @@ -269,6 +270,122 @@ function BodyDesignClass() { return null } +function layoutClassificationRequest(promise: Promise, onTimeout: () => void, timeoutMs = 10_000) { + return new Promise((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 LayoutTransitionClassifier() { + 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 | undefined } + const wslState = { + current: undefined as ReturnType["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 }), + client.session.list({ limit: 1 }, { signal: abort.signal }), + 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 null +} + // 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) { @@ -554,6 +671,7 @@ export function AppInterface(props: { + { - test("fresh profiles default to the new layout", () => { + test("blank profiles default to the new layout", () => { expect(newLayoutDesignsDefault).toBe(true) - expect(layoutTransitionState(true, false, false, false)).toEqual({ available: false, notice: false }) - expect(layoutTransitionState(true, false, true, false)).toEqual({ available: false, notice: false }) + 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("formats the English deadline with an ordinal before sunset", () => { @@ -26,16 +46,16 @@ describe("layout transition", () => { }) test("existing profiles can switch before sunset", () => { - expect(migrateSettings({ general: { newLayoutDesigns: false } }, true)).toEqual({ - general: { newLayoutDesigns: false, layoutTransitionEligible: true }, + expect(migrateSettings({ general: { newLayoutDesigns: false } })).toEqual({ + general: { newLayoutDesigns: false, layoutTransitionSettingsPresent: true }, }) expect(layoutTransitionState(true, true, false, false)).toEqual({ available: true, notice: false }) }) - test("existing profiles use their legacy default when no preference was saved", () => { - expect(migrateSettings({ general: {} }, false)).toEqual({ - general: { newLayoutDesigns: false, layoutTransitionEligible: true }, - }) + test("preserves explicit and default layout preferences", () => { + expect(resolveNewLayoutDesigns(false, false, true)).toBe(false) + expect(resolveNewLayoutDesigns(false, undefined, false)).toBe(false) + expect(resolveNewLayoutDesigns(false, undefined, true)).toBe(true) }) test("sunset replaces the toggle with a dismissible notice", () => { @@ -44,8 +64,14 @@ describe("layout transition", () => { expect(resolveNewLayoutDesigns(true, false)).toBe(true) }) + test("caps checks for sunsets beyond the browser timeout limit", () => { + expect(nextSunsetCheckDelay(maximumSunsetTimeout + 1_000, 0)).toBe(maximumSunsetTimeout) + 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, false)).toBe(settings) + expect(migrateSettings(settings)).toBe(settings) }) }) diff --git a/packages/app/src/context/settings.tsx b/packages/app/src/context/settings.tsx index b40e442ac6..511dd9320d 100644 --- a/packages/app/src/context/settings.tsx +++ b/packages/app/src/context/settings.tsx @@ -35,6 +35,7 @@ export interface Settings { mobileTitlebarPosition: "top" | "bottom" newLayoutDesigns?: boolean layoutTransitionEligible?: boolean + layoutTransitionSettingsPresent?: boolean newInterfaceNoticeDismissed?: boolean } appearance: { @@ -78,17 +79,19 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value) } -export function migrateSettings(value: unknown, legacyDefault = legacyNewLayoutDesignsDefault) { +export function migrateSettings(value: unknown) { if (!isRecord(value)) return value const general = isRecord(value.general) ? value.general : {} - if (typeof general.layoutTransitionEligible === "boolean") return value - // Only persisted profiles pass through migration; fresh profiles retain the non-eligible defaults below. + if ( + typeof general.layoutTransitionEligible === "boolean" || + typeof general.layoutTransitionSettingsPresent === "boolean" + ) + return value return { ...value, general: { ...general, - newLayoutDesigns: typeof general.newLayoutDesigns === "boolean" ? general.newLayoutDesigns : legacyDefault, - layoutTransitionEligible: true, + layoutTransitionSettingsPresent: true, }, } } @@ -100,11 +103,32 @@ export function layoutTransitionState(scheduled: boolean, eligible: boolean, ret } } +export const maximumSunsetTimeout = 2_147_483_647 + +export function nextSunsetCheckDelay(sunset: number, now: number) { + return Math.min(Math.max(0, sunset - now), maximumSunsetTimeout) +} + export function resolveNewLayoutDesigns(retired: boolean, preference: boolean | undefined, fallback = true) { if (retired) return true 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' @@ -169,8 +193,6 @@ const defaultSettings: Settings = { editToolPartsExpanded: false, showCustomAgents: false, mobileTitlebarPosition: "top", - newLayoutDesigns: newLayoutDesignsDefault, - layoutTransitionEligible: false, }, appearance: { fontSize: 14, @@ -218,20 +240,44 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont ) const sunset = oldInterfaceSunset const [oldInterfaceRetired, setOldInterfaceRetired] = createSignal(sunset ? Date.now() >= sunset.getTime() : false) + const layoutTransitionClassified = createMemo( + () => 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()), ) const newLayoutDesigns = createMemo(() => { if (!ready() && !oldInterfaceRetired()) return legacyNewLayoutDesignsDefault - return resolveNewLayoutDesigns(oldInterfaceRetired(), store.general?.newLayoutDesigns, newLayoutDesignsDefault) + if (!layoutTransitionClassified()) { + return resolveNewLayoutDesigns(oldInterfaceRetired(), store.general?.newLayoutDesigns, legacyNewLayoutDesignsDefault) + } + return resolveNewLayoutDesigns( + oldInterfaceRetired(), + store.general?.newLayoutDesigns, + layoutTransitionEligible() ? legacyNewLayoutDesignsDefault : newLayoutDesignsDefault, + ) }) const visible = (preference: () => boolean) => createMemo(() => !newLayoutDesigns() || preference()) if (sunset && !oldInterfaceRetired()) { - const timeout = setTimeout(() => setOldInterfaceRetired(true), Math.max(0, sunset.getTime() - Date.now())) - onCleanup(() => clearTimeout(timeout)) + const timeout = { current: undefined as ReturnType | undefined } + const checkSunset = () => { + if (Date.now() >= sunset.getTime()) { + setOldInterfaceRetired(true) + return + } + timeout.current = setTimeout(checkSunset, nextSunsetCheckDelay(sunset.getTime(), Date.now())) + } + checkSunset() + onCleanup(() => { + if (timeout.current !== undefined) clearTimeout(timeout.current) + }) } createEffect(() => { @@ -329,6 +375,14 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont setNewLayoutDesigns(value: boolean) { setStore("general", "newLayoutDesigns", oldInterfaceRetired() ? true : value) }, + layoutTransitionClassified, + layoutTransitionSettingsPresent, + classifyLayoutTransition(existing: boolean) { + const current = store.general?.layoutTransitionEligible + const next = resolveLayoutTransitionClassification(current, existing) + if (current === next) return + setStore("general", "layoutTransitionEligible", next) + }, layoutTransitionAvailable: createMemo(() => ready() && layoutTransition().available), newInterfaceNoticeVisible: createMemo(() => ready() && layoutTransition().notice), dismissNewInterfaceNotice() {