import { memo, useMemo, useCallback, useState, type ReactNode } from "react"; import { Text, View, type ViewStyle } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { CircleAlert, Folder, FolderGit2, Monitor } from "lucide-react-native"; import { ProjectStatusIndicator } from "@/components/sidebar/project-leading-visual"; import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop"; import { WorkspaceMetaRow, type WorkspaceServiceSummary, } from "@/components/sidebar/workspace-meta-row"; import { WorkspaceHoverCard } from "@/components/workspace-hover-card"; import type { HostBadgeModel } from "@/hosts/appearance"; import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list"; import { hasSidebarWorkspaceTrailing, type SidebarWorkspaceTrailing, } from "@/components/sidebar/workspace-trailing"; import { useAppSettings } from "@/hooks/use-settings"; import type { Theme } from "@/styles/theme"; import type { SidebarStateBucket } from "@/utils/sidebar-agent-state"; import { getStatusDotColor } from "@/utils/status-dot-color"; import { STATUS_INDICATOR_ALERT_SIZE, STATUS_INDICATOR_DOT_SIZE, STATUS_INDICATOR_FILLED_DOT_SIZE, } from "@/utils/status-indicator-geometry"; import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader"; import { StatusRing } from "@/components/status-ring"; import { resolveSidebarWorkspacePrimaryLabel } from "@/components/sidebar/sidebar-workspace-title"; import { TrailingActionScrim } from "@/components/ui/trailing-action-scrim"; import { useWorkspaceLabelDefinitions } from "@/workspace-labels"; const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); const needsInputColorMapping = (theme: Theme) => ({ color: theme.colors.surface0, fill: getStatusDotColor({ theme, bucket: "needs_input" }) ?? undefined, }); const ThemedCircleAlert = withUnistyles(CircleAlert); const ThemedMonitor = withUnistyles(Monitor); const ThemedFolder = withUnistyles(Folder); const ThemedFolderGit2 = withUnistyles(FolderGit2); export function SidebarWorkspaceRowFrame({ workspace, isDragging = false, children, }: { workspace: SidebarWorkspaceEntry; isDragging?: boolean; children: (input: { isHovered: boolean; contextMenuOpen: boolean; onContextMenuOpenChange: (open: boolean) => void; hoverHandlers: { onPointerEnter: () => void; onPointerLeave: () => void }; }) => ReactNode; }) { const [isHovered, setIsHovered] = useState(false); const [contextMenuOpen, setContextMenuOpen] = useState(false); const handlePointerEnter = useCallback(() => { if (!contextMenuOpen) setIsHovered(true); }, [contextMenuOpen]); const handlePointerLeave = useCallback(() => setIsHovered(false), []); const handleContextMenuOpenChange = useCallback((open: boolean) => { setContextMenuOpen(open); if (open) setIsHovered(false); }, []); const hoverHandlers = useMemo( () => ({ onPointerEnter: handlePointerEnter, onPointerLeave: handlePointerLeave }), [handlePointerEnter, handlePointerLeave], ); return ( {children({ isHovered: isHovered && !contextMenuOpen && !isDragging, contextMenuOpen, onContextMenuOpenChange: handleContextMenuOpenChange, hoverHandlers, })} ); } export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowContent({ workspace, hostBadge, leadingProjectName = null, leadingProjectIconDataUri = null, serviceSummary = null, backdrop, isHovered, isLoading, isCreating = false, shortcutNumber = null, showShortcutBadge = false, reserveIdleStatusIndicatorSpace = true, children, }: { workspace: SidebarWorkspaceEntry; hostBadge?: HostBadgeModel | null; /** Hoisted rows use their project icon as the leading visual because no project row contains them. */ leadingProjectName?: string | null; leadingProjectIconDataUri?: string | null; serviceSummary?: WorkspaceServiceSummary | null; /** The row's current background, so the project status badge can knock out of it. */ backdrop: SidebarSurfaceBackdrop; isHovered: boolean; isLoading: boolean; isCreating?: boolean; shortcutNumber?: number | null; showShortcutBadge?: boolean; /** Keep the empty leading slot when the workspace has no active status. */ reserveIdleStatusIndicatorSpace?: boolean; children?: ReactNode; }) { const { settings: { workspaceTitleSource }, } = useAppSettings(); const workspaceLabel = resolveSidebarWorkspacePrimaryLabel({ workspace, workspaceTitleSource }); // The workspace carries label names; their colors live in its host's catalog, so the row is // where the two meet — the meta line is handed finished definitions. const labels = useWorkspaceLabelDefinitions(workspace.serverId, workspace.labels); const workspaceBranchTextStyle = useMemo( () => [ styles.workspaceBranchText, isHovered && styles.workspaceBranchTextHovered, isCreating && styles.workspaceBranchTextCreating, ], [isHovered, isCreating], ); return ( {leadingProjectName ? ( ) : ( )} {workspaceLabel} {children} {showShortcutBadge && shortcutNumber !== null ? ( ) : null} ); }); function WorkspaceStatusIndicator({ bucket, workspaceKind, loading = false, reserveIdleSpace = true, }: { bucket: SidebarWorkspaceEntry["statusBucket"]; workspaceKind: SidebarWorkspaceEntry["workspaceKind"]; loading?: boolean; reserveIdleSpace?: boolean; }) { // Busy is the only status that moves, and it is the ring rather than a dot for the same // reason it is a dot elsewhere: every status in the sidebar sits in this one slot, so busy // has to fill it without displacing anything. A row starting up and a row working are both // busy, so they share the ring and differ only in testID. if (loading) { return ( ); } if (shouldRenderSyncedStatusLoader({ bucket })) { return ( ); } if (bucket === "needs_input") { return ( ); } if (bucket === "attention") { return ( ); } if (bucket === "done") { // An idle row still gets a dot rather than an empty slot. Nested rows are marked as // workspaces by indentation alone, and with nothing in the leading slot the rail has no // edge to read against — a workspace carrying its own glyph starts looking like a project // header. The dot is muted to half opacity so it holds the rail without reporting status. return reserveIdleSpace ? ( ) : null; } let KindIcon: typeof ThemedMonitor; if (workspaceKind === "local_checkout") KindIcon = ThemedMonitor; else if (workspaceKind === "worktree") KindIcon = ThemedFolderGit2; else KindIcon = ThemedFolder; const dotColorStyle = getStatusDotColorStyle(bucket); return ( {dotColorStyle ? : null} ); } function StatusDotOverlay({ dotColorStyle }: { dotColorStyle: ViewStyle }) { return ; } function getStatusDotColorStyle(bucket: SidebarStateBucket) { switch (bucket) { case "needs_input": return styles.statusDotNeedsInput; case "failed": return styles.statusDotFailed; case "running": return styles.statusDotRunning; case "attention": return styles.statusDotAttention; case "done": return null; } } export const sidebarWorkspaceRowStyles = StyleSheet.create((theme) => ({ // How far a workspace row sits inside the group header above it — a project row or a // status group header. Both groupings share this one indent, so every grouped workspace row // in the sidebar sits on the same rail regardless of how the list is grouped. Pinned rows // are not grouped and stay flush. // // It is row padding rather than a margin on the list, because the row's hover and selected // backgrounds have to keep spanning the group's full width. Indenting the container instead // pulls the highlight in with the content and the row stops lining up with its header. rowIndented: { paddingLeft: theme.spacing[2] + theme.spacing[2], }, rowRight: { flexDirection: "row", alignItems: "flex-start", gap: theme.spacing[2], flexShrink: 0, }, shortcutBadge: { minWidth: 18, height: 18, paddingHorizontal: theme.spacing[1], alignItems: "center", justifyContent: "center", borderRadius: theme.borderRadius.sm, borderWidth: 1, borderColor: theme.colors.surface2, backgroundColor: theme.colors.surface0, flexShrink: 0, }, shortcutBadgeText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.medium, lineHeight: 14, }, hidden: { opacity: 0 }, // Stays position:relative at zero width so the absolutely-positioned kebab keeps // anchoring to the same right edge whether or not the slot holds anything. trailingActionSlot: { position: "relative", minHeight: 20, flexShrink: 0, alignItems: "flex-end", justifyContent: "flex-start", }, trailingActionSlotReserved: { position: "relative", minWidth: 18, minHeight: 20, flexShrink: 0, alignItems: "flex-end", justifyContent: "flex-start", }, trailingActionOverlay: { position: "absolute", top: 0, right: 0, }, })); export function SidebarWorkspaceShortcutBadge({ number }: { number: number }) { return ( {number} ); } /** * What the trailing slot shows for a row. Derived in one place because three row renderers * share it: the two project-mode rows and the status-mode row. The rule used to be copied * into each of them and immediately drifted — one call site kept hiding the diff after the * others stopped. * * The trailing content survives the kebab on hover and fades under the scrim instead of * blinking out. Touch has no hover, so its permanent kebab still hides the content outright * rather than scrimming an unhovered row whose background doesn't match the gradient. */ export function resolveTrailingActionVisibility({ workspace, trailing, hasArchiveAction, isHovered, isTouchPlatform, showShortcut, }: { workspace: SidebarWorkspaceEntry; trailing: SidebarWorkspaceTrailing; hasArchiveAction: boolean; isHovered: boolean; isTouchPlatform: boolean; showShortcut: boolean; }): { showTrailing: boolean; showKebab: boolean; showScrim: boolean; renderSlot: boolean; reserveSlotWidth: boolean; } { const hasTrailing = hasSidebarWorkspaceTrailing({ workspace, trailing }); const showKebab = Boolean(hasArchiveAction && (isHovered || isTouchPlatform)) && !showShortcut; const showTrailing = hasTrailing && !showShortcut && (isHovered || !showKebab); return { showTrailing, showKebab, // The scrim paints the row's own hover background, so it can only be drawn on a hovered // row — over an unhovered one the gradient fades to the wrong color. That is also why // touch, which shows the kebab without ever hovering, never gets one. showScrim: showKebab && isHovered, renderSlot: hasArchiveAction || hasTrailing, // The slot only holds width for something that permanently sits in it. Trailing content // does; the kebab only does on touch, where there is no hover for it to appear on and so // no scrim to let it overlay the title. Everywhere else the width goes back to the title // and the kebab fades in over its tail. reserveSlotWidth: hasTrailing || (hasArchiveAction && isTouchPlatform), }; } export function SidebarWorkspaceTrailingActionSlot({ reserveWidth, children, }: { reserveWidth: boolean; children: ReactNode; }) { return ( {children} ); } export function SidebarWorkspaceTrailingActionBase({ visible, children, }: { visible: boolean; children: ReactNode; }) { if (!children) return null; return {children}; } export function SidebarWorkspaceTrailingActionOverlay({ visible, scrimBackdrop, children, }: { visible: boolean; /** Fade the row into the kebab when something (the diff stat) is still rendered behind it. */ scrimBackdrop?: SidebarSurfaceBackdrop; children: ReactNode; }) { if (!visible || !children) return null; return ( <> {scrimBackdrop ? ( ) : null} {children} ); } const styles = StyleSheet.create((theme) => ({ workspaceRowContent: { position: "relative", }, workspaceRowMain: { flexDirection: "row", alignItems: "flex-start", gap: theme.spacing[2], width: "100%", }, workspaceContentColumn: { flex: 1, minWidth: 0, }, workspaceTitleRow: { flexDirection: "row", alignItems: "flex-start", justifyContent: "space-between", gap: theme.spacing[2], }, shortcutBadgeOverlay: { position: "absolute", top: 1, right: 0, }, workspaceStatusDot: { position: "relative", width: theme.iconSize.md, height: 20, borderRadius: theme.borderRadius.full, flexShrink: 0, alignItems: "center", justifyContent: "center", }, statusDotOverlay: { position: "absolute", right: 0, bottom: 0, width: STATUS_INDICATOR_DOT_SIZE, height: STATUS_INDICATOR_DOT_SIZE, borderRadius: theme.borderRadius.full, borderWidth: 1, }, standaloneStatusDot: { width: STATUS_INDICATOR_FILLED_DOT_SIZE, height: STATUS_INDICATOR_FILLED_DOT_SIZE, borderRadius: theme.borderRadius.full, backgroundColor: getStatusDotColor({ theme, bucket: "attention" }) ?? undefined, }, idleStatusDot: { width: STATUS_INDICATOR_FILLED_DOT_SIZE, height: STATUS_INDICATOR_FILLED_DOT_SIZE, borderRadius: theme.borderRadius.full, backgroundColor: theme.colors.foregroundExtraMuted, opacity: 0.3, }, // The title owns the first line outright now that the host, change request and CI moved // to the meta row, so it takes the full width the trailing slot leaves behind. workspaceBranchText: { color: theme.colors.foreground, fontSize: theme.fontSize.base, fontWeight: "400", lineHeight: 20, opacity: 0.76, flex: 1, minWidth: 0, }, workspaceBranchTextCreating: { opacity: 0.92, }, workspaceBranchTextHovered: { opacity: 1, }, statusDotNeedsInput: { backgroundColor: getStatusDotColor({ theme, bucket: "needs_input" }) ?? undefined, borderColor: theme.colors.surface0, }, statusDotFailed: { backgroundColor: getStatusDotColor({ theme, bucket: "failed" }) ?? undefined, borderColor: theme.colors.surface0, }, statusDotRunning: { backgroundColor: getStatusDotColor({ theme, bucket: "running" }) ?? undefined, borderColor: theme.colors.surface0, }, statusDotAttention: { backgroundColor: getStatusDotColor({ theme, bucket: "attention" }) ?? undefined, borderColor: theme.colors.surface0, }, }));