feat(app): list recently closed tabs in the side panel's new tab view (#57863)

This commit is contained in:
James Long 2026-10-21 15:11:55 -04:00 • committed by GitHub
parent 3b7ba7a17f
commit 91657ca657
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 727 additions and 164 deletions

View file

@ -65,8 +65,8 @@ story.beforeEach(async ({ mount, page }) => {
LanguageProvider: language.LanguageProvider,
ExtensionHostProvider: host.ExtensionHostProvider,
useExtensionHost: host.useExtensionHost,
createRegion: (region: Omit<Parameters<typeof panels.createRegion>[0], "closed">) =>
panels.createRegion({ ...region, closed: closedTabs }),
createRegion: (region: Omit<Parameters<typeof panels.createRegion>[0], "closed" | "open">) =>
panels.createRegion({ ...region, closed: closedTabs, open() {} }),
definitions: [
{ ...browser.default, renderer: () => import(modules.browserRenderer) },
{ ...file.default, renderer: () => import(modules.fileRenderer) },
@ -93,7 +93,9 @@ story.beforeEach(async ({ mount, page }) => {
reopenSession: () => void
reopenSide: () => void
switchSession: () => void
side: () => { keys: () => readonly string[]; active: () => string | undefined; select: (key: string) => void } | undefined
side: () =>
| { keys: () => readonly string[]; active: () => string | undefined; select: (key: string) => void }
| undefined
shortcut: (shortcut: Accessor<string>) => void
}) => {
const command = commands.useCommand()

View file

@ -435,6 +435,7 @@ export function mountSessionRegion(input: { definitions: readonly Definition[];
remap() {},
}),
closed: createClosedTabs(),
open() {},
side: () => ({ opened: () => true, toggle() {} }),
})

View file

@ -363,8 +363,14 @@ test("context closes the side region only when its button opened it", async ({ p
.click()
await expect(context).toHaveAttribute("aria-selected", "true")
await expect(newTab).toHaveCount(0)
// The New tab view now lists the closed Review under "Recently closed" too; reopening it brings back the same tab.
await panel.getByRole("button", { name: "Add tab", exact: true }).click()
await panel.getByRole("button", { name: /^Review/ }).click()
await expect(panel.getByRole("region", { name: "Recently closed", exact: true })).toContainText("Review")
await panel
.getByRole("region", { name: "Recently closed", exact: true })
.getByRole("button", { name: /^Review/ })
.click()
await expect(panel.locator("#session-side-panel-review-tab")).toHaveAttribute("aria-selected", "true")
await context.click()
await contextButton.click()
await expect(context).toHaveCount(0)
@ -504,7 +510,9 @@ test("file tree expands Windows paths and scrolls long names in both directions"
await expect(longRow).toBeVisible()
await expect(content).toHaveAttribute("title", longFilename)
await expect.poll(() => content.evaluate((element) => element.scrollWidth - element.clientWidth)).toBeGreaterThan(0)
await expect.poll(() => longRow.evaluate((element) => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(0)
await expect
.poll(() => longRow.evaluate((element) => element.scrollWidth - element.clientWidth))
.toBeLessThanOrEqual(0)
await expect(content).toHaveCSS("text-overflow", "clip")
await expect(status).toHaveText("A")
await expect.poll(() => statusInset(status, "right")).toBeLessThanOrEqual(24)
@ -1260,7 +1268,10 @@ test("names a single change source and keeps the review header rows at 48px", as
await expect(region).toHaveAttribute("aria-expanded", "false")
await region.click()
await expect(panel.getByRole("tab")).toHaveCount(0)
await expect(panel.getByRole("button", { name: /^Review/ })).toBeVisible()
// Its launchers, then the closed Review under "Recently closed".
const closed = panel.getByRole("region", { name: "Recently closed", exact: true })
await expect(panel.getByRole("button", { name: /^Review/ }).first()).toBeVisible()
await expect(closed.getByRole("button", { name: /^Review/ })).toBeVisible()
})
for (const direction of ["ltr", "rtl"] as const) {

View file

@ -3,6 +3,7 @@ import { tabKey } from "../utils/app"
import { fileDiff, openSession } from "../utils/workspace"
import { openReviewLauncher } from "../utils/side-panel"
import { expectSessionTitle } from "../utils/waits"
import { base64Encode } from "@opencode/util/encode"
test("review and terminal tabs follow the session tab and the terminal stays mounted", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 })
@ -151,3 +152,65 @@ function sideContentOffset(page: Page, direction: "ltr" | "rtl") {
return direction === "rtl" ? Math.abs(frame.right - content.right) : Math.abs(frame.left - content.left)
}, direction)
}
test("the new tab view lists recently closed tabs, expands to the rest, and keeps them across a reload", async ({
page,
}) => {
await page.setViewportSize({ width: 1440, height: 900 })
const directory = "C:/OpenCode/RecentlyClosed"
const key = `local\u0000${base64Encode(directory)}/ses_recentlyclosed`
// Twelve files closed earlier, newest first, as the app stores them beside the strip.
const closed = Array.from({ length: 12 }, (_, index) => ({
key: `file://src/closed-${String(index).padStart(2, "0")}.ts`,
title: `closed-${String(index).padStart(2, "0")}.ts`,
file: `src/closed-${String(index).padStart(2, "0")}.ts`,
closed: 1_000 - index,
}))
await openSession(page, {
name: "RecentlyClosed",
fileContent: (path) => ({ type: "text", content: `contents:${path}` }),
seed: {
panes: { ses_recentlyclosed: { review: true } },
storage: {
"opencode.global.dat:layout": {
sessionTabs: { [key]: { all: ["file://README.md"], active: "file://README.md" } },
sessionView: { [key]: { scroll: {}, closed } },
},
},
},
})
const panel = page.locator("#review-panel")
const recent = panel.getByRole("region", { name: "Recently closed", exact: true })
const rows = recent.locator('[data-slot="side-recently-closed-row"]')
const toggle = page.getByRole("button", { name: "Toggle review", exact: true })
// The latest five, then all of them once expanded.
await panel.getByRole("button", { name: "Add tab", exact: true }).click()
await expect(rows).toHaveCount(5)
await expect(rows.first()).toContainText("closed-00.ts")
await recent.getByRole("button", { name: "Show more", exact: true }).click()
await expect(rows).toHaveCount(12)
await recent.getByRole("button", { name: "Show less", exact: true }).click()
await expect(rows).toHaveCount(5)
// Closing the last tab closes the region; it opens on its launchers with that tab listed first.
const readme = panel.getByRole("tab", { name: "README.md" })
await readme.click()
await readme.locator("..").getByRole("button", { name: "Close tab" }).click()
await expect(toggle).toHaveAttribute("aria-expanded", "false")
await toggle.click()
await expect(panel.getByRole("tab")).toHaveCount(0)
await expect(rows.first()).toContainText("README.md")
// The list survives a reload, and reopening a row brings its tab back and takes it off the list.
await page.reload()
await expect(rows.first()).toContainText("README.md")
await rows.first().click()
await expect(panel.getByRole("tab", { name: "README.md" })).toHaveAttribute("aria-selected", "true")
await panel.getByRole("button", { name: "Add tab", exact: true }).click()
await expect(rows.first()).toContainText("closed-00.ts")
await expect(recent).not.toContainText("README.md")
})

View file

@ -0,0 +1,236 @@
import { For, Show, createMemo, createUniqueId, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { FileIcon } from "@opencode/ui/file-icon"
import { Icon } from "@opencode/ui/icon"
import { IconButton } from "@opencode/ui/icon-button"
import { Keybind } from "@opencode/ui/keybind"
import { Menu } from "@opencode/ui/menu"
import { Tooltip } from "@opencode/ui/tooltip"
import { getDirectory, getFilename } from "@opencode/util/path"
import { isIconName } from "@opencode/ui/icons/catalog"
import { useCommand } from "@/shell/commands/command"
import { useLanguage } from "@/runtime/i18n/language"
import {
closedTabsSwap,
createModifierHeld,
hasModifier,
type ClosedSideTab,
type ClosedTabsModifier,
} from "./closed-tabs"
const MENU_LIMIT = 10
const SECTION_LIMIT = 5
/** A closed tab's icon, title and, for a file, its folder. */
export function ClosedTabVisual(props: { entry: ClosedSideTab }): JSX.Element {
const detail = () => (props.entry.file ? getDirectory(props.entry.file).replace(/\/$/, "") : props.entry.detail)
const icon = () => (props.entry.icon && isIconName(props.entry.icon) ? props.entry.icon : "file-document")
return (
<span class="flex min-w-0 items-center gap-2">
<Show
when={props.entry.file}
fallback={<Icon name={icon()} size="small" class="shrink-0 text-v2-icon-icon-muted" />}
>
{(file) => <FileIcon node={{ path: file(), type: "file" }} class="size-4 shrink-0" />}
</Show>
<span class="min-w-0 shrink truncate text-v2-text-text-base">
{props.entry.file ? getFilename(props.entry.file) : props.entry.title}
</span>
<Show when={detail()}>{(value) => <span class="min-w-0 truncate text-v2-text-text-muted">{value()}</span>}</Show>
</span>
)
}
function useReopenKeybind() {
const command = useCommand()
return createMemo(() => command.keybindParts("file.tab.reopen"))
}
/**
* The new tab view's "Recently closed": the latest `SECTION_LIMIT` closed tabs, and every kept one once expanded. The
* newest shows the ⌘⇧T that reopens it.
*/
export function ClosedTabsSection(props: { entries: readonly ClosedSideTab[]; onReopen: (key: string) => void }) {
const language = useLanguage()
const keybind = useReopenKeybind()
const [state, setState] = createStore({ expanded: false })
const heading = createUniqueId()
const shown = () => (state.expanded ? props.entries : props.entries.slice(0, SECTION_LIMIT))
return (
<Show when={props.entries.length > 0}>
<section data-slot="side-recently-closed" class="flex flex-col gap-1 pt-6" aria-labelledby={heading}>
<h2 id={heading} class="px-2 pb-1 text-[13px] font-[440] text-v2-text-text-muted">
{language.t("home.recentlyClosed")}
</h2>
<For each={shown()}>
{(entry, index) => (
<button
type="button"
data-slot="side-recently-closed-row"
class="flex h-8 w-full shrink-0 items-center justify-between gap-4 rounded-[6px] px-2 text-start text-[13px] font-[440] hover:bg-v2-background-bg-layer-02"
onClick={() => props.onReopen(entry.key)}
>
<ClosedTabVisual entry={entry} />
<Show when={index() === 0 && keybind().length > 0}>
<Keybind keys={keybind()} variant="neutral" />
</Show>
</button>
)}
</For>
<Show when={props.entries.length > SECTION_LIMIT}>
<button
type="button"
data-slot="side-recently-closed-more"
class="flex h-8 w-fit items-center gap-1 rounded-[6px] px-2 text-[13px] font-[440] text-v2-text-text-muted hover:bg-v2-background-bg-layer-02 hover:text-v2-text-text-base"
aria-expanded={state.expanded}
onClick={() => setState("expanded", (expanded) => !expanded)}
>
{state.expanded ? language.t("session.tab.showLess") : language.t("session.tab.showMore")}
<Icon name="chevron-down" size="small" classList={{ "rotate-180": state.expanded }} />
</button>
</Show>
</section>
</Show>
)
}
function ClosedTabsItems(props: { entries: readonly ClosedSideTab[]; onReopen: (key: string) => void }) {
const language = useLanguage()
const keybind = useReopenKeybind()
return (
<Menu.Group>
<Menu.GroupLabel>{language.t("home.recentlyClosed")}</Menu.GroupLabel>
<Show
when={props.entries.length > 0}
fallback={<div class="px-2 py-1.5 text-[13px] text-v2-text-text-muted">{language.t("palette.empty")}</div>}
>
<For each={props.entries.slice(0, MENU_LIMIT)}>
{(entry, index) => (
<Menu.Item
class="!gap-6 max-w-80"
onSelect={() => props.onReopen(entry.key)}
shortcut={
<Show when={index() === 0 && keybind().length > 0}>
<Keybind keys={keybind()} variant="neutral" />
</Show>
}
>
<ClosedTabVisual entry={entry} />
</Menu.Item>
)}
</For>
</Show>
</Menu.Group>
)
}
const MODIFIER_LABEL = {
mac: { alt: "⌥", shift: "⇧", ctrl: "⌃", meta: "⌘" },
other: { alt: "Alt", shift: "Shift", ctrl: "Ctrl", meta: "Win" },
} as const
/**
* Dev builds only, behind `opencode.dev.closedTabsModifier`: the tab strip's "+". Holding the modifier while pointing at it turns it into a chevron whose click lists the
* recently closed tabs; a plain click still opens a new tab. Right-click and ↓ on the focused button open the list too.
*/
export function NewTabButton(props: {
modifier: ClosedTabsModifier
entries: readonly ClosedSideTab[]
onReopen: (key: string) => void
onNewTab: () => void
}) {
const language = useLanguage()
const modifier = props.modifier
const global = closedTabsSwap() === "global"
const held = createModifierHeld(modifier)
const label = MODIFIER_LABEL[/(Mac|iPhone|iPad)/.test(navigator.platform) ? "mac" : "other"][modifier]
const [state, setState] = createStore({ hover: false, open: false })
const available = () => props.entries.length > 0
const swapped = () => state.open || (available() && held() && (global || state.hover))
// What the press that is opening the menu asked for, read when Kobalte reports the open.
let intent: "menu" | "new" = "new"
// A chosen entry reopens once the closing menu has returned focus, as the "+" menu does.
let chosen: string | undefined
return (
<Tooltip
value={
swapped() ? (
language.t("home.recentlyClosed")
) : (
<span class="flex items-center gap-2">
{language.t("session.tab.add")}
<Show when={available()}>
<span class="opacity-60">{language.t("session.tab.recentHint", { key: label })}</span>
</Show>
</span>
)
}
placement="bottom"
class="flex items-center"
>
<Menu
appearance="standard"
modal={false}
placement="bottom-start"
gutter={4}
open={state.open}
onOpenChange={(open) => {
if (!open) return setState("open", false)
if (intent === "menu" && available()) return setState("open", true)
props.onNewTab()
}}
>
<Menu.Trigger
as={IconButton}
data-slot="side-new-tab"
data-swapped={swapped() ? "" : undefined}
icon={<Icon name={swapped() ? "chevron-down" : "plus"} />}
variant="ghost-muted"
size="large"
aria-label={swapped() ? language.t("home.recentlyClosed") : language.t("session.tab.add")}
aria-haspopup="menu"
onPointerEnter={() => setState("hover", true)}
onPointerLeave={() => setState("hover", false)}
onPointerDown={(event: PointerEvent) => {
// The tablist redirects focus entering it to the selected tab, which counts as focus-outside and closes
// the menu.
event.preventDefault()
intent = swapped() || hasModifier(event, modifier) ? "menu" : "new"
}}
onKeyDown={(event: KeyboardEvent) => {
intent = event.key === "ArrowDown" || event.key === "ArrowUp" || swapped() ? "menu" : "new"
}}
onContextMenu={(event: MouseEvent) => {
if (!available()) return
event.preventDefault()
setState("open", true)
}}
/>
<Menu.Portal>
<Menu.Content
class="min-w-64"
onCloseAutoFocus={() => {
const key = chosen
if (!key) return
chosen = undefined
setTimeout(() => {
if (document.activeElement instanceof HTMLElement) document.activeElement.blur()
props.onReopen(key)
})
}}
>
<ClosedTabsItems entries={props.entries} onReopen={(key) => (chosen = key)} />
</Menu.Content>
</Menu.Portal>
</Menu>
</Tooltip>
)
}

View file

@ -1,13 +1,89 @@
import { expect, test } from "bun:test"
import { createClosedTabs } from "./closed-tabs"
import type {
MountedSession,
Panel,
PanelProps,
PanelTab,
SessionRef,
SessionScreen,
} from "@opencode/gui-extensions/sdk"
import { CLOSED_TABS_LIMIT, closedEntry, createClosedTabs } from "./closed-tabs"
test("reopens a session's closed side tabs newest first, once each, up to the limit", () => {
const closed = createClosedTabs(2)
const reopened: string[] = []
// SAFETY: the in-memory store reads only `key`; the rest of a session reference is never touched.
const session = (key: string) => ({ key }) as SessionRef
for (const tab of ["a1", "b1", "a2", "a3"]) closed.push(tab[0]!, () => reopened.push(tab))
for (const session of ["a", "a", "a", "b", "b"]) closed.reopen(session)
expect(reopened).toEqual(["a3", "a2"])
// SAFETY: `closedEntry` reads only the tab's fields; the session and screen pass through untouched.
const props = (tab: Partial<PanelTab>): PanelProps => ({
tab: { id: "main", title: "Tab", ...tab },
session: {} as MountedSession,
screen: {} as SessionScreen,
})
const panel = (fields: Partial<Panel>): Panel => ({
id: "main",
region: "side",
list: () => [],
render: () => null,
...fields,
})
test("keeps a session's closed tabs newest first, once each, up to the limit", () => {
const closed = createClosedTabs()
const a = session("a")
for (const index of Array.from({ length: CLOSED_TABS_LIMIT + 2 }, (_, value) => value))
closed.push(a, { key: `file://${index}.ts`, title: `${index}.ts` })
closed.push(a, { key: "file://5.ts", title: "5.ts" })
closed.push(session("b"), { key: "review:main", title: "Review" })
const keys = closed.list(a).map((entry) => entry.key)
expect(keys).toHaveLength(CLOSED_TABS_LIMIT)
expect(keys.slice(0, 3)).toEqual([
"file://5.ts",
`file://${CLOSED_TABS_LIMIT + 1}.ts`,
`file://${CLOSED_TABS_LIMIT}.ts`,
])
expect(keys.filter((key) => key === "file://5.ts")).toHaveLength(1)
expect(closed.list(session("b")).map((entry) => entry.key)).toEqual(["review:main"])
})
test("takes the newest closed tab, or the one asked for", () => {
const closed = createClosedTabs()
const a = session("a")
closed.push(a, { key: "file://one.ts", title: "one.ts" })
closed.push(a, { key: "browser:tab", title: "Docs", data: "https://example.com" })
closed.push(a, { key: "context:main", title: "Context" })
expect(closed.take(a, "browser:tab")?.data).toBe("https://example.com")
expect(closed.take(a)?.key).toBe("context:main")
expect(closed.list(a).map((entry) => entry.key)).toEqual(["file://one.ts"])
expect(closed.take(a, "missing")).toBeUndefined()
})
test("lists a closing tab by its panel's word, its title by default, and never a transient one unasked", () => {
const mobile = { title: "Context", order: 0, kind: "menu", icon: "status" } as const
expect(closedEntry("context:main", panel({ mobile }), props({ title: "Context" }))).toEqual({
key: "context:main",
title: "Context",
icon: "status",
file: undefined,
detail: undefined,
data: undefined,
})
expect(closedEntry("file://a.ts", panel({}), props({ title: "a.ts", file: "a.ts" }))?.file).toBe("a.ts")
const browser = panel({ closed: () => ({ icon: "globe", detail: "example.com", data: "https://example.com" }) })
expect(closedEntry("browser:t", browser, props({ title: "Docs" }))).toMatchObject({
title: "Docs",
icon: "globe",
detail: "example.com",
data: "https://example.com",
})
expect(closedEntry("browser:t", panel({ closed: () => false }), props({}))).toBeUndefined()
expect(closedEntry("terminal:1", panel({ transient: true }), props({}))).toBeUndefined()
expect(closedEntry("file:open", panel({}), props({ transient: true }))).toBeUndefined()
})

View file

@ -1,24 +1,140 @@
import { createSignal } from "solid-js"
import { createStore } from "solid-js/store"
import { createEventListener } from "@solid-primitives/event-listener"
import type { Panel, PanelProps, SessionRef } from "@opencode/gui-extensions/sdk"
import { adjacentTabKey } from "@/shell/titlebar/tab-order"
/** Side tabs the user closed in this window, newest last, each with how its panel brings it back. */
export function createClosedTabs(limit = 25) {
const stack: { session: string; reopen: () => void }[] = []
/** The most closed tabs a session keeps. */
export const CLOSED_TABS_LIMIT = 30
/** A side tab the user closed, as the new tab view lists it and ⌘⇧T brings it back. */
export type ClosedSideTab = {
/** `${extension}:${tab id}`. */
readonly key: string
readonly title: string
/** An icon name; stored, so check it before drawing. */
readonly icon?: string
/** The workspace file the tab showed, if any. */
readonly file?: string
/** A muted detail after the title. */
readonly detail?: string
/** What the panel's `restore` needs. */
readonly data?: string
/** When it closed, in milliseconds since the epoch. */
readonly closed: number
}
/** Where a session's closed tabs live, newest first. */
export type ClosedTabsStore = {
list(session: SessionRef): readonly ClosedSideTab[]
write(session: SessionRef, entries: readonly ClosedSideTab[]): void
}
/** Each session's closed side tabs, newest first, up to `CLOSED_TABS_LIMIT`. In memory unless given a store. */
export function createClosedTabs(store: ClosedTabsStore = memoryStore()) {
return {
push(session: string, reopen: () => void) {
stack.push({ session, reopen })
if (stack.length > limit) stack.shift()
/** Reactive. */
list: store.list,
push(session: SessionRef, entry: Omit<ClosedSideTab, "closed">) {
store.write(
session,
[{ ...entry, closed: Date.now() }, ...store.list(session).filter((item) => item.key !== entry.key)].slice(
0,
CLOSED_TABS_LIMIT,
),
)
},
reopen(session: string) {
const index = stack.findLastIndex((entry) => entry.session === session)
/** Removes and returns the entry under `key`, or the newest one. */
take(session: SessionRef, key?: string) {
const entries = store.list(session)
const entry = key === undefined ? entries[0] : entries.find((item) => item.key === key)
if (index !== -1) stack.splice(index, 1)[0]?.reopen()
if (entry)
store.write(
session,
entries.filter((item) => item !== entry),
)
return entry
},
}
}
export type ClosedTabs = ReturnType<typeof createClosedTabs>
/** How a closing tab lists, or nothing when its panel says it cannot come back. Transient tabs list only when asked. */
export function closedEntry(key: string, panel: Panel, input: PanelProps): Omit<ClosedSideTab, "closed"> | undefined {
const info = panel.closed?.(input)
if (info === false) return
if (!info && (panel.transient || input.tab.transient)) return
return {
key,
title: info?.title ?? input.tab.title,
icon: info?.icon ?? panel.mobile?.icon,
file: input.tab.file,
detail: info?.detail,
data: info?.data,
}
}
function memoryStore(): ClosedTabsStore {
const [entries, setEntries] = createStore<Record<string, readonly ClosedSideTab[]>>({})
return {
list: (session) => entries[session.key] ?? [],
write: (session, next) => setEntries(session.key, next),
}
}
export type ClosedTabsModifier = "alt" | "shift" | "ctrl" | "meta"
/**
* Dev builds only, while the design is evaluated: the held key that turns the side panel's "+" into a "Recently closed"
* menu, from `localStorage["opencode.dev.closedTabsModifier"]`. Off unless set.
*/
export function closedTabsModifier(): ClosedTabsModifier | undefined {
if (!import.meta.env.DEV) return
const value = localStorage.getItem("opencode.dev.closedTabsModifier")
return value === "alt" || value === "shift" || value === "ctrl" || value === "meta" ? value : undefined
}
/** Whether the swap waits for the pointer to rest on "+", or shows whenever the key is down. Hover unless the dev flag says "global". */
export function closedTabsSwap(): "hover" | "global" {
return import.meta.env.DEV && localStorage.getItem("opencode.dev.closedTabsSwap") === "global" ? "global" : "hover"
}
/** Whether an input event carries the modifier. */
export function hasModifier(event: KeyboardEvent | MouseEvent, modifier: ClosedTabsModifier) {
if (modifier === "shift") return event.shiftKey
if (modifier === "ctrl") return event.ctrlKey
if (modifier === "meta") return event.metaKey
return event.altKey
}
/**
* Whether the modifier is held, from every key and pointer event's modifier state rather than counting presses, so key
* repeat, a press made while another window had focus, and a release made elsewhere all settle. Leaving the window
* releases it, since its keyup goes to another window.
*/
export function createModifierHeld(modifier: ClosedTabsModifier) {
const [held, setHeld] = createSignal(false)
const update = (event: KeyboardEvent | MouseEvent) => setHeld(hasModifier(event, modifier))
createEventListener(window, ["keydown", "keyup"], update, { capture: true })
createEventListener(window, ["pointermove", "pointerdown"], update, { capture: true, passive: true })
createEventListener(window, "blur", () => setHeld(false))
createEventListener(document, "visibilitychange", () => document.hidden && setHeld(false))
return held
}
/**
* Whether a key event belongs to the side panel: its target is in it, or, when the target is the body because closing
* the focused tab dropped focus, the user's last focus, click or key was.

View file

@ -46,7 +46,7 @@ import { formatKeybindParts, useCommand } from "@/shell/commands/command"
import { createMediaQuery } from "@solid-primitives/media"
import { useIsRouting, useLocation } from "@solidjs/router"
import { useLanguage } from "@/runtime/i18n/language"
import { createClosedTabs } from "./closed-tabs"
import { closedEntry, createClosedTabs } from "./closed-tabs"
import { panelsMoving } from "@/shell/panel-motion"
import { createEmbeds } from "./embeds"
import { useExtensionHost, type HostApiFactories } from "./host"
@ -651,7 +651,19 @@ export function createExtensionAttachment(apis: HostApis) {
// Writes made before a session's location is known wait for it rather than being dropped.
const located = createLocatedWrites()
const closed = createClosedTabs()
// Kept per session beside its strip, so the new tab view still lists them after a reload.
const closed = createClosedTabs({
list(session) {
const value = stateKey(session)
return value ? layout.panel.closed(value) : []
},
write(session, entries) {
const value = stateKey(session)
if (value) layout.panel.setClosed(value, entries)
},
})
const open = (key: string, session: SessionRef, options?: OpenOptions) => {
const item = provider(key)
@ -703,9 +715,14 @@ export function createExtensionAttachment(apis: HostApis) {
const all = layout.panel.state(value).all
const view = mountedSession(session)
const currentScreen = screen()
const reopen = view && tab && currentScreen && item?.value.reopen?.({ tab, session: view, screen: currentScreen })
const entry =
view &&
tab &&
currentScreen &&
item &&
closedEntry(key, item.value, { tab, session: view, screen: currentScreen })
if (reopen) closed.push(session.key, reopen)
if (entry) closed.push(session, entry)
batch(() => {
layout.panel.close(value, key)
@ -877,6 +894,8 @@ export function createExtensionAttachment(apis: HostApis) {
/** The routed `MountedSession`. */
current,
closed,
/** Opens and selects a side tab by key, as a reopened closed tab comes back. */
openTab: (key: string, session: SessionRef) => open(key, session, { tab: "select" }),
region(value: Region) {
setRegion(() => value)

View file

@ -34,7 +34,7 @@ import { useExtensionHost } from "./host"
import { Contribution } from "./render"
import { useExtensionAttachment } from "./attachment"
import { legacyKeys, panelKey } from "./panel-keys"
import type { createClosedTabs } from "./closed-tabs"
import { closedEntry, type ClosedTabs } from "./closed-tabs"
type Tabs = Accessor<{
tabs(): SessionTabs
@ -112,7 +112,9 @@ export function createRegion(input: {
view: Accessor<MountedSession>
screen: SessionScreen
tabs: Tabs
closed: ReturnType<typeof createClosedTabs>
closed: ClosedTabs
/** Opens and selects a stored tab by key, as a reopened tab whose panel has no `restore` comes back. */
open: (key: string) => void
/** Whether the side region is open, and the user's toggle for it. */
side: Accessor<{ opened(): boolean; toggle(): void }>
/** Runs when a close leaves no stored tab, in the same batch as the close. */
@ -367,9 +369,9 @@ export function createRegion(input: {
close(key: string) {
const entry = byKey().get(key)
const last = stored().length === 1 && stored()[0] === key
const reopen = entry?.provider.reopen?.(panelInput(input.view, input.screen, { tab: entry.tab }))
const closed = entry && closedEntry(key, entry.provider, panelInput(input.view, input.screen, { tab: entry.tab }))
if (reopen) input.closed.push(input.view().key, reopen)
if (closed) input.closed.push(input.view(), closed)
batch(() => {
input.tabs().close(key)
@ -383,7 +385,28 @@ export function createRegion(input: {
}),
)
},
reopen: () => input.closed.reopen(input.view().key),
/** The session's closed tabs that are not open again, newest first. */
recent: createMemo(() => {
const open = new Set(stored())
return input.closed.list(input.view()).filter((entry) => !open.has(entry.key))
}),
/** Brings back the closed tab under `key`, or the newest one. */
reopen(key?: string) {
const entry = input.closed.take(input.view(), key)
if (!entry) return
const extension = entry.key.slice(0, entry.key.indexOf(":"))
const provider = providers().find((item) => item.extension === extension)?.value
if (!provider?.restore) return input.open(entry.key)
provider.restore({
session: input.view(),
screen: input.screen,
id: entry.key.slice(extension.length + 1),
data: entry.data,
})
},
}
}

View file

@ -39,6 +39,8 @@ import { createSessionPanelMenu } from "./panel-menu"
import { RegionContent, type Region, type RegionPane } from "./panels"
import { ExtensionSlot } from "./render"
import { createTabStripScroll } from "./tab-strip-scroll"
import { closedTabsModifier, type ClosedSideTab } from "./closed-tabs"
import { ClosedTabsSection, NewTabButton } from "./closed-tabs-view"
import type { PaneDrag } from "./pane-drag"
const PANE_HEIGHT_MIN = 120
@ -209,6 +211,18 @@ export function PaneTabs(props: {
// The next tab this pane's "+" menu opens lands in this pane.
const target = () => tabs().target(props.pane.id)
const closed = {
get entries() {
return props.region.recent()
},
onReopen: (key: string) => {
target()
props.region.reopen(key)
},
}
const modifier = closedTabsModifier()
// Without a new tab item, "+" opens a host-owned new tab view listing the launchers. It shows while the session and
// the pane's selection are the ones it opened over, so a tab that opens and selects itself replaces it.
const [adding, setAdding] = createSignal<{ session: string; over: string | undefined }>()
@ -313,15 +327,29 @@ export function PaneTabs(props: {
</Show>
}
>
<Tooltip value={language.t("session.tab.add")} placement="bottom" class="flex items-center">
<IconButton
icon={<Icon name="plus" />}
variant="ghost-muted"
size="large"
onClick={() => setAdding({ session: props.view.key, over: props.pane.active() })}
aria-label={language.t("session.tab.add")}
/>
</Tooltip>
<Show
when={modifier}
fallback={
<Tooltip value={language.t("session.tab.add")} placement="bottom" class="flex items-center">
<IconButton
icon={<Icon name="plus" />}
variant="ghost-muted"
size="large"
onClick={() => setAdding({ session: props.view.key, over: props.pane.active() })}
aria-label={language.t("session.tab.add")}
/>
</Tooltip>
}
>
{(key) => (
<NewTabButton
modifier={key()}
entries={closed.entries}
onReopen={closed.onReopen}
onNewTab={() => setAdding({ session: props.view.key, over: props.pane.active() })}
/>
)}
</Show>
</Show>
}
>
@ -357,7 +385,7 @@ export function PaneTabs(props: {
<Show when={newTab()}>
<Tabs.Content value="new" class="flex flex-col h-full overflow-hidden contain-strict">
<Launchers items={launchers()} onChoose={target} />
<Launchers items={launchers()} onChoose={target} closed={closed} />
</Tabs.Content>
</Show>
<Show when={props.placeholder && !newTab() && props.pane.active() === undefined}>
@ -377,7 +405,7 @@ export function PaneTabs(props: {
</div>
}
>
<Launchers items={launchers()} onChoose={target} />
<Launchers items={launchers()} onChoose={target} closed={closed} />
</Show>
</Show>
</Tabs.Content>
@ -497,12 +525,17 @@ function AddMenu(props: { items: readonly SessionPanelMenuItem[]; onChoose: () =
)
}
/** The + menu items as rows, in the empty side region and the new tab view. */
function Launchers(props: { items: readonly SessionPanelMenuItem[]; onChoose: () => void }): JSX.Element {
/** The + menu items as rows, then the session's recently closed tabs, in the empty side region and the new tab view. */
function Launchers(props: {
items: readonly SessionPanelMenuItem[]
onChoose: () => void
closed: { entries: readonly ClosedSideTab[]; onReopen: (key: string) => void }
}): JSX.Element {
return (
<div class="flex-1 min-h-0 overflow-y-auto px-6">
<div class="mx-auto flex max-w-[560px] flex-col gap-3 pt-12">
<div class="mx-auto flex max-w-[560px] flex-col gap-3 pt-12 pb-10">
<For each={props.items}>{(item) => <LauncherRow item={item} onChoose={props.onChoose} />}</For>
<ClosedTabsSection entries={props.closed.entries} onReopen={props.closed.onReopen} />
</div>
</div>
)

View file

@ -540,6 +540,9 @@ export const dict = {
"session.tab.more": "More...",
"session.view.select": "Session view",
"session.tab.add": "Add tab",
"session.tab.recentHint": "Hold {{key}} for recently closed",
"session.tab.showMore": "Show more",
"session.tab.showLess": "Show less",
"session.tab.new": "New tab",
"session.tab.unknown": "Unknown Session",
"session.panel.reviewAndFiles": "Review and files",

View file

@ -406,7 +406,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
keybind: settings.keybinds.get("tab.reopenClosed") ?? "mod+shift+t",
hidden: true,
when: inSide,
onSelect: actions.region.reopen,
onSelect: () => actions.region.reopen(),
}),
cycle &&
fileCommand({

View file

@ -84,6 +84,7 @@ function SessionScreenContent(props: {
screen: props.screen,
tabs: session.layout.tabs,
closed: attachment.closed,
open: (key) => attachment.openTab(key, props.view()),
side: () => session.layout.view().side,
// Closing the last tab closes the side region, which opens with a new tab next time. The user's own hand closed
// it, so it goes at once.

View file

@ -11,6 +11,7 @@ import { Persist, persisted, removePersisted } from "@/runtime/persistence/stora
import { Persistence } from "@/runtime/persistence/schema"
import { TabStorage } from "@/shell/tabs/schema"
import { createScrollPersistence, type SessionScroll } from "./scroll"
import type { ClosedSideTab } from "@/runtime/extension/closed-tabs"
import { SessionStateKey } from "@/runtime/server/scope"
import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./helpers"
import { requireServerKey } from "@/shell/routes/session"
@ -106,8 +107,20 @@ const sessionTabsSchema = Persistence.struct({
),
})
const closedTabSchema = Schema.Struct({
key: Schema.String,
title: Schema.String,
icon: Schema.optional(Schema.String),
file: Schema.optional(Schema.String),
detail: Schema.optional(Schema.String),
data: Schema.optional(Schema.String),
closed: Schema.Finite,
})
const sessionViewSchema = Persistence.struct({
scroll: Persistence.record(Schema.Struct({ x: Schema.Finite, y: Schema.Finite })),
// Side tabs the user closed, newest first, for the new tab view's "Recently closed".
closed: Persistence.optional(Persistence.array(closedTabSchema)),
// Review state from before extensions. An extension copies each session's entry out once; nothing writes these
// any more, and they stay so layout rewrites cannot drop a session's entry before it is copied.
reviewOpen: Schema.optional(Persistence.array(Schema.String)),
@ -258,6 +271,17 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
if (preview) setEphemeral("sessionTabPreview", session, preview.value)
// A tab that opens again leaves "Recently closed".
const closed = store.sessionView[session]?.closed
if (closed?.some((entry) => placed.all.includes(entry.key)))
setStore(
"sessionView",
session,
"closed",
closed.filter((entry) => !placed.all.includes(entry.key)),
)
if (target && placed.all.some((tab) => !previous.all.includes(tab)))
setEphemeral("sessionTabTarget", session, undefined)
})
@ -588,6 +612,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
setEphemeral("sessionTabPreview", to, ephemeral.sessionTabPreview[from])
scroll.drop([to])
setStore("sessionView", to, {
closed: store.sessionView[from]?.closed?.map((entry) => ({ ...entry })),
scroll: Object.fromEntries(
Object.entries(store.sessionView[from]?.scroll ?? {}).map(([key, value]) => [
key,
@ -598,6 +623,17 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
prune(usage.active ?? to)
})
},
/** The side tabs closed in a session, newest first. Reactive. */
closed(session: string): readonly ClosedSideTab[] {
return store.sessionView[session]?.closed ?? []
},
setClosed(session: string, entries: readonly ClosedSideTab[]) {
usage.used.set(session, Date.now())
if (store.sessionView[session]) return setStore("sessionView", session, "closed", [...entries])
setStore("sessionView", session, { scroll: {}, closed: [...entries] })
prune(usage.active ?? session)
},
scroll(session: string, tab: string) {
return scroll.scroll(session, tab)
},

View file

@ -211,51 +211,6 @@
}
}
/* A side panel tab the new tab page offers. */
[data-component="browser-launcher-row"] {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
width: 100%;
height: 41px;
padding-inline: 14px;
border: 0.5px solid var(--v2-border-border-base);
border-radius: 8px;
background: var(--v2-background-bg-base);
color: var(--v2-text-text-base);
font-size: 13px;
font-weight: 440;
line-height: var(--line-height-compact);
letter-spacing: -0.04px;
text-align: start;
cursor: default;
&:hover {
background: var(--v2-overlay-simple-overlay-hover);
}
[data-slot="browser-launcher-row-text"] {
display: flex;
flex: 1 1 0;
align-items: center;
gap: 8px;
min-width: 0;
}
[data-slot="browser-launcher-row-title"] {
flex: none;
}
[data-slot="browser-launcher-row-description"] {
min-width: 0;
overflow: hidden;
color: var(--v2-text-text-muted);
text-overflow: ellipsis;
white-space: nowrap;
}
}
/* Suggestions read the address at full strength; the new tab page mutes it. */
[data-component="browser-suggestions"] [data-slot="browser-page-row-url"] {
color: var(--v2-text-text-base);

View file

@ -14,15 +14,7 @@ import { createResizeObserver } from "@solid-primitives/resize-observer"
import { createMemo, For, on, onCleanup, Show, type Accessor, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import type { Browser } from "@opencode/plugin-browser/rpc"
import {
createKeyed,
useExtension,
usePanel,
type PanelTab,
type MountedSession,
type SessionPanelMenuItem,
type SessionScreen,
} from "../sdk"
import { createKeyed, useExtension, usePanel, type PanelTab, type MountedSession, type SessionScreen } from "../sdk"
import { addressParts, connection, loopback, resolveAddress, searches } from "./address"
import { commentNote, pageNote } from "./comment"
import { bare, completes, highlight, suggest, type Visit } from "./history"
@ -199,8 +191,6 @@ export default function SessionBrowserPane(props: {
const listed = () => suggestions().length > 0 && !store.dismissed
const chosen = () => (listed() ? suggestions()[store.active] : undefined)
const recent = () => props.model.visits().slice(0, RECENT)
// The side panel's other new tab choices; picking one replaces this tab.
const launchers = () => extension.layout.side.menu().filter((item) => !item.primary && !item.opened)
// A loopback address opens on the machine that runs the server; the system browser here reaches it only when the
// server runs here too.
@ -900,7 +890,7 @@ export default function SessionBrowserPane(props: {
<Show when={empty() && !failed() && !suspended()}>
<div dir="auto" class="size-full overflow-y-auto">
<Show
when={launchers().length > 0 || recent().length > 0}
when={recent().length > 0}
fallback={
<div class="mx-auto flex max-w-[360px] flex-col items-center gap-4 px-6 pt-20 text-center">
<Icon name="outline-globe" size="large" class="text-v2-icon-icon-muted" />
@ -915,26 +905,15 @@ export default function SessionBrowserPane(props: {
</div>
}
>
{/* As designed: the other tabs the side panel offers, then the pages the browser showed last. */}
<div class="mx-auto flex w-full max-w-[608px] flex-col gap-12 px-6 pt-12 pb-10">
<Show when={launchers().length > 0}>
<div class="flex flex-col gap-3">
<For each={launchers()}>
{(item) => <LauncherRow item={item} keys={item.keybind ? keybinds.keybind(item.keybind) : []} />}
</For>
</div>
</Show>
{/* One visit is enough to list; the list stops at a handful. */}
<Show when={recent().length > 0}>
<div class="flex flex-col gap-4">
<div class="text-[13px] font-[440] leading-[var(--line-height-compact)] text-v2-text-text-muted">
{extension.t("home.recent")}
</div>
<div class="flex flex-col gap-2">
<For each={recent()}>{(visit) => <PageRow visit={visit} onClick={() => go(visit.url)} />}</For>
</div>
</div>
</Show>
{/* The pages the browser showed last; the side panel's new tab view offers the other tabs. One visit is
enough to list; the list stops at a handful. */}
<div class="mx-auto flex w-full max-w-[608px] flex-col gap-4 px-6 pt-12 pb-10">
<div class="text-[13px] font-[440] leading-[var(--line-height-compact)] text-v2-text-text-muted">
{extension.t("home.recent")}
</div>
<div class="flex flex-col gap-2">
<For each={recent()}>{(visit) => <PageRow visit={visit} onClick={() => go(visit.url)} />}</For>
</div>
</div>
</Show>
</div>
@ -1069,26 +1048,6 @@ export default function SessionBrowserPane(props: {
)
}
/** A side panel tab the new tab page offers: its icon, title, detail and shortcut. */
function LauncherRow(props: { item: SessionPanelMenuItem; keys: readonly string[] }) {
return (
<button type="button" data-component="browser-launcher-row" onClick={() => props.item.run()}>
<span data-slot="browser-launcher-row-text">
<Show when={props.item.icon}>
{(icon) => <Icon name={icon()} size="small" class="text-v2-icon-icon-muted" />}
</Show>
<span data-slot="browser-launcher-row-title">{props.item.title}</span>
<Show when={props.item.description}>
{(description) => <span data-slot="browser-launcher-row-description">{description()}</span>}
</Show>
</span>
<Show when={props.keys.length > 0}>
<Keybind keys={[...props.keys]} variant="neutral" />
</Show>
</button>
)
}
/** A page in the suggestions or the new tab page: its icon, title and address, with the query's matches drawn. */
function PageRow(props: {
visit: Visit

View file

@ -171,19 +171,18 @@ const setup: Setup<typeof definition> = (ctx) => {
const view = sessions.current()
const value = model()
// Only once the desktop's pane answers: an empty side region runs it, and lists the other items until then.
// Only once the desktop's pane answers.
if (!view || !value?.available(view) || !value.attached(view)) return undefined
// The new tab: its page lists the other items.
// A launcher of the side panel's new tab view, between Review and Terminal.
return {
menu: "session.panel",
id: "open",
title: ctx.t("tab.new"),
title: ctx.t("tab.title"),
icon: "globe",
// mod+t opens a browser tab only inside the pane, so advertise it only while the pane is on screen.
keybind: value.pane()?.visible() ? "browser.newTab" : undefined,
order: 40,
primary: true,
order: 15,
run: () => value.open(view),
}
})
@ -264,12 +263,16 @@ const setup: Setup<typeof definition> = (ctx) => {
</Show>
),
close: (input) => model()?.closeTab(input.session, input.tab.id),
// A blank new tab does not come back.
reopen(input) {
const session = input.session
const url = model()?.tab(session, input.tab.id)?.url
// A blank new tab does not come back; a page comes back as a new tab on its address.
closed(input) {
const url = model()?.tab(input.session, input.tab.id)?.url
return url && url !== "about:blank" ? () => model()?.openURL(session, url) : undefined
if (!url || url === "about:blank") return false
return { icon: "globe", detail: URL.canParse(url) ? new URL(url).host : undefined, data: url }
},
restore: (input) => {
if (input.data) model()?.openURL(input.session, input.data)
},
focus: (input) => model()?.focusTab(input.session, input.tab.id),
// A blank tab starts in its address field, like a browser's new tab.

View file

@ -42,6 +42,7 @@ const setup: Setup<typeof definition> = (ctx) => {
legacy: { context: "main", "usage:context": "main" },
mobile: { title: ctx.t("mobile.title"), order: 40, kind: "menu", icon: "status" },
list: (input) => (input.open.includes("main") ? [tab] : []),
closed: () => ({ icon: "status" }),
render: (props) => {
const panel = usePanel()

View file

@ -342,12 +342,6 @@ const setup: Setup<typeof File> = (ctx) => {
close(input) {
tabs.get(input.screen)?.delete(input.tab.id)
},
reopen(input) {
const session = input.session
const key = `file:${input.tab.id}`
return isFileTab(input.tab.id) ? () => layout.open(key, session, { tab: "select" }) : undefined
},
render: (props) => {
const panel = usePanel()

View file

@ -150,6 +150,8 @@ const setup: Setup<typeof Review> = (ctx) => {
kind: "tab",
},
list: (input) => (!layout.narrow() && input.session.project ? [tab] : []),
// Lists as "Review" rather than the tab's change count.
closed: () => ({ title: ctx.t("tab.title"), icon: "review" }),
render: (props) => {
const frame = usePanel()

View file

@ -247,6 +247,18 @@ export interface PanelProps {
readonly screen: SessionScreen
}
/** How a closed side tab lists under "Recently closed", from `Panel.closed`. */
export type ClosedPanelTab = {
/** The row's title; the tab's title by default. */
readonly title?: string
/** The row's icon; the panel's mobile icon by default. A tab with a `file` shows its file icon. */
readonly icon?: IconName
/** A muted detail after the title, e.g. a page's host. A tab with a `file` shows its folder. */
readonly detail?: string
/** What `restore` needs to bring the tab back, e.g. a URL. */
readonly data?: string
}
/** A panel: the tabs an extension shows in a session's side region. */
export interface Panel {
/** The panel's id, unique within the extension. Its mobile view's key is `${extension}:${id}`. */
@ -315,11 +327,28 @@ export interface Panel {
*/
close?(input: PanelProps): void
/**
* Read as the user closes a tab: how ⌘⇧T in the side panel brings it back, or nothing if it cannot come back.
* Read as the user closes a tab, for the new tab view's "Recently closed" list and ⌘⇧T. Return how the row reads and
* the data `restore` needs, or `false` when the tab cannot come back. Without it, a tab that is not `transient`
* lists under its title and reopens under the same key. The host stores the entry, so keep `data` small.
*
* @param input - The closing tab, routed session and owning screen. Capture what reopening needs now.
*/
reopen?(input: PanelProps): (() => void) | undefined
closed?(input: PanelProps): ClosedPanelTab | false | undefined
/**
* Brings back a tab `closed` listed. Without it, the host opens the same key again.
*
* @param input - The routed session, owning screen, the closed tab's id and the `data` `closed` returned.
*/
restore?(input: {
/** The routed session the tab closed in. */
readonly session: MountedSession
/** The session screen that owns the panel. */
readonly screen: SessionScreen
/** The closed tab's id. */
readonly id: string
/** What `closed` returned as `data`. */
readonly data?: string
}): void
/**
* Runs when the tab becomes selected. `restored` is true for the selection the side region mounts with, e.g. the
* tab selected before a reload, and false for every later selection change.