Merge branch 'main' of github.com:getpaseo/paseo

This commit is contained in:
Mohamed Boudra
2026-06-04 00:22:40 +07:00
67 changed files with 3976 additions and 294 deletions
+137 -15
View File
@@ -1,5 +1,5 @@
import { router, usePathname } from "expo-router";
import { FolderPlus, Home, MessagesSquare, Settings, X } from "lucide-react-native";
import { FolderPlus, Home, MessagesSquare, Search, Settings, X } from "lucide-react-native";
import {
type Dispatch,
memo,
@@ -34,6 +34,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
import { SidebarGroupingSelector } from "@/components/sidebar/sidebar-grouping-selector";
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
import { Shortcut } from "@/components/ui/shortcut";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
@@ -47,6 +48,8 @@ import {
type SidebarProjectEntry,
useSidebarWorkspacesList,
} from "@/hooks/use-sidebar-workspaces-list";
import { useSidebarViewStore, type SidebarGroupMode } from "@/stores/sidebar-view-store";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { useHostRuntimeSnapshot, useHosts } from "@/runtime/host-runtime";
import {
MAX_SIDEBAR_WIDTH,
@@ -89,6 +92,7 @@ interface SidebarSharedProps {
isInitialLoad: boolean;
isRevalidating: boolean;
isManualRefresh: boolean;
groupMode: SidebarGroupMode;
collapsedProjectKeys: SidebarShortcutModel["collapsedProjectKeys"];
shortcutIndexByWorkspaceKey: SidebarShortcutModel["shortcutIndexByWorkspaceKey"];
toggleProjectCollapsed: SidebarShortcutModel["toggleProjectCollapsed"];
@@ -192,6 +196,10 @@ export const LeftSidebar = memo(function LeftSidebar({
const { collapsedProjectKeys, shortcutIndexByWorkspaceKey, toggleProjectCollapsed } =
useSidebarShortcutModel({ projects, isInitialLoad });
const groupMode = useSidebarViewStore((state) =>
activeServerId ? state.getGroupMode(activeServerId) : "project",
);
const [isManualRefresh, setIsManualRefresh] = useState(false);
const handleRefresh = useCallback(() => {
@@ -268,6 +276,7 @@ export const LeftSidebar = memo(function LeftSidebar({
isInitialLoad,
isRevalidating,
isManualRefresh,
groupMode,
collapsedProjectKeys,
shortcutIndexByWorkspaceKey,
toggleProjectCollapsed,
@@ -417,6 +426,21 @@ function AddProjectTooltipContent({
);
}
function HeaderIconTooltipContent({
label,
shortcutKeys,
}: {
label: string;
shortcutKeys?: ReturnType<typeof useShortcutKeys>;
}) {
return (
<View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>{label}</Text>
{shortcutKeys ? <Shortcut chord={shortcutKeys} /> : null}
</View>
);
}
function SidebarFooter({
theme,
activeServerId,
@@ -518,6 +542,7 @@ function MobileSidebar({
isInitialLoad,
isRevalidating,
isManualRefresh,
groupMode,
collapsedProjectKeys,
shortcutIndexByWorkspaceKey,
toggleProjectCollapsed,
@@ -698,13 +723,16 @@ function MobileSidebar({
<GestureDetector gesture={closeGesture} touchAction="pan-y">
<Animated.View style={mobileSidebarStyle} pointerEvents="auto">
<View style={styles.sidebarContent} pointerEvents="auto">
<SidebarHeaderRow
icon={MessagesSquare}
label="Sessions"
onPress={handleViewMore}
isActive={isSessionsActive}
testID="sidebar-sessions"
/>
<View style={styles.sidebarHeaderRow}>
<SidebarHeaderRow
icon={MessagesSquare}
label="Sessions"
onPress={handleViewMore}
isActive={isSessionsActive}
testID="sidebar-sessions"
/>
</View>
<WorkspacesSectionHeader serverId={activeServerId} />
<Pressable
style={styles.mobileCloseButton}
onPress={closeToAgent}
@@ -733,6 +761,7 @@ function MobileSidebar({
collapsedProjectKeys={collapsedProjectKeys}
onToggleProjectCollapsed={toggleProjectCollapsed}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
groupMode={groupMode}
projects={projects}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
@@ -777,6 +806,7 @@ function DesktopSidebar({
isInitialLoad,
isRevalidating,
isManualRefresh,
groupMode,
collapsedProjectKeys,
shortcutIndexByWorkspaceKey,
toggleProjectCollapsed,
@@ -860,14 +890,17 @@ function DesktopSidebar({
<View style={styles.sidebarDragArea}>
<TitlebarDragRegion />
{padding.top > 0 ? <View style={paddingTopSpacerStyle} /> : null}
<SidebarHeaderRow
icon={MessagesSquare}
label="Sessions"
onPress={handleViewMore}
isActive={isSessionsActive}
testID="sidebar-sessions"
/>
<View style={styles.sidebarHeaderRow}>
<SidebarHeaderRow
icon={MessagesSquare}
label="Sessions"
onPress={handleViewMore}
isActive={isSessionsActive}
testID="sidebar-sessions"
/>
</View>
</View>
<WorkspacesSectionHeader serverId={activeServerId} />
{isInitialLoad ? (
<SidebarAgentListSkeleton />
@@ -877,6 +910,7 @@ function DesktopSidebar({
collapsedProjectKeys={collapsedProjectKeys}
onToggleProjectCollapsed={toggleProjectCollapsed}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
groupMode={groupMode}
projects={projects}
isRefreshing={isManualRefresh && isRevalidating}
onRefresh={handleRefresh}
@@ -911,6 +945,61 @@ function DesktopSidebar({
);
}
function WorkspacesSectionHeader({ serverId }: { serverId: string | null }) {
const { theme } = useUnistyles();
const setCommandCenterOpen = useKeyboardShortcutsStore((state) => state.setCommandCenterOpen);
const commandCenterKeys = useShortcutKeys("toggle-command-center");
const handleSearchPress = useCallback(() => setCommandCenterOpen(true), [setCommandCenterOpen]);
const searchButtonStyle = useCallback(
({ hovered = false, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.workspacesHeaderIconButton,
(hovered || pressed) && styles.workspacesHeaderIconButtonHovered,
],
[],
);
return (
<View style={styles.workspacesSectionHeader}>
<Text style={styles.workspacesSectionTitle}>Workspaces</Text>
<View style={styles.workspacesSectionActions}>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable
accessibilityRole="button"
accessibilityLabel="Open command center"
testID="sidebar-command-center-search"
style={searchButtonStyle}
onPress={handleSearchPress}
>
{({ hovered, pressed }) => (
<Search
size={14}
color={
hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted
}
/>
)}
</Pressable>
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
<HeaderIconTooltipContent label="Search" shortcutKeys={commandCenterKeys} />
</TooltipContent>
</Tooltip>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<View>
<SidebarGroupingSelector serverId={serverId} />
</View>
</TooltipTrigger>
<TooltipContent side="bottom" align="center" offset={8}>
<HeaderIconTooltipContent label="Display preferences" />
</TooltipContent>
</Tooltip>
</View>
</View>
);
}
// Static styles for Animated.Views — must NOT use Unistyles dynamic theme to
// avoid the "Unable to find node on an unmounted component" crash when Unistyles
// tries to patch the native node that Reanimated also manages.
@@ -932,6 +1021,39 @@ const staticStyles = RNStyleSheet.create({
});
const styles = StyleSheet.create((theme) => ({
sidebarHeaderRow: {
position: "relative",
},
workspacesSectionHeader: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: theme.spacing[2],
paddingLeft: theme.spacing[2] + theme.spacing[3],
paddingRight: theme.spacing[4],
paddingTop: theme.spacing[2],
paddingBottom: theme.spacing[1],
},
workspacesSectionTitle: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
},
workspacesSectionActions: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
},
workspacesHeaderIconButton: {
width: 28,
height: 28,
alignItems: "center",
justifyContent: "center",
borderRadius: theme.borderRadius.md,
},
workspacesHeaderIconButtonHovered: {
backgroundColor: theme.colors.surfaceSidebarHover,
},
sidebarContent: {
flex: 1,
minHeight: 0,
@@ -90,6 +90,7 @@ function workspace(input: {
workspaceKind: input.name === "main" ? "local_checkout" : "worktree",
name: input.name,
status: input.status ?? "done",
statusEnteredAt: null,
archivingAt: null,
diffStat: null,
scripts: input.scripts ?? [],
@@ -41,17 +41,16 @@ import { DiffStat } from "@/components/diff-stat";
import {
Archive,
CircleAlert,
CircleCheck,
ChevronDown,
ChevronRight,
Copy,
ExternalLink,
FolderPlus,
FolderGit2,
GitPullRequest,
Globe,
Settings,
SquareTerminal,
Monitor,
MoreVertical,
Pencil,
Plus,
@@ -98,12 +97,23 @@ import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-disp
import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader";
import { isEmphasizedStatusDotBucket } from "@/utils/status-dot-color";
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
import { SidebarStatusWorkspaceList } from "@/components/sidebar/sidebar-status-list";
import {
SidebarWorkspaceRowFrame,
SidebarWorkspaceRowContent,
sidebarWorkspaceRowStyles,
} from "@/components/sidebar/sidebar-workspace-row-content";
import {
useHydratedWorkspaceEntries,
useProjectNamesMap,
} from "@/hooks/use-status-mode-workspaces";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Shortcut } from "@/components/ui/shortcut";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import { useClearWorkspaceAttention } from "@/hooks/use-clear-workspace-attention";
import type { PrHint } from "@/git/use-pr-status-query";
import { buildSidebarProjectRowModel } from "@/utils/sidebar-project-row-model";
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
@@ -119,8 +129,6 @@ import {
archiveWorkspaceOptimistically,
archiveWorkspacesOptimistically,
} from "@/workspace/workspace-archive";
import { WorkspaceHoverCard } from "@/components/workspace-hover-card";
import { GitHubIcon } from "@/components/icons/github-icon";
import { isWeb as platformIsWeb, isNative as platformIsNative } from "@/constants/platform";
const workspaceKeyExtractor = (workspace: SidebarWorkspaceEntry) => workspace.workspaceKey;
@@ -134,12 +142,10 @@ const DEFAULT_STATUS_DOT_OFFSET = 0;
const EMPHASIZED_STATUS_DOT_OFFSET = -1;
const ThemedExternalLink = withUnistyles(ExternalLink);
const ThemedGitPullRequest = withUnistyles(GitPullRequest);
const ThemedGitHubIcon = withUnistyles(GitHubIcon);
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
const ThemedCircleAlert = withUnistyles(CircleAlert);
const ThemedCircleCheck = withUnistyles(CircleCheck);
const ThemedSyncedLoader = withUnistyles(SyncedLoader);
const ThemedMonitor = withUnistyles(Monitor);
const ThemedFolderGit2 = withUnistyles(FolderGit2);
const ThemedFolderPlus = withUnistyles(FolderPlus);
const ThemedGlobe = withUnistyles(Globe);
const ThemedSquareTerminal = withUnistyles(SquareTerminal);
@@ -229,6 +235,7 @@ interface SidebarWorkspaceListProps {
collapsedProjectKeys: ReadonlySet<string>;
onToggleProjectCollapsed: (projectKey: string) => void;
shortcutIndexByWorkspaceKey: Map<string, number>;
groupMode: "project" | "status";
isRefreshing?: boolean;
onRefresh?: () => void;
onWorkspacePress?: () => void;
@@ -281,6 +288,7 @@ interface WorkspaceRowInnerProps {
onCopyBranchName?: () => void;
onCopyPath?: () => void;
onRename?: () => void;
onMarkAsRead?: () => void;
archiveShortcutKeys?: ShortcutKey[][] | null;
}
@@ -294,7 +302,7 @@ function getWorkspaceArchiveStatus(
return "idle";
}
function useSidebarWorkspaceEntry(
export function useSidebarWorkspaceEntry(
serverId: string | null,
workspaceId: string | null,
): SidebarWorkspaceEntry | null {
@@ -367,6 +375,23 @@ function workspaceKebabStyle({
return [styles.kebabButton, hovered && styles.kebabButtonHovered];
}
function getProjectWorkspaceRowStyle({
isDragging,
selected,
isHovered,
}: {
isDragging: boolean;
selected: boolean;
isHovered: boolean;
}) {
return [
styles.workspaceRow,
isDragging && styles.workspaceRowDragging,
selected && styles.sidebarRowSelected,
isHovered && styles.workspaceRowHovered,
];
}
function noop() {}
const prBadgeStyles = StyleSheet.create((theme) => ({
@@ -391,100 +416,6 @@ const prBadgeStyles = StyleSheet.create((theme) => ({
const prBadgeTextHoveredCombined = [prBadgeStyles.text, prBadgeStyles.textHovered];
function ChecksBadge({ checks }: { checks: PrHint["checks"] }): ReactElement | null {
if (!checks || checks.length === 0) return null;
const failed = checks.filter((c) => c.status === "failure").length;
if (failed === 0) return null;
const label = `${failed} failed`;
return (
<View style={checksBadgeStyles.badge}>
<ThemedGitHubIcon size={10} uniProps={redColorMapping} />
<Text style={checksBadgeStyles.text}>{label}</Text>
</View>
);
}
const checksBadgeStyles = StyleSheet.create((theme) => ({
badge: {
flexDirection: "row",
alignItems: "center",
gap: 2,
},
text: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
lineHeight: 14,
color: theme.colors.palette.red[500],
},
}));
function WorkspaceStatusIndicator({
bucket,
workspaceKind,
loading = false,
}: {
bucket: SidebarWorkspaceEntry["statusBucket"];
workspaceKind: SidebarWorkspaceEntry["workspaceKind"];
loading?: boolean;
}) {
const shouldShowSyncedLoader = shouldRenderSyncedStatusLoader({ bucket });
if (loading) {
return (
<View style={styles.workspaceStatusDot}>
<ThemedActivityIndicator size={8} uniProps={foregroundMutedColorMapping} />
</View>
);
}
if (shouldShowSyncedLoader) {
return (
<View style={styles.workspaceStatusDot}>
<ThemedSyncedLoader size={11} uniProps={syncedLoaderColorMapping} />
</View>
);
}
if (bucket === "needs_input") {
return (
<View style={styles.workspaceStatusDot}>
<ThemedCircleAlert size={14} uniProps={amberColorMapping} />
</View>
);
}
let KindIcon: typeof ThemedMonitor | null;
if (workspaceKind === "local_checkout") KindIcon = ThemedMonitor;
else if (workspaceKind === "worktree") KindIcon = ThemedFolderGit2;
else KindIcon = null;
if (!KindIcon) return null;
const dotColorStyle = getStatusDotColorStyle(bucket);
const statusDotSize = isEmphasizedStatusDotBucket(bucket)
? EMPHASIZED_STATUS_DOT_SIZE
: DEFAULT_STATUS_DOT_SIZE;
const statusDotOffset =
statusDotSize === EMPHASIZED_STATUS_DOT_SIZE
? EMPHASIZED_STATUS_DOT_OFFSET
: DEFAULT_STATUS_DOT_OFFSET;
return (
<View style={styles.workspaceStatusDot}>
<KindIcon size={14} uniProps={foregroundMutedColorMapping} />
{dotColorStyle ? (
<StatusDotOverlay
dotColorStyle={dotColorStyle}
size={statusDotSize}
offset={statusDotOffset}
/>
) : null}
</View>
);
}
function StatusDotOverlay({
dotColorStyle,
size,
@@ -620,6 +551,9 @@ function ProjectRowTrailingActions({
const trash2LeadingIcon = <ThemedTrash2 size={14} uniProps={foregroundMutedColorMapping} />;
const settingsLeadingIcon = <ThemedSettings size={14} uniProps={foregroundMutedColorMapping} />;
const copyLeadingIcon = <ThemedCopy size={14} uniProps={foregroundMutedColorMapping} />;
const markAsReadLeadingIcon = (
<ThemedCircleCheck size={14} uniProps={foregroundMutedColorMapping} />
);
const archiveLeadingIcon = <ThemedArchive size={14} uniProps={foregroundMutedColorMapping} />;
const renameLeadingIcon = <ThemedPencil size={14} uniProps={foregroundMutedColorMapping} />;
@@ -695,6 +629,7 @@ function WorkspaceRowRightGroup({
archivePendingLabel,
archiveShortcutKeys,
onArchive,
onMarkAsRead,
onCopyBranchName,
onCopyPath,
onRename,
@@ -712,13 +647,14 @@ function WorkspaceRowRightGroup({
archivePendingLabel?: string;
archiveShortcutKeys?: ShortcutKey[][] | null;
onArchive?: () => void;
onMarkAsRead?: () => void;
onCopyBranchName?: () => void;
onCopyPath?: () => void;
onRename?: () => void;
}) {
const showKebab = Boolean(onArchive && (isHovered || isTouchPlatform));
return (
<View style={styles.workspaceRowRight}>
<>
{showScriptsIcon ? (
<View testID="workspace-globe-icon" accessibilityLabel="Scripts available">
{hasRunningService ? (
@@ -735,6 +671,7 @@ function WorkspaceRowRightGroup({
onCopyPath={onCopyPath}
onCopyBranchName={onCopyBranchName}
onRename={onRename}
onMarkAsRead={onMarkAsRead}
onArchive={onArchive}
archiveLabel={archiveLabel}
archiveStatus={archiveStatus}
@@ -749,11 +686,11 @@ function WorkspaceRowRightGroup({
/>
) : null}
{showShortcutBadge && shortcutNumber !== null ? (
<View style={styles.shortcutBadge}>
<Text style={styles.shortcutBadgeText}>{shortcutNumber}</Text>
<View style={sidebarWorkspaceRowStyles.shortcutBadge}>
<Text style={sidebarWorkspaceRowStyles.shortcutBadgeText}>{shortcutNumber}</Text>
</View>
) : null}
</View>
</>
);
}
@@ -762,6 +699,7 @@ function WorkspaceKebabMenu({
onCopyPath,
onCopyBranchName,
onRename,
onMarkAsRead,
onArchive,
archiveLabel,
archiveStatus,
@@ -772,6 +710,7 @@ function WorkspaceKebabMenu({
onCopyPath?: () => void;
onCopyBranchName?: () => void;
onRename?: () => void;
onMarkAsRead?: () => void;
onArchive: () => void;
archiveLabel?: string;
archiveStatus?: "idle" | "pending" | "success";
@@ -821,6 +760,15 @@ function WorkspaceKebabMenu({
Rename workspace
</DropdownMenuItem>
) : null}
{onMarkAsRead ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-mark-as-read-${workspaceKey}`}
leading={markAsReadLeadingIcon}
onSelect={onMarkAsRead}
>
Mark as read
</DropdownMenuItem>
) : null}
<DropdownMenuItem
testID={`sidebar-workspace-menu-archive-${workspaceKey}`}
leading={archiveLeadingIcon}
@@ -1404,9 +1352,7 @@ function WorkspaceRowInner({
archiveShortcutKeys,
}: WorkspaceRowInnerProps) {
const _isCompact = useIsCompactFormFactor();
const [isHovered, setIsHovered] = useState(false);
const isTouchPlatform = platformIsNative;
const prHint = workspace.prHint;
const interaction = useLongPressDragInteraction({
drag,
menuController,
@@ -1426,96 +1372,71 @@ function WorkspaceRowInner({
onPress();
}, [interaction.didLongPressRef, onPress]);
const handlePointerEnter = useCallback(() => setIsHovered(true), []);
const handlePointerLeave = useCallback(() => setIsHovered(false), []);
const workspaceRowStyle = useCallback(
({ pressed }: PressableStateCallbackType) => [
styles.workspaceRow,
isDragging && styles.workspaceRowDragging,
selected && styles.sidebarRowSelected,
isHovered && styles.workspaceRowHovered,
pressed && styles.workspaceRowPressed,
],
[isDragging, selected, isHovered],
);
const isDesktop = !isTouchPlatform;
const showScriptsIcon = isDesktop && workspace.hasRunningScripts;
const hasRunningService = workspace.scripts.some(
(s) => s.lifecycle === "running" && (s.type ?? "service") === "service",
);
const accessibilityState = useMemo(() => ({ selected }), [selected]);
const workspaceBranchTextStyle = useMemo(
() => [
styles.workspaceBranchText,
isHovered && styles.workspaceBranchTextHovered,
isCreating && styles.workspaceBranchTextCreating,
],
[isHovered, isCreating],
);
return (
<WorkspaceHoverCard workspace={workspace} prHint={prHint} isDragging={isDragging}>
<View
{...dragAttributes}
{...dragHandleProps?.listeners}
ref={dragHandleProps?.setActivatorNodeRef as unknown as Ref<View>}
style={styles.workspaceRowContainer}
onPointerEnter={handlePointerEnter}
onPointerLeave={handlePointerLeave}
>
<Pressable
disabled={isArchiving}
aria-selected={selected}
accessibilityRole="button"
accessibilityState={accessibilityState}
style={workspaceRowStyle}
onPressIn={interaction.handlePressIn}
onTouchMove={interaction.handleTouchMove}
onPressOut={interaction.handlePressOut}
onPress={handlePress}
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
>
<View style={styles.workspaceRowMain}>
<View style={styles.workspaceRowLeft}>
<WorkspaceStatusIndicator
bucket={workspace.statusBucket}
workspaceKind={workspace.workspaceKind}
loading={isArchiving || isCreating}
/>
<Text style={workspaceBranchTextStyle} numberOfLines={1}>
{workspace.name}
</Text>
</View>
<WorkspaceRowRightGroup
workspace={workspace}
isHovered={isHovered}
isTouchPlatform={isTouchPlatform}
showScriptsIcon={showScriptsIcon}
hasRunningService={hasRunningService}
isCreating={isCreating}
showShortcutBadge={showShortcutBadge}
shortcutNumber={shortcutNumber}
archiveLabel={archiveLabel}
archiveStatus={archiveStatus}
archivePendingLabel={archivePendingLabel}
archiveShortcutKeys={archiveShortcutKeys}
onArchive={onArchive}
onCopyBranchName={onCopyBranchName}
onCopyPath={onCopyPath}
onRename={onRename}
/>
<SidebarWorkspaceRowFrame workspace={workspace} isDragging={isDragging}>
{({ isHovered, hoverHandlers }) => {
const isDesktop = !isTouchPlatform;
const showScriptsIcon = isDesktop && workspace.hasRunningScripts;
const hasRunningService = workspace.scripts.some(
(s) => s.lifecycle === "running" && (s.type ?? "service") === "service",
);
const workspaceRowStyle = getProjectWorkspaceRowStyle({
isDragging,
selected,
isHovered,
});
return (
<View
{...dragAttributes}
{...dragHandleProps?.listeners}
ref={dragHandleProps?.setActivatorNodeRef as unknown as Ref<View>}
style={styles.workspaceRowContainer}
{...hoverHandlers}
>
<Pressable
disabled={isArchiving}
aria-selected={selected}
accessibilityRole="button"
accessibilityState={accessibilityState}
style={workspaceRowStyle}
onPressIn={interaction.handlePressIn}
onTouchMove={interaction.handleTouchMove}
onPressOut={interaction.handlePressOut}
onPress={handlePress}
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
>
<SidebarWorkspaceRowContent
workspace={workspace}
isHovered={isHovered}
isLoading={isArchiving || isCreating}
isCreating={isCreating}
>
<WorkspaceRowRightGroup
workspace={workspace}
isHovered={isHovered}
isTouchPlatform={isTouchPlatform}
showScriptsIcon={showScriptsIcon}
hasRunningService={hasRunningService}
isCreating={isCreating}
showShortcutBadge={showShortcutBadge}
shortcutNumber={shortcutNumber}
archiveLabel={archiveLabel}
archiveStatus={archiveStatus}
archivePendingLabel={archivePendingLabel}
archiveShortcutKeys={archiveShortcutKeys}
onArchive={onArchive}
onCopyBranchName={onCopyBranchName}
onCopyPath={onCopyPath}
onRename={onRename}
/>
</SidebarWorkspaceRowContent>
</Pressable>
</View>
{prHint ? (
<View style={styles.workspacePrBadgeRow}>
<PrBadge hint={prHint} />
<ChecksBadge checks={prHint.checks} />
</View>
) : null}
</Pressable>
</View>
</WorkspaceHoverCard>
);
}}
</SidebarWorkspaceRowFrame>
);
}
@@ -1722,6 +1643,15 @@ function WorkspaceRowWithMenu({
}, []);
const archiveShortcutKeys = useShortcutKeys("archive-worktree");
const { hasClearableAttention, clearAttention } = useClearWorkspaceAttention({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
});
const handleMarkAsRead = useCallback(() => {
void clearAttention().catch((error) => {
toast.error(error instanceof Error ? error.message : "Failed to mark workspace as read");
});
}, [clearAttention, toast]);
useKeyboardActionHandler({
handlerId: `worktree-archive-${workspace.workspaceKey}`,
@@ -1759,6 +1689,7 @@ function WorkspaceRowWithMenu({
onCopyBranchName={canCopyBranchName ? handleCopyBranchName : undefined}
onCopyPath={handleCopyPath}
onRename={canCopyBranchName ? handleOpenRename : undefined}
onMarkAsRead={hasClearableAttention ? handleMarkAsRead : undefined}
archiveShortcutKeys={selected ? archiveShortcutKeys : null}
/>
<AdaptiveRenameModal
@@ -2429,6 +2360,7 @@ export function SidebarWorkspaceList({
collapsedProjectKeys,
onToggleProjectCollapsed,
shortcutIndexByWorkspaceKey,
groupMode,
isRefreshing: _isRefreshing = false,
onRefresh: _onRefresh,
onWorkspacePress,
@@ -2437,6 +2369,72 @@ export function SidebarWorkspaceList({
parentGestureRef,
}: SidebarWorkspaceListProps) {
const pathname = usePathname();
if (groupMode === "status") {
return (
<SidebarStatusModeWrapper
serverId={serverId}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
onWorkspacePress={onWorkspacePress}
/>
);
}
return (
<ProjectModeList
projects={projects}
serverId={serverId}
collapsedProjectKeys={collapsedProjectKeys}
onToggleProjectCollapsed={onToggleProjectCollapsed}
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
onWorkspacePress={onWorkspacePress}
onAddProject={onAddProject}
listFooterComponent={listFooterComponent}
parentGestureRef={parentGestureRef}
pathname={pathname}
/>
);
}
function SidebarStatusModeWrapper({
serverId,
shortcutIndexByWorkspaceKey: _projectShortcutIndex,
onWorkspacePress,
}: {
serverId: string | null;
shortcutIndexByWorkspaceKey: Map<string, number>;
onWorkspacePress?: () => void;
}) {
const hydratedWorkspaces = useHydratedWorkspaceEntries(serverId);
const projectNamesByKey = useProjectNamesMap(serverId);
const showShortcutBadges = useShowShortcutBadges();
return (
<SidebarStatusWorkspaceList
workspaces={hydratedWorkspaces}
projectNamesByKey={projectNamesByKey}
serverId={serverId}
shortcutIndexByWorkspaceKey={_projectShortcutIndex}
showShortcutBadges={showShortcutBadges}
onWorkspacePress={onWorkspacePress}
/>
);
}
function ProjectModeList({
projects,
serverId,
collapsedProjectKeys,
onToggleProjectCollapsed,
shortcutIndexByWorkspaceKey,
onWorkspacePress,
onAddProject,
listFooterComponent,
parentGestureRef,
pathname,
}: Omit<SidebarWorkspaceListProps, "groupMode" | "isRefreshing" | "onRefresh"> & {
pathname: string;
}) {
const [creatingWorkspaceIds, setCreatingWorkspaceIds] = useState<Set<string>>(() => new Set());
const creatingWorkspaceTimeoutsRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(
new Map(),
@@ -0,0 +1,113 @@
import { useCallback } from "react";
import { Text, View, type PressableStateCallbackType } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { Settings2 } from "lucide-react-native";
import type { Theme } from "@/styles/theme";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from "@/components/ui/dropdown-menu";
import { useSidebarViewStore, type SidebarGroupMode } from "@/stores/sidebar-view-store";
import { isWeb as platformIsWeb } from "@/constants/platform";
const ThemedSettings2 = withUnistyles(Settings2);
const filterColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const GROUP_MODE_ITEMS: Array<{ value: SidebarGroupMode; label: string }> = [
{ value: "project", label: "Project" },
{ value: "status", label: "Status" },
];
export function SidebarGroupingSelector({ serverId }: { serverId: string | null }) {
const groupMode = useSidebarViewStore((state) =>
serverId ? state.getGroupMode(serverId) : "project",
);
const setGroupMode = useSidebarViewStore((state) => state.setGroupMode);
const handleSelect = useCallback(
(mode: SidebarGroupMode) => {
if (!serverId) return;
setGroupMode(serverId, mode);
},
[serverId, setGroupMode],
);
const triggerStyle = useCallback(
({ hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.trigger,
hovered && styles.triggerHovered,
],
[],
);
return (
<DropdownMenu>
<DropdownMenuTrigger
style={triggerStyle}
accessibilityRole={platformIsWeb ? undefined : "button"}
accessibilityLabel="Sidebar grouping"
testID="sidebar-grouping-selector"
>
<ThemedSettings2 size={14} uniProps={filterColorMapping} />
</DropdownMenuTrigger>
<DropdownMenuContent align="start" width={180} testID="sidebar-grouping-menu">
<View style={styles.menuHeader}>
<Text style={styles.menuHeaderLabel}>Group by</Text>
</View>
{GROUP_MODE_ITEMS.map((item) => (
<GroupModeMenuItem
key={item.value}
item={item}
isSelected={groupMode === item.value}
onSelect={handleSelect}
/>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
function GroupModeMenuItem({
item,
isSelected,
onSelect,
}: {
item: { value: SidebarGroupMode; label: string };
isSelected: boolean;
onSelect: (mode: SidebarGroupMode) => void;
}) {
const handleSelect = useCallback(() => onSelect(item.value), [item.value, onSelect]);
return (
<DropdownMenuItem
testID={`sidebar-grouping-${item.value}`}
selected={isSelected}
onSelect={handleSelect}
>
{item.label}
</DropdownMenuItem>
);
}
const styles = StyleSheet.create((theme) => ({
trigger: {
width: 28,
height: 28,
alignItems: "center",
justifyContent: "center",
borderRadius: theme.borderRadius.md,
},
triggerHovered: {
backgroundColor: theme.colors.surfaceSidebarHover,
},
menuHeader: {
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
},
menuHeaderLabel: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.medium,
color: theme.colors.foregroundMuted,
},
}));
@@ -0,0 +1,888 @@
import { memo, useCallback, useMemo, useState } from "react";
import { View, Text, Pressable, ScrollView, type PressableStateCallbackType } from "react-native";
import { NestableScrollContainer } from "react-native-draggable-flatlist";
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
import {
buildStatusGroups,
buildStatusShortcutIndex,
type StatusGroup,
} from "@/hooks/sidebar-status-view-model";
import { isWeb as platformIsWeb, isNative as platformIsNative } from "@/constants/platform";
import { StyleSheet } from "react-native-unistyles";
import type { Theme } from "@/styles/theme";
import { withUnistyles } from "react-native-unistyles";
import {
ChevronDown,
ChevronRight,
CircleAlert,
CircleCheck,
CircleDot,
CircleX,
MoreVertical,
SquareTerminal,
Globe,
Copy,
Archive,
Pencil,
} from "lucide-react-native";
import { DiffStat } from "@/components/diff-stat";
import { useSidebarWorkspaceEntry } from "../sidebar-workspace-list";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from "@/components/ui/dropdown-menu";
import { useToast } from "@/contexts/toast-context";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { invalidateCheckoutGitQueriesForClient } from "@/git/query-keys";
import { slugify, validateBranchSlug, MAX_SLUG_LENGTH } from "@getpaseo/protocol/branch-slug";
import { AdaptiveRenameModal } from "@/components/rename-modal";
import {
requireWorkspaceExecutionDirectory,
resolveWorkspaceExecutionDirectory,
} from "@/utils/workspace-execution";
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
import { archiveWorkspaceOptimistically } from "@/workspace/workspace-archive";
import { useCheckoutGitActionsStore } from "@/git/actions-store";
import { confirmRiskyWorktreeArchive } from "@/git/worktree-archive-warning";
import { confirmDialog } from "@/utils/confirm-dialog";
import * as Clipboard from "expo-clipboard";
import { Shortcut } from "@/components/ui/shortcut";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import { useClearWorkspaceAttention } from "@/hooks/use-clear-workspace-attention";
import {
SidebarWorkspaceRowFrame,
SidebarWorkspaceRowContent,
sidebarWorkspaceRowStyles,
} from "@/components/sidebar/sidebar-workspace-row-content";
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
// Themed icon wrappers
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const foregroundMutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
const blueColorMapping = (theme: Theme) => ({ color: theme.colors.palette.blue[500] });
const amberColorMapping = (theme: Theme) => ({ color: theme.colors.palette.amber[500] });
const redColorMapping = (theme: Theme) => ({ color: theme.colors.palette.red[500] });
const greenColorMapping = (theme: Theme) => ({ color: theme.colors.palette.green[500] });
const ThemedChevronDown = withUnistyles(ChevronDown);
const ThemedChevronRight = withUnistyles(ChevronRight);
const ThemedCircleAlert = withUnistyles(CircleAlert);
const ThemedCircleCheck = withUnistyles(CircleCheck);
const ThemedCircleDot = withUnistyles(CircleDot);
const ThemedCircleX = withUnistyles(CircleX);
const ThemedGlobe = withUnistyles(Globe);
const ThemedSquareTerminal = withUnistyles(SquareTerminal);
const ThemedMoreVertical = withUnistyles(MoreVertical);
const ThemedCopy = withUnistyles(Copy);
const ThemedArchive = withUnistyles(Archive);
const ThemedPencil = withUnistyles(Pencil);
const copyLeadingIcon = <ThemedCopy size={14} uniProps={foregroundMutedColorMapping} />;
const markAsReadLeadingIcon = (
<ThemedCircleCheck size={14} uniProps={foregroundMutedColorMapping} />
);
const archiveLeadingIcon = <ThemedArchive size={14} uniProps={foregroundMutedColorMapping} />;
const renameLeadingIcon = <ThemedPencil size={14} uniProps={foregroundMutedColorMapping} />;
interface StatusWorkspaceListProps {
workspaces: SidebarWorkspaceEntry[];
projectNamesByKey: Map<string, string>;
serverId: string | null;
shortcutIndexByWorkspaceKey: Map<string, number>;
showShortcutBadges: boolean;
onWorkspacePress?: () => void;
}
export function SidebarStatusWorkspaceList({
workspaces,
projectNamesByKey,
serverId,
shortcutIndexByWorkspaceKey: _projectShortcutIndex,
showShortcutBadges,
onWorkspacePress,
}: StatusWorkspaceListProps) {
const groups = useMemo(
() => buildStatusGroups(workspaces, projectNamesByKey),
[workspaces, projectNamesByKey],
);
const collapsedStatusGroupKeys = useSidebarCollapsedSectionsStore(
(state) => state.collapsedStatusGroupKeys,
);
const statusShortcutIndex = useMemo(
() =>
showShortcutBadges
? buildStatusShortcutIndex(
groups.filter((group) => !collapsedStatusGroupKeys.has(group.bucket)),
)
: new Map<string, number>(),
[collapsedStatusGroupKeys, groups, showShortcutBadges],
);
return (
<View style={styles.container}>
{platformIsNative ? (
<NestableScrollContainer
style={styles.list}
contentContainerStyle={styles.listContent}
showsVerticalScrollIndicator={false}
testID="sidebar-status-list-scroll"
>
<StatusGroupList
groups={groups}
collapsedStatusGroupKeys={collapsedStatusGroupKeys}
projectNamesByKey={projectNamesByKey}
serverId={serverId}
shortcutIndex={statusShortcutIndex}
showShortcutBadges={showShortcutBadges}
onWorkspacePress={onWorkspacePress}
/>
</NestableScrollContainer>
) : (
<ScrollView
style={styles.list}
contentContainerStyle={styles.listContent}
showsVerticalScrollIndicator={false}
testID="sidebar-status-list-scroll"
>
<StatusGroupList
groups={groups}
collapsedStatusGroupKeys={collapsedStatusGroupKeys}
projectNamesByKey={projectNamesByKey}
serverId={serverId}
shortcutIndex={statusShortcutIndex}
showShortcutBadges={showShortcutBadges}
onWorkspacePress={onWorkspacePress}
/>
</ScrollView>
)}
</View>
);
}
function StatusGroupList({
groups,
collapsedStatusGroupKeys,
projectNamesByKey,
serverId,
shortcutIndex,
showShortcutBadges,
onWorkspacePress,
}: {
groups: StatusGroup[];
collapsedStatusGroupKeys: ReadonlySet<string>;
projectNamesByKey: Map<string, string>;
serverId: string | null;
shortcutIndex: Map<string, number>;
showShortcutBadges: boolean;
onWorkspacePress?: () => void;
}) {
return (
<>
{groups.map((group) => (
<View key={group.bucket} style={styles.statusGroupBlock}>
<StatusGroupHeader group={group} collapsed={collapsedStatusGroupKeys.has(group.bucket)} />
{!collapsedStatusGroupKeys.has(group.bucket) ? (
<View style={styles.statusWorkspaceListContainer}>
{group.rows.map((workspace) => (
<StatusWorkspaceRow
key={workspace.workspaceKey}
workspace={workspace}
projectName={projectNamesByKey.get(workspace.projectKey) ?? ""}
serverId={serverId}
shortcutNumber={shortcutIndex.get(workspace.workspaceKey) ?? null}
showShortcutBadge={showShortcutBadges}
onWorkspacePress={onWorkspacePress}
/>
))}
</View>
) : null}
</View>
))}
</>
);
}
function StatusGroupHeader({ group, collapsed }: { group: StatusGroup; collapsed: boolean }) {
const [isHovered, setIsHovered] = useState(false);
const toggleStatusGroupCollapsed = useSidebarCollapsedSectionsStore(
(state) => state.toggleStatusGroupCollapsed,
);
const handlePress = useCallback(() => {
toggleStatusGroupCollapsed(group.bucket);
}, [group.bucket, toggleStatusGroupCollapsed]);
const handleHoverIn = useCallback(() => setIsHovered(true), []);
const handleHoverOut = useCallback(() => setIsHovered(false), []);
const rowStyle = useCallback(
({ pressed }: PressableStateCallbackType) => [
styles.statusGroupRow,
isHovered && styles.statusGroupRowHovered,
pressed && styles.statusGroupRowPressed,
],
[isHovered],
);
const accessibilityState = useMemo(() => ({ expanded: !collapsed }), [collapsed]);
return (
<View onPointerEnter={handleHoverIn} onPointerLeave={handleHoverOut}>
<Pressable
accessibilityRole={platformIsWeb ? undefined : "button"}
accessibilityLabel={`${group.label} status group`}
accessibilityState={accessibilityState}
style={rowStyle}
onPress={handlePress}
testID={`sidebar-status-group-${group.bucket}`}
>
<View style={styles.statusGroupRowLeft}>
<View style={styles.statusGroupLeadingVisualSlot}>
<StatusGroupLeadingVisual
bucket={group.bucket}
collapsed={collapsed}
showChevron={isHovered}
/>
</View>
<View style={styles.statusGroupTitleGroup}>
<Text style={styles.statusGroupTitle} numberOfLines={1}>
{group.label}
</Text>
</View>
</View>
</Pressable>
</View>
);
}
function StatusGroupLeadingVisual({
bucket,
collapsed,
showChevron,
}: {
bucket: StatusGroup["bucket"];
collapsed: boolean;
showChevron: boolean;
}) {
if (!showChevron) {
return <StatusGroupIcon bucket={bucket} />;
}
if (collapsed) {
return <ThemedChevronRight size={14} uniProps={foregroundMutedColorMapping} />;
}
return <ThemedChevronDown size={14} uniProps={foregroundMutedColorMapping} />;
}
function StatusGroupIcon({ bucket }: { bucket: StatusGroup["bucket"] }) {
switch (bucket) {
case "needs_input":
return <ThemedCircleAlert size={14} uniProps={amberColorMapping} />;
case "failed":
return <ThemedCircleX size={14} uniProps={redColorMapping} />;
case "attention":
return <ThemedCircleCheck size={14} uniProps={greenColorMapping} />;
case "running":
return <ThemedCircleDot size={14} uniProps={blueColorMapping} />;
case "done":
return <ThemedCircleCheck size={14} uniProps={foregroundMutedColorMapping} />;
}
}
const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
workspace,
projectName,
serverId,
shortcutNumber,
showShortcutBadge,
onWorkspacePress,
}: {
workspace: SidebarWorkspaceEntry;
projectName: string;
serverId: string | null;
shortcutNumber: number | null;
showShortcutBadge: boolean;
onWorkspacePress?: () => void;
}) {
const hydratedWorkspace = useSidebarWorkspaceEntry(serverId, workspace.workspaceId);
const activeWorkspaceSelection = useActiveWorkspaceSelection();
const selected =
activeWorkspaceSelection?.serverId === workspace.serverId &&
activeWorkspaceSelection?.workspaceId === workspace.workspaceId;
const handlePress = useCallback(() => {
if (!serverId) return;
onWorkspacePress?.();
navigateToWorkspace(serverId, workspace.workspaceId);
}, [serverId, onWorkspacePress, workspace.workspaceId]);
if (!hydratedWorkspace) return null;
return (
<StatusWorkspaceRowWithMenu
workspace={hydratedWorkspace}
projectName={projectName}
selected={selected}
shortcutNumber={shortcutNumber}
showShortcutBadge={showShortcutBadge}
onPress={handlePress}
/>
);
});
function StatusWorkspaceRowWithMenu({
workspace,
projectName,
selected,
shortcutNumber,
showShortcutBadge,
onPress,
}: {
workspace: SidebarWorkspaceEntry;
projectName: string;
selected: boolean;
shortcutNumber: number | null;
showShortcutBadge: boolean;
onPress: () => void;
}) {
const toast = useToast();
const archiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree);
const queryClient = useQueryClient();
const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false);
const [isRenameOpen, setIsRenameOpen] = useState(false);
const workspaceDirectory = resolveWorkspaceExecutionDirectory({
workspaceDirectory: workspace.workspaceDirectory,
});
const archiveStatus = useCheckoutGitActionsStore((state) =>
workspaceDirectory
? state.getStatus({
serverId: workspace.serverId,
cwd: workspaceDirectory,
actionId: "archive-worktree",
})
: "idle",
);
const isWorktree = workspace.workspaceKind === "worktree";
const isArchiving = isWorktree ? workspace.archivingAt !== null : isArchivingWorkspace;
const redirectAfterArchive = useCallback(() => {
redirectIfArchivingActiveWorkspace({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
activeWorkspaceSelection: selected
? { serverId: workspace.serverId, workspaceId: workspace.workspaceId }
: null,
});
}, [selected, workspace]);
const archiveWorktreeAfterConfirmation = useCallback(async () => {
if (isArchiving) return;
const confirmed = await confirmRiskyWorktreeArchive({
worktreeName: workspace.name,
isDirty: workspace.archiveHasUncommittedChanges,
aheadOfOrigin: workspace.archiveUnpushedCommitCount,
diffStat: workspace.diffStat,
});
if (!confirmed) return;
let archiveDirectory: string;
try {
archiveDirectory = requireWorkspaceExecutionDirectory({
workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.workspaceDirectory,
});
} catch (error) {
toast.error(error instanceof Error ? error.message : "Workspace path not available");
return;
}
redirectAfterArchive();
void archiveWorktree({
serverId: workspace.serverId,
cwd: archiveDirectory,
worktreePath: archiveDirectory,
}).catch((error) => {
toast.error(error instanceof Error ? error.message : "Failed to archive worktree");
});
}, [archiveWorktree, isArchiving, redirectAfterArchive, toast, workspace]);
const hideWorkspaceAfterConfirmation = useCallback(async () => {
if (isArchivingWorkspace) return;
const confirmed = await confirmDialog({
title: "Hide workspace?",
message: `Hide "${workspace.name}" from the sidebar?\n\nFiles on disk will not be changed.`,
confirmLabel: "Hide",
cancelLabel: "Cancel",
destructive: true,
});
if (!confirmed) return;
const client = getHostRuntimeStore().getClient(workspace.serverId);
if (!client) {
toast.error("Host is not connected");
return;
}
setIsArchivingWorkspace(true);
try {
await archiveWorkspaceOptimistically({
client,
workspace,
afterHide: redirectAfterArchive,
});
} catch (error) {
toast.error(error instanceof Error ? error.message : "Failed to hide workspace");
} finally {
setIsArchivingWorkspace(false);
}
}, [isArchivingWorkspace, redirectAfterArchive, toast, workspace]);
const handleCopyPath = useCallback(() => {
let copyTargetDirectory: string;
try {
copyTargetDirectory = requireWorkspaceExecutionDirectory({
workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.workspaceDirectory,
});
} catch (error) {
toast.error(error instanceof Error ? error.message : "Workspace path not available");
return;
}
void Clipboard.setStringAsync(copyTargetDirectory);
toast.copied("Path copied");
}, [toast, workspace.workspaceDirectory, workspace.workspaceId]);
const handleCopyBranchName = useCallback(() => {
void Clipboard.setStringAsync(workspace.name);
toast.copied("Branch name copied");
}, [toast, workspace.name]);
const renameMutation = useMutation({
mutationFn: async (branch: string) => {
const client = getHostRuntimeStore().getClient(workspace.serverId);
if (!client) throw new Error("Host is not connected");
const targetCwd = requireWorkspaceExecutionDirectory({
workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.workspaceDirectory,
});
const payload = await client.renameBranch({ cwd: targetCwd, branch });
if (!payload.success || payload.error) {
throw new Error(payload.error?.message ?? "Failed to rename branch");
}
return { targetCwd };
},
onSuccess: async ({ targetCwd }) => {
await invalidateCheckoutGitQueriesForClient(queryClient, {
serverId: workspace.serverId,
cwd: targetCwd,
});
},
});
const handleOpenRename = useCallback(() => setIsRenameOpen(true), []);
const handleCloseRename = useCallback(() => setIsRenameOpen(false), []);
const handleSubmitRename = useCallback(
async (value: string) => {
await renameMutation.mutateAsync(slugify(value));
},
[renameMutation],
);
const validateRenameSlug = useCallback((value: string): string | null => {
const result = validateBranchSlug(slugify(value));
if (result.valid) return null;
return result.error ?? "Invalid branch name";
}, []);
const archiveShortcutKeys = useShortcutKeys("archive-worktree");
const { hasClearableAttention, clearAttention } = useClearWorkspaceAttention({
serverId: workspace.serverId,
workspaceId: workspace.workspaceId,
});
const handleMarkAsRead = useCallback(() => {
void clearAttention().catch((error) => {
toast.error(error instanceof Error ? error.message : "Failed to mark workspace as read");
});
}, [clearAttention, toast]);
useKeyboardActionHandler({
handlerId: `worktree-archive-${workspace.workspaceKey}`,
actions: ["worktree.archive"],
enabled: selected && !isArchiving,
priority: 0,
handle: () => {
if (isWorktree) {
void archiveWorktreeAfterConfirmation();
} else {
void hideWorkspaceAfterConfirmation();
}
return true;
},
});
let computedArchiveStatus: "idle" | "pending" | "success" = "idle";
if (isWorktree) {
computedArchiveStatus = archiveStatus;
} else if (isArchivingWorkspace) {
computedArchiveStatus = "pending";
}
return (
<>
<StatusWorkspaceRowInner
workspace={workspace}
projectName={projectName}
selected={selected}
shortcutNumber={shortcutNumber}
showShortcutBadge={showShortcutBadge}
onPress={onPress}
isArchiving={isArchiving}
archiveLabel={isWorktree ? "Archive worktree" : "Hide from sidebar"}
archiveStatus={computedArchiveStatus}
archivePendingLabel={isWorktree ? "Archiving..." : "Hiding..."}
onArchive={isWorktree ? archiveWorktreeAfterConfirmation : hideWorkspaceAfterConfirmation}
onCopyBranchName={workspace.projectKind === "git" ? handleCopyBranchName : undefined}
onCopyPath={handleCopyPath}
onRename={workspace.projectKind === "git" ? handleOpenRename : undefined}
onMarkAsRead={hasClearableAttention ? handleMarkAsRead : undefined}
archiveShortcutKeys={selected ? archiveShortcutKeys : null}
/>
<AdaptiveRenameModal
visible={isRenameOpen}
title="Rename workspace"
initialValue={workspace.name}
placeholder="branch-name"
submitLabel="Rename"
validate={validateRenameSlug}
maxLength={MAX_SLUG_LENGTH}
onClose={handleCloseRename}
onSubmit={handleSubmitRename}
testID={`sidebar-workspace-rename-modal-${workspace.workspaceKey}`}
/>
</>
);
}
function StatusWorkspaceRowInner({
workspace,
projectName,
selected,
shortcutNumber,
showShortcutBadge,
onPress,
isArchiving,
archiveLabel,
archiveStatus = "idle",
archivePendingLabel,
onArchive,
onCopyBranchName,
onCopyPath,
onRename,
onMarkAsRead,
archiveShortcutKeys,
}: {
workspace: SidebarWorkspaceEntry;
projectName: string;
selected: boolean;
shortcutNumber: number | null;
showShortcutBadge: boolean;
onPress: () => void;
isArchiving: boolean;
archiveLabel?: string;
archiveStatus?: "idle" | "pending" | "success";
archivePendingLabel?: string;
onArchive?: () => void;
onCopyBranchName?: () => void;
onCopyPath?: () => void;
onRename?: () => void;
onMarkAsRead?: () => void;
archiveShortcutKeys?: ShortcutKey[][] | null;
}) {
const isTouchPlatform = platformIsNative;
const isDesktop = !isTouchPlatform;
const showScriptsIcon = isDesktop && workspace.hasRunningScripts;
const hasRunningService = workspace.scripts.some(
(s) => s.lifecycle === "running" && (s.type ?? "service") === "service",
);
const accessibilityState = useMemo(() => ({ selected }), [selected]);
return (
<SidebarWorkspaceRowFrame workspace={workspace}>
{({ isHovered, hoverHandlers }) => {
const showKebab = Boolean(onArchive && (isHovered || isTouchPlatform));
const workspaceRowStyle = getStatusWorkspaceRowStyle({ selected, isHovered });
return (
<View style={styles.workspaceRowContainer} {...hoverHandlers}>
<Pressable
disabled={isArchiving}
accessibilityRole="button"
accessibilityState={accessibilityState}
style={workspaceRowStyle}
onPress={onPress}
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
>
<SidebarWorkspaceRowContent
workspace={workspace}
subtitle={projectName}
isHovered={isHovered}
isLoading={isArchiving}
>
<>
{showScriptsIcon ? (
<View accessibilityLabel="Scripts available">
{hasRunningService ? (
<ThemedGlobe size={12} uniProps={blueColorMapping} />
) : (
<ThemedSquareTerminal size={12} uniProps={blueColorMapping} />
)}
</View>
) : null}
{showKebab && onArchive ? (
<StatusKebabMenu
workspaceKey={workspace.workspaceKey}
onCopyPath={onCopyPath}
onCopyBranchName={onCopyBranchName}
onRename={onRename}
onMarkAsRead={onMarkAsRead}
onArchive={onArchive}
archiveLabel={archiveLabel}
archiveStatus={archiveStatus}
archivePendingLabel={archivePendingLabel}
archiveShortcutKeys={archiveShortcutKeys}
/>
) : null}
{!showKebab && workspace.diffStat ? (
<DiffStat
additions={workspace.diffStat.additions}
deletions={workspace.diffStat.deletions}
/>
) : null}
{showShortcutBadge && shortcutNumber !== null ? (
<View style={sidebarWorkspaceRowStyles.shortcutBadge}>
<Text style={sidebarWorkspaceRowStyles.shortcutBadgeText}>
{shortcutNumber}
</Text>
</View>
) : null}
</>
</SidebarWorkspaceRowContent>
</Pressable>
</View>
);
}}
</SidebarWorkspaceRowFrame>
);
}
function StatusKebabMenu({
workspaceKey,
onCopyPath,
onCopyBranchName,
onRename,
onMarkAsRead,
onArchive,
archiveLabel,
archiveStatus,
archivePendingLabel,
archiveShortcutKeys,
}: {
workspaceKey: string;
onCopyPath?: () => void;
onCopyBranchName?: () => void;
onRename?: () => void;
onMarkAsRead?: () => void;
onArchive: () => void;
archiveLabel?: string;
archiveStatus?: "idle" | "pending" | "success";
archivePendingLabel?: string;
archiveShortcutKeys?: ShortcutKey[][] | null;
}) {
const archiveTrailing = useMemo(
() => (archiveShortcutKeys ? <Shortcut chord={archiveShortcutKeys} /> : null),
[archiveShortcutKeys],
);
return (
<DropdownMenu>
<DropdownMenuTrigger
hitSlop={8}
style={kebabStyle}
accessibilityRole={platformIsWeb ? undefined : "button"}
accessibilityLabel="Workspace actions"
testID={`sidebar-workspace-kebab-${workspaceKey}`}
>
{({ hovered }: { hovered?: boolean }) => (
<ThemedMoreVertical
size={14}
uniProps={hovered ? foregroundColorMapping : foregroundMutedColorMapping}
/>
)}
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={260}>
{onCopyPath ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-copy-path-${workspaceKey}`}
leading={copyLeadingIcon}
onSelect={onCopyPath}
>
Copy path
</DropdownMenuItem>
) : null}
{onCopyBranchName ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-copy-branch-name-${workspaceKey}`}
leading={copyLeadingIcon}
onSelect={onCopyBranchName}
>
Copy branch name
</DropdownMenuItem>
) : null}
{onRename ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-rename-${workspaceKey}`}
leading={renameLeadingIcon}
onSelect={onRename}
>
Rename workspace
</DropdownMenuItem>
) : null}
{onMarkAsRead ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-mark-as-read-${workspaceKey}`}
leading={markAsReadLeadingIcon}
onSelect={onMarkAsRead}
>
Mark as read
</DropdownMenuItem>
) : null}
<DropdownMenuItem
testID={`sidebar-workspace-menu-archive-${workspaceKey}`}
leading={archiveLeadingIcon}
trailing={archiveTrailing}
status={archiveStatus}
pendingLabel={archivePendingLabel}
onSelect={onArchive}
>
{archiveLabel ?? "Archive"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
function kebabStyle({ hovered = false }: PressableStateCallbackType & { hovered?: boolean }) {
return [styles.kebabButton, hovered && styles.kebabButtonHovered];
}
function getStatusWorkspaceRowStyle({
selected,
isHovered,
}: {
selected: boolean;
isHovered: boolean;
}) {
return [
styles.workspaceRow,
selected && styles.sidebarRowSelected,
isHovered && styles.workspaceRowHovered,
];
}
const styles = StyleSheet.create((theme) => ({
container: {
flex: 1,
},
list: {
flex: 1,
},
listContent: {
paddingHorizontal: theme.spacing[2],
paddingTop: theme.spacing[2],
paddingBottom: theme.spacing[4],
},
statusGroupBlock: {
marginBottom: theme.spacing[1],
},
statusWorkspaceListContainer: {},
statusGroupRow: {
minHeight: 36,
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.lg,
marginBottom: theme.spacing[2],
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: theme.spacing[2],
userSelect: "none",
},
statusGroupRowHovered: {
backgroundColor: theme.colors.surfaceSidebarHover,
},
statusGroupRowPressed: {
backgroundColor: theme.colors.surface2,
},
statusGroupRowLeft: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
flex: 1,
minWidth: 0,
},
statusGroupLeadingVisualSlot: {
position: "relative",
width: theme.iconSize.md,
height: theme.iconSize.md,
flexShrink: 0,
alignItems: "center",
justifyContent: "center",
},
statusGroupTitleGroup: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
flex: 1,
minWidth: 0,
},
statusGroupTitle: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: "400",
minWidth: 0,
flexShrink: 1,
},
workspaceRowContainer: {
position: "relative",
},
workspaceRow: {
minHeight: 36,
marginBottom: theme.spacing[1],
paddingVertical: theme.spacing[2],
paddingLeft: theme.spacing[3] + theme.spacing[3],
paddingRight: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
flexDirection: "column",
alignItems: "stretch",
justifyContent: "flex-start",
gap: theme.spacing[1],
userSelect: "none",
},
workspaceRowHovered: {
backgroundColor: theme.colors.surfaceSidebarHover,
},
workspaceRowPressed: {
backgroundColor: theme.colors.surface2,
},
sidebarRowSelected: {
backgroundColor: theme.colors.surfaceSidebarHover,
},
kebabButton: {
padding: 2,
borderRadius: 4,
marginLeft: 2,
},
kebabButtonHovered: {
backgroundColor: theme.colors.surface2,
},
}));
@@ -0,0 +1,446 @@
import { memo, useCallback, useMemo, useState, type ReactNode } from "react";
import {
ActivityIndicator,
Pressable,
Text,
View,
type GestureResponderEvent,
type ViewStyle,
} from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import {
CircleAlert,
ExternalLink,
Folder,
FolderGit2,
GitPullRequest,
Monitor,
} from "lucide-react-native";
import { GitHubIcon } from "@/components/icons/github-icon";
import { WorkspaceHoverCard } from "@/components/workspace-hover-card";
import { SyncedLoader } from "@/components/synced-loader";
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
import type { Theme } from "@/styles/theme";
import type { PrHint } from "@/git/use-pr-status-query";
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
import { isEmphasizedStatusDotBucket } from "@/utils/status-dot-color";
import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader";
import { openExternalUrl } from "@/utils/open-external-url";
const WORKSPACE_STATUS_DOT_WIDTH = 14;
const DEFAULT_STATUS_DOT_SIZE = 7;
const EMPHASIZED_STATUS_DOT_SIZE = 9;
const DEFAULT_STATUS_DOT_OFFSET = 0;
const EMPHASIZED_STATUS_DOT_OFFSET = -1;
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const amberColorMapping = (theme: Theme) => ({ color: theme.colors.palette.amber[500] });
const syncedLoaderColorMapping = (theme: Theme) => ({
color:
theme.colorScheme === "light"
? theme.colors.palette.amber[700]
: theme.colors.palette.amber[500],
});
const greenColorMapping = (theme: Theme) => ({ color: theme.colors.palette.green[500] });
const redColorMapping = (theme: Theme) => ({ color: theme.colors.palette.red[500] });
const purpleColorMapping = (theme: Theme) => ({ color: theme.colors.palette.purple[500] });
const ThemedExternalLink = withUnistyles(ExternalLink);
const ThemedGitPullRequest = withUnistyles(GitPullRequest);
const ThemedGitHubIcon = withUnistyles(GitHubIcon);
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
const ThemedCircleAlert = withUnistyles(CircleAlert);
const ThemedSyncedLoader = withUnistyles(SyncedLoader);
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;
hoverHandlers: { onPointerEnter: () => void; onPointerLeave: () => void };
}) => ReactNode;
}) {
const [isHovered, setIsHovered] = useState(false);
const handlePointerEnter = useCallback(() => setIsHovered(true), []);
const handlePointerLeave = useCallback(() => setIsHovered(false), []);
const hoverHandlers = useMemo(
() => ({ onPointerEnter: handlePointerEnter, onPointerLeave: handlePointerLeave }),
[handlePointerEnter, handlePointerLeave],
);
return (
<WorkspaceHoverCard workspace={workspace} prHint={workspace.prHint} isDragging={isDragging}>
{children({ isHovered, hoverHandlers })}
</WorkspaceHoverCard>
);
}
export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowContent({
workspace,
subtitle,
isHovered,
isLoading,
isCreating = false,
children,
}: {
workspace: SidebarWorkspaceEntry;
subtitle?: string | null;
isHovered: boolean;
isLoading: boolean;
isCreating?: boolean;
children?: ReactNode;
}) {
const workspaceBranchTextStyle = useMemo(
() => [
styles.workspaceBranchText,
isHovered && styles.workspaceBranchTextHovered,
isCreating && styles.workspaceBranchTextCreating,
],
[isHovered, isCreating],
);
return (
<>
<View style={styles.workspaceRowMain}>
<View style={styles.workspaceRowLeft}>
<WorkspaceStatusIndicator
bucket={workspace.statusBucket}
workspaceKind={workspace.workspaceKind}
loading={isLoading}
/>
<Text style={workspaceBranchTextStyle} numberOfLines={1}>
{workspace.name}
</Text>
</View>
<View style={styles.workspaceRowRight}>{children}</View>
</View>
{subtitle ? (
<Text style={styles.workspaceSubtitle} numberOfLines={1}>
{subtitle}
</Text>
) : null}
{workspace.prHint ? (
<View style={styles.workspacePrBadgeRow}>
<PrBadge hint={workspace.prHint} />
<ChecksBadge checks={workspace.prHint.checks} />
</View>
) : null}
</>
);
});
function WorkspaceStatusIndicator({
bucket,
workspaceKind,
loading = false,
}: {
bucket: SidebarWorkspaceEntry["statusBucket"];
workspaceKind: SidebarWorkspaceEntry["workspaceKind"];
loading?: boolean;
}) {
const shouldShowSyncedLoader = shouldRenderSyncedStatusLoader({ bucket });
if (loading) {
return (
<View style={styles.workspaceStatusDot}>
<ThemedActivityIndicator size={8} uniProps={foregroundMutedColorMapping} />
</View>
);
}
if (shouldShowSyncedLoader) {
return (
<View style={styles.workspaceStatusDot}>
<ThemedSyncedLoader size={11} uniProps={syncedLoaderColorMapping} />
</View>
);
}
if (bucket === "needs_input") {
return (
<View style={styles.workspaceStatusDot}>
<ThemedCircleAlert size={14} uniProps={amberColorMapping} />
</View>
);
}
let KindIcon: typeof ThemedMonitor;
if (workspaceKind === "local_checkout") KindIcon = ThemedMonitor;
else if (workspaceKind === "worktree") KindIcon = ThemedFolderGit2;
else KindIcon = ThemedFolder;
const dotColorStyle = getStatusDotColorStyle(bucket);
const statusDotSize = isEmphasizedStatusDotBucket(bucket)
? EMPHASIZED_STATUS_DOT_SIZE
: DEFAULT_STATUS_DOT_SIZE;
const statusDotOffset =
statusDotSize === EMPHASIZED_STATUS_DOT_SIZE
? EMPHASIZED_STATUS_DOT_OFFSET
: DEFAULT_STATUS_DOT_OFFSET;
return (
<View style={styles.workspaceStatusDot}>
<KindIcon size={14} uniProps={foregroundMutedColorMapping} />
{dotColorStyle ? (
<StatusDotOverlay
dotColorStyle={dotColorStyle}
size={statusDotSize}
offset={statusDotOffset}
/>
) : null}
</View>
);
}
function StatusDotOverlay({
dotColorStyle,
size,
offset,
}: {
dotColorStyle: ViewStyle;
size: number;
offset: number;
}) {
const overlayStyle = useMemo(
() => [
styles.statusDotOverlay,
dotColorStyle,
{
width: size,
height: size,
right: offset,
bottom: offset,
},
],
[dotColorStyle, offset, size],
);
return <View style={overlayStyle} />;
}
function PrBadge({ hint }: { hint: PrHint }) {
const [isHovered, setIsHovered] = useState(false);
const handlePress = useCallback(
(event: GestureResponderEvent) => {
event.stopPropagation();
void openExternalUrl(hint.url);
},
[hint.url],
);
const textStyle = useMemo(
() => (isHovered ? [prBadgeStyles.text, prBadgeStyles.textHovered] : prBadgeStyles.text),
[isHovered],
);
const iconUniProps = isHovered ? foregroundColorMapping : getPrIconUniMapping(hint.state);
const handlePressIn = useCallback((event: GestureResponderEvent) => event.stopPropagation(), []);
const handleHoverIn = useCallback(() => setIsHovered(true), []);
const handleHoverOut = useCallback(() => setIsHovered(false), []);
const pressableStyle = useMemo(
() => [prBadgeStyles.badge, isHovered && prBadgeStyles.badgePressed],
[isHovered],
);
return (
<Pressable
accessibilityRole="link"
accessibilityLabel={`Pull request #${hint.number}`}
hitSlop={4}
onPressIn={handlePressIn}
onPress={handlePress}
onHoverIn={handleHoverIn}
onHoverOut={handleHoverOut}
style={pressableStyle}
>
{isHovered ? (
<ThemedExternalLink size={12} uniProps={iconUniProps} />
) : (
<ThemedGitPullRequest size={12} uniProps={iconUniProps} />
)}
<Text style={textStyle} numberOfLines={1}>
#{hint.number}
</Text>
</Pressable>
);
}
function ChecksBadge({ checks }: { checks: PrHint["checks"] }) {
if (!checks || checks.length === 0) return null;
const failed = checks.filter((check) => check.status === "failure").length;
if (failed === 0) return null;
return (
<View style={checksBadgeStyles.badge}>
<ThemedGitHubIcon size={10} uniProps={redColorMapping} />
<Text style={checksBadgeStyles.text}>{failed} failed</Text>
</View>
);
}
function getPrIconUniMapping(state: PrHint["state"]) {
switch (state) {
case "merged":
return purpleColorMapping;
case "open":
return greenColorMapping;
case "closed":
return redColorMapping;
}
}
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;
}
}
const prBadgeStyles = StyleSheet.create((theme) => ({
badge: {
flexDirection: "row",
alignItems: "center",
gap: 2,
},
badgePressed: {
opacity: 0.82,
},
text: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
lineHeight: 14,
color: theme.colors.foregroundMuted,
},
textHovered: {
color: theme.colors.foreground,
},
}));
const checksBadgeStyles = StyleSheet.create((theme) => ({
badge: {
flexDirection: "row",
alignItems: "center",
gap: 2,
},
text: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
lineHeight: 14,
color: theme.colors.palette.red[500],
},
}));
export const sidebarWorkspaceRowStyles = StyleSheet.create((theme) => ({
rowRight: {
flexDirection: "row",
alignItems: "center",
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.xs,
fontWeight: theme.fontWeight.medium,
lineHeight: 14,
},
}));
const styles = StyleSheet.create((theme) => ({
workspaceRowMain: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: theme.spacing[2],
width: "100%",
},
workspaceRowLeft: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
flex: 1,
minWidth: 0,
},
workspaceRowRight: sidebarWorkspaceRowStyles.rowRight,
workspaceStatusDot: {
position: "relative",
width: WORKSPACE_STATUS_DOT_WIDTH,
height: 16,
borderRadius: theme.borderRadius.full,
flexShrink: 0,
alignItems: "center",
justifyContent: "center",
},
statusDotOverlay: {
position: "absolute",
borderRadius: theme.borderRadius.full,
borderWidth: 1,
},
workspaceBranchText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: "400",
lineHeight: 20,
opacity: 0.76,
flex: 1,
minWidth: 0,
},
workspaceBranchTextCreating: {
opacity: 0.92,
},
workspaceBranchTextHovered: {
opacity: 1,
},
workspaceSubtitle: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
lineHeight: 14,
marginLeft: WORKSPACE_STATUS_DOT_WIDTH + theme.spacing[2],
},
workspacePrBadgeRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingLeft: WORKSPACE_STATUS_DOT_WIDTH + theme.spacing[2],
},
statusDotNeedsInput: {
backgroundColor: theme.colors.palette.amber[500],
borderColor: theme.colors.surface0,
},
statusDotFailed: {
backgroundColor: theme.colors.palette.red[500],
borderColor: theme.colors.surface0,
},
statusDotRunning: {
backgroundColor: theme.colors.palette.blue[500],
borderColor: theme.colors.surface0,
},
statusDotAttention: {
backgroundColor: theme.colors.palette.green[500],
borderColor: theme.colors.surface0,
},
}));
@@ -10,6 +10,7 @@ import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
import { useSidebarViewStore } from "@/stores/sidebar-view-store";
import { WorkspaceShortcutTargetsSubscriber } from "./workspace-shortcut-targets-subscriber";
vi.hoisted(() => {
@@ -20,18 +21,22 @@ function workspaceDescriptor(input: {
id: string;
name?: string;
projectId?: string;
projectDisplayName?: string;
status?: WorkspaceDescriptor["status"];
statusEnteredAt?: Date | null;
}): WorkspaceDescriptor {
return {
id: input.id,
projectId: input.projectId ?? "project-1",
projectDisplayName: "Project 1",
projectDisplayName: input.projectDisplayName ?? "Project 1",
projectRootPath: "/repo/main",
workspaceDirectory: `/repo/main/${input.id}`,
projectKind: "git",
workspaceKind: "worktree",
name: input.name ?? input.id,
status: "done",
status: input.status ?? "done",
archivingAt: null,
statusEnteredAt: input.statusEnteredAt ?? null,
diffStat: null,
scripts: [],
};
@@ -56,6 +61,9 @@ describe("WorkspaceShortcutTargetsSubscriber", () => {
projectOrderByServerId: {},
workspaceOrderByServerAndProject: {},
});
useSidebarViewStore.setState({
groupModeByServerId: {},
});
act(() => {
useSessionStore.getState().initializeSession("srv", null as unknown as DaemonClient);
@@ -95,6 +103,72 @@ describe("WorkspaceShortcutTargetsSubscriber", () => {
]);
});
it("publishes status-mode shortcut targets in visual status order", async () => {
act(() => {
useSidebarViewStore.getState().setGroupMode("srv", "status");
useSessionStore.getState().setWorkspaces(
"srv",
new Map([
[
"ws-done",
workspaceDescriptor({
id: "ws-done",
name: "Done",
projectId: "project-1",
projectDisplayName: "Project 1",
status: "done",
statusEnteredAt: new Date("2026-01-01T00:00:00.000Z"),
}),
],
[
"ws-running-old",
workspaceDescriptor({
id: "ws-running-old",
name: "Running old",
projectId: "project-2",
projectDisplayName: "Project 2",
status: "running",
statusEnteredAt: new Date("2026-02-01T00:00:00.000Z"),
}),
],
[
"ws-needs-input",
workspaceDescriptor({
id: "ws-needs-input",
name: "Needs input",
projectId: "project-1",
projectDisplayName: "Project 1",
status: "needs_input",
statusEnteredAt: new Date("2026-01-15T00:00:00.000Z"),
}),
],
[
"ws-running-new",
workspaceDescriptor({
id: "ws-running-new",
name: "Running new",
projectId: "project-2",
projectDisplayName: "Project 2",
status: "running",
statusEnteredAt: new Date("2026-03-01T00:00:00.000Z"),
}),
],
]),
);
});
await act(async () => {
root?.render(<WorkspaceShortcutTargetsSubscriber enabled={true} serverId="srv" />);
});
expect(useKeyboardShortcutsStore.getState().sidebarShortcutWorkspaceTargets).toEqual([
{ serverId: "srv", workspaceId: "ws-needs-input" },
{ serverId: "srv", workspaceId: "ws-running-new" },
{ serverId: "srv", workspaceId: "ws-running-old" },
{ serverId: "srv", workspaceId: "ws-done" },
]);
});
it("clears targets when disabled", async () => {
await act(async () => {
root?.render(<WorkspaceShortcutTargetsSubscriber enabled={true} serverId="srv" />);
@@ -1,8 +1,16 @@
import { useEffect, useMemo } from "react";
import {
useHydratedWorkspaceEntries,
useProjectNamesMap,
} from "@/hooks/use-status-mode-workspaces";
import { useSidebarWorkspacesList } from "@/hooks/use-sidebar-workspaces-list";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
import { buildSidebarShortcutModel } from "@/utils/sidebar-shortcuts";
import { useSidebarViewStore } from "@/stores/sidebar-view-store";
import {
buildSidebarShortcutModel,
buildStatusSidebarShortcutModel,
} from "@/utils/sidebar-shortcuts";
export function WorkspaceShortcutTargetsSubscriber({
enabled,
@@ -12,6 +20,11 @@ export function WorkspaceShortcutTargetsSubscriber({
serverId: string | null;
}) {
const { projects } = useSidebarWorkspacesList({ serverId, enabled });
const statusWorkspaces = useHydratedWorkspaceEntries(enabled ? serverId : null);
const projectNamesByKey = useProjectNamesMap(enabled ? serverId : null);
const groupMode = useSidebarViewStore((state) =>
enabled && serverId ? state.getGroupMode(serverId) : "project",
);
const collapsedProjectKeys = useSidebarCollapsedSectionsStore(
(state) => state.collapsedProjectKeys,
);
@@ -19,14 +32,19 @@ export function WorkspaceShortcutTargetsSubscriber({
(state) => state.setSidebarShortcutWorkspaceTargets,
);
const shortcutModel = useMemo(
() =>
buildSidebarShortcutModel({
projects,
collapsedProjectKeys,
}),
[collapsedProjectKeys, projects],
);
const shortcutModel = useMemo(() => {
if (groupMode === "status") {
return buildStatusSidebarShortcutModel({
workspaces: statusWorkspaces,
projectNamesByKey,
});
}
return buildSidebarShortcutModel({
projects,
collapsedProjectKeys,
});
}, [collapsedProjectKeys, groupMode, projectNamesByKey, projects, statusWorkspaces]);
useEffect(() => {
if (!enabled || !serverId) {