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 {
77 type CSSProperties,
88 type ReactNode,
99} from "react";
10import { useLocation, useNavigate } from "@tanstack/react-router";
10import { useLocation, useNavigate, useParams } from "@tanstack/react-router";
1111
1212import { isElectron } from "../env";
1313import { getLocalStorageItem, removeLocalStorageItem } from "../hooks/useLocalStorage";
......@@ -16,6 +16,13 @@ import {
1616 resolveShortcutCommand,
1717 shortcutLabelForCommand,
1818} 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";
1926import { cn, isMacPlatform } from "../lib/utils";
2027import { primaryServerKeybindingsAtom } from "../state/server";
2128import { useEnvironmentIdentificationMode, useLegacySidebarEnabled } from "../hooks/useSettings";
......@@ -147,6 +154,56 @@ function SidebarControl() {
147154 );
148155}
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
150207// Settings swaps the thread sidebar out of the tree. Keep the lightweight
151208// project projection subscribed so returning to a draft never renders the
152209// zero-project state while the environment snapshot reconnects.
......@@ -269,6 +326,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
269326 </Sidebar>
270327 {children}
271328 <SidebarControl />
329 <NavigationHistoryShortcuts />
272330 </SidebarProvider>
273331 </PanelAnimationSuppressionProvider>
274332 );
apps/web/src/keybindings.test.ts+19
......@@ -913,6 +913,25 @@ describe("resolveShortcutCommand", () => {
913913 );
914914 });
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
916935 it("matches bracket shortcuts using the physical key code", () => {
917936 assert.strictEqual(
918937 resolveShortcutCommand(
docs/user/keybindings.md+3
......@@ -117,6 +117,9 @@ actions of the same kind undo together. The notice remains available for five
117117seconds after the latest action. The default shortcut skips text fields and
118118terminals 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
120123`chat.new` may ask you to choose a project when there is more than one.
121124`chat.newLocal` skips that chooser. Both use your
122125[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
5757
5858export const STATIC_KEYBINDING_COMMANDS = [
5959 "sidebar.toggle",
60 "navigation.back",
61 "navigation.forward",
6062 "terminal.toggle",
6163 "terminal.split",
6264 "terminal.splitVertical",
packages/shared/src/keybindings.ts+2
......@@ -20,6 +20,8 @@ type WhenToken =
2020
2121export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [
2222 { key: "mod+b", command: "sidebar.toggle" },
23 { key: "mod+[", command: "navigation.back", when: "!terminalFocus" },
24 { key: "mod+]", command: "navigation.forward", when: "!terminalFocus" },
2325 { key: "mod+j", command: "terminal.toggle" },
2426 { key: "mod+alt+b", command: "rightPanel.toggle" },
2527 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },