import { useCallback, useMemo, type ReactElement, type ReactNode } from "react"; import { Pressable, Text, View, type PressableStateCallbackType } from "react-native"; import { Check } from "lucide-react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import invariant from "tiny-invariant"; import { SyncedLoader } from "@/components/synced-loader"; import { ensurePanelsRegistered } from "@/panels/register-panels"; import { getPanelRegistration } from "@/panels/panel-registry"; import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; import type { SidebarStateBucket } from "@/utils/sidebar-agent-state"; import { isEmphasizedStatusDotBucket } from "@/utils/status-dot-color"; import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader"; import type { Theme } from "@/styles/theme"; export interface WorkspaceTabPresentation { key: string; kind: WorkspaceTabDescriptor["kind"]; label: string; subtitle: string; titleState: "ready" | "loading"; icon: React.ComponentType<{ size: number; color: string }>; statusBucket: SidebarStateBucket | null; } const DEFAULT_STATUS_DOT_SIZE = 7; const EMPHASIZED_STATUS_DOT_SIZE = 9; const DEFAULT_STATUS_DOT_OFFSET = -2; const EMPHASIZED_STATUS_DOT_OFFSET = -3; interface WorkspaceTabPresentationResolverProps { tab: WorkspaceTabDescriptor; serverId: string; workspaceId: string; children: (presentation: WorkspaceTabPresentation) => ReactNode; } type WorkspaceTabPresentationResolverInnerProps = WorkspaceTabPresentationResolverProps & { registration: NonNullable>; }; export function WorkspaceTabPresentationResolver({ tab, serverId, workspaceId, children, }: WorkspaceTabPresentationResolverProps): ReactElement { ensurePanelsRegistered(); const registration = getPanelRegistration(tab.kind); invariant(registration, `No panel registration for kind: ${tab.kind}`); return ( {children} ); } function WorkspaceTabPresentationResolverInner({ registration, tab, serverId, workspaceId, children, }: WorkspaceTabPresentationResolverInnerProps): ReactElement { const descriptor = registration.useDescriptor(tab.target as never, { serverId, workspaceId, }); const presentation = useMemo( () => ({ key: tab.key, kind: tab.kind, label: descriptor.label, subtitle: descriptor.subtitle, titleState: descriptor.titleState, icon: descriptor.icon, statusBucket: descriptor.statusBucket, }), [ descriptor.icon, descriptor.label, descriptor.statusBucket, descriptor.subtitle, descriptor.titleState, tab.key, tab.kind, ], ); return <>{children(presentation)}; } interface WorkspaceTabIconProps { presentation: WorkspaceTabPresentation; active?: boolean; size?: number; statusDotBorderColor?: string; } const ThemedCheckIcon = withUnistyles(Check); const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); export function WorkspaceTabIcon({ presentation, active = false, size = 14, statusDotBorderColor, }: WorkspaceTabIconProps): ReactElement { const iconColor = active ? styles.iconActive.color : styles.iconInactive.color; const bucket = presentation.statusBucket; let statusDotColor: string | null = null; if (bucket === "needs_input") statusDotColor = styles.statusDotNeedsInput.color; else if (bucket === "failed") statusDotColor = styles.statusDotFailed.color; else if (bucket === "running") statusDotColor = styles.statusDotRunning.color; else if (bucket === "attention") statusDotColor = styles.statusDotAttention.color; const statusDotSize = isEmphasizedStatusDotBucket(presentation.statusBucket) ? EMPHASIZED_STATUS_DOT_SIZE : DEFAULT_STATUS_DOT_SIZE; const statusDotOffset = statusDotSize === EMPHASIZED_STATUS_DOT_SIZE ? EMPHASIZED_STATUS_DOT_OFFSET : DEFAULT_STATUS_DOT_OFFSET; const shouldShowLoader = shouldRenderSyncedStatusLoader({ bucket: presentation.statusBucket, }); const Icon = presentation.icon; const agentIconWrapperStyle = useMemo( () => [styles.agentIconWrapper, { width: size, height: size }], [size], ); const statusDotStyle = useMemo( () => [ styles.statusDot, { backgroundColor: statusDotColor ?? undefined, borderColor: statusDotBorderColor ?? styles.statusDotBorderDefault.borderColor, width: statusDotSize, height: statusDotSize, right: statusDotOffset, bottom: statusDotOffset, }, ], [statusDotColor, statusDotBorderColor, statusDotSize, statusDotOffset], ); if (shouldShowLoader) { return ( ); } return ( {statusDotColor ? : null} ); } interface WorkspaceTabOptionRowProps { presentation: WorkspaceTabPresentation; selected: boolean; active: boolean; onPress: () => void; trailingAccessory?: ReactNode; } export function WorkspaceTabOptionRow({ presentation, selected, active, onPress, trailingAccessory, }: WorkspaceTabOptionRowProps): ReactElement { const pressableStyle = useCallback( ({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [ styles.optionMainPressable, (Boolean(hovered) || pressed || active) && styles.optionRowActive, ], [active], ); const optionRowStyle = useMemo( () => [styles.optionRow, active && styles.optionRowActive], [active], ); return ( {presentation.titleState === "loading" ? "Loading..." : presentation.label} {selected ? ( ) : null} {trailingAccessory ? ( {trailingAccessory} ) : null} ); } const styles = StyleSheet.create((theme) => ({ agentIconWrapper: { position: "relative", alignItems: "center", justifyContent: "center", }, statusDot: { position: "absolute", right: DEFAULT_STATUS_DOT_OFFSET, bottom: DEFAULT_STATUS_DOT_OFFSET, width: DEFAULT_STATUS_DOT_SIZE, height: DEFAULT_STATUS_DOT_SIZE, borderRadius: theme.borderRadius.full, borderWidth: 1, }, statusDotBorderDefault: { borderColor: theme.colors.surface0, }, statusDotNeedsInput: { color: theme.colors.palette.amber[500], }, statusDotFailed: { color: theme.colors.palette.red[500], }, statusDotRunning: { color: theme.colors.palette.blue[500], }, statusDotAttention: { color: theme.colors.palette.green[500], }, iconActive: { color: theme.colors.foreground, }, iconInactive: { color: theme.colors.foregroundMuted, }, syncedLoader: { color: theme.colorScheme === "light" ? theme.colors.palette.amber[700] : theme.colors.palette.amber[500], }, optionRow: { flexDirection: "row", alignItems: "center", minHeight: 36, gap: theme.spacing[2], paddingHorizontal: theme.spacing[1], paddingVertical: theme.spacing[1], borderRadius: 0, marginHorizontal: theme.spacing[1], marginBottom: theme.spacing[1], }, optionMainPressable: { flex: 1, minWidth: 0, flexDirection: "row", alignItems: "center", gap: theme.spacing[2], paddingHorizontal: theme.spacing[2], paddingVertical: theme.spacing[2], }, optionRowActive: { backgroundColor: theme.colors.surface1, }, optionLeadingSlot: { width: 16, alignItems: "center", justifyContent: "center", }, optionContent: { flex: 1, flexShrink: 1, }, optionLabel: { fontSize: theme.fontSize.sm, color: theme.colors.foreground, }, optionTrailingSlot: { width: 16, alignItems: "center", justifyContent: "center", }, optionTrailingAccessorySlot: { alignItems: "center", justifyContent: "center", }, }));