import { ActivityIndicator, View, type ViewStyle } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { ChevronDown, ChevronRight, CircleAlert } from "lucide-react-native"; import { ProjectIconView } from "@/components/project-icon-view"; import { PulsingStatusDot } from "@/components/sidebar/pulsing-status-dot"; import { STATUS_BUCKET_LABELS } from "@/hooks/sidebar-status-view-model"; import { ICON_SIZE, type Theme } from "@/styles/theme"; import type { SidebarStateBucket } from "@/utils/sidebar-agent-state"; import { getProjectStatusBadgeContent, type ProjectStatusBadgeContent, type ProjectStatusBadgeDotBucket, } from "@/utils/project-status-badge-content"; import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name"; import { getStatusDotColor } from "@/utils/status-dot-color"; import type { SurfaceBackdrop } from "@/styles/surface-backdrop"; // Every surfaced status shares one badge shell, so the badge never changes size or position // between states. Only the thing inside it changes. const STATUS_BADGE_SIZE = 12; // How far the badge overhangs the icon's bottom-right corner. Well short of half the badge: // centered on the corner it reads as hanging off the icon rather than sitting on it, and the // sidebar row has no padding there to absorb the overhang. const STATUS_BADGE_OFFSET = -4; // Both glyphs must be EVEN. A centered glyph of size N sits at a (12 - N) / 2 offset — fractional // for odd N, which the browser snaps to a device-pixel boundary and renders visibly off-center (an // odd size measured 1.5 device px right and down at 3x, ~3px of asymmetry between opposite gaps). // Even sizes divide the shell into whole pixels and land dead center with no correction. // // Lucide's circle-alert paints ~83% of its nominal size, so an alert of 8 draws a ~6.6pt circle // against the 6pt dot — the two states read as the same-diameter disc. 8 is the closest even // size to the exact match (6 / 0.83 = 7.2); 6 would undershoot the dot rather than meet it. const STATUS_BADGE_DOT_SIZE = 6; const STATUS_BADGE_ALERT_SIZE = 8; // Matches the workspace title's lineHeight (sidebar-workspace-row-content's // workspaceBranchText) so the icon centers on the title rather than floating above it. const LEADING_SLOT_HEIGHT = 20; const ThemedActivityIndicator = withUnistyles(ActivityIndicator); const ThemedCircleAlert = withUnistyles(CircleAlert); const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted, }); const needsInputColorMapping = (theme: Theme) => ({ color: getStatusDotColor({ theme, bucket: "needs_input" }) ?? undefined, }); /** * Leading slot of a sidebar project row: chevron on hover, archive spinner while removing, * otherwise the project icon carrying the project's aggregate workspace status. */ export function ProjectLeadingVisual({ displayName, iconDataUri, statusBucket, projectViewKey, backdrop, chevron = null, showChevron = false, isArchiving = false, }: { displayName: string; iconDataUri: string | null; /** Aggregate status of the project's workspaces; null when it shouldn't be surfaced. */ statusBucket: SidebarStateBucket | null; projectViewKey: string; /** The row's current background, so the status badge can knock out of it. */ backdrop: SurfaceBackdrop; chevron?: "expand" | "collapse" | null; showChevron?: boolean; isArchiving?: boolean; }) { if (showChevron && chevron !== null) { return ( ); } if (isArchiving) { return ( ); } return ( ); } // The project icon (the lettered box) is what marks a row as a *project* rather than a // workspace, so it always stays and status annotates it instead of replacing it. Every // surfaced bucket lands in the identical corner badge — an amber alert glyph for needs_input, // a colored disc for the rest, nothing for done — so the badge reads as one fixed shell and // only its contents change. export function ProjectStatusIndicator({ iconDataUri, displayName, projectViewKey, statusBucket, backdrop, loading = false, testID, }: { iconDataUri: string | null; displayName: string; projectViewKey: string; statusBucket: SidebarStateBucket | null; /** The row's current background, so the status badge can knock out of it. */ backdrop: SurfaceBackdrop; loading?: boolean; testID?: string; }) { const placeholderInitial = projectIconPlaceholderLabelFromDisplayName(displayName) .charAt(0) .toUpperCase(); // A row that's still starting up and a row that's working are both "busy", and at this size // there's no room to draw the difference — so `loading` just resolves to the running bucket // and they share one badge. const badgeBucket = loading ? "running" : statusBucket; const badgeContent = getProjectStatusBadgeContent(badgeBucket); return ( {badgeContent === null || badgeBucket === null ? null : ( )} ); } function ProjectStatusBadge({ content, statusBucket, backdrop, }: { content: ProjectStatusBadgeContent; statusBucket: SidebarStateBucket; backdrop: SurfaceBackdrop; }) { return ( {content.kind === "alert" ? ( ) : ( )} ); } function getStatusBadgeBackdropStyle(backdrop: SurfaceBackdrop): ViewStyle { switch (backdrop) { case "surfaceSidebarHover": return styles.statusBadgeOnSidebarHover; case "surface2": return styles.statusBadgeOnSurface2; default: return styles.statusBadgeOnSidebar; } } function ProjectStatusDot({ bucket }: { bucket: ProjectStatusBadgeDotBucket }) { if (bucket !== "running") { return ; } return ; } function ProjectIcon({ iconDataUri, placeholderInitial, projectViewKey, }: { iconDataUri: string | null; placeholderInitial: string; projectViewKey: string; }) { return ( ); } function ProjectInlineChevron({ chevron }: { chevron: "expand" | "collapse" | null }) { if (chevron === null) { return null; } if (chevron === "collapse") { return ; } return ; } function getStatusDotColorStyle( bucket: Exclude, ): ViewStyle { return bucket === "failed" ? styles.statusDotFailed : styles.statusDotAttention; } const styles = StyleSheet.create((theme) => { // Dot statuses sit *inside* the shell rather than replacing it, so they carry the same ring // the alert does. The geometry is shared here and baked into each colored variant so the // style prop stays a single stable object; the colors come from the one bucket-to-color map // so this badge can't drift from the status dots everywhere else. const statusDot = (bucket: ProjectStatusBadgeDotBucket) => ({ width: STATUS_BADGE_DOT_SIZE, height: STATUS_BADGE_DOT_SIZE, borderRadius: theme.borderRadius.full, backgroundColor: getStatusDotColor({ theme, bucket }) ?? undefined, }) as const; return { // The slot is as tall as the title's line box, not as tall as the icon, and centers the // icon inside it. Rows lay their leading visual out with alignItems:flex-start, so a // 16pt slot next to a 20pt line box puts the icon 2pt above the title and the kebab — // which is why the workspace status indicator is also 20 tall. Keep the two in step. projectLeadingVisualSlot: { width: theme.iconSize.md, height: LEADING_SLOT_HEIGHT, flexShrink: 0, alignItems: "center", justifyContent: "center", }, // Anchors the corner badge to the icon rather than to the taller slot. projectIconBox: { position: "relative", width: theme.iconSize.md, height: theme.iconSize.md, }, projectIconFallbackText: { fontSize: 9, }, // The shell the alert and dot statuses share. It straddles the icon's bottom-right corner // (half in, half out) so the lettered project box stays readable. The shell is a knockout: // it is filled with the colour of the row behind it, which is what makes the ring around the // dot read as a gap in the icon rather than as a white halo drawn on top of it. statusBadge: { position: "absolute", right: STATUS_BADGE_OFFSET, bottom: STATUS_BADGE_OFFSET, width: STATUS_BADGE_SIZE, height: STATUS_BADGE_SIZE, borderRadius: theme.borderRadius.full, alignItems: "center", justifyContent: "center", overflow: "hidden", }, statusBadgeOnSidebar: { backgroundColor: theme.colors.surfaceSidebar }, statusBadgeOnSidebarHover: { backgroundColor: theme.colors.surfaceSidebarHover }, statusBadgeOnSurface2: { backgroundColor: theme.colors.surface2 }, statusDotRunning: statusDot("running"), statusDotFailed: statusDot("failed"), statusDotAttention: statusDot("attention"), }; });