| ... | @@ -31,7 +31,7 @@ import { readLocalApi } from "~/localApi"; | ... | @@ -31,7 +31,7 @@ import { readLocalApi } from "~/localApi"; |
| 31 | import { Button } from "~/components/ui/button"; | 31 | import { Button } from "~/components/ui/button"; |
| 32 | import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; | 32 | import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; |
| 33 | import { Kbd } from "~/components/ui/kbd"; | 33 | import { Kbd } from "~/components/ui/kbd"; |
| 34 | import { Menu, MenuItem, MenuPopup, MenuTrigger } from "~/components/ui/menu"; | 34 | import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "~/components/ui/menu"; |
| 35 | import { ScrollArea } from "~/components/ui/scroll-area"; | 35 | import { ScrollArea } from "~/components/ui/scroll-area"; |
| 36 | import { faviconUrlForOrigin } from "~/lib/favicon"; | 36 | import { faviconUrlForOrigin } from "~/lib/favicon"; |
| 37 | import { useTheme } from "~/hooks/useTheme"; | 37 | import { 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 | } |
| 175 | | 175 | |
| | 176 | type SurfaceShortcutEvent = Pick< |
| | 177 | KeyboardEvent, |
| | 178 | "altKey" | "ctrlKey" | "defaultPrevented" | "isComposing" | "key" | "metaKey" |
| | 179 | >; |
| | 180 | |
| | 181 | export 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 | |
| 176 | function DisabledReasonTooltip(props: { reason: string; trigger: ReactElement }) { | 193 | function 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 }) |
| 185 | function SurfaceMenuItem(props: { | 202 | function 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 | }; |
| 576 | | 653 | |
| 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 | <MenuTrigger | 885 | <MenuTrigger |
| 809 | render={ | 886 | render={ |
| 810 | <Button | 887 | <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 | <SurfaceMenuItem | 898 | 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 | Browser | 904 | {addSurfaceActions.map((action) => { |
| 828 | </SurfaceMenuItem> | 905 | const Icon = action.icon; |
| 829 | <SurfaceMenuItem | 906 | 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 | Terminal | 912 | onClick={action.onClick} |
| 836 | </SurfaceMenuItem> | 913 | > |
| 837 | <SurfaceMenuItem | 914 | <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} |