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,
},
}));