From 31f946eed7572ab9c31547f8afe24234c72dd28a Mon Sep 17 00:00:00 2001
From: usrnk1 <7547651+usrnk1@users.noreply.github.com>
Date: Mon, 13 Jul 2026 13:35:08 +0200
Subject: [PATCH] feat(app): enforce layout sunset for existing users
---
.../app/src/components/settings-general.tsx | 46 ++++-----
.../src/components/settings-v2/general.tsx | 44 ++++-----
packages/app/src/context/settings.test.ts | 51 ++++++++++
packages/app/src/context/settings.tsx | 94 ++++++++++++++++---
4 files changed, 181 insertions(+), 54 deletions(-)
create mode 100644 packages/app/src/context/settings.test.ts
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)
},