feat(app): enforce layout sunset for existing users
This commit is contained in:
parent
45daa7fece
commit
31f946eed7
4 changed files with 181 additions and 54 deletions
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
51
packages/app/src/context/settings.test.ts
Normal file
51
packages/app/src/context/settings.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue