import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import { threadRuntimeIsActive, type EnvironmentThreadShell, } from "@t3tools/client-runtime/state/shell"; import { useProjects, useQuietThreadShell, useServerConfigs, useThreadShells, } from "~/state/entities"; import { threadPullRequestKeysEqual, visibleThreadPullRequests, } from "@t3tools/client-runtime/thread-pull-requests"; import type { ContextMenuItem, EnvironmentId, PreviewSessionSnapshot, ProjectId, PullRequestState, ResolvedKeybindingsConfig, ThreadId, } from "@t3tools/contracts"; import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; import { closestCenter, DndContext, type DragEndEvent, PointerSensor, useSensor, useSensors, } from "@dnd-kit/core"; import { restrictToFirstScrollableAncestor, restrictToHorizontalAxis } from "@dnd-kit/modifiers"; import { horizontalListSortingStrategy, SortableContext, useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { Smartphone, ChevronDown, ChevronLeft, ChevronRight, FileDiff, Files, Globe2, MessagesSquare, Plus, TerminalSquare, } from "lucide-react"; import { Volume2, VolumeOff } from "lucide"; import { type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type ReactElement, type ReactNode, useEffect, useEffectEvent, useRef, useState, } from "react"; import { isElectron } from "~/env"; import type { DesktopPreviewOverlay } from "~/previewStateStore"; import type { RightPanelSurface } from "~/rightPanelStore"; import { cn } from "~/lib/utils"; import { resolveShortcutCommand, type ShortcutMatchContext } from "~/keybindings"; import { readLocalApi } from "~/localApi"; import { useStableCallback } from "~/hooks/useStableCallback"; import { Button } from "~/components/ui/button"; import { MorphIcon } from "~/components/MorphIcon"; 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 "@t3tools/shared/favicon"; import { useTheme } from "~/hooks/useTheme"; import { useDeviceState } from "~/state/device"; import type { PreviewPanelInlineSize } from "~/hooks/usePreviewPanelInlineSize"; import { newestPullRequestSummary, pullRequestEnvironment, useSharedPullRequestSummary, } 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"; import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons"; import { observeResize } from "~/lib/observeResize"; export interface RightPanelTabsProps { mode: PreviewPanelMode; maximized?: boolean; open?: boolean; keybindings: ResolvedKeybindingsConfig; getShortcutContext: () => ShortcutMatchContext; widthStorageKey?: string; defaultWidth?: number; inlineSize?: PreviewPanelInlineSize; layoutControls?: ReactNode; surfaces: readonly RightPanelSurface[]; environmentId: EnvironmentId | null; activeSurfaceId: string | null; pendingSurfaceIds: ReadonlySet; previewSessions: Readonly>; desktopByTabId: Readonly>; /** Maps server-local session ids to desktop runtime ids; session ids cannot address desktop operations. */ previewRuntimeTabId?: ((tabId: string) => string) | undefined; terminalLabelsById: ReadonlyMap; onActivate: (surface: RightPanelSurface) => void; onRenameDevice?: (surfaceId: string, title: string) => void; onCloseSurface: (surface: RightPanelSurface) => void; onCloseOtherSurfaces: (surface: RightPanelSurface) => void; onCloseSurfacesToRight: (surface: RightPanelSurface) => void; onCloseAllSurfaces: () => void; onMoveSurface?: (surfaceId: string, toIndex: number) => void; onCopyFilePath: (relativePath: string) => void; onAddBrowser: () => void; /** Keep separate from the DOM click handler so a MouseEvent cannot become a profile id. */ onAddBrowserInProfile: (profileId: string) => void; onAddTerminal: () => void; onAddDiff: () => void; onAddFiles: () => void; onAddPullRequest: () => void; onAddPullRequests: () => void; onAddDevice: () => void; /** Absent where side chats are not offered. */ onAddSideChat?: (() => void) | undefined; browserAvailable: boolean; terminalAvailable: boolean; diffAvailable: boolean; filesAvailable: boolean; pullRequestAvailable: boolean; pullRequestsAvailable: boolean; deviceAvailable: boolean; sideChatAvailable?: boolean; pullRequestStatusSeeds?: Readonly>; children: ReactNode; } export interface PullRequestTabStatus { projectId: string; repository: string; number: number; state: PullRequestState; isDraft: boolean; } export type PullRequestTabStatusSeed = Pick; 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: "No linked pull requests are available for this thread.", device: "Devices are only available from a thread.", sideChat: "Side chats start after the first message.", } as const; // Closed keepMounted popups must stop blocking shortcuts once their exit transition ends. const LAUNCHER_SHORTCUT_BLOCKING_LAYERS = [ "dialog-popup", "alert-dialog-popup", "command-dialog-popup", "menu-popup", "select-popup", "popover-popup", "combobox-popup", "autocomplete-popup", ] .map((slot) => `[data-slot="${slot}"]:is([data-open],[data-ending-style])`) .join(","); 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: "No linked pull requests available.", device: "Available from a thread.", sideChat: "Available after the first message.", } as const; type TabContextMenuAction = | "rename" | "copy-path" | "toggle-mute" | "close" | "close-others" | "close-to-right" | "close-all"; const TAB_SCROLL_EDGE_TOLERANCE = 1; const TAB_DRAG_MODIFIERS = [restrictToHorizontalAxis, restrictToFirstScrollableAncestor]; function tabScrollViewport(root: HTMLDivElement | null): HTMLDivElement | null { return root?.querySelector('[data-slot="scroll-area-viewport"]') ?? null; } function previewTabIdOf( surface: RightPanelSurface, sessions: Readonly>, ): string | null { if (surface.kind !== "preview" || !surface.resourceId) return null; return sessions[surface.resourceId]?.tabId ?? null; } /** * 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. */ 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"; 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 ); } /** Skip contenteditable=false islands to find their editable host, matching the composer 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 ( {props.reason} ); } function SurfaceMenuItem(props: { available: boolean; disabledReason?: string; shortcut: string; onClick: () => void; children: ReactNode; }) { const item = ( {props.children} {props.shortcut} ); if (props.available || !props.disabledReason) return item; return ; } 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; onAddDevice: () => void; /** Absent where side chats are not offered. */ onAddSideChat?: (() => void) | undefined; browserAvailable: boolean; terminalAvailable: boolean; diffAvailable: boolean; filesAvailable: boolean; pullRequestAvailable: boolean; pullRequestsAvailable: boolean; deviceAvailable: boolean; sideChatAvailable: boolean; }) { const [highlight, setHighlight] = useState(-1); const actions = [ { label: "Browser", icon: Globe2, shortcut: "B", available: props.browserAvailable, disabledReason: SURFACE_UNAVAILABLE_HINTS.browser, onClick: props.onAddBrowser, }, { label: "Terminal", icon: TerminalSquare, shortcut: "T", available: props.terminalAvailable, disabledReason: SURFACE_UNAVAILABLE_HINTS.terminal, onClick: props.onAddTerminal, }, { label: "Files", icon: Files, shortcut: "F", available: props.filesAvailable, disabledReason: SURFACE_UNAVAILABLE_HINTS.files, onClick: props.onAddFiles, }, { label: "Diff", icon: FileDiff, shortcut: "D", available: props.diffAvailable, disabledReason: SURFACE_UNAVAILABLE_HINTS.diff, onClick: props.onAddDiff, }, { label: "Pull request", icon: PullRequestGlyph.pullRequest, shortcut: "P", available: props.pullRequestAvailable, disabledReason: SURFACE_UNAVAILABLE_HINTS.pullRequest, onClick: props.onAddPullRequest, }, { label: "Linked pull requests", icon: PullRequestGlyph.link, shortcut: "L", available: props.pullRequestsAvailable, disabledReason: SURFACE_UNAVAILABLE_HINTS.pullRequests, onClick: props.onAddPullRequests, }, { 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, }, ...(props.onAddSideChat ? [ { label: "Side chat", description: "Ask a side question while this thread keeps working.", icon: MessagesSquare, shortcut: "S", available: props.sideChatAvailable, disabledReason: SURFACE_UNAVAILABLE_HINTS.sideChat, onClick: props.onAddSideChat, }, ] : []), ] 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); const handleShortcut = useEffectEvent((event: KeyboardEvent) => { const action = surfaceShortcutActionForKey(availableActions, event); if (!action) return; if (document.querySelector(LAUNCHER_SHORTCUT_BLOCKING_LAYERS)) return; const target = event.composedPath()[0] ?? event.target; if (target instanceof Element && surfaceShortcutTargetsTypingContext(target)) return; event.preventDefault(); event.stopPropagation(); action.onClick(); }); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes a capture-phase window keydown listener for surface shortcuts while the menu is mounted useEffect(() => { const handler = (event: KeyboardEvent) => handleShortcut(event); 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") { if (event.target !== event.currentTarget) return; const action = availableActions[highlightIndex]; if (!action) return; event.preventDefault(); action.onClick(); } }; const isHighlighted = (action: SurfaceAction) => highlightIndex !== -1 && availableActions[highlightIndex] === action; const actionIcon = (action: SurfaceAction, iconClassName = "size-4") => { const Icon = action.icon; return ( ); }; return (
action.shortcut).join("")} className={cn( "scrollbar-gutter-both flex min-h-0 flex-1 items-center justify-center overflow-y-auto px-6 outline-none", "pb-(--workspace-topbar-height)", )} >

