From ce79bb0a14d95acdc47018a8fb1ffba966e4bf54 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Wed, 27 May 2026 19:04:18 +0800 Subject: [PATCH 01/14] feat(settings): rewrite to two-layer takeover shell Replace the old tab-router settings-page with a two-layer takeover shell (240px left nav + content region). Migrate the six existing pages by reuse: general/shortcuts/worktrees/memory wrap the existing settings-* components, and providers+models merge into a single "Models" page that stacks the existing SettingsProviders + SettingsModels. Remote and Integrations pages are scaffolded but hidden from the nav until their real content lands; their connection management stays available in the right-panel Connections for now. Fix Escape not closing settings: the old shell only ever closed via its Close button because a global keybind consumed Escape in the bubble phase. The new shell listens in the capture phase and defers to any open dialog, so Esc reliably closes the surface without dismissing a nested provider dialog. Migrate the settings tests to the new shell: the providers spec targets the Models tab, the smoke test asserts the surface region instead of a page h1, the memory contract test reads settings-shell, and a new settings-shell spec/snap locks nav items, page switching, and close behavior. Also retire the dead notification/sound tests left by #923 (those controls were collapsed into a single notify tri-state) and fix the theme-migration assertion for the current cache-overwrite behavior. Part of #604. --- .../e2e/settings/settings-providers.spec.ts | 10 +- .../app/e2e/settings/settings-shell.spec.ts | 48 ++++ packages/app/e2e/settings/settings.spec.ts | 224 ++---------------- packages/app/e2e/snap/settings-shell.snap.ts | 29 +++ .../src/components/rate-limit-card-wiring.tsx | 4 +- .../src/components/settings-memory.test.ts | 9 +- packages/app/src/components/settings-page.tsx | 200 ---------------- packages/app/src/context/shell-surface.tsx | 4 +- packages/app/src/i18n/en.ts | 6 + packages/app/src/i18n/zh.ts | 6 + packages/app/src/pages/layout.tsx | 10 +- .../app/src/pages/layout/shell-navigation.ts | 6 +- .../app/src/pages/settings/integrations.tsx | 16 ++ packages/app/src/pages/settings/models.tsx | 16 ++ packages/app/src/pages/settings/remote.tsx | 14 ++ .../app/src/pages/settings/settings-shell.tsx | 211 +++++++++++++++++ 16 files changed, 383 insertions(+), 430 deletions(-) create mode 100644 packages/app/e2e/settings/settings-shell.spec.ts create mode 100644 packages/app/e2e/snap/settings-shell.snap.ts delete mode 100644 packages/app/src/components/settings-page.tsx create mode 100644 packages/app/src/pages/settings/integrations.tsx create mode 100644 packages/app/src/pages/settings/models.tsx create mode 100644 packages/app/src/pages/settings/remote.tsx create mode 100644 packages/app/src/pages/settings/settings-shell.tsx diff --git a/packages/app/e2e/settings/settings-providers.spec.ts b/packages/app/e2e/settings/settings-providers.spec.ts index d76bdbcf0..e31eb3ba7 100644 --- a/packages/app/e2e/settings/settings-providers.spec.ts +++ b/packages/app/e2e/settings/settings-providers.spec.ts @@ -5,7 +5,7 @@ test("custom provider form can be filled and validates input", async ({ page, go await gotoSession() const settings = await openSettings(page) - await settings.getByRole("tab", { name: "Providers" }).click() + await settings.getByRole("tab", { name: "Models" }).click() const customProviderSection = settings.locator('[data-component="custom-provider-section"]') await expect(customProviderSection).toBeVisible() @@ -41,7 +41,7 @@ test("shows volcano engine coding plan as a popular provider with api key connec await gotoSession() const settings = await openSettings(page) - await settings.getByRole("tab", { name: "Providers" }).click() + await settings.getByRole("tab", { name: "Models" }).click() const connectedRow = settings.locator('[data-provider-id="volcengine-plan"][data-provider-section="connected"]') const row = settings.locator('[data-provider-id="volcengine-plan"][data-provider-section="popular"]') @@ -68,7 +68,7 @@ test("custom provider form shows validation errors", async ({ page, gotoSession await gotoSession() const settings = await openSettings(page) - await settings.getByRole("tab", { name: "Providers" }).click() + await settings.getByRole("tab", { name: "Models" }).click() const customProviderSection = settings.locator('[data-component="custom-provider-section"]') await customProviderSection.getByRole("button", { name: "Connect" }).click() @@ -94,7 +94,7 @@ test("custom provider form can add and remove models", async ({ page, gotoSessio await gotoSession() const settings = await openSettings(page) - await settings.getByRole("tab", { name: "Providers" }).click() + await settings.getByRole("tab", { name: "Models" }).click() const customProviderSection = settings.locator('[data-component="custom-provider-section"]') await customProviderSection.getByRole("button", { name: "Connect" }).click() @@ -130,7 +130,7 @@ test("custom provider form can add and remove headers", async ({ page, gotoSessi await gotoSession() const settings = await openSettings(page) - await settings.getByRole("tab", { name: "Providers" }).click() + await settings.getByRole("tab", { name: "Models" }).click() const customProviderSection = settings.locator('[data-component="custom-provider-section"]') await customProviderSection.getByRole("button", { name: "Connect" }).click() diff --git a/packages/app/e2e/settings/settings-shell.spec.ts b/packages/app/e2e/settings/settings-shell.spec.ts new file mode 100644 index 000000000..c46d95dad --- /dev/null +++ b/packages/app/e2e/settings/settings-shell.spec.ts @@ -0,0 +1,48 @@ +import { test, expect } from "../fixtures" +import { closeSettingsPanel, openSettings } from "../actions" + +// PR1 地基行为锁:两层 takeover 壳 + 扁平 nav,平移挂载 6 个现有页(远程/集成就绪前隐藏)。 +test("settings shell shows the migrated nav and switches pages", async ({ page, gotoSession }) => { + await gotoSession() + + const settings = await openSettings(page) + + // nav 当前 5 项:通用 / 快捷键 / 模型 / 工作树 / 记忆 + for (const name of ["General", "Shortcuts", "Models", "Worktrees", "Memory"]) { + await expect(settings.getByRole("tab", { name })).toBeVisible() + } + // 远程访问 / 集成页就绪前不露出 + await expect(settings.getByRole("tab", { name: "Remote access" })).toHaveCount(0) + await expect(settings.getByRole("tab", { name: "Integrations" })).toHaveCount(0) + + // 模型页 = 提供商 + 模型 堆叠复用:两块内容都在 + await settings.getByRole("tab", { name: "Models" }).click() + await expect(settings.locator('[data-component="custom-provider-section"]')).toBeVisible() + await expect(settings.getByPlaceholder("Search models")).toBeVisible() + + // 切到记忆页:模型页内容消失,证明内容随 nav 切换 + await settings.getByRole("tab", { name: "Memory" }).click() + await expect(settings.locator('[data-component="custom-provider-section"]')).toHaveCount(0) + + await closeSettingsPanel(page, settings) +}) + +test("escape closes the settings shell", async ({ page, gotoSession }) => { + await gotoSession() + + const settings = await openSettings(page) + await expect(settings).toBeVisible() + + await page.keyboard.press("Escape") + await expect(page.locator('[data-component="settings-page"]')).toHaveCount(0) +}) + +test("back-to-app button closes the settings shell", async ({ page, gotoSession }) => { + await gotoSession() + + const settings = await openSettings(page) + await expect(settings).toBeVisible() + + await settings.getByRole("button", { name: "Back to app" }).click() + await expect(page.locator('[data-component="settings-page"]')).toHaveCount(0) +}) diff --git a/packages/app/e2e/settings/settings.spec.ts b/packages/app/e2e/settings/settings.spec.ts index 7ef4b9cc3..e29fc3a48 100644 --- a/packages/app/e2e/settings/settings.spec.ts +++ b/packages/app/e2e/settings/settings.spec.ts @@ -3,13 +3,7 @@ import { closeDialog, closeSettingsPanel, openSettings } from "../actions" import { settingsCodeFontSelector, settingsLanguageSelectSelector, - settingsNotificationsAgentSelector, - settingsNotificationsErrorsSelector, - settingsNotificationsPermissionsSelector, settingsReleaseNotesSelector, - settingsSoundsAgentSelector, - settingsSoundsErrorsSelector, - settingsSoundsPermissionsSelector, settingsUIFontSelector, settingsUpdatesStartupSelector, titlebarCenterSelector, @@ -46,7 +40,8 @@ test('@smoke PawWork settings opens as a full-pane surface, not a dialog', async await expect(page.locator('[data-component="settings-page"]')).toBeVisible() await expect(page.locator('[data-component="dialog-overlay"]')).toHaveCount(0) - await expect(page.getByRole("heading", { level: 1, name: "Settings" })).toBeVisible() + // 新壳把标题挪到标题栏(PawworkTitlebar),页面内不再有 h1;壳子 section 带 aria-label 提供无障碍名。 + await expect(page.getByRole("region", { name: "Settings" })).toBeVisible() await expect(page.locator(titlebarCenterSelector)).toContainText("Settings") }) @@ -105,21 +100,19 @@ test("unknown theme ids migrate to pawwork and clear cached css", async ({ page, }) .toBe("pawwork") + // 迁移到 pawwork 后,属于旧主题(dracula)的缓存 CSS 不能再生效:preload 清空、 + // 运行时 ThemeProvider 写入 pawwork 真实 CSS 覆盖。断言旧假值不再残留即可。 await expect .poll(async () => { - return await page.evaluate(() => { - return localStorage.getItem("pawwork-theme-css-light") - }) + return await page.evaluate(() => localStorage.getItem("pawwork-theme-css-light")) }) - .toBeNull() + .not.toBe("--background-base:#fff;") await expect .poll(async () => { - return await page.evaluate(() => { - return localStorage.getItem("pawwork-theme-css-dark") - }) + return await page.evaluate(() => localStorage.getItem("pawwork-theme-css-dark")) }) - .toBeNull() + .not.toBe("--background-base:#000;") }) test("typing a code font with spaces persists and updates CSS variable", async ({ page, gotoSession }) => { @@ -440,93 +433,19 @@ test("code font and UI font rehydrate after reload", async ({ page, gotoSession expect(rehydratedSettings?.appearance?.sans).toBe(sans) }) -test("toggling notification agent switch updates localStorage", async ({ page, gotoSession }) => { - await gotoSession() - - const dialog = await openSettings(page) - const switchContainer = dialog.locator(settingsNotificationsAgentSelector) - await expect(switchContainer).toBeVisible() - - const toggleInput = switchContainer.locator('[data-slot="switch-input"]') - const initialState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked) - expect(initialState).toBe(true) - - await switchContainer.locator('[data-slot="switch-control"]').click() - await page.waitForTimeout(100) - - const newState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked) - expect(newState).toBe(false) - - const stored = await page.evaluate((key) => { - const raw = localStorage.getItem(key) - return raw ? JSON.parse(raw) : null - }, settingsKey) - - expect(stored?.notifications?.agent).toBe(false) -}) - -test("toggling notification permissions switch updates localStorage", async ({ page, gotoSession }) => { - await gotoSession() - - const dialog = await openSettings(page) - const switchContainer = dialog.locator(settingsNotificationsPermissionsSelector) - await expect(switchContainer).toBeVisible() - - const toggleInput = switchContainer.locator('[data-slot="switch-input"]') - const initialState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked) - expect(initialState).toBe(true) - - await switchContainer.locator('[data-slot="switch-control"]').click() - await page.waitForTimeout(100) - - const newState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked) - expect(newState).toBe(false) - - const stored = await page.evaluate((key) => { - const raw = localStorage.getItem(key) - return raw ? JSON.parse(raw) : null - }, settingsKey) - - expect(stored?.notifications?.permissions).toBe(false) -}) - -test("toggling notification errors switch updates localStorage", async ({ page, gotoSession }) => { +test("changing notification level persists in localStorage", async ({ page, gotoSession }) => { + // #923 把多个通知开关 + 音效选择合并成单个 tri-state(never / unfocused / always); + // 旧的 settings-notifications-* / settings-sounds-* 控件已删,这里测合并后的单控件。 await gotoSession() const dialog = await openSettings(page) - const switchContainer = dialog.locator(settingsNotificationsErrorsSelector) - await expect(switchContainer).toBeVisible() - - const toggleInput = switchContainer.locator('[data-slot="switch-input"]') - const initialState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked) - expect(initialState).toBe(false) - - await switchContainer.locator('[data-slot="switch-control"]').click() - await page.waitForTimeout(100) - - const newState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked) - expect(newState).toBe(true) - - const stored = await page.evaluate((key) => { - const raw = localStorage.getItem(key) - return raw ? JSON.parse(raw) : null - }, settingsKey) - - expect(stored?.notifications?.errors).toBe(true) -}) - -test("changing sound agent selection persists in localStorage", async ({ page, gotoSession }) => { - await gotoSession() - - const dialog = await openSettings(page) - const select = dialog.locator(settingsSoundsAgentSelector) + const select = dialog.locator('[data-action="settings-notify-level"]') await expect(select).toBeVisible() await select.locator('[data-slot="select-select-trigger"]').click() - const items = page.locator('[data-slot="select-select-item"]') - // Options render as [None, Notification, Error]; nth(2) selects the Error sound. - await items.nth(2).click() + await expect(items).toHaveCount(3) // never / unfocused / always + await items.nth(2).click() // always await expect .poll(async () => { @@ -535,120 +454,7 @@ test("changing sound agent selection persists in localStorage", async ({ page, g return raw ? JSON.parse(raw) : null }, settingsKey) }) - .toMatchObject({ sounds: { agent: "error" } }) -}) - -test("selecting none disables agent sound", async ({ page, gotoSession }) => { - await gotoSession() - - const dialog = await openSettings(page) - const select = dialog.locator(settingsSoundsAgentSelector) - const trigger = select.locator('[data-slot="select-select-trigger"]') - await expect(select).toBeVisible() - await expect(trigger).toBeEnabled() - - await trigger.click() - const items = page.locator('[data-slot="select-select-item"]') - await expect(items.first()).toBeVisible() - await items.first().click() - - const stored = await page.evaluate((key) => { - const raw = localStorage.getItem(key) - return raw ? JSON.parse(raw) : null - }, settingsKey) - - expect(stored?.sounds?.agentEnabled).toBe(false) -}) - -test("changing permissions and errors sounds updates localStorage", async ({ page, gotoSession }) => { - await gotoSession() - - const dialog = await openSettings(page) - const permissionsSelect = dialog.locator(settingsSoundsPermissionsSelector) - const errorsSelect = dialog.locator(settingsSoundsErrorsSelector) - await expect(permissionsSelect).toBeVisible() - await expect(errorsSelect).toBeVisible() - - const initial = await page.evaluate((key) => { - const raw = localStorage.getItem(key) - return raw ? JSON.parse(raw) : null - }, settingsKey) - - const permissionsCurrent = - (await permissionsSelect.locator('[data-slot="select-select-trigger-value"]').textContent())?.trim() ?? "" - await permissionsSelect.locator('[data-slot="select-select-trigger"]').click() - const permissionItems = page.locator('[data-slot="select-select-item"]') - expect(await permissionItems.count()).toBeGreaterThan(1) - if (permissionsCurrent) { - await permissionItems.filter({ hasNotText: permissionsCurrent }).first().click() - } - if (!permissionsCurrent) { - await permissionItems.nth(1).click() - } - - const errorsCurrent = - (await errorsSelect.locator('[data-slot="select-select-trigger-value"]').textContent())?.trim() ?? "" - await errorsSelect.locator('[data-slot="select-select-trigger"]').click() - const errorItems = page.locator('[data-slot="select-select-item"]') - expect(await errorItems.count()).toBeGreaterThan(1) - if (errorsCurrent) { - await errorItems.filter({ hasNotText: errorsCurrent }).first().click() - } - if (!errorsCurrent) { - await errorItems.nth(1).click() - } - - await expect - .poll(async () => { - return await page.evaluate((key) => { - const raw = localStorage.getItem(key) - return raw ? JSON.parse(raw) : null - }, settingsKey) - }) - .toMatchObject({ - sounds: { - permissions: expect.any(String), - errors: expect.any(String), - }, - }) - - const stored = await page.evaluate((key) => { - const raw = localStorage.getItem(key) - return raw ? JSON.parse(raw) : null - }, settingsKey) - - expect(stored?.sounds?.permissions).not.toBe(initial?.sounds?.permissions) - expect(stored?.sounds?.errors).not.toBe(initial?.sounds?.errors) -}) - -test("legacy sound ids fall back to current defaults", async ({ page, gotoSession }) => { - // Upgrading users have stale ids from the old 45-sound set persisted. withSoundFallback - // must map them to the bundled defaults so the dropdown stays populated, not blank/silent. - await page.addInitScript((key) => { - localStorage.setItem( - key, - JSON.stringify({ - sounds: { - agentEnabled: true, - agent: "staplebops-01", - permissionsEnabled: true, - permissions: "nope-03", - errorsEnabled: true, - errors: "yup-02", - }, - }), - ) - }, settingsKey) - - await gotoSession() - - const dialog = await openSettings(page) - const valueOf = (selector: string) => - dialog.locator(selector).locator('[data-slot="select-select-trigger-value"]') - - await expect(valueOf(settingsSoundsAgentSelector)).toHaveText("Notification") - await expect(valueOf(settingsSoundsPermissionsSelector)).toHaveText("Notification") - await expect(valueOf(settingsSoundsErrorsSelector)).toHaveText("Error") + .toMatchObject({ notify: "always" }) }) test("toggling updates startup switch updates localStorage", async ({ page, gotoSession }) => { diff --git a/packages/app/e2e/snap/settings-shell.snap.ts b/packages/app/e2e/snap/settings-shell.snap.ts new file mode 100644 index 000000000..01ee7c994 --- /dev/null +++ b/packages/app/e2e/snap/settings-shell.snap.ts @@ -0,0 +1,29 @@ +import { test } from "../fixtures" +import { openSettings } from "../actions" +import { composeGrid, snapOutputPath, type Shot } from "./_compose" + +test.use({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 2 }) + +// 验收 PR1 地基:两层 takeover 外壳 + 240 左 nav(返回应用 + 5 项 + 版本 foot)。 +// nav 当前 5 项:通用/快捷键/模型/工作树/记忆(远程访问、集成页就绪前不露出)。 +// 截 3 张:通用(默认)/ 模型(合并提供商+模型)/ 记忆(演示切到另一类页正常)。 +test("settings-shell", async ({ page, project }) => { + test.setTimeout(180_000) + + await project.open() + + const settings = await openSettings(page) + await settings.waitFor({ state: "visible", timeout: 30_000 }) + + const shots: Shot[] = [{ name: "general", buf: await settings.screenshot() }] + + for (const tab of ["Models", "Memory"] as const) { + await settings.getByRole("tab", { name: tab }).click() + await page.waitForTimeout(300) + shots.push({ name: tab.toLowerCase(), buf: await settings.screenshot() }) + } + + const out = snapOutputPath("settings-shell") + await composeGrid(shots, out) + process.stdout.write(`\n[snap] settings-shell grid -> ${out}\n\n`) +}) diff --git a/packages/app/src/components/rate-limit-card-wiring.tsx b/packages/app/src/components/rate-limit-card-wiring.tsx index 1e4881c75..06a727b15 100644 --- a/packages/app/src/components/rate-limit-card-wiring.tsx +++ b/packages/app/src/components/rate-limit-card-wiring.tsx @@ -16,7 +16,7 @@ type ApiWithOpenLink = NonNullable & { openLink?: (url: string) = * things that cannot live in packages/ui: * - window.api.openLink (Electron preload IPC) * - trackEvent (app-level telemetry hook) - * - shellSurface.openSettings("providers") + * - shellSurface.openSettings("models") * * Passes pre-bound callbacks to the pure presentational RateLimitCard so * packages/ui stays framework-agnostic. @@ -38,7 +38,7 @@ export function RateLimitCardWiring(props: { }} onUseOwnModelClick={() => { trackEvent("rate_limit_card.byo_click", { providerID: props.classification.providerID }) - shell.openSettings("providers") + shell.openSettings("models") }} /> ) diff --git a/packages/app/src/components/settings-memory.test.ts b/packages/app/src/components/settings-memory.test.ts index e6e3ed43e..f5c4cedca 100644 --- a/packages/app/src/components/settings-memory.test.ts +++ b/packages/app/src/components/settings-memory.test.ts @@ -3,11 +3,12 @@ import { readFileSync } from "node:fs" describe("memory settings source contract", () => { test("registers the Memory settings tab", () => { - const page = readFileSync("src/components/settings-page.tsx", "utf8") + // 新壳 settings-shell.tsx 取代旧 settings-page.tsx 注册各 tab;读新壳否则测的是已删的死代码。 + const shell = readFileSync("src/pages/settings/settings-shell.tsx", "utf8") - expect(page).toContain('"memory"') - expect(page).toContain("SettingsMemory") - expect(page).toContain("settings.tab.memory") + expect(shell).toContain('"memory"') + expect(shell).toContain("SettingsMemory") + expect(shell).toContain("settings.tab.memory") }) test("keeps the v1 raw controls", () => { diff --git a/packages/app/src/components/settings-page.tsx b/packages/app/src/components/settings-page.tsx deleted file mode 100644 index 05c40f912..000000000 --- a/packages/app/src/components/settings-page.tsx +++ /dev/null @@ -1,200 +0,0 @@ -import { type Component, onCleanup, onMount } from "solid-js" -import { Button } from "@opencode-ai/ui/button" -import { Icon } from "@opencode-ai/ui/icon" -import { Tabs } from "@opencode-ai/ui/tabs" -import { useLanguage } from "@/context/language" -import { usePlatform } from "@/context/platform" - -const FOCUSABLE_SELECTOR = - 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])' - -function focusablesIn(root: HTMLElement): HTMLElement[] { - return Array.from(root.querySelectorAll(FOCUSABLE_SELECTOR)).filter( - (el) => el.offsetParent !== null || el === document.activeElement, - ) -} -import { SettingsGeneral } from "./settings-general" -import { SettingsKeybinds } from "./settings-keybinds" -import { SettingsMemory } from "./settings-memory" -import { SettingsModels } from "./settings-models" -import { SettingsProviders } from "./settings-providers" -import { SettingsWorktrees } from "./settings-worktrees" - -export type SettingsPageTab = "general" | "shortcuts" | "providers" | "models" | "worktrees" | "memory" - -export const SettingsPage: Component<{ - active: SettingsPageTab - directory?: string - onSelect: (value: SettingsPageTab) => void - onClose: () => void -}> = (props) => { - const language = useLanguage() - const platform = usePlatform() - let root: HTMLElement | undefined - let returnFocus: HTMLElement | undefined - - onMount(() => { - const active = document.activeElement - if (active instanceof HTMLElement && !root?.contains(active)) returnFocus = active - if (!root) return - const [first] = focusablesIn(root) - first?.focus() - }) - - onCleanup(() => { - const target = returnFocus - returnFocus = undefined - if (!target || !target.isConnected) return - target.focus() - }) - - const handleKeyDown = (event: KeyboardEvent) => { - if (event.defaultPrevented || !root) return - - if (event.key === "Escape") { - event.preventDefault() - event.stopPropagation() - props.onClose() - return - } - - if (event.key !== "Tab") return - const focusables = focusablesIn(root) - if (focusables.length === 0) return - const first = focusables[0] - const last = focusables[focusables.length - 1] - const active = document.activeElement as HTMLElement | null - const inside = !!active && root.contains(active) - - if (event.shiftKey) { - if (!inside || active === first) { - event.preventDefault() - last.focus() - } - } else if (!inside || active === last) { - event.preventDefault() - first.focus() - } - } - - return ( -
(root = el)} - data-component="settings-page" - class="flex size-full min-h-0 bg-bg-base" - onKeyDown={handleKeyDown} - > - { - if ( - value !== "general" && - value !== "shortcuts" && - value !== "providers" && - value !== "models" && - value !== "worktrees" && - value !== "memory" - ) - return - props.onSelect(value) - }} - class="h-full w-full" - > - -
-
-
-

