From 16d731bd674daf9f2fdebb3bf977cfad973bd8f4 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Wed, 26 Aug 2026 17:57:36 +1000 Subject: [PATCH] feat(app): render settings as a fullscreen surface (#45190) --- .../remote-session-settings.spec.ts | 27 ++-- .../regression/server-dialog-focus.spec.ts | 8 +- packages/app/src/runtime/i18n/en.ts | 1 + packages/app/src/settings/command.tsx | 20 +-- packages/app/src/settings/settings.css | 121 +++++++++++++++--- packages/app/src/settings/shell.tsx | 49 +++++-- packages/app/src/settings/surface.tsx | 32 +++++ packages/app/src/shell/routes/routes.tsx | 5 +- packages/app/src/shell/shell.tsx | 17 ++- 9 files changed, 220 insertions(+), 60 deletions(-) create mode 100644 packages/app/src/settings/surface.tsx diff --git a/packages/app/e2e/regression/remote-session-settings.spec.ts b/packages/app/e2e/regression/remote-session-settings.spec.ts index 3ae2cedd50c..c137f53d78d 100644 --- a/packages/app/e2e/regression/remote-session-settings.spec.ts +++ b/packages/app/e2e/regression/remote-session-settings.spec.ts @@ -24,11 +24,17 @@ test("session settings use the remote server context", async ({ page }) => { await configureServers(page) await page.goto(`/server/${base64Encode(serverB)}/session/${sessionB.id}`) - await expect(page.getByRole("heading", { name: sessionB.title, exact: true })).toBeVisible() + const sessionHeading = page.getByRole("heading", { name: sessionB.title, exact: true, includeHidden: true }) + await expect(sessionHeading).toBeVisible() await page.keyboard.press("Control+,") - const dialog = page.locator(".settings-dialog") - const autoAccept = dialog.locator('[data-action="settings-auto-accept-permissions"]') + const settings = page.getByTestId("settings-screen") + await expect(settings).toBeVisible() + await expect(page.getByRole("dialog")).toHaveCount(0) + await expect(settings.getByRole("tablist")).toHaveCSS("width", "328px") + await expect(sessionHeading).toBeAttached() + await expect(sessionHeading).toBeHidden() + const autoAccept = settings.locator('[data-action="settings-auto-accept-permissions"]') const input = autoAccept.getByRole("switch") await expect(autoAccept).toBeVisible() await expect(input).toBeEnabled() @@ -55,9 +61,12 @@ test("session settings use the remote server context", async ({ page }) => { }, ]) - await dialog.getByRole("tab", { name: "Models" }).click() - await expect(dialog.getByRole("switch", { name: "Server B Model" })).toBeEnabled() - await expect(dialog.getByRole("switch", { name: "Server A Model" })).toHaveCount(0) + await settings.getByRole("tab", { name: "Models" }).click() + await expect(settings.getByRole("switch", { name: "Server B Model" })).toBeEnabled() + await expect(settings.getByRole("switch", { name: "Server A Model" })).toHaveCount(0) + await settings.getByRole("button", { name: "Back to app" }).click() + await expect(settings).toBeHidden() + await expect(sessionHeading).toBeVisible() }) test("auto-accept responds for an unfocused server session", async ({ page }) => { @@ -78,7 +87,7 @@ test("auto-accept responds for an unfocused server session", async ({ page }) => await page.goto(`/server/${base64Encode(serverA)}/session/${sessionA.id}`) await expect(page.getByRole("heading", { name: sessionA.title, exact: true })).toBeVisible() await page.keyboard.press("Control+,") - const autoAccept = page.locator(".settings-dialog").locator('[data-action="settings-auto-accept-permissions"]') + const autoAccept = page.getByTestId("settings-screen").locator('[data-action="settings-auto-accept-permissions"]') await autoAccept.locator('[data-slot="switch-control"]').click() await expect(autoAccept.getByRole("switch")).toBeChecked() await expect @@ -178,7 +187,7 @@ test("auto-accept sweeps again after a reconnect", async ({ page }) => { const first = await transport.waitForConnection() await page.keyboard.press("Control+,") - const autoAccept = page.locator(".settings-dialog").locator('[data-action="settings-auto-accept-permissions"]') + const autoAccept = page.getByTestId("settings-screen").locator('[data-action="settings-auto-accept-permissions"]') await autoAccept.locator('[data-slot="switch-control"]').click() await expect(autoAccept.getByRole("switch")).toBeChecked() await expect @@ -234,7 +243,7 @@ test("auto-accept approves a request discovered by opening a session", async ({ await expect(page.getByRole("heading", { name: sessionA.title, exact: true })).toBeVisible() await page.keyboard.press("Control+,") - const autoAccept = page.locator(".settings-dialog").locator('[data-action="settings-auto-accept-permissions"]') + const autoAccept = page.getByTestId("settings-screen").locator('[data-action="settings-auto-accept-permissions"]') await autoAccept.locator('[data-slot="switch-control"]').click() await expect(autoAccept.getByRole("switch")).toBeChecked() diff --git a/packages/app/e2e/regression/server-dialog-focus.spec.ts b/packages/app/e2e/regression/server-dialog-focus.spec.ts index c8788b98c83..01415bc89ee 100644 --- a/packages/app/e2e/regression/server-dialog-focus.spec.ts +++ b/packages/app/e2e/regression/server-dialog-focus.spec.ts @@ -2,7 +2,7 @@ import { expect, test, type Route } from "@playwright/test" const server = "http://127.0.0.1:4097" -test("nested server dialog keeps focus inside the top layer", async ({ page }) => { +test("server dialog keeps focus above fullscreen settings", async ({ page }) => { await page.addInitScript((server) => { localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [server] })) }, server) @@ -24,8 +24,9 @@ test("nested server dialog keeps focus inside the top layer", async ({ page }) = await page.goto("/") await page.keyboard.press("Control+,") - const settings = page.locator(".settings-dialog") + const settings = page.getByTestId("settings-screen") await expect(settings).toBeVisible() + await expect(page.getByRole("dialog")).toHaveCount(0) await settings.getByRole("tab", { name: "Servers" }).click() await settings.getByRole("button", { name: "Add server" }).click() @@ -41,6 +42,9 @@ test("nested server dialog keeps focus inside the top layer", async ({ page }) = await expect(password).toBeFocused() await password.fill("secret") await expect(password).toHaveValue("secret") + await page.keyboard.press("Escape") + await expect(editor).toBeHidden() + await expect(settings).toBeVisible() }) function json(route: Route, body: unknown, status = 200) { diff --git a/packages/app/src/runtime/i18n/en.ts b/packages/app/src/runtime/i18n/en.ts index ab94e92f882..898ae4fd1ab 100644 --- a/packages/app/src/runtime/i18n/en.ts +++ b/packages/app/src/runtime/i18n/en.ts @@ -884,6 +884,7 @@ export const dict = { "settings.section.desktop": "Desktop", "settings.section.server": "Server", + "settings.backToApp": "Back to app", "settings.tab.general": "General", "settings.tab.preferences": "Preferences", "settings.tab.shortcuts": "Shortcuts", diff --git a/packages/app/src/settings/command.tsx b/packages/app/src/settings/command.tsx index 0869821c23c..4258c7d3d3f 100644 --- a/packages/app/src/settings/command.tsx +++ b/packages/app/src/settings/command.tsx @@ -1,24 +1,10 @@ -import { onCleanup } from "solid-js" import { useCommand } from "@/shell/commands/command" import { useLanguage } from "@/runtime/i18n/language" -import { useDialog } from "@opencode-ai/ui/context/dialog" +import { useSettingsSurface } from "./surface" export function useSettingsDialog(defaultValue?: string) { - const dialog = useDialog() - let run = 0 - let dead = false - - onCleanup(() => { - dead = true - }) - - return () => { - const current = ++run - void import("@/settings/shell").then((module) => { - if (dead || run !== current) return - void dialog.show(() => ) - }) - } + const settings = useSettingsSurface() + return () => settings.open(defaultValue) } export function useSettingsCommand() { diff --git a/packages/app/src/settings/settings.css b/packages/app/src/settings/settings.css index 845f44a4cac..7f94987eac3 100644 --- a/packages/app/src/settings/settings.css +++ b/packages/app/src/settings/settings.css @@ -5,6 +5,89 @@ height: 100%; } +.settings-screen { + display: flex; + width: 100%; + height: 100%; + min-width: 0; + min-height: 0; + justify-content: center; + overflow: hidden; + background: var(--v2-background-bg-deep); + outline: none; +} + +.settings-screen > .settings { + display: flex; + width: min(100%, 1048px); + min-width: 0; +} + +.settings-screen + > .settings[data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"] + > [data-slot="tabs-v2-list"] { + width: 328px; + min-width: 328px; + padding-block: 48px; + padding-inline-start: 24px; + padding-inline-end: 104px; + border-inline-end: none; + background: transparent; +} + +.settings-screen > .settings > .settings-panel { + flex: 1; + min-width: 0; + max-width: 720px; +} + +.settings-screen .settings-tab-header { + padding: 48px 0 32px; + background: linear-gradient(to bottom, var(--v2-background-bg-deep) calc(100% - 24px), transparent); +} + +.settings-screen .settings-tab-body { + padding-inline: 0; +} + +.settings-nav { + display: flex; + width: 200px; + flex-direction: column; + gap: 16px; +} + +.settings-back { + display: flex; + height: 28px; + align-items: center; + gap: 6px; + padding-inline: 6px; + border: 0; + border-radius: 4px; + background: transparent; + color: var(--v2-text-text-muted); + font: inherit; + line-height: var(--line-height-compact); + text-align: start; + cursor: pointer; +} + +.settings-back:hover, +.settings-back:focus-visible { + background: var(--v2-background-bg-layer-03); + color: var(--v2-text-text-base); +} + +.settings-back:focus-visible { + outline: 2px solid var(--v2-border-border-focus); + outline-offset: 2px; +} + +[dir="rtl"] .settings-back-icon { + transform: scaleX(-1); +} + [data-component="dialog-v2"][data-variant="settings"] [data-slot="dialog-container"] { background: var(--v2-background-bg-base); } @@ -172,12 +255,12 @@ width: 100%; } -[data-component="dialog-v2"][data-variant="settings"] [data-component="select-v2-root"] { +:is([data-component="dialog-v2"][data-variant="settings"], .settings-screen) [data-component="select-v2-root"] { width: fit-content; max-width: 100%; } -[data-component="dialog-v2"][data-variant="settings"] [data-component="button-v2"] { +:is([data-component="dialog-v2"][data-variant="settings"], .settings-screen) [data-component="button-v2"] { width: fit-content; max-width: 100%; } @@ -187,27 +270,33 @@ } @media (max-width: 639px) { + .settings-screen .settings-nav { + width: 100%; + } + + .settings-screen > .settings > .settings-panel { + padding-inline-end: 16px; + } + + .settings-screen .settings-tab-header { + padding-top: 24px; + } + .settings[data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"] > [data-slot="tabs-v2-list"] { width: 144px; min-width: 144px; padding-inline: 8px; } -} -.settings-nav-footer { - display: flex; - flex-direction: column; - gap: 8px; - padding: 4px 0 4px 4px; - user-select: none; -} - -.settings-nav-footer > span { - font-size: 11px; - font-weight: 440; - line-height: 1; - color: var(--v2-text-text-faint); + .settings-screen + > .settings[data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"] + > [data-slot="tabs-v2-list"] { + width: 160px; + min-width: 160px; + padding-block: 24px; + padding-inline: 12px; + } } .settings-provider-row { diff --git a/packages/app/src/settings/shell.tsx b/packages/app/src/settings/shell.tsx index 4f1debbb11a..d9f2326757c 100644 --- a/packages/app/src/settings/shell.tsx +++ b/packages/app/src/settings/shell.tsx @@ -1,9 +1,7 @@ -import { Component, createEffect, createMemo, createSignal, startTransition } from "solid-js" -import { Dialog } from "@opencode-ai/ui/dialog" +import { Component, createEffect, createMemo, createSignal, onCleanup, onMount, startTransition } from "solid-js" import { Tabs } from "@opencode-ai/ui/tabs" import { Icon } from "@opencode-ai/ui/icon" import { useLanguage } from "@/runtime/i18n/language" -import { usePlatform } from "@/runtime/platform/platform" import { SettingsGeneral } from "./general/general" import { SettingsAppearance } from "./appearance/appearance" import { SettingsKeybinds } from "./keybinds/keybinds" @@ -20,19 +18,32 @@ import { useLayout } from "@/shell/state/layout" import { useTabs } from "@/shell/tabs/tabs" import { useGlobal, useServerCtx } from "@/runtime/server/runtime" import { ServerConnection, useServers } from "@/runtime/server/registry" +import { useCommand } from "@/shell/commands/command" +import { useSettingsSurface } from "./surface" import "@/settings/settings.css" -export const DialogSettings: Component<{ +export const SettingsScreen: Component<{ defaultValue?: string }> = (props) => { const language = useLanguage() - const platform = usePlatform() const dialog = useDialog() + const command = useCommand() + const surface = useSettingsSurface() const layout = useLayout() const servers = useServers() const tabs = useTabs() const global = useGlobal() const [tab, setTab] = createSignal(props.defaultValue ?? "general") + let root: HTMLDivElement | undefined + + onMount(() => { + command.keybinds(false) + root?.focus({ preventScroll: true }) + }) + onCleanup(() => command.keybinds(true)) + + createEffect(() => setTab(props.defaultValue ?? "general")) + const server = createMemo(() => { const route = layout.route() switch (route.type) { @@ -67,11 +78,22 @@ export const DialogSettings: Component<{ }) const showProviders = () => { - void dialog.show(() => ) + dialog.close() + setTab("providers") } return ( - +
{ + if (event.key !== "Escape" || event.defaultPrevented || dialog.active) return + event.preventDefault() + surface.close() + }} + > -
+
+
{/* Group 1: Preferences */}
@@ -134,11 +160,6 @@ export const DialogSettings: Component<{
- -
@@ -175,6 +196,6 @@ export const DialogSettings: Component<{ -
+ ) } diff --git a/packages/app/src/settings/surface.tsx b/packages/app/src/settings/surface.tsx new file mode 100644 index 00000000000..cc24ee840f2 --- /dev/null +++ b/packages/app/src/settings/surface.tsx @@ -0,0 +1,32 @@ +import { useLocation } from "@solidjs/router" +import { createEffect, on } from "solid-js" +import { createStore } from "solid-js/store" +import { createSimpleContext } from "@opencode-ai/ui/context" + +export const { use: useSettingsSurface, provider: SettingsSurfaceProvider } = createSimpleContext({ + name: "SettingsSurface", + gate: false, + init: () => { + const location = useLocation() + const [store, setStore] = createStore({ open: false, tab: "general" }) + let focus: HTMLElement | undefined + + const close = () => { + if (!store.open) return + setStore("open", false) + if (focus?.isConnected) focus.focus({ preventScroll: true }) + focus = undefined + } + + createEffect(on(() => `${location.pathname}${location.search}`, close, { defer: true })) + + return { + store, + open(tab = "general") { + if (!store.open && document.activeElement instanceof HTMLElement) focus = document.activeElement + setStore({ open: true, tab }) + }, + close, + } + }, +}) diff --git a/packages/app/src/shell/routes/routes.tsx b/packages/app/src/shell/routes/routes.tsx index e81b606d4ca..8edc1b9c7fc 100644 --- a/packages/app/src/shell/routes/routes.tsx +++ b/packages/app/src/shell/routes/routes.tsx @@ -6,6 +6,7 @@ import { useGlobal } from "@/runtime/server/runtime" import { ServerConnection } from "@/runtime/server/registry" import { SessionPanelFrame, SessionRouteFrame } from "@/session/session-frame" import { LayoutProvider } from "@/shell/state/layout" +import { SettingsSurfaceProvider } from "@/settings/surface" import Shell from "@/shell/shell" import { requireServerKey } from "./session" @@ -69,7 +70,9 @@ function TargetServerRoute(props: ParentProps) { function AppLayout(props: ParentProps) { return ( - {props.children} + + {props.children} + ) } diff --git a/packages/app/src/shell/shell.tsx b/packages/app/src/shell/shell.tsx index ed93f9a56e4..060e988dee4 100644 --- a/packages/app/src/shell/shell.tsx +++ b/packages/app/src/shell/shell.tsx @@ -4,11 +4,14 @@ import { Titlebar, type TitlebarUpdate } from "@/shell/titlebar/titlebar" import { usePlatform } from "@/runtime/platform/platform" import { ToastRegion } from "@/shell/notifications/toast" import { TitlebarRightProvider } from "@/shell/titlebar/right-slot" +import { useSettingsSurface } from "@/settings/surface" const DebugBar = lazy(() => import("@/shell/debug/debug-bar").then((module) => ({ default: module.DebugBar }))) +const SettingsScreen = lazy(() => import("@/settings/shell").then((module) => ({ default: module.SettingsScreen }))) export default function Layout(props: ParentProps) { const platform = usePlatform() + const settings = useSettingsSurface() const [state, setState] = createStore({ debugTools: false }) const update: TitlebarUpdate = { @@ -41,7 +44,19 @@ export default function Layout(props: ParentProps) { } />
- {props.children} + + + + + +