From 89f9375bc0dd5dfe8d549a4ffe101f2344892c55 Mon Sep 17 00:00:00 2001 From: Brian Sweatt Date: Fri, 14 Aug 2026 23:06:40 -0700 Subject: [PATCH] =?UTF-8?q?feat(desktop):=20archive=20the=20current=20sess?= =?UTF-8?q?ion=20via=20hotkey=20and=20=E2=8C=A5+=E2=87=A7-click?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a rebindable 'session.archive' keybind action (shipped unbound, like session.togglePin) plus an ⌥+⇧-click gesture on sidebar session rows, extracted into a pure, unit-tested click resolver so modifier precedence (⌥⇧ archive vs ⇧ pin vs ⌘/⌃⇧ new window) stays correct. Salvaged from #59759. Closes #59308. --- .../chat/sidebar/session-row-gesture.test.ts | 46 +++++++++++++++++ .../app/chat/sidebar/session-row-gesture.ts | 50 +++++++++++++++++++ .../src/app/chat/sidebar/session-row.tsx | 49 +++++++++--------- apps/desktop/src/app/contrib/wiring.tsx | 12 +++++ apps/desktop/src/app/hooks/use-keybinds.ts | 3 ++ apps/desktop/src/i18n/en.ts | 1 + apps/desktop/src/i18n/zh.ts | 1 + apps/desktop/src/lib/keybinds/actions.test.ts | 33 ++++++++++++ apps/desktop/src/lib/keybinds/actions.ts | 5 ++ 9 files changed, 175 insertions(+), 25 deletions(-) create mode 100644 apps/desktop/src/app/chat/sidebar/session-row-gesture.test.ts create mode 100644 apps/desktop/src/app/chat/sidebar/session-row-gesture.ts create mode 100644 apps/desktop/src/lib/keybinds/actions.test.ts diff --git a/apps/desktop/src/app/chat/sidebar/session-row-gesture.test.ts b/apps/desktop/src/app/chat/sidebar/session-row-gesture.test.ts new file mode 100644 index 0000000000..1758bad042 --- /dev/null +++ b/apps/desktop/src/app/chat/sidebar/session-row-gesture.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' + +import { resolveSessionRowClick } from './session-row-gesture' + +const NO_MODS = { altKey: false, ctrlKey: false, metaKey: false, shiftKey: false } +const WINDOW_OK = { canOpenWindow: true } +const NO_WINDOW = { canOpenWindow: false } + +describe('resolveSessionRowClick', () => { + it('resumes on a plain click', () => { + expect(resolveSessionRowClick(NO_MODS, WINDOW_OK)).toBe('resume') + }) + + it('pins on ⇧-click', () => { + expect(resolveSessionRowClick({ ...NO_MODS, shiftKey: true }, WINDOW_OK)).toBe('pin') + }) + + it('opens a new tab on ⌘/⌃-click', () => { + expect(resolveSessionRowClick({ ...NO_MODS, metaKey: true }, WINDOW_OK)).toBe('newTab') + expect(resolveSessionRowClick({ ...NO_MODS, ctrlKey: true }, WINDOW_OK)).toBe('newTab') + expect(resolveSessionRowClick({ ...NO_MODS, ctrlKey: true }, NO_WINDOW)).toBe('newTab') + }) + + it('opens a new window on ⌘/⌃+⇧-click when supported', () => { + expect(resolveSessionRowClick({ ...NO_MODS, metaKey: true, shiftKey: true }, WINDOW_OK)).toBe('newWindow') + expect(resolveSessionRowClick({ ...NO_MODS, ctrlKey: true, shiftKey: true }, WINDOW_OK)).toBe('newWindow') + }) + + it('falls back to a new tab for ⌘/⌃+⇧-click when windows are unavailable (web embed)', () => { + expect(resolveSessionRowClick({ ...NO_MODS, metaKey: true, shiftKey: true }, NO_WINDOW)).toBe('newTab') + }) + + // The regression this whole module guards: ⌥+⇧ sets shiftKey too, so a naive + // "check shiftKey first" would swallow archive into "pin". + it('archives on ⌥+⇧-click', () => { + expect(resolveSessionRowClick({ ...NO_MODS, altKey: true, shiftKey: true }, WINDOW_OK)).toBe('archive') + }) + + it('archives regardless of window support (archive needs no standalone window)', () => { + expect(resolveSessionRowClick({ ...NO_MODS, altKey: true, shiftKey: true }, NO_WINDOW)).toBe('archive') + }) + + it('does not archive on ⌥-click alone', () => { + expect(resolveSessionRowClick({ ...NO_MODS, altKey: true }, WINDOW_OK)).toBe('resume') + }) +}) diff --git a/apps/desktop/src/app/chat/sidebar/session-row-gesture.ts b/apps/desktop/src/app/chat/sidebar/session-row-gesture.ts new file mode 100644 index 0000000000..5b2f668c72 --- /dev/null +++ b/apps/desktop/src/app/chat/sidebar/session-row-gesture.ts @@ -0,0 +1,50 @@ +// Which action a left-click on a sidebar session row triggers, given the +// modifier keys held. Kept as a pure resolver (separate from the row +// component) so the precedence — the part that's easy to get subtly wrong — +// is unit-testable without rendering the whole sidebar. + +export type SessionRowClickAction = 'archive' | 'newTab' | 'newWindow' | 'pin' | 'resume' + +export interface SessionRowClickModifiers { + altKey: boolean + ctrlKey: boolean + metaKey: boolean + shiftKey: boolean +} + +/** + * Resolve the click action from its modifiers. + * + * Precedence matters: the multi-modifier gestures (⌥+⇧ archive, ⌘/⌃+⇧ new + * window) MUST be checked before the single-modifier pin (⇧) and new-tab + * (⌘/⌃) gestures, because they set those flags too — testing `shiftKey` + * first would swallow both into "pin". + * + * Archive is independent of window support (it works in the web embed too); + * only the new-window gesture needs standalone windows, and without them + * ⌘/⌃+⇧ falls through to the plain ⌘/⌃ new-tab behaviour. + */ +export function resolveSessionRowClick( + { altKey, ctrlKey, metaKey, shiftKey }: SessionRowClickModifiers, + opts: { canOpenWindow: boolean } +): SessionRowClickAction { + const primaryModifier = metaKey || ctrlKey + + if (altKey && shiftKey) { + return 'archive' + } + + if (primaryModifier && shiftKey && opts.canOpenWindow) { + return 'newWindow' + } + + if (primaryModifier) { + return 'newTab' + } + + if (shiftKey) { + return 'pin' + } + + return 'resume' +} diff --git a/apps/desktop/src/app/chat/sidebar/session-row.tsx b/apps/desktop/src/app/chat/sidebar/session-row.tsx index bb96ff9c6b..835c7620eb 100644 --- a/apps/desktop/src/app/chat/sidebar/session-row.tsx +++ b/apps/desktop/src/app/chat/sidebar/session-row.tsx @@ -45,6 +45,7 @@ import { } from './chrome' import { SessionActionsMenu, SessionContextMenu } from './session-actions-menu' import { sessionRowDetails } from './session-row-details' +import { resolveSessionRowClick } from './session-row-gesture' import { useProfilePrewarm } from './use-profile-prewarm' interface SidebarSessionRowProps extends React.ComponentProps<'div'> { @@ -383,39 +384,37 @@ function SidebarSessionRowImpl({ openSession(session.id, () => undefined, 'tab') })} onClick={event => { - const mod = event.metaKey || event.ctrlKey + // Modifier-click gestures on a row (see `resolveSessionRowClick`): + // ⇧ → pin / unpin + // ⌘/⌃ → open in a new tab (stack into main) + // ⌘/⌃ + ⇧ → pop into its own window (needs standalone windows) + // ⌥ + ⇧ → archive + // A plain click resumes. Archive also lives in the row's ⋯ and + // right-click menus and as a rebindable hotkey (`session.archive`). + // `openSession`'s 'window' intent already falls back to 'tab' when + // the bridge lacks standalone windows, so the resolver can always + // offer the window action here. + const action = resolveSessionRowClick(event, { canOpenWindow: true }) - // ⇧⌘-click → pop into its own window (needs standalone windows). - if (mod && event.shiftKey) { - event.preventDefault() - event.stopPropagation() - triggerHaptic('selection') - openSession(session.id, () => undefined, 'window') + if (action === 'resume') { + onResume() return } - // ⌘/⌃-click → open in a new tab (stack into main). - if (mod) { - event.preventDefault() - event.stopPropagation() - triggerHaptic('selection') - openSession(session.id, () => undefined, 'tab') + event.preventDefault() + event.stopPropagation() + triggerHaptic('selection') - return - } - - // ⇧-click → pin. - if (event.shiftKey) { - event.preventDefault() - event.stopPropagation() - triggerHaptic('selection') + if (action === 'archive') { + onArchive() + } else if (action === 'pin') { onPin() - - return + } else if (action === 'newTab') { + openSession(session.id, () => undefined, 'tab') + } else { + openSession(session.id, () => undefined, 'window') } - - onResume() }} > {(() => { diff --git a/apps/desktop/src/app/contrib/wiring.tsx b/apps/desktop/src/app/contrib/wiring.tsx index ea819a0960..ab5ecaafa4 100644 --- a/apps/desktop/src/app/contrib/wiring.tsx +++ b/apps/desktop/src/app/contrib/wiring.tsx @@ -825,9 +825,21 @@ export function ContribWiring({ children }: { children: ReactNode }) { void openNewSessionTile('center', { listed: false }) }, [openNewSessionTile]) + // Archive the selected session (rebindable `session.archive` hotkey). + const archiveSelectedSession = useCallback(() => { + const sessionId = $selectedStoredSessionId.get() + + if (!sessionId) { + return + } + + void archiveSession(sessionId) + }, [archiveSession]) + // Single global listener for every rebindable hotkey plus the on-screen // keybind editor's capture mode (same as DesktopController). useKeybinds({ + archiveSelectedSession, openNewSessionTab, startFreshSession: startFreshSessionDraft, toggleCommandCenter, diff --git a/apps/desktop/src/app/hooks/use-keybinds.ts b/apps/desktop/src/app/hooks/use-keybinds.ts index 24982264e3..6d8f5a430e 100644 --- a/apps/desktop/src/app/hooks/use-keybinds.ts +++ b/apps/desktop/src/app/hooks/use-keybinds.ts @@ -87,6 +87,8 @@ export interface KeybindRuntimeDeps { openNewSessionTab: () => void /** Pin/unpin the active session. */ toggleSelectedPin: () => void + /** Archive the active session. */ + archiveSelectedSession: () => void } type HandlerMap = Record void> @@ -211,6 +213,7 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void { ...sessionSlotHandlers, 'session.focusSearch': requestSessionSearchFocus, 'session.togglePin': deps.toggleSelectedPin, + 'session.archive': deps.archiveSelectedSession, // openWorktreeDialog resolves the target. There is no test for a repo // here, so the key works from a detached session that sits inside a // project, and not only from a session with a repo. When no repo is in diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 63c49820d0..c56875e6da 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -253,6 +253,7 @@ export const en: Translations = { 'session.slot.9': 'Switch to recent session 9', 'session.focusSearch': 'Search sessions', 'session.togglePin': 'Pin / unpin current session', + 'session.archive': 'Archive current session', 'workspace.newWorktree': 'New worktree', 'workspace.openFolder': 'Open folder as project', 'composer.focus': 'Focus composer', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 9b8955c123..4f0a302eb5 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -248,6 +248,7 @@ export const zh: Translations = { 'session.slot.9': '切换到最近会话 9', 'session.focusSearch': '搜索会话', 'session.togglePin': '固定/取消固定当前会话', + 'session.archive': '归档当前会话', 'workspace.newWorktree': '新建工作树', 'workspace.openFolder': '打开文件夹为项目', 'composer.focus': '聚焦输入框', diff --git a/apps/desktop/src/lib/keybinds/actions.test.ts b/apps/desktop/src/lib/keybinds/actions.test.ts new file mode 100644 index 0000000000..dafc92a5ce --- /dev/null +++ b/apps/desktop/src/lib/keybinds/actions.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' + +import { en } from '@/i18n/en' + +import { defaultBindings, KEYBIND_ACTIONS, keybindAction } from './actions' + +describe('session.archive keybind action', () => { + it('is registered under the session category', () => { + const action = keybindAction('session.archive') + + expect(action).toBeDefined() + expect(action?.category).toBe('session') + }) + + it('ships unbound so it does not claim a chord for every user', () => { + const action = keybindAction('session.archive') + + expect(action?.defaults).toEqual([]) + // A missing entry would silently drop from the panel; an accidental + // default binding would change behaviour for everyone. Guard both. + expect(defaultBindings()['session.archive']).toEqual([]) + }) + + it('has an English label so it renders in the shortcuts panel', () => { + expect(en.keybinds.actions['session.archive']).toBe('Archive current session') + }) + + it('appears exactly once in KEYBIND_ACTIONS', () => { + const matches = KEYBIND_ACTIONS.filter(action => action.id === 'session.archive') + + expect(matches).toHaveLength(1) + }) +}) diff --git a/apps/desktop/src/lib/keybinds/actions.ts b/apps/desktop/src/lib/keybinds/actions.ts index 9751818ec5..b84824362a 100644 --- a/apps/desktop/src/lib/keybinds/actions.ts +++ b/apps/desktop/src/lib/keybinds/actions.ts @@ -90,6 +90,11 @@ export const KEYBIND_ACTIONS: readonly KeybindActionMeta[] = [ ...SESSION_SLOT_ACTIONS, { id: 'session.focusSearch', category: 'session', defaults: ['mod+shift+f'] }, { id: 'session.togglePin', category: 'session', defaults: [] }, + // Archive the active session. Ships unbound (like `session.togglePin`) so an + // irreversible-feeling, mouse-only action doesn't silently claim a chord for + // every user — surfaced in the panel for opt-in binding (the issue suggests + // ⌘⇧⌫ / Ctrl+Shift+⌫). + { id: 'session.archive', category: 'session', defaults: [] }, // ⌘⇧B — "b" for branch: spin up a new git worktree from the active repo. { id: 'workspace.newWorktree', category: 'session', defaults: ['mod+shift+b'] }, // ⌘O — the editor-standard "open folder" chord (VS Code ⌘O, Zed's