import { useCallback, useEffect, useMemo, useRef, useState, type Dispatch, type SetStateAction, } from "react"; import { ActivityIndicator, Pressable, ScrollView, Text, View, type LayoutChangeEvent, type PressableStateCallbackType, } from "react-native"; import { CopyX, ArrowLeftToLine, ArrowRightToLine, Columns2, Copy, RotateCw, Rows2, SquarePen, SquareTerminal, X, } from "lucide-react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; 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 { 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 } from "@/constants/layout"; import { useWorkspaceTabLayout } from "@/screens/workspace/use-workspace-tab-layout"; import { WorkspaceTabPresentationResolver, WorkspaceTabIcon, type WorkspaceTabPresentation, } from "@/screens/workspace/workspace-tab-presentation"; import { buildWorkspaceDesktopTabActions, type WorkspaceDesktopTabActions, type WorkspaceTabMenuEntry, } from "@/screens/workspace/workspace-tab-menu"; import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; import type { Theme } from "@/styles/theme"; const DROPDOWN_WIDTH = 220; const LOADING_TAB_LABEL_SKELETON_WIDTH = 80; const ThemedActivityIndicator = withUnistyles(ActivityIndicator); const ThemedX = withUnistyles(X); const ThemedCopy = withUnistyles(Copy); const ThemedRotateCw = withUnistyles(RotateCw); const ThemedArrowLeftToLine = withUnistyles(ArrowLeftToLine); const ThemedArrowRightToLine = withUnistyles(ArrowRightToLine); const ThemedCopyX = withUnistyles(CopyX); const ThemedSquarePen = withUnistyles(SquarePen); const ThemedSquareTerminal = withUnistyles(SquareTerminal); const ThemedColumns2 = withUnistyles(Columns2); const ThemedRows2 = withUnistyles(Rows2); const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground }); const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); function newTabActionButtonStyle({ hovered, pressed }: PressableStateCallbackType) { return [styles.newTabActionButton, (hovered || pressed) && styles.newTabActionButtonHovered]; } 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 "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 WorkspaceDesktopTabsRowProps { paneId?: string; isFocused?: boolean; tabs: WorkspaceDesktopTabRowItem[]; normalizedServerId: string; normalizedWorkspaceId: string; setHoveredTabKey: Dispatch>; setHoveredCloseTabKey: Dispatch>; onNavigateTab: (tabId: string) => void; onCloseTab: (tabId: string) => Promise | void; onCopyResumeCommand: (agentId: string) => Promise | void; onCopyAgentId: (agentId: string) => Promise | void; onReloadAgent: (agentId: string) => Promise | 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 }) => void; disableCreateTerminal?: boolean; isWaitingOnTerminalReadiness?: boolean; onReorderTabs: (nextTabs: WorkspaceTabDescriptor[]) => void; onSplitRight: () => void; onSplitDown: () => void; externalDndContext?: boolean; activeDragTabId?: string | null; tabDropPreviewIndex?: number | null; showPaneSplitActions?: boolean; } function getFallbackTabLabel(tab: WorkspaceTabDescriptor): string { if (tab.target.kind === "draft") { return "New Agent"; } if (tab.target.kind === "setup") { return "Setup"; } if (tab.target.kind === "terminal") { return "Terminal"; } if (tab.target.kind === "file") { return tab.target.path.split("/").findLast(Boolean) ?? tab.target.path; } return "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; } function TabHandleContent({ presentation, isHighlighted, showLabel, tabLabelSkeletonStyle, tabLabelStyle, }: { presentation: WorkspaceTabPresentation; isHighlighted: boolean; showLabel: boolean; tabLabelSkeletonStyle: React.ComponentProps["style"]; tabLabelStyle: React.ComponentProps["style"]; }) { 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, setHoveredTabKey, 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; setHoveredTabKey: Dispatch>; setHoveredCloseTabKey: Dispatch>; onNavigateTab: (tabId: string) => void; onCloseTab: (tabId: string) => Promise | void; dragHandleProps: DraggableListDragHandleProps | undefined; }) { const { closeButtonTestId, contextMenuTestId, menuEntries } = resolvedTab; const middleClickRef = useMiddleClickClose( useCallback(() => void onCloseTab(tab.tabId), [onCloseTab, tab.tabId]), ); const [hovered, setHovered] = useState(false); const isHighlighted = isActive || hovered || isCloseHovered; const closeButtonDragBlockers = isWeb ? ({ onPointerDown: (event: { stopPropagation?: () => void }) => { event.stopPropagation?.(); }, onMouseDown: (event: { stopPropagation?: () => void }) => { event.stopPropagation?.(); }, } as const) : undefined; const tabChipStyle = useCallback( () => [ styles.tab, isWeb && isDragging && ({ cursor: "grabbing" } as object), { minWidth: resolvedTabWidth, width: resolvedTabWidth, maxWidth: resolvedTabWidth, }, ], [isDragging, resolvedTabWidth], ); const handleTabHoverIn = useCallback(() => { setHovered(true); setHoveredTabKey(tab.key); }, [setHoveredTabKey, tab.key]); const handleTabHoverOut = useCallback(() => { setHovered(false); setHoveredTabKey((current) => (current === tab.key ? null : current)); }, [setHoveredTabKey, tab.key]); const handleNavigateTab = useCallback(() => { onNavigateTab(tab.tabId); }, [onNavigateTab, tab.tabId]); const handleCloseButtonPressIn = useCallback((event: { stopPropagation?: () => void }) => { event.stopPropagation?.(); }, []); const handleCloseButtonHoverIn = useCallback(() => { setHoveredTabKey(tab.key); setHoveredCloseTabKey(tab.key); }, [setHoveredTabKey, setHoveredCloseTabKey, tab.key]); const handleCloseButtonHoverOut = useCallback(() => { setHoveredTabKey((current) => (current === tab.key ? null : current)); setHoveredCloseTabKey((current) => (current === tab.key ? null : current)); }, [setHoveredTabKey, setHoveredCloseTabKey, tab.key]); const handleCloseButtonPress = useCallback( (event: { stopPropagation?: () => void }) => { event.stopPropagation?.(); void onCloseTab(tab.tabId); }, [onCloseTab, tab.tabId], ); const closeButtonStyle = useCallback( ({ hovered: isButtonHovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [ styles.tabCloseButton, styles.tabCloseButtonShown, (Boolean(isButtonHovered) || pressed) && styles.tabCloseButtonActive, ], [], ); const tabAccessibilityState = useMemo(() => ({ selected: isActive }), [isActive]); const tabFocusIndicatorStyle = useMemo( () => [styles.tabFocusIndicator, !isFocused && styles.tabFocusIndicatorUnfocused], [isFocused], ); const tabLabelSkeletonStyle = useMemo( () => [styles.tabLabelSkeleton, showCloseButton && styles.tabLabelSkeletonWithCloseButton], [showCloseButton], ); const tabLabelStyle = useMemo( () => [ styles.tabLabel, isHighlighted && styles.tabLabelActive, showCloseButton && styles.tabLabelWithCloseButton, ], [isHighlighted, showCloseButton], ); return ( {isActive && } {showCloseButton ? ( {({ hovered: closeHovered, pressed }) => isClosingTab ? ( ) : ( ) } ) : null} {tab.target.kind === "agent" ? ( {tooltipLabel} {tab.target.agentId.slice(0, 7)} ) : ( {tooltipLabel} )} {menuEntries.map((entry) => entry.kind === "separator" ? ( ) : ( ), )} ); } export function WorkspaceDesktopTabsRow({ paneId, isFocused = false, tabs, normalizedServerId, normalizedWorkspaceId, setHoveredTabKey, setHoveredCloseTabKey, onNavigateTab, onCloseTab, onCopyResumeCommand, onCopyAgentId, onReloadAgent, onCloseTabsToLeft, onCloseTabsToRight, onCloseOtherTabs, onCreateDraftTab, onCreateTerminalTab, disableCreateTerminal = false, isWaitingOnTerminalReadiness = false, onReorderTabs, onSplitRight, onSplitDown, externalDndContext = false, activeDragTabId = null, tabDropPreviewIndex = null, showPaneSplitActions = true, }: WorkspaceDesktopTabsRowProps) { const newTabKeys = useShortcutKeys("workspace-tab-new"); const newTerminalKeys = useShortcutKeys("workspace-terminal-new"); const splitRightKeys = useShortcutKeys("workspace-pane-split-right"); const splitDownKeys = useShortcutKeys("workspace-pane-split-down"); const [tabsContainerWidth, setTabsContainerWidth] = useState(0); const [tabsActionsWidth, setTabsActionsWidth] = useState(0); const handleTabsContainerLayout = useCallback((event: LayoutChangeEvent) => { const nextWidth = Math.round(event.nativeEvent.layout.width); setTabsContainerWidth((current) => (Math.abs(current - nextWidth) > 1 ? nextWidth : current)); }, []); const handleTabsActionsLayout = useCallback((event: LayoutChangeEvent) => { const nextWidth = Math.round(event.nativeEvent.layout.width); setTabsActionsWidth((current) => (Math.abs(current - nextWidth) > 1 ? nextWidth : current)); }, []); const layoutMetrics = useMemo( () => ({ rowHorizontalInset: 0, actionsReservedWidth: Math.max(0, tabsActionsWidth), rowPaddingHorizontal: 0, tabGap: 0, maxTabWidth: 200, tabIconWidth: 14, tabHorizontalPadding: 12, estimatedCharWidth: 7, closeButtonWidth: 22, }), [tabsActionsWidth], ); const tabLabelLengths = useMemo( () => tabs.map((tab) => { const label = getFallbackTabLabel(tab.tab); return label.length; }), [tabs], ); const { layout } = useWorkspaceTabLayout({ tabLabelLengths, viewportWidthOverride: tabsContainerWidth > 0 ? tabsContainerWidth : null, metrics: layoutMetrics, }); const handleDragEnd = useCallback( (nextTabs: WorkspaceDesktopTabRowItem[]) => { onReorderTabs(nextTabs.map((tab) => tab.tab)); }, [onReorderTabs], ); const getTabDragData = useMemo(() => { if (!paneId) return undefined; return (tab: WorkspaceDesktopTabRowItem) => ({ 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 terminalDisabled = disableCreateTerminal || isWaitingOnTerminalReadiness; const newTerminalActionButtonStyle = useCallback( ({ hovered, pressed }: PressableStateCallbackType) => [ styles.newTabActionButton, terminalDisabled && styles.newTabActionButtonDisabled, (hovered || pressed) && styles.newTabActionButtonHovered, ], [terminalDisabled], ); 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 === tabs.length && index === tabs.length - 1; return ( ); }, [ activeDragTabId, isFocused, layout.closeButtonPolicy, layout.items, normalizedServerId, normalizedWorkspaceId, onCloseOtherTabs, onCloseTab, onCloseTabsToLeft, onCloseTabsToRight, onCopyAgentId, onCopyResumeCommand, onNavigateTab, onReloadAgent, setHoveredCloseTabKey, setHoveredTabKey, tabDropPreviewIndex, tabs.length, ], ); const tabsScrollStyle = useMemo( () => [ styles.tabsScroll, layout.requiresHorizontalScrollFallback ? styles.tabsScrollOverflow : styles.tabsScrollFitContent, ], [layout.requiresHorizontalScrollFallback], ); return ( New agent tab {newTabKeys ? ( ) : null} {isWaitingOnTerminalReadiness ? "Preparing terminal..." : "New terminal tab"} {newTerminalKeys ? ( ) : null} {showPaneSplitActions ? ( <> Split pane right {splitRightKeys ? ( ) : null} Split pane down {splitDownKeys ? ( ) : null} ) : null} ); } function ResolvedDesktopTabChip({ item, isFocused, isDragging, index, tabCount, normalizedServerId, normalizedWorkspaceId, onCopyResumeCommand, onCopyAgentId, onReloadAgent, onCloseTabsToLeft, onCloseTabsToRight, onCloseOtherTabs, resolvedTabWidth, showLabel, showCloseButton, setHoveredTabKey, setHoveredCloseTabKey, onNavigateTab, onCloseTab, dragHandleProps, showDropIndicatorBefore, showDropIndicatorAfter, }: { item: WorkspaceDesktopTabRowItem; isFocused: boolean; isDragging: boolean; index: number; tabCount: number; normalizedServerId: string; normalizedWorkspaceId: string; onCopyResumeCommand: (agentId: string) => Promise | void; onCopyAgentId: (agentId: string) => Promise | void; onReloadAgent: (agentId: string) => Promise | void; onCloseTabsToLeft: (tabId: string) => Promise | void; onCloseTabsToRight: (tabId: string) => Promise | void; onCloseOtherTabs: (tabId: string) => Promise | void; resolvedTabWidth: number; showLabel: boolean; showCloseButton: boolean; setHoveredTabKey: Dispatch>; setHoveredCloseTabKey: Dispatch>; onNavigateTab: (tabId: string) => void; onCloseTab: (tabId: string) => Promise | void; dragHandleProps: DraggableListDragHandleProps | undefined; showDropIndicatorBefore: boolean; showDropIndicatorAfter: boolean; }) { const resolvedTab = useMemo( () => buildWorkspaceDesktopTabActions({ tab: item.tab, index, tabCount, onCopyResumeCommand, onCopyAgentId, onReloadAgent, onCloseTab, onCloseTabsToLeft, onCloseTabsToRight, onCloseOtherTabs, }), [ index, item.tab, onCloseOtherTabs, onCloseTab, onCloseTabsToLeft, onCloseTabsToRight, onCopyAgentId, onCopyResumeCommand, onReloadAgent, tabCount, ], ); return ( {(presentation) => { const tooltipLabel = presentation.titleState === "loading" ? "Loading agent title" : presentation.label; 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, }, tabsScrollFitContent: { flex: 1, }, tabsScrollOverflow: { flex: 1, }, tabsContent: { flexDirection: "row", alignItems: "stretch", }, tabsActions: { flexDirection: "row", alignItems: "center", paddingHorizontal: theme.spacing[2], }, tab: { paddingHorizontal: theme.spacing[3], paddingVertical: theme.spacing[2], borderRightWidth: 1, borderRightColor: theme.colors.border, flexDirection: "row", alignItems: "center", gap: theme.spacing[1], userSelect: "none", }, tabSlot: { position: "relative", overflow: "visible", }, tabHandle: { flexDirection: "row", alignItems: "center", gap: theme.spacing[1], flex: 1, minWidth: 0, userSelect: "none", }, tabIcon: { flexShrink: 0, }, tabFocusIndicator: { position: "absolute", top: 0, left: 0, right: 0, height: 2, backgroundColor: theme.colors.accent, }, tabFocusIndicatorUnfocused: { backgroundColor: theme.colors.borderAccent, }, tabDropIndicator: { position: "absolute", top: theme.spacing[2], bottom: theme.spacing[2], width: 5, borderRadius: theme.borderRadius.full, backgroundColor: theme.colors.accent, zIndex: 10, pointerEvents: "none", }, tabDropIndicatorBefore: { left: -3, }, tabDropIndicatorAfter: { right: -3, }, tabLabel: { flexShrink: 1, minWidth: 0, color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.normal, userSelect: "none", }, tabLabelSkeleton: { width: 96, maxWidth: "100%", flexShrink: 1, minWidth: 0, height: 10, borderRadius: theme.borderRadius.full, backgroundColor: theme.colors.surface3, opacity: 0.9, }, tabLabelSkeletonWithCloseButton: { width: LOADING_TAB_LABEL_SKELETON_WIDTH, }, tabLabelWithCloseButton: { paddingRight: 0, }, tabLabelActive: { color: theme.colors.foreground, }, tabCloseButton: { width: 18, height: 18, marginLeft: 0, borderRadius: theme.borderRadius.sm, alignItems: "center", justifyContent: "center", }, tabCloseButtonShown: { opacity: 1, }, tabCloseButtonActive: { backgroundColor: theme.colors.surface3, }, newTabActionButton: { width: 22, height: 22, borderRadius: theme.borderRadius.md, alignItems: "center", justifyContent: "center", }, newTabActionButtonDisabled: { opacity: 0.5, }, newTabActionButtonHovered: { backgroundColor: theme.colors.surface2, }, newTabTooltipText: { color: theme.colors.foreground, fontSize: theme.fontSize.sm, }, newTabTooltipRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], }, newTabTooltipShortcut: { backgroundColor: theme.colors.surface3, borderColor: theme.colors.borderAccent, }, tooltipAgentRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], }, tooltipAgentId: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, }, menuItemHint: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, }, })); const TAB_DROP_INDICATOR_BEFORE_STYLE = [styles.tabDropIndicator, styles.tabDropIndicatorBefore]; const TAB_DROP_INDICATOR_AFTER_STYLE = [styles.tabDropIndicator, styles.tabDropIndicatorAfter];