authorgravatar for gianmarcosimone89@gmail.comGianmarco <gianmarcosimone89@gmail.com> 2026-08-19 06:31:08+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-19 00:31:08-04:00
log3b8e7bbbe0c49b00630f0c89e931056df679a650
treefb5a8038ca81bdb2c0e12d8f40e0cf1efd67ad25
parent5ea5a80a83c21c50f97f0851a8bd2cb9871f413a
signature Signed by PGP key B5690EEEBB952194

feat(web): add shortcuts to the surface dropdown (#7318)


2 files changed, 149 insertions(+), 58 deletions(-)

apps/web/src/components/RightPanelTabs.test.tsx+42-1
...@@ -2,7 +2,22 @@ import type { DesktopPreviewFavicon, PreviewSessionSnapshot } from "@t3tools/con...@@ -2,7 +2,22 @@ import type { DesktopPreviewFavicon, PreviewSessionSnapshot } from "@t3tools/con
2import { renderToStaticMarkup } from "react-dom/server";2import { renderToStaticMarkup } from "react-dom/server";
3import { describe, expect, it } from "vite-plus/test";3import { describe, expect, it } from "vite-plus/test";
44
5import { RightPanelTabs, tabMuteMenuItem } from "./RightPanelTabs";5import { RightPanelTabs, surfaceShortcutActionForKey, tabMuteMenuItem } from "./RightPanelTabs";
6
7function shortcutEvent(
8 key: string,
9 overrides: Partial<Parameters<typeof surfaceShortcutActionForKey>[1]> = {},
10): Parameters<typeof surfaceShortcutActionForKey>[1] {
11 return {
12 key,
13 altKey: false,
14 ctrlKey: false,
15 defaultPrevented: false,
16 isComposing: false,
17 metaKey: false,
18 ...overrides,
19 };
20}
621
7const previewSurface = {22const previewSurface = {
8 id: "browser:tab-1" as const,23 id: "browser:tab-1" as const,
...@@ -125,6 +140,32 @@ describe("RightPanelTabs preview favicon", () => {...@@ -125,6 +140,32 @@ describe("RightPanelTabs preview favicon", () => {
125 });140 });
126});141});
127142
143describe("surface shortcuts", () => {
144 const actions = [
145 { shortcut: "B", available: true, label: "Browser" },
146 { shortcut: "D", available: false, label: "Diff" },
147 ] as const;
148
149 it("matches available surface shortcuts case-insensitively", () => {
150 expect(surfaceShortcutActionForKey(actions, shortcutEvent("b"))).toBe(actions[0]);
151 expect(surfaceShortcutActionForKey(actions, shortcutEvent("B"))).toBe(actions[0]);
152 });
153
154 it("does not activate unavailable surfaces", () => {
155 expect(surfaceShortcutActionForKey(actions, shortcutEvent("d"))).toBeNull();
156 });
157
158 it("leaves modified, composing, and already-handled key events alone", () => {
159 expect(surfaceShortcutActionForKey(actions, shortcutEvent("b", { metaKey: true }))).toBeNull();
160 expect(
161 surfaceShortcutActionForKey(actions, shortcutEvent("b", { isComposing: true })),
162 ).toBeNull();
163 expect(
164 surfaceShortcutActionForKey(actions, shortcutEvent("b", { defaultPrevented: true })),
165 ).toBeNull();
166 });
167});
168
128describe("RightPanelTabs audio indicator", () => {169describe("RightPanelTabs audio indicator", () => {
129 // A muted tab only shows the indicator while it is actually making sound:170 // A muted tab only shows the indicator while it is actually making sound:
130 // arming mute on a quiet tab is deliberate and stays invisible until there171 // arming mute on a quiet tab is deliberate and stays invisible until there
apps/web/src/components/RightPanelTabs.tsx+107-57
...@@ -31,7 +31,7 @@ import { readLocalApi } from "~/localApi";...@@ -31,7 +31,7 @@ import { readLocalApi } from "~/localApi";
31import { Button } from "~/components/ui/button";31import { Button } from "~/components/ui/button";
32import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";32import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";
33import { Kbd } from "~/components/ui/kbd";33import { Kbd } from "~/components/ui/kbd";
34import { Menu, MenuItem, MenuPopup, MenuTrigger } from "~/components/ui/menu";34import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "~/components/ui/menu";
35import { ScrollArea } from "~/components/ui/scroll-area";35import { ScrollArea } from "~/components/ui/scroll-area";
36import { faviconUrlForOrigin } from "~/lib/favicon";36import { faviconUrlForOrigin } from "~/lib/favicon";
37import { useTheme } from "~/hooks/useTheme";37import { useTheme } from "~/hooks/useTheme";
...@@ -173,6 +173,23 @@ function tabAudioState(overlay: DesktopPreviewOverlay | null): TabAudioState {...@@ -173,6 +173,23 @@ function tabAudioState(overlay: DesktopPreviewOverlay | null): TabAudioState {
173 return overlay.audioMuted ? "muted" : "audible";173 return overlay.audioMuted ? "muted" : "audible";
174}174}
175175
176type SurfaceShortcutEvent = Pick<
177 KeyboardEvent,
178 "altKey" | "ctrlKey" | "defaultPrevented" | "isComposing" | "key" | "metaKey"
179>;
180
181export function surfaceShortcutActionForKey<
182 const Action extends { available: boolean; shortcut: string },
183>(actions: readonly Action[], event: SurfaceShortcutEvent): Action | null {
184 if (event.defaultPrevented || event.isComposing) return null;
185 if (event.metaKey || event.ctrlKey || event.altKey) return null;
186 return (
187 actions.find(
188 (action) => action.available && action.shortcut.toLowerCase() === event.key.toLowerCase(),
189 ) ?? null
190 );
191}
192
176function DisabledReasonTooltip(props: { reason: string; trigger: ReactElement }) {193function DisabledReasonTooltip(props: { reason: string; trigger: ReactElement }) {
177 return (194 return (
178 <Tooltip>195 <Tooltip>
...@@ -185,6 +202,7 @@ function DisabledReasonTooltip(props: { reason: string; trigger: ReactElement })...@@ -185,6 +202,7 @@ function DisabledReasonTooltip(props: { reason: string; trigger: ReactElement })
185function SurfaceMenuItem(props: {202function SurfaceMenuItem(props: {
186 available: boolean;203 available: boolean;
187 disabledReason?: string;204 disabledReason?: string;
205 shortcut: string;
188 onClick: () => void;206 onClick: () => void;
189 children: ReactNode;207 children: ReactNode;
190}) {208}) {
...@@ -193,8 +211,10 @@ function SurfaceMenuItem(props: {...@@ -193,8 +211,10 @@ function SurfaceMenuItem(props: {
193 className={!props.available ? "data-disabled:pointer-events-auto" : undefined}211 className={!props.available ? "data-disabled:pointer-events-auto" : undefined}
194 onClick={props.onClick}212 onClick={props.onClick}
195 disabled={!props.available}213 disabled={!props.available}
214 aria-keyshortcuts={props.shortcut}
196 >215 >
197 {props.children}216 {props.children}
217 <MenuShortcut>{props.shortcut}</MenuShortcut>
198 </MenuItem>218 </MenuItem>
199 );219 );
200 if (props.available || !props.disabledReason) return item;220 if (props.available || !props.disabledReason) return item;
...@@ -305,8 +325,8 @@ function RightPanelEmptyState(props: {...@@ -305,8 +325,8 @@ function RightPanelEmptyState(props: {
305 });325 });
306 useEffect(() => {326 useEffect(() => {
307 const handler = (event: KeyboardEvent) => {327 const handler = (event: KeyboardEvent) => {
308 if (event.defaultPrevented || event.isComposing) return;328 const action = surfaceShortcutActionForKey(shortcutActionsRef.current, event);
309 if (event.metaKey || event.ctrlKey || event.altKey) return;329 if (!action) return;
310 if (document.querySelector(LAUNCHER_SHORTCUT_BLOCKING_LAYERS)) return;330 if (document.querySelector(LAUNCHER_SHORTCUT_BLOCKING_LAYERS)) return;
311 const target = event.target;331 const target = event.target;
312 if (target instanceof HTMLElement) {332 if (target instanceof HTMLElement) {
...@@ -316,10 +336,6 @@ function RightPanelEmptyState(props: {...@@ -316,10 +336,6 @@ function RightPanelEmptyState(props: {
316 const editable = target.isContentEditable ? target : target.closest("[contenteditable]");336 const editable = target.isContentEditable ? target : target.closest("[contenteditable]");
317 if (editable && (editable.textContent ?? "").trim().length > 0) return;337 if (editable && (editable.textContent ?? "").trim().length > 0) return;
318 }338 }
319 const action = shortcutActionsRef.current.find(
320 (candidate) => candidate.shortcut.toLowerCase() === event.key.toLowerCase(),
321 );
322 if (!action) return;
323 event.preventDefault();339 event.preventDefault();
324 event.stopPropagation();340 event.stopPropagation();
325 action.onClick();341 action.onClick();
...@@ -573,6 +589,67 @@ export function RightPanelTabs(props: RightPanelTabsProps) {...@@ -573,6 +589,67 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
573 const ownsDesktopTitleBar = isElectron && props.mode === "inline";589 const ownsDesktopTitleBar = isElectron && props.mode === "inline";
574 const { resolvedTheme } = useTheme();590 const { resolvedTheme } = useTheme();
575 const tabListRef = useRef<HTMLDivElement>(null);591 const tabListRef = useRef<HTMLDivElement>(null);
592 const [addSurfaceMenuOpen, setAddSurfaceMenuOpen] = useState(false);
593
594 const addSurfaceActions = [
595 {
596 label: "Browser",
597 icon: Globe2,
598 shortcut: "B",
599 available: props.browserAvailable,
600 disabledReason: SURFACE_DISABLED_REASONS.browser,
601 onClick: props.onAddBrowser,
602 },
603 {
604 label: "Terminal",
605 icon: TerminalSquare,
606 shortcut: "T",
607 available: props.terminalAvailable,
608 disabledReason: SURFACE_DISABLED_REASONS.terminal,
609 onClick: props.onAddTerminal,
610 },
611 {
612 label: "Files",
613 icon: Files,
614 shortcut: "F",
615 available: props.filesAvailable,
616 disabledReason: SURFACE_DISABLED_REASONS.files,
617 onClick: props.onAddFiles,
618 },
619 {
620 label: "Diff",
621 icon: FileDiff,
622 shortcut: "D",
623 available: props.diffAvailable,
624 disabledReason: SURFACE_DISABLED_REASONS.diff,
625 onClick: props.onAddDiff,
626 },
627 {
628 label: "Pull request",
629 icon: GitPullRequest,
630 shortcut: "P",
631 available: props.pullRequestAvailable,
632 disabledReason: SURFACE_DISABLED_REASONS.pullRequest,
633 onClick: props.onAddPullRequest,
634 },
635 {
636 label: "Agents",
637 icon: Bot,
638 shortcut: "A",
639 available: props.agentsAvailable,
640 disabledReason: SURFACE_DISABLED_REASONS.agents,
641 onClick: props.onAddAgents,
642 },
643 ] as const;
644
645 const handleAddSurfaceMenuKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
646 const action = surfaceShortcutActionForKey(addSurfaceActions, event.nativeEvent);
647 if (!action) return;
648 event.preventDefault();
649 event.stopPropagation();
650 setAddSurfaceMenuOpen(false);
651 action.onClick();
652 };
576653
577 const handleTabContextMenu = useCallback(654 const handleTabContextMenu = useCallback(
578 async (event: ReactMouseEvent, surface: RightPanelSurface) => {655 async (event: ReactMouseEvent, surface: RightPanelSurface) => {
...@@ -804,7 +881,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) {...@@ -804,7 +881,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
804 );881 );
805 })}882 })}
806 {props.surfaces.length > 0 ? (883 {props.surfaces.length > 0 ? (
807 <Menu>884 <Menu open={addSurfaceMenuOpen} onOpenChange={setAddSurfaceMenuOpen}>
808 <MenuTrigger885 <MenuTrigger
809 render={886 render={
810 <Button887 <Button
...@@ -817,55 +894,28 @@ export function RightPanelTabs(props: RightPanelTabsProps) {...@@ -817,55 +894,28 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
817 >894 >
818 <Plus className="size-3.5" />895 <Plus className="size-3.5" />
819 </MenuTrigger>896 </MenuTrigger>
820 <MenuPopup align="start" side="bottom" sideOffset={6} className="min-w-44">897 <MenuPopup
821 <SurfaceMenuItem898 align="start"
822 available={props.browserAvailable}899 side="bottom"
823 disabledReason={SURFACE_DISABLED_REASONS.browser}900 sideOffset={6}
824 onClick={props.onAddBrowser}901 className="min-w-44"
825 >902 onKeyDownCapture={handleAddSurfaceMenuKeyDown}
826 <Globe2 />903 >
827 Browser904 {addSurfaceActions.map((action) => {
828 </SurfaceMenuItem>905 const Icon = action.icon;
829 <SurfaceMenuItem906 return (
830 available={props.terminalAvailable}907 <SurfaceMenuItem
831 disabledReason={SURFACE_DISABLED_REASONS.terminal}908 key={action.label}
832 onClick={props.onAddTerminal}909 available={action.available}
833 >910 disabledReason={action.disabledReason}
834 <TerminalSquare />911 shortcut={action.shortcut}
835 Terminal912 onClick={action.onClick}
836 </SurfaceMenuItem>913 >
837 <SurfaceMenuItem914 <Icon />
838 available={props.filesAvailable}915 {action.label}
839 disabledReason={SURFACE_DISABLED_REASONS.files}916 </SurfaceMenuItem>
840 onClick={props.onAddFiles}917 );
841 >918 })}
842 <Files />
843 Files
844 </SurfaceMenuItem>
845 <SurfaceMenuItem
846 available={props.diffAvailable}
847 disabledReason={SURFACE_DISABLED_REASONS.diff}
848 onClick={props.onAddDiff}
849 >
850 <FileDiff />
851 Diff
852 </SurfaceMenuItem>
853 <SurfaceMenuItem
854 available={props.pullRequestAvailable}
855 disabledReason={SURFACE_DISABLED_REASONS.pullRequest}
856 onClick={props.onAddPullRequest}
857 >
858 <GitPullRequest />
859 Pull request
860 </SurfaceMenuItem>
861 <SurfaceMenuItem
862 available={props.agentsAvailable}
863 disabledReason={SURFACE_DISABLED_REASONS.agents}
864 onClick={props.onAddAgents}
865 >
866 <Bot />
867 Agents
868 </SurfaceMenuItem>
869 </MenuPopup>919 </MenuPopup>
870 </Menu>920 </Menu>
871 ) : null}921 ) : null}