diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 32a8e309beb..5c2fb3d6d4c 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -480,6 +480,14 @@ function shouldTypeToFocusComposer(event: KeyboardEvent): boolean { if (eventPathContainsSelector(event, TYPE_TO_FOCUS_INTERACTIVE_SELECTOR)) return false; if (document.querySelector(TYPE_TO_FOCUS_FLOATING_LAYER_SELECTOR)) return false; + // The right-panel surface launcher claims its shortcut letters while it is + // visible (data attribute set in RightPanelTabs); those keys open surfaces + // instead of typing into the composer. + const launcherKeys = document + .querySelector("[data-surface-launcher-keys]") + ?.getAttribute("data-surface-launcher-keys"); + if (launcherKeys && launcherKeys.toLowerCase().includes(event.key.toLowerCase())) return false; + return true; } diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index 7eb07582413..32c66dca6ae 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -11,6 +11,7 @@ import { X, } from "lucide-react"; import { + type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type ReactElement, type ReactNode, @@ -25,6 +26,7 @@ import type { RightPanelSurface } from "~/rightPanelStore"; import { cn } from "~/lib/utils"; import { readLocalApi } from "~/localApi"; import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; +import { Kbd } from "~/components/ui/kbd"; import { Menu, MenuItem, MenuPopup, MenuTrigger } from "~/components/ui/menu"; import { ScrollArea } from "~/components/ui/scroll-area"; import { faviconUrlForOrigin } from "~/lib/favicon"; @@ -88,6 +90,28 @@ const SURFACE_DISABLED_REASONS = { agents: "Agents are only available from a thread.", } as const; +/** Overlays that must win over the launcher's letter shortcuts. */ +const LAUNCHER_SHORTCUT_BLOCKING_LAYERS = [ + '[data-slot="dialog-popup"]', + '[data-slot="alert-dialog-popup"]', + '[data-slot="command-dialog-popup"]', + '[data-slot="menu-popup"]', + '[data-slot="select-popup"]', + '[data-slot="popover-popup"]', + '[data-slot="combobox-popup"]', + '[data-slot="autocomplete-popup"]', +].join(","); + +/** One-line unavailability hints for the empty-state cards. */ +const SURFACE_UNAVAILABLE_HINTS = { + browser: "Only available in the desktop app.", + terminal: "Available when a project is open.", + files: "Available when a project is open.", + diff: "Available for Git repositories.", + pullRequest: "No pull request on this branch yet.", + agents: "Available from a thread.", +} as const; + type TabContextMenuAction = "copy-path" | "close" | "close-others" | "close-to-right" | "close-all"; function DisabledReasonTooltip(props: { reason: string; trigger: ReactElement }) { @@ -118,6 +142,13 @@ function SurfaceMenuItem(props: { return ; } +/** + * Card launcher shown when the right panel has no surfaces. Keyboard-first + * without palette chrome: a surface's letter opens it directly from anywhere + * outside a typing context, and arrows plus Enter work while the launcher is + * focused. The highlight only appears on hover or arrow use. Unavailable + * surfaces stay visible with a one-line reason. + */ function RightPanelEmptyState(props: { onAddBrowser: () => void; onAddTerminal: () => void; @@ -133,13 +164,17 @@ function RightPanelEmptyState(props: { agentsAvailable: boolean; liveAgentCount: number; }) { + // -1 means no highlight: it only appears on hover or arrow use. + const [highlight, setHighlight] = useState(-1); + const actions = [ { label: "Browser", description: "Open a local app or URL.", icon: Globe2, + shortcut: "B", available: props.browserAvailable, - disabledReason: SURFACE_DISABLED_REASONS.browser, + disabledReason: SURFACE_UNAVAILABLE_HINTS.browser, onClick: props.onAddBrowser, badgeCount: 0, }, @@ -147,8 +182,9 @@ function RightPanelEmptyState(props: { label: "Terminal", description: "Start a shell in this workspace.", icon: TerminalSquare, + shortcut: "T", available: props.terminalAvailable, - disabledReason: SURFACE_DISABLED_REASONS.terminal, + disabledReason: SURFACE_UNAVAILABLE_HINTS.terminal, onClick: props.onAddTerminal, badgeCount: 0, }, @@ -156,8 +192,9 @@ function RightPanelEmptyState(props: { label: "Files", description: "Browse and read workspace files.", icon: Files, + shortcut: "F", available: props.filesAvailable, - disabledReason: SURFACE_DISABLED_REASONS.files, + disabledReason: SURFACE_UNAVAILABLE_HINTS.files, onClick: props.onAddFiles, badgeCount: 0, }, @@ -165,91 +202,199 @@ function RightPanelEmptyState(props: { label: "Diff", description: "Review changes in this thread.", icon: FileDiff, + shortcut: "D", available: props.diffAvailable, - disabledReason: SURFACE_DISABLED_REASONS.diff, + disabledReason: SURFACE_UNAVAILABLE_HINTS.diff, onClick: props.onAddDiff, badgeCount: 0, }, { label: "Pull request", - description: "Open the pull request for this thread's branch.", + description: "Open this branch's pull request.", icon: GitPullRequest, + shortcut: "P", available: props.pullRequestAvailable, - disabledReason: SURFACE_DISABLED_REASONS.pullRequest, + disabledReason: SURFACE_UNAVAILABLE_HINTS.pullRequest, onClick: props.onAddPullRequest, badgeCount: 0, }, { label: "Agents", - description: "Watch subagents and workflows run.", + description: "Follow subagents and workflows.", icon: Bot, + shortcut: "A", available: props.agentsAvailable, - disabledReason: SURFACE_DISABLED_REASONS.agents, + disabledReason: SURFACE_UNAVAILABLE_HINTS.agents, onClick: props.onAddAgents, badgeCount: props.liveAgentCount, }, ] as const; + type SurfaceAction = (typeof actions)[number]; + + const availableActions = actions.filter((action) => action.available); + const highlightIndex = + availableActions.length === 0 ? -1 : Math.min(highlight, availableActions.length - 1); + + // Letter shortcuts work while the launcher is visible, not only while it + // is focused; focus moves around too easily (stray clicks) to carry them. + // Capture phase so app-level key handlers cannot swallow the event first; + // typing contexts and already-handled events are left alone. + const shortcutActionsRef = useRef(availableActions); + useEffect(() => { + shortcutActionsRef.current = availableActions; + }); + useEffect(() => { + const handler = (event: KeyboardEvent) => { + if (event.defaultPrevented || event.isComposing) return; + if (event.metaKey || event.ctrlKey || event.altKey) return; + if (document.querySelector(LAUNCHER_SHORTCUT_BLOCKING_LAYERS)) return; + const target = event.target; + if (target instanceof HTMLElement) { + if (target.closest("input, textarea, select")) return; + // An empty contenteditable (the chat composer at rest) does not + // count as typing; letters only become text once a draft exists. + const editable = target.isContentEditable ? target : target.closest("[contenteditable]"); + if (editable && (editable.textContent ?? "").trim().length > 0) return; + } + const action = shortcutActionsRef.current.find( + (candidate) => candidate.shortcut.toLowerCase() === event.key.toLowerCase(), + ); + if (!action) return; + event.preventDefault(); + event.stopPropagation(); + action.onClick(); + }; + window.addEventListener("keydown", handler, true); + return () => window.removeEventListener("keydown", handler, true); + }, []); + + const handleKeyDown = (event: ReactKeyboardEvent) => { + if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey) return; + if (availableActions.length === 0) return; + if (event.key === "ArrowDown" || event.key === "ArrowRight") { + event.preventDefault(); + setHighlight((highlightIndex + 1) % availableActions.length); + return; + } + if (event.key === "ArrowUp" || event.key === "ArrowLeft") { + event.preventDefault(); + setHighlight( + highlightIndex === -1 + ? availableActions.length - 1 + : (highlightIndex - 1 + availableActions.length) % availableActions.length, + ); + return; + } + if (event.key === "Enter") { + // A focused card button owns its own activation; only open from the + // highlight when the container itself has focus. + if (event.target instanceof HTMLElement && event.target.closest("button")) return; + const action = availableActions[highlightIndex]; + if (!action) return; + event.preventDefault(); + action.onClick(); + } + }; + + // Stable identity so React only runs this callback ref on mount/unmount; + // an inline arrow would re-attach and re-focus on every render. + const focusOnMount = useCallback((node: HTMLDivElement | null) => { + node?.focus(); + }, []); + + const isHighlighted = (action: SurfaceAction) => + highlightIndex !== -1 && availableActions[highlightIndex] === action; + + const actionIcon = (action: SurfaceAction, iconClassName = "size-4") => { + const Icon = action.icon; + return ( + + + {action.badgeCount > 0 ? ( + + {action.badgeCount} + + ) : null} + + ); + }; + + const cardShellClass = + "rounded-lg border border-border/80 bg-card dark:border-transparent dark:shadow-none dark:inset-ring-1 dark:inset-ring-white/5"; + const highlightedCardClass = "bg-accent/60 dark:inset-ring-white/20"; + return ( -
-
-
-

Open a surface

-

+

action.shortcut).join("")} + className={cn( + "flex min-h-0 flex-1 items-center justify-center overflow-y-auto px-6 pt-6 outline-none", + // The panel topbar sits above this container; matching bottom padding + // keeps the cards centered against the full panel, not the leftover. + "pb-[calc(var(--workspace-topbar-height)+--spacing(6))]", + )} + > +
+
+

Open a surface

+

Choose what to show in the right panel.

- {actions.map((action) => { - const Icon = action.icon; - const content = ( - <> - - - {action.badgeCount > 0 ? ( - - {action.badgeCount} - - ) : null} - - {action.label} - - {action.description} - - - ); - if (action.available) { - return ( - - ); - } - const disabledCard = ( + {actions.map((action) => + action.available ? ( - ); - return ( - - ); - })} + className={cn( + "relative flex w-full flex-col items-start p-4 opacity-40", + cardShellClass, + )} + > + {action.shortcut} + + {actionIcon(action)} + {action.label} + + + {action.disabledReason} + +
+ ), + )}