Open a surface

{actions.map((action) => action.available ? (
setHighlight(availableActions.indexOf(action))} onMouseLeave={() => setHighlight((current) => current === availableActions.indexOf(action) ? -1 : current, ) } > {action.label === "Browser" && props.browserProfiles.length > 1 ? ( } > {props.browserProfiles.map((profile) => ( props.onAddBrowserInProfile(profile.id)} > {profile.name} ))} ) : null}
) : ( {actionIcon(action, "size-4")} {action.label} {action.shortcut}
} /> ), )}
); } function surfaceTitle( surface: RightPanelSurface, sessions: Readonly>, terminalLabelsById: ReadonlyMap, ): string { switch (surface.kind) { case "diff": return "Diff"; case "files": return "Files"; case "file": return surface.relativePath.slice( Math.max(surface.relativePath.lastIndexOf("/"), surface.relativePath.lastIndexOf("\\")) + 1, ); case "terminal": return ( terminalLabelsById.get(surface.activeTerminalId) ?? getTerminalLabel(surface.activeTerminalId) ); case "pull-request": return `#${surface.number}`; case "pull-requests": return "Pull requests"; case "device": return surface.title ?? surface.target?.name ?? "Device"; case "side-chat": return "Side chat"; case "preview": { const snapshot = surface.resourceId ? sessions[surface.resourceId] : null; if (!snapshot || snapshot.navStatus._tag === "Idle") return "Browser"; if (snapshot.navStatus.title.trim().length > 0) return snapshot.navStatus.title; try { return new URL(snapshot.navStatus.url).host || "Browser"; } catch { return "Browser"; } } } } function PreviewFavicon({ capturedUrl, url }: { capturedUrl: string | null; url: string | null }) { const publicProviderUrl = faviconUrlForOrigin(url, 32); return ( } className="size-3 shrink-0 rounded-sm object-contain" /> ); } function sameOrigin(left: string, right: string): boolean { try { return new URL(left).origin === new URL(right).origin; } catch { return false; } } function previewFaviconOf( surface: RightPanelSurface, sessions: Readonly>, desktopByTabId: Readonly>, ): { url: string | null; capturedUrl: string | null } { const snapshot = surface.kind === "preview" && surface.resourceId ? sessions[surface.resourceId] : null; const url = !snapshot || snapshot.navStatus._tag === "Idle" ? null : snapshot.navStatus.url; const favicon = snapshot ? (desktopByTabId[snapshot.tabId]?.favicon ?? null) : null; const capturedUrl = favicon && url && sameOrigin(favicon.pageUrl, url) ? favicon.dataUrl : null; return { url, capturedUrl }; } function SurfaceIcon({ surface, previewUrl, previewCapturedUrl, theme, environmentId, pullRequestStatusSeed, }: { surface: RightPanelSurface; previewUrl: string | null; previewCapturedUrl: string | null; theme: "light" | "dark"; environmentId: EnvironmentId | null; pullRequestStatusSeed: PullRequestTabStatusSeed | undefined; }) { switch (surface.kind) { case "preview": return ; case "diff": return ; case "files": return ; case "file": return ( ); case "terminal": return ; case "pull-request": return ( ); case "pull-requests": return ; case "side-chat": return ; case "device": return surface.target?.platform === "ios" ? ( ) : surface.target?.platform === "android" ? ( ) : ( ); } } // Static dots: a side chat tab can stay open for a whole run. function SideChatTabIcon(props: { environmentId: EnvironmentId | null; threadId: ThreadId }) { const shell = useQuietThreadShell( props.environmentId === null ? null : scopeThreadRef(props.environmentId, props.threadId), ); const needsYou = shell !== null && (shell.hasPendingApprovals || shell.hasPendingUserInput || Boolean(shell.pendingSubagentRequest)); const running = shell !== null && threadRuntimeIsActive(shell.runtime); return ( <> {needsYou || running ? ( ) : null} ); } export function resolvePullRequestTabLink( threads: readonly Pick[], environmentId: EnvironmentId | null, host: string | null, reference: { repository: string; number: number }, ) { if (environmentId === null || host === null) return undefined; let newest: EnvironmentThreadShell["pullRequests"][number] | undefined; for (const thread of threads) { if (thread.environmentId !== environmentId) continue; for (const link of visibleThreadPullRequests(thread.pullRequests)) { if ( !threadPullRequestKeysEqual(link, { host, repository: reference.repository, number: reference.number, }) ) continue; if ( newest === undefined || (link.snapshot?.syncedAt ?? "") > (newest.snapshot?.syncedAt ?? "") ) newest = link; } } return newest; } function PullRequestSurfaceIcon({ surface, environmentId, seed, }: { surface: Extract; environmentId: EnvironmentId | null; seed: PullRequestTabStatusSeed | undefined; }) { const resolvedEnvironmentId = (surface.environmentId as EnvironmentId | undefined) ?? environmentId; const projects = useProjects(); const threads = useThreadShells(); const project = projects.find( (entry) => entry.environmentId === resolvedEnvironmentId && entry.id === surface.projectId, ); const identity = project?.repositoryIdentity; const host = surface.host ?? (identity?.provider ? sourceControlClients.changeRequestHost(identity, identity.provider) : null); const configs = useServerConfigs(); const capabilities = resolvedEnvironmentId === null ? undefined : configs.get(resolvedEnvironmentId)?.environment.capabilities; const linkedSnapshot = capabilities !== undefined ? (resolvePullRequestTabLink(threads, resolvedEnvironmentId, host, surface)?.snapshot ?? null) : null; const detail = useEnvironmentQuery( resolvedEnvironmentId === null || capabilities === undefined || linkedSnapshot !== null ? null : pullRequestEnvironment.detail({ environmentId: resolvedEnvironmentId, input: { projectId: surface.projectId as ProjectId, ...(capabilities !== undefined && surface.host !== undefined ? { host: surface.host } : {}), repository: surface.repository, number: surface.number, allowStale: false, }, }), ).data; const reference = { projectId: surface.projectId as ProjectId, repository: surface.repository, number: surface.number, }; const sharedSummary = useSharedPullRequestSummary(resolvedEnvironmentId, reference); const status = linkedSnapshot ?? newestPullRequestSummary(detail, sharedSummary) ?? seed ?? null; if (status === null) { return ; } const presentation = resolvePullRequestState({ state: status.state, isDraft: status.isDraft ?? detail?.isDraft ?? seed?.isDraft ?? false, }); return ; } function SortableTabList(props: { surfaces: readonly RightPanelSurface[]; onMove: ((surfaceId: string, toIndex: number) => void) | undefined; children: ReactNode; }) { const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } })); const surfaceIds = props.surfaces.map((surface) => surface.id); const { onMove, surfaces } = props; const handleDragEnd = ({ active, over }: DragEndEvent) => { if (!over || active.id === over.id) return; const toIndex = surfaces.findIndex((surface) => surface.id === over.id); if (toIndex >= 0) onMove?.(String(active.id), toIndex); }; return ( {props.children} ); } interface RightPanelTabHandlers { onActivate: (surface: RightPanelSurface) => void; onClose: (surface: RightPanelSurface) => void; onAuxClick: (event: ReactMouseEvent, surface: RightPanelSurface) => void; onContextMenu: (event: ReactMouseEvent, surface: RightPanelSurface) => void; onStartRename: (surfaceId: string) => void; onFinishRename: (surfaceId: string, title: string) => void; } // Chromium on Linux only suppresses primary-selection paste when mouseup is cancelled. function preventTabMiddleButtonDefault(event: ReactMouseEvent) { if (event.button !== 1) return; event.preventDefault(); } // Stable handlers and per-tab values let the compiler reuse tabs across parent renders. function RightPanelTab( props: RightPanelTabHandlers & { surface: RightPanelSurface; dragDisabled: boolean; active: boolean; pending: boolean; title: string; audio: TabAudioState; audioRuntimeTabId: string | null; previewUrl: string | null; previewCapturedUrl: string | null; theme: "light" | "dark"; environmentId: EnvironmentId | null; pullRequestStatusSeed: PullRequestTabStatusSeed | undefined; renaming: boolean; canRename: boolean; ownsDesktopTitleBar: boolean; }, ) { const { surface, audio, audioRuntimeTabId, onActivate, onClose, onAuxClick, onContextMenu, onStartRename, onFinishRename, } = props; const { listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: surface.id, disabled: props.dragDisabled, }); const sideChatShell = useQuietThreadShell( surface.kind === "side-chat" && props.environmentId !== null ? scopeThreadRef(props.environmentId, surface.threadId) : null, ); const title = sideChatShell?.title ?? props.title; const content = ( <> onClose(surface)}> {props.pending ? ( ) : null} {audio === "none" || !audioRuntimeTabId ? null : ( { event.stopPropagation(); void previewBridge ?.setAudioMuted(audioRuntimeTabId, audio !== "muted") .catch(() => undefined); }} > } /> {audio === "muted" ? "Unmute tab" : "Mute tab"} )} {props.renaming ? ( { element?.focus(); element?.select(); }} onBlur={(event) => onFinishRename(surface.id, event.currentTarget.value)} onKeyDown={(event) => { event.stopPropagation(); if (event.key === "Enter") event.currentTarget.blur(); if (event.key === "Escape") { event.currentTarget.value = title; event.currentTarget.blur(); } }} /> ) : ( { if (surface.kind === "device" && props.canRename) onStartRename(surface.id); }} className="cursor-pointer flex min-w-0 items-center" onClick={() => onActivate(surface)} > {title} } /> {surface.kind === "device" ? ( ) : ( title )} )} ); return (
onAuxClick(event, surface)} onContextMenu={(event) => onContextMenu(event, surface)} {...listeners} ref={setNodeRef} style={{ transform: CSS.Translate.toString(transform), transition }} className={cn( "cursor-pointer group/tab flex h-6 max-w-36 shrink-0 items-center gap-0.5 rounded-md pr-2 pl-1.5 text-xs", props.ownsDesktopTitleBar && "[-webkit-app-region:no-drag]", props.active ? "bg-accent text-foreground" : "text-muted-foreground hover:bg-accent/60 hover:text-foreground", isDragging && "relative z-10", )} > {content}
); } export function RightPanelTabs(props: RightPanelTabsProps) { const focusedSurface = props.surfaces.find((surface) => surface.id === props.activeSurfaceId); const ownsDesktopTitleBar = isElectron && props.mode === "inline"; const browserProfiles = useBrowserDefaults().profiles; const { resolvedTheme } = useTheme(); const tabListRef = useRef(null); const addSurfaceTriggerRef = useRef(null); const [renamingDevice, setRenamingDevice] = useState(null); const [addSurfaceMenuOpen, setAddSurfaceMenuOpen] = useState(false); const [tabScrollState, setTabScrollState] = useState({ hasOverflow: false, canScrollLeft: false, canScrollRight: false, }); if (props.open === false && addSurfaceMenuOpen) setAddSurfaceMenuOpen(false); const onNewSurfaceKeyDown = useEffectEvent((event: KeyboardEvent) => { if (event.defaultPrevented || event.isComposing) return; if ( resolveShortcutCommand(event, props.keybindings, { context: { ...props.getShortcutContext(), rightPanelOpen: true }, }) !== "rightPanel.new" ) return; if (!addSurfaceMenuOpen && document.querySelector(LAUNCHER_SHORTCUT_BLOCKING_LAYERS)) return; event.preventDefault(); event.stopPropagation(); if (!event.repeat) { addSurfaceTriggerRef.current?.focus(); setAddSurfaceMenuOpen(true); } }); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes a document keydown listener for the new surface shortcut while the panel is open useEffect(() => { if (props.open === false) return; document.addEventListener("keydown", onNewSurfaceKeyDown, true); return () => document.removeEventListener("keydown", onNewSurfaceKeyDown, true); }, [props.open]); const updateTabScrollState = (viewport: HTMLDivElement) => { const hasOverflow = viewport.scrollWidth - viewport.clientWidth > TAB_SCROLL_EDGE_TOLERANCE; const canScrollLeft = hasOverflow && viewport.scrollLeft > TAB_SCROLL_EDGE_TOLERANCE; const canScrollRight = hasOverflow && viewport.scrollLeft + viewport.clientWidth < viewport.scrollWidth - TAB_SCROLL_EDGE_TOLERANCE; setTabScrollState((current) => { if ( current.hasOverflow === hasOverflow && current.canScrollLeft === canScrollLeft && current.canScrollRight === canScrollRight ) { return current; } return { hasOverflow, canScrollLeft, canScrollRight }; }); }; const scrollTabs = (direction: -1 | 1) => { const viewport = tabScrollViewport(tabListRef.current); if (!viewport) return; const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; viewport.scrollBy({ left: direction * Math.max(120, viewport.clientWidth * 0.75), behavior: reduceMotion ? "auto" : "smooth", }); }; const addSurfaceActions = [ { label: "Browser", icon: Globe2, shortcut: "B", available: props.browserAvailable, disabledReason: SURFACE_DISABLED_REASONS.browser, onClick: props.onAddBrowser, }, { label: "Terminal", icon: TerminalSquare, shortcut: "T", available: props.terminalAvailable, disabledReason: SURFACE_DISABLED_REASONS.terminal, onClick: props.onAddTerminal, }, { label: "Files", icon: Files, shortcut: "F", available: props.filesAvailable, disabledReason: SURFACE_DISABLED_REASONS.files, onClick: props.onAddFiles, }, { label: "Diff", icon: FileDiff, shortcut: "D", available: props.diffAvailable, disabledReason: SURFACE_DISABLED_REASONS.diff, onClick: props.onAddDiff, }, { label: "Pull request", icon: PullRequestGlyph.pullRequest, shortcut: "P", available: props.pullRequestAvailable, disabledReason: SURFACE_DISABLED_REASONS.pullRequest, onClick: props.onAddPullRequest, }, { label: "Linked pull requests", icon: PullRequestGlyph.link, shortcut: "L", available: props.pullRequestsAvailable, disabledReason: SURFACE_DISABLED_REASONS.pullRequests, onClick: props.onAddPullRequests, }, { label: "Device", icon: Smartphone, shortcut: "M", available: props.deviceAvailable, disabledReason: SURFACE_DISABLED_REASONS.device, onClick: props.onAddDevice, }, ...(props.onAddSideChat ? [ { label: "Side chat", icon: MessagesSquare, shortcut: "S", available: props.sideChatAvailable === true, disabledReason: SURFACE_DISABLED_REASONS.sideChat, onClick: props.onAddSideChat, }, ] : []), ] as const; const handleAddSurfaceMenuKeyDown = (event: ReactKeyboardEvent) => { const action = surfaceShortcutActionForKey(addSurfaceActions, event.nativeEvent); if (!action) return; event.preventDefault(); event.stopPropagation(); setAddSurfaceMenuOpen(false); action.onClick(); }; const handleTabContextMenu = async (event: ReactMouseEvent, surface: RightPanelSurface) => { event.preventDefault(); event.stopPropagation(); const api = readLocalApi(); if (!api) return; const surfaceIndex = props.surfaces.findIndex((entry) => entry.id === surface.id); if (surfaceIndex < 0) return; const items: ContextMenuItem[] = []; if (surface.kind === "device" && props.onRenameDevice) items.push({ id: "rename", label: "Rename" }); if (surface.kind === "file" && surface.attachment === undefined) { items.push({ id: "copy-path", label: "Copy path" }); } const menuPreviewTabId = previewTabIdOf(surface, props.previewSessions); const menuOverlay = menuPreviewTabId ? (props.desktopByTabId[menuPreviewTabId] ?? null) : null; const menuMuted = menuOverlay?.audioMuted ?? false; if (surface.kind === "preview") { items.push({ id: "toggle-mute", ...tabMuteMenuItem({ overlay: menuOverlay, canResolveRuntimeTabId: props.previewRuntimeTabId !== undefined, }), }); } items.push( { id: "close", label: "Close" }, { id: "close-others", label: "Close others", disabled: props.surfaces.length <= 1, }, { id: "close-to-right", label: "Close to the right", disabled: surfaceIndex >= props.surfaces.length - 1, }, { id: "close-all", label: "Close all", disabled: props.surfaces.length === 0, }, ); const action = await api.contextMenu.show(items, { x: event.clientX, y: event.clientY }); switch (action) { case "rename": setRenamingDevice(surface.id); break; case "copy-path": if (surface.kind === "file" && surface.attachment === undefined) { props.onCopyFilePath(surface.relativePath); } break; case "toggle-mute": { const runtimeTabId = menuPreviewTabId && menuOverlay ? (props.previewRuntimeTabId?.(menuPreviewTabId) ?? null) : null; if (runtimeTabId) { void previewBridge?.setAudioMuted(runtimeTabId, !menuMuted).catch(() => undefined); } break; } case "close": props.onCloseSurface(surface); break; case "close-others": props.onCloseOtherSurfaces(surface); break; case "close-to-right": props.onCloseSurfacesToRight(surface); break; case "close-all": props.onCloseAllSurfaces(); break; case null: break; } }; const handleTabAuxClick = (event: ReactMouseEvent, surface: RightPanelSurface) => { if (event.button !== 1) return; event.preventDefault(); event.stopPropagation(); props.onCloseSurface(surface); }; const onActivateTab = useStableCallback(props.onActivate); const onCloseTab = useStableCallback(props.onCloseSurface); const onTabAuxClick = useStableCallback(handleTabAuxClick); const onTabContextMenu = useStableCallback( (event: ReactMouseEvent, surface: RightPanelSurface) => { void handleTabContextMenu(event, surface); }, ); const onStartRenameTab = useStableCallback((surfaceId: string) => setRenamingDevice(surfaceId)); const onFinishRenameTab = useStableCallback((surfaceId: string, title: string) => { props.onRenameDevice?.(surfaceId, title); setRenamingDevice(null); }); // A newly active tab, or tabs that start overflowing, can sit outside the scrolled strip. // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- scrolls the active tab DOM node into view after it changes or the tab strip starts overflowing useEffect(() => { if (!props.activeSurfaceId || !tabScrollState.hasOverflow) return; const activeTab = tabListRef.current?.querySelector("[data-active-tab='true']"); activeTab?.scrollIntoView({ block: "nearest", inline: "nearest" }); }, [props.activeSurfaceId, tabScrollState.hasOverflow]); const attachTabList = (root: HTMLDivElement | null) => { tabListRef.current = root; const viewport = tabScrollViewport(root); if (!viewport) return; const update = () => updateTabScrollState(viewport); const handleWheel = (event: WheelEvent) => { if (event.ctrlKey) return; let delta = Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY; if (event.deltaMode === WheelEvent.DOM_DELTA_LINE) delta *= 16; if (event.deltaMode === WheelEvent.DOM_DELTA_PAGE) delta *= viewport.clientWidth; if (delta === 0) return; const previousScrollLeft = viewport.scrollLeft; viewport.scrollLeft += delta; if (viewport.scrollLeft === previousScrollLeft) return; event.preventDefault(); update(); }; const content = viewport.firstElementChild; const stopObserving = observeResize(content ? [viewport, content] : viewport, update); viewport.addEventListener("scroll", update, { passive: true }); viewport.addEventListener("wheel", handleWheel, { passive: false }); update(); return () => { tabListRef.current = null; stopObserving(); viewport.removeEventListener("scroll", update); viewport.removeEventListener("wheel", handleWheel); }; }; return (
{props.surfaces.map((surface) => { const previewTabId = previewTabIdOf(surface, props.previewSessions); const favicon = previewFaviconOf( surface, props.previewSessions, props.desktopByTabId, ); return ( ); })} {props.open !== false ? ( } > {addSurfaceActions.map((action) => { const Icon = action.icon; if (action.label === "Browser" && action.available) { return ( { const pointerType = "pointerType" in event.nativeEvent && typeof event.nativeEvent.pointerType === "string" ? event.nativeEvent.pointerType : undefined; if (!shouldOpenDefaultBrowserProfileFromMenuClick(pointerType)) return; setAddSurfaceMenuOpen(false); action.onClick(); }} > {action.label} {action.shortcut} {browserProfiles.map((profile) => ( props.onAddBrowserInProfile(profile.id)} > {profile.name} ))} ); } return ( {action.label} ); })} ) : null}
{tabScrollState.hasOverflow ? (
} /> Scroll tabs left } /> Scroll tabs right
) : null} {props.layoutControls} {ownsDesktopTitleBar && !props.layoutControls ? ( ) : null} {ownsDesktopTitleBar ? ( ) : null}
{props.activeSurfaceId === null ? ( ) : ( props.children )}
); } function DeviceTabTooltip(props: { surface: Extract; environmentId: EnvironmentId | null; title: string; }) { const target = props.surface.target; const { state } = useDeviceState(target ? props.environmentId : null); const device = target ? state.devices.find((entry) => entry.hostId === target.hostId && entry.id === target.deviceId) : undefined; const host = target ? state.hosts.find((entry) => entry.id === target.hostId) : undefined; return (
{props.title} {target ? ( {host?.label ?? "Device host"} ยท{" "} {device?.version ?? (target.platform === "ios" ? "iOS" : "Android")} ) : null}
); }