Compare commits

...
Author SHA1 Message Date
David Hill d49c5b6279 feat(app): add recently closed tabs menu 2026-09-28 17:24:54 -06:00
7 changed files with 261 additions and 50 deletions

No files matched your search

+33 -5
View File
@@ -1,4 +1,4 @@
import type { SessionTab, Tab } from "./tabs"
import type { SessionTab, Tab, TabInfo } from "./tabs"
import type { TabStorage } from "./schema"
export type ClosedTab = typeof TabStorage.ClosedTab.Type
@@ -7,14 +7,38 @@ const CLOSED_TAB_LIMIT = 25
// Only session tabs are recorded; closing a draft tab deletes its persisted
// state, so a reopened draft would come back empty anyway.
export function pushClosedTab(stack: ClosedTab[], tab: Tab, index: number): ClosedTab[] {
export function pushClosedTab(stack: ClosedTab[], tab: Tab, index: number, info?: TabInfo): ClosedTab[] {
if (tab.type !== "session") return stack
return [...stack, { tab: { ...tab }, index }].slice(-CLOSED_TAB_LIMIT)
return [
...stack.filter((entry) => !sameTab(entry.tab, tab)),
{ tab: { ...tab }, index, ...(info ? { info: { ...info } } : {}) },
].slice(-CLOSED_TAB_LIMIT)
}
export function listClosedTabs(stack: ClosedTab[], tabs: Tab[]) {
const seen = new Set<string>()
return stack.toReversed().filter((entry) => {
const key = `${entry.tab.server}\n${entry.tab.sessionId}`
if (seen.has(key)) return false
seen.add(key)
return !isOpen(tabs, entry.tab)
})
}
// Pops the most recently closed tab that is not open again,
// discarding stale entries along the way.
export function takeClosedTab(stack: ClosedTab[], tabs: Tab[]): { entry?: ClosedTab; stack: ClosedTab[] } {
export function takeClosedTab(
stack: ClosedTab[],
tabs: Tab[],
target?: SessionTab,
): { entry?: ClosedTab; stack: ClosedTab[] } {
if (target) {
const index = stack.findLastIndex((entry) => sameTab(entry.tab, target))
const entry = stack[index]
if (!entry || isOpen(tabs, entry.tab)) return { stack }
return { entry, stack: [...stack.slice(0, index), ...stack.slice(index + 1)] }
}
const remaining = [...stack]
while (remaining.length) {
const entry = remaining.pop()
@@ -34,5 +58,9 @@ export function nextTabAfterClose(tabs: Tab[], index: number, active: boolean) {
}
function isOpen(tabs: Tab[], tab: SessionTab) {
return tabs.some((item) => item.type === "session" && item.server === tab.server && item.sessionId === tab.sessionId)
return tabs.some((item) => item.type === "session" && sameTab(item, tab))
}
function sameTab(first: SessionTab, second: SessionTab) {
return first.server === second.server && first.sessionId === second.sessionId
}
+6 -1
View File
@@ -46,6 +46,7 @@ export const Recent = Persistence.struct({
export const Info = Persistence.struct({
title: Schema.optional(Schema.String),
directory: Schema.optional(Schema.String),
prompted: Schema.optional(Schema.Boolean),
})
export const Infos = Schema.Record(Schema.String, Schema.mutableKey(Info))
export const Panes = Schema.Record(
@@ -59,5 +60,9 @@ export const Panes = Schema.Record(
}),
),
)
export const ClosedTab = Schema.Struct({ tab: SessionCodec, index: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)) })
export const ClosedTab = Persistence.struct({
tab: SessionCodec,
index: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
info: Persistence.optional(Info),
})
export const Closed = Persistence.array(ClosedTab)
+64 -3
View File
@@ -1,7 +1,14 @@
import { describe, expect, test } from "bun:test"
import { createRoot, getOwner, onCleanup } from "solid-js"
import { createTabMemory } from "./memory"
import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed"
import {
listClosedTabs,
nextTabAfterClose,
pushClosedTab,
removeClosedTabs,
takeClosedTab,
type ClosedTab,
} from "./closed"
import { findSessionTab, sessionIDHasOpenTab, tabHref, tabKey, type SessionTab, type Tab } from "./tabs"
import { Schema } from "effect"
import { TabStorage } from "./schema"
@@ -140,9 +147,15 @@ describe("tab memory", () => {
describe("closed tab stack", () => {
test("records session tabs with their index", () => {
const stack = pushClosedTab([], sessionTab("a"), 2)
const stack = pushClosedTab([], sessionTab("a"), 2, {
title: "Alpha",
directory: "/project",
prompted: true,
})
expect(stack).toEqual([{ tab: sessionTab("a"), index: 2 }])
expect(stack).toEqual([
{ tab: sessionTab("a"), index: 2, info: { title: "Alpha", directory: "/project", prompted: true } },
])
})
test("ignores draft tabs", () => {
@@ -162,6 +175,19 @@ describe("closed tab stack", () => {
expect(stack.at(-1)?.tab.sessionId).toBe("s29")
})
test("keeps only the newest close record for a session", () => {
const stack = pushClosedTab(
pushClosedTab([], sessionTab("a"), 1, { title: "Old", directory: "/old", prompted: true }),
sessionTab("a"),
3,
{ title: "New", directory: "/new", prompted: true },
)
expect(stack).toEqual([
{ tab: sessionTab("a"), index: 3, info: { title: "New", directory: "/new", prompted: true } },
])
})
test("pops the most recently closed tab", () => {
const stack = [
{ tab: sessionTab("a"), index: 0 },
@@ -184,6 +210,41 @@ describe("closed tab stack", () => {
expect(result.stack).toEqual([])
})
test("lists closed tabs from newest to oldest and excludes open tabs", () => {
const stack = [
{ tab: sessionTab("a"), index: 0 },
{ tab: sessionTab("b"), index: 1 },
{ tab: sessionTab("c"), index: 2 },
]
expect(listClosedTabs(stack, [sessionTab("b")]).map((entry) => entry.tab.sessionId)).toEqual(["c", "a"])
})
test("deduplicates previously stored close records", () => {
const stack = [
{ tab: sessionTab("a"), index: 0 },
{ tab: sessionTab("b"), index: 1 },
{ tab: sessionTab("a"), index: 2 },
]
expect(listClosedTabs(stack, []).map((entry) => [entry.tab.sessionId, entry.index])).toEqual([
["a", 2],
["b", 1],
])
})
test("takes a selected closed tab without removing newer entries", () => {
const stack = [
{ tab: sessionTab("a"), index: 0 },
{ tab: sessionTab("b"), index: 1 },
{ tab: sessionTab("c"), index: 2 },
]
const result = takeClosedTab(stack, [], sessionTab("a"))
expect(result.entry?.tab.sessionId).toBe("a")
expect(result.stack.map((entry) => entry.tab.sessionId)).toEqual(["b", "c"])
})
test("returns no entry when everything is open or empty", () => {
expect(takeClosedTab([], []).entry).toBeUndefined()
+20 -9
View File
@@ -364,20 +364,21 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
closeTab(index: number) {
const tab = store[index]
if (!tab) return
if (tab.type === "session") updateClosed((stack) => pushClosedTab(stack, tab, index))
if (tab.type === "session") updateClosed((stack) => pushClosedTab(stack, tab, index, info[tabKey(tab)]))
removeTab(index)
},
reopenClosedTab() {
reopenClosedTab(target?: SessionTab, options?: { append?: boolean }) {
if (!closedReady()) {
void closedReady.promise?.then(() => actions.reopenClosedTab())
void closedReady.promise?.then(() => actions.reopenClosedTab(target, options))
return
}
const result = takeClosedTab(closed, store)
const result = takeClosedTab(closed, store, target)
if (result.stack.length === closed.length) return
setClosed(() => result.stack)
const entry = result.entry
if (!entry) return
const index = Math.min(entry.index, store.length)
const index = options?.append ? store.length : Math.min(entry.index, store.length)
if (entry.info) setInfo(tabKey(entry.tab), entry.info)
void startTransition(() => {
setStore(
produce((tabs) => {
@@ -457,11 +458,21 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
for (const key of removed) memory.remove(key)
for (const key of removed) removeInfo(key)
},
rememberSessionInfo(tab: SessionTab, session: SessionInfo) {
rememberSessionInfo(tab: SessionTab, session: SessionInfo, prompted: boolean) {
const key = tabKey(tab)
const next = { title: session.title, directory: session.location.directory }
const current = info[key]
if (current && current.title === next.title && current.directory === next.directory) return
const next = {
title: session.title,
directory: session.location.directory,
prompted: current?.prompted === true || prompted,
}
if (
current &&
current.title === next.title &&
current.directory === next.directory &&
current.prompted === next.prompted
)
return
console.debug("[tabs] update persisted session info", { key, sessionID: session.id, current, next })
setInfo(key, next)
},
@@ -533,6 +544,6 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
},
}
return { ...actions, store, info, ready, infoReady, recentReady, panesReady }
return { ...actions, store, info, closed, ready, infoReady, recentReady, closedReady, panesReady }
},
})
@@ -0,0 +1,123 @@
import { createMemo, For, Show, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { Icon } from "@opencode/ui/icon"
import { IconButton } from "@opencode/ui/icon-button"
import { Menu } from "@opencode/ui/menu"
import { Tooltip } from "@opencode/ui/tooltip"
import { useLanguage } from "@/runtime/i18n/language"
import { ServerConnection } from "@/runtime/server/registry"
import { useGlobal, useServerCtx } from "@/runtime/server/runtime"
import { SessionTabAvatarView } from "@/shell/layout/session-tab-avatar"
import { listClosedTabs, type ClosedTab } from "@/shell/tabs/closed"
import { useTabs } from "@/shell/tabs/tabs"
import { sessionTabTitle } from "./tab-title"
export function RecentlyClosedTabsMenu(props: {
onNewTab: () => void
tooltip?: JSX.Element
vertical?: boolean
keybind?: string
}) {
const language = useLanguage()
const tabs = useTabs()
const recent = createMemo(() => listClosedTabs(tabs.closed, tabs.store).filter((entry) => entry.info?.prompted === true))
return (
<Menu.Context modal={false}>
<Show
when={props.vertical}
fallback={
<Tooltip placement="bottom" value={props.tooltip}>
<Menu.Context.Trigger
as={IconButton}
type="button"
variant="ghost-muted"
size="large"
class="shrink-0"
icon={<Icon name="plus" />}
onClick={props.onNewTab}
aria-label={language.t("command.session.new")}
/>
</Tooltip>
}
>
<Menu.Context.Trigger
as="button"
type="button"
data-titlebar-tab-action
data-action="vertical-tabs-new-session"
class="group flex h-7 w-full shrink-0 items-center gap-1.5 rounded-[6px] ps-1.5 pe-2 text-[13px] leading-4 text-v2-text-text-faint hover:text-v2-text-text-base"
onClick={props.onNewTab}
aria-label={language.t("command.session.new")}
>
<Icon name="edit" class="shrink-0" />
<span class="min-w-0 truncate">{language.t("command.session.new")}</span>
<span
class="ms-auto hidden min-w-0 truncate text-v2-text-text-faint group-hover:block group-focus-visible:block"
aria-hidden="true"
>
<bdi dir="ltr">{props.keybind}</bdi>
</span>
</Menu.Context.Trigger>
</Show>
<Menu.Context.Portal>
<Menu.Context.Content class="max-h-[66.667dvh] w-56 max-w-56 overflow-y-auto">
<Menu.Group>
<Menu.GroupLabel>{language.t("home.recentlyClosed")}</Menu.GroupLabel>
<For each={recent()}>
{(entry) => (
<RecentlyClosedTabItem entry={entry} onSelect={() => tabs.reopenClosedTab(entry.tab, { append: true })} />
)}
</For>
</Menu.Group>
</Menu.Context.Content>
</Menu.Context.Portal>
</Menu.Context>
)
}
function RecentlyClosedTabItem(props: { entry: ClosedTab; onSelect: () => void }) {
const global = useGlobal()
const language = useLanguage()
const [state, setState] = createStore({ truncated: false })
const placement = language.direction() === "rtl" ? "left-start" : "right-start"
const serverCtx = useServerCtx(() =>
global.servers.list().find((item) => ServerConnection.key(item) === props.entry.tab.server),
)
const session = createMemo(() => serverCtx()?.data.session.get(props.entry.tab.sessionId))
const directory = () => props.entry.info?.directory ?? session()?.location.directory ?? ""
const project = createMemo(() => {
const value = session()
if (value) return serverCtx()?.projects.forSession(value)
if (!directory()) return
return serverCtx()?.projects.resolve({ worktree: directory(), expanded: false })
})
const title = () =>
sessionTabTitle(session()?.title ?? props.entry.info?.title, language.t("session.tab.session"))
return (
<Menu.Item class="max-w-56" onSelect={props.onSelect}>
<span class="flex size-4 shrink-0 items-center justify-center">
<SessionTabAvatarView project={project()} directory={directory()} unread={false} loading={false} />
</span>
<Tooltip
placement={placement}
value={<bdi dir="auto">{title()}</bdi>}
disabled={!state.truncated}
class="min-w-0 flex-1"
contentClass="max-w-[min(480px,calc(100vw-32px))] break-words"
>
<span
ref={(element) =>
createResizeObserver(element, () => setState("truncated", element.scrollWidth > element.clientWidth))
}
dir="auto"
class="block min-w-0 truncate text-start"
>
{title()}
</span>
</Tooltip>
</Menu.Item>
)
}
@@ -150,7 +150,11 @@ function SessionTabEntry(props: {
createEffect(() => {
const value = session()
if (!value) return
tabs.rememberSessionInfo(props.tab, value)
tabs.rememberSessionInfo(
props.tab,
value,
props.serverCtx?.data.session.message.list(value.id).some((message) => message.type === "user") ?? false,
)
const current = sdk()
if (!current) return
createTabComposerState(tabs, props.tab, current.scope, {
+10 -31
View File
@@ -29,6 +29,7 @@ import { MobileDrawer, MobileDrawerContent, MobileDrawerLabel, MobileDrawerTrigg
import { sessionTabTitle } from "./tab-title"
import { SessionTabAvatar } from "@/shell/layout/session-tab-avatar"
import { SessionProgressIndicatorV2 } from "@opencode/session-ui/v2/session-progress-indicator-v2"
import { RecentlyClosedTabsMenu } from "./recently-closed-tabs-menu"
import { useSettingsDialog } from "@/settings/command"
import { updaterAction } from "@/shell/updates/action"
import type { UpdaterState } from "@/shell/updates/types"
@@ -629,25 +630,15 @@ export function Titlebar(props: {
}}
onReorder={(keys) => tabsStoreActions.reorder(keys)}
/>
<Tooltip
placement="bottom"
value={
<RecentlyClosedTabsMenu
onNewTab={openNewTab}
tooltip={
<>
{language.t("command.session.new")}
<Keybind keys={newTabTooltipKeybind(command)} variant="neutral" />
</>
}
>
<IconButton
type="button"
variant="ghost-muted"
size="large"
class="shrink-0"
icon={<Icon name="plus" />}
onClick={openNewTab}
aria-label={language.t("command.session.new")}
/>
</Tooltip>
/>
</>
}
>
@@ -669,23 +660,11 @@ export function Titlebar(props: {
<ChannelIndicator sidebar debugTools={props.debugTools} />
</Show>
{homeButton(true)}
<button
type="button"
data-titlebar-tab-action
data-action="vertical-tabs-new-session"
class="group flex h-7 w-full shrink-0 items-center gap-1.5 rounded-[6px] ps-1.5 pe-2 text-[13px] leading-4 text-v2-text-text-faint hover:text-v2-text-text-base"
onClick={openNewTab}
aria-label={language.t("command.session.new")}
>
<Icon name="edit" class="shrink-0" />
<span class="min-w-0 truncate">{language.t("command.session.new")}</span>
<span
class="ms-auto hidden min-w-0 truncate text-v2-text-text-faint group-hover:block group-focus-visible:block"
aria-hidden="true"
>
<bdi dir="ltr">{command.keybind("tab.new")}</bdi>
</span>
</button>
<RecentlyClosedTabsMenu
vertical
onNewTab={openNewTab}
keybind={command.keybind("tab.new")}
/>
<div class="h-4 w-full shrink-0" aria-hidden="true" />
<div class="flex min-h-0 flex-1 flex-col gap-1">
<TitlebarTabStrip