{language.t("sidebar.settings")}

- -
- -
-
- {language.t("settings.section.desktop")} -
- - - {language.t("settings.tab.general")} - - - - {language.t("settings.tab.shortcuts")} - - - - {language.t("settings.tab.memory")} - -
-
- -
- {language.t("settings.section.server")} -
- - - {language.t("settings.providers.title")} - - - - {language.t("settings.models.title")} - - - - {language.t("settings.tab.worktrees")} - -
-
-
-
- -
- {language.t("app.name.desktop")} - v{platform.version} -
-
-
- -
- -
-
- -
- -
-
- -
- -
-
- -
- -
-
- -
- -
-
- -
- -
-
-
-
- ) -} diff --git a/packages/app/src/context/shell-surface.tsx b/packages/app/src/context/shell-surface.tsx index fd811c354..c278b6318 100644 --- a/packages/app/src/context/shell-surface.tsx +++ b/packages/app/src/context/shell-surface.tsx @@ -1,12 +1,12 @@ import { createContext, useContext, type Accessor } from "solid-js" import type { Session } from "@opencode-ai/sdk/v2/client" -import type { SettingsPageTab } from "../components/settings-page" +import type { SettingsTab } from "../pages/settings/settings-shell" export type ShellSurfaceContextValue = { settingsOpen: Accessor openNewSession: (directory?: string) => void openSession: (session: Session | undefined) => void - openSettings: (tab?: SettingsPageTab) => void + openSettings: (tab?: SettingsTab) => void closeSettings: () => void } diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 5fb66c879..f4e5a4926 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -832,7 +832,13 @@ export const dict = { "settings.section.server": "Server", "settings.tab.general": "General", "settings.tab.shortcuts": "Shortcuts", + "settings.tab.models": "Models", + "settings.tab.remoteAccess": "Remote access", + "settings.tab.integrations": "Integrations", "settings.tab.memory": "Memory", + "settings.backToApp": "Back to app", + "settings.remote.placeholder": "Remote access settings will live here.", + "settings.integrations.placeholder": "Integrations settings will live here.", "settings.memory.title": "Memory", "settings.memory.description": "Review what PawWork remembers and edit the raw MEMORY.md file.", "settings.memory.enabled.title": "Enable memory", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index 0c0bc2438..9c1542ca3 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -719,7 +719,13 @@ export const dict = { "settings.tab.general": "通用", "settings.tab.shortcuts": "快捷键", + "settings.tab.models": "模型", + "settings.tab.remoteAccess": "远程访问", + "settings.tab.integrations": "集成", "settings.tab.memory": "记忆", + "settings.backToApp": "返回应用", + "settings.remote.placeholder": "远程访问设置即将在此处提供。", + "settings.integrations.placeholder": "集成设置即将在此处提供。", "settings.memory.title": "记忆", "settings.memory.description": "查看爪印记住了什么,并编辑原始 MEMORY.md 文件。", "settings.memory.enabled.title": "启用记忆", diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index f3d510cb5..b8bbd8570 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -118,7 +118,7 @@ import { type WorkspaceSidebarContext } from "./layout/sidebar-workspace" import { PawworkSidebar, type PawworkSidebarSession } from "./layout/pawwork-sidebar" import { PawworkTitlebar } from "./layout/pawwork-titlebar" import { createDefaultLayoutPageState, createLayoutPagePersistTarget, removePinnedSessionIDs } from "./layout/layout-page-store" -import { SettingsPage, type SettingsPageTab } from "@/components/settings-page" +import { SettingsShell, type SettingsTab } from "@/pages/settings/settings-shell" import { DialogDeleteSession } from "@/components/dialog-delete-session" import { sessionTitle } from "@/utils/session-title" import { sizingStopEvents } from "@/pages/session/helpers" @@ -135,7 +135,7 @@ export default function Layout(props: ParentProps) { let dialogRun = 0 let dialogDead = false const [settingsOpen, setSettingsOpen] = createSignal(false) - const [settingsTab, setSettingsTab] = createSignal("general") + const [settingsTab, setSettingsTab] = createSignal("general") const params = useParams() const location = useLocation() @@ -1511,12 +1511,12 @@ export default function Layout(props: ParentProps) { }) } - function openSettingsSurface(tab?: SettingsPageTab) { + function openSettingsSurface(tab?: SettingsTab) { setSettingsTab(tab ?? "general") setSettingsOpen(true) } - function openSettings(tab?: SettingsPageTab) { + function openSettings(tab?: SettingsTab) { shellNavigation.openSettings(tab) } @@ -2455,7 +2455,7 @@ export default function Layout(props: ParentProps) {
- +
diff --git a/packages/app/src/pages/layout/shell-navigation.ts b/packages/app/src/pages/layout/shell-navigation.ts index 691db2f2b..58b26043d 100644 --- a/packages/app/src/pages/layout/shell-navigation.ts +++ b/packages/app/src/pages/layout/shell-navigation.ts @@ -1,4 +1,4 @@ -import type { SettingsPageTab } from "../../components/settings-page" +import type { SettingsTab } from "../settings/settings-shell" import { newSessionRoute, openSessionRoute } from "./helpers" export type ShellNavigationReleaseReason = "new-session" | "session" | "settings" | "choose-project" @@ -14,7 +14,7 @@ export function createShellNavigation(input: { resolveProjectRoot: (directory: string) => string | undefined currentProjectRoot: () => string | undefined chooseProject: () => void - openSettingsSurface: (tab?: SettingsPageTab) => void + openSettingsSurface: (tab?: SettingsTab) => void closeSettingsSurface: () => void }) { const resolveNewSessionRoot = (directory?: string) => { @@ -41,7 +41,7 @@ export function createShellNavigation(input: { input.navigate(openSessionRoute(session.directory, session.id)) } - const openSettings = (tab?: SettingsPageTab) => { + const openSettings = (tab?: SettingsTab) => { input.releaseTransientLocks("settings") input.openSettingsSurface(tab) } diff --git a/packages/app/src/pages/settings/integrations.tsx b/packages/app/src/pages/settings/integrations.tsx new file mode 100644 index 000000000..50f3ee4a1 --- /dev/null +++ b/packages/app/src/pages/settings/integrations.tsx @@ -0,0 +1,16 @@ +import { type Component } from "solid-js" +import { useLanguage } from "@/context/language" + +// 集成页(MCP / 语言服务器 / 远程服务器 / 插件)。PR1 body 占位。 +// TODO: 把 components/session/session-status-connections.tsx 的内容(服务器/MCP/LSP/插件 4 节 + Manage Servers)搬来, +// 同时删右侧栏 status tab 里的 Connections 区块 → 关 #862。 +// 架构点:该组件用 session-scoped useSync(),搬进全局设置页需先确认数据源(是否换 useGlobalSync 之类),按分工可能要 Codex 把关。 +export const IntegrationsPage: Component = () => { + const language = useLanguage() + return ( +
+

{language.t("settings.tab.integrations")}

+

{language.t("settings.integrations.placeholder")}

+
+ ) +} diff --git a/packages/app/src/pages/settings/models.tsx b/packages/app/src/pages/settings/models.tsx new file mode 100644 index 000000000..cd183e7d2 --- /dev/null +++ b/packages/app/src/pages/settings/models.tsx @@ -0,0 +1,16 @@ +import { type Component } from "solid-js" +import { SettingsProviders } from "@/components/settings-providers" +import { SettingsModels } from "@/components/settings-models" + +// 模型页(合并 提供商 + 模型,菜单显示「模型」/Models)。 +// PR1 body 第一版:复用现有 SettingsProviders + SettingsModels 堆叠,功能对等先 ship。 +// 后续按 docs/design/preview/settings-ai.html 的 master-detail 重写(左提供商列表 + 右模型列表 + 可见性开关), +// 复用 context/models.tsx 的 visible()/setVisibility(),不重造可见性规则。 +export const ModelsPage: Component = () => { + return ( +
+ + +
+ ) +} diff --git a/packages/app/src/pages/settings/remote.tsx b/packages/app/src/pages/settings/remote.tsx new file mode 100644 index 000000000..d19283344 --- /dev/null +++ b/packages/app/src/pages/settings/remote.tsx @@ -0,0 +1,14 @@ +import { type Component } from "solid-js" +import { useLanguage } from "@/context/language" + +// 远程访问页。PR1 body 占位:保证菜单点了不空、整体可上线。 +// 后续迁入远程服务器连接 / 管理(原 Connections 的服务器部分 + Manage Servers)真功能。 +export const RemotePage: Component = () => { + const language = useLanguage() + return ( +
+

{language.t("settings.tab.remoteAccess")}

+

{language.t("settings.remote.placeholder")}

+
+ ) +} diff --git a/packages/app/src/pages/settings/settings-shell.tsx b/packages/app/src/pages/settings/settings-shell.tsx new file mode 100644 index 000000000..73c0e1243 --- /dev/null +++ b/packages/app/src/pages/settings/settings-shell.tsx @@ -0,0 +1,211 @@ +import { type Component, onCleanup, onMount } from "solid-js" +import { Button } from "@opencode-ai/ui/button" +import { Icon } from "@opencode-ai/ui/icon" +import { Tabs } from "@opencode-ai/ui/tabs" +import { useLanguage } from "@/context/language" +import { usePlatform } from "@/context/platform" +import { SettingsGeneral } from "@/components/settings-general" +import { SettingsKeybinds } from "@/components/settings-keybinds" +import { SettingsMemory } from "@/components/settings-memory" +import { SettingsWorktrees } from "@/components/settings-worktrees" +import { ModelsPage } from "./models" +// 远程访问 / 集成:页面内容就绪前先不在 nav 露出(点进去只有占位,体验是空的)。 +// 这俩要承接的连接管理目前仍在右侧栏 Connections 可用,功能不丢。文件保留待后续 PR 填充后放出。 +// import { RemotePage } from "./remote" +// import { IntegrationsPage } from "./integrations" + +// 两层 takeover 设置外壳:240 左 nav(扁平 7 项 + 返回应用行 + 版本 foot)+ 右内容。 +// 替换旧 SettingsPage(components/settings-page.tsx);旧 6 tab(含分开的 providers/models)→ 7 项,providers+models 合并为 models(显示「模型」)。 +// 形态真值 docs/design/preview/settings-shell.{css,js} + settings-{general,ai,int}.html。 +export type SettingsTab = "general" | "shortcuts" | "models" | "remote" | "integrations" | "worktrees" | "memory" + +const TAB_VALUES: SettingsTab[] = ["general", "shortcuts", "models", "remote", "integrations", "worktrees", "memory"] + +function isSettingsTab(value: string): value is SettingsTab { + return (TAB_VALUES as string[]).includes(value) +} + +const FOCUSABLE_SELECTOR = + 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])' + +function focusablesIn(root: HTMLElement): HTMLElement[] { + return Array.from(root.querySelectorAll(FOCUSABLE_SELECTOR)).filter( + (el) => el.offsetParent !== null || el === document.activeElement, + ) +} + +export const SettingsShell: Component<{ + active: SettingsTab + directory?: string + onSelect: (value: SettingsTab) => void + onClose: () => void +}> = (props) => { + const language = useLanguage() + const platform = usePlatform() + let root: HTMLElement | undefined + let returnFocus: HTMLElement | undefined + + onMount(() => { + const active = document.activeElement + if (active instanceof HTMLElement && !root?.contains(active)) returnFocus = active + if (!root) return + const [first] = focusablesIn(root) + first?.focus() + + // Escape 关闭设置:挂 document 而非靠 section 焦点冒泡(打开瞬间焦点未必落在壳内)。 + // 设置内若开着更上层 dialog(如连接服务商),让它先吃 Escape,不连带关掉整个设置。 + const onEscape = (event: KeyboardEvent) => { + if (event.key !== "Escape") return + // 设置内若开着 dialog(连接服务商等),让它先吃 Escape,不连带关设置 + if (document.querySelector('[data-component="dialog-overlay"]')) return + event.preventDefault() + props.onClose() + } + // capture 阶段:抢在全局 keybind/command 消费并 preventDefault 之前收到 Escape + document.addEventListener("keydown", onEscape, true) + onCleanup(() => document.removeEventListener("keydown", onEscape, true)) + }) + + onCleanup(() => { + const target = returnFocus + returnFocus = undefined + if (!target || !target.isConnected) return + target.focus() + }) + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented || !root) return + if (event.key !== "Tab") return + const focusables = focusablesIn(root) + if (focusables.length === 0) return + const first = focusables[0] + const last = focusables[focusables.length - 1] + const active = document.activeElement as HTMLElement | null + const inside = !!active && root.contains(active) + + if (event.shiftKey) { + if (!inside || active === first) { + event.preventDefault() + last.focus() + } + } else if (!inside || active === last) { + event.preventDefault() + first.focus() + } + } + + return ( +
(root = el)} + data-component="settings-page" + aria-label={language.t("sidebar.settings")} + class="flex size-full min-h-0 bg-bg-base" + onKeyDown={handleKeyDown} + > + { + if (!isSettingsTab(value)) return + props.onSelect(value) + }} + class="h-full w-full" + > + +
+
+ +
+
+ + + {language.t("settings.tab.general")} + + + + {language.t("settings.tab.shortcuts")} + + + + {language.t("settings.tab.models")} + + {/* 远程访问 / 集成页就绪前先不露出(见顶部 import 注释) + + + {language.t("settings.tab.remoteAccess")} + + + + {language.t("settings.tab.integrations")} + + */} + + + {language.t("settings.tab.worktrees")} + + + + {language.t("settings.tab.memory")} + +
+
+ +
+ {language.t("app.name.desktop")} + v{platform.version} +
+
+ + + +
+ +
+
+ +
+ +
+
+ +
+ +
+
+ {/* 远程访问 / 集成页就绪前先不露出(见顶部 import 注释) + +
+ +
+
+ +
+ +
+
+ */} + +
+ +
+
+ +
+ +
+
+ +
+ ) +} From ce13400176e40a186390b8c00884b17b9c9ff99a Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Wed, 27 May 2026 19:04:26 +0800 Subject: [PATCH 02/14] test(settings): drop dead sound-settings snapshot and selectors The notification and sound controls were collapsed into a single notify tri-state in #923, so the settings-sounds snapshot has no target and the six data-action selectors it relied on are unused. Remove both. Part of #604. --- packages/app/e2e/selectors.ts | 6 ---- packages/app/e2e/snap/settings-sounds.snap.ts | 32 ------------------- 2 files changed, 38 deletions(-) delete mode 100644 packages/app/e2e/snap/settings-sounds.snap.ts diff --git a/packages/app/e2e/selectors.ts b/packages/app/e2e/selectors.ts index 3fa16dbdb..ca4b373ce 100644 --- a/packages/app/e2e/selectors.ts +++ b/packages/app/e2e/selectors.ts @@ -30,12 +30,6 @@ export const settingsColorSchemeSelector = '[data-action="settings-color-scheme" export const settingsThemeSelector = '[data-action="settings-theme"]' export const settingsCodeFontSelector = '[data-action="settings-code-font"]' export const settingsUIFontSelector = '[data-action="settings-ui-font"]' -export const settingsNotificationsAgentSelector = '[data-action="settings-notifications-agent"]' -export const settingsNotificationsPermissionsSelector = '[data-action="settings-notifications-permissions"]' -export const settingsNotificationsErrorsSelector = '[data-action="settings-notifications-errors"]' -export const settingsSoundsAgentSelector = '[data-action="settings-sounds-agent"]' -export const settingsSoundsPermissionsSelector = '[data-action="settings-sounds-permissions"]' -export const settingsSoundsErrorsSelector = '[data-action="settings-sounds-errors"]' export const settingsUpdatesStartupSelector = '[data-action="settings-updates-startup"]' export const settingsReleaseNotesSelector = '[data-action="settings-release-notes"]' export const desktopShellSelector = '[data-component="desktop-shell"]' diff --git a/packages/app/e2e/snap/settings-sounds.snap.ts b/packages/app/e2e/snap/settings-sounds.snap.ts deleted file mode 100644 index 85dabbc3f..000000000 --- a/packages/app/e2e/snap/settings-sounds.snap.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { test } from "../fixtures" -import { openSettings } from "../actions" -import { settingsSoundsAgentSelector } from "../selectors" -import { composeGrid, snapOutputPath, type Shot } from "./_compose" - -test.use({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 2 }) - -test("settings-sounds", async ({ page, project }) => { - test.setTimeout(180_000) - - await project.open() - - const settings = await openSettings(page) - const select = settings.locator(settingsSoundsAgentSelector) - await select.scrollIntoViewIfNeeded() - await select.waitFor({ state: "visible", timeout: 30_000 }) - - const shots: Shot[] = [{ name: "section", buf: await settings.screenshot() }] - - // Open the agent-sound dropdown to show the reduced option set - // (None / Notification / Error) rendered in the portal. - await select.locator('[data-slot="select-select-trigger"]').click() - await page - .locator('[data-slot="select-select-item"]') - .first() - .waitFor({ state: "visible", timeout: 10_000 }) - shots.push({ name: "agent-options", buf: await page.screenshot() }) - - const out = snapOutputPath("settings-sounds") - await composeGrid(shots, out) - process.stdout.write(`\n[snap] settings-sounds grid -> ${out}\n\n`) -}) From 6dbd39bcbd967e534e2be16534676d6a76177825 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Wed, 27 May 2026 21:16:50 +0800 Subject: [PATCH 03/14] chore(settings): translate settings page comments to English --- packages/app/src/components/settings-memory.test.ts | 3 ++- packages/app/src/pages/settings/integrations.tsx | 10 ++++++---- packages/app/src/pages/settings/models.tsx | 9 +++++---- packages/app/src/pages/settings/remote.tsx | 5 +++-- 4 files changed, 16 insertions(+), 11 deletions(-) diff --git a/packages/app/src/components/settings-memory.test.ts b/packages/app/src/components/settings-memory.test.ts index f5c4cedca..74ce77d3c 100644 --- a/packages/app/src/components/settings-memory.test.ts +++ b/packages/app/src/components/settings-memory.test.ts @@ -3,7 +3,8 @@ import { readFileSync } from "node:fs" describe("memory settings source contract", () => { test("registers the Memory settings tab", () => { - // 新壳 settings-shell.tsx 取代旧 settings-page.tsx 注册各 tab;读新壳否则测的是已删的死代码。 + // settings-shell.tsx replaces the old settings-page.tsx as where tabs are registered; read the new + // shell, otherwise this asserts against deleted dead code. const shell = readFileSync("src/pages/settings/settings-shell.tsx", "utf8") expect(shell).toContain('"memory"') diff --git a/packages/app/src/pages/settings/integrations.tsx b/packages/app/src/pages/settings/integrations.tsx index 50f3ee4a1..7c3ebd4f7 100644 --- a/packages/app/src/pages/settings/integrations.tsx +++ b/packages/app/src/pages/settings/integrations.tsx @@ -1,10 +1,12 @@ import { type Component } from "solid-js" import { useLanguage } from "@/context/language" -// 集成页(MCP / 语言服务器 / 远程服务器 / 插件)。PR1 body 占位。 -// TODO: 把 components/session/session-status-connections.tsx 的内容(服务器/MCP/LSP/插件 4 节 + Manage Servers)搬来, -// 同时删右侧栏 status tab 里的 Connections 区块 → 关 #862。 -// 架构点:该组件用 session-scoped useSync(),搬进全局设置页需先确认数据源(是否换 useGlobalSync 之类),按分工可能要 Codex 把关。 +// Integrations page (MCP / language servers / remote servers / plugins). PR1 body is a placeholder. +// TODO: move the content of components/session/session-status-connections.tsx (servers / MCP / LSP / +// plugins sections + Manage Servers) here, and drop the Connections block from the right-panel status +// tab -> closes #862. +// Architecture note: that component uses a session-scoped useSync(); moving it into the global settings +// page needs the data source settled first (e.g. switching to a useGlobalSync), likely worth a Codex review. export const IntegrationsPage: Component = () => { const language = useLanguage() return ( diff --git a/packages/app/src/pages/settings/models.tsx b/packages/app/src/pages/settings/models.tsx index cd183e7d2..ed1547566 100644 --- a/packages/app/src/pages/settings/models.tsx +++ b/packages/app/src/pages/settings/models.tsx @@ -2,10 +2,11 @@ import { type Component } from "solid-js" import { SettingsProviders } from "@/components/settings-providers" import { SettingsModels } from "@/components/settings-models" -// 模型页(合并 提供商 + 模型,菜单显示「模型」/Models)。 -// PR1 body 第一版:复用现有 SettingsProviders + SettingsModels 堆叠,功能对等先 ship。 -// 后续按 docs/design/preview/settings-ai.html 的 master-detail 重写(左提供商列表 + 右模型列表 + 可见性开关), -// 复用 context/models.tsx 的 visible()/setVisibility(),不重造可见性规则。 +// Models page (providers + models merged, shown as "Models" in the nav). +// First PR1 body: reuse the existing SettingsProviders + SettingsModels stacked, shipping at parity. +// Later rewrite as the master-detail in docs/design/preview/settings-ai.html (provider list on the +// left + model list on the right + visibility toggles), reusing context/models.tsx visible() / +// setVisibility() instead of re-inventing the visibility rules. export const ModelsPage: Component = () => { return (
diff --git a/packages/app/src/pages/settings/remote.tsx b/packages/app/src/pages/settings/remote.tsx index d19283344..f28aee6b2 100644 --- a/packages/app/src/pages/settings/remote.tsx +++ b/packages/app/src/pages/settings/remote.tsx @@ -1,8 +1,9 @@ import { type Component } from "solid-js" import { useLanguage } from "@/context/language" -// 远程访问页。PR1 body 占位:保证菜单点了不空、整体可上线。 -// 后续迁入远程服务器连接 / 管理(原 Connections 的服务器部分 + Manage Servers)真功能。 +// Remote access page. PR1 body is a placeholder so the nav entry is not empty and the whole thing ships. +// Later it gains the real remote-server connect / manage feature (the server part of the old Connections +// + Manage Servers). export const RemotePage: Component = () => { const language = useLanguage() return ( From 144d21097d30c3b2174f0cef137b907aae29c828 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Wed, 27 May 2026 21:17:05 +0800 Subject: [PATCH 04/14] style(sidebar): unify nav row icon-to-label gap (gap-2 -> gap-3) --- packages/app/src/pages/layout/pawwork-sidebar.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/app/src/pages/layout/pawwork-sidebar.tsx b/packages/app/src/pages/layout/pawwork-sidebar.tsx index 269eeb542..8169585f6 100644 --- a/packages/app/src/pages/layout/pawwork-sidebar.tsx +++ b/packages/app/src/pages/layout/pawwork-sidebar.tsx @@ -57,7 +57,7 @@ function ProjectGroupHeader(props: { data-collapsed={props.collapsed ? "true" : undefined} aria-expanded={!props.collapsed} onClick={props.onToggle} - class="min-w-0 h-full flex-1 flex items-center gap-2 px-2.5 text-left focus:outline-none" + class="min-w-0 h-full flex-1 flex items-center gap-3 px-2.5 text-left focus:outline-none" > {props.label} @@ -461,7 +461,7 @@ export const PawworkSidebar = (props: { type="button" data-action="pawwork-session-new" onClick={props.onNew} - class="w-full h-[30px] flex items-center gap-2 px-2.5 rounded-md hover:bg-row-hover-overlay focus-visible:bg-row-hover-overlay transition-colors text-left focus:outline-none" + class="w-full h-[30px] flex items-center gap-3 px-2.5 rounded-md hover:bg-row-hover-overlay focus-visible:bg-row-hover-overlay transition-colors text-left focus:outline-none" > @@ -478,7 +478,7 @@ export const PawworkSidebar = (props: { type="button" data-action="pawwork-session-search" onClick={props.onSearch} - class="w-full h-[30px] flex items-center gap-2 px-2.5 rounded-md hover:bg-row-hover-overlay focus-visible:bg-row-hover-overlay transition-colors text-left focus:outline-none" + class="w-full h-[30px] flex items-center gap-3 px-2.5 rounded-md hover:bg-row-hover-overlay focus-visible:bg-row-hover-overlay transition-colors text-left focus:outline-none" > @@ -693,7 +693,7 @@ export const PawworkSidebar = (props: { data-action="pawwork-open-settings" onClick={props.onOpenSettings} aria-label={props.settingsLabel()} - class="w-full h-[30px] flex items-center gap-2 px-2.5 rounded-md hover:bg-row-hover-overlay focus-visible:bg-row-hover-overlay transition-colors text-left focus:outline-none" + class="w-full h-[30px] flex items-center gap-3 px-2.5 rounded-md hover:bg-row-hover-overlay focus-visible:bg-row-hover-overlay transition-colors text-left focus:outline-none" > From 0af05f95cd6d4c5898c456e63d0d333b2f07b458 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Wed, 27 May 2026 21:17:36 +0800 Subject: [PATCH 05/14] feat(settings): take over shell slots instead of overlaying Settings now renders into the existing shell slots: the nav fills the sidebar slot and the page fills the main slot, inheriting their width/background/border so there is no alignment drift with the session layout. The session sidebar and main subtree stay mounted but hidden (inert + aria-hidden + invisible) to preserve terminal/scroll state. - Drop Kobalte Tabs (cannot span two slots); hand-roll role=tablist with roving tabindex + arrow/home/end keys and a shared active signal. - Fix settings opening blank: the sidebar button forwarded its click event as the tab arg; openSettingsSurface now validates the tab and the wiring drops the event. - Settings content canvas is bg-base; update the 4 sticky-header gradients from surface-raised to bg-base to match. - Align nav tabs to the session row visuals (px-2, gap-0.5 row gap, row-hover / row-active overlays). - Update e2e helpers/specs and snapshot; remove the close-on-nav spec (path removed by the takeover; covered by the shell-navigation unit test). --- packages/app/e2e/actions.ts | 45 ++- .../settings/settings-close-on-nav.spec.ts | 26 -- .../app/e2e/settings/settings-shell.spec.ts | 24 +- packages/app/e2e/settings/settings.spec.ts | 13 +- packages/app/e2e/snap/settings-shell.snap.ts | 6 +- .../app/src/components/settings-general.tsx | 2 +- .../app/src/components/settings-keybinds.tsx | 2 +- .../app/src/components/settings-models.tsx | 2 +- .../app/src/components/settings-providers.tsx | 2 +- packages/app/src/pages/layout.tsx | 79 +++-- .../app/src/pages/settings/settings-shell.tsx | 308 +++++++++--------- 11 files changed, 251 insertions(+), 258 deletions(-) delete mode 100644 packages/app/e2e/settings/settings-close-on-nav.spec.ts diff --git a/packages/app/e2e/actions.ts b/packages/app/e2e/actions.ts index 22add5bde..9b9528cd0 100644 --- a/packages/app/e2e/actions.ts +++ b/packages/app/e2e/actions.ts @@ -222,38 +222,31 @@ export async function closeDialog(page: Page, dialog: Locator) { } export async function closeSettingsPanel(page: Page, panel: Locator) { - const isSettingsPage = await panel - .evaluate((element) => element instanceof HTMLElement && element.dataset.component === "settings-page") - .catch(() => false) - - if (!isSettingsPage) { + // Detect settings independently of the passed panel: key on settings-page (only present while + // the settings content is mounted). openSettings now returns the shell-content ancestor, which + // also exists in the session view and never detaches. + const settingsPage = page.locator('[data-component="settings-page"]') + if ((await settingsPage.count()) === 0) { await closeDialog(page, panel) return } - const waitClosed = () => - panel - .waitFor({ state: "hidden", timeout: 1500 }) - .then(() => true) - .catch(() => - panel - .waitFor({ state: "detached", timeout: 1500 }) - .then(() => true) - .catch(() => false), - ) - - const closeButton = panel.getByRole("button", { name: /close/i }).first() - if ((await closeButton.count()) > 0) { - await closeButton.click() + // The settings shell has no close button; use Back to app or Escape. + const back = page.locator('[data-action="settings-back"]') + if ((await back.count()) > 0) { + await back.first().click() } else { await page.keyboard.press("Escape") } - const closed = await waitClosed() + const closed = await settingsPage + .waitFor({ state: "detached", timeout: 1500 }) + .then(() => true) + .catch(() => false) if (closed) return await page.keyboard.press("Escape") - await expect(panel).toBeHidden() + await expect(settingsPage).toHaveCount(0) } async function isSidebarClosed(page: Page) { @@ -368,6 +361,12 @@ export async function openSettings(page: Page) { const dialog = page.getByRole("dialog") const settingsPage = page.locator('[data-component="settings-page"]') + // Settings is a shell-slot takeover: the nav lives in the sidebar slot and the content in + // the main slot, in two separate DOM subtrees. Return their common ancestor (shell-content) + // so callers can query both the nav (getByRole("tab")) and the content. Detection still keys + // on settings-page (only rendered while settings is open); the underlying session content is + // inert/aria-hidden and so is ignored by getByRole. + const settingsSurface = page.locator('[data-component="shell-content"]') await page.keyboard.press(`${modKey}+Comma`).catch(() => undefined) const pageOpened = await settingsPage @@ -375,7 +374,7 @@ export async function openSettings(page: Page) { .then(() => true) .catch(() => false) - if (pageOpened) return settingsPage + if (pageOpened) return settingsSurface const opened = await dialog .waitFor({ state: "visible", timeout: 3000 }) @@ -392,7 +391,7 @@ export async function openSettings(page: Page) { .then(() => true) .catch(() => false) - if (pageOpenedFromClick) return settingsPage + if (pageOpenedFromClick) return settingsSurface await expect(dialog).toBeVisible() return dialog diff --git a/packages/app/e2e/settings/settings-close-on-nav.spec.ts b/packages/app/e2e/settings/settings-close-on-nav.spec.ts deleted file mode 100644 index a139c3a73..000000000 --- a/packages/app/e2e/settings/settings-close-on-nav.spec.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { test, expect } from "../fixtures" -import { openSettings, openSidebar, withSession } from "../actions" -import { pawworkSidebarSelector } from "../selectors" - -test("clicking a sidebar session closes the settings overlay and navigates", async ({ - page, - sdk, - gotoSession, -}) => { - const stamp = Date.now() - await withSession(sdk, `settings-nav a ${stamp}`, async (a) => { - await withSession(sdk, `settings-nav b ${stamp}`, async (b) => { - await gotoSession(a.id) - await openSidebar(page) - - const settings = await openSettings(page) - await expect(settings).toBeVisible() - - const sidebar = page.locator(pawworkSidebarSelector).first() - await sidebar.locator(`[data-session-id="${b.id}"]`).first().click() - - await expect(settings).toBeHidden() - await expect.poll(() => page.url()).toContain(b.id) - }) - }) -}) diff --git a/packages/app/e2e/settings/settings-shell.spec.ts b/packages/app/e2e/settings/settings-shell.spec.ts index c46d95dad..63654c47a 100644 --- a/packages/app/e2e/settings/settings-shell.spec.ts +++ b/packages/app/e2e/settings/settings-shell.spec.ts @@ -1,26 +1,34 @@ import { test, expect } from "../fixtures" import { closeSettingsPanel, openSettings } from "../actions" -// PR1 地基行为锁:两层 takeover 壳 + 扁平 nav,平移挂载 6 个现有页(远程/集成就绪前隐藏)。 +// PR1 foundation lock: shell-slot takeover (nav in the sidebar slot, content in the main slot), +// migrating the existing pages in place (remote / integrations hidden until ready). test("settings shell shows the migrated nav and switches pages", async ({ page, gotoSession }) => { await gotoSession() const settings = await openSettings(page) - // nav 当前 5 项:通用 / 快捷键 / 模型 / 工作树 / 记忆 + // The nav takes over the sidebar slot rather than painting its own column. + await expect(page.locator('[data-component="sidebar-nav-desktop"] [data-component="settings-nav"]')).toBeVisible() + + // On open it lands on General (selected + content shown) without needing a manual click. + await expect(settings.getByRole("tab", { name: "General" })).toHaveAttribute("aria-selected", "true") + await expect(settings.locator('[data-action="settings-language"]')).toBeVisible() + + // Currently 5 tabs: General / Shortcuts / Models / Worktrees / Memory for (const name of ["General", "Shortcuts", "Models", "Worktrees", "Memory"]) { await expect(settings.getByRole("tab", { name })).toBeVisible() } - // 远程访问 / 集成页就绪前不露出 + // Remote access / Integrations stay hidden until their pages are ready await expect(settings.getByRole("tab", { name: "Remote access" })).toHaveCount(0) await expect(settings.getByRole("tab", { name: "Integrations" })).toHaveCount(0) - // 模型页 = 提供商 + 模型 堆叠复用:两块内容都在 + // Models page = providers + models stacked: both blocks render await settings.getByRole("tab", { name: "Models" }).click() await expect(settings.locator('[data-component="custom-provider-section"]')).toBeVisible() await expect(settings.getByPlaceholder("Search models")).toBeVisible() - // 切到记忆页:模型页内容消失,证明内容随 nav 切换 + // Switch to Memory: the models content disappears, proving content follows the nav await settings.getByRole("tab", { name: "Memory" }).click() await expect(settings.locator('[data-component="custom-provider-section"]')).toHaveCount(0) @@ -30,8 +38,8 @@ test("settings shell shows the migrated nav and switches pages", async ({ page, test("escape closes the settings shell", async ({ page, gotoSession }) => { await gotoSession() - const settings = await openSettings(page) - await expect(settings).toBeVisible() + await openSettings(page) + await expect(page.locator('[data-component="settings-page"]')).toBeVisible() await page.keyboard.press("Escape") await expect(page.locator('[data-component="settings-page"]')).toHaveCount(0) @@ -41,7 +49,7 @@ test("back-to-app button closes the settings shell", async ({ page, gotoSession await gotoSession() const settings = await openSettings(page) - await expect(settings).toBeVisible() + await expect(page.locator('[data-component="settings-page"]')).toBeVisible() await settings.getByRole("button", { name: "Back to app" }).click() await expect(page.locator('[data-component="settings-page"]')).toHaveCount(0) diff --git a/packages/app/e2e/settings/settings.spec.ts b/packages/app/e2e/settings/settings.spec.ts index e29fc3a48..e87c7335f 100644 --- a/packages/app/e2e/settings/settings.spec.ts +++ b/packages/app/e2e/settings/settings.spec.ts @@ -40,7 +40,8 @@ test('@smoke PawWork settings opens as a full-pane surface, not a dialog', async await expect(page.locator('[data-component="settings-page"]')).toBeVisible() await expect(page.locator('[data-component="dialog-overlay"]')).toHaveCount(0) - // 新壳把标题挪到标题栏(PawworkTitlebar),页面内不再有 h1;壳子 section 带 aria-label 提供无障碍名。 + // The new shell moves the title into the titlebar (PawworkTitlebar); there is no in-page h1 anymore, + // so the shell section carries an aria-label to provide the accessible name. await expect(page.getByRole("region", { name: "Settings" })).toBeVisible() await expect(page.locator(titlebarCenterSelector)).toContainText("Settings") }) @@ -100,8 +101,9 @@ test("unknown theme ids migrate to pawwork and clear cached css", async ({ page, }) .toBe("pawwork") - // 迁移到 pawwork 后,属于旧主题(dracula)的缓存 CSS 不能再生效:preload 清空、 - // 运行时 ThemeProvider 写入 pawwork 真实 CSS 覆盖。断言旧假值不再残留即可。 + // After migrating to pawwork, cached CSS from the old theme (dracula) must no longer apply: the + // preload clears it and the runtime ThemeProvider writes the real pawwork CSS over it. Just assert + // the old fake values no longer linger. await expect .poll(async () => { return await page.evaluate(() => localStorage.getItem("pawwork-theme-css-light")) @@ -434,8 +436,9 @@ test("code font and UI font rehydrate after reload", async ({ page, gotoSession }) test("changing notification level persists in localStorage", async ({ page, gotoSession }) => { - // #923 把多个通知开关 + 音效选择合并成单个 tri-state(never / unfocused / always); - // 旧的 settings-notifications-* / settings-sounds-* 控件已删,这里测合并后的单控件。 + // #923 merged the multiple notification toggles + sound selectors into a single tri-state + // (never / unfocused / always); the old settings-notifications-* / settings-sounds-* controls were + // removed, so this tests the merged single control. await gotoSession() const dialog = await openSettings(page) diff --git a/packages/app/e2e/snap/settings-shell.snap.ts b/packages/app/e2e/snap/settings-shell.snap.ts index 01ee7c994..4c2da01af 100644 --- a/packages/app/e2e/snap/settings-shell.snap.ts +++ b/packages/app/e2e/snap/settings-shell.snap.ts @@ -4,9 +4,9 @@ import { composeGrid, snapOutputPath, type Shot } from "./_compose" test.use({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 2 }) -// 验收 PR1 地基:两层 takeover 外壳 + 240 左 nav(返回应用 + 5 项 + 版本 foot)。 -// nav 当前 5 项:通用/快捷键/模型/工作树/记忆(远程访问、集成页就绪前不露出)。 -// 截 3 张:通用(默认)/ 模型(合并提供商+模型)/ 记忆(演示切到另一类页正常)。 +// Review the PR1 foundation: shell-slot takeover + left nav (back-to-app + 5 tabs + version footer). +// Currently 5 tabs: General / Shortcuts / Models / Worktrees / Memory (remote access, integrations hidden until ready). +// Capture 3 shots: General (default) / Models (providers + models merged) / Memory (shows switching to another page works). test("settings-shell", async ({ page, project }) => { test.setTimeout(180_000) diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index 66f31e5c7..23d14ee0d 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -283,7 +283,7 @@ export const SettingsGeneral: Component = () => { return (
-
+

