import { useCallback, useMemo, type ReactElement, type ReactNode } from "react"; import { Pressable, Text, View, type PressableStateCallbackType } from "react-native"; import { Check, CircleAlert } from "lucide-react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { useTranslation } from "react-i18next"; import invariant from "tiny-invariant"; 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 type { SurfaceBackdrop } from "@/styles/surface-backdrop"; import { getStatusDotColor } from "@/utils/status-dot-color"; import { StatusRing } from "@/components/status-ring"; import { getStatusRingOffset } from "@/components/status-ring/geometry"; import { STATUS_INDICATOR_ALERT_SIZE, STATUS_INDICATOR_DOT_SIZE, } from "@/utils/status-indicator-geometry"; import type { Theme } from "@/styles/theme"; import { usePanelInstanceAttributes } from "@/panels/panel-instance-attributes"; export interface WorkspaceTabPresentation { key: string; kind: WorkspaceTabDescriptor["kind"]; label: string; subtitle: string; tooltip: string; modified: boolean; titleState: "ready" | "loading"; icon: React.ComponentType<{ size: number; color: string }>; statusBucket: SidebarStateBucket | null; } const DEFAULT_STATUS_DOT_OFFSET = -2; const STATUS_ALERT_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, tabId: tab.tabId, }); const attributes = usePanelInstanceAttributes({ serverId, workspaceId, tabId: tab.tabId }); const presentation = useMemo( () => ({ key: tab.key, kind: tab.kind, label: descriptor.label, subtitle: descriptor.subtitle, tooltip: descriptor.tooltip, modified: attributes.modified, titleState: descriptor.titleState, icon: descriptor.icon, statusBucket: descriptor.statusBucket, }), [ descriptor.icon, descriptor.label, descriptor.tooltip, descriptor.statusBucket, descriptor.subtitle, descriptor.titleState, tab.key, tab.kind, attributes.modified, ], ); return <>{children(presentation)}; } interface WorkspaceTabIconProps { presentation: WorkspaceTabPresentation; active?: boolean; size?: number; statusDotBorderColor?: string; /** * The surface this icon is sitting on, so the running ring can knock out of it. This icon is * shared by the desktop tab strip, the tab switcher trigger, the split drag chip and the * subagents track, which are on different surfaces and some of which change surface on hover — * it cannot work the colour out for itself. */ backdrop: SurfaceBackdrop; } const ThemedCheckIcon = withUnistyles(Check); const ThemedCircleAlert = withUnistyles(CircleAlert); const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); const needsInputAlertMapping = (theme: Theme) => ({ color: theme.colors.surface0, fill: getStatusDotColor({ theme, bucket: "needs_input" }) ?? undefined, }); export function WorkspaceTabIcon({ presentation, active = false, size = 14, statusDotBorderColor, backdrop, }: WorkspaceTabIconProps): ReactElement { const iconColor = active ? styles.iconActive.color : styles.iconInactive.color; const bucket = presentation.statusBucket; const isRunning = bucket === "running"; let statusDotColor: string | undefined; if (bucket === "failed") statusDotColor = styles.statusDotFailed.color; else if (bucket === "attention") statusDotColor = styles.statusDotAttention.color; const showNeedsInputAlert = bucket === "needs_input"; const Icon = presentation.icon; const agentIconWrapperStyle = useMemo( () => [styles.agentIconWrapper, { width: size, height: size }], [size], ); const statusDotStyle = useMemo( () => [ styles.statusDot, { backgroundColor: statusDotColor, borderColor: statusDotBorderColor ?? styles.statusDotBorderDefault.borderColor, }, ], [statusDotColor, statusDotBorderColor], ); return ( {isRunning ? ( ) : null} {statusDotColor ? : null} {showNeedsInputAlert ? ( ) : null} ); } interface WorkspaceTabOptionRowProps { presentation: WorkspaceTabPresentation; selected: boolean; active: boolean; onPress: () => void; trailingAccessory?: ReactNode; } export function WorkspaceTabOptionRow({ presentation, selected, active, onPress, trailingAccessory, }: WorkspaceTabOptionRowProps): ReactElement { const { t } = useTranslation(); const isOptionActive = useCallback( ({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => Boolean(hovered) || pressed || active, [active], ); const pressableStyle = useCallback( (state: PressableStateCallbackType & { hovered?: boolean }) => [ styles.optionMainPressable, isOptionActive(state) && styles.optionRowActive, ], [isOptionActive], ); const optionRowStyle = useMemo( () => [styles.optionRow, active && styles.optionRowActive], [active], ); return ( {(state) => { const optionActive = isOptionActive(state); return ( <> {presentation.titleState === "loading" ? t("workspace.tabs.loading") : presentation.label} ); }} {presentation.modified ? ( ) : null} {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: STATUS_INDICATOR_DOT_SIZE, height: STATUS_INDICATOR_DOT_SIZE, borderRadius: theme.borderRadius.full, borderWidth: 1, }, statusRing: { position: "absolute", right: getStatusRingOffset(DEFAULT_STATUS_DOT_OFFSET, STATUS_INDICATOR_DOT_SIZE), bottom: getStatusRingOffset(DEFAULT_STATUS_DOT_OFFSET, STATUS_INDICATOR_DOT_SIZE), }, statusDotBorderDefault: { borderColor: theme.colors.surface0, }, statusAlertOverlay: { position: "absolute", right: STATUS_ALERT_OFFSET, bottom: STATUS_ALERT_OFFSET, }, statusDotFailed: { color: getStatusDotColor({ theme, bucket: "failed" }) ?? undefined, }, statusDotRunning: { color: getStatusDotColor({ theme, bucket: "running" }) ?? undefined, }, statusDotAttention: { color: getStatusDotColor({ theme, bucket: "attention" }) ?? undefined, }, iconActive: { color: theme.colors.foreground, }, iconInactive: { color: theme.colors.foregroundMuted, }, 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.base, color: theme.colors.foreground, }, optionTrailingSlot: { width: 16, alignItems: "center", justifyContent: "center", }, optionModifiedDot: { width: 8, height: 8, borderRadius: theme.borderRadius.full, backgroundColor: theme.colors.foregroundMuted, }, optionTrailingAccessorySlot: { alignItems: "center", justifyContent: "center", }, }));