Skip to content

Commit fc637ad

Browse files
usrnk1Brendonovich
andauthored
feat(desktop): add layout transition switch (anomalyco#36667)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
1 parent 9beac28 commit fc637ad

38 files changed

Lines changed: 550 additions & 100 deletions

packages/app/src/components/settings-general.tsx

Lines changed: 53 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { Select } from "@opencode-ai/ui/select"
55
import { Switch } from "@opencode-ai/ui/switch"
66
import { TextField } from "@opencode-ai/ui/text-field"
77
import { Tooltip } from "@opencode-ai/ui/tooltip"
8+
import { Tag } from "@opencode-ai/ui/v2/badge-v2"
89
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
910
import { useDialog } from "@opencode-ai/ui/context/dialog"
1011
import { useParams } from "@solidjs/router"
@@ -248,6 +249,50 @@ export const SettingsGeneral: Component = () => {
248249
triggerVariant: "settings" as const,
249250
})
250251

252+
const InterfaceSection = () => (
253+
<div class="flex flex-col gap-1">
254+
<SettingsList>
255+
<SettingsRow
256+
title={
257+
<span class="flex items-center gap-2">
258+
{language.t("settings.general.row.newInterface.title")}
259+
<Tag variant="accent">{language.t("settings.general.row.newInterface.badge")}</Tag>
260+
</span>
261+
}
262+
description={language.t("settings.general.row.newInterface.description")}
263+
>
264+
<div data-action="settings-new-layout-designs">
265+
<Switch
266+
checked={settings.general.newLayoutDesigns()}
267+
onChange={(checked) => {
268+
settings.general.setNewLayoutDesigns(checked)
269+
if (!checked) return
270+
void import("@/components/settings-v2").then((module) => {
271+
void dialog.show(() => <module.DialogSettings />)
272+
})
273+
}}
274+
/>
275+
</div>
276+
</SettingsRow>
277+
</SettingsList>
278+
</div>
279+
)
280+
281+
const InterfaceNoticeSection = () => (
282+
<div class="flex flex-col gap-1">
283+
<SettingsList>
284+
<SettingsRow
285+
title={language.t("settings.general.row.newInterfaceNotice.title")}
286+
description={language.t("settings.general.row.newInterfaceNotice.description")}
287+
>
288+
<Button size="small" variant="ghost" onClick={settings.general.dismissNewInterfaceNotice}>
289+
{language.t("settings.general.row.newInterfaceNotice.dismiss")}
290+
</Button>
291+
</SettingsRow>
292+
</SettingsList>
293+
</div>
294+
)
295+
251296
const GeneralSection = () => (
252297
<div class="flex flex-col gap-1">
253298
<SettingsList>
@@ -335,23 +380,6 @@ export const SettingsGeneral: Component = () => {
335380
</div>
336381
</SettingsRow>
337382

338-
<SettingsRow
339-
title={language.t("settings.general.row.newLayoutDesigns.title")}
340-
description={language.t("settings.general.row.newLayoutDesigns.description")}
341-
>
342-
<div data-action="settings-new-layout-designs">
343-
<Switch
344-
checked={settings.general.newLayoutDesigns()}
345-
onChange={(checked) => {
346-
settings.general.setNewLayoutDesigns(checked)
347-
if (!checked) return
348-
void import("@/components/settings-v2").then((module) => {
349-
dialog.show(() => <module.DialogSettings />)
350-
})
351-
}}
352-
/>
353-
</div>
354-
</SettingsRow>
355383
</SettingsList>
356384
</div>
357385
)
@@ -718,6 +746,14 @@ export const SettingsGeneral: Component = () => {
718746
</div>
719747

720748
<div class="flex flex-col gap-8 w-full">
749+
<Show when={settings.general.layoutTransitionAvailable()}>
750+
<InterfaceSection />
751+
</Show>
752+
753+
<Show when={settings.general.newInterfaceNoticeVisible()}>
754+
<InterfaceNoticeSection />
755+
</Show>
756+
721757
<GeneralSection />
722758

723759
<AppearanceSection />

packages/app/src/components/settings-v2/general.tsx

Lines changed: 34 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
2828
import { Link } from "../link"
2929
import { SettingsListV2 } from "./parts/list"
3030
import { SettingsRowV2 } from "./parts/row"
31+
import { LayoutRetirementNotice, LayoutTransitionToggle } from "./interface-transition"
3132
import "./settings-v2.css"
3233

3334
let demoSoundState = {
@@ -226,6 +227,31 @@ export const SettingsGeneralV2: Component<{
226227
},
227228
})
228229

230+
const InterfaceSection = () => (
231+
<LayoutTransitionToggle
232+
title={language.t("settings.general.row.newInterface.title")}
233+
badge={language.t("settings.general.row.newInterface.badge")}
234+
description={language.t("settings.general.row.newInterface.description")}
235+
checked={settings.general.newLayoutDesigns()}
236+
onChange={(checked) => {
237+
settings.general.setNewLayoutDesigns(checked)
238+
if (checked) return
239+
void import("@/components/dialog-settings").then((module) => {
240+
void dialog.show(() => <module.DialogSettings />)
241+
})
242+
}}
243+
/>
244+
)
245+
246+
const InterfaceNoticeSection = () => (
247+
<LayoutRetirementNotice
248+
title={language.t("settings.general.row.newInterfaceNotice.title")}
249+
description={language.t("settings.general.row.newInterfaceNotice.description")}
250+
dismiss={language.t("settings.general.row.newInterfaceNotice.dismiss")}
251+
onDismiss={settings.general.dismissNewInterfaceNotice}
252+
/>
253+
)
254+
229255
const GeneralSection = () => (
230256
<div class="settings-v2-section">
231257
<SettingsListV2>
@@ -312,24 +338,6 @@ export const SettingsGeneralV2: Component<{
312338
</div>
313339
</SettingsRowV2>
314340

315-
<SettingsRowV2
316-
title={language.t("settings.general.row.newLayoutDesigns.title")}
317-
description={language.t("settings.general.row.newLayoutDesigns.description")}
318-
>
319-
<div data-action="settings-new-layout-designs">
320-
<Switch
321-
checked={settings.general.newLayoutDesigns()}
322-
onChange={(checked) => {
323-
settings.general.setNewLayoutDesigns(checked)
324-
if (checked) return
325-
void import("@/components/dialog-settings").then((module) => {
326-
dialog.show(() => <module.DialogSettings />)
327-
})
328-
}}
329-
/>
330-
</div>
331-
</SettingsRowV2>
332-
333341
<Show when={mobile() && import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"}>
334342
<SettingsRowV2
335343
title={language.t("settings.general.row.mobileTitlebarBottom.title")}
@@ -683,6 +691,14 @@ export const SettingsGeneralV2: Component<{
683691
</div>
684692

685693
<div class="settings-v2-tab-body">
694+
<Show when={settings.general.layoutTransitionAvailable()}>
695+
<InterfaceSection />
696+
</Show>
697+
698+
<Show when={settings.general.newInterfaceNoticeVisible()}>
699+
<InterfaceNoticeSection />
700+
</Show>
701+
686702
<GeneralSection />
687703

688704
<AppearanceSection />
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
// @ts-nocheck
2+
import { Show } from "solid-js"
3+
import { createStore } from "solid-js/store"
4+
import { LayoutRetirementNotice, LayoutTransitionToggle } from "./interface-transition"
5+
6+
const copy = {
7+
title: "New layout",
8+
badge: "New",
9+
description: "Use the new tabs and home layout. Switch between layouts for a limited time.",
10+
noticeTitle: "You're now using new layout",
11+
noticeDescription: "The previous layout is no longer available",
12+
dismiss: "Dismiss",
13+
}
14+
15+
function Frame(props) {
16+
return <div class="w-[640px] max-w-full">{props.children}</div>
17+
}
18+
19+
function ToggleExample(props) {
20+
const [state, setState] = createStore({ checked: props.checked })
21+
return (
22+
<Frame>
23+
<LayoutTransitionToggle
24+
title={copy.title}
25+
badge={copy.badge}
26+
description={copy.description}
27+
checked={state.checked}
28+
onChange={(checked) => setState("checked", checked)}
29+
/>
30+
</Frame>
31+
)
32+
}
33+
34+
function NoticeExample() {
35+
const [state, setState] = createStore({ dismissed: false })
36+
return (
37+
<Frame>
38+
<Show when={!state.dismissed} fallback={<span class="text-v2-text-text-muted">Notice dismissed</span>}>
39+
<LayoutRetirementNotice
40+
title={copy.noticeTitle}
41+
description={copy.noticeDescription}
42+
dismiss={copy.dismiss}
43+
onDismiss={() => setState("dismissed", true)}
44+
/>
45+
</Show>
46+
</Frame>
47+
)
48+
}
49+
50+
export default {
51+
title: "App/Settings/Layout transition",
52+
id: "app-settings-layout-transition",
53+
component: LayoutTransitionToggle,
54+
}
55+
56+
export const NewLayoutEnabled = {
57+
render: () => <ToggleExample checked />,
58+
}
59+
60+
export const PreviousLayoutEnabled = {
61+
render: () => <ToggleExample checked={false} />,
62+
}
63+
64+
export const PreviousLayoutRetired = {
65+
render: () => <NoticeExample />,
66+
}
67+
68+
export const AllStates = {
69+
render: () => (
70+
<div class="flex flex-col gap-8">
71+
<ToggleExample checked />
72+
<ToggleExample checked={false} />
73+
<NoticeExample />
74+
</div>
75+
),
76+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { Tag } from "@opencode-ai/ui/v2/badge-v2"
2+
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
3+
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
4+
import { SettingsListV2 } from "./parts/list"
5+
import { SettingsRowV2 } from "./parts/row"
6+
7+
export function LayoutTransitionToggle(props: {
8+
title: string
9+
badge: string
10+
description: string
11+
checked: boolean
12+
onChange: (checked: boolean) => void
13+
}) {
14+
return (
15+
<div class="settings-v2-section">
16+
<div class="settings-v2-interface-feature">
17+
<SettingsListV2>
18+
<SettingsRowV2
19+
title={
20+
<span class="flex items-center gap-2">
21+
{props.title}
22+
<Tag variant="accent">{props.badge}</Tag>
23+
</span>
24+
}
25+
description={props.description}
26+
>
27+
<div data-action="settings-new-layout-designs">
28+
<Switch checked={props.checked} onChange={props.onChange} />
29+
</div>
30+
</SettingsRowV2>
31+
</SettingsListV2>
32+
</div>
33+
</div>
34+
)
35+
}
36+
37+
export function LayoutRetirementNotice(props: {
38+
title: string
39+
description: string
40+
dismiss: string
41+
onDismiss: () => void
42+
}) {
43+
return (
44+
<div class="settings-v2-section">
45+
<SettingsListV2>
46+
<SettingsRowV2 title={props.title} description={props.description}>
47+
<ButtonV2 size="small" variant="ghost-muted" onClick={props.onDismiss}>
48+
{props.dismiss}
49+
</ButtonV2>
50+
</SettingsRowV2>
51+
</SettingsListV2>
52+
</div>
53+
)
54+
}

packages/app/src/components/settings-v2/settings-v2.css

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,11 @@
9090
box-shadow: inset 0 0 0 0.5px var(--v2-border-border-muted);
9191
}
9292

93+
.settings-v2-interface-feature [data-component="settings-v2-list"] {
94+
background-color: var(--v2-background-bg-base);
95+
box-shadow: var(--v2-elevation-raised);
96+
}
97+
9398
[data-component="settings-v2-row"] {
9499
display: flex;
95100
flex-wrap: wrap;
@@ -128,7 +133,7 @@
128133
}
129134

130135
[data-slot="settings-v2-row-description"] {
131-
font-size: 11px;
136+
font-size: 13px;
132137
font-weight: 440;
133138
line-height: 1;
134139
color: var(--v2-text-text-muted);
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { describe, expect, test } from "bun:test"
2+
import {
3+
layoutTransitionState,
4+
maximumSunsetTimeout,
5+
newLayoutDesignsDefault,
6+
nextSunsetCheckDelay,
7+
resolveNewLayoutDesigns,
8+
} from "./settings"
9+
10+
describe("layout transition", () => {
11+
test("blank profiles default to the new layout", () => {
12+
expect(newLayoutDesignsDefault).toBe(true)
13+
})
14+
15+
test("hides the transition until a sunset is scheduled", () => {
16+
expect(layoutTransitionState(false, true, false, false)).toEqual({ available: false, notice: false })
17+
})
18+
19+
test("existing profiles can switch before sunset", () => {
20+
expect(layoutTransitionState(true, true, false, false)).toEqual({ available: true, notice: false })
21+
})
22+
23+
test("preserves explicit and default layout preferences", () => {
24+
expect(resolveNewLayoutDesigns(false, false, true)).toBe(false)
25+
expect(resolveNewLayoutDesigns(false, undefined, false)).toBe(false)
26+
expect(resolveNewLayoutDesigns(false, undefined, true)).toBe(true)
27+
})
28+
29+
test("sunset replaces the toggle with a dismissible notice", () => {
30+
expect(layoutTransitionState(true, true, true, false)).toEqual({ available: false, notice: true })
31+
expect(layoutTransitionState(true, true, true, true)).toEqual({ available: false, notice: false })
32+
expect(resolveNewLayoutDesigns(true, false)).toBe(true)
33+
})
34+
35+
test("caps checks for sunsets beyond the browser timeout limit", () => {
36+
expect(nextSunsetCheckDelay(maximumSunsetTimeout + 1_000, 0)).toBe(maximumSunsetTimeout)
37+
expect(nextSunsetCheckDelay(10_000, 9_000)).toBe(1_000)
38+
expect(nextSunsetCheckDelay(9_000, 10_000)).toBe(0)
39+
})
40+
})

0 commit comments

Comments
 (0)