Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
ce79bb0
feat(settings): rewrite to two-layer takeover shell
Astro-Han May 27, 2026
ce13400
test(settings): drop dead sound-settings snapshot and selectors
Astro-Han May 27, 2026
6dbd39b
chore(settings): translate settings page comments to English
Astro-Han May 27, 2026
144d210
style(sidebar): unify nav row icon-to-label gap (gap-2 -> gap-3)
Astro-Han May 27, 2026
0af05f9
feat(settings): take over shell slots instead of overlaying
Astro-Han May 27, 2026
39098bb
Merge remote-tracking branch 'origin/dev' into claude/settings-rewrite
Astro-Han May 27, 2026
b89e066
refactor(settings): remove shell/edit tool default-open toggles
Astro-Han May 27, 2026
f6e29d7
refactor(settings): drop the misplaced auto-accept permissions toggle
Astro-Han May 27, 2026
42df7e3
Merge remote-tracking branch 'origin/dev' into claude/settings-rewrite
Astro-Han May 27, 2026
9f9401c
fix(settings): pass the current directory into settings content
Astro-Han May 27, 2026
476bef7
test(settings): harden settings-shell e2e against flakiness
Astro-Han May 27, 2026
e1076d4
fix(settings): drop unreachable remote/integrations tabs
Astro-Han May 27, 2026
1dc5e5c
fix(e2e): assert settings close on settings-page, not always-present …
Astro-Han May 27, 2026
2cfcc0a
test(e2e): run settings-shell foundation spec in PR CI (@smoke)
Astro-Han May 27, 2026
0fb7c19
test(opencode): register settings-shell smoke tests in inventory guard
Astro-Han May 27, 2026
f94fd96
fix(settings): keep Escape from closing the shell while a Select drop…
Astro-Han May 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 22 additions & 23 deletions packages/app/e2e/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -368,14 +361,20 @@ 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
.waitFor({ state: "visible", timeout: 3000 })
.then(() => true)
.catch(() => false)

if (pageOpened) return settingsPage
if (pageOpened) return settingsSurface

const opened = await dialog
.waitFor({ state: "visible", timeout: 3000 })
Expand All @@ -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
Expand Down
125 changes: 1 addition & 124 deletions packages/app/e2e/perf/perf-probe.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.",
Expand All @@ -76,9 +73,6 @@ type PerfProject = {
sdk: PerfSdk
trackSession: (sessionID: string) => void
}
type PerfLlm = {
tool: (name: string, input: unknown) => Promise<void>
}

type TimelineMetrics = {
scrollTop: number
Expand Down Expand Up @@ -545,86 +539,6 @@ async function sustainMovingScrollWindow(
}
}

async function enableShellToolPartsExpanded(page: Parameters<typeof snapshotPerfProbe>[0]) {
const apply = () => {
const raw = localStorage.getItem("settings.v3")
const current = (() => {
if (!raw) return {}
try {
return JSON.parse(raw) as Record<string, unknown>
} 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"]')
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 0 additions & 2 deletions packages/app/e2e/perf/profiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -18,7 +17,6 @@ const defaultScenarios = new Set<PerfScenarioName>([
"long-session-input-lag",
"session-streaming-long",
"tool-call-expand",
"tool-default-open-heavy-bash",
"terminal-side-panel-open",
"session-scroll-reading",
])
Expand Down
7 changes: 0 additions & 7 deletions packages/app/e2e/perf/profiles.unit.ts
Original file line number Diff line number Diff line change
@@ -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

Expand Down
16 changes: 6 additions & 10 deletions packages/app/e2e/prompt/prompt-shell.spec.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down
6 changes: 0 additions & 6 deletions packages/app/e2e/selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]'
Expand Down
24 changes: 0 additions & 24 deletions packages/app/e2e/session/session-composer-dock.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@ import { test, expect } from "../fixtures"
import {
cleanupSession,
clearSessionDockSeed,
closeSettingsPanel,
openSettings,
openRightPanel,
rightPanelTabList,
seedSessionQuestion,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
{
Expand Down Expand Up @@ -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,
{
Expand Down Expand Up @@ -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,
{
Expand Down Expand Up @@ -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({
Expand Down
Loading
Loading