feat(desktop): archive the current session via hotkey and ⌥+⇧-click

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.
This commit is contained in:
Brian Sweatt
2026-08-14 23:06:40 -07:00
committed by Teknium
parent 34f484542e
commit 89f9375bc0
9 changed files with 175 additions and 25 deletions
@@ -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')
})
})
@@ -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'
}
@@ -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()
}}
>
{(() => {
+12
View File
@@ -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,
@@ -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<string, () => 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
+1
View File
@@ -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',
+1
View File
@@ -248,6 +248,7 @@ export const zh: Translations = {
'session.slot.9': '切换到最近会话 9',
'session.focusSearch': '搜索会话',
'session.togglePin': '固定/取消固定当前会话',
'session.archive': '归档当前会话',
'workspace.newWorktree': '新建工作树',
'workspace.openFolder': '打开文件夹为项目',
'composer.focus': '聚焦输入框',
@@ -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)
})
})
+5
View File
@@ -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