feat(app): preserve previous layout for active users
This commit is contained in:
parent
545b86587d
commit
d87f987800
3 changed files with 219 additions and 21 deletions
|
|
@ -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<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 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<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 }),
|
||||
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: {
|
|||
<GlobalProvider>
|
||||
<SettingsProvider>
|
||||
<ConnectionGate disableHealthCheck={props.disableHealthCheck} startup={props.startup}>
|
||||
<LayoutTransitionClassifier />
|
||||
<Show when={useSettings().general.newLayoutDesigns().toString()} keyed>
|
||||
<Dynamic
|
||||
component={props.router ?? Router}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,37 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
formatOldInterfaceSunset,
|
||||
hasMeaningfulLayoutData,
|
||||
layoutTransitionState,
|
||||
maximumSunsetTimeout,
|
||||
migrateSettings,
|
||||
newLayoutDesignsDefault,
|
||||
nextSunsetCheckDelay,
|
||||
resolveLayoutTransitionClassification,
|
||||
resolveNewLayoutDesigns,
|
||||
} from "./settings"
|
||||
|
||||
describe("layout transition", () => {
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> {
|
|||
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<typeof setTimeout> | 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() {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue