From d49c5b6279ff3b653de628be31929eeb8dbc86b7 Mon Sep 17 00:00:00 2001 From: David Hill Date: Mon, 28 Sep 2026 17:24:28 -0600 Subject: [PATCH] feat(app): add recently closed tabs menu --- packages/app/src/shell/tabs/closed.ts | 38 +++++- packages/app/src/shell/tabs/schema.ts | 7 +- packages/app/src/shell/tabs/tabs.test.ts | 67 +++++++++- packages/app/src/shell/tabs/tabs.tsx | 29 +++-- .../titlebar/recently-closed-tabs-menu.tsx | 123 ++++++++++++++++++ packages/app/src/shell/titlebar/tab-strip.tsx | 6 +- packages/app/src/shell/titlebar/titlebar.tsx | 41 ++---- 7 files changed, 261 insertions(+), 50 deletions(-) create mode 100644 packages/app/src/shell/titlebar/recently-closed-tabs-menu.tsx diff --git a/packages/app/src/shell/tabs/closed.ts b/packages/app/src/shell/tabs/closed.ts index 5241f0e71bd..4aa3a78a5c6 100644 --- a/packages/app/src/shell/tabs/closed.ts +++ b/packages/app/src/shell/tabs/closed.ts @@ -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() + 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 } diff --git a/packages/app/src/shell/tabs/schema.ts b/packages/app/src/shell/tabs/schema.ts index c8f3e8b582d..ca8582bb588 100644 --- a/packages/app/src/shell/tabs/schema.ts +++ b/packages/app/src/shell/tabs/schema.ts @@ -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) diff --git a/packages/app/src/shell/tabs/tabs.test.ts b/packages/app/src/shell/tabs/tabs.test.ts index 15cfd30c276..482180171fc 100644 --- a/packages/app/src/shell/tabs/tabs.test.ts +++ b/packages/app/src/shell/tabs/tabs.test.ts @@ -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() diff --git a/packages/app/src/shell/tabs/tabs.tsx b/packages/app/src/shell/tabs/tabs.tsx index 648cc6fd127..83a2d528fdf 100644 --- a/packages/app/src/shell/tabs/tabs.tsx +++ b/packages/app/src/shell/tabs/tabs.tsx @@ -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 } }, }) diff --git a/packages/app/src/shell/titlebar/recently-closed-tabs-menu.tsx b/packages/app/src/shell/titlebar/recently-closed-tabs-menu.tsx new file mode 100644 index 00000000000..9210694cd41 --- /dev/null +++ b/packages/app/src/shell/titlebar/recently-closed-tabs-menu.tsx @@ -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 ( + + + } + onClick={props.onNewTab} + aria-label={language.t("command.session.new")} + /> + + } + > + + + {language.t("command.session.new")} + + + + + + + {language.t("home.recentlyClosed")} + + {(entry) => ( + tabs.reopenClosedTab(entry.tab, { append: true })} /> + )} + + + + + + ) +} + +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 ( + + + + + {title()}} + disabled={!state.truncated} + class="min-w-0 flex-1" + contentClass="max-w-[min(480px,calc(100vw-32px))] break-words" + > + + createResizeObserver(element, () => setState("truncated", element.scrollWidth > element.clientWidth)) + } + dir="auto" + class="block min-w-0 truncate text-start" + > + {title()} + + + + ) +} diff --git a/packages/app/src/shell/titlebar/tab-strip.tsx b/packages/app/src/shell/titlebar/tab-strip.tsx index 8383665a0c1..747fb9513ea 100644 --- a/packages/app/src/shell/titlebar/tab-strip.tsx +++ b/packages/app/src/shell/titlebar/tab-strip.tsx @@ -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, { diff --git a/packages/app/src/shell/titlebar/titlebar.tsx b/packages/app/src/shell/titlebar/titlebar.tsx index b90fa873e59..3e91d35ea54 100644 --- a/packages/app/src/shell/titlebar/titlebar.tsx +++ b/packages/app/src/shell/titlebar/titlebar.tsx @@ -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)} /> - {language.t("command.session.new")} } - > - } - onClick={openNewTab} - aria-label={language.t("command.session.new")} - /> - + /> } > @@ -669,23 +660,11 @@ export function Titlebar(props: { {homeButton(true)} - +