diff --git a/packages/app/e2e/regression/session-request-docks.spec.ts b/packages/app/e2e/regression/session-request-docks.spec.ts
index 5ea9d4f761..d8fd8e5c72 100644
--- a/packages/app/e2e/regression/session-request-docks.spec.ts
+++ b/packages/app/e2e/regression/session-request-docks.spec.ts
@@ -94,7 +94,7 @@ test("shows a pending permission dock", async ({ page }) => {
await expect(permission).toBeVisible()
await expect(permission.getByText("git status")).toBeVisible()
await expect(permission.getByText("git diff")).toBeVisible()
- await expect(permission.locator('[data-slot="permission-footer-actions"] button')).toHaveCount(3)
+ await expect(permission.locator('[data-slot="permission-footer-actions"] button')).toHaveCount(2)
await expect(page.locator('[data-component="session-composer"]')).toHaveCount(0)
const reply = page.waitForRequest((request) => request.method() === "POST")
diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx
index da1770cd13..9c7e924654 100644
--- a/packages/app/src/components/session/session-header.tsx
+++ b/packages/app/src/components/session/session-header.tsx
@@ -527,8 +527,6 @@ type SessionHeaderV2ActionsState = {
}
function SessionHeaderV2Actions(props: { state: SessionHeaderV2ActionsState }) {
- const language = useLanguage()
-
return (
diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx
index 6235f35c45..374fbb9f12 100644
--- a/packages/app/src/context/layout.tsx
+++ b/packages/app/src/context/layout.tsx
@@ -711,9 +711,9 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
return
}
setStore("fileTree", "tab", tab)
- },
- open() {
- if (!store.fileTree) {
+ },
+ open() {
+ if (!store.fileTree) {
setStore("fileTree", { opened: true, width: DEFAULT_FILE_TREE_WIDTH, tab: "changes" })
return
}
@@ -726,12 +726,13 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
}
setStore("fileTree", "opened", false)
},
- toggle() {
- if (!store.fileTree) {
+ toggle() {
+ const next = !(store.fileTree?.opened ?? true)
+ if (!store.fileTree) {
setStore("fileTree", { opened: true, width: DEFAULT_FILE_TREE_WIDTH, tab: "changes" })
return
}
- setStore("fileTree", "opened", (x) => !x)
+ setStore("fileTree", "opened", next)
},
resize(width: number) {
if (!store.fileTree) {
diff --git a/packages/app/src/i18n/parity.test.ts b/packages/app/src/i18n/parity.test.ts
index 566be37e64..4dfde8e7c2 100644
--- a/packages/app/src/i18n/parity.test.ts
+++ b/packages/app/src/i18n/parity.test.ts
@@ -20,7 +20,6 @@ const appLocales = [
"zht",
] as const
const desktopLocales = appLocales.filter((locale) => locale !== "th" && locale !== "tr")
-
const domains = [
{
name: "app",
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx
index c6e3a5fcbb..bc6f604465 100644
--- a/packages/app/src/pages/session.tsx
+++ b/packages/app/src/pages/session.tsx
@@ -11,6 +11,7 @@ import {
Match,
Switch,
createMemo,
+ createResource,
createEffect,
createComputed,
createSignal,
@@ -50,7 +51,8 @@ import { useLayout } from "@/context/layout"
import { ModelsProvider } from "@/context/models"
import { useNotification } from "@/context/notification"
import { PromptProvider, usePrompt } from "@/context/prompt"
-import { usePlatform } from "@/context/platform"
+import { browserPaneAvailable, createBrowserPaneBinding, usePlatform } from "@/context/platform"
+import type { BrowserPaneRegistration } from "@/context/platform"
import { SDKProvider, useSDK } from "@/context/sdk"
import { useServerSDK } from "@/context/server-sdk"
import { ServerConnection, serverName, useServer } from "@/context/server"
@@ -103,6 +105,7 @@ import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/sessio
import { useUsageExceededDialogs } from "./session/usage-exceeded-dialogs"
import { createSessionOwnership } from "./session/session-ownership"
import { createSessionLineage } from "./session/session-lineage"
+import { SessionBrowserPane } from "./session/browser-pane"
type FollowupItem = FollowupDraft & { id: string }
type FollowupEdit = Pick
@@ -352,6 +355,7 @@ function SessionPanelFrame(props: ParentProps<{ newLayout: boolean; raised?: boo
export default function Page() {
const serverSync = useServerSync()
+ const server = useServer()
const layout = useLayout()
const local = useLocal()
const file = useFile()
@@ -447,12 +451,87 @@ export default function Page() {
const isDesktop = createMediaQuery("(min-width: 768px)")
const size = createSizing()
+ const [serverProtocol] = createResource(
+ () => serverSDK().protocol,
+ (protocol) => protocol,
+ )
+ const browserAvailable = createMemo(() =>
+ browserPaneAvailable({
+ platform: !!platform.browserPane,
+ sessionID: params.id,
+ protocol: serverProtocol(),
+ }),
+ )
+ const browserServer = createMemo(
+ () => {
+ if (!browserAvailable()) return undefined
+ const routeServer = serverSDK().server
+ const serverKey = ServerConnection.key(routeServer)
+ const connection = server.list.find((item) => ServerConnection.key(item) === serverKey) ?? routeServer
+ return {
+ endpoint: {
+ url: connection.http.url,
+ username: connection.http.username,
+ password: connection.http.password,
+ },
+ }
+ },
+ undefined,
+ {
+ equals: (left, right) => {
+ if (!left || !right) return left === right
+ return (
+ left.endpoint.url === right.endpoint.url &&
+ left.endpoint.username === right.endpoint.username &&
+ left.endpoint.password === right.endpoint.password
+ )
+ },
+ },
+ )
+ const browserBinding = createMemo(() => {
+ if (!params.id) return undefined
+ const target = browserServer()
+ if (!target) return undefined
+ return createBrowserPaneBinding({ sessionID: params.id, endpoint: target.endpoint })
+ })
+ const [browserPane, setBrowserPane] = createStore<{
+ registration?: BrowserPaneRegistration
+ opened: boolean
+ }>({ opened: false })
+ createEffect(() => {
+ const binding = browserBinding()
+ const browser = platform.browserPane
+ if (!binding || !browser) {
+ setBrowserPane({ registration: undefined, opened: false })
+ return
+ }
+ const registration = browser.register(binding, () => {
+ view().reviewPanel.close()
+ layout.fileTree.close()
+ setBrowserPane("opened", true)
+ })
+ setBrowserPane("registration", registration)
+ onCleanup(() => {
+ setBrowserPane({ registration: undefined, opened: false })
+ registration.close()
+ })
+ })
+ createEffect(() => {
+ if (!browserPane.opened) return
+ if (!view().reviewPanel.opened() && !layout.fileTree.opened()) return
+ setBrowserPane("opened", false)
+ })
+ const desktopBrowserBinding = createMemo(() => {
+ if (!isDesktop() || !browserAvailable() || !browserPane.opened) return undefined
+ return browserPane.registration
+ })
+ const desktopBrowserOpen = createMemo(() => !!desktopBrowserBinding())
const desktopReviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
const desktopV2ReviewOpen = createMemo(() => newSessionDesign() && desktopReviewOpen() && !!params.id)
const terminalOpen = createMemo(() => view().terminal.opened())
const desktopTerminalOpen = createMemo(() => isDesktop() && terminalOpen())
const desktopInlineTerminalOnlyOpen = createMemo(
- () => newSessionDesign() && desktopTerminalOpen() && !desktopV2ReviewOpen(),
+ () => newSessionDesign() && desktopTerminalOpen() && !desktopV2ReviewOpen() && !desktopBrowserOpen(),
)
const desktopFileTreeOpen = createMemo(
() =>
@@ -463,7 +542,9 @@ export default function Page() {
}),
)
const desktopSessionResizeOpen = createMemo(() =>
- newSessionDesign() ? desktopV2ReviewOpen() || desktopTerminalOpen() : desktopReviewOpen(),
+ newSessionDesign()
+ ? desktopV2ReviewOpen() || desktopBrowserOpen() || desktopTerminalOpen()
+ : desktopReviewOpen() || desktopBrowserOpen(),
)
const desktopSidePanelOpen = createMemo(() => desktopSessionResizeOpen() || desktopFileTreeOpen())
let panelRow: HTMLDivElement | undefined
@@ -505,6 +586,7 @@ export default function Page() {
const desktopV2PanelLayout = createMemo(() =>
sessionPanelLayout({
review: desktopV2ReviewOpen(),
+ browser: desktopBrowserOpen(),
terminal: desktopTerminalOpen(),
files: desktopFileTreeOpen(),
}),
@@ -2298,27 +2380,42 @@ export default function Page() {
-
-
-
+
+
+
+ }
+ >
+ {(registration) => }
+
-
+
+ {(registration) => (
+
+
+
+ )}
+
+
{
+ frame = undefined
+ if (!surface) return
+ const rect = surface.getBoundingClientRect()
+ const zoom = platform.webviewZoom?.() ?? 1
+ const left = Math.round(rect.left * zoom)
+ const top = Math.round(rect.top * zoom)
+ const right = Math.round(rect.right * zoom)
+ const bottom = Math.round(rect.bottom * zoom)
+ props.registration.setLayout({
+ visible: store.visible && !dialog.active,
+ bounds: { x: left, y: top, width: Math.max(0, right - left), height: Math.max(0, bottom - top) },
+ })
+ if (performance.now() < until) frame = requestAnimationFrame(measure)
+ }
+
+ const schedule = (duration = 0) => {
+ until = Math.max(until, performance.now() + duration)
+ if (frame !== undefined) return
+ frame = requestAnimationFrame(measure)
+ }
+
+ createEffect(() => {
+ props.registration
+ platform.webviewZoom?.()
+ dialog.active
+ store.visible
+ schedule(300)
+ })
+
+ onMount(() => {
+ const resize = new ResizeObserver(() => schedule())
+ if (surface) resize.observe(surface)
+ const onResize = () => schedule(300)
+ const onVisibility = () => setStore("visible", document.visibilityState === "visible")
+ window.addEventListener("resize", onResize)
+ document.addEventListener("visibilitychange", onVisibility)
+ schedule(300)
+ onCleanup(() => {
+ resize.disconnect()
+ window.removeEventListener("resize", onResize)
+ document.removeEventListener("visibilitychange", onVisibility)
+ if (frame !== undefined) cancelAnimationFrame(frame)
+ props.registration.setLayout()
+ })
+ })
+
+ return
+}
diff --git a/packages/app/src/pages/session/composer/session-permission-dock.test.ts b/packages/app/src/pages/session/composer/session-permission-dock.test.ts
new file mode 100644
index 0000000000..2ce5554c60
--- /dev/null
+++ b/packages/app/src/pages/session/composer/session-permission-dock.test.ts
@@ -0,0 +1,9 @@
+import { describe, expect, test } from "bun:test"
+import { canRememberPermission } from "./session-permission-dock"
+
+describe("canRememberPermission", () => {
+ test("hides Always when the request has no persistent resources", () => {
+ expect(canRememberPermission({ always: [] })).toBe(false)
+ expect(canRememberPermission({ always: ["https://example.com/*"] })).toBe(true)
+ })
+})
diff --git a/packages/app/src/pages/session/composer/session-permission-dock.tsx b/packages/app/src/pages/session/composer/session-permission-dock.tsx
index 35a62f75cf..3694c6c4c6 100644
--- a/packages/app/src/pages/session/composer/session-permission-dock.tsx
+++ b/packages/app/src/pages/session/composer/session-permission-dock.tsx
@@ -5,6 +5,10 @@ import { DockPrompt } from "@opencode-ai/session-ui/dock-prompt"
import { Icon } from "@opencode-ai/ui/icon"
import { useLanguage } from "@/context/language"
+export function canRememberPermission(request: Pick) {
+ return request.always.length > 0
+}
+
export function SessionPermissionDock(props: {
request: PermissionRequest
responding: boolean
@@ -37,14 +41,16 @@ export function SessionPermissionDock(props: {
-
+
+
+
diff --git a/packages/app/src/pages/session/session-panel-layout.test.ts b/packages/app/src/pages/session/session-panel-layout.test.ts
index 499dd7411a..29c9f4d896 100644
--- a/packages/app/src/pages/session/session-panel-layout.test.ts
+++ b/packages/app/src/pages/session/session-panel-layout.test.ts
@@ -3,15 +3,19 @@ import { sessionPanelLayout } from "./session-panel-layout"
describe("sessionPanelLayout", () => {
test("keeps one V2 owner while changing panel geometry", () => {
- expect(sessionPanelLayout({ review: false, terminal: false, files: false })).toEqual({
+ expect(sessionPanelLayout({ review: false, browser: false, terminal: false, files: false })).toEqual({
visible: false,
stacked: false,
})
- expect(sessionPanelLayout({ review: false, terminal: true, files: false })).toEqual({
+ expect(sessionPanelLayout({ review: false, browser: false, terminal: true, files: false })).toEqual({
visible: true,
stacked: false,
})
- expect(sessionPanelLayout({ review: true, terminal: true, files: false })).toEqual({
+ expect(sessionPanelLayout({ review: true, browser: false, terminal: true, files: false })).toEqual({
+ visible: true,
+ stacked: true,
+ })
+ expect(sessionPanelLayout({ review: false, browser: true, terminal: true, files: false })).toEqual({
visible: true,
stacked: true,
})
diff --git a/packages/app/src/pages/session/session-panel-layout.ts b/packages/app/src/pages/session/session-panel-layout.ts
index 49feaebede..7c5e22f8ff 100644
--- a/packages/app/src/pages/session/session-panel-layout.ts
+++ b/packages/app/src/pages/session/session-panel-layout.ts
@@ -1,6 +1,6 @@
-export function sessionPanelLayout(input: { review: boolean; terminal: boolean; files: boolean }) {
+export function sessionPanelLayout(input: { review: boolean; browser: boolean; terminal: boolean; files: boolean }) {
return {
- visible: input.review || input.terminal || input.files,
- stacked: input.review && input.terminal,
+ visible: input.review || input.browser || input.terminal || input.files,
+ stacked: (input.review || input.browser) && input.terminal,
}
}