hermes-agent/apps/desktop/src/store/session-switcher.test.ts
brooklyn! ab5f1a1f11
feat(desktop): Mac-style session switcher (^Tab / ^⇧Tab / ^1-9) (#43111)
Bind session.next/prev to Control+Tab / Control+Shift+Tab with a distinct
`ctrl` modifier token (literal Control on macOS — not Cmd, which the OS
reserves). Add ^1…^9 positional jumps mirroring profile ⌘1…⌘9.

Mac-style interaction:
- Quick ^Tab tap jumps on keydown with no HUD (even if Ctrl stays down)
- Hold Tab ~220ms, or tap Tab again while Ctrl is held → compact HUD
- Ctrl↑ commits the highlight; Esc cancels; rows clickable (^+click safe)
- Recency-ordered list snapshotted on open; cycles by stored session id

Includes combo.test.ts + session-switcher.test.ts.
2026-06-09 20:12:46 -05:00

115 lines
2.6 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { SessionInfo } from '@/types/hermes'
import { $selectedStoredSessionId, $sessions } from './session'
import {
$switcherIndex,
$switcherOpen,
$switcherSessions,
closeSwitcher,
commitOnCtrlUp,
onSwitcherTabDown,
onSwitcherTabUp,
openOrAdvanceSwitcher,
slotSessionId,
SWITCHER_REVEAL_MS
} from './session-switcher'
const session = (id: string): SessionInfo => ({ id }) as SessionInfo
const seed = (ids: string[], selected: null | string) => {
$sessions.set(ids.map(session))
$selectedStoredSessionId.set(selected)
}
const tabTap = (direction: 1 | -1 = 1) => {
onSwitcherTabDown()
const target = openOrAdvanceSwitcher(direction)
onSwitcherTabUp()
return target
}
beforeEach(() => {
vi.useRealTimers()
closeSwitcher()
$switcherSessions.set([])
$switcherIndex.set(0)
})
afterEach(() => {
seed([], null)
})
describe('openOrAdvanceSwitcher', () => {
it('does nothing with fewer than two sessions', () => {
seed(['a'], 'a')
onSwitcherTabDown()
expect(openOrAdvanceSwitcher(1)).toBeNull()
})
it('jumps immediately on a quick Tab tap without opening the HUD', () => {
seed(['a', 'b', 'c'], 'a')
expect(tabTap()).toBe('b')
expect($switcherOpen.get()).toBe(false)
expect(commitOnCtrlUp()).toBeNull()
})
it('does not open the HUD when Ctrl stays down but Tab was released quickly', () => {
vi.useFakeTimers()
seed(['a', 'b', 'c'], 'a')
tabTap()
vi.advanceTimersByTime(SWITCHER_REVEAL_MS)
expect($switcherOpen.get()).toBe(false)
})
it('opens the HUD when Tab stays held past the reveal delay', () => {
vi.useFakeTimers()
seed(['a', 'b', 'c'], 'a')
onSwitcherTabDown()
openOrAdvanceSwitcher(1)
vi.advanceTimersByTime(SWITCHER_REVEAL_MS)
expect($switcherOpen.get()).toBe(true)
onSwitcherTabUp()
})
it('opens on a second Tab while Ctrl is still down', () => {
seed(['a', 'b', 'c'], 'a')
expect(tabTap()).toBe('b')
onSwitcherTabDown()
openOrAdvanceSwitcher(1)
onSwitcherTabUp()
expect($switcherOpen.get()).toBe(true)
expect($switcherIndex.get()).toBe(2)
})
it('commits the HUD highlight on Ctrl up', () => {
seed(['a', 'b', 'c'], 'a')
expect(tabTap()).toBe('b')
onSwitcherTabDown()
openOrAdvanceSwitcher(1)
onSwitcherTabUp()
expect(commitOnCtrlUp()).toBe('c')
})
})
describe('slotSessionId', () => {
it('reads the armed snapshot while browsing is pending', () => {
seed(['a', 'b', 'c'], 'a')
tabTap()
$sessions.set([session('x')])
expect(slotSessionId(2)).toBe('b')
})
})