From cdca203be28408c11935e7923e0ec3d5159b51f0 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Tue, 14 Jul 2026 21:59:02 +1000 Subject: [PATCH] fix(app): enable remote session auto-accept (#36777) --- .../remote-session-settings.spec.ts | 138 ++++++++++++++++++ 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 | 43 ++++-- 5 files changed, 169 insertions(+), 21 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/src/pages/home.tsx b/packages/app/src/pages/home.tsx index da309ab0f32..08c00ccd8de 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" @@ -275,7 +275,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 810b4a12685..43029eb827e 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 { setCursorPosition } from "@/components/prompt-input/editor-dom" import { promptLength } from "@/components/prompt-input/history" import { type FollowupDraft, sendFollowupDraft } from "@/components/prompt-input/submit" @@ -155,13 +156,25 @@ 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 ( - - - + // Settings must keep the complete target-server context and remain registered + // when session content falls back to the route error boundary. + params.id}> + + + + + ) } +function TargetSessionSettingsCommand() { + useSettingsCommand() + return null +} + export function SessionRouteErrorBoundary( props: ParentProps<{ sessionID?: string; serverKey?: ServerConnection.Key; padded?: boolean }>, ) { @@ -250,19 +263,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. + + + + + + + ) }