From e15a6fff02415bdbe6dd332c965aeca4845f08ca Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Tue, 14 Jul 2026 08:55:05 +1000 Subject: [PATCH 1/2] fix(app): enable remote session auto-accept --- .../e2e/regression/remote-tab-busy.spec.ts | 22 +++++++++++++++++++ packages/app/src/pages/home.tsx | 4 ++-- packages/app/src/pages/layout-new.tsx | 2 -- packages/app/src/pages/new-session.tsx | 3 ++- packages/app/src/pages/session.tsx | 21 +++++++++++++----- 5 files changed, 42 insertions(+), 10 deletions(-) diff --git a/packages/app/e2e/regression/remote-tab-busy.spec.ts b/packages/app/e2e/regression/remote-tab-busy.spec.ts index 119fc7ee2da..523065e8ef6 100644 --- a/packages/app/e2e/regression/remote-tab-busy.spec.ts +++ b/packages/app/e2e/regression/remote-tab-busy.spec.ts @@ -38,6 +38,28 @@ test("tab busy indicator reflects the tab server's own session status", async ({ await expect(tabA.locator('[data-component="session-progress-indicator-v2"]')).toHaveCount(0) }) +test("auto-accept can be enabled for a session on a remote server", async ({ page }) => { + await mockServers(page) + await page.addInitScript( + ({ serverB }) => { + localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) + localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [serverB] })) + }, + { serverB }, + ) + + await page.goto(`/server/${base64Encode(serverB)}/session/${sessionB.id}`) + await expect(page.getByText(sessionB.title).first()).toBeVisible() + await page.keyboard.press("Control+,") + + const autoAccept = page.locator('[data-action="settings-auto-accept-permissions"]') + const input = autoAccept.locator('[data-slot="switch-input"]') + await expect(autoAccept).toBeVisible() + await expect(input).toBeEnabled() + await autoAccept.locator('[data-slot="switch-control"]').click() + await expect(input).toBeChecked() +}) + function session(id: string, directory: string, title: string) { return { id, diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index d697e53ae71..07035c585f1 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -35,7 +35,7 @@ import { usePlatform } from "@/context/platform" import { DateTime } from "luxon" import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDirectoryPicker } from "@/components/directory-picker" -import { useSettingsDialog } from "@/components/settings-dialog" +import { useSettingsCommand } from "@/components/settings-dialog" import { DialogSelectServer, useServerManagementController } from "@/components/dialog-select-server" import { DialogServerV2 } from "@/components/settings-v2/dialog-server-v2" import { ServerConnection, serverName, useServer } from "@/context/server" @@ -269,7 +269,7 @@ export function NewHome() { const command = useCommand() const notification = useNotification() const marked = useMarked() - const openSettings = useSettingsDialog() + const openSettings = useSettingsCommand() let focusSessionSearch: (() => void) | undefined const [state, setState] = createStore({ search: "", diff --git a/packages/app/src/pages/layout-new.tsx b/packages/app/src/pages/layout-new.tsx index 095d1ebd7ea..45844320cb8 100644 --- a/packages/app/src/pages/layout-new.tsx +++ b/packages/app/src/pages/layout-new.tsx @@ -6,13 +6,11 @@ import { Titlebar, type TitlebarUpdate } from "@/components/titlebar" import { usePlatform } from "@/context/platform" import { setNavigate } from "@/utils/notification-click" import { setV2Toast, ToastRegion } from "@/utils/toast" -import { useSettingsCommand } from "@/components/settings-dialog" export default function NewLayout(props: ParentProps) { const platform = usePlatform() const navigate = useNavigate() setNavigate(navigate) - useSettingsCommand() createEffect(() => setV2Toast(true)) diff --git a/packages/app/src/pages/new-session.tsx b/packages/app/src/pages/new-session.tsx index 19c90b9a384..3cb5a0d4594 100644 --- a/packages/app/src/pages/new-session.tsx +++ b/packages/app/src/pages/new-session.tsx @@ -28,7 +28,7 @@ import { PromptWorkspaceSelector } from "@/components/prompt-workspace-selector" import { useTitlebarRightMount } from "@/components/titlebar" import { useCommand } from "@/context/command" import { useProviders } from "@/hooks/use-providers" -import { useSettingsDialog } from "@/components/settings-dialog" +import { useSettingsCommand, useSettingsDialog } from "@/components/settings-dialog" import { Persist, persisted } from "@/utils/persist" import createPresence from "solid-presence" import { useLocal } from "@/context/local" @@ -54,6 +54,7 @@ export default function NewSessionPage() { const command = useCommand() const providers = useProviders(() => sdk().directory) const openProviderSettings = useSettingsDialog("providers") + useSettingsCommand() const route = useSessionKey() const [searchParams, setSearchParams] = useSearchParams<{ draftId?: string; prompt?: string }>() const local = useLocal() diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 7ce4627135e..e24ae415560 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -58,6 +58,7 @@ import { useSync } from "@/context/sync" import { useTabs } from "@/context/tabs" import { TerminalProvider, useTerminal } from "@/context/terminal" import { PromptInput } from "@/components/prompt-input" +import { useSettingsCommand } from "@/components/settings-dialog" import { type FollowupDraft, sendFollowupDraft } from "@/components/prompt-input/submit" import { createPromptInputController, @@ -153,13 +154,23 @@ export function SessionPage() { // workspace-scoped state (terminal, directory providers) lives below. export function TargetSessionRouteContent() { const params = useParams<{ serverKey: string; id: string }>() + const serverSync = useServerSync() + const directory = createMemo(() => serverSync().session.lineage.peek(params.id)?.session.directory) return ( - - - + + + + + + ) } +function TargetSessionSettingsCommand() { + useSettingsCommand() + return null +} + export function SessionRouteErrorBoundary( props: ParentProps<{ sessionID?: string; serverKey?: ServerConnection.Key; padded?: boolean }>, ) { @@ -281,10 +292,10 @@ function TargetServerScopedProviders( props: ParentProps<{ directory?: () => string | undefined; sessionID?: () => string | undefined }>, ) { return ( - + <> {props.children} - + ) } From 56878236101d4a8b88be4df8e00f06a66915bdcd Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Tue, 14 Jul 2026 09:29:28 +1000 Subject: [PATCH 2/2] fix(app): scope remote settings models --- .../remote-session-settings.spec.ts | 138 ++++++++++++++++++ .../e2e/regression/remote-tab-busy.spec.ts | 22 --- packages/app/src/pages/session.tsx | 34 ++--- 3 files changed, 155 insertions(+), 39 deletions(-) create mode 100644 packages/app/e2e/regression/remote-session-settings.spec.ts diff --git a/packages/app/e2e/regression/remote-session-settings.spec.ts b/packages/app/e2e/regression/remote-session-settings.spec.ts new file mode 100644 index 00000000000..320cc2c1c62 --- /dev/null +++ b/packages/app/e2e/regression/remote-session-settings.spec.ts @@ -0,0 +1,138 @@ +import { base64Encode } from "@opencode-ai/core/util/encode" +import { expect, test, type Page, type Route } from "@playwright/test" + +const serverA = "http://127.0.0.1:4096" +const serverB = "http://127.0.0.1:4097" +const directoryA = "C:/server-a" +const directoryB = "/home/server-b" +const sessionB = { + id: "ses_server_b", + slug: "ses_server_b", + projectID: "project-server-b", + directory: directoryB, + title: "Server B session", + version: "dev", + time: { created: 1, updated: 1 }, +} + +test("session settings use the remote server context", async ({ page }) => { + const permissionRequests: string[] = [] + await mockServers(page, permissionRequests) + await page.addInitScript( + ({ serverB }) => { + localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) + localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [serverB] })) + }, + { serverB }, + ) + + await page.goto(`/server/${base64Encode(serverB)}/session/${sessionB.id}`) + await expect(page.getByText(sessionB.title).first()).toBeVisible() + await page.keyboard.press(process.platform === "darwin" ? "Meta+," : "Control+,") + + const dialog = page.locator(".settings-v2-dialog") + const autoAccept = dialog.locator('[data-action="settings-auto-accept-permissions"]') + const input = autoAccept.getByRole("switch") + await expect(autoAccept).toBeVisible() + await expect(input).toBeEnabled() + permissionRequests.length = 0 + await autoAccept.locator('[data-slot="switch-control"]').click() + await expect(input).toBeChecked() + await expect + .poll(() => + permissionRequests.some((request) => { + const url = new URL(request) + return url.origin === serverB && url.searchParams.get("directory") === directoryB + }), + ) + .toBe(true) + expect(permissionRequests.every((request) => new URL(request).origin === serverB)).toBe(true) + + await dialog.getByRole("tab", { name: "Models" }).click() + await expect(dialog.getByRole("switch", { name: "Server B Model" })).toBeEnabled() + await expect(dialog.getByRole("switch", { name: "Server A Model" })).toHaveCount(0) +}) + +async function mockServers(page: Page, permissionRequests: string[]) { + await page.route("**/*", async (route) => { + const url = new URL(route.request().url()) + if (url.origin !== serverA && url.origin !== serverB) return route.fallback() + const remote = url.origin === serverB + const directory = remote ? directoryB : directoryA + const requestDirectory = url.searchParams.get("directory") + if (requestDirectory && requestDirectory !== directory) return json(route, { name: "InvalidDirectory" }, 500) + if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route) + if (url.pathname === "/global/health") return json(route, { healthy: true }) + if (url.pathname === "/session/status") return json(route, {}) + if (url.pathname === "/session") return json(route, remote ? [sessionB] : []) + if (url.pathname === `/session/${sessionB.id}` && remote) return json(route, sessionB) + if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404) + if (url.pathname === `/session/${sessionB.id}/message`) return json(route, []) + if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, []) + if (url.pathname === "/permission") { + permissionRequests.push(url.toString()) + return json(route, []) + } + if (["/skill", "/command", "/lsp", "/formatter", "/question", "/vcs/diff", "/pty/shells"].includes(url.pathname)) + return json(route, []) + if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {}) + if (url.pathname === "/provider") return json(route, provider(remote ? "server-b" : "server-a")) + if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }]) + if (url.pathname === "/project" || url.pathname === "/project/current") { + const project = { + id: remote ? sessionB.projectID : "project-server-a", + worktree: directory, + vcs: "git", + time: { created: 1, updated: 1 }, + sandboxes: [], + } + return json(route, url.pathname === "/project" ? [project] : project) + } + if (url.pathname === "/path") + return json(route, { + state: directory, + config: directory, + worktree: directory, + directory, + home: directory, + }) + if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" }) + return json(route, {}) + }) +} + +function provider(id: string) { + const name = id === "server-b" ? "Server B" : "Server A" + return { + all: [ + { + id, + name: `${name} Provider`, + models: { + [id]: { + id, + name: `${name} Model`, + family: id, + release_date: "2026-01-01", + limit: { context: 200_000 }, + }, + }, + }, + ], + connected: [id], + default: { providerID: id, modelID: id }, + } +} + +function json(route: Route, body: unknown, status = 200) { + return route.fulfill({ + status, + contentType: "application/json", + headers: { "access-control-allow-origin": "*" }, + body: JSON.stringify(body), + }) +} + +function sse(route: Route) { + return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" }) +} diff --git a/packages/app/e2e/regression/remote-tab-busy.spec.ts b/packages/app/e2e/regression/remote-tab-busy.spec.ts index 523065e8ef6..119fc7ee2da 100644 --- a/packages/app/e2e/regression/remote-tab-busy.spec.ts +++ b/packages/app/e2e/regression/remote-tab-busy.spec.ts @@ -38,28 +38,6 @@ test("tab busy indicator reflects the tab server's own session status", async ({ await expect(tabA.locator('[data-component="session-progress-indicator-v2"]')).toHaveCount(0) }) -test("auto-accept can be enabled for a session on a remote server", async ({ page }) => { - await mockServers(page) - await page.addInitScript( - ({ serverB }) => { - localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) - localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [serverB] })) - }, - { serverB }, - ) - - await page.goto(`/server/${base64Encode(serverB)}/session/${sessionB.id}`) - await expect(page.getByText(sessionB.title).first()).toBeVisible() - await page.keyboard.press("Control+,") - - const autoAccept = page.locator('[data-action="settings-auto-accept-permissions"]') - const input = autoAccept.locator('[data-slot="switch-input"]') - await expect(autoAccept).toBeVisible() - await expect(input).toBeEnabled() - await autoAccept.locator('[data-slot="switch-control"]').click() - await expect(input).toBeChecked() -}) - function session(id: string, directory: string, title: string) { return { id, diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index e24ae415560..ca415d77c46 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -157,12 +157,14 @@ export function TargetSessionRouteContent() { const serverSync = useServerSync() const directory = createMemo(() => serverSync().session.lineage.peek(params.id)?.session.directory) return ( - + // Settings must keep the complete target-server context and remain registered + // when session content falls back to the route error boundary. + params.id}> - + ) } @@ -259,19 +261,17 @@ function ResolvedTargetSessionRoute() { }) return ( - params.id}> - {/* Non-keyed: closes only while the target's directory is unknown (uncached - lineage mid-resolution), which tears down the workspace subtree including - the terminal. Same-workspace tab switches keep it open because warm - targets resolve synchronously from the sync cache. */} - - - - - - - - + // Non-keyed: closes only while the target's directory is unknown (uncached + // lineage mid-resolution), which tears down the workspace subtree including + // the terminal. Same-workspace tab switches keep it open because warm + // targets resolve synchronously from the sync cache. + + + + + + + ) } @@ -292,10 +292,10 @@ function TargetServerScopedProviders( props: ParentProps<{ directory?: () => string | undefined; sessionID?: () => string | undefined }>, ) { return ( - <> + {props.children} - + ) }