feat(desktop): add layout transition switch (#36667)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
This commit is contained in:
parent
3a938bb6d8
commit
265a93927d
38 changed files with 550 additions and 100 deletions
40
packages/app/src/context/settings.test.ts
Normal file
40
packages/app/src/context/settings.test.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
layoutTransitionState,
|
||||
maximumSunsetTimeout,
|
||||
newLayoutDesignsDefault,
|
||||
nextSunsetCheckDelay,
|
||||
resolveNewLayoutDesigns,
|
||||
} from "./settings"
|
||||
|
||||
describe("layout transition", () => {
|
||||
test("blank profiles default to the new layout", () => {
|
||||
expect(newLayoutDesignsDefault).toBe(true)
|
||||
})
|
||||
|
||||
test("hides the transition until a sunset is scheduled", () => {
|
||||
expect(layoutTransitionState(false, true, false, false)).toEqual({ available: false, notice: false })
|
||||
})
|
||||
|
||||
test("existing profiles can switch before sunset", () => {
|
||||
expect(layoutTransitionState(true, true, false, false)).toEqual({ available: true, notice: false })
|
||||
})
|
||||
|
||||
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", () => {
|
||||
expect(layoutTransitionState(true, true, true, false)).toEqual({ available: false, notice: true })
|
||||
expect(layoutTransitionState(true, true, true, true)).toEqual({ available: false, notice: false })
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { createEffect, createMemo } from "solid-js"
|
||||
import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { persisted } from "@/utils/persist"
|
||||
|
||||
|
|
@ -34,6 +34,8 @@ export interface Settings {
|
|||
showCustomAgents: boolean
|
||||
mobileTitlebarPosition: "top" | "bottom"
|
||||
newLayoutDesigns?: boolean
|
||||
layoutTransitionEligible?: boolean
|
||||
newInterfaceNoticeDismissed?: boolean
|
||||
}
|
||||
appearance: {
|
||||
fontSize: number
|
||||
|
|
@ -52,7 +54,28 @@ export interface Settings {
|
|||
export const monoDefault = "System Mono"
|
||||
export const sansDefault = "System Sans"
|
||||
export const terminalDefault = "JetBrainsMono Nerd Font Mono"
|
||||
export const newLayoutDesignsDefault = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
|
||||
const legacyNewLayoutDesignsDefault = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
|
||||
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 = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod" ? new Date(2026, 7, 14) : null
|
||||
|
||||
export function layoutTransitionState(scheduled: boolean, eligible: boolean, retired: boolean, dismissed: boolean) {
|
||||
return {
|
||||
available: scheduled && eligible && !retired,
|
||||
notice: scheduled && eligible && retired && !dismissed,
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
const monoFallback =
|
||||
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
|
||||
|
|
@ -160,9 +183,50 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
|||
() => store.general?.showCustomAgents,
|
||||
defaultSettings.general.showCustomAgents,
|
||||
)
|
||||
const newLayoutDesigns = withFallback(() => store.general?.newLayoutDesigns, newLayoutDesignsDefault)
|
||||
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 newInterfaceNoticeDismissed = withFallback(() => store.general?.newInterfaceNoticeDismissed, false)
|
||||
const layoutTransition = createMemo(() =>
|
||||
layoutTransitionState(!!sunset, layoutTransitionEligible(), oldInterfaceRetired(), newInterfaceNoticeDismissed()),
|
||||
)
|
||||
const newLayoutDesigns = createMemo(() => {
|
||||
if (!ready() && !oldInterfaceRetired()) return legacyNewLayoutDesignsDefault
|
||||
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 = { 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(() => {
|
||||
if (!ready() || !oldInterfaceRetired()) return
|
||||
if (store.general?.newLayoutDesigns === true) return
|
||||
setStore("general", "newLayoutDesigns", true)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (typeof document === "undefined") return
|
||||
const root = document.documentElement
|
||||
|
|
@ -250,7 +314,18 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
|||
},
|
||||
newLayoutDesigns,
|
||||
setNewLayoutDesigns(value: boolean) {
|
||||
setStore("general", "newLayoutDesigns", value)
|
||||
setStore("general", "newLayoutDesigns", oldInterfaceRetired() ? true : value)
|
||||
},
|
||||
layoutTransitionClassified,
|
||||
setOldLayoutEligible(eligible: boolean) {
|
||||
const current = store.general?.layoutTransitionEligible
|
||||
if (typeof current === "boolean") return
|
||||
setStore("general", "layoutTransitionEligible", eligible)
|
||||
},
|
||||
layoutTransitionAvailable: createMemo(() => ready() && layoutTransition().available),
|
||||
newInterfaceNoticeVisible: createMemo(() => ready() && layoutTransition().notice),
|
||||
dismissNewInterfaceNotice() {
|
||||
setStore("general", "newInterfaceNoticeDismissed", true)
|
||||
},
|
||||
},
|
||||
visibility: {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue