From 18102a3ce81b696b6a605e9248ebdea09701ed8e Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:06:18 -0400 Subject: [PATCH 1/5] feat(sessions): switch between sessions and projects in the session picker Add a Sessions | Projects tab switcher to the session picker header, a Projects tab listing navigable projects with search by name or branch and keyboard navigation, and a configurable switchPickerTab direct shortcut (default Ctrl+S) shown in the footer hint and Settings. --- .../session/ProjectPickerContent.tsx | 224 ++++++++++++++++++ .../session/SessionPickerDialog.test.tsx | 151 +++++++++++- .../session/SessionPickerDialog.tsx | 94 ++++++-- .../components/settings/KeyboardShortcuts.tsx | 2 +- frontend/src/pages/SessionDetail.tsx | 6 +- shared/src/schemas/settings.ts | 3 +- 6 files changed, 459 insertions(+), 21 deletions(-) create mode 100644 frontend/src/components/session/ProjectPickerContent.tsx diff --git a/frontend/src/components/session/ProjectPickerContent.tsx b/frontend/src/components/session/ProjectPickerContent.tsx new file mode 100644 index 000000000..4a2fa48ba --- /dev/null +++ b/frontend/src/components/session/ProjectPickerContent.tsx @@ -0,0 +1,224 @@ +import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react' +import { Search } from 'lucide-react' +import type { Repo } from '@/api/types' +import { Input } from '@/components/ui/input' +import { useNavigableRepos } from '@/hooks/useSidebarRepoGroups' +import { cn, getRepoBranchLabel, getRepoDisplayName } from '@/lib/utils' + +const PAGE_STEP = 10 + +interface ProjectPickerContentProps { + open: boolean + currentRepo: Repo | undefined + onSelectRepo: (repo: Repo) => void + switchHint: string + searchInputRef: React.RefObject +} + +function optionId(baseId: string, index: number): string { + return `${baseId}-option-${index}` +} + +export function ProjectPickerContent({ + open, + currentRepo, + onSelectRepo, + switchHint, + searchInputRef, +}: ProjectPickerContentProps) { + const { repos, isLoading } = useNavigableRepos() + const rowRefs = useRef(new Map()) + const listId = useId() + const optionBaseId = useId() + + const [query, setQuery] = useState('') + const [cursorIndex, setCursorIndex] = useState(null) + + const trimmedQuery = query.trim() + + const filteredRepos = useMemo(() => { + const needle = trimmedQuery.toLowerCase() + if (!needle) return repos + return repos.filter((repo) => { + const name = getRepoDisplayName(repo).toLowerCase() + const branch = (getRepoBranchLabel(repo) ?? '').toLowerCase() + return name.includes(needle) || branch.includes(needle) + }) + }, [repos, trimmedQuery]) + + const defaultCursorIndex = useMemo(() => { + if (!trimmedQuery && currentRepo) { + const index = filteredRepos.findIndex((repo) => repo.id === currentRepo.id) + if (index >= 0) return index + } + return filteredRepos.length > 0 ? 0 : -1 + }, [trimmedQuery, currentRepo, filteredRepos]) + + const effectiveCursorIndex = cursorIndex ?? defaultCursorIndex + const cursorRepo = effectiveCursorIndex >= 0 ? filteredRepos[effectiveCursorIndex] : undefined + + useEffect(() => { + setCursorIndex(null) + }, [trimmedQuery]) + + const setRowRef = useCallback((key: number, node: HTMLDivElement | null) => { + if (node) rowRefs.current.set(key, node) + else rowRefs.current.delete(key) + }, []) + + useEffect(() => { + if (!cursorRepo) return + const node = rowRefs.current.get(cursorRepo.id) + if (node && typeof node.scrollIntoView === 'function') { + node.scrollIntoView({ block: 'nearest' }) + } + }, [cursorRepo]) + + const moveCursor = useCallback( + (index: number) => { + if (!filteredRepos[index]) return + setCursorIndex(index) + }, + [filteredRepos], + ) + + const handleSearchKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (event.nativeEvent.isComposing) return + if (event.metaKey || event.altKey) return + + const key = event.key + const ctrlKey = event.key.toLowerCase() + + const moveBy = (delta: number, wrap: boolean) => { + if (filteredRepos.length === 0) return + const base = effectiveCursorIndex < 0 ? (delta > 0 ? -1 : 0) : effectiveCursorIndex + const raw = base + delta + const next = wrap + ? ((raw % filteredRepos.length) + filteredRepos.length) % filteredRepos.length + : Math.max(0, Math.min(filteredRepos.length - 1, raw)) + moveCursor(next) + } + + if (key === 'ArrowDown' || (event.ctrlKey && ctrlKey === 'n')) { + event.preventDefault() + moveBy(1, true) + return + } + if (key === 'ArrowUp' || (event.ctrlKey && ctrlKey === 'p')) { + event.preventDefault() + moveBy(-1, true) + return + } + if (key === 'PageDown') { + event.preventDefault() + moveBy(PAGE_STEP, false) + return + } + if (key === 'PageUp') { + event.preventDefault() + moveBy(-PAGE_STEP, false) + return + } + if (key === 'Enter') { + if (!cursorRepo) return + event.preventDefault() + onSelectRepo(cursorRepo) + } + }, + [filteredRepos, effectiveCursorIndex, moveCursor, cursorRepo, onSelectRepo], + ) + + return ( +
+
+
+ + = 0 ? optionId(optionBaseId, effectiveCursorIndex) : undefined} + value={query} + onChange={(event) => setQuery(event.target.value)} + onKeyDown={handleSearchKeyDown} + placeholder="Search projects..." + className="h-9 pl-9" + autoComplete="off" + name="project-picker-search" + /> +
+
+ +
+ {filteredRepos.length === 0 ? ( +
+ {isLoading ? 'Loading projects...' : 'No projects found'} +
+ ) : ( + filteredRepos.map((repo, index) => { + const isCursor = index === effectiveCursorIndex + const isCurrent = repo.id === currentRepo?.id + const branch = getRepoBranchLabel(repo) + return ( +
setRowRef(repo.id, node)} + role="presentation" + onPointerMove={(event) => { + if (event.pointerType === 'mouse') moveCursor(index) + }} + className={cn( + 'group flex items-center rounded-md text-sm min-h-8 pointer-coarse:min-h-11', + isCursor ? 'bg-primary text-primary-foreground' : 'hover:bg-accent', + )} + > +
onSelectRepo(repo)} + className="flex min-w-0 flex-1 cursor-pointer items-center gap-2 px-2" + > + + {isCurrent && } + + + {getRepoDisplayName(repo)} + + {branch && ( + + {branch} + + )} +
+
+ ) + }) + )} +
+ +
+ ↑↓ move · Enter open + {switchHint ? ` · ${switchHint} sessions` : ''} +
+
+ ) +} diff --git a/frontend/src/components/session/SessionPickerDialog.test.tsx b/frontend/src/components/session/SessionPickerDialog.test.tsx index bfde94af8..6a8fdc3af 100644 --- a/frontend/src/components/session/SessionPickerDialog.test.tsx +++ b/frontend/src/components/session/SessionPickerDialog.test.tsx @@ -5,6 +5,7 @@ import { render, screen, fireEvent, waitFor, act } from '@testing-library/react' import userEvent from '@testing-library/user-event' import type { Repo, Session } from '@/api/types' import { stubMatchMedia } from '@/test/test-utils' +import { KeyboardShortcutsProvider } from '@/contexts/KeyboardShortcutsContext' import { SessionPickerDialog } from './SessionPickerDialog' const mocks = vi.hoisted(() => ({ @@ -22,6 +23,13 @@ const mocks = vi.hoisted(() => ({ getOpenCodeLocation: vi.fn(), sessionPins: [] as Array<{ sessionId: string; directory: string; pinnedAt: number }>, repos: [] as Repo[], + preferences: undefined as + | { leaderKey?: string; keyboardShortcuts?: Record; directShortcuts?: string[] } + | undefined, +})) + +vi.mock('@/hooks/useSettings', () => ({ + useSettings: () => ({ preferences: mocks.preferences }), })) vi.mock('@/api/opencode', async (importOriginal) => { @@ -104,13 +112,18 @@ function renderWithClient(ui: React.ReactElement) { if (mocks.locationData && directory) { queryClient.setQueryData(['opencode', 'location', directory], mocks.locationData) } - return render({ui}) + return render( + + {ui} + , + ) } function renderPicker(overrides: Partial> = {}) { const onOpenChange = vi.fn() const onSelectSession = vi.fn() const onDeleteActiveSession = vi.fn() + const onSelectRepo = vi.fn() renderWithClient( , ) - return { onOpenChange, onSelectSession, onDeleteActiveSession } + return { onOpenChange, onSelectSession, onDeleteActiveSession, onSelectRepo } } const combobox = () => screen.getByRole('combobox') +const openProjectsTab = () => { + fireEvent.mouseDown(screen.getByRole('tab', { name: 'Projects' })) +} + describe('SessionPickerDialog', () => { beforeEach(() => { vi.clearAllMocks() @@ -141,6 +159,7 @@ describe('SessionPickerDialog', () => { mocks.sessionPins = [] mocks.repos = [repo(1, 'alpha', '/w/a'), repo(2, 'beta', '/w/b')] mocks.sessionsData = [] + mocks.preferences = undefined }) afterEach(() => { @@ -336,6 +355,7 @@ describe('SessionPickerDialog', () => { currentRepo={mocks.repos[0]} onSelectSession={vi.fn()} onDeleteActiveSession={vi.fn()} + onSelectRepo={vi.fn()} /> ) @@ -454,6 +474,7 @@ describe('SessionPickerDialog', () => { activeSessionID="ses_active" onSelectSession={vi.fn()} onDeleteActiveSession={vi.fn()} + onSelectRepo={vi.fn()} /> ) @@ -470,4 +491,130 @@ describe('SessionPickerDialog', () => { await waitFor(() => expect(screen.getByRole('combobox')).toHaveValue('')) expect(screen.getByRole('option', { name: /Active session/ })).toHaveAttribute('aria-selected', 'true') }) + + it('renders a Sessions/Projects tab switcher and defaults to the sessions tab', () => { + renderPicker() + + expect(screen.getByRole('tab', { name: 'Sessions' })).toHaveAttribute('aria-selected', 'true') + expect(screen.getByRole('tab', { name: 'Projects' })).toHaveAttribute('aria-selected', 'false') + expect(combobox()).toHaveAttribute('placeholder', 'Search sessions...') + }) + + it('lists repos in the projects tab and opens the cursor repo on Enter', async () => { + mocks.repos = [repo(1, 'alpha', '/w/a'), repo(2, 'beta', '/w/b')] + const { onSelectRepo } = renderPicker() + + openProjectsTab() + + const alpha = await screen.findByRole('option', { name: /alpha/ }) + expect(alpha).toHaveAttribute('aria-selected', 'true') + expect(screen.getByRole('option', { name: /beta/ })).toBeInTheDocument() + + fireEvent.keyDown(combobox(), { key: 'Enter' }) + + expect(onSelectRepo).toHaveBeenCalledWith(mocks.repos[0]) + }) + + it('opens a repo on click in the projects tab', async () => { + mocks.repos = [repo(1, 'alpha', '/w/a'), repo(2, 'beta', '/w/b')] + const { onSelectRepo } = renderPicker() + + openProjectsTab() + + fireEvent.click(await screen.findByRole('option', { name: /beta/ })) + + expect(onSelectRepo).toHaveBeenCalledWith(mocks.repos[1]) + }) + + it('filters projects by name and branch', async () => { + mocks.repos = [ + { ...repo(1, 'alpha', '/w/a'), currentBranch: 'feature/x' }, + repo(2, 'beta', '/w/b'), + ] + renderPicker() + + openProjectsTab() + await screen.findByRole('option', { name: /alpha/ }) + + fireEvent.change(combobox(), { target: { value: 'beta' } }) + expect(screen.queryByRole('option', { name: /alpha/ })).not.toBeInTheDocument() + expect(screen.getByRole('option', { name: /beta/ })).toBeInTheDocument() + + fireEvent.change(combobox(), { target: { value: 'feature/x' } }) + expect(screen.getByRole('option', { name: /alpha/ })).toBeInTheDocument() + expect(screen.queryByRole('option', { name: /beta/ })).not.toBeInTheDocument() + }) + + it('shows the empty projects state when nothing matches', async () => { + renderPicker() + + openProjectsTab() + await screen.findByRole('option', { name: /alpha/ }) + + fireEvent.change(combobox(), { target: { value: 'zzz' } }) + + expect(screen.getByText('No projects found')).toBeInTheDocument() + }) + + it('toggles between tabs with the default Ctrl+S binding in either search input', async () => { + renderPicker() + + fireEvent.keyDown(combobox(), { key: 's', ctrlKey: true }) + expect(await screen.findByRole('tab', { name: 'Projects', selected: true })).toBeInTheDocument() + + fireEvent.keyDown(combobox(), { key: 's', ctrlKey: true }) + expect(screen.getByRole('tab', { name: 'Sessions', selected: true })).toBeInTheDocument() + }) + + it('honors a configured switch binding and shows it in the footer', async () => { + mocks.preferences = { keyboardShortcuts: { switchPickerTab: 'Ctrl+G' } } + renderPicker() + + expect(screen.getByText(/Ctrl\+G projects/)).toBeInTheDocument() + + fireEvent.keyDown(combobox(), { key: 'g', ctrlKey: true }) + expect(await screen.findByRole('tab', { name: 'Projects', selected: true })).toBeInTheDocument() + + expect(screen.getByText(/Ctrl\+G sessions/)).toBeInTheDocument() + }) + + it('no longer switches tabs on Ctrl+O', () => { + renderPicker() + + fireEvent.keyDown(combobox(), { key: 'o', ctrlKey: true }) + + expect(screen.getByRole('tab', { name: 'Sessions', selected: true })).toBeInTheDocument() + }) + + it('resets to the sessions tab when the dialog reopens', async () => { + function Host() { + const [open, setOpen] = useState(true) + return ( + <> + + + + + ) + } + + renderWithClient() + + openProjectsTab() + expect(screen.getByRole('tab', { name: 'Projects' })).toHaveAttribute('aria-selected', 'true') + + fireEvent.click(screen.getByText('close-picker')) + fireEvent.click(screen.getByText('open-picker')) + + await waitFor(() => + expect(screen.getByRole('tab', { name: 'Sessions' })).toHaveAttribute('aria-selected', 'true'), + ) + }) }) diff --git a/frontend/src/components/session/SessionPickerDialog.tsx b/frontend/src/components/session/SessionPickerDialog.tsx index a197aac56..2a248e069 100644 --- a/frontend/src/components/session/SessionPickerDialog.tsx +++ b/frontend/src/components/session/SessionPickerDialog.tsx @@ -14,7 +14,9 @@ import { } from '@/components/ui/dropdown-menu' import { Input } from '@/components/ui/input' import { SessionStatusIndicator } from '@/components/ui/session-status-indicator' +import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs' import { DeleteSessionDialog } from '@/components/session/DeleteSessionDialog' +import { ProjectPickerContent } from '@/components/session/ProjectPickerContent' import { partitionSessions } from '@/components/session/session-partition' import { buildRepoByDirectory } from '@/components/navigation/sidebar-session-tree' import { useDeleteSession } from '@/hooks/useOpenCode' @@ -23,7 +25,10 @@ import { useSessionPins, useToggleSessionPin } from '@/hooks/useSessionPins' import { usePersistentBoolean } from '@/hooks/usePersistentBoolean' import { useNavigableRepos } from '@/hooks/useSidebarRepoGroups' import { useSessionStatusForSession } from '@/stores/sessionStatusStore' +import { useShortcutActions } from '@/contexts/KeyboardShortcutsContext' +import { useSettings } from '@/hooks/useSettings' import { buildPinnedSessionKeys, getSessionKey } from '@/lib/sessionKey' +import { normalizeShortcut, resolveShortcutBindings, type ShortcutBindings } from '@/lib/keyboardShortcuts' import { cn, formatShortRelativeTime, getRepoDisplayName } from '@/lib/utils' import { FINE_POINTER_MEDIA_QUERY, useMediaQuery } from '@/hooks/useMediaQuery' @@ -32,6 +37,16 @@ const UNTITLED_SESSION_TITLE = 'Untitled Session' const PAGE_STEP = 10 const NEAR_BOTTOM_PX = 240 +type SessionPickerTab = 'sessions' | 'projects' + +function switchTabShortcutLabel(bindings: ShortcutBindings): string { + const keys = bindings.shortcuts.switchPickerTab + if (!keys) return '' + const normalized = normalizeShortcut(keys) + if (bindings.directShortcuts.includes('switchPickerTab')) return normalized + return `${bindings.leaderKey} ${normalized}` +} + interface SessionPickerDialogProps { open: boolean onOpenChange: (open: boolean) => void @@ -39,6 +54,7 @@ interface SessionPickerDialogProps { activeSessionID?: string onSelectSession: (session: Session, repo: Repo) => void onDeleteActiveSession: () => void + onSelectRepo: (repo: Repo) => void } interface SessionPickerRow { @@ -281,6 +297,7 @@ interface SessionPickerContentProps { activeSessionID?: string onSelectSession: (session: Session, repo: Repo) => void onDeleteActiveSession: () => void + switchHint: string searchInputRef: React.RefObject hasFinePointer: boolean } @@ -291,6 +308,7 @@ function SessionPickerContent({ activeSessionID, onSelectSession, onDeleteActiveSession, + switchHint, searchInputRef, hasFinePointer, }: SessionPickerContentProps) { @@ -548,13 +566,6 @@ function SessionPickerContent({ return ( <> - - Sessions - {!allProjects && currentRepo && ( - for {getRepoDisplayName(currentRepo)} - )} - -
@@ -672,6 +683,7 @@ function SessionPickerContent({
↑↓ move · Enter open · Ctrl+D delete · Ctrl+A {allProjects ? 'this project' : 'all projects'} + {switchHint ? ` · ${switchHint} projects` : ''}
@@ -697,9 +709,35 @@ export function SessionPickerDialog({ activeSessionID, onSelectSession, onDeleteActiveSession, + onSelectRepo, }: SessionPickerDialogProps) { const searchInputRef = useRef(null) + const projectSearchInputRef = useRef(null) const hasFinePointer = useMediaQuery(FINE_POINTER_MEDIA_QUERY) + const { preferences } = useSettings() + const switchHint = useMemo( + () => switchTabShortcutLabel(resolveShortcutBindings(preferences)), + [preferences], + ) + const [tab, setTab] = useState('sessions') + const [prevOpen, setPrevOpen] = useState(open) + + if (prevOpen !== open) { + setPrevOpen(open) + if (open) setTab('sessions') + } + + const toggleTab = useCallback(() => { + setTab((current) => (current === 'sessions' ? 'projects' : 'sessions')) + }, []) + + useShortcutActions({ switchPickerTab: open ? toggleTab : undefined }) + + useEffect(() => { + if (!open || !hasFinePointer) return + if (tab === 'projects') projectSearchInputRef.current?.focus() + else searchInputRef.current?.focus() + }, [open, tab, hasFinePointer]) return ( @@ -716,15 +754,39 @@ export function SessionPickerDialog({ } }} > - + setTab(value as SessionPickerTab)} + className="flex min-h-0 w-full min-w-0 flex-1 flex-col gap-0" + > +
+ {tab === 'sessions' ? 'Sessions' : 'Projects'} + + Sessions + Projects + +
+ {tab === 'sessions' ? ( + + ) : ( + + )} +
) diff --git a/frontend/src/components/settings/KeyboardShortcuts.tsx b/frontend/src/components/settings/KeyboardShortcuts.tsx index a68d833d7..42f911a17 100644 --- a/frontend/src/components/settings/KeyboardShortcuts.tsx +++ b/frontend/src/components/settings/KeyboardShortcuts.tsx @@ -8,7 +8,7 @@ import { applyTuiKeybindImport, parseTuiKeybindConfig } from '@/lib/tuiKeybindIm import { showToast } from '@/lib/toast' const CONVERSATION_ACTIONS = ['submit', 'abort', 'clearPrompt', 'toggleMode', 'undo', 'redo', 'compact', 'fork', 'timeline', 'exportSession', 'selectModel', 'variantCycle', 'favoriteCycle', 'halfPageUp', 'halfPageDown'] -const NAVIGATION_ACTIONS = ['settings', 'sessions', 'newSession', 'closeSession', 'toggleSidebar', 'toggleTerminal', 'toggleSourceControl'] +const NAVIGATION_ACTIONS = ['settings', 'sessions', 'switchPickerTab', 'newSession', 'closeSession', 'toggleSidebar', 'toggleTerminal', 'toggleSourceControl'] const formatShortcutLabel = (action: string): string => { return action.replace(/([A-Z])/g, ' $1').trim() diff --git a/frontend/src/pages/SessionDetail.tsx b/frontend/src/pages/SessionDetail.tsx index 4ec7aeb05..325efade3 100644 --- a/frontend/src/pages/SessionDetail.tsx +++ b/frontend/src/pages/SessionDetail.tsx @@ -9,7 +9,7 @@ import { X, CornerUpLeft } from "lucide-react"; import { SquareFill } from "@/components/ui/square-fill"; import { Header } from "@/components/ui/header"; import { SessionPickerDialog } from "@/components/session/SessionPickerDialog"; -import { getSessionListPath, getSessionPath } from '@/lib/navigation' +import { getRepoPath, getSessionListPath, getSessionPath } from '@/lib/navigation' import { FetchError } from '@/api/fetchWrapper' import { FileBrowserSheet } from "@/components/file-browser/FileBrowserSheet"; @@ -872,6 +872,10 @@ export function SessionDetail() { setSessionsDialogOpen(false) handleCloseSession() }} + onSelectRepo={(selectedRepo) => { + navigate(getRepoPath(selectedRepo.id)) + setSessionsDialogOpen(false) + }} /> {sideQuestion && sessionId && ( diff --git a/shared/src/schemas/settings.ts b/shared/src/schemas/settings.ts index d2921e370..6e4efe58f 100644 --- a/shared/src/schemas/settings.ts +++ b/shared/src/schemas/settings.ts @@ -76,6 +76,7 @@ export const DEFAULT_KEYBOARD_SHORTCUTS = { fork: '', settings: '', sessions: 'L', + switchPickerTab: 'Ctrl+S', newSession: 'N', closeSession: 'W', toggleSidebar: 'B', @@ -92,7 +93,7 @@ export const DEFAULT_KEYBOARD_SHORTCUTS = { export type KeyboardShortcutAction = keyof typeof DEFAULT_KEYBOARD_SHORTCUTS; -export const DEFAULT_DIRECT_SHORTCUTS = ['submit', 'abort', 'clearPrompt', 'toggleMode', 'variantCycle', 'favoriteCycle', 'halfPageUp', 'halfPageDown']; +export const DEFAULT_DIRECT_SHORTCUTS = ['submit', 'abort', 'clearPrompt', 'toggleMode', 'variantCycle', 'favoriteCycle', 'halfPageUp', 'halfPageDown', 'switchPickerTab']; export const GitCredentialSchema = z.object({ id: z.string().optional(), From c41eff304c421ea6f9fe0fa4d5ddd7e9b00a9869 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Wed, 14 Oct 2026 01:14:55 -0400 Subject: [PATCH 2/5] fix(shortcuts): share one shortcut label formatter between settings and the session picker --- .../session/SessionPickerDialog.test.tsx | 11 +++++++++++ .../session/SessionPickerDialog.tsx | 19 +++++++++---------- .../components/settings/KeyboardShortcuts.tsx | 13 +++++-------- frontend/src/lib/keyboardShortcuts.test.ts | 9 ++++++++- frontend/src/lib/keyboardShortcuts.ts | 10 ++++++++++ 5 files changed, 43 insertions(+), 19 deletions(-) diff --git a/frontend/src/components/session/SessionPickerDialog.test.tsx b/frontend/src/components/session/SessionPickerDialog.test.tsx index 6a8fdc3af..4aecddc2e 100644 --- a/frontend/src/components/session/SessionPickerDialog.test.tsx +++ b/frontend/src/components/session/SessionPickerDialog.test.tsx @@ -578,6 +578,17 @@ describe('SessionPickerDialog', () => { expect(screen.getByText(/Ctrl\+G sessions/)).toBeInTheDocument() }) + it('shows the normalized leader, arrow and key in the footer for a leader switch binding', () => { + mocks.preferences = { + keyboardShortcuts: { switchPickerTab: 'G' }, + directShortcuts: [], + leaderKey: 'Ctrl+X', + } + renderPicker() + + expect(screen.getByText(/Ctrl\+X → G projects/)).toBeInTheDocument() + }) + it('no longer switches tabs on Ctrl+O', () => { renderPicker() diff --git a/frontend/src/components/session/SessionPickerDialog.tsx b/frontend/src/components/session/SessionPickerDialog.tsx index 2a248e069..b3c03bdae 100644 --- a/frontend/src/components/session/SessionPickerDialog.tsx +++ b/frontend/src/components/session/SessionPickerDialog.tsx @@ -28,7 +28,7 @@ import { useSessionStatusForSession } from '@/stores/sessionStatusStore' import { useShortcutActions } from '@/contexts/KeyboardShortcutsContext' import { useSettings } from '@/hooks/useSettings' import { buildPinnedSessionKeys, getSessionKey } from '@/lib/sessionKey' -import { normalizeShortcut, resolveShortcutBindings, type ShortcutBindings } from '@/lib/keyboardShortcuts' +import { formatShortcutLabel, resolveShortcutBindings } from '@/lib/keyboardShortcuts' import { cn, formatShortRelativeTime, getRepoDisplayName } from '@/lib/utils' import { FINE_POINTER_MEDIA_QUERY, useMediaQuery } from '@/hooks/useMediaQuery' @@ -39,14 +39,6 @@ const NEAR_BOTTOM_PX = 240 type SessionPickerTab = 'sessions' | 'projects' -function switchTabShortcutLabel(bindings: ShortcutBindings): string { - const keys = bindings.shortcuts.switchPickerTab - if (!keys) return '' - const normalized = normalizeShortcut(keys) - if (bindings.directShortcuts.includes('switchPickerTab')) return normalized - return `${bindings.leaderKey} ${normalized}` -} - interface SessionPickerDialogProps { open: boolean onOpenChange: (open: boolean) => void @@ -716,7 +708,14 @@ export function SessionPickerDialog({ const hasFinePointer = useMediaQuery(FINE_POINTER_MEDIA_QUERY) const { preferences } = useSettings() const switchHint = useMemo( - () => switchTabShortcutLabel(resolveShortcutBindings(preferences)), + () => { + const bindings = resolveShortcutBindings(preferences) + return formatShortcutLabel( + bindings.shortcuts.switchPickerTab, + bindings.directShortcuts.includes('switchPickerTab'), + bindings.leaderKey, + ) + }, [preferences], ) const [tab, setTab] = useState('sessions') diff --git a/frontend/src/components/settings/KeyboardShortcuts.tsx b/frontend/src/components/settings/KeyboardShortcuts.tsx index 42f911a17..c6e151dfd 100644 --- a/frontend/src/components/settings/KeyboardShortcuts.tsx +++ b/frontend/src/components/settings/KeyboardShortcuts.tsx @@ -3,14 +3,14 @@ import { useSettings } from '@/hooks/useSettings' import { useMobile } from '@/hooks/useMobile' import { Loader2, X } from 'lucide-react' import { Button } from '@/components/ui/button' -import { formatEventModifiers, formatShortcutEvent, isModifierOnlyEvent, normalizeShortcut, resolveShortcutBindings } from '@/lib/keyboardShortcuts' +import { formatEventModifiers, formatShortcutEvent, formatShortcutLabel, isModifierOnlyEvent, normalizeShortcut, resolveShortcutBindings } from '@/lib/keyboardShortcuts' import { applyTuiKeybindImport, parseTuiKeybindConfig } from '@/lib/tuiKeybindImport' import { showToast } from '@/lib/toast' const CONVERSATION_ACTIONS = ['submit', 'abort', 'clearPrompt', 'toggleMode', 'undo', 'redo', 'compact', 'fork', 'timeline', 'exportSession', 'selectModel', 'variantCycle', 'favoriteCycle', 'halfPageUp', 'halfPageDown'] const NAVIGATION_ACTIONS = ['settings', 'sessions', 'switchPickerTab', 'newSession', 'closeSession', 'toggleSidebar', 'toggleTerminal', 'toggleSourceControl'] -const formatShortcutLabel = (action: string): string => { +const formatActionLabel = (action: string): string => { return action.replace(/([A-Z])/g, ' $1').trim() } @@ -87,7 +87,7 @@ function ShortcutRow({ action, keys, isDirect, display, isRecording, currentKeys return (
-

{formatShortcutLabel(action)}

+

{formatActionLabel(action)}

- {tab === 'sessions' ? ( + - ) : ( + + - )} + From abcecd1c0c2ab2b5dc92da50c1afb64e7ddd6dbe Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:36:23 -0400 Subject: [PATCH 4/5] fix(sessions): keep search focus when switching picker tabs --- .../session/SessionPickerDialog.test.tsx | 26 +++++++++++++++++++ .../session/SessionPickerDialog.tsx | 7 +++-- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/session/SessionPickerDialog.test.tsx b/frontend/src/components/session/SessionPickerDialog.test.tsx index 1edfca4af..747c7abf5 100644 --- a/frontend/src/components/session/SessionPickerDialog.test.tsx +++ b/frontend/src/components/session/SessionPickerDialog.test.tsx @@ -514,6 +514,32 @@ describe('SessionPickerDialog', () => { expect(screen.queryByRole('tabpanel', { name: 'Sessions' })).not.toBeInTheDocument() }) + it('focuses the projects search input after clicking the Projects tab and filters on typing', async () => { + mocks.repos = [repo(1, 'alpha', '/w/a'), repo(2, 'beta', '/w/b')] + const user = userEvent.setup() + renderPicker() + + await user.click(screen.getByRole('tab', { name: 'Projects' })) + + await waitFor(() => expect(screen.getByPlaceholderText('Search projects...')).toHaveFocus()) + + await user.keyboard('beta') + + expect(screen.getByRole('option', { name: /beta/ })).toBeInTheDocument() + expect(screen.queryByRole('option', { name: /alpha/ })).not.toBeInTheDocument() + }) + + it('focuses the projects search input after switching tabs with the shortcut', async () => { + mocks.repos = [repo(1, 'alpha', '/w/a'), repo(2, 'beta', '/w/b')] + const user = userEvent.setup() + renderPicker() + + await user.click(screen.getByPlaceholderText('Search sessions...')) + await user.keyboard('{Control>}s{/Control}') + + await waitFor(() => expect(screen.getByPlaceholderText('Search projects...')).toHaveFocus()) + }) + it('lists repos in the projects tab and opens the cursor repo on Enter', async () => { mocks.repos = [repo(1, 'alpha', '/w/a'), repo(2, 'beta', '/w/b')] const { onSelectRepo } = renderPicker() diff --git a/frontend/src/components/session/SessionPickerDialog.tsx b/frontend/src/components/session/SessionPickerDialog.tsx index 1305a76ea..02d760033 100644 --- a/frontend/src/components/session/SessionPickerDialog.tsx +++ b/frontend/src/components/session/SessionPickerDialog.tsx @@ -734,8 +734,11 @@ export function SessionPickerDialog({ useEffect(() => { if (!open || !hasFinePointer) return - if (tab === 'projects') projectSearchInputRef.current?.focus() - else searchInputRef.current?.focus() + const frame = requestAnimationFrame(() => { + if (tab === 'projects') projectSearchInputRef.current?.focus() + else searchInputRef.current?.focus() + }) + return () => cancelAnimationFrame(frame) }, [open, tab, hasFinePointer]) return ( From afc95039a83068ddebddc47eb59fe9eee40e79a2 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:45:08 -0400 Subject: [PATCH 5/5] fix(sessions): focus picker search after the tab shortcut on touch devices --- .../session/SessionPickerDialog.test.tsx | 23 +++++++++++++++++++ .../session/SessionPickerDialog.tsx | 9 +++++++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/session/SessionPickerDialog.test.tsx b/frontend/src/components/session/SessionPickerDialog.test.tsx index 747c7abf5..d0b607f78 100644 --- a/frontend/src/components/session/SessionPickerDialog.test.tsx +++ b/frontend/src/components/session/SessionPickerDialog.test.tsx @@ -540,6 +540,29 @@ describe('SessionPickerDialog', () => { await waitFor(() => expect(screen.getByPlaceholderText('Search projects...')).toHaveFocus()) }) + it('focuses the projects search input after the switch shortcut on a coarse pointer device', async () => { + stubMatchMedia(false) + mocks.repos = [repo(1, 'alpha', '/w/a'), repo(2, 'beta', '/w/b')] + const user = userEvent.setup() + renderPicker() + + await user.click(screen.getByPlaceholderText('Search sessions...')) + await user.keyboard('{Control>}s{/Control}') + + await waitFor(() => expect(screen.getByPlaceholderText('Search projects...')).toHaveFocus()) + }) + + it('does not focus the projects search input when switching tabs by pointer on a coarse pointer device', async () => { + stubMatchMedia(false) + mocks.repos = [repo(1, 'alpha', '/w/a'), repo(2, 'beta', '/w/b')] + const user = userEvent.setup() + renderPicker() + + await user.click(screen.getByRole('tab', { name: 'Projects' })) + + expect(await screen.findByPlaceholderText('Search projects...')).not.toHaveFocus() + }) + it('lists repos in the projects tab and opens the cursor repo on Enter', async () => { mocks.repos = [repo(1, 'alpha', '/w/a'), repo(2, 'beta', '/w/b')] const { onSelectRepo } = renderPicker() diff --git a/frontend/src/components/session/SessionPickerDialog.tsx b/frontend/src/components/session/SessionPickerDialog.tsx index 02d760033..1d7ee8fb7 100644 --- a/frontend/src/components/session/SessionPickerDialog.tsx +++ b/frontend/src/components/session/SessionPickerDialog.tsx @@ -705,6 +705,7 @@ export function SessionPickerDialog({ }: SessionPickerDialogProps) { const searchInputRef = useRef(null) const projectSearchInputRef = useRef(null) + const focusSearchAfterSwitchRef = useRef(false) const hasFinePointer = useMediaQuery(FINE_POINTER_MEDIA_QUERY) const { preferences } = useSettings() const switchHint = useMemo( @@ -727,14 +728,20 @@ export function SessionPickerDialog({ } const toggleTab = useCallback(() => { + focusSearchAfterSwitchRef.current = true setTab((current) => (current === 'sessions' ? 'projects' : 'sessions')) }, []) useShortcutActions({ switchPickerTab: open ? toggleTab : undefined }) useEffect(() => { - if (!open || !hasFinePointer) return + if (!open) { + focusSearchAfterSwitchRef.current = false + return + } + if (!hasFinePointer && !focusSearchAfterSwitchRef.current) return const frame = requestAnimationFrame(() => { + focusSearchAfterSwitchRef.current = false if (tab === 'projects') projectSearchInputRef.current?.focus() else searchInputRef.current?.focus() })