From 0c42f4144671a9a8db2279f93aaa5d66f4bd1be5 Mon Sep 17 00:00:00 2001 From: chefadmin-netizen Date: Wed, 2 Sep 2026 00:55:56 +0200 Subject: [PATCH] feat(shortcuts): navigate sessions and projects --- src/App.tsx | 88 +++++++++++++++++++++++++++++++++- src/chrome/Sidebar.tsx | 24 ++++++++-- src/lib/sessionFolders.test.ts | 23 +++++++++ src/lib/sessionFolders.ts | 17 +++++++ src/lib/settings.test.ts | 20 ++++++++ src/lib/settings.ts | 20 ++++++++ src/lib/tabKeys.test.ts | 42 +++++++++++++++- src/lib/tabKeys.ts | 31 ++++++++++++ 8 files changed, 259 insertions(+), 6 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 3024d9e7..865e00c2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -190,6 +190,7 @@ import { loadRecents, looksLikeProject, normalizeProjectPath, + projectRailItems, rememberProject, sameProjectPath, } from "./lib/recents"; @@ -237,7 +238,11 @@ import { liveAgentsFromSessions } from "./lib/liveAgents"; import { hiddenApprovalNotices } from "./lib/approvalToast"; import { nextUnseenFinishedSessions } from "./lib/sessionDone"; import { playCue } from "./lib/sounds"; -import { tabCommand } from "./lib/tabKeys"; +import { + adjacentItemId, + shouldHandleListNavigation, + tabCommand, +} from "./lib/tabKeys"; import { canTabVisitBack, canTabVisitForward, @@ -558,6 +563,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); @@ -3962,6 +3974,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, onClosePane, @@ -3982,6 +4033,8 @@ export default function App({ onNewTerminal, onNewTerminalTab, onToggleProjectTerminal, + onNavigateSessionList, + onNavigateProjectList, openSettings, }); actions.current = { @@ -4004,6 +4057,8 @@ export default function App({ onNewTerminal, onNewTerminalTab, onToggleProjectTerminal, + onNavigateSessionList, + onNavigateProjectList, openSettings, }; @@ -4021,6 +4076,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 && @@ -4066,6 +4143,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)); @@ -4239,6 +4324,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 85dc5d1a..c8894b13 100644 --- a/src/chrome/Sidebar.tsx +++ b/src/chrome/Sidebar.tsx @@ -59,6 +59,7 @@ import { renameFolder, reorderSessionFolders, saveSessionFolders, + sessionListNavigationIds, setFolderCollapsed, setFolderColor, ungroupedSessions, @@ -165,6 +166,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,10 @@ function SidebarComponent({ deckLayout || searchOpen ? searchQuery : "", ), ].sort(compareSessionSummaries); + const filtersActive = hasActiveSessionFilters(sessionFilters); + const searchNarrowed = Boolean( + (deckLayout || searchOpen) && 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,18 +405,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${deckLayout || searchOpen ? searchQuery : ""}`; const sessionHarnesses = harnessesInSessions(sessions); - const filtersActive = hasActiveSessionFilters(sessionFilters); - const searchNarrowed = Boolean( - (deckLayout || searchOpen) && 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 398d3ce0..0ab31771 100644 --- a/src/lib/sessionFolders.test.ts +++ b/src/lib/sessionFolders.test.ts @@ -15,6 +15,7 @@ import { removeSessionFromFolder, renameFolder, saveSessionFolders, + sessionListNavigationIds, setFolderCollapsed, setFolderColor, reorderSessionFolders, @@ -164,6 +165,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 1ecc3777..325401e1 100644 --- a/src/lib/sessionFolders.ts +++ b/src/lib/sessionFolders.ts @@ -116,6 +116,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 6037f93e..ffe85726 100644 --- a/src/lib/settings.test.ts +++ b/src/lib/settings.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { COMPOSER_RUNNER_DEFAULT, GRID_ARCADE_ENABLED_DEFAULT, + KEYBINDINGS, LIVE_AGENTS_ENABLED_DEFAULT, loadComposerRunner, loadGridArcadeEnabled, @@ -122,3 +123,22 @@ describe("grid arcade enabled setting", () => { expect(loadGridArcadeEnabled()).toBe(true); }); }); + +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, + ); + }); +}); diff --git a/src/lib/settings.ts b/src/lib/settings.ts index 4a5f912d..a8cf844b 100644 --- a/src/lib/settings.ts +++ b/src/lib/settings.ts @@ -275,6 +275,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 61c1e6c4..a5ea1511 100644 --- a/src/lib/tabKeys.test.ts +++ b/src/lib/tabKeys.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { tabCommand } from "./tabKeys"; +import { + adjacentItemId, + shouldHandleListNavigation, + tabCommand, +} from "./tabKeys"; function key( partial: Partial< @@ -71,4 +75,40 @@ 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); + }); }); diff --git a/src/lib/tabKeys.ts b/src/lib/tabKeys.ts index 5498b971..8a1351b3 100644 --- a/src/lib/tabKeys.ts +++ b/src/lib/tabKeys.ts @@ -16,6 +16,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 */ import type { FocusDir } from "./layout"; @@ -32,6 +36,10 @@ export type TabCommand = | "new-terminal" | "new-terminal-tab" | "toggle-terminal" + | "prev-session" + | "next-session" + | "prev-project" + | "next-project" | { activate: number } | { focus: FocusDir }; @@ -63,6 +71,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; } @@ -77,3 +89,22 @@ export function tabCommand(e: KeyboardEvent): TabCommand | null { if (key === "9") return { activate: -1 }; 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; +}