diff --git a/src/App.tsx b/src/App.tsx index 50c6f57..c161722 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -201,6 +201,7 @@ import { loadRecents, looksLikeProject, normalizeProjectPath, + projectRailItems, rememberProject, sameProjectPath, } from "./lib/recents"; @@ -259,8 +260,10 @@ import { hiddenApprovalNotices } from "./lib/approvalToast"; import { nextUnseenFinishedSessions } from "./lib/sessionDone"; import { playCue } from "./lib/sounds"; import { + adjacentItemId, deferUnhandledEscape, focusedBusyAgentSessionId, + shouldHandleListNavigation, shouldStopFocusedTurnOnEscape, tabCommand, } from "./lib/tabKeys"; @@ -644,6 +647,13 @@ export default function App({ inboxViewOpenRef.current = inboxViewOpen; const notesViewOpenRef = useRef(notesViewOpen); notesViewOpenRef.current = notesViewOpen; + const settingsOpenRef = useRef(settingsOpen); + settingsOpenRef.current = settingsOpen; + const sessionNavigationIdsRef = useRef([]); + const filePickerOpenRef = useRef(filePickerOpen); + filePickerOpenRef.current = filePickerOpen; + const whatsNewVersionRef = useRef(whatsNewVersion); + whatsNewVersionRef.current = whatsNewVersion; useEffect(() => { if (!notesEnabled) setNotesViewOpen(false); @@ -4339,6 +4349,45 @@ export default function App({ ); }, []); + const onSessionNavigationOrder = useCallback((ids: readonly string[]) => { + sessionNavigationIdsRef.current = ids; + }, []); + + const onNavigateSessionList = useCallback( + (delta: number) => { + const activeWorkspace = tabsRef.current.find( + (entry) => entry.id === activeTabIdRef.current, + ); + if (!activeWorkspace || activeWorkspace.diffFocused) return; + const current = sessionsRef.current.find( + (session) => session.id === activeWorkspace.focusedId, + ); + if (!current) return; + + const next = adjacentItemId( + sessionNavigationIdsRef.current, + current.id, + delta, + ); + if (!next || next === current.id) return; + void onSelectHistorySession(next); + }, + [onSelectHistorySession], + ); + + const onNavigateProjectList = useCallback( + (delta: number) => { + const current = normalizeProjectPath(projectCwdRef.current); + const ids = projectRailItems(loadRecents(), current).map( + (project) => project.path, + ); + const next = adjacentItemId(ids, current, delta); + if (!next || sameProjectPath(next, current)) return; + onSelectProject(next); + }, + [onSelectProject], + ); + const actions = useRef({ onNew, onCloseOtherTabs, @@ -4360,6 +4409,8 @@ export default function App({ onNewTerminal, onNewTerminalTab, onToggleProjectTerminal, + onNavigateSessionList, + onNavigateProjectList, openSettings, }); actions.current = { @@ -4383,6 +4434,8 @@ export default function App({ onNewTerminal, onNewTerminalTab, onToggleProjectTerminal, + onNavigateSessionList, + onNavigateProjectList, openSettings, }; @@ -4400,6 +4453,28 @@ export default function App({ const cmd = tabCommand(e); if (cmd) { const target = e.target instanceof Element ? e.target : null; + const listNavigation = + cmd === "prev-session" || + cmd === "next-session" || + cmd === "prev-project" || + cmd === "next-project"; + if (listNavigation) { + const blockedTarget = Boolean( + target?.closest( + 'input, textarea, select, [contenteditable="true"], .cm-editor, .monocode-terminal, [role="dialog"], [data-model-picker], [data-file-picker], [data-branch-picker], [data-skill-picker], [data-mention-picker], [data-app-search]', + ), + ); + const surfaceOpen = + searchViewOpenRef.current || + inboxViewOpenRef.current || + notesViewOpenRef.current || + settingsOpenRef.current || + filePickerOpenRef.current || + Boolean(whatsNewVersionRef.current); + if (!shouldHandleListNavigation({ blockedTarget, surfaceOpen })) { + return; + } + } if ( target?.closest(".monocode-terminal") && e.ctrlKey && @@ -4444,6 +4519,14 @@ export default function App({ run("new-terminal-tab", a.onNewTerminalTab); else if (cmd === "toggle-terminal") run("toggle-terminal", a.onToggleProjectTerminal); + else if (cmd === "prev-session") + run("prev-session", () => a.onNavigateSessionList(-1)); + else if (cmd === "next-session") + run("next-session", () => a.onNavigateSessionList(1)); + else if (cmd === "prev-project") + run("prev-project", () => a.onNavigateProjectList(-1)); + else if (cmd === "next-project") + run("next-project", () => a.onNavigateProjectList(1)); else if ("focus" in cmd) run(`focus-${cmd.focus}`, () => a.onFocusDir(cmd.focus)); else run(`activate-${cmd.activate}`, () => a.onActivate(cmd.activate)); @@ -4620,6 +4703,7 @@ export default function App({ status={historyFailed ? "error" : "idle"} pending={historyPending} onSelectSession={onSelectHistorySession} + onSessionNavigationOrder={onSessionNavigationOrder} onPlaceSessionOnPane={onPlaceSessionOnPane} onRenameSession={onRenameHistorySession} onArchiveSession={onArchiveHistorySession} diff --git a/src/chrome/Sidebar.tsx b/src/chrome/Sidebar.tsx index ac5e711..14ae30d 100644 --- a/src/chrome/Sidebar.tsx +++ b/src/chrome/Sidebar.tsx @@ -59,6 +59,7 @@ import { renameFolder, reorderSessionFolders, saveSessionFolders, + sessionListNavigationIds, setFolderCollapsed, setFolderColor, setFolderCustomColor, @@ -164,6 +165,7 @@ type Props = { /** First listing for this project has not arrived yet. */ pending: boolean; onSelectSession: (sessionId: string) => void; + onSessionNavigationOrder?: (ids: readonly string[]) => void; onPrefetchSession?: (sessionId: string) => void; onPlaceSessionOnPane?: ( sessionId: string, @@ -236,6 +238,7 @@ function SidebarComponent({ status, pending, onSelectSession, + onSessionNavigationOrder, onPrefetchSession, onPlaceSessionOnPane, onRenameSession, @@ -385,6 +388,8 @@ function SidebarComponent({ searchQuery, ), ].sort(compareSessionSummaries); + const filtersActive = hasActiveSessionFilters(sessionFilters); + const searchNarrowed = Boolean(searchQuery.trim()); // Summaries for the whole project stay in `sessions` so filters still work. // Folders sit above the ungrouped list. Only a page of ungrouped cards // mounts; the sentinel below asks for the next page. @@ -398,16 +403,27 @@ function SidebarComponent({ activeUngroupedIndex, ); const shownUngrouped = ungroupedVisible.slice(0, shownUngroupedCount); + const fullSessionListEntries = buildSessionList( + visibleSessions, + sessionFolders, + ungroupedVisible, + ); const sessionListEntries = buildSessionList( visibleSessions, sessionFolders, shownUngrouped, ); + const sessionNavigationIds = sessionListNavigationIds( + fullSessionListEntries, + searchNarrowed, + ); + const sessionNavigationKey = sessionNavigationIds.join("\0"); + useEffect(() => { + onSessionNavigationOrder?.(sessionNavigationIds); + }, [onSessionNavigationOrder, sessionNavigationKey]); const hasMoreSessions = shownUngroupedCount < ungroupedVisible.length; const sessionListKey = `${cwd}\0${sessionFilters.showArchived}\0${sessionFilters.time}\0${sessionFilters.hiddenHarnesses.join(",")}\0${sessionFilters.status.working}\0${sessionFilters.status.needsApproval}\0${sessionFilters.status.done}\0${searchQuery}`; const sessionHarnesses = harnessesInSessions(sessions); - const filtersActive = hasActiveSessionFilters(sessionFilters); - const searchNarrowed = Boolean(searchQuery.trim()); const narrowedByUser = searchNarrowed || filtersActive; const sortable = useSortable(tabOrder, (ids) => { const next = ids as SidebarTab[]; diff --git a/src/lib/sessionFolders.test.ts b/src/lib/sessionFolders.test.ts index a0c8e39..8b8ab68 100644 --- a/src/lib/sessionFolders.test.ts +++ b/src/lib/sessionFolders.test.ts @@ -15,6 +15,7 @@ import { removeSessionFromFolder, renameFolder, saveSessionFolders, + sessionListNavigationIds, setFolderCollapsed, setFolderColor, setFolderCustomColor, @@ -165,6 +166,28 @@ describe("buildSessionList", () => { ), ).toEqual(["pin", "divider", "rest"]); }); + + it("exposes the full visible navigation order without pagination", () => { + const sessions = [ + summary("folder-a"), + summary("folder-b"), + summary("loose"), + ]; + const folders = [ + folder("work", ["folder-a", "folder-b"], { collapsed: true }), + ]; + const entries = buildSessionList( + sessions, + folders, + ungroupedSessions(sessions, folders), + ); + expect(sessionListNavigationIds(entries, false)).toEqual(["loose"]); + expect(sessionListNavigationIds(entries, true)).toEqual([ + "folder-a", + "folder-b", + "loose", + ]); + }); }); describe("folder mutations", () => { diff --git a/src/lib/sessionFolders.ts b/src/lib/sessionFolders.ts index 31d0e34..f64e99d 100644 --- a/src/lib/sessionFolders.ts +++ b/src/lib/sessionFolders.ts @@ -120,6 +120,23 @@ export function buildSessionList( return entries; } +export function sessionListNavigationIds( + entries: readonly SessionListEntry[], + expandCollapsed: boolean, +): string[] { + const ids: string[] = []; + for (const entry of entries) { + if (entry.kind === "session") { + ids.push(entry.session.id); + continue; + } + if (entry.kind !== "folder") continue; + if (entry.folder.collapsed && !expandCollapsed) continue; + ids.push(...entry.sessions.map((session) => session.id)); + } + return ids; +} + export function createFolderWithSessions( folders: SessionFolder[], sessionIds: string[], diff --git a/src/lib/settings.test.ts b/src/lib/settings.test.ts index 16cc424..caa06ba 100644 --- a/src/lib/settings.test.ts +++ b/src/lib/settings.test.ts @@ -4,6 +4,7 @@ import { DIFF_VIEWER_DEFAULT, FOLLOW_UP_BEHAVIOR_DEFAULT, GRID_ARCADE_ENABLED_DEFAULT, + KEYBINDINGS, LIVE_AGENTS_ENABLED_DEFAULT, loadComposerRunner, loadDiffViewer, @@ -153,6 +154,25 @@ describe("grid arcade enabled setting", () => { }); }); +describe("workspace navigation keybindings", () => { + it("documents session and project cycling in the shortcut list", () => { + const rows = KEYBINDINGS.filter( + (row) => + row.command.startsWith("Session:") || + row.command.startsWith("Project:"), + ); + expect(rows.map((row) => row.command)).toEqual([ + "Session: Previous", + "Session: Next", + "Project: Previous", + "Project: Next", + ]); + expect(rows.every((row) => row.when === "!textFocus && !overlay")).toBe( + true, + ); + }); +}); + describe("diff viewer setting", () => { beforeEach(mockLocalStorage); afterEach(() => { diff --git a/src/lib/settings.ts b/src/lib/settings.ts index 5245e7c..0aadb20 100644 --- a/src/lib/settings.ts +++ b/src/lib/settings.ts @@ -342,6 +342,26 @@ export const KEYBINDINGS: KeybindingRow[] = [ { command: "Tab: Forward", keys: `${MOD}]`, when: "Always" }, { command: "Tab: Activate 1–8", keys: `${MOD}1 … ${MOD}8`, when: "Always" }, { command: "Tab: Activate Last", keys: `${MOD}9`, when: "Always" }, + { + command: "Session: Previous", + keys: `${MOD}${SHIFT}↑`, + when: "!textFocus && !overlay", + }, + { + command: "Session: Next", + keys: `${MOD}${SHIFT}↓`, + when: "!textFocus && !overlay", + }, + { + command: "Project: Previous", + keys: `${MOD}${SHIFT}←`, + when: "!textFocus && !overlay", + }, + { + command: "Project: Next", + keys: `${MOD}${SHIFT}→`, + when: "!textFocus && !overlay", + }, { command: "Pane: Close", keys: `${MOD}W`, when: "Always" }, { command: "Pane: Split Right", keys: `${MOD}D`, when: "!editorFocus" }, { diff --git a/src/lib/tabKeys.test.ts b/src/lib/tabKeys.test.ts index 1654b59..ac483be 100644 --- a/src/lib/tabKeys.test.ts +++ b/src/lib/tabKeys.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from "vitest"; import { + adjacentItemId, deferUnhandledEscape, focusedBusyAgentSessionId, + shouldHandleListNavigation, shouldStopFocusedTurnOnEscape, tabCommand, } from "./tabKeys"; @@ -86,6 +88,42 @@ describe("tabCommand", () => { ), ).toBe("next"); }); + + it("uses shift-mod arrows for session and project navigation", () => { + expect( + tabCommand(key({ key: "ArrowUp", metaKey: true, shiftKey: true })), + ).toBe("prev-session"); + expect( + tabCommand(key({ key: "ArrowDown", metaKey: true, shiftKey: true })), + ).toBe("next-session"); + expect( + tabCommand(key({ key: "ArrowLeft", metaKey: true, shiftKey: true })), + ).toBe("prev-project"); + expect( + tabCommand(key({ key: "ArrowRight", metaKey: true, shiftKey: true })), + ).toBe("next-project"); + }); + + it("cycles ordered item ids and wraps at both ends", () => { + expect(adjacentItemId(["a", "b", "c"], "b", 1)).toBe("c"); + expect(adjacentItemId(["a", "b", "c"], "c", 1)).toBe("a"); + expect(adjacentItemId(["a", "b", "c"], "a", -1)).toBe("c"); + expect(adjacentItemId(["a", "b", "c"], "missing", 1)).toBe("a"); + expect(adjacentItemId(["a", "b", "c"], "missing", -1)).toBe("c"); + expect(adjacentItemId([], "a", 1)).toBeNull(); + }); + + it("blocks list navigation while another text or app surface owns focus", () => { + expect( + shouldHandleListNavigation({ blockedTarget: false, surfaceOpen: false }), + ).toBe(true); + expect( + shouldHandleListNavigation({ blockedTarget: true, surfaceOpen: false }), + ).toBe(false); + expect( + shouldHandleListNavigation({ blockedTarget: false, surfaceOpen: true }), + ).toBe(false); + }); }); describe("shouldStopFocusedTurnOnEscape", () => { diff --git a/src/lib/tabKeys.ts b/src/lib/tabKeys.ts index 2c7bf6e..49cf7a6 100644 --- a/src/lib/tabKeys.ts +++ b/src/lib/tabKeys.ts @@ -17,6 +17,10 @@ * New terminal cmd-` * New terminal tab shift-cmd-` * Toggle terminal cmd-j + * Previous session shift-cmd-up + * Next session shift-cmd-down + * Previous project shift-cmd-left + * Next project shift-cmd-right * Stop focused turn escape */ @@ -35,6 +39,10 @@ export type TabCommand = | "new-terminal" | "new-terminal-tab" | "toggle-terminal" + | "prev-session" + | "next-session" + | "prev-project" + | "next-project" | { activate: number } | { focus: FocusDir }; @@ -67,6 +75,10 @@ export function tabCommand(e: KeyboardEvent): TabCommand | null { if (e.shiftKey) { if (e.key === "]" || e.key === "}") return "next"; if (e.key === "[" || e.key === "{") return "prev"; + if (e.key === "ArrowUp") return "prev-session"; + if (e.key === "ArrowDown") return "next-session"; + if (e.key === "ArrowLeft") return "prev-project"; + if (e.key === "ArrowRight") return "next-project"; if (key === "d") return "split-down"; return null; } @@ -82,6 +94,25 @@ export function tabCommand(e: KeyboardEvent): TabCommand | null { return null; } +export function adjacentItemId( + ids: readonly string[], + current: string | null, + delta: number, +): string | null { + if (ids.length === 0) return null; + const index = current ? ids.indexOf(current) : -1; + if (index < 0) return delta < 0 ? ids[ids.length - 1] : ids[0]; + const next = (index + (delta < 0 ? -1 : 1) + ids.length) % ids.length; + return ids[next] ?? null; +} + +export function shouldHandleListNavigation(input: { + blockedTarget: boolean; + surfaceOpen: boolean; +}): boolean { + return !input.blockedTarget && !input.surfaceOpen; +} + type EscapeKeyEvent = Pick< KeyboardEvent, | "key"