diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 1b67af90ef3cdd09114c338ad20bb1d66ad0a9af..eccf3ef21355f57dc2f7dea5563873ca6d81c0b5 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -7,7 +7,7 @@ import { type CSSProperties, type ReactNode, } from "react"; -import { useLocation, useNavigate } from "@tanstack/react-router"; +import { useLocation, useNavigate, useParams } from "@tanstack/react-router"; import { isElectron } from "../env"; import { getLocalStorageItem, removeLocalStorageItem } from "../hooks/useLocalStorage"; @@ -16,6 +16,13 @@ import { resolveShortcutCommand, shortcutLabelForCommand, } from "../keybindings"; +import { isEditableFocused } from "../lib/editableFocus"; +import { isPreviewFocused } from "../lib/previewFocus"; +import { isTerminalFocused } from "../lib/terminalFocus"; +import { isModelPickerOpen } from "../modelPickerVisibility"; +import { selectActiveRightPanel, useRightPanelStore } from "../rightPanelStore"; +import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; +import { resolveThreadRouteRef } from "../threadRoutes"; import { cn, isMacPlatform } from "../lib/utils"; import { primaryServerKeybindingsAtom } from "../state/server"; import { useEnvironmentIdentificationMode, useLegacySidebarEnabled } from "../hooks/useSettings"; @@ -147,6 +154,56 @@ function SidebarControl() { ); } +// Moves through the app's route history like a browser's back/forward buttons. +function NavigationHistoryShortcuts() { + const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const routeThreadRef = useParams({ + strict: false, + select: (params) => resolveThreadRouteRef(params), + }); + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented) return; + if ( + event.target instanceof HTMLElement && + event.target.closest("[data-keybinding-capture]") + ) { + return; + } + const command = resolveShortcutCommand(event, keybindings, { + context: { + terminalFocus: isTerminalFocused(), + terminalOpen: routeThreadRef + ? selectThreadTerminalUiState( + useTerminalUiStateStore.getState().terminalUiStateByThreadKey, + routeThreadRef, + ).terminalOpen + : false, + previewFocus: isPreviewFocused(), + previewOpen: routeThreadRef + ? selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, routeThreadRef) === + "preview" + : false, + editableFocus: isEditableFocused(event.target), + modelPickerOpen: isModelPickerOpen(), + }, + }); + if (command !== "navigation.back" && command !== "navigation.forward") return; + + event.preventDefault(); + event.stopPropagation(); + if (command === "navigation.back") window.history.back(); + else window.history.forward(); + }; + + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [keybindings, routeThreadRef]); + + return null; +} + // Settings swaps the thread sidebar out of the tree. Keep the lightweight // project projection subscribed so returning to a draft never renders the // zero-project state while the environment snapshot reconnects. @@ -269,6 +326,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { {children} + ); diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index 82348be3b6e496133442fe710e0a324a553abc80..2d9da2e5ebc1f14b2d94fd227291bc67c6813bf7 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -913,6 +913,25 @@ describe("resolveShortcutCommand", () => { ); }); + it("navigates history with mod+[ and mod+] outside the terminal", () => { + const back = event({ key: "[", code: "BracketLeft", metaKey: true }); + const forward = event({ key: "]", code: "BracketRight", ctrlKey: true }); + assert.strictEqual( + resolveShortcutCommand(back, DEFAULT_RESOLVED_KEYBINDINGS, { platform: "MacIntel" }), + "navigation.back", + ); + assert.strictEqual( + resolveShortcutCommand(forward, DEFAULT_RESOLVED_KEYBINDINGS, { platform: "Linux" }), + "navigation.forward", + ); + assert.isNull( + resolveShortcutCommand(back, DEFAULT_RESOLVED_KEYBINDINGS, { + platform: "MacIntel", + context: { terminalFocus: true }, + }), + ); + }); + it("matches bracket shortcuts using the physical key code", () => { assert.strictEqual( resolveShortcutCommand( diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md index 548b03fd1a73d20b02dcd47c5dafc830e499b0b7..ad1b157bbb75229db0c00804e2365ddb16ab1570 100644 --- a/docs/user/keybindings.md +++ b/docs/user/keybindings.md @@ -117,6 +117,9 @@ actions of the same kind undo together. The notice remains available for five seconds after the latest action. The default shortcut skips text fields and terminals so native undo keeps working there. +`navigation.back` (`mod+[` by default) and `navigation.forward` (`mod+]`) move +through the pages you have visited, like a browser's back and forward buttons. + `chat.new` may ask you to choose a project when there is more than one. `chat.newLocal` skips that chooser. Both use your [new-thread defaults](./thread-sidebar.md#start-a-thread). diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index 5093ba9a25adcc850b3d2b5aedfe177e7a81ca0d..75dc891959696d7739e1bf69c4d4a463432e997e 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -57,6 +57,8 @@ export type ModelPickerKeybindingCommand = (typeof MODEL_PICKER_KEYBINDING_COMMA export const STATIC_KEYBINDING_COMMANDS = [ "sidebar.toggle", + "navigation.back", + "navigation.forward", "terminal.toggle", "terminal.split", "terminal.splitVertical", diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index ee159b6838a3dfd6aa21f2b4ee8092ef80f1bfeb..6f1071767ae03c6b0bd1082783dccfc47eca5472 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -20,6 +20,8 @@ type WhenToken = export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+b", command: "sidebar.toggle" }, + { key: "mod+[", command: "navigation.back", when: "!terminalFocus" }, + { key: "mod+]", command: "navigation.forward", when: "!terminalFocus" }, { key: "mod+j", command: "terminal.toggle" }, { key: "mod+alt+b", command: "rightPanel.toggle" }, { key: "mod+d", command: "terminal.split", when: "terminalFocus" },