| ... | ... | @@ -31,7 +31,7 @@ import { readLocalApi } from "~/localApi"; |
| 31 | 31 | import { Button } from "~/components/ui/button"; |
| 32 | 32 | import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; |
| 33 | 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 | 35 | import { ScrollArea } from "~/components/ui/scroll-area"; |
| 36 | 36 | import { faviconUrlForOrigin } from "~/lib/favicon"; |
| 37 | 37 | import { useTheme } from "~/hooks/useTheme"; |
| ... | ... | @@ -173,6 +173,23 @@ function tabAudioState(overlay: DesktopPreviewOverlay | null): TabAudioState { |
| 173 | 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 | 193 | function DisabledReasonTooltip(props: { reason: string; trigger: ReactElement }) { |
| 177 | 194 | return ( |
| 178 | 195 | <Tooltip> |
| ... | ... | @@ -185,6 +202,7 @@ function DisabledReasonTooltip(props: { reason: string; trigger: ReactElement }) |
| 185 | 202 | function SurfaceMenuItem(props: { |
| 186 | 203 | available: boolean; |
| 187 | 204 | disabledReason?: string; |
| 205 | shortcut: string; |
| 188 | 206 | onClick: () => void; |
| 189 | 207 | children: ReactNode; |
| 190 | 208 | }) { |
| ... | ... | @@ -193,8 +211,10 @@ function SurfaceMenuItem(props: { |
| 193 | 211 | className={!props.available ? "data-disabled:pointer-events-auto" : undefined} |
| 194 | 212 | onClick={props.onClick} |
| 195 | 213 | disabled={!props.available} |
| 214 | aria-keyshortcuts={props.shortcut} |
| 196 | 215 | > |
| 197 | 216 | {props.children} |
| 217 | <MenuShortcut>{props.shortcut}</MenuShortcut> |
| 198 | 218 | </MenuItem> |
| 199 | 219 | ); |
| 200 | 220 | if (props.available || !props.disabledReason) return item; |
| ... | ... | @@ -305,8 +325,8 @@ function RightPanelEmptyState(props: { |
| 305 | 325 | }); |
| 306 | 326 | useEffect(() => { |
| 307 | 327 | const handler = (event: KeyboardEvent) => { |
| 308 | | if (event.defaultPrevented || event.isComposing) return; |
| 309 | | if (event.metaKey || event.ctrlKey || event.altKey) return; |
| 328 | const action = surfaceShortcutActionForKey(shortcutActionsRef.current, event); |
| 329 | if (!action) return; |
| 310 | 330 | if (document.querySelector(LAUNCHER_SHORTCUT_BLOCKING_LAYERS)) return; |
| 311 | 331 | const target = event.target; |
| 312 | 332 | if (target instanceof HTMLElement) { |
| ... | ... | @@ -316,10 +336,6 @@ function RightPanelEmptyState(props: { |
| 316 | 336 | const editable = target.isContentEditable ? target : target.closest("[contenteditable]"); |
| 317 | 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 | 339 | event.preventDefault(); |
| 324 | 340 | event.stopPropagation(); |
| 325 | 341 | action.onClick(); |
| ... | ... | @@ -573,6 +589,67 @@ export function RightPanelTabs(props: RightPanelTabsProps) { |
| 573 | 589 | const ownsDesktopTitleBar = isElectron && props.mode === "inline"; |
| 574 | 590 | const { resolvedTheme } = useTheme(); |
| 575 | 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 | 654 | const handleTabContextMenu = useCallback( |
| 578 | 655 | async (event: ReactMouseEvent, surface: RightPanelSurface) => { |
| ... | ... | @@ -804,7 +881,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { |
| 804 | 881 | ); |
| 805 | 882 | })} |
| 806 | 883 | {props.surfaces.length > 0 ? ( |
| 807 | | <Menu> |
| 884 | <Menu open={addSurfaceMenuOpen} onOpenChange={setAddSurfaceMenuOpen}> |
| 808 | 885 | <MenuTrigger |
| 809 | 886 | render={ |
| 810 | 887 | <Button |
| ... | ... | @@ -817,55 +894,28 @@ export function RightPanelTabs(props: RightPanelTabsProps) { |
| 817 | 894 | > |
| 818 | 895 | <Plus className="size-3.5" /> |
| 819 | 896 | </MenuTrigger> |
| 820 | | <MenuPopup align="start" side="bottom" sideOffset={6} className="min-w-44"> |
| 821 | | <SurfaceMenuItem |
| 822 | | available={props.browserAvailable} |
| 823 | | disabledReason={SURFACE_DISABLED_REASONS.browser} |
| 824 | | onClick={props.onAddBrowser} |
| 825 | | > |
| 826 | | <Globe2 /> |
| 827 | | Browser |
| 828 | | </SurfaceMenuItem> |
| 829 | | <SurfaceMenuItem |
| 830 | | available={props.terminalAvailable} |
| 831 | | disabledReason={SURFACE_DISABLED_REASONS.terminal} |
| 832 | | onClick={props.onAddTerminal} |
| 833 | | > |
| 834 | | <TerminalSquare /> |
| 835 | | Terminal |
| 836 | | </SurfaceMenuItem> |
| 837 | | <SurfaceMenuItem |
| 838 | | available={props.filesAvailable} |
| 839 | | disabledReason={SURFACE_DISABLED_REASONS.files} |
| 840 | | onClick={props.onAddFiles} |
| 841 | | > |
| 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> |
| 897 | <MenuPopup |
| 898 | align="start" |
| 899 | side="bottom" |
| 900 | sideOffset={6} |
| 901 | className="min-w-44" |
| 902 | onKeyDownCapture={handleAddSurfaceMenuKeyDown} |
| 903 | > |
| 904 | {addSurfaceActions.map((action) => { |
| 905 | const Icon = action.icon; |
| 906 | return ( |
| 907 | <SurfaceMenuItem |
| 908 | key={action.label} |
| 909 | available={action.available} |
| 910 | disabledReason={action.disabledReason} |
| 911 | shortcut={action.shortcut} |
| 912 | onClick={action.onClick} |
| 913 | > |
| 914 | <Icon /> |
| 915 | {action.label} |
| 916 | </SurfaceMenuItem> |
| 917 | ); |
| 918 | })} |
| 869 | 919 | </MenuPopup> |
| 870 | 920 | </Menu> |
| 871 | 921 | ) : null} |