diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index 9968fa3b59..80330c6c1c 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -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 = () => ( -
+
{ } 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), })} >
@@ -280,23 +278,23 @@ export const SettingsGeneral: Component = () => {
+
+ ) - - - - - - - + const InterfaceNoticeSection = () => ( +
+ + + + +
) @@ -753,10 +751,14 @@ export const SettingsGeneral: Component = () => {
- + + + + + diff --git a/packages/app/src/components/settings-v2/general.tsx b/packages/app/src/components/settings-v2/general.tsx index 120eed726d..a16d0afd96 100644 --- a/packages/app/src/components/settings-v2/general.tsx +++ b/packages/app/src/components/settings-v2/general.tsx @@ -22,7 +22,7 @@ import { sansInput, terminalDefault, terminalFontFamily, - oldInterfaceSunset, + formatOldInterfaceSunset, terminalInput, useSettings, } from "@/context/settings" @@ -240,9 +240,7 @@ export const SettingsGeneralV2: Component<{ } 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), })} >
@@ -260,23 +258,23 @@ export const SettingsGeneralV2: Component<{
+
+ ) - - - - - {language.t("settings.general.row.newInterfaceNotice.dismiss")} - - - - + const InterfaceNoticeSection = () => ( +
+ + + + {language.t("settings.general.row.newInterfaceNotice.dismiss")} + + +
) @@ -719,10 +717,14 @@ export const SettingsGeneralV2: Component<{
- + + + + + diff --git a/packages/app/src/context/settings.test.ts b/packages/app/src/context/settings.test.ts new file mode 100644 index 0000000000..e45f0340f4 --- /dev/null +++ b/packages/app/src/context/settings.test.ts @@ -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) + }) +}) diff --git a/packages/app/src/context/settings.tsx b/packages/app/src/context/settings.tsx index 68c9e840e2..b40e442ac6 100644 --- a/packages/app/src/context/settings.tsx +++ b/packages/app/src/context/settings.tsx @@ -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 { + 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(defaultSettings)) + const [store, setStore, _, ready] = persisted( + { key: "settings.v3", migrate: migrateSettings }, + createStore(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) },