mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-26 19:31:39 -04:00
feat(app): render settings as a fullscreen surface (#45190)
This commit is contained in:
@@ -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()
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(() => <module.DialogSettings defaultValue={defaultValue} />)
|
||||
})
|
||||
}
|
||||
const settings = useSettingsSurface()
|
||||
return () => settings.open(defaultValue)
|
||||
}
|
||||
|
||||
export function useSettingsCommand() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(() => <DialogSettings defaultValue="providers" />)
|
||||
dialog.close()
|
||||
setTab("providers")
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog size="x-large" variant="settings" class="settings-dialog">
|
||||
<div
|
||||
ref={root}
|
||||
data-testid="settings-screen"
|
||||
class="settings-screen"
|
||||
tabIndex={-1}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Escape" || event.defaultPrevented || dialog.active) return
|
||||
event.preventDefault()
|
||||
surface.close()
|
||||
}}
|
||||
>
|
||||
<Tabs
|
||||
orientation="vertical"
|
||||
variant="settings"
|
||||
@@ -80,7 +102,11 @@ export const DialogSettings: Component<{
|
||||
class="settings"
|
||||
>
|
||||
<Tabs.List>
|
||||
<div class="flex flex-col justify-between h-full w-full">
|
||||
<div class="settings-nav">
|
||||
<button type="button" class="settings-back" onClick={surface.close}>
|
||||
<Icon name="arrow-left" size="small" class="settings-back-icon" />
|
||||
<span>{language.t("settings.backToApp")}</span>
|
||||
</button>
|
||||
<div class="flex flex-col gap-4 w-full">
|
||||
{/* Group 1: Preferences */}
|
||||
<div class="flex flex-col gap-1 w-full">
|
||||
@@ -134,11 +160,6 @@ export const DialogSettings: Component<{
|
||||
</Tabs.Trigger>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-nav-footer">
|
||||
<span>{language.t("app.name.desktop")}</span>
|
||||
<span>v{platform.version}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.List>
|
||||
|
||||
@@ -175,6 +196,6 @@ export const DialogSettings: Component<{
|
||||
</Tabs.Content>
|
||||
</SettingsServerScope>
|
||||
</Tabs>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -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 (
|
||||
<LayoutProvider>
|
||||
<Shell>{props.children}</Shell>
|
||||
<SettingsSurfaceProvider>
|
||||
<Shell>{props.children}</Shell>
|
||||
</SettingsSurfaceProvider>
|
||||
</LayoutProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
/>
|
||||
<main class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-strict">
|
||||
<Suspense>{props.children}</Suspense>
|
||||
<div
|
||||
class="flex size-full min-h-0 min-w-0 flex-col"
|
||||
hidden={settings.store.open}
|
||||
inert={settings.store.open}
|
||||
aria-hidden={settings.store.open}
|
||||
>
|
||||
<Suspense>{props.children}</Suspense>
|
||||
</div>
|
||||
<Show when={settings.store.open}>
|
||||
<Suspense>
|
||||
<SettingsScreen defaultValue={settings.store.tab} />
|
||||
</Suspense>
|
||||
</Show>
|
||||
</main>
|
||||
<Show when={import.meta.env.DEV && state.debugTools}>
|
||||
<Suspense>
|
||||
|
||||
Reference in New Issue
Block a user