{language.t("settings.tab.general")}

diff --git a/packages/app/src/components/settings-keybinds.tsx b/packages/app/src/components/settings-keybinds.tsx index 67d129120..1b42e62e6 100644 --- a/packages/app/src/components/settings-keybinds.tsx +++ b/packages/app/src/components/settings-keybinds.tsx @@ -373,7 +373,7 @@ export const SettingsKeybinds: Component = () => { return (
-
+

{language.t("settings.shortcuts.title")}

diff --git a/packages/app/src/components/settings-models.tsx b/packages/app/src/components/settings-models.tsx index 83e873068..2c58ebfd3 100644 --- a/packages/app/src/components/settings-models.tsx +++ b/packages/app/src/components/settings-models.tsx @@ -60,7 +60,7 @@ export const SettingsModels: Component = () => { return (
-
+

{language.t("settings.models.title")}

diff --git a/packages/app/src/components/settings-providers.tsx b/packages/app/src/components/settings-providers.tsx index 72c59388c..4b62cce02 100644 --- a/packages/app/src/components/settings-providers.tsx +++ b/packages/app/src/components/settings-providers.tsx @@ -140,7 +140,7 @@ export const SettingsProviders: Component = () => { return (
-
+

{language.t("settings.providers.title")}

diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index b8bbd8570..8c19fddcd 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -118,7 +118,7 @@ import { type WorkspaceSidebarContext } from "./layout/sidebar-workspace" import { PawworkSidebar, type PawworkSidebarSession } from "./layout/pawwork-sidebar" import { PawworkTitlebar } from "./layout/pawwork-titlebar" import { createDefaultLayoutPageState, createLayoutPagePersistTarget, removePinnedSessionIDs } from "./layout/layout-page-store" -import { SettingsShell, type SettingsTab } from "@/pages/settings/settings-shell" +import { SettingsContent, SettingsNav, isSettingsTab, type SettingsTab } from "@/pages/settings/settings-shell" import { DialogDeleteSession } from "@/components/dialog-delete-session" import { sessionTitle } from "@/utils/session-title" import { sizingStopEvents } from "@/pages/session/helpers" @@ -1512,7 +1512,10 @@ export default function Layout(props: ParentProps) { } function openSettingsSurface(tab?: SettingsTab) { - setSettingsTab(tab ?? "general") + // Guard against callers that forward a DOM event (e.g. an onClick handler) + // as the tab argument — only a known tab string selects a page, anything + // else falls back to General. + setSettingsTab(typeof tab === "string" && isSettingsTab(tab) ? tab : "general") setSettingsOpen(true) } @@ -2333,7 +2336,7 @@ export default function Layout(props: ParentProps) { onNew={() => openPawworkHome(options?.directory)} onSearch={() => command.show()} onOpenProject={chooseProject} - onOpenSettings={openSettings} + onOpenSettings={() => openSettings()} settingsLabel={() => language.t("sidebar.settings")} settingsKeybind={() => command.keybind("settings.open")} newSessionKeybind={() => command.keybind("session.new")} @@ -2375,7 +2378,7 @@ export default function Layout(props: ParentProps) { isMacShell(platform) ? `calc(var(--shell-titlebar-height, 44px) / ${platform.webviewZoom?.() ?? 1})` : "var(--shell-titlebar-height, 44px)", - "--sidebar-width": layout.sidebar.opened() ? `${side()}px` : "0px", + "--sidebar-width": layout.sidebar.opened() || settingsOpen() ? `${side()}px` : "0px", "--right-panel-width": layout.rightPanel.opened() ? `${layout.rightPanel.width()}px` : "0px", "--right-panel-divider": layout.rightPanel.opened() ? "var(--border-weaker)" : "transparent", }} @@ -2390,8 +2393,8 @@ export default function Layout(props: ParentProps) { language.t("sidebar.settings")} />
-
- +
+ -
setState("sizing", true)} - > - { - setState("sizing", true) - if (sizet !== undefined) clearTimeout(sizet) - sizet = window.setTimeout(() => setState("sizing", false), 120) - layout.sidebar.resize(w) - }} - /> -
+ {/* Hide the resize handle while settings is open: the sidebar width is not draggable inside settings. */} + +
setState("sizing", true)} + > + { + setState("sizing", true) + if (sizet !== undefined) clearTimeout(sizet) + sizet = window.setTimeout(() => setState("sizing", false), 120) + layout.sidebar.resize(w) + }} + /> +
+
}> {props.children}
+ {/* Settings takeover: the content overlays the session page (kept mounted, only inert, so the terminal/right panel are not torn down); geometry is inherited from the main slot. */} -
- +
+
diff --git a/packages/app/src/pages/settings/settings-shell.tsx b/packages/app/src/pages/settings/settings-shell.tsx index 73c0e1243..5bc8bc534 100644 --- a/packages/app/src/pages/settings/settings-shell.tsx +++ b/packages/app/src/pages/settings/settings-shell.tsx @@ -1,7 +1,6 @@ -import { type Component, onCleanup, onMount } from "solid-js" +import { type Component, For, Match, Switch, onCleanup, onMount } from "solid-js" import { Button } from "@opencode-ai/ui/button" import { Icon } from "@opencode-ai/ui/icon" -import { Tabs } from "@opencode-ai/ui/tabs" import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" import { SettingsGeneral } from "@/components/settings-general" @@ -9,203 +8,194 @@ import { SettingsKeybinds } from "@/components/settings-keybinds" import { SettingsMemory } from "@/components/settings-memory" import { SettingsWorktrees } from "@/components/settings-worktrees" import { ModelsPage } from "./models" -// 远程访问 / 集成:页面内容就绪前先不在 nav 露出(点进去只有占位,体验是空的)。 -// 这俩要承接的连接管理目前仍在右侧栏 Connections 可用,功能不丢。文件保留待后续 PR 填充后放出。 -// import { RemotePage } from "./remote" -// import { IntegrationsPage } from "./integrations" -// 两层 takeover 设置外壳:240 左 nav(扁平 7 项 + 返回应用行 + 版本 foot)+ 右内容。 -// 替换旧 SettingsPage(components/settings-page.tsx);旧 6 tab(含分开的 providers/models)→ 7 项,providers+models 合并为 models(显示「模型」)。 -// 形态真值 docs/design/preview/settings-shell.{css,js} + settings-{general,ai,int}.html。 +// Settings renders as a shell-slot takeover: the nav goes into the sidebar slot +// (SettingsNav) and the page into the main slot (SettingsContent). Geometry +// (width / background / border) is inherited from the shell slots instead of being +// re-declared, which removes the alignment drift the old standalone overlay had +// (its fixed 200px nav + surface-raised content diverged from the real sidebar). +// Remote access / Integrations stay hidden from the nav until their pages are ready; +// the connection management they will host is still reachable via right-panel Connections. export type SettingsTab = "general" | "shortcuts" | "models" | "remote" | "integrations" | "worktrees" | "memory" const TAB_VALUES: SettingsTab[] = ["general", "shortcuts", "models", "remote", "integrations", "worktrees", "memory"] -function isSettingsTab(value: string): value is SettingsTab { +export function isSettingsTab(value: string): value is SettingsTab { return (TAB_VALUES as string[]).includes(value) } -const FOCUSABLE_SELECTOR = - 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])' +// Currently 5 visible tabs; remote / integrations omitted until ready (see above). +const NAV_ITEMS = [ + { value: "general", icon: "settings-gear", labelKey: "settings.tab.general" }, + { value: "shortcuts", icon: "keyboard", labelKey: "settings.tab.shortcuts" }, + { value: "models", icon: "models", labelKey: "settings.tab.models" }, + { value: "worktrees", icon: "worktree", labelKey: "settings.tab.worktrees" }, + { value: "memory", icon: "brain", labelKey: "settings.tab.memory" }, +] as const satisfies ReadonlyArray<{ value: SettingsTab; icon: string; labelKey: string }> -function focusablesIn(root: HTMLElement): HTMLElement[] { - return Array.from(root.querySelectorAll(FOCUSABLE_SELECTOR)).filter( - (el) => el.offsetParent !== null || el === document.activeElement, +// Settings nav: fills the sidebar slot as a flat tablist (back-to-app + tabs + version footer). +// Tab roles/keyboard are hand-rolled (role=tab/tablist + arrow roving) because the nav and the +// content live in two separate shell slots and cannot share a single Kobalte Tabs root. +export const SettingsNav: Component<{ + active: SettingsTab + onSelect: (value: SettingsTab) => void + onClose: () => void +}> = (props) => { + const language = useLanguage() + const platform = usePlatform() + let listRef: HTMLDivElement | undefined + + const focusTab = (value: SettingsTab) => { + props.onSelect(value) + listRef?.querySelector(`[data-tab="${value}"]`)?.focus() + } + + const onKeyDown = (event: KeyboardEvent) => { + const index = NAV_ITEMS.findIndex((item) => item.value === props.active) + if (index === -1) return + switch (event.key) { + case "ArrowDown": + event.preventDefault() + focusTab(NAV_ITEMS[(index + 1) % NAV_ITEMS.length].value) + break + case "ArrowUp": + event.preventDefault() + focusTab(NAV_ITEMS[(index - 1 + NAV_ITEMS.length) % NAV_ITEMS.length].value) + break + case "Home": + event.preventDefault() + focusTab(NAV_ITEMS[0].value) + break + case "End": + event.preventDefault() + focusTab(NAV_ITEMS[NAV_ITEMS.length - 1].value) + break + } + } + + return ( + ) } -export const SettingsShell: Component<{ +// Settings content: fills the main slot and swaps page by active tab. Mounting equals +// entering settings, so it owns Escape-to-close and focus save/restore. +export const SettingsContent: Component<{ active: SettingsTab directory?: string - onSelect: (value: SettingsTab) => void onClose: () => void }> = (props) => { const language = useLanguage() - const platform = usePlatform() - let root: HTMLElement | undefined - let returnFocus: HTMLElement | undefined onMount(() => { - const active = document.activeElement - if (active instanceof HTMLElement && !root?.contains(active)) returnFocus = active - if (!root) return - const [first] = focusablesIn(root) - first?.focus() + // Entering settings: move focus to the selected tab (or the back button); restore + // focus to whatever was focused before on exit. + const previous = document.activeElement as HTMLElement | null + const target = + document.querySelector('[data-component="settings-nav"] [aria-selected="true"]') ?? + document.querySelector('[data-action="settings-back"]') + target?.focus() - // Escape 关闭设置:挂 document 而非靠 section 焦点冒泡(打开瞬间焦点未必落在壳内)。 - // 设置内若开着更上层 dialog(如连接服务商),让它先吃 Escape,不连带关掉整个设置。 + // Escape closes settings via a document capture listener, ahead of the global keybind + // that would otherwise consume Escape. If a higher dialog is open inside settings + // (e.g. connecting a provider), let it consume Escape first. const onEscape = (event: KeyboardEvent) => { if (event.key !== "Escape") return - // 设置内若开着 dialog(连接服务商等),让它先吃 Escape,不连带关设置 if (document.querySelector('[data-component="dialog-overlay"]')) return event.preventDefault() props.onClose() } - // capture 阶段:抢在全局 keybind/command 消费并 preventDefault 之前收到 Escape document.addEventListener("keydown", onEscape, true) - onCleanup(() => document.removeEventListener("keydown", onEscape, true)) - }) - onCleanup(() => { - const target = returnFocus - returnFocus = undefined - if (!target || !target.isConnected) return - target.focus() + onCleanup(() => { + document.removeEventListener("keydown", onEscape, true) + if (previous?.isConnected) previous.focus() + }) }) - const handleKeyDown = (event: KeyboardEvent) => { - if (event.defaultPrevented || !root) return - if (event.key !== "Tab") return - const focusables = focusablesIn(root) - if (focusables.length === 0) return - const first = focusables[0] - const last = focusables[focusables.length - 1] - const active = document.activeElement as HTMLElement | null - const inside = !!active && root.contains(active) - - if (event.shiftKey) { - if (!inside || active === first) { - event.preventDefault() - last.focus() - } - } else if (!inside || active === last) { - event.preventDefault() - first.focus() - } - } - return (
(root = el)} data-component="settings-page" aria-label={language.t("sidebar.settings")} - class="flex size-full min-h-0 bg-bg-base" - onKeyDown={handleKeyDown} + class="no-scrollbar size-full overflow-y-auto bg-bg-base" > - { - if (!isSettingsTab(value)) return - props.onSelect(value) - }} - class="h-full w-full" - > - -
-
- -
-
- - - {language.t("settings.tab.general")} - - - - {language.t("settings.tab.shortcuts")} - - - - {language.t("settings.tab.models")} - - {/* 远程访问 / 集成页就绪前先不露出(见顶部 import 注释) - - - {language.t("settings.tab.remoteAccess")} - - - - {language.t("settings.tab.integrations")} - - */} - - - {language.t("settings.tab.worktrees")} - - - - {language.t("settings.tab.memory")} - -
-
- -
- {language.t("app.name.desktop")} - v{platform.version} -
-
- - - -
+
+ + -
- - -
+ + -
-
- -
+ + -
-
- {/* 远程访问 / 集成页就绪前先不露出(见顶部 import 注释) - -
- -
-
- -
- -
-
- */} - -
+ + -
-
- -
+ + -
-
- + + +
) } From b89e066dc9c524cd3a35ee3d0342733e237617ff Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Wed, 27 May 2026 22:24:44 +0800 Subject: [PATCH 06/14] refactor(settings): remove shell/edit tool default-open toggles Tools always fold by default now; users expand them on demand. The two opt-in settings only ever flipped that default, so drop them along with their full plumbing: - settings schema entries, defaults, and getter/setters - the two settings-general rows and their i18n strings - the shellToolDefaultOpen/editToolDefaultOpen props threaded through AssistantParts, session-turn, message-timeline, and the playground - the snapshot fixture scenarios and perf-probe scenario that existed solely to exercise the removed setting Part of the settings rewrite (#604). --- packages/app/e2e/perf/perf-probe.spec.ts | 125 +----------------- packages/app/e2e/perf/profiles.ts | 2 - packages/app/e2e/perf/profiles.unit.ts | 7 - .../e2e/snap/fixtures/trow-snap-fixture.tsx | 25 +--- packages/app/e2e/snap/session-trow.snap.ts | 12 -- .../components/session/session-new-view.tsx | 4 +- .../app/src/components/settings-general.tsx | 24 ---- packages/app/src/context/settings.tsx | 18 --- packages/app/src/i18n/en.ts | 6 - packages/app/src/i18n/zh.ts | 4 - .../src/pages/session/message-timeline.tsx | 4 - .../message-part/assistant-parts.tsx | 11 +- packages/ui/src/components/session-turn.tsx | 9 +- .../timeline-playground.stories.tsx | 2 - 14 files changed, 8 insertions(+), 245 deletions(-) diff --git a/packages/app/e2e/perf/perf-probe.spec.ts b/packages/app/e2e/perf/perf-probe.spec.ts index 3902474b9..18ace5c48 100644 --- a/packages/app/e2e/perf/perf-probe.spec.ts +++ b/packages/app/e2e/perf/perf-probe.spec.ts @@ -3,7 +3,7 @@ import path from "node:path" import type { Locator, Page } from "@playwright/test" import { raw } from "../../../opencode/test/lib/llm-server" import { test, expect } from "../fixtures" -import { cleanupSession, waitSessionIdle, waitSessionSaved, waitTerminalFocusIdle, withSession } from "../actions" +import { waitTerminalFocusIdle, withSession } from "../actions" import { promptSelector, sessionMessageItemSelector, @@ -48,9 +48,6 @@ const longMarkdown = [ ...Array.from({ length: 80 }, (_, index) => `Paragraph ${index + 1}: ${"streaming markdown content ".repeat(8)}`), ].join("\n") -const heavyBashCommand = - 'node -e \'for (let i = 0; i < 900; i++) console.log(String(i).padStart(4, "0") + " " + "heavy bash output ".repeat(8))\'' - const inputLagText = [ "Long session input lag probe.", "Typing remains responsive while a realistic message history is mounted.", @@ -76,9 +73,6 @@ type PerfProject = { sdk: PerfSdk trackSession: (sessionID: string) => void } -type PerfLlm = { - tool: (name: string, input: unknown) => Promise -} type TimelineMetrics = { scrollTop: number @@ -545,86 +539,6 @@ async function sustainMovingScrollWindow( } } -async function enableShellToolPartsExpanded(page: Parameters[0]) { - const apply = () => { - const raw = localStorage.getItem("settings.v3") - const current = (() => { - if (!raw) return {} - try { - return JSON.parse(raw) as Record - } catch { - return {} - } - })() - const general = current.general && typeof current.general === "object" ? current.general : {} - localStorage.setItem( - "settings.v3", - JSON.stringify({ - ...current, - general: { - ...general, - shellToolPartsExpanded: true, - }, - }), - ) - } - - await page.addInitScript(apply) - await page.evaluate(apply) -} - -async function seedHeavyBashSession(input: { project: PerfProject; llm: PerfLlm; run: number }) { - const session = await input.project.sdk.session - .create({ - title: `perf heavy bash ${Date.now()}-${input.run}`, - permission: [{ permission: "bash", pattern: "*", action: "allow" }], - }) - .then((result) => result.data) - if (!session?.id) throw new Error("Session create did not return an id") - input.project.trackSession(session.id) - - await input.llm.tool("bash", { - command: heavyBashCommand, - description: "Prints heavy deterministic output", - }) - await input.project.sdk.session.promptAsync({ - sessionID: session.id, - agent: "build", - parts: [{ type: "text", text: "Run the heavy bash perf fixture." }], - }) - await waitSessionIdle(input.project.sdk, session.id, 90_000) - await waitSessionSaved(input.project.directory, session.id, 90_000, input.project.url) - - await expect - .poll( - async () => { - const messages = await input.project.sdk.session.messages({ sessionID: session.id, limit: 20 }) - return (messages.data ?? []).some((message) => - message.parts.some( - (part) => - part.type === "tool" && - part.tool === "bash" && - part.state.status === "completed" && - typeof part.state.output === "string" && - part.state.output.includes("heavy bash output"), - ), - ) - }, - { timeout: 30_000 }, - ) - .toBe(true) - - return session -} - -async function revealTrowBodyIfPresent(page: Page) { - const summary = page.locator('[data-slot="trow-summary"]').first() - if (!(await summary.isVisible({ timeout: 1_000 }).catch(() => false))) return - const body = page.locator('[data-slot="trow-body"]').first() - if (!(await body.isVisible().catch(() => false))) await summary.click() - await expect(body).toBeVisible() -} - function expandableToolTriggers(page: Page) { return page .locator('[data-slot="collapsible-trigger"]') @@ -857,43 +771,6 @@ test.describe("PR0.1 perf probe baseline", () => { ) }) - test("tool-default-open-heavy-bash emits a 3-run JSON baseline", async ({ page, project, llm }) => { - skipUnlessScenario("tool-default-open-heavy-bash") - await installPerfProbe(page) - await applyPerfProfile(page, PERF_PROFILE) - await project.open() - await enableShellToolPartsExpanded(page) - - const runs = [] - for (let run = 0; run < 3; run += 1) { - const session = await seedHeavyBashSession({ project, llm, run }) - try { - await page.goto(sessionPath(project.directory, session.id)) - await revealTrowBodyIfPresent(page) - await expect - .poll(async () => Boolean(await visibleExpandableToolTrigger(page)), { timeout: 30_000 }) - .toBe(true) - const trigger = await visibleExpandableToolTrigger(page) - if (!trigger) throw new Error("No expandable tool trigger found") - await expect(trigger).toHaveAttribute("aria-expanded", "true") - await settleFrames(page, 2) - runs.push(await snapshotPerfProbe(page)) - } finally { - await cleanupSession({ sdk: project.sdk, sessionID: session.id }).catch(() => undefined) - } - if (run < 2) await cooldownAfterRun(page) - } - - scenarioResults.push( - summarizeScenarioRuns({ - branch: perfBranch, - profile: PERF_PROFILE, - scenario: "tool-default-open-heavy-bash", - runs, - }), - ) - }) - test("terminal-side-panel-open emits a 3-run JSON baseline", async ({ page, project }) => { skipUnlessScenario("terminal-side-panel-open") await installPerfProbe(page) diff --git a/packages/app/e2e/perf/profiles.ts b/packages/app/e2e/perf/profiles.ts index 52094d47e..519ad9592 100644 --- a/packages/app/e2e/perf/profiles.ts +++ b/packages/app/e2e/perf/profiles.ts @@ -6,7 +6,6 @@ export type PerfScenarioName = | "long-session-input-lag" | "session-streaming-long" | "tool-call-expand" - | "tool-default-open-heavy-bash" | "terminal-side-panel-open" | "session-scroll-reading" | "session-scroll-reading-long" @@ -18,7 +17,6 @@ const defaultScenarios = new Set([ "long-session-input-lag", "session-streaming-long", "tool-call-expand", - "tool-default-open-heavy-bash", "terminal-side-panel-open", "session-scroll-reading", ]) diff --git a/packages/app/e2e/perf/profiles.unit.ts b/packages/app/e2e/perf/profiles.unit.ts index fd2bb5cc8..6015f6046 100644 --- a/packages/app/e2e/perf/profiles.unit.ts +++ b/packages/app/e2e/perf/profiles.unit.ts @@ -1,13 +1,6 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test" import { shouldRunScenario, type PerfScenarioName } from "./profiles" -test("default profile runs heavy default-open bash perf coverage", () => { - const scenario = "tool-default-open-heavy-bash" as PerfScenarioName - - expect(shouldRunScenario("default", scenario)).toBe(true) - expect(shouldRunScenario("low-end", scenario)).toBe(false) -}) - test("default profile runs long-session input lag coverage", () => { const scenario = "long-session-input-lag" as PerfScenarioName diff --git a/packages/app/e2e/snap/fixtures/trow-snap-fixture.tsx b/packages/app/e2e/snap/fixtures/trow-snap-fixture.tsx index f0b99894b..eccf3734b 100644 --- a/packages/app/e2e/snap/fixtures/trow-snap-fixture.tsx +++ b/packages/app/e2e/snap/fixtures/trow-snap-fixture.tsx @@ -24,7 +24,6 @@ import { singleResultParts, singleRunningParts, snapAssistantMessage, - tool, toolOutputParts, zhI18n, } from "./trow-snap-fixture-data" @@ -33,21 +32,13 @@ function FileStub() { return
File viewer stub
} -function AssistantPartsCase(props: { - parts: Part[] - shellToolDefaultOpen?: boolean - editToolDefaultOpen?: boolean -}) { +function AssistantPartsCase(props: { parts: Part[] }) { return ( - + ) } @@ -268,18 +259,6 @@ function TrowSnapFixture() {
-
- -
-
- -
{ expect(singleErrorMetrics.iconTitleTopDelta).toBeLessThanOrEqual(3) shots.push(await captureBlock("single-command-error", singleError)) - const singleShellSettingCollapsed = page.locator('[data-snap="single-shell-setting-collapsed"]') - await expect(singleShellSettingCollapsed).toContainText("执行命令", { timeout: 30_000 }) - await expect(singleShellSettingCollapsed).toContainText("respects shell setting", { timeout: 30_000 }) - await expect(singleShellSettingCollapsed.locator('[data-component="bash-output"]')).toBeHidden({ timeout: 30_000 }) - shots.push(await captureBlock("single-shell-setting-collapsed", singleShellSettingCollapsed)) - - const singleShellSettingExpanded = page.locator('[data-snap="single-shell-setting-expanded"]') - await expect(singleShellSettingExpanded).toContainText("执行命令", { timeout: 30_000 }) - await expect(singleShellSettingExpanded).toContainText("respects shell setting", { timeout: 30_000 }) - await expect(singleShellSettingExpanded.locator('[data-component="bash-output"]')).toBeVisible({ timeout: 30_000 }) - shots.push(await captureBlock("single-shell-setting-expanded", singleShellSettingExpanded)) - const singleRunning = page.locator('[data-snap="single-command-running"]') await expect(singleRunning.locator('[data-component="session-turn-trow-block"][data-single]')).toBeVisible({ timeout: 30_000, diff --git a/packages/app/src/components/session/session-new-view.tsx b/packages/app/src/components/session/session-new-view.tsx index 03d670795..96158fcf5 100644 --- a/packages/app/src/components/session/session-new-view.tsx +++ b/packages/app/src/components/session/session-new-view.tsx @@ -4,8 +4,8 @@ import { useLanguage } from "@/context/language" // Lazy-loaded so the module + its reactive setup (4 contexts, createEffect with // prompt.dirty + sessionCount tracking, For-loop chip render) doesn't run on // the home's cold paint path. perf-probe-baseline showed +183ms frame_gap_max -// on homepage-cold and +267ms on tool-default-open-heavy-bash (both go through -// project.open() → home first) when this was mounted eagerly. +// on homepage-cold (which goes through project.open() → home first) when this +// was mounted eagerly. const HomeSuggestionList = lazy(() => import("@/components/home/home-suggestion-list").then((module) => ({ default: module.HomeSuggestionList })), ) diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index f7b9d180d..499009246 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -128,30 +128,6 @@ export const SettingsGeneral: Component = () => { - -
- settings.general.setShellToolPartsExpanded(checked)} - /> -
-
- - -
- settings.general.setEditToolPartsExpanded(checked)} - /> -
-
- store.general?.shellToolPartsExpanded, - defaultSettings.general.shellToolPartsExpanded, - ), - setShellToolPartsExpanded(value: boolean) { - setStore("general", "shellToolPartsExpanded", value) - }, - editToolPartsExpanded: withFallback( - () => store.general?.editToolPartsExpanded, - defaultSettings.general.editToolPartsExpanded, - ), - setEditToolPartsExpanded(value: boolean) { - setStore("general", "editToolPartsExpanded", value) - }, lspEnabled: withFallback(() => store.general?.lspEnabled, defaultSettings.general.lspEnabled), setLspEnabled(value: boolean) { // The createEffect above mirrors this change to the Electron main diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 87c24530b..279c81fec 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -894,12 +894,6 @@ export const dict = { "settings.general.row.followup.description": "Choose whether follow-up prompts steer immediately or wait in a queue", "settings.general.row.followup.option.queue": "Queue", "settings.general.row.followup.option.steer": "Steer", - "settings.general.row.shellToolPartsExpanded.title": "Expand shell tool parts", - "settings.general.row.shellToolPartsExpanded.description": - "Show shell tool parts expanded by default in the timeline", - "settings.general.row.editToolPartsExpanded.title": "Expand edit tool parts", - "settings.general.row.editToolPartsExpanded.description": - "Show edit, write, and patch tool parts expanded by default in the timeline", "settings.general.row.lsp.title": "Language Server Protocol (LSP)", "settings.general.row.lsp.description": "Detect type errors and symbol references when editing code", "settings.general.webSearch.title": "Web search", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index 04937a00e..4aa79d902 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -781,10 +781,6 @@ export const dict = { "settings.general.row.followup.description": "选择跟进提示是立即引导还是在队列中等待", "settings.general.row.followup.option.queue": "排队", "settings.general.row.followup.option.steer": "引导", - "settings.general.row.shellToolPartsExpanded.title": "展开 shell 工具部分", - "settings.general.row.shellToolPartsExpanded.description": "默认在时间线中展开 shell 工具部分", - "settings.general.row.editToolPartsExpanded.title": "展开编辑工具部分", - "settings.general.row.editToolPartsExpanded.description": "默认在时间线中展开 edit、write 和 patch 工具部分", "settings.general.row.lsp.title": "语言服务器协议(LSP)", "settings.general.row.lsp.description": "修改代码时识别项目类型错误和符号引用", "settings.general.webSearch.title": "网页搜索", diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index 0d1016d65..3a1374bb5 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -44,7 +44,6 @@ import { useLanguage } from "@/context/language" import { useSessionRouteKey } from "@/pages/session/session-layout" import { usePlatform } from "@/context/platform" import { emitRendererDiagnostic } from "@/context/renderer-diagnostics" -import { useSettings } from "@/context/settings" import { useShellSurface } from "@/context/shell-surface" import { useSync } from "@/context/sync" import { webSearchRecoveryToast } from "./websearch-toasts" @@ -116,7 +115,6 @@ export function MessageTimeline(props: { | undefined const sync = useSync() - const settings = useSettings() const language = useLanguage() const shellSurface = useShellSurface() const { params } = useSessionRouteKey() @@ -318,8 +316,6 @@ export function MessageTimeline(props: { active={active()} status={active() ? sessionStatus() : undefined} rateLimitCardSlot={(classification) => } - shellToolDefaultOpen={settings.general.shellToolPartsExpanded()} - editToolDefaultOpen={settings.general.editToolPartsExpanded()} turnChanges={props.turnChangeController.turnChanges} turnChangeActions={{ ...props.turnChangeController.actions, diff --git a/packages/ui/src/components/message-part/assistant-parts.tsx b/packages/ui/src/components/message-part/assistant-parts.tsx index 0f98b9cc3..5f6f95392 100644 --- a/packages/ui/src/components/message-part/assistant-parts.tsx +++ b/packages/ui/src/components/message-part/assistant-parts.tsx @@ -11,8 +11,6 @@ import { Part } from "./message-router" export function AssistantParts(props: { messages: AssistantMessage[] working?: boolean - shellToolDefaultOpen?: boolean - editToolDefaultOpen?: boolean }) { const data = useData() const i18n = useI18n() @@ -66,8 +64,7 @@ export function AssistantParts(props: { ) const toolParts = createMemo(() => parts().filter((p): p is ToolPart => p.type === "tool")) const singleTool = createMemo(() => toolParts().length === 1 && parts().length === 1) - const defaultOpenForTool = (tool: ToolPart) => - partDefaultOpen(tool, props.shellToolDefaultOpen, props.editToolDefaultOpen) ?? singleTool() + const defaultOpenForTool = (tool: ToolPart) => partDefaultOpen(tool) ?? singleTool() const renderTool = (tool: ToolPart) => { const message = msgs().get(tool.messageID) if (!message) return null @@ -136,11 +133,7 @@ export function AssistantParts(props: { diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 599ad09d0..9019cea03 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -119,8 +119,6 @@ export function SessionTurn( assistantMessages?: AssistantMessage[] messages?: MessageType[] actions?: UserActions - shellToolDefaultOpen?: boolean - editToolDefaultOpen?: boolean turnChanges?: Record turnChangeActions?: TurnChangeActions active?: boolean @@ -460,12 +458,7 @@ export function SessionTurn( if (assistantHidden()) blurActiveElementInside(assistantContent()) }} > - +
diff --git a/packages/ui/src/components/timeline-playground.stories.tsx b/packages/ui/src/components/timeline-playground.stories.tsx index 1aa57e6bb..543858691 100644 --- a/packages/ui/src/components/timeline-playground.stories.tsx +++ b/packages/ui/src/components/timeline-playground.stories.tsx @@ -1989,8 +1989,6 @@ function Playground() { messageID={msg.id} messages={state.messages} active={false} - shellToolDefaultOpen={true} - editToolDefaultOpen={true} classes={{ root: "min-w-0 w-full relative", content: "flex flex-col justify-between !overflow-visible", From f6e29d7e95e0609d3065a49dab235791169ea507 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Wed, 27 May 2026 22:46:44 +0800 Subject: [PATCH 07/14] refactor(settings): drop the misplaced auto-accept permissions toggle The settings toggle controlled auto-accept for the current session/directory, yet lived in global settings, so it sat inert whenever settings opened without an active session. The same control already exists in-session as the mod+shift+a command, making the settings row a confusing duplicate. - remove the settings row and its permission/params/decode64 plumbing - remove the orphaned permissions.autoApprove settings schema (no readers) - migrate the two e2e tests that enabled auto-accept through the toggle to press the command keybind instead; drop the switch-only "toggle works before first submit" test, whose logic stays covered by the permission-auto-respond unit tests Part of the settings rewrite (#604). --- packages/app/e2e/prompt/prompt-shell.spec.ts | 16 +++----- .../e2e/session/session-composer-dock.spec.ts | 24 ------------ .../app/src/components/settings-general.tsx | 39 ------------------- packages/app/src/context/settings.tsx | 12 ------ 4 files changed, 6 insertions(+), 85 deletions(-) diff --git a/packages/app/e2e/prompt/prompt-shell.spec.ts b/packages/app/e2e/prompt/prompt-shell.spec.ts index 4f15412de..042bf1147 100644 --- a/packages/app/e2e/prompt/prompt-shell.spec.ts +++ b/packages/app/e2e/prompt/prompt-shell.spec.ts @@ -1,8 +1,9 @@ import type { ToolPart } from "@opencode-ai/sdk/v2/client" import type { Page } from "@playwright/test" import { test, expect } from "../fixtures" -import { closeSettingsPanel, openSettings, withSession } from "../actions" +import { withSession } from "../actions" import { promptModelSelector, promptSelector, promptVariantSelector } from "../selectors" +import { modKey } from "../utils" const isBash = (part: unknown): part is ToolPart => { if (!part || typeof part !== "object") return false @@ -33,15 +34,10 @@ test("shell mode runs a command in the project directory", async ({ page, projec await withSession(project.sdk, `e2e shell ${Date.now()}`, async (session) => { project.trackSession(session.id) await project.gotoSession(session.id) - const dialog = await openSettings(page) - const toggle = dialog.locator('[data-action="settings-auto-accept-permissions"]').first() - const input = toggle.locator('[data-slot="switch-input"]').first() - await expect(toggle).toBeVisible() - if ((await input.getAttribute("aria-checked")) !== "true") { - await toggle.locator('[data-slot="switch-control"]').click() - await expect(input).toHaveAttribute("aria-checked", "true") - } - await closeSettingsPanel(page, dialog) + // Enable auto-accept via its command keybind so the shell command runs + // without a permission prompt. Modified keybinds fire even while the + // composer input is focused. + await page.keyboard.press(`${modKey}+Shift+A`) await project.shell(cmd) await expect diff --git a/packages/app/e2e/session/session-composer-dock.spec.ts b/packages/app/e2e/session/session-composer-dock.spec.ts index 68a2bcd7c..f40044425 100644 --- a/packages/app/e2e/session/session-composer-dock.spec.ts +++ b/packages/app/e2e/session/session-composer-dock.spec.ts @@ -4,8 +4,6 @@ import { test, expect } from "../fixtures" import { cleanupSession, clearSessionDockSeed, - closeSettingsPanel, - openSettings, openRightPanel, rightPanelTabList, seedSessionQuestion, @@ -203,17 +201,6 @@ async function clearPermissionDock(page: any, label: RegExp) { await dock.getByRole("button", { name: label }).click() } -async function setAutoAccept(page: any, enabled: boolean) { - const dialog = await openSettings(page) - const toggle = dialog.locator('[data-action="settings-auto-accept-permissions"]').first() - const input = toggle.locator('[data-slot="switch-input"]').first() - await expect(toggle).toBeVisible() - const checked = (await input.getAttribute("aria-checked")) === "true" - if (checked !== enabled) await toggle.locator('[data-slot="switch-control"]').click() - await expect(input).toHaveAttribute("aria-checked", enabled ? "true" : "false") - await closeSettingsPanel(page, dialog) -} - async function expectQuestionBlocked(page: any) { await expect(page.locator(questionDockSelector)).toBeVisible() await expect(page.locator(promptSelector)).toHaveCount(0) @@ -394,13 +381,6 @@ test("default dock shows prompt input", async ({ page, project }) => { ) }) -test("auto-accept toggle works before first submit", async ({ page, project }) => { - await project.open() - - await setAutoAccept(page, true) - await setAutoAccept(page, false) -}) - test("blocked question flow unblocks after submit", async ({ page, llm, project }) => { await project.open() await withDockSession( @@ -667,7 +647,6 @@ test("blocked permission flow supports allow once", async ({ page, project }) => "e2e composer dock permission once", async (session) => { await project.gotoSession(session.id) - await setAutoAccept(page, false) await withMockPermission( page, { @@ -700,7 +679,6 @@ test("blocked permission flow supports reject", async ({ page, project }) => { "e2e composer dock permission reject", async (session) => { await project.gotoSession(session.id) - await setAutoAccept(page, false) await withMockPermission( page, { @@ -732,7 +710,6 @@ test("blocked permission flow supports allow always", async ({ page, project }) "e2e composer dock permission always", async (session) => { await project.gotoSession(session.id) - await setAutoAccept(page, false) await withMockPermission( page, { @@ -879,7 +856,6 @@ test("child session permission request blocks parent dock and supports allow onc "e2e composer dock child permission parent", async (session) => { await project.gotoSession(session.id) - await setAutoAccept(page, false) const child = await project.sdk.session .create({ diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index 499009246..fb117463b 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -5,9 +5,7 @@ import { Switch } from "@opencode-ai/ui/switch" import { TextField } from "@opencode-ai/ui/text-field" import { Tooltip } from "@opencode-ai/ui/tooltip" import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context" -import { useParams } from "@solidjs/router" import { useLanguage } from "@/context/language" -import { usePermission } from "@/context/permission" import { canUseDisplayBackend, usePlatform } from "@/context/platform" import { monoDefault, @@ -19,7 +17,6 @@ import { useSettings, type NotifyLevel, } from "@/context/settings" -import { decode64 } from "@/utils/base64" import { Link } from "./link" import { SettingsList } from "./settings-list" import { SettingsRow } from "./settings-row" @@ -40,9 +37,7 @@ type ThemeOption = { export const SettingsGeneral: Component = () => { const theme = useTheme() const language = useLanguage() - const permission = usePermission() const platform = usePlatform() - const params = useParams() const settings = useSettings() onMount(() => { @@ -50,31 +45,6 @@ export const SettingsGeneral: Component = () => { }) const linux = createMemo(() => platform.os === "linux" && canUseDisplayBackend(platform)) - const dir = createMemo(() => decode64(params.dir)) - const accepting = createMemo(() => { - const value = dir() - if (!value) return false - if (!params.id) return permission.isAutoAcceptingDirectory(value) - return permission.isAutoAccepting(params.id, value) - }) - - const toggleAccept = (checked: boolean) => { - const value = dir() - if (!value) return - - if (!params.id) { - if (permission.isAutoAcceptingDirectory(value) === checked) return - permission.toggleAutoAcceptDirectory(value) - return - } - - if (checked) { - permission.enableAutoAccept(params.id, value) - return - } - - permission.disableAutoAccept(params.id, value) - } const themeOptions = createMemo(() => theme.ids().map((id) => ({ id, name: theme.name(id) }))) @@ -117,15 +87,6 @@ export const SettingsGeneral: Component = () => { /> - -
- -
-
- - permissions: { - autoApprove: boolean - } notify: NotifyLevel } @@ -106,9 +103,6 @@ const defaultSettings: Settings = { sans: "", }, keybinds: {}, - permissions: { - autoApprove: false, - }, notify: "unfocused" as NotifyLevel, } @@ -308,12 +302,6 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont setStore("keybinds", reconcile({})) }, }, - permissions: { - autoApprove: withFallback(() => store.permissions?.autoApprove, defaultSettings.permissions.autoApprove), - setAutoApprove(value: boolean) { - setStore("permissions", "autoApprove", value) - }, - }, notify: { level: withFallback(() => store.notify, defaultSettings.notify), setLevel(value: NotifyLevel) { From 9f9401cf54dcb9725834cd671a19aad3527269d7 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Wed, 27 May 2026 23:00:53 +0800 Subject: [PATCH 08/14] fix(settings): pass the current directory into settings content The settings takeover mounted SettingsContent without a directory, so SettingsMemory received undefined and created its SDK client with no project scope, losing project-scoped memory behaviour. Forward currentDir(). Addresses review feedback on #951. --- packages/app/src/pages/layout.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 8c19fddcd..743a15990 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -2474,7 +2474,7 @@ export default function Layout(props: ParentProps) { {/* Settings takeover: the content overlays the session page (kept mounted, only inert, so the terminal/right panel are not torn down); geometry is inherited from the main slot. */}
- +
From 476bef7fdc3e5941f05956a59631d10f106aa40a Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Wed, 27 May 2026 23:00:54 +0800 Subject: [PATCH 09/14] test(settings): harden settings-shell e2e against flakiness - snap: wait for each tab's content to render instead of a fixed 300ms delay - spec: assert the locator returned by openSettings actually closes, rather than a hard-coded settings-page selector that passes even on a dialog fallback Addresses review feedback on #951. --- packages/app/e2e/settings/settings-shell.spec.ts | 10 +++++----- packages/app/e2e/snap/settings-shell.snap.ts | 8 +++++++- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/packages/app/e2e/settings/settings-shell.spec.ts b/packages/app/e2e/settings/settings-shell.spec.ts index 63654c47a..bc215df8a 100644 --- a/packages/app/e2e/settings/settings-shell.spec.ts +++ b/packages/app/e2e/settings/settings-shell.spec.ts @@ -38,19 +38,19 @@ test("settings shell shows the migrated nav and switches pages", async ({ page, test("escape closes the settings shell", async ({ page, gotoSession }) => { await gotoSession() - await openSettings(page) - await expect(page.locator('[data-component="settings-page"]')).toBeVisible() + const settings = await openSettings(page) + await expect(settings).toBeVisible() await page.keyboard.press("Escape") - await expect(page.locator('[data-component="settings-page"]')).toHaveCount(0) + await expect(settings).toBeHidden() }) test("back-to-app button closes the settings shell", async ({ page, gotoSession }) => { await gotoSession() const settings = await openSettings(page) - await expect(page.locator('[data-component="settings-page"]')).toBeVisible() + await expect(settings).toBeVisible() await settings.getByRole("button", { name: "Back to app" }).click() - await expect(page.locator('[data-component="settings-page"]')).toHaveCount(0) + await expect(settings).toBeHidden() }) diff --git a/packages/app/e2e/snap/settings-shell.snap.ts b/packages/app/e2e/snap/settings-shell.snap.ts index 4c2da01af..c5bbc0e91 100644 --- a/packages/app/e2e/snap/settings-shell.snap.ts +++ b/packages/app/e2e/snap/settings-shell.snap.ts @@ -17,9 +17,15 @@ test("settings-shell", async ({ page, project }) => { const shots: Shot[] = [{ name: "general", buf: await settings.screenshot() }] + // Wait for each tab's content to render before snapping, instead of a fixed delay. + const tabReady = { + Models: '[data-component="custom-provider-section"]', + Memory: '[data-action="settings-memory-raw"]', + } as const + for (const tab of ["Models", "Memory"] as const) { await settings.getByRole("tab", { name: tab }).click() - await page.waitForTimeout(300) + await settings.locator(tabReady[tab]).first().waitFor({ state: "visible", timeout: 30_000 }) shots.push({ name: tab.toLowerCase(), buf: await settings.screenshot() }) } From e1076d449f8c8839a68ab311dd2c733f771f2ad8 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Wed, 27 May 2026 23:25:59 +0800 Subject: [PATCH 10/14] fix(settings): drop unreachable remote/integrations tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit remote and integrations were valid SettingsTab values and listed in TAB_VALUES, yet SettingsContent has no content branch for them, so a programmatic openSettings("remote") landed on an empty settings page. They have no page yet, so remove them from both the type and TAB_VALUES (isSettingsTab now rejects them and the open path falls back to general). They return — type, TAB_VALUES, NAV_ITEMS and a content Match together — when their pages land. Addresses review feedback on #951. --- packages/app/src/pages/settings/settings-shell.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/app/src/pages/settings/settings-shell.tsx b/packages/app/src/pages/settings/settings-shell.tsx index 5bc8bc534..e448eb663 100644 --- a/packages/app/src/pages/settings/settings-shell.tsx +++ b/packages/app/src/pages/settings/settings-shell.tsx @@ -14,17 +14,18 @@ import { ModelsPage } from "./models" // (width / background / border) is inherited from the shell slots instead of being // re-declared, which removes the alignment drift the old standalone overlay had // (its fixed 200px nav + surface-raised content diverged from the real sidebar). -// Remote access / Integrations stay hidden from the nav until their pages are ready; -// the connection management they will host is still reachable via right-panel Connections. -export type SettingsTab = "general" | "shortcuts" | "models" | "remote" | "integrations" | "worktrees" | "memory" +// Remote access / Integrations are not part of this surface yet: their pages have no content +// branch, so they are intentionally absent from both the type and TAB_VALUES. They come back — +// type, TAB_VALUES, NAV_ITEMS and a content Match together — when their pages land. The +// connection management they will host is still reachable via right-panel Connections. +export type SettingsTab = "general" | "shortcuts" | "models" | "worktrees" | "memory" -const TAB_VALUES: SettingsTab[] = ["general", "shortcuts", "models", "remote", "integrations", "worktrees", "memory"] +const TAB_VALUES: SettingsTab[] = ["general", "shortcuts", "models", "worktrees", "memory"] export function isSettingsTab(value: string): value is SettingsTab { return (TAB_VALUES as string[]).includes(value) } -// Currently 5 visible tabs; remote / integrations omitted until ready (see above). const NAV_ITEMS = [ { value: "general", icon: "settings-gear", labelKey: "settings.tab.general" }, { value: "shortcuts", icon: "keyboard", labelKey: "settings.tab.shortcuts" }, From 1dc5e5c52d8988deb3c8a4d76076b3b22b4256aa Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Wed, 27 May 2026 23:37:37 +0800 Subject: [PATCH 11/14] fix(e2e): assert settings close on settings-page, not always-present shell-content openSettings returns the shell-content slot ancestor, which also hosts the session view and never detaches, so expect(settings).toBeHidden() could never hold. The close tests are not @smoke-tagged, so PR CI (smoke-only) never ran them and the broken assertion went unnoticed. Key the close assertion on settings-page (mounted only while settings is open) like closeSettingsPanel. --- packages/app/e2e/settings/settings-shell.spec.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/app/e2e/settings/settings-shell.spec.ts b/packages/app/e2e/settings/settings-shell.spec.ts index bc215df8a..8330da432 100644 --- a/packages/app/e2e/settings/settings-shell.spec.ts +++ b/packages/app/e2e/settings/settings-shell.spec.ts @@ -35,22 +35,27 @@ test("settings shell shows the migrated nav and switches pages", async ({ page, await closeSettingsPanel(page, settings) }) +// openSettings returns shell-content (the slot ancestor), which also hosts the session view and +// never detaches. Assert close on settings-page instead: it is only mounted while settings is open, +// the same signal closeSettingsPanel keys on. test("escape closes the settings shell", async ({ page, gotoSession }) => { await gotoSession() - const settings = await openSettings(page) - await expect(settings).toBeVisible() + await openSettings(page) + const settingsPage = page.locator('[data-component="settings-page"]') + await expect(settingsPage).toBeVisible() await page.keyboard.press("Escape") - await expect(settings).toBeHidden() + await expect(settingsPage).toHaveCount(0) }) test("back-to-app button closes the settings shell", async ({ page, gotoSession }) => { await gotoSession() const settings = await openSettings(page) - await expect(settings).toBeVisible() + const settingsPage = page.locator('[data-component="settings-page"]') + await expect(settingsPage).toBeVisible() await settings.getByRole("button", { name: "Back to app" }).click() - await expect(settings).toBeHidden() + await expect(settingsPage).toHaveCount(0) }) From 2cfcc0a4fe5f3671245c8597ffdf20fe4a0af8ee Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Wed, 27 May 2026 23:40:59 +0800 Subject: [PATCH 12/14] test(e2e): run settings-shell foundation spec in PR CI (@smoke) PR CI runs the smoke suite only (--grep @smoke). The PR1 foundation-lock spec had no @smoke tag, so its nav-takeover / tab-switch / close tests never ran on PRs and could not guard the shell. Tag all three so the lock is real; they are local-only UI (no network), ~6s total, sharing the already-booted smoke worker. --- packages/app/e2e/settings/settings-shell.spec.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/app/e2e/settings/settings-shell.spec.ts b/packages/app/e2e/settings/settings-shell.spec.ts index 8330da432..bc20645d6 100644 --- a/packages/app/e2e/settings/settings-shell.spec.ts +++ b/packages/app/e2e/settings/settings-shell.spec.ts @@ -3,7 +3,7 @@ import { closeSettingsPanel, openSettings } from "../actions" // PR1 foundation lock: shell-slot takeover (nav in the sidebar slot, content in the main slot), // migrating the existing pages in place (remote / integrations hidden until ready). -test("settings shell shows the migrated nav and switches pages", async ({ page, gotoSession }) => { +test("@smoke settings shell shows the migrated nav and switches pages", async ({ page, gotoSession }) => { await gotoSession() const settings = await openSettings(page) @@ -38,7 +38,7 @@ test("settings shell shows the migrated nav and switches pages", async ({ page, // openSettings returns shell-content (the slot ancestor), which also hosts the session view and // never detaches. Assert close on settings-page instead: it is only mounted while settings is open, // the same signal closeSettingsPanel keys on. -test("escape closes the settings shell", async ({ page, gotoSession }) => { +test("@smoke escape closes the settings shell", async ({ page, gotoSession }) => { await gotoSession() await openSettings(page) @@ -49,7 +49,7 @@ test("escape closes the settings shell", async ({ page, gotoSession }) => { await expect(settingsPage).toHaveCount(0) }) -test("back-to-app button closes the settings shell", async ({ page, gotoSession }) => { +test("@smoke back-to-app button closes the settings shell", async ({ page, gotoSession }) => { await gotoSession() const settings = await openSettings(page) From 0fb7c196312efea22ff455799f13931578503c42 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Wed, 27 May 2026 23:54:11 +0800 Subject: [PATCH 13/14] test(opencode): register settings-shell smoke tests in inventory guard The e2e smoke-tagging guard locks the exact @smoke inventory. Tagging the three settings-shell tests (2cfcc0a4fe) broke it; add them to expectedSmokeTests in sorted order. --- packages/opencode/test/config/e2e-smoke-tagging.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/opencode/test/config/e2e-smoke-tagging.test.ts b/packages/opencode/test/config/e2e-smoke-tagging.test.ts index 737053e48..39323e256 100644 --- a/packages/opencode/test/config/e2e-smoke-tagging.test.ts +++ b/packages/opencode/test/config/e2e-smoke-tagging.test.ts @@ -25,6 +25,9 @@ const expectedSmokeTests = [ "packages/app/e2e/prompt/prompt.spec.ts:@smoke can send a prompt and receive a reply", "packages/app/e2e/release-notes/release-notes-toast.spec.ts:@smoke shows subtle toast when stored version is older than current", "packages/app/e2e/settings/settings-memory.spec.ts:@smoke memory settings exposes the raw MEMORY.md controls", + "packages/app/e2e/settings/settings-shell.spec.ts:@smoke back-to-app button closes the settings shell", + "packages/app/e2e/settings/settings-shell.spec.ts:@smoke escape closes the settings shell", + "packages/app/e2e/settings/settings-shell.spec.ts:@smoke settings shell shows the migrated nav and switches pages", "packages/app/e2e/settings/settings.spec.ts:@smoke PawWork settings opens as a full-pane surface, not a dialog", "packages/app/e2e/settings/settings.spec.ts:@smoke new installs start with the PawWork theme", "packages/app/e2e/settings/settings.spec.ts:@smoke settings dialog opens, switches tabs, closes", From f94fd96730443141d8f1ef05691eec2d1b826fe0 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Thu, 28 May 2026 00:09:54 +0800 Subject: [PATCH 14/14] fix(settings): keep Escape from closing the shell while a Select dropdown is open The Escape capture listener runs before Kobalte's bubble-phase dropdown dismissal, so pressing Escape to close a Select (language / notify / theme) tore down the whole settings shell. Bail while a select-content layer is open (alongside dialog-overlay) so the popover consumes Escape first. Adds an e2e guard and registers it in the smoke inventory. --- .../app/e2e/settings/settings-shell.spec.ts | 20 +++++++++++++++++++ .../app/src/pages/settings/settings-shell.tsx | 9 ++++++--- .../test/config/e2e-smoke-tagging.test.ts | 1 + 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/packages/app/e2e/settings/settings-shell.spec.ts b/packages/app/e2e/settings/settings-shell.spec.ts index bc20645d6..9bf93ebd6 100644 --- a/packages/app/e2e/settings/settings-shell.spec.ts +++ b/packages/app/e2e/settings/settings-shell.spec.ts @@ -49,6 +49,26 @@ test("@smoke escape closes the settings shell", async ({ page, gotoSession }) => await expect(settingsPage).toHaveCount(0) }) +// The Escape capture listener must let an open Kobalte popover (Select dropdown, etc.) consume +// Escape first — otherwise pressing Escape to dismiss a dropdown tears down the whole shell. +test("@smoke escape with an open select closes the dropdown, not the settings shell", async ({ page, gotoSession }) => { + await gotoSession() + + await openSettings(page) + const settingsPage = page.locator('[data-component="settings-page"]') + await expect(settingsPage).toBeVisible() + + await settingsPage.locator('[data-action="settings-language"] [data-slot="select-select-trigger"]').click() + const dropdown = page.locator('[data-component="select-content"]') + await expect(dropdown).toBeVisible() + + await page.keyboard.press("Escape") + + // Dropdown dismissed, settings still open. + await expect(dropdown).toHaveCount(0) + await expect(settingsPage).toBeVisible() +}) + test("@smoke back-to-app button closes the settings shell", async ({ page, gotoSession }) => { await gotoSession() diff --git a/packages/app/src/pages/settings/settings-shell.tsx b/packages/app/src/pages/settings/settings-shell.tsx index e448eb663..12b581bcc 100644 --- a/packages/app/src/pages/settings/settings-shell.tsx +++ b/packages/app/src/pages/settings/settings-shell.tsx @@ -156,11 +156,14 @@ export const SettingsContent: Component<{ target?.focus() // Escape closes settings via a document capture listener, ahead of the global keybind - // that would otherwise consume Escape. If a higher dialog is open inside settings - // (e.g. connecting a provider), let it consume Escape first. + // that would otherwise consume Escape. Because capture runs before the popover's own + // bubble-phase Escape handler, bail while a transient overlay is open so it can consume + // Escape first: a dialog (e.g. connecting a provider) or an open Select dropdown. Both + // mount their layer only while open. Any future portalled popover in settings must be + // added here, or Escape will tear down the whole shell instead of closing the popover. const onEscape = (event: KeyboardEvent) => { if (event.key !== "Escape") return - if (document.querySelector('[data-component="dialog-overlay"]')) return + if (document.querySelector('[data-component="dialog-overlay"], [data-component="select-content"]')) return event.preventDefault() props.onClose() } diff --git a/packages/opencode/test/config/e2e-smoke-tagging.test.ts b/packages/opencode/test/config/e2e-smoke-tagging.test.ts index 39323e256..1551b80cc 100644 --- a/packages/opencode/test/config/e2e-smoke-tagging.test.ts +++ b/packages/opencode/test/config/e2e-smoke-tagging.test.ts @@ -27,6 +27,7 @@ const expectedSmokeTests = [ "packages/app/e2e/settings/settings-memory.spec.ts:@smoke memory settings exposes the raw MEMORY.md controls", "packages/app/e2e/settings/settings-shell.spec.ts:@smoke back-to-app button closes the settings shell", "packages/app/e2e/settings/settings-shell.spec.ts:@smoke escape closes the settings shell", + "packages/app/e2e/settings/settings-shell.spec.ts:@smoke escape with an open select closes the dropdown, not the settings shell", "packages/app/e2e/settings/settings-shell.spec.ts:@smoke settings shell shows the migrated nav and switches pages", "packages/app/e2e/settings/settings.spec.ts:@smoke PawWork settings opens as a full-pane surface, not a dialog", "packages/app/e2e/settings/settings.spec.ts:@smoke new installs start with the PawWork theme",