import { LoadingSpinner } from "@/components/ui/loading-spinner"; import React, { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type Dispatch, type SetStateAction, } from "react"; import { Pressable, Text, View, type LayoutChangeEvent, type PressableStateCallbackType, } from "react-native"; import { CopyX, ArrowLeftToLine, ArrowRightToLine, Copy, Pencil, RotateCw, Globe, FileDiff, FolderTree, GitPullRequest, Maximize2, Minimize2, Plus, SquarePen, SquareTerminal, X, } from "lucide-react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import Animated, { useAnimatedScrollHandler, useAnimatedStyle, useSharedValue, type AnimatedStyle, } from "react-native-reanimated"; import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from "react-native-svg"; import { useTranslation } from "react-i18next"; import { useRouter, type Href } from "expo-router"; import { SortableInlineList } from "@/components/sortable-inline-list"; import type { DraggableListDragHandleProps, DraggableRenderItemInfo, } from "@/components/draggable-list.types"; import { isNative, isWeb } from "@/constants/platform"; import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger, } from "@/components/ui/context-menu"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuLabel, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Shortcut } from "@/components/ui/shortcut"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; import { WORKSPACE_SECONDARY_HEADER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout"; import { buttonControlHeight } from "@/components/ui/control-geometry"; import type { ShortcutKey } from "@/utils/format-shortcut"; import { useWorkspaceTabLayout } from "@/screens/workspace/use-workspace-tab-layout"; import { retainWorkspaceTabMeasuredWidth } from "@/screens/workspace/workspace-tab-layout"; import { WorkspaceTabPresentationResolver, WorkspaceTabIcon, type WorkspaceTabPresentation, } from "@/screens/workspace/workspace-tab-presentation"; import { buildDeterministicWorkspaceTabId } from "@/workspace-tabs/identity"; import { buildWorkspaceDesktopTabActions, type WorkspaceDesktopTabActions, type WorkspaceTabMenuEntry, type WorkspaceTabMenuLabels, } from "@/screens/workspace/workspace-tab-menu"; import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; import type { SurfaceBackdrop } from "@/styles/surface-backdrop"; import type { Theme } from "@/styles/theme"; import { RenderProfile } from "@/utils/render-profiler"; import { useDaemonConfig } from "@/hooks/use-daemon-config"; import { getTerminalProfileIcon, resolveTerminalProfiles, } from "@getpaseo/protocol/terminal-profiles"; import { buildSettingsHostSectionRoute } from "@/utils/host-routes"; import type { TerminalProfileInput } from "@/screens/workspace/terminals/use-workspace-terminals"; import { TerminalProfileIcon } from "@/components/terminal-profile-icon"; import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; import { useWorkspaceDirectory } from "@/stores/session-store-hooks"; import { useCheckoutStatusQuery } from "@/git/use-status-query"; import { TrailingActionScrim } from "@/components/ui/trailing-action-scrim"; import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher"; const DROPDOWN_WIDTH = 220; const DEFAULT_INLINE_ADD_BUTTON_RESERVED_WIDTH = 36; // Chip geometry. `layoutMetrics` measures tabs from these same numbers, so a chip that changes // shape without changing them mis-measures and drops the row into the overflow-scroll fallback at // the wrong width. Keep them together. // Tabs and the adjacent New Tab trigger are one control family. Keep their outer box and corner // token identical; only their horizontal sizing differs (content-width chip versus square icon). const TAB_CHIP_HORIZONTAL_PADDING = 8; const TAB_CHIP_GAP = 4; const TAB_ROW_PADDING_HORIZONTAL = 4; const TAB_ICON_WIDTH = 14; const TAB_CONTENT_GAP = 4; const TAB_DROP_INDICATOR_WIDTH = 4; const TAB_MIN_WIDTH = 96; const TAB_MAX_WIDTH = 160; const TAB_CLOSE_BUTTON_RESERVED_WIDTH = 0; const TAB_LABEL_LAYOUT_ALLOWANCE = 4; const TAB_SCROLL_SHADE_WIDTH = 24; const TAB_SCROLL_EDGE_EPSILON = 1; const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); const ThemedX = withUnistyles(X); const ThemedCopy = withUnistyles(Copy); function WorkspaceTabScrollShadeSvg({ side, color }: { side: "left" | "right"; color: string }) { const gradientId = `workspace-tab-scroll-shade-${side}-${useId().replace(/[^a-zA-Z0-9_-]/g, "")}`; return ( ); } const ThemedWorkspaceTabScrollShadeSvg = withUnistyles(WorkspaceTabScrollShadeSvg); const tabScrollShadeColorMapping = (theme: Theme) => ({ color: theme.colors.surface0 }); function WorkspaceTabScrollShades({ visible, leftStyle, rightStyle, }: { visible: boolean; leftStyle: AnimatedStyle<{ opacity: number }>; rightStyle: AnimatedStyle<{ opacity: number }>; }) { if (!visible) return null; return ( <> ); } const ThemedRotateCw = withUnistyles(RotateCw); const ThemedArrowLeftToLine = withUnistyles(ArrowLeftToLine); const ThemedArrowRightToLine = withUnistyles(ArrowRightToLine); const ThemedCopyX = withUnistyles(CopyX); const ThemedPencil = withUnistyles(Pencil); const ThemedSquarePen = withUnistyles(SquarePen); const ThemedSquareTerminal = withUnistyles(SquareTerminal); const ThemedGlobe = withUnistyles(Globe); const ThemedPlus = withUnistyles(Plus); const ThemedFileDiff = withUnistyles(FileDiff); const ThemedFolderTree = withUnistyles(FolderTree); const ThemedGitPullRequest = withUnistyles(GitPullRequest); const ThemedMaximize2 = withUnistyles(Maximize2); const ThemedMinimize2 = withUnistyles(Minimize2); const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground }); const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); const extraMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundExtraMuted }); const AGENT_ICON = ; const TERMINAL_ICON = ; const BROWSER_ICON = ; const CHANGES_ICON = ; const FILES_ICON = ; const PULL_REQUEST_ICON = ; const CHANGES_TARGET = { kind: "working_diff" } as const; const FILES_TARGET = { kind: "files" } as const; const PULL_REQUEST_TARGET = { kind: "pull_request" } as const; function inlineAddActionButtonStyle({ hovered, pressed, open = false, }: PressableStateCallbackType & { open?: boolean }) { return [ styles.inlineAddActionButton, (hovered || pressed || open) && styles.newTabActionButtonHovered, ]; } function updateMeasuredWidth( setWidth: React.Dispatch>, event: LayoutChangeEvent, ) { const nextWidth = Math.round(event.nativeEvent.layout.width); setWidth((current) => retainWorkspaceTabMeasuredWidth(current, nextWidth)); } function TabLabelMeasurement({ tabKey, label, onMeasure, }: { tabKey: string; label: string; onMeasure: (tabKey: string, label: string, event: LayoutChangeEvent) => void; }) { const handleLayout = useCallback( (event: LayoutChangeEvent) => onMeasure(tabKey, label, event), [label, onMeasure, tabKey], ); return ( {label} ); } interface TerminalProfileMenuItemProps { profile: { id: string; name: string; command: string; args?: string[]; icon?: string }; disabled?: boolean; onLaunch: (profile: TerminalProfileInput) => void; } function TerminalProfileMenuItem({ profile, disabled, onLaunch }: TerminalProfileMenuItemProps) { const leading = useMemo( () => , [profile], ); const handleSelect = useCallback(() => onLaunch(profile), [onLaunch, profile]); return ( {profile.name} ); } interface TabTargetLauncherOptions { normalizedServerId: string; onCreateAgentTab: () => void; onCreateTerminal: () => void; onCreateBrowser: () => void; onOpenChanges: () => void; onOpenFiles: () => void; onOpenPullRequest: () => void; onCreateTerminalWithProfile: (profile: TerminalProfileInput) => void; } interface WorkspaceNewTabButtonProps extends TabTargetLauncherOptions { open: boolean; onOpenChange: (open: boolean) => void; shortcutKeys: ShortcutKey[][] | null; onEditProfiles: () => void; showCreateBrowserTab: boolean; terminalDisabled: boolean; isGit: boolean; showPullRequest: boolean; onLayout: (event: LayoutChangeEvent) => void; } function WorkspaceNewTabButton({ open, onOpenChange, shortcutKeys, onCreateAgentTab, onCreateTerminal, onCreateBrowser, onOpenChanges, onOpenFiles, onOpenPullRequest, onCreateTerminalWithProfile, onEditProfiles, normalizedServerId, showCreateBrowserTab, terminalDisabled, isGit, showPullRequest, onLayout, }: WorkspaceNewTabButtonProps) { const { t } = useTranslation(); const { config } = useDaemonConfig(normalizedServerId); const profiles = useMemo( () => resolveTerminalProfiles(config?.terminalProfiles), [config?.terminalProfiles], ); const agentKeys = useShortcutKeys("workspace-tab-target-agent"); const terminalKeys = useShortcutKeys("workspace-terminal-new"); const browserKeys = useShortcutKeys("workspace-tab-target-browser"); const changesKeys = useShortcutKeys("workspace-tab-target-changes"); const filesKeys = useShortcutKeys("workspace-tab-target-files"); const tooltipText = t("workspace.tabs.actions.newTab"); const agentShortcut = useMemo( () => (agentKeys ? : undefined), [agentKeys], ); const terminalShortcut = useMemo( () => (terminalKeys ? : undefined), [terminalKeys], ); const browserShortcut = useMemo( () => (browserKeys ? : undefined), [browserKeys], ); const changesShortcut = useMemo( () => (changesKeys ? : undefined), [changesKeys], ); const filesShortcut = useMemo( () => (filesKeys ? : undefined), [filesKeys], ); return ( {tooltipText} {shortcutKeys ? : null} {t("workspace.tabs.actions.newAgent")} {t("workspace.tabs.actions.newTerminal")} {showCreateBrowserTab ? ( {t("workspace.tabs.actions.newBrowser")} ) : null} {isGit ? ( {t("workspace.tabs.actions.changes")} ) : null} {t("workspace.tabs.actions.files")} {showPullRequest ? ( {t("workspace.tabs.actions.pullRequest")} ) : null} {t("workspace.tabs.actions.terminalProfilesMenu")} {profiles.map((profile) => ( ))} {t("workspace.tabs.actions.editTerminalProfiles")} ); } function WorkspacePaneMaximizeButton({ visible, maximized, onPress, onLayout, }: { visible: boolean; maximized: boolean; onPress?: () => void; onLayout: (event: LayoutChangeEvent) => void; }) { const { t } = useTranslation(); const maximizePaneKeys = useShortcutKeys("workspace-explorer-maximize"); if (!visible || !onPress) { return null; } const label = t( maximized ? "workspace.tabs.actions.restorePane" : "workspace.tabs.actions.maximizePane", ); return ( {maximized ? ( ) : ( )} {label} {maximizePaneKeys ? : null} ); } function WorkspaceExitFocusModeButton({ visible, onPress, onLayout, }: { visible: boolean; onPress: () => void; onLayout: (event: LayoutChangeEvent) => void; }) { const { t } = useTranslation(); const focusModeKeys = useShortcutKeys("toggle-focus"); if (!visible) { return null; } return ( {t("workspace.tabs.actions.exitFocusMode")} {focusModeKeys ? ( ) : null} ); } function resolvePaneMaximizeReservedWidth(visible: boolean, measuredWidth: number): number { return visible ? measuredWidth : 0; } function TabContextMenuItem({ entry, }: { entry: Extract; }) { const leading = useMemo(() => { switch (entry.icon) { case "copy": return ; case "rotate-cw": return ; case "arrow-left-to-line": return ; case "arrow-right-to-line": return ; case "copy-x": return ; case "pencil": return ; case "x": return ; default: return undefined; } }, [entry.icon]); const trailing = useMemo( () => (entry.hint ? {entry.hint} : undefined), [entry.hint], ); return ( {entry.label} ); } function tabKeyExtractor(tab: WorkspaceDesktopTabRowItem) { return `${tab.tab.key}:${tab.tab.kind}`; } export interface WorkspaceDesktopTabRowItem { tab: WorkspaceTabDescriptor; isActive: boolean; isCloseHovered: boolean; isClosingTab: boolean; } interface ResolvedWorkspaceDesktopTabRowItem extends WorkspaceDesktopTabRowItem { presentation: WorkspaceTabPresentation; } interface WorkspaceTabLabel { key: string; label: string; } interface WorkspaceTabLabelMeasurement { label: string; width: number; } interface WorkspaceTabTrackSnapshot { signature: string; tabs: ResolvedWorkspaceDesktopTabRowItem[]; labels: WorkspaceTabLabel[]; labelWidths: number[]; } function workspaceTabLabelSignature(labels: WorkspaceTabLabel[]): string { return JSON.stringify(labels); } function completeWorkspaceTabLabelWidths( labels: WorkspaceTabLabel[], measurements: Map, ): number[] | null { const widths: number[] = []; for (const { key, label } of labels) { const measurement = measurements.get(key); if (!measurement || measurement.label !== label || measurement.width <= 0) { return null; } widths.push(measurement.width + TAB_LABEL_LAYOUT_ALLOWANCE); } return widths; } function sameWidths(left: number[], right: number[]): boolean { return left.length === right.length && left.every((width, index) => width === right[index]); } interface WorkspaceDesktopTabsRowProps { paneId?: string; isFocused?: boolean; tabs: WorkspaceDesktopTabRowItem[]; normalizedServerId: string; normalizedWorkspaceId: string; setHoveredCloseTabKey: Dispatch>; onNavigateTab: (tabId: string) => void; onCloseTab: (tabId: string) => Promise | void; onCopyResumeCommand: (agentId: string) => Promise | void; onCopyAgentId: (agentId: string) => Promise | void; onCopyTerminalId: (terminalId: string) => Promise | void; onCopyFilePath: (path: string) => Promise | void; onReloadAgent: (agentId: string) => Promise | void; onRenameTab: (tab: WorkspaceTabDescriptor) => void; onCloseTabsToLeft: (tabId: string) => Promise | void; onCloseTabsToRight: (tabId: string) => Promise | void; onCloseOtherTabs: (tabId: string) => Promise | void; onCreateDraftTab: (input: { paneId?: string }) => void; onCreateTerminalTab: (input: { paneId?: string; profile?: TerminalProfileInput }) => void; onCreateBrowserTab: (input: { paneId?: string }) => void; showCreateBrowserTab?: boolean; disableCreateTerminal?: boolean; isWaitingOnTerminalReadiness?: boolean; onReorderTabs: (nextTabs: WorkspaceTabDescriptor[]) => void; externalDndContext?: boolean; activeDragTabId?: string | null; tabDropPreviewIndex?: number | null; showPaneMaximizeAction?: boolean; paneMaximized?: boolean; onTogglePaneMaximized?: () => void; focusModeEnabled: boolean; onExitFocusMode: () => void; } interface ResolvedWorkspaceDesktopTabsRowProps extends Omit { tabs: ResolvedWorkspaceDesktopTabRowItem[]; } interface WorkspaceDesktopTabPresentationSlotProps { tab: WorkspaceTabDescriptor; serverId: string; workspaceId: string; onResolve: (tabKey: string, presentation: WorkspaceTabPresentation) => void; } const EMPTY_RESOLVED_TAB_ROWS: ResolvedWorkspaceDesktopTabRowItem[] = []; function WorkspaceDesktopTabPresentationSlot({ tab, serverId, workspaceId, onResolve, }: WorkspaceDesktopTabPresentationSlotProps) { return ( {(presentation) => ( )} ); } function WorkspaceDesktopTabPresentationCommit({ tabKey, presentation, onResolve, }: { tabKey: string; presentation: WorkspaceTabPresentation; onResolve: (tabKey: string, presentation: WorkspaceTabPresentation) => void; }) { useLayoutEffect(() => { onResolve(tabKey, presentation); }, [onResolve, presentation, tabKey]); return null; } function getFallbackTabLabel( tab: WorkspaceTabDescriptor, labels: { newAgent: string; setup: string; terminal: string; agent: string; changes: string; files: string; pullRequest: string; }, ): string { if (tab.target.kind === "draft") { return labels.newAgent; } if (tab.target.kind === "setup") { return labels.setup; } if (tab.target.kind === "terminal") { return labels.terminal; } if (tab.target.kind === "file") { return tab.target.path.split("/").findLast(Boolean) ?? tab.target.path; } if (tab.target.kind === "working_diff") { return labels.changes; } if (tab.target.kind === "files") { return labels.files; } if (tab.target.kind === "pull_request") { return labels.pullRequest; } return labels.agent; } function useMiddleClickClose(onClose: () => void) { const ref = useRef(null); useEffect(() => { if (isNative) return; const node = ref.current as unknown as HTMLElement | null; if (!node) return; function handleAuxClick(event: MouseEvent) { if (event.button === 1) { event.preventDefault(); onClose(); } } node.addEventListener("auxclick", handleAuxClick); return () => node.removeEventListener("auxclick", handleAuxClick); }, [onClose]); return ref; } /** The chip fill the running-status ring has to knock out of. Mirrors `styles.tab*` exactly. */ function resolveChipBackdrop({ isActiveFocused, isFilled, }: { isActiveFocused: boolean; isFilled: boolean; }): SurfaceBackdrop { if (isActiveFocused) return "surface2"; return isFilled ? "surface1" : "surface0"; } function TabHandleContent({ presentation, isHighlighted, showLabel, backdrop, tabLabelSkeletonStyle, tabLabelStyle, }: { presentation: WorkspaceTabPresentation; isHighlighted: boolean; showLabel: boolean; backdrop: SurfaceBackdrop; tabLabelSkeletonStyle: React.ComponentProps["style"]; tabLabelStyle: React.ComponentProps["style"]; }) { const tabHandleDataSet = useMemo( () => ({ statusBucket: presentation.statusBucket ?? "none" }), [presentation.statusBucket], ); return ( {showLabel && presentation.titleState === "loading" ? ( ) : null} {showLabel && presentation.titleState !== "loading" ? ( {presentation.label} ) : null} ); } function TabChip({ tab, isActive, isDragging, isFocused, resolvedTabWidth, showLabel, showCloseButton, isCloseHovered, isClosingTab, presentation, tooltipLabel, resolvedTab, setHoveredCloseTabKey, onNavigateTab, onCloseTab, dragHandleProps, }: { tab: WorkspaceTabDescriptor; isActive: boolean; isDragging: boolean; isFocused: boolean; resolvedTabWidth: number; showLabel: boolean; showCloseButton: boolean; isCloseHovered: boolean; isClosingTab: boolean; presentation: WorkspaceTabPresentation; tooltipLabel: string; resolvedTab: WorkspaceDesktopTabActions; setHoveredCloseTabKey: Dispatch>; onNavigateTab: (tabId: string) => void; onCloseTab: (tabId: string) => Promise | void; dragHandleProps: DraggableListDragHandleProps | undefined; }) { const { t } = useTranslation(); const { closeButtonTestId, contextMenuTestId, menuEntries } = resolvedTab; const middleClickRef = useMiddleClickClose( useCallback(() => void onCloseTab(tab.tabId), [onCloseTab, tab.tabId]), ); const isCompact = useIsCompactFormFactor(); const [hovered, setHovered] = useState(false); // An active tab in a pane that does not have focus stays legible but quiet: it keeps the fill of // a hovered chip and the muted label, so only one chip in the window reads as the live one. const isActiveFocused = isActive && isFocused; const isHovered = hovered || isCloseHovered; const isHighlighted = isActiveFocused || isHovered; const chipBackdrop: SurfaceBackdrop = resolveChipBackdrop({ isActiveFocused, isFilled: isActive || isHovered, }); const showCloseControl = showCloseButton && (isHovered || isNative || isCompact || isClosingTab); const closeButtonDragBlockers = isWeb ? ({ onPointerDown: (event: { stopPropagation?: () => void }) => { event.stopPropagation?.(); }, onMouseDown: (event: { stopPropagation?: () => void }) => { event.stopPropagation?.(); }, } as const) : undefined; const tabChipStyle = useCallback( () => [ styles.tab, isActiveFocused && styles.tabActive, isActive && !isFocused && styles.tabActiveUnfocused, !isActive && isHovered && styles.tabHovered, isWeb && isDragging && ({ cursor: "grabbing" } as object), { minWidth: resolvedTabWidth, width: resolvedTabWidth, maxWidth: resolvedTabWidth, }, ], [isActive, isActiveFocused, isDragging, isFocused, isHovered, resolvedTabWidth], ); const handleTabPointerEnter = useCallback(() => { setHovered(true); }, []); const handleTabPointerLeave = useCallback(() => { setHovered(false); }, []); const handleNavigateTab = useCallback(() => { onNavigateTab(tab.tabId); }, [onNavigateTab, tab.tabId]); const handleCloseButtonPressIn = useCallback((event: { stopPropagation?: () => void }) => { event.stopPropagation?.(); }, []); const handleCloseButtonHoverIn = useCallback(() => { setHoveredCloseTabKey(tab.key); }, [setHoveredCloseTabKey, tab.key]); const handleCloseButtonHoverOut = useCallback(() => { setHoveredCloseTabKey((current) => (current === tab.key ? null : current)); }, [setHoveredCloseTabKey, tab.key]); const handleCloseButtonPress = useCallback( (event: { stopPropagation?: () => void }) => { event.stopPropagation?.(); void onCloseTab(tab.tabId); }, [onCloseTab, tab.tabId], ); const tabAccessibilityState = useMemo(() => ({ selected: isActive }), [isActive]); const tabLabelSkeletonStyle = styles.tabLabelSkeleton; const tabLabelStyle = useMemo( () => [styles.tabLabel, isHighlighted && styles.tabLabelActive], [isHighlighted], ); return ( {presentation.modified ? ( ) : null} {tab.target.kind === "agent" ? ( {tooltipLabel} {tab.target.agentId.slice(0, 7)} ) : ( {tooltipLabel} )} {showCloseButton ? ( {({ hovered: closeHovered, pressed }) => { const highlighted = closeHovered || pressed; if (isClosingTab) { return ( ); } return ( ); }} ) : null} {menuEntries.map((entry) => entry.kind === "separator" ? ( ) : ( ), )} ); } export function WorkspaceDesktopTabsRow(props: WorkspaceDesktopTabsRowProps) { const [presentations, setPresentations] = useState( () => new Map(), ); const handlePresentation = useCallback( (tabKey: string, presentation: WorkspaceTabPresentation) => { setPresentations((current) => { if (current.get(tabKey) === presentation) { return current; } const next = new Map(current); next.set(tabKey, presentation); return next; }); }, [], ); const currentTabKeys = useMemo( () => new Set(props.tabs.map((item) => item.tab.key)), [props.tabs], ); useEffect(() => { setPresentations((current) => { const removedKeys = [...current.keys()].filter((key) => !currentTabKeys.has(key)); if (removedKeys.length === 0) { return current; } const next = new Map(current); for (const key of removedKeys) { next.delete(key); } return next; }); }, [currentTabKeys]); const resolvedTabs = useMemo( () => props.tabs.flatMap((item) => { const presentation = presentations.get(item.tab.key); return presentation ? [{ ...item, presentation }] : []; }), [presentations, props.tabs], ); return ( <> {props.tabs.map(({ tab }) => ( ))} ); } function ResolvedWorkspaceDesktopTabsRow({ paneId, isFocused = false, tabs, normalizedServerId, normalizedWorkspaceId, setHoveredCloseTabKey, onNavigateTab, onCloseTab, onCopyResumeCommand, onCopyAgentId, onCopyTerminalId, onCopyFilePath, onReloadAgent, onRenameTab, onCloseTabsToLeft, onCloseTabsToRight, onCloseOtherTabs, onCreateDraftTab, onCreateTerminalTab, onCreateBrowserTab, showCreateBrowserTab = false, disableCreateTerminal = false, isWaitingOnTerminalReadiness = false, onReorderTabs, externalDndContext = false, activeDragTabId = null, tabDropPreviewIndex = null, showPaneMaximizeAction = false, paneMaximized = false, onTogglePaneMaximized, focusModeEnabled, onExitFocusMode, }: ResolvedWorkspaceDesktopTabsRowProps) { const { t } = useTranslation(); const router = useRouter(); const newTabKeys = useShortcutKeys("workspace-tab-new"); const [tabsContainerWidth, setTabsContainerWidth] = useState(0); const [inlineAddButtonWidth, setInlineAddButtonWidth] = useState(0); const [paneMaximizeButtonWidth, setPaneMaximizeButtonWidth] = useState(0); const [exitFocusModeWidth, setExitFocusModeWidth] = useState(0); const [newTabMenuOpen, setNewTabMenuOpen] = useState(false); const tabScrollOffset = useSharedValue(0); const tabScrollViewportWidth = useSharedValue(0); const tabScrollContentWidth = useSharedValue(0); const labelMeasurementsRef = useRef(new Map()); const [trackSnapshot, setTrackSnapshot] = useState(null); const workspaceRoot = useWorkspaceDirectory(normalizedServerId, normalizedWorkspaceId) ?? ""; const checkoutStatus = useCheckoutStatusQuery({ serverId: normalizedServerId, cwd: workspaceRoot, }); const isGit = checkoutStatus.status?.isGit === true; const showPullRequest = isGit; const workspaceKey = buildWorkspaceTabPersistenceKey({ serverId: normalizedServerId, workspaceId: normalizedWorkspaceId, }); const focusPane = useWorkspaceLayoutStore((state) => state.focusPane); const openTabFocused = useWorkspaceLayoutStore((state) => state.openTabFocused); const handleTabsContainerLayout = useCallback((event: LayoutChangeEvent) => { updateMeasuredWidth(setTabsContainerWidth, event); }, []); const handleInlineAddButtonLayout = useCallback((event: LayoutChangeEvent) => { updateMeasuredWidth(setInlineAddButtonWidth, event); }, []); const handleExitFocusModeLayout = useCallback((event: LayoutChangeEvent) => { updateMeasuredWidth(setExitFocusModeWidth, event); }, []); const handlePaneMaximizeButtonLayout = useCallback((event: LayoutChangeEvent) => { updateMeasuredWidth(setPaneMaximizeButtonWidth, event); }, []); const handleTabScrollLayout = useCallback( (event: LayoutChangeEvent) => { tabScrollViewportWidth.value = event.nativeEvent.layout.width; }, [tabScrollViewportWidth], ); const handleTabScrollContentSizeChange = useCallback( (width: number) => { tabScrollContentWidth.value = width; }, [tabScrollContentWidth], ); const handleTabScroll = useAnimatedScrollHandler((event) => { tabScrollOffset.value = event.contentOffset.x; tabScrollViewportWidth.value = event.layoutMeasurement.width; tabScrollContentWidth.value = event.contentSize.width; }); const leftTabScrollShadeStyle = useAnimatedStyle(() => ({ opacity: Number(tabScrollOffset.value > TAB_SCROLL_EDGE_EPSILON), })); const rightTabScrollShadeStyle = useAnimatedStyle(() => ({ opacity: Number( tabScrollOffset.value + tabScrollViewportWidth.value < tabScrollContentWidth.value - TAB_SCROLL_EDGE_EPSILON, ), })); const layoutMetrics = useMemo( () => ({ rowHorizontalInset: 0, actionsReservedWidth: Math.max( 0, (inlineAddButtonWidth || DEFAULT_INLINE_ADD_BUTTON_RESERVED_WIDTH) + (focusModeEnabled ? exitFocusModeWidth : 0) + resolvePaneMaximizeReservedWidth(showPaneMaximizeAction, paneMaximizeButtonWidth), ), rowPaddingHorizontal: TAB_ROW_PADDING_HORIZONTAL, tabGap: TAB_CHIP_GAP, minTabWidth: TAB_MIN_WIDTH, maxTabWidth: TAB_MAX_WIDTH, tabIconWidth: TAB_ICON_WIDTH, tabContentGap: TAB_CONTENT_GAP, tabHorizontalPadding: TAB_CHIP_HORIZONTAL_PADDING, closeButtonWidth: TAB_CLOSE_BUTTON_RESERVED_WIDTH, }), [ exitFocusModeWidth, focusModeEnabled, inlineAddButtonWidth, paneMaximizeButtonWidth, showPaneMaximizeAction, ], ); const fallbackTabLabels = useMemo( () => ({ newAgent: t("workspace.tabs.fallback.newAgent"), setup: t("workspace.tabs.fallback.setup"), terminal: t("workspace.tabs.fallback.terminal"), agent: t("workspace.tabs.fallback.agent"), changes: t("panels.diff.changesLabel"), files: t("panels.files.label"), pullRequest: t("panels.pullRequest.label"), }), [t], ); const tabMenuLabels = useMemo( () => ({ copyResumeCommand: t("workspace.tabs.menu.copyResumeCommand"), copyAgentId: t("workspace.tabs.menu.copyAgentId"), copyTerminalId: t("workspace.tabs.menu.copyTerminalId"), copyFilePath: t("workspace.tabs.menu.copyFilePath"), rename: t("workspace.tabs.menu.rename"), closeAbove: t("workspace.tabs.menu.closeAbove"), closeBelow: t("workspace.tabs.menu.closeBelow"), closeLeft: t("workspace.tabs.menu.closeLeft"), closeRight: t("workspace.tabs.menu.closeRight"), closeOthers: t("workspace.tabs.menu.closeOthers"), reloadAgent: t("workspace.tabs.menu.reloadAgent"), reloadAgentTooltip: t("workspace.tabs.menu.reloadAgentTooltip"), close: t("workspace.tabs.menu.close"), }), [t], ); const tabLabels = useMemo( () => tabs.map((tab) => { const label = tab.presentation.titleState === "loading" ? getFallbackTabLabel(tab.tab, fallbackTabLabels) : tab.presentation.label; return { key: tab.tab.key, label }; }), [fallbackTabLabels, tabs], ); const tabLabelSignature = useMemo(() => workspaceTabLabelSignature(tabLabels), [tabLabels]); const publishMeasuredTrack = useCallback(() => { if (tabsContainerWidth <= 0) { return; } const labelWidths = completeWorkspaceTabLabelWidths(tabLabels, labelMeasurementsRef.current); if (!labelWidths) { return; } const retainedMeasurements = new Map(); for (const { key } of tabLabels) { const measurement = labelMeasurementsRef.current.get(key); if (measurement) { retainedMeasurements.set(key, measurement); } } labelMeasurementsRef.current = retainedMeasurements; setTrackSnapshot((current) => { if ( current?.signature === tabLabelSignature && sameWidths(current.labelWidths, labelWidths) ) { return current; } return { signature: tabLabelSignature, tabs, labels: tabLabels, labelWidths, }; }); }, [tabLabelSignature, tabLabels, tabs, tabsContainerWidth]); useLayoutEffect(() => { publishMeasuredTrack(); }, [publishMeasuredTrack]); const handleTabLabelLayout = useCallback( (key: string, label: string, event: LayoutChangeEvent) => { const width = Math.ceil(event.nativeEvent.layout.width); if (width <= 0) { return; } const current = labelMeasurementsRef.current.get(key); if (current?.label === label && current.width === width) { return; } labelMeasurementsRef.current.set(key, { label, width }); publishMeasuredTrack(); }, [publishMeasuredTrack], ); const displayedTabs = useMemo(() => { if (!trackSnapshot) { return EMPTY_RESOLVED_TAB_ROWS; } const currentTabs = new Map( tabs.map((tab, index) => [tab.tab.key, { tab, label: tabLabels[index]?.label }]), ); return trackSnapshot.tabs.map((snapshotTab, index) => { const current = currentTabs.get(snapshotTab.tab.key); return current?.label === trackSnapshot.labels[index]?.label ? current.tab : snapshotTab; }); }, [tabLabels, tabs, trackSnapshot]); const { layout } = useWorkspaceTabLayout({ tabLabelWidths: trackSnapshot?.labelWidths ?? [], viewportWidthOverride: tabsContainerWidth > 0 ? tabsContainerWidth : null, metrics: layoutMetrics, }); const handleDragEnd = useCallback( (nextTabs: ResolvedWorkspaceDesktopTabRowItem[]) => { onReorderTabs(nextTabs.map((tab) => tab.tab)); }, [onReorderTabs], ); const getTabDragData = useMemo(() => { if (!paneId) return undefined; return (tab: ResolvedWorkspaceDesktopTabRowItem) => ({ kind: "workspace-tab" as const, paneId, tabId: tab.tab.tabId, }); }, [paneId]); const handleCreateAgentTab = useCallback(() => { onCreateDraftTab({ paneId }); }, [onCreateDraftTab, paneId]); const handleCreateTerminal = useCallback(() => { onCreateTerminalTab({ paneId }); }, [onCreateTerminalTab, paneId]); const handleCreateTerminalWithProfile = useCallback( (profile: TerminalProfileInput) => { onCreateTerminalTab({ paneId, profile }); }, [onCreateTerminalTab, paneId], ); const handleEditProfiles = useCallback(() => { router.push(buildSettingsHostSectionRoute(normalizedServerId, "terminals") as Href); }, [normalizedServerId, router]); const handleCreateBrowser = useCallback(() => { onCreateBrowserTab({ paneId }); }, [onCreateBrowserTab, paneId]); const openPanelTarget = useCallback( (target: { kind: "working_diff" } | { kind: "files" } | { kind: "pull_request" }) => { if (!workspaceKey) { return; } if (paneId) { focusPane(workspaceKey, paneId); } openTabFocused(workspaceKey, target); }, [focusPane, openTabFocused, paneId, workspaceKey], ); const handleOpenChanges = useCallback(() => openPanelTarget(CHANGES_TARGET), [openPanelTarget]); const handleOpenFiles = useCallback(() => openPanelTarget(FILES_TARGET), [openPanelTarget]); const handleOpenPullRequest = useCallback( () => openPanelTarget(PULL_REQUEST_TARGET), [openPanelTarget], ); const terminalDisabled = disableCreateTerminal || isWaitingOnTerminalReadiness; useEffect(() => { if (!isFocused) { setNewTabMenuOpen(false); } }, [isFocused]); const handleNewTabKeyboardAction = useCallback( (action: KeyboardActionDefinition): boolean => { if (!isFocused) return false; if (action.id === "workspace.tab.menu.open") { setNewTabMenuOpen(true); return true; } switch (action.id) { case "workspace.tab.target.agent": setNewTabMenuOpen(false); handleCreateAgentTab(); return true; case "workspace.tab.target.browser": if (!showCreateBrowserTab) return true; setNewTabMenuOpen(false); handleCreateBrowser(); return true; case "workspace.tab.target.changes": if (!isGit) return true; setNewTabMenuOpen(false); handleOpenChanges(); return true; case "workspace.tab.target.files": setNewTabMenuOpen(false); handleOpenFiles(); return true; default: return false; } }, [ handleCreateAgentTab, handleCreateBrowser, handleOpenChanges, handleOpenFiles, isFocused, isGit, showCreateBrowserTab, ], ); useKeyboardActionHandler({ handlerId: `workspace-new-tab-menu:${normalizedServerId}:${paneId ?? "main"}`, actions: [ "workspace.tab.menu.open", "workspace.tab.target.agent", "workspace.tab.target.browser", "workspace.tab.target.changes", "workspace.tab.target.files", ], enabled: isFocused, priority: 200, handle: handleNewTabKeyboardAction, }); const renderTab = useCallback( ({ item, index, dragHandleProps, isActive, }: DraggableRenderItemInfo) => { const shouldShowCloseButton = layout.closeButtonPolicy === "all"; const layoutItem = layout.items[index] ?? null; const resolvedTabWidth = layoutItem?.width ?? 150; const showLabel = layoutItem?.showLabel ?? true; const showDropIndicatorBefore = activeDragTabId !== null && tabDropPreviewIndex === index; const showDropIndicatorAfter = activeDragTabId !== null && tabDropPreviewIndex === displayedTabs.length && index === displayedTabs.length - 1; return ( ); }, [ activeDragTabId, isFocused, layout.closeButtonPolicy, layout.items, onCloseOtherTabs, onCloseTab, onCloseTabsToLeft, onCloseTabsToRight, onCopyAgentId, onCopyTerminalId, onCopyFilePath, onCopyResumeCommand, onNavigateTab, onReloadAgent, onRenameTab, setHoveredCloseTabKey, tabMenuLabels, tabDropPreviewIndex, displayedTabs.length, ], ); const tabsScrollStyle = useMemo( () => [ styles.tabsScroll, layout.requiresHorizontalScrollFallback ? styles.tabsScrollOverflow : styles.tabsScrollFitContent, ], [layout.requiresHorizontalScrollFallback], ); const row = ( {tabLabels.map(({ key, label }) => ( ))} {!layout.requiresHorizontalScrollFallback ? ( ) : null} {layout.requiresHorizontalScrollFallback ? ( ) : null} ); return {row}; } function ResolvedDesktopTabChip({ item, isFocused, isDragging, index, tabCount, onCopyResumeCommand, onCopyAgentId, onCopyTerminalId, onCopyFilePath, onReloadAgent, onRenameTab, onCloseTabsToLeft, onCloseTabsToRight, onCloseOtherTabs, resolvedTabWidth, showLabel, showCloseButton, setHoveredCloseTabKey, onNavigateTab, onCloseTab, labels, dragHandleProps, showDropIndicatorBefore, showDropIndicatorAfter, }: { item: ResolvedWorkspaceDesktopTabRowItem; isFocused: boolean; isDragging: boolean; index: number; tabCount: number; onCopyResumeCommand: (agentId: string) => Promise | void; onCopyAgentId: (agentId: string) => Promise | void; onCopyTerminalId: (terminalId: string) => Promise | void; onCopyFilePath: (path: string) => Promise | void; onReloadAgent: (agentId: string) => Promise | void; onRenameTab: (tab: WorkspaceTabDescriptor) => void; onCloseTabsToLeft: (tabId: string) => Promise | void; onCloseTabsToRight: (tabId: string) => Promise | void; onCloseOtherTabs: (tabId: string) => Promise | void; resolvedTabWidth: number; showLabel: boolean; showCloseButton: boolean; setHoveredCloseTabKey: Dispatch>; onNavigateTab: (tabId: string) => void; onCloseTab: (tabId: string) => Promise | void; labels: WorkspaceTabMenuLabels; dragHandleProps: DraggableListDragHandleProps | undefined; showDropIndicatorBefore: boolean; showDropIndicatorAfter: boolean; }) { const { t } = useTranslation(); const presentation = item.presentation; const resolvedTab = useMemo( () => buildWorkspaceDesktopTabActions({ tab: item.tab, index, tabCount, onCopyResumeCommand, onCopyAgentId, onCopyTerminalId, onCopyFilePath, onReloadAgent, onRenameTab, onCloseTab, onCloseTabsToLeft, onCloseTabsToRight, onCloseOtherTabs, labels, }), [ index, item.tab, onCloseOtherTabs, onCloseTab, onCloseTabsToLeft, onCloseTabsToRight, onCopyAgentId, onCopyTerminalId, onCopyFilePath, onCopyResumeCommand, labels, onReloadAgent, onRenameTab, tabCount, ], ); const tooltipLabel = presentation.titleState === "loading" ? t("workspace.tabs.loadingAgentTitle") : presentation.tooltip; return ( {showDropIndicatorBefore ? ( ) : null} {showDropIndicatorAfter ? ( ) : null} ); } const styles = StyleSheet.create((theme) => ({ tabsContainer: { minWidth: 0, height: WORKSPACE_SECONDARY_HEADER_HEIGHT, borderBottomWidth: 1, borderBottomColor: theme.colors.border, backgroundColor: theme.colors.surface0, flexDirection: "row", alignItems: "center", overflow: "visible", }, tabsScroll: { minWidth: 0, }, tabsScrollContainer: { minWidth: 0, flex: 1, alignSelf: "stretch", }, tabsScrollFitContent: { flex: 1, }, tabsScrollOverflow: { flex: 1, }, tabsContent: { flexDirection: "row", alignItems: "center", paddingHorizontal: TAB_ROW_PADDING_HORIZONTAL, }, tabScrollShade: { position: "absolute", top: 0, bottom: 1, width: TAB_SCROLL_SHADE_WIDTH, }, tabScrollShadeLeft: { left: 0, }, tabScrollShadeRight: { right: 0, }, exitFocusModeSlot: { alignSelf: "stretch", flexDirection: "row", alignItems: "center", paddingHorizontal: theme.spacing[1], borderRightWidth: 1, borderRightColor: theme.colors.border, }, inlineAddButton: { flexDirection: "row", alignItems: "center", paddingHorizontal: theme.spacing[1], }, paneMaximizeButtonSlot: { // Keep the glyph centered in its hit target while placing that glyph on the trailing rail. marginRight: theme.spacing[2], }, tab: { height: buttonControlHeight.xs, paddingHorizontal: TAB_CHIP_HORIZONTAL_PADDING, borderRadius: theme.borderRadius.md, flexDirection: "row", alignItems: "center", gap: theme.spacing[1], userSelect: "none", }, tabHovered: { backgroundColor: theme.colors.surface1, }, tabActive: { backgroundColor: theme.colors.surface2, }, tabActiveUnfocused: { backgroundColor: theme.colors.surface1, }, tabHoverFrame: { position: "relative", }, tabSlot: { position: "relative", overflow: "visible", marginHorizontal: TAB_CHIP_GAP / 2, }, tabHandle: { flexDirection: "row", alignItems: "center", gap: theme.spacing[1], flex: 1, minWidth: 0, userSelect: "none", }, tabIcon: { width: TAB_ICON_WIDTH, height: TAB_ICON_WIDTH, flexShrink: 0, alignItems: "center", justifyContent: "center", }, // The chip box stops at the slot's padding box, so the gap between two chips runs from // -TAB_CHIP_GAP to 0. Centre a TAB_DROP_INDICATOR_WIDTH pill in it. tabDropIndicator: { position: "absolute", top: theme.spacing[0.5], bottom: theme.spacing[0.5], width: TAB_DROP_INDICATOR_WIDTH, borderRadius: theme.borderRadius.full, backgroundColor: theme.colors.accent, zIndex: 10, pointerEvents: "none", }, tabDropIndicatorBefore: { left: -TAB_CHIP_GAP / 2 - TAB_DROP_INDICATOR_WIDTH / 2, }, tabDropIndicatorAfter: { right: -TAB_CHIP_GAP / 2 - TAB_DROP_INDICATOR_WIDTH / 2, }, tabLabel: { flexShrink: 1, minWidth: 0, color: theme.colors.foregroundMuted, fontSize: theme.fontSize.base, fontWeight: theme.fontWeight.normal, userSelect: "none", }, tabLabelMeasurements: { position: "absolute", top: 0, left: 0, opacity: 0, alignItems: "flex-start", pointerEvents: "none", }, tabLabelMeasurement: { flexShrink: 0, }, tabLabelSkeleton: { width: 96, maxWidth: "100%", flexShrink: 1, minWidth: 0, height: 10, borderRadius: theme.borderRadius.full, backgroundColor: theme.colors.surface3, opacity: 0.9, }, tabLabelActive: { color: theme.colors.foreground, }, tabTrailingOverlay: { position: "absolute", top: 0, right: 0, bottom: 0, width: 48, borderTopRightRadius: theme.borderRadius.md, borderBottomRightRadius: theme.borderRadius.md, alignItems: "center", justifyContent: "center", zIndex: 2, overflow: "hidden", }, tabTrailingOverlayShown: { opacity: 1, }, tabTrailingOverlayHidden: { opacity: 0, }, tabCloseButton: { position: "absolute", right: 4, width: 18, height: 18, borderRadius: theme.borderRadius.sm, alignItems: "center", justifyContent: "center", zIndex: 1, }, tabModifiedIndicator: { position: "absolute", right: 8, top: 0, bottom: 0, alignItems: "center", justifyContent: "center", }, tabModifiedDot: { width: 8, height: 8, borderRadius: theme.borderRadius.full, backgroundColor: theme.colors.foregroundMuted, }, inlineAddActionButton: { width: buttonControlHeight.xs, height: buttonControlHeight.xs, borderRadius: theme.borderRadius.md, alignItems: "center", justifyContent: "center", outlineWidth: 0, outlineColor: "transparent", }, newTabActionButtonHovered: { backgroundColor: theme.colors.surface2, }, newTabTooltipText: { color: theme.colors.foreground, fontSize: theme.fontSize.base, }, newTabTooltipRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], }, newTabTooltipShortcut: {}, tooltipAgentRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], }, tooltipAgentId: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, }, menuItemHint: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, }, }));