Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,7 @@ import {
loadRecents,
looksLikeProject,
normalizeProjectPath,
projectRailItems,
rememberProject,
sameProjectPath,
} from "./lib/recents";
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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<readonly string[]>([]);
const filePickerOpenRef = useRef(filePickerOpen);
filePickerOpenRef.current = filePickerOpen;
const whatsNewVersionRef = useRef(whatsNewVersion);
whatsNewVersionRef.current = whatsNewVersion;

useEffect(() => {
if (!notesEnabled) setNotesViewOpen(false);
Expand Down Expand Up @@ -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,
Expand All @@ -4360,6 +4409,8 @@ export default function App({
onNewTerminal,
onNewTerminalTab,
onToggleProjectTerminal,
onNavigateSessionList,
onNavigateProjectList,
openSettings,
});
actions.current = {
Expand All @@ -4383,6 +4434,8 @@ export default function App({
onNewTerminal,
onNewTerminalTab,
onToggleProjectTerminal,
onNavigateSessionList,
onNavigateProjectList,
openSettings,
};

Expand All @@ -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 &&
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -4620,6 +4703,7 @@ export default function App({
status={historyFailed ? "error" : "idle"}
pending={historyPending}
onSelectSession={onSelectHistorySession}
onSessionNavigationOrder={onSessionNavigationOrder}
onPlaceSessionOnPane={onPlaceSessionOnPane}
onRenameSession={onRenameHistorySession}
onArchiveSession={onArchiveHistorySession}
Expand Down
20 changes: 18 additions & 2 deletions src/chrome/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import {
renameFolder,
reorderSessionFolders,
saveSessionFolders,
sessionListNavigationIds,
setFolderCollapsed,
setFolderColor,
setFolderCustomColor,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -236,6 +238,7 @@ function SidebarComponent({
status,
pending,
onSelectSession,
onSessionNavigationOrder,
onPrefetchSession,
onPlaceSessionOnPane,
onRenameSession,
Expand Down Expand Up @@ -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.
Expand All @@ -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[];
Expand Down
23 changes: 23 additions & 0 deletions src/lib/sessionFolders.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
removeSessionFromFolder,
renameFolder,
saveSessionFolders,
sessionListNavigationIds,
setFolderCollapsed,
setFolderColor,
setFolderCustomColor,
Expand Down Expand Up @@ -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", () => {
Expand Down
17 changes: 17 additions & 0 deletions src/lib/sessionFolders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[],
Expand Down
20 changes: 20 additions & 0 deletions src/lib/settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
DIFF_VIEWER_DEFAULT,
FOLLOW_UP_BEHAVIOR_DEFAULT,
GRID_ARCADE_ENABLED_DEFAULT,
KEYBINDINGS,
LIVE_AGENTS_ENABLED_DEFAULT,
loadComposerRunner,
loadDiffViewer,
Expand Down Expand Up @@ -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(() => {
Expand Down
20 changes: 20 additions & 0 deletions src/lib/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
{
Expand Down
38 changes: 38 additions & 0 deletions src/lib/tabKeys.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { describe, expect, it } from "vitest";
import {
adjacentItemId,
deferUnhandledEscape,
focusedBusyAgentSessionId,
shouldHandleListNavigation,
shouldStopFocusedTurnOnEscape,
tabCommand,
} from "./tabKeys";
Expand Down Expand Up @@ -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", () => {
Expand Down
Loading
Loading