fix(app): refine browser toolbar and empty state (#48061)

Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
This commit is contained in:
David Hill
2026-09-09 01:17:11 -06:00
committed by GitHub
Unverified
parent 8b92833624
commit 9128e847bd
6 changed files with 109 additions and 35 deletions
+2 -1
View File
@@ -107,6 +107,7 @@ export const dict = {
"command.session.new": "New session",
"command.file.open": "Open file",
"command.browser.open": "Open browser",
"command.browser.reload": "Reload browser page",
"command.tab.close": "Close tab",
"command.tab.reopenClosed": "Reopen closed tab",
"command.context.addSelection": "Add selection to context",
@@ -897,7 +898,7 @@ export const dict = {
"session.browser.address": "Browser address",
"session.browser.replaced": "Browser control moved to another desktop window.",
"session.browser.suspended": "Browser suspended. Interact with this session to reconnect.",
"session.browser.address.placeholder": "Enter a URL",
"session.browser.address.placeholder": "Enter URL",
"titlebar.update": "Update",
"titlebar.tabs": "Tabs",
+85 -28
View File
@@ -1,6 +1,8 @@
import { Icon } from "@opencode/ui/icon"
import { IconButton } from "@opencode/ui/icon-button"
import { Loader } from "@opencode/ui/loader"
import { Keybind } from "@opencode/ui/keybind"
import { Tooltip } from "@opencode/ui/tooltip"
import { useDialog } from "@opencode/ui/context/dialog"
import { createEventListener } from "@solid-primitives/event-listener"
import { createResizeObserver } from "@solid-primitives/resize-observer"
@@ -8,13 +10,16 @@ import { createEffect, For, on, onCleanup, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { useCommand } from "@/shell/commands/command"
import type { createSessionBrowser } from "./model"
export function SessionBrowserPane(props: { browser: ReturnType<typeof createSessionBrowser>; visible: boolean }) {
const platform = usePlatform()
const language = useLanguage()
const dialog = useDialog()
const command = useCommand()
const state = props.browser.active
const address = () => (state()?.url === "about:blank" ? "" : (state()?.url ?? ""))
const registration = props.browser.registration
const button = { variant: "ghost", size: "large" } as const
const [store, setStore] = createStore({
@@ -23,12 +28,28 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
visible: typeof document === "undefined" || document.visibilityState === "visible",
})
let surface: HTMLDivElement | undefined
let addressDisplay: HTMLDivElement | undefined
let frame: number | undefined
let layout: string | undefined
let until = 0
const canvas = document.createElement("canvas")
canvas.width = canvas.height = 1
const paint = canvas.getContext("2d", { willReadFrequently: true })
const scheme = () => store.address.match(/^https?:\/\//i)?.[0] ?? ""
command.register("browser.navigation", () => [
{
id: "browser.reload",
title: language.t("command.browser.reload"),
category: language.t("command.category.view"),
keybind: "f5",
disabled: !props.visible || !state(),
onSelect: () => {
const tab = state()
if (tab) props.browser.command({ type: "reload", tabID: tab.id })
},
},
])
// The native page always paints above the DOM, so hide it while a floating
// menu, select, or popover overlaps it. Tooltips are excluded.
@@ -86,7 +107,7 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
if (frame === undefined) frame = requestAnimationFrame(tick)
}
createEffect(() => !store.editing && setStore("address", state()?.url ?? ""))
createEffect(() => !store.editing && setStore("address", address()))
createEffect(
on(
[
@@ -124,37 +145,58 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
return (
<aside id="browser-panel" class="relative size-full min-w-0 overflow-hidden bg-v2-background-bg-base flex flex-col">
<div class="h-10 shrink-0 flex items-center gap-1 px-2 border-b border-v2-border-border-muted bg-v2-background-bg-layer-02">
<div class="h-10 shrink-0 flex items-center gap-1 px-2 border-b border-v2-border-border-muted">
<For each={["back", "forward"] as const}>
{(direction) => (
<IconButton
{...button}
disabled={!state()?.[direction === "back" ? "canGoBack" : "canGoForward"]}
aria-label={language.t(direction === "back" ? "common.goBack" : "common.goForward")}
onClick={() => {
const tab = state()
if (tab) props.browser.command({ type: direction, tabID: tab.id })
}}
icon={<Icon name={direction === "back" ? "chevron-left" : "chevron-right"} size="small" />}
/>
<Tooltip placement="top" value={language.t(direction === "back" ? "common.goBack" : "common.goForward")}>
<IconButton
{...button}
disabled={!state()?.[direction === "back" ? "canGoBack" : "canGoForward"]}
aria-label={language.t(direction === "back" ? "common.goBack" : "common.goForward")}
onClick={() => {
const tab = state()
if (tab) props.browser.command({ type: direction, tabID: tab.id })
}}
icon={
<Icon
name={direction === "back" ? "chevron-left" : "chevron-right"}
size="small"
class="rtl:rotate-180"
/>
}
/>
</Tooltip>
)}
</For>
<IconButton
{...button}
disabled={!state()}
aria-label={language.t(state()?.loading ? "prompt.action.stop" : "error.page.action.reload")}
onClick={() => {
const tab = state()
if (tab) props.browser.command({ type: tab.loading ? "stop" : "reload", tabID: tab.id })
}}
icon={
<Show when={state()?.loading} fallback={<Icon name="reset" size="small" />}>
<Loader />
</Show>
<Tooltip
placement="top"
value={
<div class="flex items-center gap-2">
<span>{language.t(state()?.loading ? "prompt.action.stop" : "error.page.action.reload")}</span>
<Show when={!state()?.loading}>
<Keybind keys={command.keybindParts("browser.reload")} variant="neutral" />
</Show>
</div>
}
/>
>
<IconButton
{...button}
disabled={!state()}
aria-label={language.t(state()?.loading ? "prompt.action.stop" : "error.page.action.reload")}
onClick={() => {
const tab = state()
if (tab) props.browser.command({ type: tab.loading ? "stop" : "reload", tabID: tab.id })
}}
icon={
<Show when={state()?.loading} fallback={<Icon name="refresh" size="small" />}>
<Loader />
</Show>
}
/>
</Tooltip>
<form
class="min-w-0 flex-1"
dir="ltr"
class="relative min-w-0 flex-1 h-7 rounded-md hover:bg-v2-overlay-simple-overlay-hover focus-within:bg-v2-overlay-simple-overlay-hover text-12-regular"
onSubmit={(event) => {
event.preventDefault()
const tab = state()
@@ -163,15 +205,30 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
}}
>
<input
class="w-full h-7 px-2 rounded-md border border-v2-border-border-muted bg-v2-background-bg-base text-12-regular text-v2-text-text-base outline-none focus:border-v2-border-border-focus"
class="w-full h-full px-2 rounded-md border border-transparent bg-transparent text-transparent caret-v2-text-text-base placeholder:text-v2-text-text-faint outline-none focus:border-v2-border-border-focus"
spellcheck={false}
autocomplete="off"
value={store.address}
disabled={!state()}
placeholder={language.t("session.browser.address.placeholder")}
aria-label={language.t("session.browser.address")}
onFocus={() => setStore("editing", true)}
onBlur={() => setStore({ editing: false, address: state()?.url ?? "" })}
onBlur={() => setStore({ editing: false, address: address() })}
onInput={(event) => setStore("address", event.currentTarget.value)}
onScroll={(event) => {
if (addressDisplay) addressDisplay.scrollLeft = event.currentTarget.scrollLeft
}}
/>
{/* Keep native input editing and selection while coloring the scheme, including during editing. */}
<div
aria-hidden="true"
class="absolute inset-0 flex items-center px-2 border border-transparent pointer-events-none"
>
<div ref={addressDisplay} class="w-full overflow-hidden whitespace-pre text-v2-text-text-base">
<span class="text-v2-text-text-muted">{scheme()}</span>
{store.address.slice(scheme().length)}
</div>
</div>
</form>
</div>
<Show when={props.browser.error()}>
@@ -396,9 +396,11 @@ export function SessionSidePanel(props: {
ariaControls={activeTab() === tab ? browserTabPanelID : undefined}
>
<div class="flex items-center gap-1.5">
<Icon name="window-cursor" size="small" />
<Icon name="globe" size="small" />
<span class="max-w-40 truncate">
{item().title || language.t("session.tab.browser")}
{!item().url || item().url === "about:blank"
? language.t("session.tab.browser")
: item().title || item().url}
</span>
</div>
</SortableTab>
@@ -508,7 +510,7 @@ export function SessionSidePanel(props: {
}
>
<div class="flex items-center gap-2">
<Icon name="open-file" size="small" />
<Icon name="file-tree" size="small" />
<span>{language.t("command.file.open")}</span>
</div>
</Menu.Item>
@@ -522,7 +524,7 @@ export function SessionSidePanel(props: {
}
>
<div class="flex items-center gap-2">
<Icon name="window-cursor" size="small" />
<Icon name="globe" size="small" />
<span>{language.t("session.tab.browser")}</span>
</div>
</Menu.Item>
+1 -1
View File
@@ -15,7 +15,7 @@ const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(na
const PALETTE_ID = "command.palette"
export const DEFAULT_PALETTE_KEYBIND = "mod+k,mod+shift+p"
const SUGGESTED_PREFIX = "suggested."
const EDITABLE_KEYBIND_IDS = new Set(["terminal.toggle", "terminal.new", "file.attach"])
const EDITABLE_KEYBIND_IDS = new Set(["terminal.toggle", "terminal.new", "file.attach", "browser.reload"])
type KeyLabel =
| "common.key.ctrl"
@@ -58,7 +58,13 @@ export function createBrowserPage(
const contents = view.webContents
const detachNetwork = options.network?.attach(contents)
contents.on("before-input-event", (event, input) => {
if (input.type !== "keyDown" || input.alt || !(process.platform === "darwin" ? input.meta : input.control)) return
if (input.type !== "keyDown") return
if (input.key === "F5" && !input.meta && !input.control && !input.alt && !input.shift) {
event.preventDefault()
contents.reload()
return
}
if (input.alt || !(process.platform === "darwin" ? input.meta : input.control)) return
const step =
input.key === "=" || input.key === "+" || input.code === "NumpadAdd"
? 0.5
+8
View File
@@ -8,6 +8,10 @@ const icons = {
viewBox: "0 0 16 16",
body: `<rect x="3.5" y="7" width="9" height="7" rx="1" stroke="currentColor"/><path d="M5 7V5a3 3 0 0 1 6 0v2M8 10v1" stroke="currentColor" stroke-linecap="round"/>`,
},
globe: {
viewBox: "0 0 16 16",
body: `<circle cx="8" cy="8" r="6" stroke="currentColor"/><ellipse cx="8" cy="8" rx="2.5" ry="6" stroke="currentColor"/><path d="M2 8h12" stroke="currentColor"/>`,
},
flask: {
viewBox: "0 0 16 16",
body: `<path d="M5.5 2H10.5M6 2V6L2.5 12C2 13 2.5 14 3.5 14H12.5C13.5 14 14 13 13.5 12L10 6V2M4.25 9H11.75" stroke="currentColor" stroke-linecap="square" stroke-linejoin="round"/>`,
@@ -196,6 +200,10 @@ const icons = {
viewBox: "0 0 20 20",
body: `<path d="M2.5 10s3.33-5.42 7.5-5.42S17.5 10 17.5 10s-3.33 5.42-7.5 5.42S2.5 10 2.5 10Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/><circle cx="10" cy="10" r="2.5" stroke="currentColor"/><path d="M3 3 17 17" stroke="currentColor" stroke-linecap="round"/>`,
},
refresh: {
viewBox: "0 0 20 20",
body: `<path d="M16.25 7.5A6.5 6.5 0 1 0 16.5 11M16.25 3.5v4h-4" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>`,
},
reset: {
viewBox: "0 0 20 20",
body: `<path d="M5.83333 4.16406L2.5 7.4974L5.83333 10.8307M3.33333 7.4974H17.9167V15.4141H10" stroke="currentColor" stroke-linecap="square"/>`,