authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-02 23:15:23-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-02 23:15:23-07:00
logd4bd8923ad8cab346a854967015b06d9e5cd77f7
tree5cb0e638ef084cc4c810ad081b1e2245584397d6
parentb5f4e8137b3cbd657fbe2b698f9db4e321acbc3d
signature Signed by PGP key B5690EEEBB952194

feat(web): mod+w closes the active right panel tab before the window (#9363)

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

7 files changed, 70 insertions(+), 0 deletions(-)

apps/server/src/keybindings.test.ts+1
...@@ -207,6 +207,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => {...@@ -207,6 +207,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => {
207 assert.equal(defaultsByCommand.get("sidebar.toggle"), "mod+b");207 assert.equal(defaultsByCommand.get("sidebar.toggle"), "mod+b");
208 assert.equal(defaultsByCommand.get("rightPanel.toggle"), "mod+alt+b");208 assert.equal(defaultsByCommand.get("rightPanel.toggle"), "mod+alt+b");
209 assert.isFalse(defaultsByCommand.has("rightPanel.toggleMaximized"));209 assert.isFalse(defaultsByCommand.has("rightPanel.toggleMaximized"));
210 assert.equal(defaultsByCommand.get("rightPanel.close"), "mod+w");
210 assert.equal(defaultsByCommand.get("terminal.splitVertical"), "mod+shift+d");211 assert.equal(defaultsByCommand.get("terminal.splitVertical"), "mod+shift+d");
211 assert.equal(defaultsByCommand.get("modelPicker.jump.1"), "mod+1");212 assert.equal(defaultsByCommand.get("modelPicker.jump.1"), "mod+1");
212 assert.equal(defaultsByCommand.get("modelPicker.jump.9"), "mod+9");213 assert.equal(defaultsByCommand.get("modelPicker.jump.9"), "mod+9");
apps/web/src/components/ChatView.tsx+11
...@@ -5691,6 +5691,16 @@ function ChatViewContent(props: ChatViewProps) {...@@ -5691,6 +5691,16 @@ function ChatViewContent(props: ChatViewProps) {
5691 return;5691 return;
5692 }5692 }
56935693
5694 if (command === "rightPanel.close") {
5695 // Nothing open: leave the event alone so the shortcut keeps its
5696 // native meaning (close window on desktop, close tab in a browser).
5697 if (!activeRightPanelSurface) return;
5698 event.preventDefault();
5699 event.stopPropagation();
5700 if (!event.repeat) closeRightPanelSurface(activeRightPanelSurface);
5701 return;
5702 }
5703
5694 if (command === "terminal.split") {5704 if (command === "terminal.split") {
5695 event.preventDefault();5705 event.preventDefault();
5696 event.stopPropagation();5706 event.stopPropagation();
...@@ -5779,6 +5789,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -5779,6 +5789,7 @@ function ChatViewContent(props: ChatViewProps) {
5779 terminalUiState.terminalOpen,5789 terminalUiState.terminalOpen,
5780 terminalUiState.activeTerminalId,5790 terminalUiState.activeTerminalId,
5781 activeThreadId,5791 activeThreadId,
5792 closeRightPanelSurface,
5782 requestCloseTerminal,5793 requestCloseTerminal,
5783 requestClosePanelTerminal,5794 requestClosePanelTerminal,
5784 createNewTerminal,5795 createNewTerminal,
apps/web/src/keybindings.test.ts+23
...@@ -108,6 +108,11 @@ const DEFAULT_BINDINGS = compile([...@@ -108,6 +108,11 @@ const DEFAULT_BINDINGS = compile([
108 command: "terminal.close",108 command: "terminal.close",
109 whenAst: whenIdentifier("terminalFocus"),109 whenAst: whenIdentifier("terminalFocus"),
110 },110 },
111 {
112 shortcut: modShortcut("w"),
113 command: "rightPanel.close",
114 whenAst: whenNot(whenIdentifier("terminalFocus")),
115 },
111 {116 {
112 shortcut: modShortcut("d"),117 shortcut: modShortcut("d"),
113 command: "diff.toggle",118 command: "diff.toggle",
...@@ -751,6 +756,24 @@ describe("resolveShortcutCommand", () => {...@@ -751,6 +756,24 @@ describe("resolveShortcutCommand", () => {
751 );756 );
752 });757 });
753758
759 it("routes mod+w to the terminal while focused and to the right panel otherwise", () => {
760 const closeEvent = event({ key: "w", metaKey: true });
761 assert.strictEqual(
762 resolveShortcutCommand(closeEvent, DEFAULT_BINDINGS, {
763 platform: "MacIntel",
764 context: { terminalFocus: true },
765 }),
766 "terminal.close",
767 );
768 assert.strictEqual(
769 resolveShortcutCommand(closeEvent, DEFAULT_BINDINGS, {
770 platform: "MacIntel",
771 context: { terminalFocus: false },
772 }),
773 "rightPanel.close",
774 );
775 });
776
754 it("resolves a custom right panel maximize binding", () => {777 it("resolves a custom right panel maximize binding", () => {
755 const keybindings = compile([778 const keybindings = compile([
756 {779 {
apps/web/src/routes/_chat.pull-requests.tsx+27
...@@ -11,6 +11,7 @@ import type {...@@ -11,6 +11,7 @@ import type {
11 PullRequestListState,11 PullRequestListState,
12 SourceControlProviderKind,12 SourceControlProviderKind,
13} from "@t3tools/contracts";13} from "@t3tools/contracts";
14import { useAtomValue } from "@effect/atom-react";
14import { createFileRoute, useNavigate } from "@tanstack/react-router";15import { createFileRoute, useNavigate } from "@tanstack/react-router";
15import {16import {
16 ArrowDownUpIcon,17 ArrowDownUpIcon,
...@@ -34,6 +35,7 @@ import {...@@ -34,6 +35,7 @@ import {
34import {35import {
35 useCallback,36 useCallback,
36 useEffect,37 useEffect,
38 useEffectEvent,
37 useMemo,39 useMemo,
38 useRef,40 useRef,
39 useState,41 useState,
...@@ -107,7 +109,10 @@ import {...@@ -107,7 +109,10 @@ import {
107} from "../components/WorkspaceBreadcrumb";109} from "../components/WorkspaceBreadcrumb";
108import { WorkspacePageContainer } from "../components/WorkspacePageContainer";110import { WorkspacePageContainer } from "../components/WorkspacePageContainer";
109import { WorkspacePageHeader } from "../components/WorkspacePageHeader";111import { WorkspacePageHeader } from "../components/WorkspacePageHeader";
112import { isCommandPaletteOpen } from "../commandPaletteBus";
110import { isElectron } from "../env";113import { isElectron } from "../env";
114import { resolveShortcutCommand } from "../keybindings";
115import { isTerminalFocused } from "../lib/terminalFocus";
111import { PanelLayoutControls } from "../components/chat/PanelLayoutControls";116import { PanelLayoutControls } from "../components/chat/PanelLayoutControls";
112import { Button } from "../components/ui/button";117import { Button } from "../components/ui/button";
113import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "../components/ui/menu";118import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "../components/ui/menu";
...@@ -135,6 +140,7 @@ import {...@@ -135,6 +140,7 @@ import {
135} from "../state/pullRequests";140} from "../state/pullRequests";
136import { useAtomCommand } from "../state/use-atom-command";141import { useAtomCommand } from "../state/use-atom-command";
137import { cn } from "~/lib/utils";142import { cn } from "~/lib/utils";
143import { primaryServerKeybindingsAtom } from "~/state/server";
138import { getSourceControlPresentationForKind } from "~/sourceControlPresentation";144import { getSourceControlPresentationForKind } from "~/sourceControlPresentation";
139145
140export interface PullRequestsSearch extends PullRequestListPreferences {146export interface PullRequestsSearch extends PullRequestListPreferences {
...@@ -285,6 +291,7 @@ function PullRequestsRouteView() {...@@ -285,6 +291,7 @@ function PullRequestsRouteView() {
285 const statsPolicy: PullRequestStatsPolicy =291 const statsPolicy: PullRequestStatsPolicy =
286 sort === "ready" || sort === "largest" || sort === "smallest" ? "eager" : "visible";292 sort === "ready" || sort === "largest" || sort === "smallest" ? "eager" : "visible";
287 const navigate = useNavigate({ from: Route.fullPath });293 const navigate = useNavigate({ from: Route.fullPath });
294 const keybindings = useAtomValue(primaryServerKeybindingsAtom);
288 const { environments } = useEnvironments();295 const { environments } = useEnvironments();
289 // Every connected environment that has said it can list pull requests. Sorted, so the query296 // Every connected environment that has said it can list pull requests. Sorted, so the query
290 // keys, the scope key and the stored snapshot all read the same whichever order the297 // keys, the scope key and the stored snapshot all read the same whichever order the
...@@ -1888,6 +1895,26 @@ function PullRequestsRouteView() {...@@ -1888,6 +1895,26 @@ function PullRequestsRouteView() {
1888 selectSurfaceInUrl(null);1895 selectSurfaceInUrl(null);
1889 };1896 };
18901897
1898 // This page has no ChatView, so the shared panel handles `rightPanel.close`
1899 // itself. With nothing open the event falls through to its native meaning.
1900 const closeActiveSurfaceFromShortcut = useEffectEvent((event: KeyboardEvent) => {
1901 if (activePullRequestSurface === null) return;
1902 event.preventDefault();
1903 event.stopPropagation();
1904 if (!event.repeat) closeSurface(activePullRequestSurface);
1905 });
1906 useEffect(() => {
1907 const onKeyDown = (event: KeyboardEvent) => {
1908 if (event.defaultPrevented || isCommandPaletteOpen()) return;
1909 const command = resolveShortcutCommand(event, keybindings, {
1910 context: { terminalFocus: isTerminalFocused() },
1911 });
1912 if (command === "rightPanel.close") closeActiveSurfaceFromShortcut(event);
1913 };
1914 window.addEventListener("keydown", onKeyDown);
1915 return () => window.removeEventListener("keydown", onKeyDown);
1916 }, [keybindings]);
1917
1891 return (1918 return (
1892 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground">1919 <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground">
1893 <div className="relative flex min-h-0 flex-1">1920 <div className="relative flex min-h-0 flex-1">
docs/user/keybindings.md+6
...@@ -52,6 +52,12 @@ successful pick; its hover glow and badge preview the element and color family t...@@ -52,6 +52,12 @@ successful pick; its hover glow and badge preview the element and color family t
52`rightPanel.toggleMaximized` maximizes or restores the open right panel. It has no default shortcut,52`rightPanel.toggleMaximized` maximizes or restores the open right panel. It has no default shortcut,
53so add one in **Settings** → **Keybindings** if you want to use it.53so add one in **Settings** → **Keybindings** if you want to use it.
5454
55`rightPanel.close` closes the active right panel tab and defaults to `mod+w`. Press it again to close
56the next tab. With the terminal focused, `mod+w` closes the terminal instead, and with nothing left
57to close it closes the desktop window as before. Browsers reserve `mod+w` for closing their own tab
58and never pass it to the page, so in a browser rebind this command (and `terminal.close`) to a
59shortcut the browser leaves alone, such as `alt+w`.
60
55`thread.copyReference` copies the active thread's pull request link, or its thread ID when no pull61`thread.copyReference` copies the active thread's pull request link, or its thread ID when no pull
56request is available. Its default shortcut is `mod+shift+c`, and it does not replace terminal copy62request is available. Its default shortcut is `mod+shift+c`, and it does not replace terminal copy
57while the terminal has focus.63while the terminal has focus.
packages/contracts/src/keybindings.ts+1
...@@ -59,6 +59,7 @@ export const STATIC_KEYBINDING_COMMANDS = [...@@ -59,6 +59,7 @@ export const STATIC_KEYBINDING_COMMANDS = [
59 "terminal.close",59 "terminal.close",
60 "rightPanel.toggle",60 "rightPanel.toggle",
61 "rightPanel.toggleMaximized",61 "rightPanel.toggleMaximized",
62 "rightPanel.close",
62 "diff.toggle",63 "diff.toggle",
63 "preview.toggle",64 "preview.toggle",
64 "preview.refresh",65 "preview.refresh",
packages/shared/src/keybindings.ts+1
...@@ -26,6 +26,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [...@@ -26,6 +26,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [
26 { key: "mod+shift+d", command: "terminal.splitVertical", when: "terminalFocus" },26 { key: "mod+shift+d", command: "terminal.splitVertical", when: "terminalFocus" },
27 { key: "mod+n", command: "terminal.new", when: "terminalFocus" },27 { key: "mod+n", command: "terminal.new", when: "terminalFocus" },
28 { key: "mod+w", command: "terminal.close", when: "terminalFocus" },28 { key: "mod+w", command: "terminal.close", when: "terminalFocus" },
29 { key: "mod+w", command: "rightPanel.close", when: "!terminalFocus" },
29 { key: "mod+d", command: "diff.toggle", when: "!terminalFocus" },30 { key: "mod+d", command: "diff.toggle", when: "!terminalFocus" },
30 { key: "mod+shift+j", command: "preview.toggle" },31 { key: "mod+shift+j", command: "preview.toggle" },
31 { key: "mod+r", command: "preview.refresh", when: "previewFocus" },32 { key: "mod+r", command: "preview.refresh", when: "previewFocus" },