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) {
1160311603 <RightPanelTabs
1160411604 mode="inline"
1160511605 open={rightPanelOpen}
11606 keybindings={keybindings}
11607 getShortcutContext={getShortcutContext}
1160611608 maximized={rightPanelMaximized}
1160711609 inlineSize={previewPanelInlineSize}
1160811610 surfaces={renderedRightPanelSurfaces}
......@@ -11650,6 +11652,9 @@ export default function ChatView(props: ChatViewProps) {
1165011652 >
1165111653 <RightPanelTabs
1165211654 mode="sheet"
11655 open={rightPanelOpen}
11656 keybindings={keybindings}
11657 getShortcutContext={getShortcutContext}
1165311658 inlineSize={previewPanelInlineSize}
1165411659 // Same effective inset as the closed-state titlebar controls
1165511660 // (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 @@
11import { EnvironmentId, type ThreadPullRequestLink } from "@t3tools/contracts";
22import type { DesktopPreviewFavicon, PreviewSessionSnapshot } from "@t3tools/contracts";
33import { renderToStaticMarkup } from "react-dom/server";
4import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings";
45import { describe, expect, it } from "vite-plus/test";
56
67import {
......@@ -98,6 +99,15 @@ function renderTabs(
9899 return renderToStaticMarkup(
99100 <RightPanelTabs
100101 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 })}
101111 surfaces={second ? [previewSurface, secondSurface] : [previewSurface]}
102112 environmentId={null}
103113 activeSurfaceId={previewSurface.id}
apps/web/src/components/RightPanelTabs.tsx+32-1
......@@ -11,6 +11,7 @@ import type {
1111 PreviewSessionSnapshot,
1212 ProjectId,
1313 PullRequestState,
14 ResolvedKeybindingsConfig,
1415} from "@t3tools/contracts";
1516import { getTerminalLabel } from "@t3tools/shared/terminalLabels";
1617import {
......@@ -32,6 +33,7 @@ import {
3233 type ReactNode,
3334 useCallback,
3435 useEffect,
36 useEffectEvent,
3537 useMemo,
3638 useRef,
3739 useState,
......@@ -41,6 +43,7 @@ import { isElectron } from "~/env";
4143import type { DesktopPreviewOverlay } from "~/previewStateStore";
4244import type { RightPanelSurface } from "~/rightPanelStore";
4345import { cn } from "~/lib/utils";
46import { resolveShortcutCommand, type ShortcutMatchContext } from "~/keybindings";
4447import { readLocalApi } from "~/localApi";
4548import { Button } from "~/components/ui/button";
4649import { MorphIcon } from "~/components/MorphIcon";
......@@ -83,6 +86,8 @@ interface RightPanelTabsProps {
8386 mode: PreviewPanelMode;
8487 maximized?: boolean;
8588 open?: boolean;
89 keybindings: ResolvedKeybindingsConfig;
90 getShortcutContext: () => ShortcutMatchContext;
8691 /** Forwarded to PreviewPanelShell so this surface persists its own width. */
8792 widthStorageKey?: string;
8893 /** Forwarded to PreviewPanelShell as the initial width before a user resize. */
......@@ -793,6 +798,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
793798 const browserProfiles = useBrowserDefaults().profiles;
794799 const { resolvedTheme } = useTheme();
795800 const tabListRef = useRef<HTMLDivElement>(null);
801 const addSurfaceTriggerRef = useRef<HTMLButtonElement>(null);
796802 const [renamingDevice, setRenamingDevice] = useState<string | null>(null);
797803 const [addSurfaceMenuOpen, setAddSurfaceMenuOpen] = useState(false);
798804 const [tabScrollState, setTabScrollState] = useState({
......@@ -801,6 +807,30 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
801807 canScrollRight: false,
802808 });
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
804834 const updateTabScrollState = useCallback(() => {
805835 const viewport = tabScrollViewport(tabListRef.current);
806836 if (!viewport) return;
......@@ -1214,9 +1244,10 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
12141244 </div>
12151245 );
12161246 })}
1217 {props.surfaces.length > 0 ? (
1247 {props.open !== false ? (
12181248 <Menu open={addSurfaceMenuOpen} onOpenChange={setAddSurfaceMenuOpen}>
12191249 <MenuTrigger
1250 ref={addSurfaceTriggerRef}
12201251 render={
12211252 <Button
12221253 aria-label="Add panel surface"
apps/web/src/routes/_chat.pull-requests.tsx+2
......@@ -2215,6 +2215,8 @@ function PullRequestsRouteView() {
22152215 <RightPanelTabs
22162216 mode="inline"
22172217 open={rightPanelState.isOpen}
2218 keybindings={keybindings}
2219 getShortcutContext={getShortcutContext}
22182220 widthStorageKey="t3code:pull-request-panel-width"
22192221 // Default to roughly half the viewport: the PR list needs more
22202222 // 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 = [
6666 "terminal.new",
6767 "terminal.close",
6868 "rightPanel.toggle",
69 "rightPanel.new",
6970 "threadPanel.toggle",
7071 "rightPanel.toggleMaximized",
7172 "rightPanel.close",
packages/shared/src/keybindings.ts+1
......@@ -126,6 +126,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [
126126 { key: "mod+]", command: "navigation.forward", when: "!terminalFocus" },
127127 { key: "mod+j", command: "terminal.toggle" },
128128 { key: "mod+alt+b", command: "rightPanel.toggle" },
129 { key: "mod+t", command: "rightPanel.new", when: "rightPanelOpen && !terminalFocus" },
129130 { key: "mod+d", command: "terminal.split", when: "terminalFocus" },
130131 { key: "mod+shift+d", command: "terminal.splitVertical", when: "terminalFocus" },
131132 { key: "mod+n", command: "terminal.new", when: "terminalFocus" },