fix(app): close browser tabs from the address field (#54501)

This commit is contained in:
Kit Langton
2026-10-13 13:13:28 -07:00
committed by GitHub
Unverified
parent e9def88a77
commit 84e095526c
6 changed files with 243 additions and 33 deletions
@@ -1,4 +1,7 @@
import { expect, sourceURL, story } from "../../storybook/playwright/story"
import type { ParentProps } from "solid-js"
import type { Command } from "@opencode/gui-extensions/sdk"
import type { Item } from "../src/runtime/extension/host"
const source = (path: string) => sourceURL(new URL(path, import.meta.url))
@@ -9,21 +12,49 @@ const modules = {
language: source("../src/runtime/i18n/language.tsx"),
browser: source("../../gui-extensions/src/browser/index.ts"),
browserRenderer: source("../../gui-extensions/src/browser/renderer.tsx"),
browserPanel: source("../../gui-extensions/src/browser/panel.tsx"),
file: source("../../gui-extensions/src/file/index.ts"),
fileRenderer: source("../../gui-extensions/src/file/renderer.tsx"),
commands: source("../src/shell/commands/command.tsx"),
sdk: source("../../gui-extensions/src/sdk/index.ts"),
render: source("../src/runtime/extension/render.tsx"),
settings: source("../src/settings/model.tsx"),
platform: source("../src/runtime/platform/platform.tsx"),
solid: source("./extension-host.fixture.tsx"),
}
story.beforeEach(async ({ mount, page }) => {
// Any story loads the app styles; the fixture mounts the real side region and extensions beside it.
await mount("ui-line-comment--editor")
await page.evaluate(async (modules) => {
const [{ mountBrowserRegion }, host, panels, language, browser, file] = await Promise.all([
const [
{ mountBrowserRegion },
host,
panels,
language,
browser,
file,
commands,
sdk,
renderer,
settings,
platform,
solid,
] = await Promise.all([
import(modules.fixture),
import(modules.host),
import(modules.panels),
import(modules.language),
import(modules.browser),
import(modules.file),
import(modules.commands),
import(modules.sdk),
import(modules.render),
import(modules.settings),
import(modules.platform),
import(modules.solid),
// Load the pane before mounting: Vite must not reload the page to optimize its dependencies mid-fixture.
import(modules.browserPanel),
])
mountBrowserRegion({
@@ -35,10 +66,87 @@ story.beforeEach(async ({ mount, page }) => {
{ ...browser.default, renderer: () => import(modules.browserRenderer) },
{ ...file.default, renderer: () => import(modules.fileRenderer) },
],
Shell: (props: ParentProps) =>
solid.createComponent(platform.PlatformProvider, {
value: { platform: "web", openExternal() {}, restart: async () => undefined, notify: async () => undefined },
get children() {
return solid.createComponent(settings.SettingsProvider, {
get children() {
return solid.createComponent(commands.CommandProvider, {
get children() {
return props.children
},
})
},
})
},
}),
Commands: (props: { close: () => void }) => {
const command = commands.useCommand()
const extensions = host.useExtensionHost()
command.register("fixture-session", () => [
{
id: "tab.close",
title: "Close session tab",
keybind: "mod+w",
onSelect: props.close,
},
])
// Storybook aliases the app's command hook to a no-op. Bridge the real extension contributions
// to the real dispatcher imported by source URL, so the shortcut runs rather than its alias.
command.register("fixture-extensions", () =>
extensions.items(sdk.Command).map((item: Item<Command>) => {
const scope = item.value.scope
return {
id: `${item.extension}.${item.value.id}`,
title: item.value.title,
keybind: item.value.bind,
disabled: item.value.enabled === false,
hidden: item.value.hidden,
editable: item.value.editable,
when: scope
? (event: KeyboardEvent) => event.target instanceof Element && !!event.target.closest(scope)
: undefined,
onSelect: () => item.value.run(),
}
}),
)
return null
},
Contribution: renderer.Contribution,
})
}, modules)
})
story("closes a blank browser tab from its address field without closing the session", async ({ page }) => {
const root = page.getByTestId("browser-region-fixture")
const outside = root.getByRole("button", { name: "Beta", exact: true })
await expect(root.getByText("Registrations: 1", { exact: true })).toBeVisible()
await outside.click()
await expect(root.getByText("Registrations: 2", { exact: true })).toBeVisible()
await root.getByRole("button", { name: "Blank page", exact: true }).click()
await expect(root.getByRole("tab", { name: "New tab", exact: true })).toHaveAttribute("aria-selected", "true")
// Prove the scope while browser.close is still registered.
await outside.focus()
await outside.press("ControlOrMeta+w")
await expect(root.getByTestId("session-state")).toHaveText("closed")
await expect(root.getByTestId("browser-closes")).toHaveText("[]")
await expect(root.getByRole("tab", { name: "New tab", exact: true })).toBeVisible()
await outside.click()
await expect(root.getByTestId("session-state")).toHaveText("open")
const address = root.getByRole("combobox", { name: "Browser address", exact: true })
await address.fill("example.com")
await expect(address).toBeFocused()
await address.press("ControlOrMeta+w")
await expect(root.getByRole("tab", { name: "New tab", exact: true })).toHaveCount(0)
await expect(root.getByTestId("browser-closes")).toHaveText('["tab_33333333-3333-3333-3333-333333333333"]')
await expect(root.getByTestId("session-state")).toHaveText("open")
})
story("keeps a restored browser tab selected and undrawn until the desktop's first inventory", async ({ page }) => {
const root = page.getByTestId("browser-region-fixture")
const tabs = root.getByRole("tab")
@@ -3,6 +3,7 @@ export default {
"command.reload": "Reload browser page",
"command.inspect": "Select an element in the browser page",
"command.address": "Focus browser address",
"command.close": "Close browser tab",
"tab.title": "Browser",
"tab.new": "New tab",
"address.label": "Browser address",
@@ -57,6 +57,8 @@ type PaneHandle = {
visible: () => boolean
address: () => string
reload: () => void
/** Closes the selected browser tab, including a blank page. */
close: () => void
inspectable: () => boolean
/** Turns the element picker on or off. */
inspect: () => void
@@ -32,6 +32,7 @@ import {
type MountedSession,
type Mutable,
type PanelFrame,
type Panel,
type PanelTab,
type Router,
type Servers,
@@ -585,10 +586,16 @@ type RegionHost = {
}): {
keys(): readonly string[]
active(): string | undefined
entry(key: string): { readonly tab: PanelTab } | undefined
entry(key: string): { readonly tab: PanelTab; readonly extension: string; readonly provider: Panel } | undefined
}
/** The real browser and file extensions. */
definitions: readonly Definition[]
/** Real shell command providers for shortcut cases. */
Shell: ParentComponent
/** Shell fallback and the real extension command adapter. */
Commands: Component<{ close: () => void }>
/** The real host wrapper that renders an extension in its context. */
Contribution: Component<{ extension: string; children: () => JSX.Element }>
}
type RegionFixtureState = {
@@ -598,6 +605,12 @@ type RegionFixtureState = {
registrations: { binding: string; session: string; restore: number }[]
/** The pane's Ipc is gone, as while its main extension reloads or is disabled. */
away: boolean
/** Whether the fixture's shell session was closed by its fallback shortcut. */
closed: boolean
/** The page main reports, blank or loaded. */
blank: boolean
/** Native browser tab close requests, by tab id. */
closes: string[]
}
// Component-test fixture: the real side region over the real browser and file extensions, with the host's
@@ -622,6 +635,9 @@ export function mountBrowserRegion(input: RegionHost) {
},
registrations: [],
away: false,
closed: false,
blank: false,
closes: [],
})
// The file tree's state, Changes or All files, as the file extension stores it.
@@ -700,10 +716,10 @@ export function mountBrowserRegion(input: RegionHost) {
const view = () => views.get(store.session) ?? fallback
// One screen object while the strip mounts, whichever session it routes.
// SAFETY: this fixture draws tab triggers only. Its file model implements the list, normalize and focus paths;
// comment, composer and file-view operations are never invoked here.
// SAFETY: this fixture draws file tab triggers and the browser pane. Its file model implements list,
// normalize and focus; the browser has a composer attach callback. File-view operations are never invoked here.
// oxlint-disable-next-line anti-slop/no-chained-type-assertions -- see SAFETY above
const screen = { file } as unknown as SessionScreen
const screen = { file, composer: { attach() {} } } as unknown as SessionScreen
const layout = (extension: string): Layout => ({
narrow: () => false,
@@ -781,7 +797,16 @@ export function mountBrowserRegion(input: RegionHost) {
])
},
load: async () => undefined,
command: async () => undefined,
command: async (value) => {
if (value.command.type !== "tabs.close") return
const tabID = value.command.tabID
setStore("closes", (items) => [...items, tabID])
listeners.forEach((listener) =>
listener({ binding: value.binding, event: { type: "state", state: { tabs: [], focusedTabID: null } } }),
)
},
inspect: async () => undefined,
highlight: async () => undefined,
zoom: async () => undefined,
@@ -806,7 +831,9 @@ export function mountBrowserRegion(input: RegionHost) {
keybinds: () => keybinds,
servers: () => servers,
workspaces: () => workspaces,
desktop: () => undefined,
desktop: () => ({ zoom: () => 1 }),
embeds: () => ({ View: () => null, capture: async () => undefined }),
system: () => ({ copy: async () => undefined, openExternal() {}, save: async () => false }),
sessions: () => ({ list: () => refs, current: view }),
screen: () => ({ current: () => screen }),
layout,
@@ -823,8 +850,8 @@ export function mountBrowserRegion(input: RegionHost) {
const tab = {
id: tabID,
url: "http://localhost:4173/",
title: "Preview",
url: store.blank ? "about:blank" : "http://localhost:4173/",
title: store.blank ? "" : "Preview",
loading: false,
canGoBack: false,
canGoForward: false,
@@ -857,6 +884,12 @@ export function mountBrowserRegion(input: RegionHost) {
}),
})
const browser = () => {
const entry = region.entry(region.active() ?? "")
return entry?.extension === "browser" ? entry : undefined
}
return (
<>
{/* The host strip draws a trigger for each of these keys. */}
@@ -870,6 +903,35 @@ export function mountBrowserRegion(input: RegionHost) {
</For>
</div>
<p data-testid="selected">{region.active() ?? "none"}</p>
<Show when={browser()}>
{(entry) => (
<PanelContext.Provider
value={{
visible: () => true,
present: () => true,
placement: () => "side",
reserve: () => false,
animate: () => false,
sidebar: { opened: () => false, width: () => 0, transition: () => false, resize() {}, toggle() {} },
open: () => [tabID],
}}
>
<input.Contribution extension={entry().extension}>
{() =>
entry().provider.render({
get tab() {
return entry().tab
},
get session() {
return view()
},
screen,
})
}
</input.Contribution>
</PanelContext.Provider>
)}
</Show>
</>
)
}
@@ -882,31 +944,44 @@ export function mountBrowserRegion(input: RegionHost) {
return (
<DialogProvider>
<input.ExtensionHostProvider
definitions={input.definitions}
disabled={() => new Set<string>()}
apis={apis}
whenMounted={(run) => {
run()
<input.Shell>
<input.ExtensionHostProvider
definitions={input.definitions}
disabled={() => new Set<string>()}
apis={apis}
whenMounted={(run) => {
run()
return () => undefined
}}
ipc={(token) => (token.id === BrowserPane.id && !store.away ? pane : undefined)}
>
<h1 style={{ "font-size": "24px", "margin-bottom": "16px" }}>Restored side strip</h1>
<nav style={{ display: "flex", gap: "12px", margin: "16px 0" }}>
<button onClick={() => setStore("session", beta)}>Beta</button>
<button onClick={inventory}>First inventory</button>
<button onClick={() => setStore("away", true)}>Pane away</button>
<button onClick={() => setStore("away", false)}>Pane back</button>
</nav>
<p>Registrations: {store.registrations.length}</p>
<p>Beta restores: {latest()?.restore ?? 0}</p>
<p data-testid="tree">{JSON.stringify(tree())}</p>
<Ready>
<Strip />
</Ready>
</input.ExtensionHostProvider>
return () => undefined
}}
ipc={(token) => (token.id === BrowserPane.id && !store.away ? pane : undefined)}
>
<input.Commands close={() => setStore("closed", true)} />
<h1 style={{ "font-size": "24px", "margin-bottom": "16px" }}>Restored side strip</h1>
<nav style={{ display: "flex", gap: "12px", margin: "16px 0" }}>
<button onClick={() => setStore({ session: beta, closed: false })}>Beta</button>
<button onClick={inventory}>First inventory</button>
<button
onClick={() => {
setStore("blank", true)
inventory()
}}
>
Blank page
</button>
<button onClick={() => setStore("away", true)}>Pane away</button>
<button onClick={() => setStore("away", false)}>Pane back</button>
</nav>
<p>Registrations: {store.registrations.length}</p>
<p>Beta restores: {latest()?.restore ?? 0}</p>
<p data-testid="tree">{JSON.stringify(tree())}</p>
<p data-testid="session-state">{store.closed ? "closed" : "open"}</p>
<p data-testid="browser-closes">{JSON.stringify(store.closes)}</p>
<Ready>
<Strip />
</Ready>
</input.ExtensionHostProvider>
</input.Shell>
</DialogProvider>
)
}
@@ -384,6 +384,11 @@ export default function SessionBrowserPane(props: {
if (tab) command({ type: "reload", tabID: tab.id })
},
close: () => {
const tab = state()
if (tab) props.model.closeTab(props.session, tab.id)
},
inspectable,
inspect: toggleInspect,
focusAddress,
@@ -88,6 +88,25 @@ const setup: Setup<typeof definition> = (ctx) => {
}
})
// Blank pages have no focused native view, so the app must claim close inside the pane too.
ctx.add(Command, (): Command | undefined => {
const pane = model()?.pane()
if (!pane) return undefined
return {
id: "close",
title: ctx.t("command.close"),
group: ctx.t("command.category.view"),
bind: "mod+w",
scope: "#browser-panel",
hidden: true,
editable: true,
enabled: pane.visible(),
run: pane.close,
}
})
// Ctrl+Shift+C copies in the terminal, so only the focused page claims it, as in Chromium.
ctx.add(Command, (): Command | undefined => {
const pane = model()?.pane()