authorgravatar for 62337003+Bil0000@users.noreply.github.comBilal Bakr <62337003+Bil0000@users.noreply.github.com> 2026-10-07 03:04:00+03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-07 00:04:00+00:00
log7202ba666080cfca1351c080d7aaa2d81bfa9a66
tree70fa7b51990a65e81193f113f8ee84b8a3a9d65c
parentab28039ef77ca8847c6bfe845c3502ff7e348c30
signature Signed by PGP key B5690EEEBB952194

feat(web): open right panel tab menu with Mod+T (#15686)

Co-authored-by: Julius Marminge <julius0216@outlook.com>

7 files changed, 272 insertions(+), 1 deletions(-)

apps/web/src/components/ChatView.tsx+5
...@@ -11603,6 +11603,8 @@ export default function ChatView(props: ChatViewProps) {...@@ -11603,6 +11603,8 @@ export default function ChatView(props: ChatViewProps) {
11603 <RightPanelTabs11603 <RightPanelTabs
11604 mode="inline"11604 mode="inline"
11605 open={rightPanelOpen}11605 open={rightPanelOpen}
11606 keybindings={keybindings}
11607 getShortcutContext={getShortcutContext}
11606 maximized={rightPanelMaximized}11608 maximized={rightPanelMaximized}
11607 inlineSize={previewPanelInlineSize}11609 inlineSize={previewPanelInlineSize}
11608 surfaces={renderedRightPanelSurfaces}11610 surfaces={renderedRightPanelSurfaces}
...@@ -11650,6 +11652,9 @@ export default function ChatView(props: ChatViewProps) {...@@ -11650,6 +11652,9 @@ export default function ChatView(props: ChatViewProps) {
11650 >11652 >
11651 <RightPanelTabs11653 <RightPanelTabs
11652 mode="sheet"11654 mode="sheet"
11655 open={rightPanelOpen}
11656 keybindings={keybindings}
11657 getShortcutContext={getShortcutContext}
11653 inlineSize={previewPanelInlineSize}11658 inlineSize={previewPanelInlineSize}
11654 // Same effective inset as the closed-state titlebar controls11659 // Same effective inset as the closed-state titlebar controls
11655 // (pr-3 in the tab bar plus this pixel equals the absolute11660 // (pr-3 in the tab bar plus this pixel equals the absolute
apps/web/src/components/RightPanelTabs.keyboard.test.tsx created+221
...@@ -0,0 +1,221 @@
1// @vitest-environment jsdom
2
3import { act, type ComponentProps } from "react";
4import { createRoot, type Root } from "react-dom/client";
5import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
6import {
7 compileResolvedKeybindingsConfig,
8 DEFAULT_RESOLVED_KEYBINDINGS,
9} from "@t3tools/shared/keybindings";
10
11import { RightPanelTabs } from "./RightPanelTabs";
12
13vi.mock("~/hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "light" }) }));
14vi.mock("~/browser/browserDefaults", () => ({ useBrowserDefaults: () => ({ profiles: [] }) }));
15
16let root: Root;
17let container: HTMLDivElement;
18const addFiles = vi.fn();
19const noop = () => undefined;
20
21beforeEach(() => {
22 vi.useFakeTimers();
23 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
24 vi.stubGlobal(
25 "ResizeObserver",
26 class {
27 observe() {}
28 disconnect() {}
29 unobserve() {}
30 },
31 );
32 Object.defineProperty(Element.prototype, "getAnimations", {
33 configurable: true,
34 value: () => [],
35 });
36 vi.spyOn(navigator, "platform", "get").mockReturnValue("MacIntel");
37 addFiles.mockClear();
38 container = document.createElement("div");
39 document.body.append(container);
40 root = createRoot(container);
41});
42
43afterEach(async () => {
44 await act(() => root.unmount());
45 container.remove();
46 vi.restoreAllMocks();
47 vi.unstubAllGlobals();
48 vi.useRealTimers();
49});
50
51async function renderPanel(overrides: Partial<ComponentProps<typeof RightPanelTabs>> = {}) {
52 await act(() =>
53 root.render(
54 <RightPanelTabs
55 mode="inline"
56 open
57 keybindings={DEFAULT_RESOLVED_KEYBINDINGS}
58 getShortcutContext={() => ({
59 terminalFocus: false,
60 terminalOpen: false,
61 previewFocus: false,
62 previewOpen: false,
63 isWeb: true,
64 isDesktop: false,
65 })}
66 surfaces={[]}
67 environmentId={null}
68 activeSurfaceId={null}
69 pendingSurfaceIds={new Set()}
70 previewSessions={{}}
71 desktopByTabId={{}}
72 terminalLabelsById={new Map()}
73 onActivate={noop}
74 onCloseSurface={noop}
75 onCloseOtherSurfaces={noop}
76 onCloseSurfacesToRight={noop}
77 onCloseAllSurfaces={noop}
78 onCopyFilePath={noop}
79 onAddBrowser={noop}
80 onAddBrowserInProfile={noop}
81 onAddTerminal={noop}
82 onAddDiff={noop}
83 onAddFiles={addFiles}
84 onAddPullRequest={noop}
85 onAddPullRequests={noop}
86 onAddDevice={noop}
87 browserAvailable={false}
88 terminalAvailable={false}
89 diffAvailable={false}
90 filesAvailable
91 pullRequestAvailable={false}
92 pullRequestsAvailable={false}
93 deviceAvailable={false}
94 {...overrides}
95 >
96 content
97 </RightPanelTabs>,
98 ),
99 );
100 await act(() => vi.advanceTimersByTimeAsync(0));
101 await act(() => vi.advanceTimersToNextFrame());
102}
103
104async function press(key: string, options: KeyboardEventInit = {}) {
105 const event = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true, ...options });
106 await act(() => (document.activeElement ?? document.body).dispatchEvent(event));
107 await act(() => vi.advanceTimersByTimeAsync(0));
108 await act(() => vi.advanceTimersToNextFrame());
109 return event;
110}
111
112describe("right panel new-tab shortcut", () => {
113 it.each(["MacIntel", "Win32", "Linux x86_64"])(
114 "opens the menu on %s and chooses a tab",
115 async (platform) => {
116 vi.spyOn(navigator, "platform", "get").mockReturnValue(platform);
117 await renderPanel();
118 const event = await press(
119 "t",
120 platform === "MacIntel" ? { metaKey: true } : { ctrlKey: true },
121 );
122 expect(event.defaultPrevented).toBe(true);
123 expect(document.querySelector('[role="menu"]')?.textContent).toContain(
124 "Linked pull requests",
125 );
126 expect(document.activeElement?.closest('[role="menu"]')).not.toBeNull();
127 expect(
128 (
129 await press("t", {
130 metaKey: platform === "MacIntel",
131 ctrlKey: platform !== "MacIntel",
132 repeat: true,
133 })
134 ).defaultPrevented,
135 ).toBe(true);
136 await press("f");
137 expect(addFiles).toHaveBeenCalledOnce();
138 expect(document.querySelector('[role="menu"]:not([data-closed])')).toBeNull();
139 },
140 );
141
142 it("leaves the shortcut alone while the mounted panel is closed", async () => {
143 await renderPanel({ open: false });
144 expect((await press("t", { metaKey: true })).defaultPrevented).toBe(false);
145 expect(document.querySelector('[role="menu"]:not([data-closed])')).toBeNull();
146 });
147
148 it.each(["Win32", "Linux x86_64"])(
149 "leaves Ctrl+T for the focused terminal on %s",
150 async (platform) => {
151 vi.spyOn(navigator, "platform", "get").mockReturnValue(platform);
152 await renderPanel({
153 getShortcutContext: () => ({
154 terminalFocus: true,
155 terminalOpen: true,
156 previewFocus: false,
157 previewOpen: false,
158 isWeb: true,
159 isDesktop: false,
160 }),
161 });
162 const terminal = document.createElement("textarea");
163 const onKeyDown = vi.fn();
164 terminal.addEventListener("keydown", onKeyDown);
165 container.append(terminal);
166 await act(() => terminal.focus());
167 const event = await press("t", { ctrlKey: true });
168 expect(event.defaultPrevented).toBe(false);
169 expect(onKeyDown).toHaveBeenCalledWith(event);
170 expect(document.querySelector('[role="menu"]:not([data-closed])')).toBeNull();
171 },
172 );
173
174 it("uses a custom binding from a text field and closes on Escape", async () => {
175 const input = document.createElement("input");
176 await renderPanel({
177 keybindings: compileResolvedKeybindingsConfig([{ key: "mod+y", command: "rightPanel.new" }]),
178 });
179 document.body.append(input);
180 input.focus();
181 expect((await press("t", { metaKey: true })).defaultPrevented).toBe(false);
182 await press("y", { metaKey: true });
183 expect(document.querySelector('[role="menu"]')).not.toBeNull();
184 await press("Escape");
185 expect(document.querySelector('[role="menu"]:not([data-closed])')).toBeNull();
186 expect(document.activeElement).toBe(
187 container.querySelector('[aria-label="Add panel surface"]'),
188 );
189 input.remove();
190 });
191
192 it("does not open over another popup or during text composition", async () => {
193 await renderPanel();
194 expect((await press("t", { metaKey: true, isComposing: true })).defaultPrevented).toBe(false);
195 const dialog = document.createElement("div");
196 dialog.dataset.slot = "dialog-popup";
197 container.append(dialog);
198 expect((await press("t", { metaKey: true })).defaultPrevented).toBe(false);
199 expect(document.querySelector('[role="menu"]')).toBeNull();
200 });
201
202 it("supports arrow keys with an existing tab in sheet mode and clears the menu on close", async () => {
203 const panel = {
204 mode: "sheet" as const,
205 surfaces: [{ id: "files" as const, kind: "files" as const }],
206 };
207 await renderPanel(panel);
208 await press("t", { metaKey: true });
209 await press("ArrowDown");
210 await press("ArrowDown");
211 await press("ArrowDown");
212 expect(document.activeElement?.textContent).toContain("Files");
213 await press("Enter");
214 expect(addFiles).toHaveBeenCalledOnce();
215 await press("t", { metaKey: true });
216 await renderPanel({ ...panel, open: false });
217 expect(document.querySelector('[role="menu"]:not([data-closed])')).toBeNull();
218 await renderPanel(panel);
219 expect(document.querySelector('[role="menu"]:not([data-closed])')).toBeNull();
220 });
221});
apps/web/src/components/RightPanelTabs.test.tsx+10
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import { EnvironmentId, type ThreadPullRequestLink } from "@t3tools/contracts";1import { EnvironmentId, type ThreadPullRequestLink } from "@t3tools/contracts";
2import type { DesktopPreviewFavicon, PreviewSessionSnapshot } from "@t3tools/contracts";2import type { DesktopPreviewFavicon, PreviewSessionSnapshot } from "@t3tools/contracts";
3import { renderToStaticMarkup } from "react-dom/server";3import { renderToStaticMarkup } from "react-dom/server";
4import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings";
4import { describe, expect, it } from "vite-plus/test";5import { describe, expect, it } from "vite-plus/test";
56
6import {7import {
...@@ -98,6 +99,15 @@ function renderTabs(...@@ -98,6 +99,15 @@ function renderTabs(
98 return renderToStaticMarkup(99 return renderToStaticMarkup(
99 <RightPanelTabs100 <RightPanelTabs
100 mode="inline"101 mode="inline"
102 keybindings={DEFAULT_RESOLVED_KEYBINDINGS}
103 getShortcutContext={() => ({
104 terminalFocus: false,
105 terminalOpen: false,
106 previewFocus: false,
107 previewOpen: false,
108 isWeb: true,
109 isDesktop: false,
110 })}
101 surfaces={second ? [previewSurface, secondSurface] : [previewSurface]}111 surfaces={second ? [previewSurface, secondSurface] : [previewSurface]}
102 environmentId={null}112 environmentId={null}
103 activeSurfaceId={previewSurface.id}113 activeSurfaceId={previewSurface.id}
apps/web/src/components/RightPanelTabs.tsx+32-1
...@@ -11,6 +11,7 @@ import type {...@@ -11,6 +11,7 @@ import type {
11 PreviewSessionSnapshot,11 PreviewSessionSnapshot,
12 ProjectId,12 ProjectId,
13 PullRequestState,13 PullRequestState,
14 ResolvedKeybindingsConfig,
14} from "@t3tools/contracts";15} from "@t3tools/contracts";
15import { getTerminalLabel } from "@t3tools/shared/terminalLabels";16import { getTerminalLabel } from "@t3tools/shared/terminalLabels";
16import {17import {
...@@ -32,6 +33,7 @@ import {...@@ -32,6 +33,7 @@ import {
32 type ReactNode,33 type ReactNode,
33 useCallback,34 useCallback,
34 useEffect,35 useEffect,
36 useEffectEvent,
35 useMemo,37 useMemo,
36 useRef,38 useRef,
37 useState,39 useState,
...@@ -41,6 +43,7 @@ import { isElectron } from "~/env";...@@ -41,6 +43,7 @@ import { isElectron } from "~/env";
41import type { DesktopPreviewOverlay } from "~/previewStateStore";43import type { DesktopPreviewOverlay } from "~/previewStateStore";
42import type { RightPanelSurface } from "~/rightPanelStore";44import type { RightPanelSurface } from "~/rightPanelStore";
43import { cn } from "~/lib/utils";45import { cn } from "~/lib/utils";
46import { resolveShortcutCommand, type ShortcutMatchContext } from "~/keybindings";
44import { readLocalApi } from "~/localApi";47import { readLocalApi } from "~/localApi";
45import { Button } from "~/components/ui/button";48import { Button } from "~/components/ui/button";
46import { MorphIcon } from "~/components/MorphIcon";49import { MorphIcon } from "~/components/MorphIcon";
...@@ -83,6 +86,8 @@ interface RightPanelTabsProps {...@@ -83,6 +86,8 @@ interface RightPanelTabsProps {
83 mode: PreviewPanelMode;86 mode: PreviewPanelMode;
84 maximized?: boolean;87 maximized?: boolean;
85 open?: boolean;88 open?: boolean;
89 keybindings: ResolvedKeybindingsConfig;
90 getShortcutContext: () => ShortcutMatchContext;
86 /** Forwarded to PreviewPanelShell so this surface persists its own width. */91 /** Forwarded to PreviewPanelShell so this surface persists its own width. */
87 widthStorageKey?: string;92 widthStorageKey?: string;
88 /** Forwarded to PreviewPanelShell as the initial width before a user resize. */93 /** Forwarded to PreviewPanelShell as the initial width before a user resize. */
...@@ -793,6 +798,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) {...@@ -793,6 +798,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
793 const browserProfiles = useBrowserDefaults().profiles;798 const browserProfiles = useBrowserDefaults().profiles;
794 const { resolvedTheme } = useTheme();799 const { resolvedTheme } = useTheme();
795 const tabListRef = useRef<HTMLDivElement>(null);800 const tabListRef = useRef<HTMLDivElement>(null);
801 const addSurfaceTriggerRef = useRef<HTMLButtonElement>(null);
796 const [renamingDevice, setRenamingDevice] = useState<string | null>(null);802 const [renamingDevice, setRenamingDevice] = useState<string | null>(null);
797 const [addSurfaceMenuOpen, setAddSurfaceMenuOpen] = useState(false);803 const [addSurfaceMenuOpen, setAddSurfaceMenuOpen] = useState(false);
798 const [tabScrollState, setTabScrollState] = useState({804 const [tabScrollState, setTabScrollState] = useState({
...@@ -801,6 +807,30 @@ export function RightPanelTabs(props: RightPanelTabsProps) {...@@ -801,6 +807,30 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
801 canScrollRight: false,807 canScrollRight: false,
802 });808 });
803809
810 if (props.open === false && addSurfaceMenuOpen) setAddSurfaceMenuOpen(false);
811
812 const onNewSurfaceKeyDown = useEffectEvent((event: KeyboardEvent) => {
813 if (event.defaultPrevented || event.isComposing) return;
814 if (
815 resolveShortcutCommand(event, props.keybindings, {
816 context: { ...props.getShortcutContext(), rightPanelOpen: true },
817 }) !== "rightPanel.new"
818 )
819 return;
820 if (!addSurfaceMenuOpen && document.querySelector(LAUNCHER_SHORTCUT_BLOCKING_LAYERS)) return;
821 event.preventDefault();
822 event.stopPropagation();
823 if (!event.repeat) {
824 addSurfaceTriggerRef.current?.focus();
825 setAddSurfaceMenuOpen(true);
826 }
827 });
828 useEffect(() => {
829 if (props.open === false) return;
830 document.addEventListener("keydown", onNewSurfaceKeyDown, true);
831 return () => document.removeEventListener("keydown", onNewSurfaceKeyDown, true);
832 }, [props.open]);
833
804 const updateTabScrollState = useCallback(() => {834 const updateTabScrollState = useCallback(() => {
805 const viewport = tabScrollViewport(tabListRef.current);835 const viewport = tabScrollViewport(tabListRef.current);
806 if (!viewport) return;836 if (!viewport) return;
...@@ -1214,9 +1244,10 @@ export function RightPanelTabs(props: RightPanelTabsProps) {...@@ -1214,9 +1244,10 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
1214 </div>1244 </div>
1215 );1245 );
1216 })}1246 })}
1217 {props.surfaces.length > 0 ? (1247 {props.open !== false ? (
1218 <Menu open={addSurfaceMenuOpen} onOpenChange={setAddSurfaceMenuOpen}>1248 <Menu open={addSurfaceMenuOpen} onOpenChange={setAddSurfaceMenuOpen}>
1219 <MenuTrigger1249 <MenuTrigger
1250 ref={addSurfaceTriggerRef}
1220 render={1251 render={
1221 <Button1252 <Button
1222 aria-label="Add panel surface"1253 aria-label="Add panel surface"
apps/web/src/routes/_chat.pull-requests.tsx+2
...@@ -2215,6 +2215,8 @@ function PullRequestsRouteView() {...@@ -2215,6 +2215,8 @@ function PullRequestsRouteView() {
2215 <RightPanelTabs2215 <RightPanelTabs
2216 mode="inline"2216 mode="inline"
2217 open={rightPanelState.isOpen}2217 open={rightPanelState.isOpen}
2218 keybindings={keybindings}
2219 getShortcutContext={getShortcutContext}
2218 widthStorageKey="t3code:pull-request-panel-width"2220 widthStorageKey="t3code:pull-request-panel-width"
2219 // Default to roughly half the viewport: the PR list needs more2221 // Default to roughly half the viewport: the PR list needs more
2220 // room than a chat, so the 540px chat-preview default squashes2222 // room than a chat, so the 540px chat-preview default squashes
packages/contracts/src/keybindings.ts+1
...@@ -66,6 +66,7 @@ export const STATIC_KEYBINDING_COMMANDS = [...@@ -66,6 +66,7 @@ export const STATIC_KEYBINDING_COMMANDS = [
66 "terminal.new",66 "terminal.new",
67 "terminal.close",67 "terminal.close",
68 "rightPanel.toggle",68 "rightPanel.toggle",
69 "rightPanel.new",
69 "threadPanel.toggle",70 "threadPanel.toggle",
70 "rightPanel.toggleMaximized",71 "rightPanel.toggleMaximized",
71 "rightPanel.close",72 "rightPanel.close",
packages/shared/src/keybindings.ts+1
...@@ -126,6 +126,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [...@@ -126,6 +126,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [
126 { key: "mod+]", command: "navigation.forward", when: "!terminalFocus" },126 { key: "mod+]", command: "navigation.forward", when: "!terminalFocus" },
127 { key: "mod+j", command: "terminal.toggle" },127 { key: "mod+j", command: "terminal.toggle" },
128 { key: "mod+alt+b", command: "rightPanel.toggle" },128 { key: "mod+alt+b", command: "rightPanel.toggle" },
129 { key: "mod+t", command: "rightPanel.new", when: "rightPanelOpen && !terminalFocus" },
129 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },130 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },
130 { key: "mod+shift+d", command: "terminal.splitVertical", when: "terminalFocus" },131 { key: "mod+shift+d", command: "terminal.splitVertical", when: "terminalFocus" },
131 { key: "mod+n", command: "terminal.new", when: "terminalFocus" },132 { key: "mod+n", command: "terminal.new", when: "terminalFocus" },