Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
1 change: 1 addition & 0 deletions packages/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"type": "module",
"exports": {
".": "./src/index.ts",
"./browser-pane": "./src/browser-pane.ts",
"./desktop-menu": "./src/desktop-menu.ts",
"./updater": "./src/updater.ts",
"./wsl/types": "./src/wsl/types.ts",
Expand Down
40 changes: 40 additions & 0 deletions packages/app/src/browser-pane.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import type { ServerProtocol } from "./utils/server-protocol"

export type BrowserPaneTarget = Readonly<{ sessionID: string }>

export type BrowserPaneEndpoint = Readonly<{ url: string; username?: string; password?: string }>

export type BrowserPaneBinding = BrowserPaneTarget &
Readonly<{ bindingID: string; endpoint: BrowserPaneEndpoint }>

export type BrowserPaneBounds = { x: number; y: number; width: number; height: number }

export type BrowserPaneLayout = {
visible: boolean
bounds?: BrowserPaneBounds
}

export type BrowserPaneRegistration = {
setLayout(layout?: BrowserPaneLayout): void
close(): void
}

export type BrowserPanePlatform = {
register(binding: BrowserPaneBinding, onOpen: () => void): BrowserPaneRegistration
}

export function browserPaneAvailable(input: {
platform: boolean
sessionID?: string
protocol?: ServerProtocol
}) {
return input.platform && !!input.sessionID && input.protocol === "v2"
}

export function createBrowserPaneBinding(input: BrowserPaneTarget & { endpoint: BrowserPaneEndpoint }) {
return {
sessionID: input.sessionID,
bindingID: globalThis.crypto.randomUUID(),
endpoint: input.endpoint,
} satisfies BrowserPaneBinding
}
2 changes: 0 additions & 2 deletions packages/app/src/components/session/session-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -527,8 +527,6 @@ type SessionHeaderV2ActionsState = {
}

function SessionHeaderV2Actions(props: { state: SessionHeaderV2ActionsState }) {
const language = useLanguage()

return (
<div class="flex items-center gap-2">
<Show when={props.state.statusVisible}>
Expand Down
13 changes: 7 additions & 6 deletions packages/app/src/context/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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) {
Expand Down
14 changes: 14 additions & 0 deletions packages/app/src/context/platform.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,17 @@ import type { DesktopMenuAction } from "../desktop-menu"
import { ServerConnection } from "./server"
import type { WslServersPlatform } from "../wsl/types"
import type { UpdaterPlatform } from "../updater"
import type { BrowserPanePlatform } from "../browser-pane"
export type {
BrowserPaneBinding,
BrowserPaneBounds,
BrowserPaneEndpoint,
BrowserPaneLayout,
BrowserPanePlatform,
BrowserPaneRegistration,
BrowserPaneTarget,
} from "../browser-pane"
export { browserPaneAvailable, createBrowserPaneBinding } from "../browser-pane"

type PickerPaths = string | string[] | null
type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean }
Expand Down Expand Up @@ -123,6 +134,9 @@ type PlatformBase = {

/** Record a fatal renderer error in platform logs (desktop only) */
recordFatalRendererError?(error: FatalRendererErrorLog): Promise<void>

/** Native browser pane hosted by the platform (desktop only). */
browserPane?: BrowserPanePlatform
}

export type Platform = PlatformBase &
Expand Down
1 change: 0 additions & 1 deletion packages/app/src/i18n/parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ const appLocales = [
"zht",
] as const
const desktopLocales = appLocales.filter((locale) => locale !== "th" && locale !== "tr")

const domains = [
{
name: "app",
Expand Down
137 changes: 117 additions & 20 deletions packages/app/src/pages/session.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
Match,
Switch,
createMemo,
createResource,
createEffect,
createComputed,
createSignal,
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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<FollowupItem, "id" | "prompt" | "context">
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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(
() =>
Expand All @@ -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
Expand Down Expand Up @@ -505,6 +586,7 @@ export default function Page() {
const desktopV2PanelLayout = createMemo(() =>
sessionPanelLayout({
review: desktopV2ReviewOpen(),
browser: desktopBrowserOpen(),
terminal: desktopTerminalOpen(),
files: desktopFileTreeOpen(),
}),
Expand Down Expand Up @@ -2298,27 +2380,42 @@ export default function Page() {
</div>

<Show when={!newSessionDesign() && desktopSidePanelOpen()}>
<Suspense>
<SessionSidePanel
canReview={canReview}
diffs={reviewDiffs}
diffsReady={reviewReady}
empty={reviewEmptyText}
hasReview={hasReview}
reviewHasFocusableContent={hasReview}
reviewCount={reviewCount}
reviewPanel={reviewPanel}
activeDiff={activeReviewFile()}
focusReviewDiff={focusReviewDiff}
reviewSnap={ui.reviewSnap}
size={size}
/>
</Suspense>
<Show
when={desktopBrowserBinding()}
keyed
fallback={
<Suspense>
<SessionSidePanel
canReview={canReview}
diffs={reviewDiffs}
diffsReady={reviewReady}
empty={reviewEmptyText}
hasReview={hasReview}
reviewHasFocusableContent={hasReview}
reviewCount={reviewCount}
reviewPanel={reviewPanel}
activeDiff={activeReviewFile()}
focusReviewDiff={focusReviewDiff}
reviewSnap={ui.reviewSnap}
size={size}
/>
</Suspense>
}
>
{(registration) => <SessionBrowserPane registration={registration} />}
</Show>
</Show>
<Show when={newSessionDesign()}>
<Show when={isDesktop() ? desktopV2PanelLayout().visible : terminalOpen()}>
<div class="min-w-0 h-full flex flex-1 flex-col">
<Show when={isDesktop() && (desktopV2ReviewOpen() || desktopFileTreeOpen())}>
<Show when={desktopBrowserBinding()} keyed>
{(registration) => (
<div class="min-h-0 flex-1">
<SessionBrowserPane registration={registration} />
</div>
)}
</Show>
<Show when={isDesktop() && !desktopBrowserOpen() && (desktopV2ReviewOpen() || desktopFileTreeOpen())}>
<div class="min-h-0 flex-1">
<Suspense>
<SessionSidePanel
Expand Down
64 changes: 64 additions & 0 deletions packages/app/src/pages/session/browser-pane.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { createEffect, onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import { usePlatform, type BrowserPaneRegistration } from "@/context/platform"

export function SessionBrowserPane(props: { registration: BrowserPaneRegistration }) {
const platform = usePlatform()
const dialog = useDialog()
const [store, setStore] = createStore({
visible: typeof document === "undefined" || document.visibilityState === "visible",
})
let surface: HTMLDivElement | undefined
let frame: number | undefined
let until = 0

const measure = () => {
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 <div ref={surface} class="size-full bg-background-base" />
}
Loading
Loading