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, } }