import { useCallback, useEffect, useMemo, useState, type Dispatch, type SetStateAction } from "react"; import { ActivityIndicator, Pressable, ScrollView, Text, View, type LayoutChangeEvent } from "react-native"; import { Plus, X } from "lucide-react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { SortableInlineList } from "@/components/sortable-inline-list"; 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 { useWorkspaceTabLayout } from "@/screens/workspace/use-workspace-tab-layout"; import { useWorkspaceTabPresentation, WorkspaceTabIcon, type WorkspaceTabPresentation, } from "@/screens/workspace/workspace-tab-presentation"; import { buildWorkspaceTabMenuEntries } from "@/screens/workspace/workspace-tab-menu"; import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; import { encodeFilePathForPathSegment } from "@/utils/host-routes"; const DROPDOWN_WIDTH = 220; const LOADING_TAB_LABEL_SKELETON_WIDTH = 80; type NewTabOptionId = "__new_tab_agent__"; type WorkspaceDesktopTabsRowProps = { tabs: WorkspaceTabDescriptor[]; activeTabKey: string; normalizedServerId: string; normalizedWorkspaceId: string; hoveredCloseTabKey: string | null; setHoveredTabKey: Dispatch>; setHoveredCloseTabKey: Dispatch>; isArchivingAgent: (input: { serverId: string; agentId: string }) => boolean; killTerminalPending: boolean; killTerminalId: string | null; onNavigateTab: (tabId: string) => void; onCloseTab: (tabId: string) => Promise | void; onCopyResumeCommand: (agentId: string) => Promise | void; onCopyAgentId: (agentId: string) => Promise | void; onCloseTabsToLeft: (tabId: string) => Promise | void; onCloseTabsToRight: (tabId: string) => Promise | void; onCloseOtherTabs: (tabId: string) => Promise | void; onSelectNewTabOption: (optionId: NewTabOptionId) => void; newTabAgentOptionId: NewTabOptionId; onReorderTabs: (nextTabs: WorkspaceTabDescriptor[]) => void; }; function getFallbackTabLabel(tab: WorkspaceTabDescriptor): string { if (tab.target.kind === "draft") { return "New Agent"; } if (tab.target.kind === "terminal") { return "Terminal"; } if (tab.target.kind === "file") { return tab.target.path.split("/").filter(Boolean).pop() ?? tab.target.path; } return ""; } function getCloseButtonTestId(tab: WorkspaceTabDescriptor): string { if (tab.target.kind === "agent") { return `workspace-agent-close-${tab.target.agentId}`; } if (tab.target.kind === "terminal") { return `workspace-terminal-close-${tab.target.terminalId}`; } if (tab.target.kind === "draft") { return `workspace-draft-close-${tab.target.draftId}`; } return `workspace-file-close-${encodeFilePathForPathSegment(tab.target.path)}`; } function TabChip({ tab, index, tabCount, isActive, resolvedTabWidth, showLabel, showCloseButton, isCloseHovered, isClosingTab, normalizedServerId, normalizedWorkspaceId, setHoveredTabKey, setHoveredCloseTabKey, onNavigateTab, onCloseTab, onCopyResumeCommand, onCopyAgentId, onCloseTabsToLeft, onCloseTabsToRight, onCloseOtherTabs, onPresentationChange, dragHandleProps, }: { tab: WorkspaceTabDescriptor; index: number; tabCount: number; isActive: boolean; resolvedTabWidth: number; showLabel: boolean; showCloseButton: boolean; isCloseHovered: boolean; isClosingTab: boolean; 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; onCloseTabsToLeft: (tabId: string) => Promise | void; onCloseTabsToRight: (tabId: string) => Promise | void; onCloseOtherTabs: (tabId: string) => Promise | void; onPresentationChange: (tabKey: string, presentation: WorkspaceTabPresentation) => void; dragHandleProps: any; }) { const { theme } = useUnistyles(); const presentation = useWorkspaceTabPresentation({ tab, serverId: normalizedServerId, workspaceId: normalizedWorkspaceId, }); useEffect(() => { onPresentationChange(tab.key, presentation); }, [onPresentationChange, presentation, tab.key]); const tooltipLabel = presentation.titleState === "loading" ? "Loading agent title" : presentation.label; const contextMenuTestId = `workspace-tab-context-${tab.key}`; const menuEntries = buildWorkspaceTabMenuEntries({ surface: "desktop", tab, index, tabCount, menuTestIDBase: contextMenuTestId, onCopyResumeCommand, onCopyAgentId, onCloseTab, onCloseTabsBefore: onCloseTabsToLeft, onCloseTabsAfter: onCloseTabsToRight, onCloseOtherTabs, }); return ( [ styles.tab, { minWidth: resolvedTabWidth, width: resolvedTabWidth, maxWidth: resolvedTabWidth, }, isActive && styles.tabActive, !isActive && (hovered || pressed || isCloseHovered) && styles.tabHovered, ]} onHoverIn={() => { setHoveredTabKey(tab.key); }} onHoverOut={() => { setHoveredTabKey((current) => (current === tab.key ? null : current)); }} onPressIn={() => { onNavigateTab(tab.tabId); }} onPress={() => { onNavigateTab(tab.tabId); }} accessibilityLabel={tooltipLabel} > {showLabel ? ( presentation.titleState === "loading" ? ( ) : ( {presentation.label} ) ) : null} {showCloseButton ? ( { setHoveredTabKey(tab.key); setHoveredCloseTabKey(tab.key); }} onHoverOut={() => { setHoveredTabKey((current) => (current === tab.key ? null : current)); setHoveredCloseTabKey((current) => (current === tab.key ? null : current)); }} onPress={(event) => { event.stopPropagation?.(); void onCloseTab(tab.tabId); }} style={({ hovered, pressed }) => [ styles.tabCloseButton, styles.tabCloseButtonShown, (hovered || pressed) && styles.tabCloseButtonActive, ]} > {({ hovered, pressed }) => isClosingTab ? ( ) : ( ) } ) : null} {tooltipLabel} {menuEntries.map((entry) => entry.kind === "separator" ? ( ) : ( {entry.label} ) )} ); } export function WorkspaceDesktopTabsRow({ tabs, activeTabKey, normalizedServerId, normalizedWorkspaceId, hoveredCloseTabKey, setHoveredTabKey, setHoveredCloseTabKey, isArchivingAgent, killTerminalPending, killTerminalId, onNavigateTab, onCloseTab, onCopyResumeCommand, onCopyAgentId, onCloseTabsToLeft, onCloseTabsToRight, onCloseOtherTabs, onSelectNewTabOption, newTabAgentOptionId, onReorderTabs, }: WorkspaceDesktopTabsRowProps) { const { theme } = useUnistyles(); const [tabsContainerWidth, setTabsContainerWidth] = useState(0); const [tabsActionsWidth, setTabsActionsWidth] = useState(0); const [presentationsByKey, setPresentationsByKey] = useState>( () => new Map() ); const handlePresentationChange = useCallback( (tabKey: string, presentation: WorkspaceTabPresentation) => { setPresentationsByKey((current) => { const existing = current.get(tabKey); if ( existing?.label === presentation.label && existing?.subtitle === presentation.subtitle && existing?.titleState === presentation.titleState && existing?.statusBucket === presentation.statusBucket && existing?.icon === presentation.icon ) { return current; } const next = new Map(current); next.set(tabKey, presentation); return next; }); }, [] ); useEffect(() => { setPresentationsByKey((current) => { const next = new Map(); for (const tab of tabs) { const presentation = current.get(tab.key); if (presentation) { next.set(tab.key, presentation); } } return next.size === current.size ? current : next; }); }, [tabs]); 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: theme.spacing[2], tabGap: theme.spacing[1], maxTabWidth: 200, tabIconWidth: 14, tabHorizontalPadding: theme.spacing[3], estimatedCharWidth: 7, closeButtonWidth: 22, }), [tabsActionsWidth, theme.spacing] ); const tabLabelLengths = useMemo( () => tabs.map((tab) => { const presentation = presentationsByKey.get(tab.key); if (presentation?.titleState === "loading") { return Math.max(1, Math.ceil(LOADING_TAB_LABEL_SKELETON_WIDTH / layoutMetrics.estimatedCharWidth)); } const label = presentation?.label ?? getFallbackTabLabel(tab); return label.length; }), [layoutMetrics.estimatedCharWidth, presentationsByKey, tabs] ); const { layout } = useWorkspaceTabLayout({ tabLabelLengths, viewportWidthOverride: tabsContainerWidth > 0 ? tabsContainerWidth : null, metrics: layoutMetrics, }); return ( tab.key} useDragHandle disabled={tabs.length < 2} onDragEnd={onReorderTabs} renderItem={({ item: tab, index, dragHandleProps }) => { const isActive = tab.key === activeTabKey; const isCloseHovered = hoveredCloseTabKey === tab.key; const isClosingAgent = tab.target.kind === "agent" && isArchivingAgent({ serverId: normalizedServerId, agentId: tab.target.agentId, }); const isClosingTerminal = tab.target.kind === "terminal" && killTerminalPending && killTerminalId === tab.target.terminalId; const isClosingTab = isClosingAgent || isClosingTerminal; const shouldShowCloseButton = layout.closeButtonPolicy === "all"; const layoutItem = layout.items[index] ?? null; const resolvedTabWidth = layoutItem?.width ?? 150; const showLabel = layoutItem?.showLabel ?? true; return ( ); }} /> onSelectNewTabOption(newTabAgentOptionId)} accessibilityRole="button" accessibilityLabel="New agent tab" style={({ hovered, pressed }) => [ styles.newTabActionButton, (hovered || pressed) && styles.newTabActionButtonHovered, ]} > New agent tab ); } const styles = StyleSheet.create((theme) => ({ tabsContainer: { borderBottomWidth: 1, borderBottomColor: theme.colors.border, backgroundColor: theme.colors.surface0, flexDirection: "row", alignItems: "center", }, tabsScroll: { minWidth: 0, }, tabsScrollFitContent: { flexGrow: 0, flexShrink: 1, }, tabsScrollOverflow: { flex: 1, }, tabsContent: { flexDirection: "row", alignItems: "center", gap: theme.spacing[1], paddingHorizontal: theme.spacing[2], paddingVertical: theme.spacing[1], }, tabsActions: { flexDirection: "row", alignItems: "center", gap: theme.spacing[1], paddingRight: theme.spacing[2], paddingVertical: theme.spacing[1], }, tab: { paddingHorizontal: theme.spacing[3], paddingVertical: theme.spacing[2], borderRadius: theme.borderRadius.md, flexDirection: "row", alignItems: "center", gap: theme.spacing[1], userSelect: "none", }, tabHandle: { flexDirection: "row", alignItems: "center", gap: theme.spacing[1], flex: 1, minWidth: 0, userSelect: "none", }, tabIcon: { flexShrink: 0, }, tabActive: { backgroundColor: theme.colors.surface3, }, tabHovered: { backgroundColor: theme.colors.surface2, }, 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: 28, height: 28, borderRadius: theme.borderRadius.md, alignItems: "center", justifyContent: "center", }, 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, }, }));