From f1a96a04d137bfa41a340a49941682df0bd44613 Mon Sep 17 00:00:00 2001 From: usrnk1 <7547651+usrnk1@users.noreply.github.com> Date: Thu, 2 Jul 2026 21:24:17 +0200 Subject: [PATCH 1/3] feat(desktop): reopen closed session tabs with mod+shift+t User-initiated tab closes are recorded in a persisted stack; tab.reopenClosed restores the most recent one at its old index. Exposed in the File menu as Reopen Closed Tab (Shift+Cmd+T on macOS). theme.cycle moves to mod+alt+shift+t: the renderer keymap resolves duplicate keybinds by registration order, and whichever handler wins calls preventDefault, which on macOS also suppresses the matching menu accelerator. --- packages/app/src/components/titlebar.tsx | 11 +++- packages/app/src/context/closed-tabs.ts | 30 +++++++++++ packages/app/src/context/tabs.test.ts | 64 ++++++++++++++++++++++++ packages/app/src/context/tabs.tsx | 35 +++++++++++++ packages/app/src/desktop-menu.test.ts | 13 +++++ packages/app/src/desktop-menu.ts | 6 +++ packages/app/src/i18n/en.ts | 1 + packages/app/src/pages/layout.tsx | 3 +- 8 files changed, 160 insertions(+), 3 deletions(-) create mode 100644 packages/app/src/context/closed-tabs.ts diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index 703082d55370..3592bbe6227f 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -374,9 +374,16 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { keybind: "mod+w", hidden: true, onSelect: () => { - tabsStoreActions.removeTab(tabsStore.findIndex((tab) => current === tab)) + tabsStoreActions.closeTab(tabsStore.findIndex((tab) => current === tab)) }, }, + { + id: "tab.reopenClosed", + category: language.t("command.category.file"), + title: language.t("command.tab.reopenClosed"), + keybind: "mod+shift+t", + onSelect: () => tabsStoreActions.reopenClosedTab(), + }, { id: `tab.prev`, category: "tab", @@ -464,7 +471,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { }} onClose={(tab) => { const index = tabsStore.findIndex((item) => tabKey(item) === tabKey(tab)) - if (index !== -1) tabsStoreActions.removeTab(index) + if (index !== -1) tabsStoreActions.closeTab(index) }} onReorder={(keys) => tabsStoreActions.reorder(keys)} /> diff --git a/packages/app/src/context/closed-tabs.ts b/packages/app/src/context/closed-tabs.ts new file mode 100644 index 000000000000..49bf639bde06 --- /dev/null +++ b/packages/app/src/context/closed-tabs.ts @@ -0,0 +1,30 @@ +import type { SessionTab, Tab } from "./tabs" + +export type ClosedTab = { + tab: SessionTab + index: number +} + +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[] { + if (tab.type !== "session") return stack + return [...stack, { tab: { ...tab }, index }].slice(-CLOSED_TAB_LIMIT) +} + +// 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[] } { + const remaining = [...stack] + while (remaining.length) { + const entry = remaining.pop() + if (entry && !isOpen(tabs, entry.tab)) return { entry, stack: remaining } + } + return { stack: remaining } +} + +function isOpen(tabs: Tab[], tab: SessionTab) { + return tabs.some((item) => item.type === "session" && item.server === tab.server && item.sessionId === tab.sessionId) +} diff --git a/packages/app/src/context/tabs.test.ts b/packages/app/src/context/tabs.test.ts index 27525af63d9d..4f09e0e2172f 100644 --- a/packages/app/src/context/tabs.test.ts +++ b/packages/app/src/context/tabs.test.ts @@ -1,6 +1,15 @@ import { describe, expect, test } from "bun:test" import { createRoot, getOwner, onCleanup } from "solid-js" import { createTabMemory } from "./tab-memory" +import { pushClosedTab, takeClosedTab, type ClosedTab } from "./closed-tabs" +import type { SessionTab, Tab } from "./tabs" +import type { ServerConnection } from "./server" + +const server = "local\nhttp://localhost:4096" as ServerConnection.Key + +function sessionTab(sessionId: string): SessionTab { + return { type: "session", server, sessionId } +} describe("tab memory", () => { test("keeps state until its tab is removed", () => { @@ -22,3 +31,58 @@ describe("tab memory", () => { }) }) }) + +describe("closed tab stack", () => { + test("records session tabs with their index", () => { + const stack = pushClosedTab([], sessionTab("a"), 2) + + expect(stack).toEqual([{ tab: sessionTab("a"), index: 2 }]) + }) + + test("ignores draft tabs", () => { + const draft: Tab = { type: "draft", draftID: "d1", server, directory: "/tmp" } + + expect(pushClosedTab([], draft, 0)).toEqual([]) + }) + + test("caps the stack size", () => { + const stack = Array.from({ length: 30 }, (_, i) => i).reduce( + (acc, i) => pushClosedTab(acc, sessionTab(`s${i}`), i), + [], + ) + + expect(stack).toHaveLength(25) + expect(stack[0]?.tab.sessionId).toBe("s5") + expect(stack.at(-1)?.tab.sessionId).toBe("s29") + }) + + test("pops the most recently closed tab", () => { + const stack = [ + { tab: sessionTab("a"), index: 0 }, + { tab: sessionTab("b"), index: 1 }, + ] + const result = takeClosedTab(stack, []) + + expect(result.entry?.tab.sessionId).toBe("b") + expect(result.stack).toEqual([{ tab: sessionTab("a"), index: 0 }]) + }) + + test("skips entries whose tab is already open", () => { + const stack = [ + { tab: sessionTab("a"), index: 0 }, + { tab: sessionTab("b"), index: 1 }, + ] + const result = takeClosedTab(stack, [sessionTab("b")]) + + expect(result.entry?.tab.sessionId).toBe("a") + expect(result.stack).toEqual([]) + }) + + test("returns no entry when everything is open or empty", () => { + expect(takeClosedTab([], []).entry).toBeUndefined() + + const result = takeClosedTab([{ tab: sessionTab("a"), index: 0 }], [sessionTab("a")]) + expect(result.entry).toBeUndefined() + expect(result.stack).toEqual([]) + }) +}) diff --git a/packages/app/src/context/tabs.tsx b/packages/app/src/context/tabs.tsx index 6a73d7736dc7..36ef7bf54e18 100644 --- a/packages/app/src/context/tabs.tsx +++ b/packages/app/src/context/tabs.tsx @@ -10,6 +10,7 @@ import { uuid } from "@/utils/uuid" import { SessionTabsRemovedDetail } from "@/components/titlebar-session-events" import { sessionHref } from "@/utils/session-route" import { createTabMemory } from "./tab-memory" +import { pushClosedTab, takeClosedTab, type ClosedTab } from "./closed-tabs" export type SessionTab = { type: "session" @@ -63,6 +64,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ createStore([]), ) const [recent, setRecent, , recentReady] = persisted(Persist.window("tabs.recent"), createStore({})) + const [closed, setClosed, , closedReady] = persisted(Persist.global("tabs.closed"), createStore([])) const params = useParams() const navigate = useNavigate() @@ -106,6 +108,13 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ if (recent.key && !next.some((tab) => tabKey(tab) === recent.key)) setRecentKey(undefined) }) + createEffect(() => { + if (!closedReady()) return + const servers = new Set(server.list.map(ServerConnection.key)) + const next = closed.filter((entry) => servers.has(entry.tab.server)) + if (next.length !== closed.length) setClosed(() => next) + }) + const navigateTab = (tab: Tab) => { const href = tabHref(tab) setRecentKey(tabKey(tab)) @@ -201,6 +210,32 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ removeDraftPersisted(draftID) }, removeTab, + // User-initiated close: records the tab so it can be reopened. + // Cleanup paths (missing sessions, archive, server removal) go through + // removeTab and friends directly and are not recorded. + closeTab(index: number) { + const tab = store[index] + if (!tab) return + if (tab.type === "session") setClosed((stack) => pushClosedTab(stack, tab, index)) + removeTab(index) + }, + reopenClosedTab() { + const result = takeClosedTab(closed, store) + 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) + void startTransition(() => { + setStore( + produce((tabs) => { + if (tabs.some((item) => tabKey(item) === tabKey(entry.tab))) return + tabs.splice(index, 0, entry.tab) + }), + ) + navigateTab(entry.tab) + }) + }, removeSessionTab(input: Omit) { const index = store.findIndex( (tab) => tab.type === "session" && tab.server === input.server && tab.sessionId === input.sessionId, diff --git a/packages/app/src/desktop-menu.test.ts b/packages/app/src/desktop-menu.test.ts index c7e36a7cb73b..6f10e808c884 100644 --- a/packages/app/src/desktop-menu.test.ts +++ b/packages/app/src/desktop-menu.test.ts @@ -10,4 +10,17 @@ describe("desktop menu", () => { expect(items).toHaveLength(2) expect(items.every((item) => item.type === "item" && item.command === "logs.export" && !item.action)).toBe(true) }) + + test("reopens closed tabs through the command registry", () => { + const items = DESKTOP_MENU.flatMap((menu) => menu.items ?? []).filter( + (item) => item.type === "item" && item.command === "tab.reopenClosed", + ) + + expect(items).toHaveLength(1) + expect(items[0]).toMatchObject({ + label: "Reopen Closed Tab", + accelerator: { macos: "Shift+Cmd+T" }, + }) + expect(items[0]?.type === "item" && !items[0].action).toBe(true) + }) }) diff --git a/packages/app/src/desktop-menu.ts b/packages/app/src/desktop-menu.ts index d3902cd2b245..5cb0e8e9d5eb 100644 --- a/packages/app/src/desktop-menu.ts +++ b/packages/app/src/desktop-menu.ts @@ -113,6 +113,12 @@ export const DESKTOP_MENU: DesktopMenu[] = [ action: "window.new", accelerator: { macos: "Cmd+Shift+N", windows: "Ctrl+Shift+N" }, }, + { + type: "item", + label: "Reopen Closed Tab", + command: "tab.reopenClosed", + accelerator: { macos: "Shift+Cmd+T" }, + }, { type: "separator" }, { type: "item", label: "Close Window", action: "window.close", role: "close" }, ], diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 0124fcf6ed76..0d5127cfc3ad 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -48,6 +48,7 @@ export const dict = { "command.session.new": "New session", "command.file.open": "Open file", "command.tab.close": "Close tab", + "command.tab.reopenClosed": "Reopen closed tab", "command.context.addSelection": "Add selection to context", "command.context.addSelection.description": "Add selected lines from the current file", "command.input.focus": "Focus input", diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 690460d3af6e..6bc9b7240358 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -1021,7 +1021,8 @@ export default function LegacyLayout(props: ParentProps) { id: "theme.cycle", title: language.t("command.theme.cycle"), category: language.t("command.category.theme"), - keybind: "mod+shift+t", + // mod+shift+t belongs to tab.reopenClosed (matches browser convention) + keybind: "mod+alt+shift+t", onSelect: () => cycleTheme(1), }, ] From 6029c25113898e24ac0d7c19dc3587d1092934a3 Mon Sep 17 00:00:00 2001 From: usrnk1 <7547651+usrnk1@users.noreply.github.com> Date: Thu, 2 Jul 2026 21:24:59 +0200 Subject: [PATCH 2/3] feat(app): open home sessions in background tabs with modifier click Cmd+click on macOS (Ctrl+click elsewhere) on a session row or search result adds the tab without navigating. Background opens from search keep the results open so several sessions can be opened in a row. --- packages/app/src/pages/home.tsx | 36 ++++++++++++++++++++++++--------- 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index 799049526059..f3ed389f5cb9 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -238,6 +238,16 @@ function useHomeSessionHeaderOpacity(groups: () => HomeSessionGroup[]) { return { setViewport, setContentRef, setHeaderRef, update, titleOpacity } } +const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) + +// Cmd+click on macOS (Ctrl+click elsewhere) opens a session tab in the +// background without navigating, matching browser conventions. +function isBackgroundOpen(event: MouseEvent) { + return IS_MAC ? event.metaKey : event.ctrlKey +} + +type OpenSessionOptions = { background?: boolean } + export function NewHome() { const sync = useServerSync() const layout = useLayout() @@ -373,9 +383,11 @@ export function NewHome() { setState("searchFocused", false) } - function selectSearchSession(session: Session) { - openSession(session) - closeSearch() + function selectSearchSession(session: Session, options?: OpenSessionOptions) { + openSession(session, options) + // Background opens keep the search visible so several results can be + // opened in a row. + if (!options?.background) closeSearch() } command.register("home", () => [ @@ -460,7 +472,7 @@ export function NewHome() { .forEach((directory) => state.project.markViewed(directory)) } - function openSession(session: Session) { + function openSession(session: Session, options?: OpenSessionOptions) { const project = projectForSession(session, projects(), projectByID()) const conn = focusedServer() if (!conn) return @@ -468,6 +480,10 @@ export function NewHome() { const ctx = global.ensureServerCtx(conn) ctx.projects.open(directory) ctx.projects.touch(directory) + if (options?.background) { + tabs.addSessionTab({ server: ServerConnection.key(conn), sessionId: session.id }) + return + } startTransition(() => { const tab = tabs.addSessionTab({ server: ServerConnection.key(conn), sessionId: session.id }) tabs.select(tab) @@ -1031,7 +1047,7 @@ function HomeSessionSearch(props: { onInput: (value: string) => void onFocus: () => void onClose: () => void - onSelect: (session: Session) => void + onSelect: (session: Session, options?: OpenSessionOptions) => void }) { const language = useLanguage() const [store, setStore] = createStore({ active: "" }) @@ -1145,7 +1161,7 @@ function HomeSessionSearch(props: { server={props.server} selected={store.active === homeSessionSearchKey(record)} onHighlight={() => setStore("active", homeSessionSearchKey(record))} - onSelect={(session) => props.onSelect(session)} + onSelect={(session, options) => props.onSelect(session, options)} /> )} @@ -1225,7 +1241,7 @@ function HomeSessionSearchResultRow(props: { server: ServerConnection.Key selected: boolean onHighlight: () => void - onSelect: (session: Session) => void + onSelect: (session: Session, options?: OpenSessionOptions) => void }) { const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id) const showProjectName = () => props.showProjectName && props.record.projectName @@ -1246,7 +1262,7 @@ function HomeSessionSearchResultRow(props: { group: !!showProjectName(), }} onMouseEnter={() => props.onHighlight()} - onClick={() => props.onSelect(props.record.session)} + onClick={(event) => props.onSelect(props.record.session, { background: isBackgroundOpen(event) })} > void + openSession: (session: Session, options?: OpenSessionOptions) => void archiveSession: (session: Session) => Promise }) { const language = useLanguage() @@ -1306,7 +1322,7 @@ function HomeSessionRow(props: { type="button" data-component="home-session-row" class={`${HOME_ROW} h-10 min-w-0 flex-1 gap-2 py-3 pl-3 pr-10`} - onClick={() => props.openSession(props.record.session)} + onClick={(event) => props.openSession(props.record.session, { background: isBackgroundOpen(event) })} > Date: Sun, 5 Jul 2026 11:26:45 +1000 Subject: [PATCH 3/3] fix(app): harden tab reopening behavior --- packages/app/src/context/closed-tabs.ts | 10 +++++++ packages/app/src/context/tabs.test.ts | 25 +++++++++++++++- packages/app/src/context/tabs.tsx | 30 +++++++++++++++---- packages/app/src/desktop-menu.test.ts | 13 -------- packages/app/src/desktop-menu.ts | 6 ---- .../app/src/pages/home-session-open.test.ts | 12 ++++++++ packages/app/src/pages/home-session-open.ts | 11 +++++++ packages/app/src/pages/home.tsx | 13 +++++--- packages/app/src/pages/layout.tsx | 3 +- 9 files changed, 91 insertions(+), 32 deletions(-) create mode 100644 packages/app/src/pages/home-session-open.test.ts create mode 100644 packages/app/src/pages/home-session-open.ts diff --git a/packages/app/src/context/closed-tabs.ts b/packages/app/src/context/closed-tabs.ts index 49bf639bde06..5c49b94b6fb2 100644 --- a/packages/app/src/context/closed-tabs.ts +++ b/packages/app/src/context/closed-tabs.ts @@ -25,6 +25,16 @@ export function takeClosedTab(stack: ClosedTab[], tabs: Tab[]): { entry?: Closed return { stack: remaining } } +export function removeClosedTabs(stack: ClosedTab[], server: SessionTab["server"], sessionIDs: string[]) { + const removed = new Set(sessionIDs) + return stack.filter((entry) => entry.tab.server !== server || !removed.has(entry.tab.sessionId)) +} + +export function nextTabAfterClose(tabs: Tab[], index: number, active: boolean) { + if (!active) return undefined + return tabs[index + 1] ?? tabs[index - 1] ?? null +} + function isOpen(tabs: Tab[], tab: SessionTab) { return tabs.some((item) => item.type === "session" && item.server === tab.server && item.sessionId === tab.sessionId) } diff --git a/packages/app/src/context/tabs.test.ts b/packages/app/src/context/tabs.test.ts index 4f09e0e2172f..fbc40ea09e6a 100644 --- a/packages/app/src/context/tabs.test.ts +++ b/packages/app/src/context/tabs.test.ts @@ -1,7 +1,13 @@ import { describe, expect, test } from "bun:test" import { createRoot, getOwner, onCleanup } from "solid-js" import { createTabMemory } from "./tab-memory" -import { pushClosedTab, takeClosedTab, type ClosedTab } from "./closed-tabs" +import { + nextTabAfterClose, + pushClosedTab, + removeClosedTabs, + takeClosedTab, + type ClosedTab, +} from "./closed-tabs" import type { SessionTab, Tab } from "./tabs" import type { ServerConnection } from "./server" @@ -85,4 +91,21 @@ describe("closed tab stack", () => { expect(result.entry).toBeUndefined() expect(result.stack).toEqual([]) }) + + test("purges removed sessions", () => { + const stack = [ + { tab: sessionTab("a"), index: 0 }, + { tab: sessionTab("b"), index: 1 }, + ] + + expect(removeClosedTabs(stack, server, ["a"])).toEqual([{ tab: sessionTab("b"), index: 1 }]) + }) + + test("does not navigate when a background tab closes", () => { + const tabs = [sessionTab("a"), sessionTab("b"), sessionTab("c")] + + expect(nextTabAfterClose(tabs, 1, false)).toBeUndefined() + expect(nextTabAfterClose(tabs, 1, true)).toEqual(sessionTab("c")) + expect(nextTabAfterClose([sessionTab("a")], 0, true)).toBeNull() + }) }) diff --git a/packages/app/src/context/tabs.tsx b/packages/app/src/context/tabs.tsx index 36ef7bf54e18..11155d2ca479 100644 --- a/packages/app/src/context/tabs.tsx +++ b/packages/app/src/context/tabs.tsx @@ -10,7 +10,7 @@ import { uuid } from "@/utils/uuid" import { SessionTabsRemovedDetail } from "@/components/titlebar-session-events" import { sessionHref } from "@/utils/session-route" import { createTabMemory } from "./tab-memory" -import { pushClosedTab, takeClosedTab, type ClosedTab } from "./closed-tabs" +import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed-tabs" export type SessionTab = { type: "session" @@ -64,7 +64,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ createStore([]), ) const [recent, setRecent, , recentReady] = persisted(Persist.window("tabs.recent"), createStore({})) - const [closed, setClosed, , closedReady] = persisted(Persist.global("tabs.closed"), createStore([])) + const [closed, setClosed, , closedReady] = persisted(Persist.window("tabs.closed"), createStore([])) const params = useParams() const navigate = useNavigate() @@ -89,6 +89,15 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ }) } + const updateClosed = (update: (stack: ClosedTab[]) => ClosedTab[]) => { + const apply = () => setClosed((stack) => update(stack)) + if (closedReady()) { + apply() + return + } + void closedReady.promise?.then(apply) + } + const removeDraftPersisted = (draftID: string) => { for (const key of draftPersistedKeys()) removePersisted(Persist.draft(draftID, key), platform) } @@ -126,7 +135,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ if (!tab) return const key = tabKey(tab) const draftID = tab.type === "draft" ? tab.draftID : undefined - const nextTab = store[index + 1] ?? store[index - 1] + const nextTab = nextTabAfterClose(store, index, recentKey() === key && location.pathname !== "/") closing.add(key) void startTransition(() => { setStore( @@ -134,9 +143,11 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ tabs.splice(index, 1) }), ) - if (recent.key === key) setRecentKey(nextTab && tabKey(nextTab)) + if (nextTab === null) { + setRecentKey(undefined) + navigate("/") + } if (nextTab) navigateTab(nextTab) - else navigate("/") }).finally(() => closing.delete(key)) memory.remove(key) if (draftID) removeDraftPersisted(draftID) @@ -216,10 +227,14 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ closeTab(index: number) { const tab = store[index] if (!tab) return - if (tab.type === "session") setClosed((stack) => pushClosedTab(stack, tab, index)) + if (tab.type === "session") updateClosed((stack) => pushClosedTab(stack, tab, index)) removeTab(index) }, reopenClosedTab() { + if (!closedReady()) { + void closedReady.promise?.then(() => actions.reopenClosedTab()) + return + } const result = takeClosedTab(closed, store) if (result.stack.length === closed.length) return setClosed(() => result.stack) @@ -237,12 +252,14 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ }) }, removeSessionTab(input: Omit) { + updateClosed((stack) => removeClosedTabs(stack, input.server, [input.sessionId])) const index = store.findIndex( (tab) => tab.type === "session" && tab.server === input.server && tab.sessionId === input.sessionId, ) if (index !== -1) removeTab(index) }, removeServer(key: ServerConnection.Key) { + updateClosed((stack) => stack.filter((entry) => entry.tab.server !== key)) const drafts = store.flatMap((tab) => (tab.type === "draft" && tab.server === key ? [tab.draftID] : [])) const removed = store.filter((tab) => tab.server === key).map(tabKey) setStore((tabs) => tabs.filter((tab) => tab.server !== key)) @@ -253,6 +270,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ }, removeSessions: (input: SessionTabsRemovedDetail) => { const targetServer = input.server ?? server.key + updateClosed((stack) => removeClosedTabs(stack, targetServer, input.sessionIDs)) const removed = store .filter( (tab) => tab.type === "session" && tab.server === targetServer && input.sessionIDs.includes(tab.sessionId), diff --git a/packages/app/src/desktop-menu.test.ts b/packages/app/src/desktop-menu.test.ts index 6f10e808c884..c7e36a7cb73b 100644 --- a/packages/app/src/desktop-menu.test.ts +++ b/packages/app/src/desktop-menu.test.ts @@ -10,17 +10,4 @@ describe("desktop menu", () => { expect(items).toHaveLength(2) expect(items.every((item) => item.type === "item" && item.command === "logs.export" && !item.action)).toBe(true) }) - - test("reopens closed tabs through the command registry", () => { - const items = DESKTOP_MENU.flatMap((menu) => menu.items ?? []).filter( - (item) => item.type === "item" && item.command === "tab.reopenClosed", - ) - - expect(items).toHaveLength(1) - expect(items[0]).toMatchObject({ - label: "Reopen Closed Tab", - accelerator: { macos: "Shift+Cmd+T" }, - }) - expect(items[0]?.type === "item" && !items[0].action).toBe(true) - }) }) diff --git a/packages/app/src/desktop-menu.ts b/packages/app/src/desktop-menu.ts index 5cb0e8e9d5eb..d3902cd2b245 100644 --- a/packages/app/src/desktop-menu.ts +++ b/packages/app/src/desktop-menu.ts @@ -113,12 +113,6 @@ export const DESKTOP_MENU: DesktopMenu[] = [ action: "window.new", accelerator: { macos: "Cmd+Shift+N", windows: "Ctrl+Shift+N" }, }, - { - type: "item", - label: "Reopen Closed Tab", - command: "tab.reopenClosed", - accelerator: { macos: "Shift+Cmd+T" }, - }, { type: "separator" }, { type: "item", label: "Close Window", action: "window.close", role: "close" }, ], diff --git a/packages/app/src/pages/home-session-open.test.ts b/packages/app/src/pages/home-session-open.test.ts new file mode 100644 index 000000000000..a71d58ff0723 --- /dev/null +++ b/packages/app/src/pages/home-session-open.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, test } from "bun:test" +import { shouldOpenSessionInBackground } from "./home-session-open" + +describe("shouldOpenSessionInBackground", () => { + test("requires only the platform primary modifier", () => { + expect(shouldOpenSessionInBackground({ mac: true, meta: true, ctrl: false, shift: false, alt: false })).toBe(true) + expect(shouldOpenSessionInBackground({ mac: false, meta: false, ctrl: true, shift: false, alt: false })).toBe(true) + expect(shouldOpenSessionInBackground({ mac: true, meta: true, ctrl: false, shift: true, alt: false })).toBe(false) + expect(shouldOpenSessionInBackground({ mac: false, meta: false, ctrl: true, shift: false, alt: true })).toBe(false) + expect(shouldOpenSessionInBackground({ mac: false, meta: true, ctrl: false, shift: false, alt: false })).toBe(false) + }) +}) diff --git a/packages/app/src/pages/home-session-open.ts b/packages/app/src/pages/home-session-open.ts new file mode 100644 index 000000000000..8e32efb559d2 --- /dev/null +++ b/packages/app/src/pages/home-session-open.ts @@ -0,0 +1,11 @@ +export function shouldOpenSessionInBackground(input: { + mac: boolean + meta: boolean + ctrl: boolean + shift: boolean + alt: boolean +}) { + if (input.shift || input.alt) return false + if (input.mac) return input.meta && !input.ctrl + return input.ctrl && !input.meta +} diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index f3ed389f5cb9..f13a832a2d54 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -66,6 +66,7 @@ import { Persist, persisted } from "@/utils/persist" import { useMarked } from "@opencode-ai/ui/context/marked" import { preloadMarkdown } from "@opencode-ai/session-ui/markdown-cache" import { archiveHomeSession } from "./home-session-archive" +import { shouldOpenSessionInBackground } from "./home-session-open" import { showToast } from "@/utils/toast" const HOME_SESSION_LIMIT = 64 @@ -238,12 +239,16 @@ function useHomeSessionHeaderOpacity(groups: () => HomeSessionGroup[]) { return { setViewport, setContentRef, setHeaderRef, update, titleOpacity } } -const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) - // Cmd+click on macOS (Ctrl+click elsewhere) opens a session tab in the // background without navigating, matching browser conventions. function isBackgroundOpen(event: MouseEvent) { - return IS_MAC ? event.metaKey : event.ctrlKey + return shouldOpenSessionInBackground({ + mac: typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform), + meta: event.metaKey, + ctrl: event.ctrlKey, + shift: event.shiftKey, + alt: event.altKey, + }) } type OpenSessionOptions = { background?: boolean } @@ -479,11 +484,11 @@ export function NewHome() { const directory = project?.worktree ?? session.directory const ctx = global.ensureServerCtx(conn) ctx.projects.open(directory) - ctx.projects.touch(directory) if (options?.background) { tabs.addSessionTab({ server: ServerConnection.key(conn), sessionId: session.id }) return } + ctx.projects.touch(directory) startTransition(() => { const tab = tabs.addSessionTab({ server: ServerConnection.key(conn), sessionId: session.id }) tabs.select(tab) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 6bc9b7240358..690460d3af6e 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -1021,8 +1021,7 @@ export default function LegacyLayout(props: ParentProps) { id: "theme.cycle", title: language.t("command.theme.cycle"), category: language.t("command.category.theme"), - // mod+shift+t belongs to tab.reopenClosed (matches browser convention) - keybind: "mod+alt+shift+t", + keybind: "mod+shift+t", onSelect: () => cycleTheme(1), }, ]