feat(app): enforce layout sunset for existing users

This commit is contained in:
usrnk1 2026-07-13 13:35:08 +02:00
commit 31f946eed7
4 changed files with 181 additions and 54 deletions

View file

@ -24,7 +24,7 @@ import {
sansInput,
terminalDefault,
terminalFontFamily,
oldInterfaceSunset,
formatOldInterfaceSunset,
terminalInput,
useSettings,
} from "@/context/settings"
@ -251,7 +251,7 @@ export const SettingsGeneral: Component = () => {
})
const InterfaceSection = () => (
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-1">
<SettingsList>
<SettingsRow
title={
@ -261,9 +261,7 @@ export const SettingsGeneral: Component = () => {
</span>
}
description={language.t("settings.general.row.newInterface.description", {
date: new Intl.DateTimeFormat(language.intl(), { month: "long", day: "numeric" }).format(
oldInterfaceSunset,
),
date: formatOldInterfaceSunset(language.intl(), true),
})}
>
<div data-action="settings-new-layout-designs">
@ -280,23 +278,23 @@ export const SettingsGeneral: Component = () => {
</div>
</SettingsRow>
</SettingsList>
</div>
)
<Show when={!settings.general.newInterfaceNoticeDismissed()}>
<SettingsList>
<SettingsRow
title={language.t("settings.general.row.newInterfaceNotice.title")}
description={language.t("settings.general.row.newInterfaceNotice.description", {
date: new Intl.DateTimeFormat(language.intl(), { month: "long", day: "numeric" }).format(
oldInterfaceSunset,
),
})}
>
<Button size="small" variant="ghost" onClick={settings.general.dismissNewInterfaceNotice}>
{language.t("settings.general.row.newInterfaceNotice.dismiss")}
</Button>
</SettingsRow>
</SettingsList>
</Show>
const InterfaceNoticeSection = () => (
<div class="flex flex-col gap-1">
<SettingsList>
<SettingsRow
title={language.t("settings.general.row.newInterfaceNotice.title")}
description={language.t("settings.general.row.newInterfaceNotice.description", {
date: formatOldInterfaceSunset(language.intl()),
})}
>
<Button size="small" variant="ghost" onClick={settings.general.dismissNewInterfaceNotice}>
{language.t("settings.general.row.newInterfaceNotice.dismiss")}
</Button>
</SettingsRow>
</SettingsList>
</div>
)
@ -753,10 +751,14 @@ export const SettingsGeneral: Component = () => {
</div>
<div class="flex flex-col gap-8 w-full">
<Show when={Date.now() < oldInterfaceSunset.getTime()}>
<Show when={settings.general.layoutTransitionAvailable()}>
<InterfaceSection />
</Show>
<Show when={settings.general.newInterfaceNoticeVisible()}>
<InterfaceNoticeSection />
</Show>
<GeneralSection />
<AppearanceSection />

View file

@ -22,7 +22,7 @@ import {
sansInput,
terminalDefault,
terminalFontFamily,
oldInterfaceSunset,
formatOldInterfaceSunset,
terminalInput,
useSettings,
} from "@/context/settings"
@ -240,9 +240,7 @@ export const SettingsGeneralV2: Component<{
</span>
}
description={language.t("settings.general.row.newInterface.description", {
date: new Intl.DateTimeFormat(language.intl(), { month: "long", day: "numeric" }).format(
oldInterfaceSunset,
),
date: formatOldInterfaceSunset(language.intl(), true),
})}
>
<div data-action="settings-new-layout-designs">
@ -260,23 +258,23 @@ export const SettingsGeneralV2: Component<{
</SettingsRowV2>
</SettingsListV2>
</div>
</div>
)
<Show when={!settings.general.newInterfaceNoticeDismissed()}>
<SettingsListV2>
<SettingsRowV2
title={language.t("settings.general.row.newInterfaceNotice.title")}
description={language.t("settings.general.row.newInterfaceNotice.description", {
date: new Intl.DateTimeFormat(language.intl(), { month: "long", day: "numeric" }).format(
oldInterfaceSunset,
),
})}
>
<ButtonV2 size="small" variant="ghost-muted" onClick={settings.general.dismissNewInterfaceNotice}>
{language.t("settings.general.row.newInterfaceNotice.dismiss")}
</ButtonV2>
</SettingsRowV2>
</SettingsListV2>
</Show>
const InterfaceNoticeSection = () => (
<div class="settings-v2-section">
<SettingsListV2>
<SettingsRowV2
title={language.t("settings.general.row.newInterfaceNotice.title")}
description={language.t("settings.general.row.newInterfaceNotice.description", {
date: formatOldInterfaceSunset(language.intl()),
})}
>
<ButtonV2 size="small" variant="ghost-muted" onClick={settings.general.dismissNewInterfaceNotice}>
{language.t("settings.general.row.newInterfaceNotice.dismiss")}
</ButtonV2>
</SettingsRowV2>
</SettingsListV2>
</div>
)
@ -719,10 +717,14 @@ export const SettingsGeneralV2: Component<{
</div>
<div class="settings-v2-tab-body">
<Show when={Date.now() < oldInterfaceSunset.getTime()}>
<Show when={settings.general.layoutTransitionAvailable()}>
<InterfaceSection />
</Show>
<Show when={settings.general.newInterfaceNoticeVisible()}>
<InterfaceNoticeSection />
</Show>
<GeneralSection />
<AppearanceSection />

View file

@ -0,0 +1,51 @@
import { describe, expect, test } from "bun:test"
import {
formatOldInterfaceSunset,
layoutTransitionState,
migrateSettings,
newLayoutDesignsDefault,
resolveNewLayoutDesigns,
} from "./settings"
describe("layout transition", () => {
test("fresh 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 })
})
test("formats the English deadline with an ordinal before sunset", () => {
const sunset = new Date(2026, 7, 6)
expect(formatOldInterfaceSunset("en-US", true, sunset)).toBe("August 6th")
expect(formatOldInterfaceSunset("en-US", false, sunset)).toBe("August 6")
})
test("hides the transition until a sunset is scheduled", () => {
expect(layoutTransitionState(false, true, false, false)).toEqual({ available: false, notice: false })
expect(formatOldInterfaceSunset("en-US")).toBe("")
})
test("existing profiles can switch before sunset", () => {
expect(migrateSettings({ general: { newLayoutDesigns: false } }, true)).toEqual({
general: { newLayoutDesigns: false, layoutTransitionEligible: 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("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("migration does not reclassify fresh profiles", () => {
const settings = { general: { newLayoutDesigns: true, layoutTransitionEligible: false } }
expect(migrateSettings(settings, false)).toBe(settings)
})
})

View file

@ -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,7 @@ export interface Settings {
showCustomAgents: boolean
mobileTitlebarPosition: "top" | "bottom"
newLayoutDesigns?: boolean
layoutTransitionEligible?: boolean
newInterfaceNoticeDismissed?: boolean
}
appearance: {
@ -53,9 +54,56 @@ 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"
// Date the old interface is retired; the settings toggle references this countdown.
export const oldInterfaceSunset = new Date(2026, 7, 6)
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 = null as Date | null
export function formatOldInterfaceSunset(locale: string, ordinal = false, sunset = oldInterfaceSunset) {
if (!sunset) return ""
const date = new Intl.DateTimeFormat(locale, { month: "long", day: "numeric" }).format(sunset)
if (!ordinal || !locale.startsWith("en")) return date
return `${date}${ordinalSuffix(sunset.getDate())}`
}
function ordinalSuffix(day: number) {
if (day % 100 >= 11 && day % 100 <= 13) return "th"
if (day % 10 === 1) return "st"
if (day % 10 === 2) return "nd"
if (day % 10 === 3) return "rd"
return "th"
}
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) {
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.
return {
...value,
general: {
...general,
newLayoutDesigns: typeof general.newLayoutDesigns === "boolean" ? general.newLayoutDesigns : legacyDefault,
layoutTransitionEligible: true,
},
}
}
export function layoutTransitionState(scheduled: boolean, eligible: boolean, retired: boolean, dismissed: boolean) {
return {
available: scheduled && eligible && !retired,
notice: scheduled && eligible && retired && !dismissed,
}
}
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'
@ -121,6 +169,8 @@ const defaultSettings: Settings = {
editToolPartsExpanded: false,
showCustomAgents: false,
mobileTitlebarPosition: "top",
newLayoutDesigns: newLayoutDesignsDefault,
layoutTransitionEligible: false,
},
appearance: {
fontSize: 14,
@ -155,7 +205,10 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
name: "Settings",
gate: false,
init: () => {
const [store, setStore, _, ready] = persisted("settings.v3", createStore<Settings>(defaultSettings))
const [store, setStore, _, ready] = persisted(
{ key: "settings.v3", migrate: migrateSettings },
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)
@ -163,9 +216,30 @@ 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 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
return resolveNewLayoutDesigns(oldInterfaceRetired(), store.general?.newLayoutDesigns, 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))
}
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
@ -253,12 +327,10 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
},
newLayoutDesigns,
setNewLayoutDesigns(value: boolean) {
setStore("general", "newLayoutDesigns", value)
setStore("general", "newLayoutDesigns", oldInterfaceRetired() ? true : value)
},
newInterfaceNoticeDismissed: withFallback(
() => store.general?.newInterfaceNoticeDismissed,
false,
),
layoutTransitionAvailable: createMemo(() => ready() && layoutTransition().available),
newInterfaceNoticeVisible: createMemo(() => ready() && layoutTransition().notice),
dismissNewInterfaceNotice() {
setStore("general", "newInterfaceNoticeDismissed", true)
},