Bläddra i källkod

fix(app): enable remote session auto-accept (#36777)

Luke Parker 1 månad sedan
förälder
incheckning
cdca203be2

+ 138 - 0
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" })
+}

+ 2 - 2
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: "",

+ 0 - 2
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))
 

+ 2 - 1
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()

+ 27 - 16
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 (
-    <SessionRouteErrorBoundary sessionID={params.id} serverKey={requireServerKey(params.serverKey)} padded>
-      <ResolvedTargetSessionRoute />
-    </SessionRouteErrorBoundary>
+    // Settings must keep the complete target-server context and remain registered
+    // when session content falls back to the route error boundary.
+    <TargetServerScopedProviders directory={directory} sessionID={() => params.id}>
+      <TargetSessionSettingsCommand />
+      <SessionRouteErrorBoundary sessionID={params.id} serverKey={requireServerKey(params.serverKey)} padded>
+        <ResolvedTargetSessionRoute />
+      </SessionRouteErrorBoundary>
+    </TargetServerScopedProviders>
   )
 }
 
+function TargetSessionSettingsCommand() {
+  useSettingsCommand()
+  return null
+}
+
 export function SessionRouteErrorBoundary(
   props: ParentProps<{ sessionID?: string; serverKey?: ServerConnection.Key; padded?: boolean }>,
 ) {
@@ -250,19 +263,17 @@ function ResolvedTargetSessionRoute() {
   })
 
   return (
-    <TargetServerScopedProviders directory={directory} sessionID={() => 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. */}
-      <Show when={directory()}>
-        <SDKProvider directory={targetDirectory}>
-          <DirectoryDataProvider directory={targetDirectory} server={serverKey}>
-            <TargetSessionPage />
-          </DirectoryDataProvider>
-        </SDKProvider>
-      </Show>
-    </TargetServerScopedProviders>
+    // 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.
+    <Show when={directory()}>
+      <SDKProvider directory={targetDirectory}>
+        <DirectoryDataProvider directory={targetDirectory} server={serverKey}>
+          <TargetSessionPage />
+        </DirectoryDataProvider>
+      </SDKProvider>
+    </Show>
   )
 }