diff --git a/packages/ui/src/components/layout/SessionTabsStrip.tsx b/packages/ui/src/components/layout/SessionTabsStrip.tsx index 100d4216..ff18ddd3 100644 --- a/packages/ui/src/components/layout/SessionTabsStrip.tsx +++ b/packages/ui/src/components/layout/SessionTabsStrip.tsx @@ -31,6 +31,7 @@ import { Icon } from '@/components/icon/Icon'; import { cn } from '@/lib/utils'; import { useI18n } from '@/lib/i18n'; import { useSessionTabsStore } from '@/stores/useSessionTabsStore'; +import { closeSessionTabAndActivateNeighbour } from '@/lib/sessionTabs'; import { useGlobalSessionsStore, resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { useGlobalSessionStatus } from '@/sync/sync-context'; @@ -391,13 +392,11 @@ export const SessionTabsStrip: React.FC<{ const { t } = useI18n(); const tabIds = useSessionTabsStore((state) => state.tabIds); const ensureTab = useSessionTabsStore((state) => state.ensureTab); - const closeTab = useSessionTabsStore((state) => state.closeTab); const closeOtherTabs = useSessionTabsStore((state) => state.closeOtherTabs); const reorderTabs = useSessionTabsStore((state) => state.reorderTabs); const currentSessionId = useSessionUIStore((state) => state.currentSessionId); const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession); - const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft); const activeSessions = useGlobalSessionsStore((state) => state.activeSessions); // Opening a session anywhere (sidebar, palette, deep link) adds its tab. @@ -425,20 +424,9 @@ export const SessionTabsStrip: React.FC<{ setCurrentSession(tab.id, resolveGlobalSessionDirectory(tab.session)); }, [setCurrentSession]); - const activateNeighbour = React.useCallback((closedId: string) => { - const index = tabs.findIndex((tab) => tab.id === closedId); - const neighbour = tabs[index + 1] ?? tabs[index - 1] ?? null; - if (neighbour) { - handleSelect(neighbour); - } else { - openNewSessionDraft(); - } - }, [tabs, handleSelect, openNewSessionDraft]); - const handleClose = React.useCallback((id: string) => { - if (id === currentSessionId) activateNeighbour(id); - closeTab(id); - }, [activateNeighbour, closeTab, currentSessionId]); + closeSessionTabAndActivateNeighbour(id); + }, []); const handleCloseOthers = React.useCallback((id: string) => { closeOtherTabs(id); diff --git a/packages/ui/src/hooks/useKeyboardShortcuts.ts b/packages/ui/src/hooks/useKeyboardShortcuts.ts index 2d2c4439..60db12cb 100644 --- a/packages/ui/src/hooks/useKeyboardShortcuts.ts +++ b/packages/ui/src/hooks/useKeyboardShortcuts.ts @@ -1,6 +1,7 @@ import React from 'react'; import { isTerminalEventTarget } from '@/lib/terminalFocus'; import { useSessionUIStore } from '@/sync/session-ui-store'; +import { closeSessionTabAndActivateNeighbour } from '@/lib/sessionTabs'; import { useSelectionStore } from '@/sync/selection-store'; import * as sessionActions from '@/sync/session-actions'; import { normalizeContextPanelDirectoryKey, useUIStore } from '@/stores/useUIStore'; @@ -299,6 +300,14 @@ export const useKeyboardShortcuts = () => { return; } + if (!isVSCodeRuntime() && eventMatchesShortcut(e, combo('close_session_tab'))) { + e.preventDefault(); + if (currentSessionId) { + closeSessionTabAndActivateNeighbour(currentSessionId); + } + return; + } + const matchedNewSessionShortcut = eventMatchesShortcut(e, combo('new_chat')); const matchedWorktreeShortcut = eventMatchesShortcut(e, combo('new_chat_worktree')); diff --git a/packages/ui/src/lib/sessionTabs.ts b/packages/ui/src/lib/sessionTabs.ts new file mode 100644 index 00000000..b3ce2bb6 --- /dev/null +++ b/packages/ui/src/lib/sessionTabs.ts @@ -0,0 +1,33 @@ +import { useSessionTabsStore } from '@/stores/useSessionTabsStore'; +import { useGlobalSessionsStore, resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; + +/** + * Close one header session tab. Closing the active tab activates its right + * neighbour (falling back left), or opens a new-session draft when it was the + * last tab. Only tabs whose session is present in the loaded session list + * count as neighbours — the same rule the strip uses for rendering. The + * session itself is never touched. + */ +export const closeSessionTabAndActivateNeighbour = (sessionId: string): void => { + const { tabIds, closeTab } = useSessionTabsStore.getState(); + if (!tabIds.includes(sessionId)) return; + + const { currentSessionId, setCurrentSession, openNewSessionDraft } = useSessionUIStore.getState(); + if (sessionId === currentSessionId) { + const sessionsById = new Map( + useGlobalSessionsStore.getState().activeSessions.map((session) => [session.id, session] as const), + ); + const renderable = tabIds.filter((id) => sessionsById.has(id)); + const index = renderable.indexOf(sessionId); + const neighbourId = renderable[index + 1] ?? renderable[index - 1] ?? null; + const neighbour = neighbourId ? sessionsById.get(neighbourId) : null; + if (neighbour) { + setCurrentSession(neighbour.id, resolveGlobalSessionDirectory(neighbour)); + } else { + openNewSessionDraft(); + } + } + + closeTab(sessionId); +}; diff --git a/packages/ui/src/lib/shortcuts.ts b/packages/ui/src/lib/shortcuts.ts index 4398aca5..aab2f9f1 100644 --- a/packages/ui/src/lib/shortcuts.ts +++ b/packages/ui/src/lib/shortcuts.ts @@ -239,6 +239,13 @@ const SHORTCUT_ACTIONS: ReadonlyArray = [ description: 'Create a new worktree and open a draft in it', customizable: true, }, + { + id: 'close_session_tab', + defaultCombo: 'alt+w', + label: 'Close session tab', + description: 'Close the active session tab in the header (the session itself stays)', + customizable: true, + }, { id: 'new_mini_chat', defaultCombo: 'mod+alt+n',