authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-22 22:52:25-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-22 22:52:25-07:00
log829af7b73a960d390a0b32becd37ad6483627aa4
treedd46444fe7f24b26e5e04dc00721007674618dd9
parent23c7ab901afa66dc8e791a3873b600726a6ba8c7
signature Signed by PGP key B5690EEEBB952194

feat(web): navigate back and forward with mod+[ and mod+] (#13212)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

5 files changed, 85 insertions(+), 1 deletions(-)

apps/web/src/components/AppSidebarLayout.tsx+59-1
...@@ -7,7 +7,7 @@ import {...@@ -7,7 +7,7 @@ import {
7 type CSSProperties,7 type CSSProperties,
8 type ReactNode,8 type ReactNode,
9} from "react";9} from "react";
10import { useLocation, useNavigate } from "@tanstack/react-router";10import { useLocation, useNavigate, useParams } from "@tanstack/react-router";
1111
12import { isElectron } from "../env";12import { isElectron } from "../env";
13import { getLocalStorageItem, removeLocalStorageItem } from "../hooks/useLocalStorage";13import { getLocalStorageItem, removeLocalStorageItem } from "../hooks/useLocalStorage";
...@@ -16,6 +16,13 @@ import {...@@ -16,6 +16,13 @@ import {
16 resolveShortcutCommand,16 resolveShortcutCommand,
17 shortcutLabelForCommand,17 shortcutLabelForCommand,
18} from "../keybindings";18} from "../keybindings";
19import { isEditableFocused } from "../lib/editableFocus";
20import { isPreviewFocused } from "../lib/previewFocus";
21import { isTerminalFocused } from "../lib/terminalFocus";
22import { isModelPickerOpen } from "../modelPickerVisibility";
23import { selectActiveRightPanel, useRightPanelStore } from "../rightPanelStore";
24import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";
25import { resolveThreadRouteRef } from "../threadRoutes";
19import { cn, isMacPlatform } from "../lib/utils";26import { cn, isMacPlatform } from "../lib/utils";
20import { primaryServerKeybindingsAtom } from "../state/server";27import { primaryServerKeybindingsAtom } from "../state/server";
21import { useEnvironmentIdentificationMode, useLegacySidebarEnabled } from "../hooks/useSettings";28import { useEnvironmentIdentificationMode, useLegacySidebarEnabled } from "../hooks/useSettings";
...@@ -147,6 +154,56 @@ function SidebarControl() {...@@ -147,6 +154,56 @@ function SidebarControl() {
147 );154 );
148}155}
149156
157// Moves through the app's route history like a browser's back/forward buttons.
158function NavigationHistoryShortcuts() {
159 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
160 const routeThreadRef = useParams({
161 strict: false,
162 select: (params) => resolveThreadRouteRef(params),
163 });
164
165 useEffect(() => {
166 const onKeyDown = (event: KeyboardEvent) => {
167 if (event.defaultPrevented) return;
168 if (
169 event.target instanceof HTMLElement &&
170 event.target.closest("[data-keybinding-capture]")
171 ) {
172 return;
173 }
174 const command = resolveShortcutCommand(event, keybindings, {
175 context: {
176 terminalFocus: isTerminalFocused(),
177 terminalOpen: routeThreadRef
178 ? selectThreadTerminalUiState(
179 useTerminalUiStateStore.getState().terminalUiStateByThreadKey,
180 routeThreadRef,
181 ).terminalOpen
182 : false,
183 previewFocus: isPreviewFocused(),
184 previewOpen: routeThreadRef
185 ? selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, routeThreadRef) ===
186 "preview"
187 : false,
188 editableFocus: isEditableFocused(event.target),
189 modelPickerOpen: isModelPickerOpen(),
190 },
191 });
192 if (command !== "navigation.back" && command !== "navigation.forward") return;
193
194 event.preventDefault();
195 event.stopPropagation();
196 if (command === "navigation.back") window.history.back();
197 else window.history.forward();
198 };
199
200 window.addEventListener("keydown", onKeyDown);
201 return () => window.removeEventListener("keydown", onKeyDown);
202 }, [keybindings, routeThreadRef]);
203
204 return null;
205}
206
150// Settings swaps the thread sidebar out of the tree. Keep the lightweight207// Settings swaps the thread sidebar out of the tree. Keep the lightweight
151// project projection subscribed so returning to a draft never renders the208// project projection subscribed so returning to a draft never renders the
152// zero-project state while the environment snapshot reconnects.209// zero-project state while the environment snapshot reconnects.
...@@ -269,6 +326,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {...@@ -269,6 +326,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
269 </Sidebar>326 </Sidebar>
270 {children}327 {children}
271 <SidebarControl />328 <SidebarControl />
329 <NavigationHistoryShortcuts />
272 </SidebarProvider>330 </SidebarProvider>
273 </PanelAnimationSuppressionProvider>331 </PanelAnimationSuppressionProvider>
274 );332 );
apps/web/src/keybindings.test.ts+19
...@@ -913,6 +913,25 @@ describe("resolveShortcutCommand", () => {...@@ -913,6 +913,25 @@ describe("resolveShortcutCommand", () => {
913 );913 );
914 });914 });
915915
916 it("navigates history with mod+[ and mod+] outside the terminal", () => {
917 const back = event({ key: "[", code: "BracketLeft", metaKey: true });
918 const forward = event({ key: "]", code: "BracketRight", ctrlKey: true });
919 assert.strictEqual(
920 resolveShortcutCommand(back, DEFAULT_RESOLVED_KEYBINDINGS, { platform: "MacIntel" }),
921 "navigation.back",
922 );
923 assert.strictEqual(
924 resolveShortcutCommand(forward, DEFAULT_RESOLVED_KEYBINDINGS, { platform: "Linux" }),
925 "navigation.forward",
926 );
927 assert.isNull(
928 resolveShortcutCommand(back, DEFAULT_RESOLVED_KEYBINDINGS, {
929 platform: "MacIntel",
930 context: { terminalFocus: true },
931 }),
932 );
933 });
934
916 it("matches bracket shortcuts using the physical key code", () => {935 it("matches bracket shortcuts using the physical key code", () => {
917 assert.strictEqual(936 assert.strictEqual(
918 resolveShortcutCommand(937 resolveShortcutCommand(
docs/user/keybindings.md+3
...@@ -117,6 +117,9 @@ actions of the same kind undo together. The notice remains available for five...@@ -117,6 +117,9 @@ actions of the same kind undo together. The notice remains available for five
117seconds after the latest action. The default shortcut skips text fields and117seconds after the latest action. The default shortcut skips text fields and
118terminals so native undo keeps working there.118terminals so native undo keeps working there.
119119
120`navigation.back` (`mod+[` by default) and `navigation.forward` (`mod+]`) move
121through the pages you have visited, like a browser's back and forward buttons.
122
120`chat.new` may ask you to choose a project when there is more than one.123`chat.new` may ask you to choose a project when there is more than one.
121`chat.newLocal` skips that chooser. Both use your124`chat.newLocal` skips that chooser. Both use your
122[new-thread defaults](./thread-sidebar.md#start-a-thread).125[new-thread defaults](./thread-sidebar.md#start-a-thread).
packages/contracts/src/keybindings.ts+2
...@@ -57,6 +57,8 @@ export type ModelPickerKeybindingCommand = (typeof MODEL_PICKER_KEYBINDING_COMMA...@@ -57,6 +57,8 @@ export type ModelPickerKeybindingCommand = (typeof MODEL_PICKER_KEYBINDING_COMMA
5757
58export const STATIC_KEYBINDING_COMMANDS = [58export const STATIC_KEYBINDING_COMMANDS = [
59 "sidebar.toggle",59 "sidebar.toggle",
60 "navigation.back",
61 "navigation.forward",
60 "terminal.toggle",62 "terminal.toggle",
61 "terminal.split",63 "terminal.split",
62 "terminal.splitVertical",64 "terminal.splitVertical",
packages/shared/src/keybindings.ts+2
...@@ -20,6 +20,8 @@ type WhenToken =...@@ -20,6 +20,8 @@ type WhenToken =
2020
21export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [21export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [
22 { key: "mod+b", command: "sidebar.toggle" },22 { key: "mod+b", command: "sidebar.toggle" },
23 { key: "mod+[", command: "navigation.back", when: "!terminalFocus" },
24 { key: "mod+]", command: "navigation.forward", when: "!terminalFocus" },
23 { key: "mod+j", command: "terminal.toggle" },25 { key: "mod+j", command: "terminal.toggle" },
24 { key: "mod+alt+b", command: "rightPanel.toggle" },26 { key: "mod+alt+b", command: "rightPanel.toggle" },
25 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },27 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },