import { pullRequestHostOf, type SourceControlProviderKind } from "@t3tools/contracts";import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";import { useProjects, useServerConfigs, useThreadShells } from "~/state/entities";import { threadPullRequestKeysEqual, visibleThreadPullRequests,} from "@t3tools/shared/threadPullRequests";import type { ContextMenuItem, EnvironmentId, PreviewSessionSnapshot, ProjectId, PullRequestState,} from "@t3tools/contracts";import { getTerminalLabel } from "@t3tools/shared/terminalLabels";import { Bot, Smartphone, ChevronDown, ChevronLeft, ChevronRight, FileDiff, Files, GitPullRequest, GitPullRequestArrow, Globe2, Plus, TerminalSquare, Volume2, VolumeOff,} from "lucide-react";import { type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type ReactElement, type ReactNode, useCallback, useEffect, useRef, useState,} from "react"; import { isElectron } from "~/env";import type { DesktopPreviewOverlay } from "~/previewStateStore";import type { RightPanelSurface } from "~/rightPanelStore";import { cn } from "~/lib/utils";import { readLocalApi } from "~/localApi";import { Button } from "~/components/ui/button";import { AndroidIcon, AppleIcon } from "~/components/Icons";import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";import { Kbd } from "~/components/ui/kbd";import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuSub, MenuSubPopup, MenuSubTrigger, MenuTrigger,} from "~/components/ui/menu";import { useBrowserDefaults } from "~/browser/browserDefaults";import { ScrollArea } from "~/components/ui/scroll-area";import { PanelTabCloseButton } from "~/components/ui/panel-tab-close-button";import { faviconUrlForOrigin } from "~/lib/favicon";import { useTheme } from "~/hooks/useTheme";import { pullRequestEnvironment } from "~/state/pullRequests";import { useEnvironmentQuery } from "~/state/query";import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; import { PreviewPanelShell, type PreviewPanelMode } from "./preview/PreviewPanelShell";import { FaviconImage } from "./preview/PreviewFaviconIcon";import { previewBridge } from "./preview/previewBridge";import { PierreEntryIcon } from "./chat/PierreEntryIcon";import { resolvePullRequestState } from "./pullRequest/pullRequestPresentation"; interface RightPanelTabsProps { mode: PreviewPanelMode; maximized?: boolean; open?: boolean; /** Forwarded to PreviewPanelShell so this surface persists its own width. */ widthStorageKey?: string; /** Forwarded to PreviewPanelShell as the initial width before a user resize. */ defaultWidth?: number; layoutControls?: ReactNode; surfaces: readonly RightPanelSurface[]; /** Fallback environment for surfaces that do not carry their own. */ environmentId: EnvironmentId | null; activeSurfaceId: string | null; pendingSurfaceIds: ReadonlySet<string>; previewSessions: Readonly<Record<string, PreviewSessionSnapshot>>; desktopByTabId: Readonly<Record<string, DesktopPreviewOverlay>>; /** * Maps a server session tab id to the desktop runtime tab id the Electron * preview manager is keyed by. Session ids are only unique within one server * process, so desktop operations must not be addressed with them. */ previewRuntimeTabId?: ((tabId: string) => string) | undefined; terminalLabelsById: ReadonlyMap<string, string>; onActivate: (surface: RightPanelSurface) => void; onRenameDevice?: (surfaceId: string, title: string) => void; onCloseSurface: (surface: RightPanelSurface) => void; onCloseOtherSurfaces: (surface: RightPanelSurface) => void; onCloseSurfacesToRight: (surface: RightPanelSurface) => void; onCloseAllSurfaces: () => void; onCopyFilePath: (relativePath: string) => void; onAddBrowser: () => void; /** * Separate from `onAddBrowser` on purpose: that one is passed directly as a * DOM click handler, and a `(profileId?: string)` signature would silently * accept the MouseEvent as a profile id. */ onAddBrowserInProfile: (profileId: string) => void; onAddTerminal: () => void; onAddDiff: () => void; onAddFiles: () => void; onAddPullRequest: () => void; onAddPullRequests: () => void; onAddAgents: () => void; onAddDevice: () => void; browserAvailable: boolean; terminalAvailable: boolean; diffAvailable: boolean; filesAvailable: boolean; pullRequestAvailable: boolean; pullRequestsAvailable: boolean; agentsAvailable: boolean; deviceAvailable: boolean; pullRequestStatusSeeds?: Readonly<Record<string, PullRequestTabStatusSeed>>; /** Running + waiting subagents; badges the Agents card in the empty state. */ liveAgentCount: number; children: ReactNode;} export interface PullRequestTabStatus { projectId: string; repository: string; number: number; state: PullRequestState; isDraft: boolean;} export type PullRequestTabStatusSeed = Pick<PullRequestTabStatus, "state" | "isDraft">; export function shouldOpenDefaultBrowserProfileFromMenuClick( pointerType: string | undefined,): boolean { return pointerType !== "touch";} const SURFACE_DISABLED_REASONS = { browser: "Browser previews are only available in the T3 Code desktop app.", terminal: "Terminal surfaces are only available from a project thread.", files: "Files are only available when a project is open.", diff: "Diff is only available for server threads in Git repositories.", pullRequest: "This thread's branch has no pull request yet.", pullRequests: "Linked pull requests are only available for server threads.", agents: "Agents are only available from a thread.", device: "Devices 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 rows. */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.", pullRequests: "Available for server threads.", agents: "Available from a thread.", device: "Available from a thread.",} as const; type TabContextMenuAction = | "rename" | "copy-path" | "toggle-mute" | "close" | "close-others" | "close-to-right" | "close-all"; const TAB_SCROLL_EDGE_TOLERANCE = 1; function tabScrollViewport(root: HTMLDivElement | null): HTMLDivElement | null { return root?.querySelector<HTMLDivElement>('[data-slot="scroll-area-viewport"]') ?? null;} /** * Desktop preview tab backing a surface, or null for non-preview surfaces, the * "new browser tab" placeholder, and the web build where no desktop tab exists. */function previewTabIdOf( surface: RightPanelSurface, sessions: Readonly<Record<string, PreviewSessionSnapshot>>,): string | null { if (surface.kind !== "preview" || !surface.resourceId) return null; return sessions[surface.resourceId]?.tabId ?? null;} /** * Label and enabled state for a preview tab's mute menu entry. * Stays disabled until desktop overlay state arrives: a server session id can * resolve while the preview manager's createTab is still in flight, and muting * then fails with a PreviewTabNotFoundError nothing surfaces to the user. */export function tabMuteMenuItem(input: { overlay: DesktopPreviewOverlay | null; canResolveRuntimeTabId: boolean;}): { label: string; disabled: boolean } { const muted = input.overlay?.audioMuted ?? false; return { label: muted ? "Unmute tab" : "Mute tab", disabled: input.overlay === null || !input.canResolveRuntimeTabId, };} type TabAudioState = "none" | "audible" | "muted"; /** * A muted tab that is not making sound shows nothing: mute is armed silently, * and the indicator only appears once there is audio to speak of. */function tabAudioState(overlay: DesktopPreviewOverlay | null): TabAudioState { if (!overlay?.audible) return "none"; return overlay.audioMuted ? "muted" : "audible";} type SurfaceShortcutEvent = Pick< KeyboardEvent, "altKey" | "ctrlKey" | "defaultPrevented" | "isComposing" | "key" | "metaKey">; export function surfaceShortcutActionForKey< const Action extends { available: boolean; shortcut: string },>(actions: readonly Action[], event: SurfaceShortcutEvent): Action | null { if (event.defaultPrevented || event.isComposing) return null; if (event.metaKey || event.ctrlKey || event.altKey) return null; return ( actions.find( (action) => action.available && action.shortcut.toLowerCase() === event.key.toLowerCase(), ) ?? null );} /** * A focused editable is a typing context whether or not it has text yet: an * empty chat composer at rest is still where the user's next keystrokes are * meant to land, and claiming launcher letters from it would redirect prompts * into whatever surface opens. The `:not` clause lets `closest` see past * non-editable islands (`contenteditable="false"`) to an editable host around * them, matching ComposerPendingUserInputPanel's typing guard. */export function surfaceShortcutTargetsTypingContext( target: { closest(selectors: string): unknown } | null,): boolean { return ( target?.closest('input, textarea, select, [contenteditable]:not([contenteditable="false"])') != null );} function DisabledReasonTooltip(props: { reason: string; trigger: ReactElement }) { return ( <Tooltip> <TooltipTrigger render={props.trigger} /> <TooltipPopup side="top">{props.reason}</TooltipPopup> </Tooltip> );} function SurfaceMenuItem(props: { available: boolean; disabledReason?: string; shortcut: string; onClick: () => void; children: ReactNode;}) { const item = ( <MenuItem className={!props.available ? "data-disabled:pointer-events-auto" : undefined} onClick={props.onClick} disabled={!props.available} aria-keyshortcuts={props.shortcut} > {props.children} <MenuShortcut>{props.shortcut}</MenuShortcut> </MenuItem> ); if (props.available || !props.disabledReason) return item; return <DisabledReasonTooltip reason={props.disabledReason} trigger={item} />;} /** * List 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; onAddBrowserInProfile: (profileId: string) => void; browserProfiles: ReadonlyArray<{ readonly id: string; readonly name: string }>; onAddTerminal: () => void; onAddDiff: () => void; onAddFiles: () => void; onAddPullRequest: () => void; onAddPullRequests: () => void; onAddAgents: () => void; onAddDevice: () => void; browserAvailable: boolean; terminalAvailable: boolean; diffAvailable: boolean; filesAvailable: boolean; pullRequestAvailable: boolean; pullRequestsAvailable: boolean; agentsAvailable: boolean; deviceAvailable: boolean; liveAgentCount: number;}) { // -1 means no highlight: it only appears on hover or arrow use. const [highlight, setHighlight] = useState(-1); const actions = [ { label: "Browser", icon: Globe2, shortcut: "B", available: props.browserAvailable, disabledReason: SURFACE_UNAVAILABLE_HINTS.browser, onClick: props.onAddBrowser, badgeCount: 0, }, { label: "Terminal", icon: TerminalSquare, shortcut: "T", available: props.terminalAvailable, disabledReason: SURFACE_UNAVAILABLE_HINTS.terminal, onClick: props.onAddTerminal, badgeCount: 0, }, { label: "Files", icon: Files, shortcut: "F", available: props.filesAvailable, disabledReason: SURFACE_UNAVAILABLE_HINTS.files, onClick: props.onAddFiles, badgeCount: 0, }, { label: "Diff", icon: FileDiff, shortcut: "D", available: props.diffAvailable, disabledReason: SURFACE_UNAVAILABLE_HINTS.diff, onClick: props.onAddDiff, badgeCount: 0, }, { label: "Pull request", icon: GitPullRequest, shortcut: "P", available: props.pullRequestAvailable, disabledReason: SURFACE_UNAVAILABLE_HINTS.pullRequest, onClick: props.onAddPullRequest, badgeCount: 0, }, { label: "Linked pull requests", icon: GitPullRequestArrow, shortcut: "L", available: props.pullRequestsAvailable, disabledReason: SURFACE_UNAVAILABLE_HINTS.pullRequests, onClick: props.onAddPullRequests, badgeCount: 0, }, { label: "Agents", icon: Bot, shortcut: "A", available: props.agentsAvailable, disabledReason: SURFACE_UNAVAILABLE_HINTS.agents, onClick: props.onAddAgents, badgeCount: props.liveAgentCount, }, { label: "Device", description: "Watch an iOS Simulator or Android Emulator.", icon: Smartphone, shortcut: "M", available: props.deviceAvailable, disabledReason: SURFACE_UNAVAILABLE_HINTS.device, onClick: props.onAddDevice, badgeCount: 0, }, ] 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) => { const action = surfaceShortcutActionForKey(shortcutActionsRef.current, event); if (!action) return; if (document.querySelector(LAUNCHER_SHORTCUT_BLOCKING_LAYERS)) return; const target = event.target; if (target instanceof Element && surfaceShortcutTargetsTypingContext(target)) return; event.preventDefault(); event.stopPropagation(); action.onClick(); }; window.addEventListener("keydown", handler, true); return () => window.removeEventListener("keydown", handler, true); }, []); const handleKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => { 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") { // Only activate the highlight when the launcher itself has focus. if (event.target !== event.currentTarget) 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 ( <span className="relative inline-flex shrink-0"> <Icon className={iconClassName} /> {action.badgeCount > 0 ? ( <span aria-hidden className="absolute -top-1.5 -right-2 flex h-3.5 min-w-3.5 items-center justify-center rounded-full bg-info px-1 text-[9px] font-semibold tabular-nums text-white" > {action.badgeCount} </span> ) : null} </span> ); }; return ( <div ref={focusOnMount} tabIndex={0} onKeyDown={handleKeyDown} aria-label="Open a surface"Lines 1–500 of 1416. Load more
import { pullRequestHostOf, type SourceControlProviderKind } from "@t3tools/contracts"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import { useProjects, useServerConfigs, useThreadShells } from "~/state/entities"; import { threadPullRequestKeysEqual, visibleThreadPullRequests, } from "@t3tools/shared/threadPullRequests"; import type { ContextMenuItem, EnvironmentId, PreviewSessionSnapshot, ProjectId, PullRequestState, } from "@t3tools/contracts"; import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; import { Bot, Smartphone, ChevronDown, ChevronLeft, ChevronRight, FileDiff, Files, GitPullRequest, GitPullRequestArrow, Globe2, Plus, TerminalSquare, Volume2, VolumeOff, } from "lucide-react"; import { type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type ReactElement, type ReactNode, useCallback, useEffect, useRef, useState, } from "react"; import { isElectron } from "~/env"; import type { DesktopPreviewOverlay } from "~/previewStateStore"; import type { RightPanelSurface } from "~/rightPanelStore"; import { cn } from "~/lib/utils"; import { readLocalApi } from "~/localApi"; import { Button } from "~/components/ui/button"; import { AndroidIcon, AppleIcon } from "~/components/Icons"; import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; import { Kbd } from "~/components/ui/kbd"; import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuSub, MenuSubPopup, MenuSubTrigger, MenuTrigger, } from "~/components/ui/menu"; import { useBrowserDefaults } from "~/browser/browserDefaults"; import { ScrollArea } from "~/components/ui/scroll-area"; import { PanelTabCloseButton } from "~/components/ui/panel-tab-close-button"; import { faviconUrlForOrigin } from "~/lib/favicon"; import { useTheme } from "~/hooks/useTheme"; import { pullRequestEnvironment } from "~/state/pullRequests"; import { useEnvironmentQuery } from "~/state/query"; import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; import { PreviewPanelShell, type PreviewPanelMode } from "./preview/PreviewPanelShell"; import { FaviconImage } from "./preview/PreviewFaviconIcon"; import { previewBridge } from "./preview/previewBridge"; import { PierreEntryIcon } from "./chat/PierreEntryIcon"; import { resolvePullRequestState } from "./pullRequest/pullRequestPresentation"; interface RightPanelTabsProps { mode: PreviewPanelMode; maximized?: boolean; open?: boolean; /** Forwarded to PreviewPanelShell so this surface persists its own width. */ widthStorageKey?: string; /** Forwarded to PreviewPanelShell as the initial width before a user resize. */ defaultWidth?: number; layoutControls?: ReactNode; surfaces: readonly RightPanelSurface[]; /** Fallback environment for surfaces that do not carry their own. */ environmentId: EnvironmentId | null; activeSurfaceId: string | null; pendingSurfaceIds: ReadonlySet<string>; previewSessions: Readonly<Record<string, PreviewSessionSnapshot>>; desktopByTabId: Readonly<Record<string, DesktopPreviewOverlay>>; /** * Maps a server session tab id to the desktop runtime tab id the Electron * preview manager is keyed by. Session ids are only unique within one server * process, so desktop operations must not be addressed with them. */ previewRuntimeTabId?: ((tabId: string) => string) | undefined; terminalLabelsById: ReadonlyMap<string, string>; onActivate: (surface: RightPanelSurface) => void; onRenameDevice?: (surfaceId: string, title: string) => void; onCloseSurface: (surface: RightPanelSurface) => void; onCloseOtherSurfaces: (surface: RightPanelSurface) => void; onCloseSurfacesToRight: (surface: RightPanelSurface) => void; onCloseAllSurfaces: () => void; onCopyFilePath: (relativePath: string) => void; onAddBrowser: () => void; /** * Separate from `onAddBrowser` on purpose: that one is passed directly as a * DOM click handler, and a `(profileId?: string)` signature would silently * accept the MouseEvent as a profile id. */ onAddBrowserInProfile: (profileId: string) => void; onAddTerminal: () => void; onAddDiff: () => void; onAddFiles: () => void; onAddPullRequest: () => void; onAddPullRequests: () => void; onAddAgents: () => void; onAddDevice: () => void; browserAvailable: boolean; terminalAvailable: boolean; diffAvailable: boolean; filesAvailable: boolean; pullRequestAvailable: boolean; pullRequestsAvailable: boolean; agentsAvailable: boolean; deviceAvailable: boolean; pullRequestStatusSeeds?: Readonly<Record<string, PullRequestTabStatusSeed>>; /** Running + waiting subagents; badges the Agents card in the empty state. */ liveAgentCount: number; children: ReactNode; } export interface PullRequestTabStatus { projectId: string; repository: string; number: number; state: PullRequestState; isDraft: boolean; } export type PullRequestTabStatusSeed = Pick<PullRequestTabStatus, "state" | "isDraft">; export function shouldOpenDefaultBrowserProfileFromMenuClick( pointerType: string | undefined, ): boolean { return pointerType !== "touch"; } const SURFACE_DISABLED_REASONS = { browser: "Browser previews are only available in the T3 Code desktop app.", terminal: "Terminal surfaces are only available from a project thread.", files: "Files are only available when a project is open.", diff: "Diff is only available for server threads in Git repositories.", pullRequest: "This thread's branch has no pull request yet.", pullRequests: "Linked pull requests are only available for server threads.", agents: "Agents are only available from a thread.", device: "Devices 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 rows. */ 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.", pullRequests: "Available for server threads.", agents: "Available from a thread.", device: "Available from a thread.", } as const; type TabContextMenuAction = | "rename" | "copy-path" | "toggle-mute" | "close" | "close-others" | "close-to-right" | "close-all"; const TAB_SCROLL_EDGE_TOLERANCE = 1; function tabScrollViewport(root: HTMLDivElement | null): HTMLDivElement | null { return root?.querySelector<HTMLDivElement>('[data-slot="scroll-area-viewport"]') ?? null; } /** * Desktop preview tab backing a surface, or null for non-preview surfaces, the * "new browser tab" placeholder, and the web build where no desktop tab exists. */ function previewTabIdOf( surface: RightPanelSurface, sessions: Readonly<Record<string, PreviewSessionSnapshot>>, ): string | null { if (surface.kind !== "preview" || !surface.resourceId) return null; return sessions[surface.resourceId]?.tabId ?? null; } /** * Label and enabled state for a preview tab's mute menu entry. * Stays disabled until desktop overlay state arrives: a server session id can * resolve while the preview manager's createTab is still in flight, and muting * then fails with a PreviewTabNotFoundError nothing surfaces to the user. */ export function tabMuteMenuItem(input: { overlay: DesktopPreviewOverlay | null; canResolveRuntimeTabId: boolean; }): { label: string; disabled: boolean } { const muted = input.overlay?.audioMuted ?? false; return { label: muted ? "Unmute tab" : "Mute tab", disabled: input.overlay === null || !input.canResolveRuntimeTabId, }; } type TabAudioState = "none" | "audible" | "muted"; /** * A muted tab that is not making sound shows nothing: mute is armed silently, * and the indicator only appears once there is audio to speak of. */ function tabAudioState(overlay: DesktopPreviewOverlay | null): TabAudioState { if (!overlay?.audible) return "none"; return overlay.audioMuted ? "muted" : "audible"; } type SurfaceShortcutEvent = Pick< KeyboardEvent, "altKey" | "ctrlKey" | "defaultPrevented" | "isComposing" | "key" | "metaKey" >; export function surfaceShortcutActionForKey< const Action extends { available: boolean; shortcut: string }, >(actions: readonly Action[], event: SurfaceShortcutEvent): Action | null { if (event.defaultPrevented || event.isComposing) return null; if (event.metaKey || event.ctrlKey || event.altKey) return null; return ( actions.find( (action) => action.available && action.shortcut.toLowerCase() === event.key.toLowerCase(), ) ?? null ); } /** * A focused editable is a typing context whether or not it has text yet: an * empty chat composer at rest is still where the user's next keystrokes are * meant to land, and claiming launcher letters from it would redirect prompts * into whatever surface opens. The `:not` clause lets `closest` see past * non-editable islands (`contenteditable="false"`) to an editable host around * them, matching ComposerPendingUserInputPanel's typing guard. */ export function surfaceShortcutTargetsTypingContext( target: { closest(selectors: string): unknown } | null, ): boolean { return ( target?.closest('input, textarea, select, [contenteditable]:not([contenteditable="false"])') != null ); } function DisabledReasonTooltip(props: { reason: string; trigger: ReactElement }) { return ( <Tooltip> <TooltipTrigger render={props.trigger} /> <TooltipPopup side="top">{props.reason}</TooltipPopup> </Tooltip> ); } function SurfaceMenuItem(props: { available: boolean; disabledReason?: string; shortcut: string; onClick: () => void; children: ReactNode; }) { const item = ( <MenuItem className={!props.available ? "data-disabled:pointer-events-auto" : undefined} onClick={props.onClick} disabled={!props.available} aria-keyshortcuts={props.shortcut} > {props.children} <MenuShortcut>{props.shortcut}</MenuShortcut> </MenuItem> ); if (props.available || !props.disabledReason) return item; return <DisabledReasonTooltip reason={props.disabledReason} trigger={item} />; } /** * List 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; onAddBrowserInProfile: (profileId: string) => void; browserProfiles: ReadonlyArray<{ readonly id: string; readonly name: string }>; onAddTerminal: () => void; onAddDiff: () => void; onAddFiles: () => void; onAddPullRequest: () => void; onAddPullRequests: () => void; onAddAgents: () => void; onAddDevice: () => void; browserAvailable: boolean; terminalAvailable: boolean; diffAvailable: boolean; filesAvailable: boolean; pullRequestAvailable: boolean; pullRequestsAvailable: boolean; agentsAvailable: boolean; deviceAvailable: boolean; liveAgentCount: number; }) { // -1 means no highlight: it only appears on hover or arrow use. const [highlight, setHighlight] = useState(-1); const actions = [ { label: "Browser", icon: Globe2, shortcut: "B", available: props.browserAvailable, disabledReason: SURFACE_UNAVAILABLE_HINTS.browser, onClick: props.onAddBrowser, badgeCount: 0, }, { label: "Terminal", icon: TerminalSquare, shortcut: "T", available: props.terminalAvailable, disabledReason: SURFACE_UNAVAILABLE_HINTS.terminal, onClick: props.onAddTerminal, badgeCount: 0, }, { label: "Files", icon: Files, shortcut: "F", available: props.filesAvailable, disabledReason: SURFACE_UNAVAILABLE_HINTS.files, onClick: props.onAddFiles, badgeCount: 0, }, { label: "Diff", icon: FileDiff, shortcut: "D", available: props.diffAvailable, disabledReason: SURFACE_UNAVAILABLE_HINTS.diff, onClick: props.onAddDiff, badgeCount: 0, }, { label: "Pull request", icon: GitPullRequest, shortcut: "P", available: props.pullRequestAvailable, disabledReason: SURFACE_UNAVAILABLE_HINTS.pullRequest, onClick: props.onAddPullRequest, badgeCount: 0, }, { label: "Linked pull requests", icon: GitPullRequestArrow, shortcut: "L", available: props.pullRequestsAvailable, disabledReason: SURFACE_UNAVAILABLE_HINTS.pullRequests, onClick: props.onAddPullRequests, badgeCount: 0, }, { label: "Agents", icon: Bot, shortcut: "A", available: props.agentsAvailable, disabledReason: SURFACE_UNAVAILABLE_HINTS.agents, onClick: props.onAddAgents, badgeCount: props.liveAgentCount, }, { label: "Device", description: "Watch an iOS Simulator or Android Emulator.", icon: Smartphone, shortcut: "M", available: props.deviceAvailable, disabledReason: SURFACE_UNAVAILABLE_HINTS.device, onClick: props.onAddDevice, badgeCount: 0, }, ] 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) => { const action = surfaceShortcutActionForKey(shortcutActionsRef.current, event); if (!action) return; if (document.querySelector(LAUNCHER_SHORTCUT_BLOCKING_LAYERS)) return; const target = event.target; if (target instanceof Element && surfaceShortcutTargetsTypingContext(target)) return; event.preventDefault(); event.stopPropagation(); action.onClick(); }; window.addEventListener("keydown", handler, true); return () => window.removeEventListener("keydown", handler, true); }, []); const handleKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => { 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") { // Only activate the highlight when the launcher itself has focus. if (event.target !== event.currentTarget) 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 ( <span className="relative inline-flex shrink-0"> <Icon className={iconClassName} /> {action.badgeCount > 0 ? ( <span aria-hidden className="absolute -top-1.5 -right-2 flex h-3.5 min-w-3.5 items-center justify-center rounded-full bg-info px-1 text-[9px] font-semibold tabular-nums text-white" > {action.badgeCount} </span> ) : null} </span> ); }; return ( <div ref={focusOnMount} tabIndex={0} onKeyDown={handleKeyDown} aria-label="Open a surface"