Compare commits

..

1 Commits

Author SHA1 Message Date
LukeParkerDev 541b9b5c78 feat(app): add browser pane UI 2026-07-29 16:43:06 +10:00
10 changed files with 222 additions and 44 deletions
@@ -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")
@@ -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}>
+7 -6
View File
@@ -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) {
-1
View File
@@ -20,7 +20,6 @@ const appLocales = [
"zht",
] as const
const desktopLocales = appLocales.filter((locale) => locale !== "th" && locale !== "tr")
const domains = [
{
name: "app",
+117 -20
View File
@@ -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<FollowupItem, "id" | "prompt" | "context">
@@ -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() {
</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
@@ -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" />
}
@@ -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)
})
})
@@ -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<PermissionRequest, "always">) {
return request.always.length > 0
}
export function SessionPermissionDock(props: {
request: PermissionRequest
responding: boolean
@@ -37,14 +41,16 @@ export function SessionPermissionDock(props: {
<Button variant="ghost" size="normal" onClick={() => props.onDecide("reject")} disabled={props.responding}>
{language.t("ui.permission.deny")}
</Button>
<Button
variant="secondary"
size="normal"
onClick={() => props.onDecide("always")}
disabled={props.responding}
>
{language.t("ui.permission.allowAlways")}
</Button>
<Show when={canRememberPermission(props.request)}>
<Button
variant="secondary"
size="normal"
onClick={() => props.onDecide("always")}
disabled={props.responding}
>
{language.t("ui.permission.allowAlways")}
</Button>
</Show>
<Button variant="primary" size="normal" onClick={() => props.onDecide("once")} disabled={props.responding}>
{language.t("ui.permission.allowOnce")}
</Button>
@@ -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,
})
@@ -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,
}
}