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:
@@ -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()
|
||||
}}
|
||||
>
|
||||
{(() => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user