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) {
@@ -19,6 +19,7 @@ function workspace(input: {
name: "main",
status: "running",
archivingAt: null,
statusEnteredAt: null,
diffStat: null,
scripts: input.scripts ?? [],
};
@@ -18,6 +18,7 @@ const baseWorkspace: WorkspaceDescriptor = {
name: "feature",
status: "done",
archivingAt: "2026-04-30T00:00:00.000Z",
statusEnteredAt: null,
diffStat: null,
scripts: [],
};
@@ -43,6 +43,7 @@ function workspace(input: Partial<WorkspaceDescriptor> & Pick<WorkspaceDescripto
name: input.name ?? input.id,
status: input.status ?? "done",
archivingAt: input.archivingAt ?? null,
statusEnteredAt: null,
diffStat: input.diffStat ?? null,
scripts: input.scripts ?? [],
} satisfies WorkspaceDescriptor;
@@ -0,0 +1,213 @@
import { describe, expect, it } from "vitest";
import type { SidebarWorkspaceEntry } from "./sidebar-workspaces-view-model";
import {
buildStatusGroups,
buildStatusShortcutIndex,
STATUS_BUCKET_LABELS,
STATUS_BUCKET_ORDER,
type StatusGroup,
} from "./sidebar-status-view-model";
function ws(
input: Partial<SidebarWorkspaceEntry> & { workspaceKey: string },
): SidebarWorkspaceEntry {
return {
serverId: input.serverId ?? "srv",
workspaceId: input.workspaceId ?? input.workspaceKey.split(":")[1] ?? "ws",
projectKey: input.projectKey ?? "proj",
projectRootPath: input.projectRootPath,
workspaceDirectory: input.workspaceDirectory,
projectKind: input.projectKind ?? "git",
workspaceKind: input.workspaceKind ?? "worktree",
name: input.name ?? "main",
statusBucket: input.statusBucket ?? "done",
statusEnteredAt: input.statusEnteredAt ?? null,
archivingAt: null,
diffStat: null,
prHint: null,
archiveHasUncommittedChanges: null,
archiveUnpushedCommitCount: null,
scripts: [],
hasRunningScripts: false,
workspaceKey: input.workspaceKey,
};
}
function d(iso: string): Date {
return new Date(iso);
}
const emptyProjectNames = new Map<string, string>();
describe("buildStatusGroups", () => {
it("groups workspaces by status bucket in fixed order", () => {
const workspaces = [
ws({ workspaceKey: "srv:done-ws", statusBucket: "done", name: "done-ws" }),
ws({
workspaceKey: "srv:needs-input-ws",
statusBucket: "needs_input",
name: "needs-input-ws",
}),
ws({ workspaceKey: "srv:running-ws", statusBucket: "running", name: "running-ws" }),
];
const groups = buildStatusGroups(workspaces, emptyProjectNames);
expect(groups.map((g) => g.bucket)).toEqual(["needs_input", "running", "done"]);
expect(groups[0]?.label).toBe("Needs input");
expect(groups[1]?.label).toBe("Working");
expect(groups[2]?.label).toBe("Done");
});
it("omits empty buckets", () => {
const workspaces = [
ws({ workspaceKey: "srv:a", statusBucket: "done" }),
ws({ workspaceKey: "srv:b", statusBucket: "running" }),
];
const groups = buildStatusGroups(workspaces, emptyProjectNames);
expect(groups.map((g) => g.bucket)).toEqual(["running", "done"]);
});
it("sorts by statusEnteredAt desc within a bucket", () => {
const workspaces = [
ws({
workspaceKey: "srv:old",
statusBucket: "done",
statusEnteredAt: d("2026-01-01T00:00:00Z"),
}),
ws({
workspaceKey: "srv:new",
statusBucket: "done",
statusEnteredAt: d("2026-06-01T00:00:00Z"),
}),
ws({
workspaceKey: "srv:mid",
statusBucket: "done",
statusEnteredAt: d("2026-03-01T00:00:00Z"),
}),
];
const groups = buildStatusGroups(workspaces, emptyProjectNames);
expect(groups[0]?.rows.map((r) => r.workspaceKey)).toEqual(["srv:new", "srv:mid", "srv:old"]);
});
it("sorts null timestamps last within a bucket", () => {
const workspaces = [
ws({ workspaceKey: "srv:null-a", statusBucket: "done", statusEnteredAt: null }),
ws({
workspaceKey: "srv:ts",
statusBucket: "done",
statusEnteredAt: d("2026-01-01T00:00:00Z"),
}),
ws({ workspaceKey: "srv:null-b", statusBucket: "done", statusEnteredAt: null }),
];
const groups = buildStatusGroups(workspaces, emptyProjectNames);
expect(groups[0]?.rows.map((r) => r.workspaceKey)).toEqual([
"srv:ts",
"srv:null-a",
"srv:null-b",
]);
});
it("tie-breaks by project name, then workspace name, then workspaceKey", () => {
const projectNames = new Map<string, string>([
["proj-b", "Beta"],
["proj-a", "Alpha"],
]);
const workspaces = [
ws({ workspaceKey: "srv:1", statusBucket: "done", projectKey: "proj-b", name: "zebra" }),
ws({ workspaceKey: "srv:2", statusBucket: "done", projectKey: "proj-a", name: "alpha" }),
ws({ workspaceKey: "srv:3", statusBucket: "done", projectKey: "proj-a", name: "alpha" }),
];
const groups = buildStatusGroups(workspaces, projectNames);
expect(groups[0]?.rows.map((r) => r.workspaceKey)).toEqual(["srv:2", "srv:3", "srv:1"]);
});
it("returns empty array for no workspaces", () => {
const groups = buildStatusGroups([], emptyProjectNames);
expect(groups).toEqual([]);
});
it("uses hydrated workspace entries with real status, not structural placeholders", () => {
const workspaces = [
ws({
workspaceKey: "srv:ni",
statusBucket: "needs_input",
statusEnteredAt: d("2026-01-01T00:00:00Z"),
}),
ws({
workspaceKey: "srv:fail",
statusBucket: "failed",
statusEnteredAt: d("2026-01-01T00:00:00Z"),
}),
ws({
workspaceKey: "srv:att",
statusBucket: "attention",
statusEnteredAt: d("2026-01-01T00:00:00Z"),
}),
ws({
workspaceKey: "srv:run",
statusBucket: "running",
statusEnteredAt: d("2026-01-01T00:00:00Z"),
}),
ws({ workspaceKey: "srv:dn", statusBucket: "done", statusEnteredAt: null }),
];
const groups = buildStatusGroups(workspaces, emptyProjectNames);
expect(groups.map((g) => g.bucket)).toEqual(STATUS_BUCKET_ORDER);
expect(groups.map((g) => g.label)).toEqual(
STATUS_BUCKET_ORDER.map((b) => STATUS_BUCKET_LABELS[b]),
);
// Each group has exactly one row with the matching bucket
for (const group of groups) {
expect(group.rows).toHaveLength(1);
expect(group.rows[0]?.statusBucket).toBe(group.bucket);
}
});
});
describe("buildStatusShortcutIndex", () => {
it("assigns sequential numbers in status visual order", () => {
const groups: StatusGroup[] = [
{ bucket: "needs_input", label: "Needs input", rows: [ws({ workspaceKey: "srv:ni" })] },
{
bucket: "running",
label: "Working",
rows: [ws({ workspaceKey: "srv:run" }), ws({ workspaceKey: "srv:run2" })],
},
{ bucket: "done", label: "Done", rows: [ws({ workspaceKey: "srv:dn" })] },
];
const index = buildStatusShortcutIndex(groups);
expect(index.get("srv:ni")).toBe(1);
expect(index.get("srv:run")).toBe(2);
expect(index.get("srv:run2")).toBe(3);
expect(index.get("srv:dn")).toBe(4);
});
it("stops at 9 shortcuts", () => {
const rows = Array.from({ length: 12 }, (_, i) => ws({ workspaceKey: `srv:ws${i}` }));
const groups: StatusGroup[] = [{ bucket: "done", label: "Done", rows }];
const index = buildStatusShortcutIndex(groups);
expect(index.size).toBe(9);
expect(index.has("srv:ws8")).toBe(true);
expect(index.has("srv:ws9")).toBe(false);
});
it("returns empty map for empty groups", () => {
const index = buildStatusShortcutIndex([]);
expect(index.size).toBe(0);
});
});
@@ -0,0 +1,94 @@
import type { SidebarWorkspaceEntry } from "@/hooks/sidebar-workspaces-view-model";
export type StatusBucket = SidebarWorkspaceEntry["statusBucket"];
export const STATUS_BUCKET_ORDER: readonly StatusBucket[] = [
"needs_input",
"failed",
"attention",
"running",
"done",
] as const;
export const STATUS_BUCKET_LABELS: Record<StatusBucket, string> = {
needs_input: "Needs input",
failed: "Failed",
attention: "Ready to review",
running: "Working",
done: "Done",
};
export interface StatusGroup {
bucket: StatusBucket;
label: string;
rows: SidebarWorkspaceEntry[];
}
export function buildStatusGroups(
workspaces: SidebarWorkspaceEntry[],
projectNamesByKey: Map<string, string>,
): StatusGroup[] {
const bucketRows = new Map<StatusBucket, SidebarWorkspaceEntry[]>();
for (const ws of workspaces) {
const bucket: StatusBucket = ws.statusBucket;
let rows = bucketRows.get(bucket);
if (!rows) {
rows = [];
bucketRows.set(bucket, rows);
}
rows.push(ws);
}
const groups: StatusGroup[] = [];
for (const bucket of STATUS_BUCKET_ORDER) {
const rows = bucketRows.get(bucket);
if (!rows || rows.length === 0) continue;
rows.sort((a, b) => compareStatusRows(a, b, projectNamesByKey));
groups.push({ bucket, label: STATUS_BUCKET_LABELS[bucket], rows });
}
return groups;
}
function compareStatusRows(
a: SidebarWorkspaceEntry,
b: SidebarWorkspaceEntry,
projectNamesByKey: Map<string, string>,
): number {
const aTime = a.statusEnteredAt?.getTime() ?? null;
const bTime = b.statusEnteredAt?.getTime() ?? null;
if (aTime !== null && bTime !== null) {
if (aTime !== bTime) return bTime - aTime;
} else if (aTime !== null) {
return -1;
} else if (bTime !== null) {
return 1;
}
const aProject = projectNamesByKey.get(a.projectKey) ?? "";
const bProject = projectNamesByKey.get(b.projectKey) ?? "";
const projectCmp = aProject.localeCompare(bProject);
if (projectCmp !== 0) return projectCmp;
const nameCmp = a.name.localeCompare(b.name);
if (nameCmp !== 0) return nameCmp;
return a.workspaceKey.localeCompare(b.workspaceKey);
}
export function buildStatusShortcutIndex(groups: StatusGroup[]): Map<string, number> {
const index = new Map<string, number>();
let shortcutNumber = 1;
for (const group of groups) {
for (const row of group.rows) {
if (shortcutNumber > 9) return index;
index.set(row.workspaceKey, shortcutNumber);
shortcutNumber += 1;
}
}
return index;
}
@@ -17,6 +17,7 @@ export interface SidebarWorkspaceEntry {
workspaceKind: WorkspaceDescriptor["workspaceKind"];
name: string;
statusBucket: SidebarStateBucket;
statusEnteredAt: Date | null;
archivingAt: string | null;
diffStat: { additions: number; deletions: number } | null;
prHint: PrHint | null;
@@ -50,6 +51,7 @@ function createStructuralWorkspaceEntry(input: {
workspaceKind: "checkout",
name: input.workspaceId,
statusBucket: "done",
statusEnteredAt: null,
archivingAt: null,
diffStat: null,
prHint: null,
@@ -0,0 +1,37 @@
import { useCallback, useMemo } from "react";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store";
export interface ClearWorkspaceAttentionController {
hasClearableAttention: boolean;
clearAttention: () => Promise<void>;
}
export function useClearWorkspaceAttention({
serverId,
workspaceId,
}: {
serverId: string;
workspaceId: string;
}): ClearWorkspaceAttentionController {
const hasClearableAttention = useSessionStore((state) => {
const workspace = state.sessions[serverId]?.workspaces.get(workspaceId);
return workspace?.status === "attention" || workspace?.status === "failed";
});
const clearAttention = useCallback(async () => {
if (!hasClearableAttention) {
return;
}
const client = getHostRuntimeStore().getClient(serverId);
if (!client) {
throw new Error("Host is not connected");
}
await client.clearWorkspaceAttention(workspaceId);
}, [hasClearableAttention, serverId, workspaceId]);
return useMemo(
() => ({ hasClearableAttention, clearAttention }),
[clearAttention, hasClearableAttention],
);
}
@@ -24,6 +24,7 @@ import { getDesktopHost, isElectronRuntime } from "@/desktop/host";
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
import { useActiveServerId } from "@/hooks/use-active-server-id";
import {
type ActiveWorkspaceSelection,
navigateToLastWorkspace,
useActiveWorkspaceSelection,
} from "@/stores/navigation-active-workspace-store";
@@ -56,6 +57,13 @@ export function useKeyboardShortcuts({
const activeServerId = useActiveServerId();
const openProjectPickerAction = useOpenProjectPicker(activeServerId);
const activeWorkspaceSelection = useActiveWorkspaceSelection();
const keyboardWorkspaceSelectionRef = useRef<ActiveWorkspaceSelection | null>(null);
useEffect(() => {
if (activeWorkspaceSelection) {
keyboardWorkspaceSelectionRef.current = activeWorkspaceSelection;
}
}, [activeWorkspaceSelection]);
useEffect(() => {
if (!enabled) return;
@@ -95,6 +103,10 @@ export function useKeyboardShortcuts({
case "dispatch":
return keyboardActionDispatcher.dispatch(action.action);
case "navigate-workspace":
keyboardWorkspaceSelectionRef.current = {
serverId: action.serverId,
workspaceId: action.workspaceId,
};
navigateToWorkspace(action.serverId, action.workspaceId, { currentPathname: pathname });
return true;
case "navigate-last-workspace":
@@ -197,7 +209,8 @@ export function useKeyboardShortcuts({
pathname,
isMobile,
sidebarShortcutTargets: store.sidebarShortcutWorkspaceTargets,
navigationActiveWorkspace: activeWorkspaceSelection,
navigationActiveWorkspace:
keyboardWorkspaceSelectionRef.current ?? activeWorkspaceSelection,
commandCenterOpen: store.commandCenterOpen,
shortcutsDialogOpen: store.shortcutsDialogOpen,
},
@@ -17,6 +17,7 @@ function buildWorkspacePayload() {
name: "project",
archivingAt: null,
status: "done" as const,
statusEnteredAt: null,
activityAt: null,
diffStat: null,
scripts: [],
@@ -84,6 +84,7 @@ function workspace(input: {
name: input.id,
archivingAt: null,
status: "done",
statusEnteredAt: null,
activityAt: null,
diffStat: null,
scripts: [],
@@ -46,6 +46,7 @@ export function createSidebarWorkspaceEntry(input: {
workspaceKind: input.workspace.workspaceKind,
name: input.workspace.name,
statusBucket: input.workspace.status,
statusEnteredAt: input.workspace.statusEnteredAt,
archivingAt: input.workspace.archivingAt,
diffStat: input.workspace.diffStat,
prHint: selectPrHintFromStatus(input.workspace.githubRuntime?.pullRequest),
@@ -0,0 +1,37 @@
import { useMemo } from "react";
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
import {
createSidebarWorkspaceEntry,
type SidebarWorkspaceEntry,
} from "./use-sidebar-workspaces-list";
export function useHydratedWorkspaceEntries(serverId: string | null): SidebarWorkspaceEntry[] {
const workspaces = useSessionStore((state) =>
serverId ? state.sessions[serverId]?.workspaces : undefined,
);
return useMemo(() => {
if (!serverId || !workspaces || workspaces.size === 0) return [];
return Array.from(workspaces.values()).map((workspace: WorkspaceDescriptor) =>
createSidebarWorkspaceEntry({ serverId, workspace }),
);
}, [serverId, workspaces]);
}
export function useProjectNamesMap(serverId: string | null): Map<string, string> {
const workspaces = useSessionStore((state) =>
serverId ? state.sessions[serverId]?.workspaces : undefined,
);
return useMemo(() => {
const map = new Map<string, string>();
if (!serverId || !workspaces) return map;
for (const workspace of workspaces.values()) {
const key = workspace.project?.projectKey ?? workspace.projectId;
if (!map.has(key)) {
map.set(key, workspace.projectCustomName ?? workspace.projectDisplayName);
}
}
return map;
}, [serverId, workspaces]);
}
@@ -329,6 +329,8 @@ describe("keyboard-shortcuts", () => {
context: { isMac: true, isDesktop: false },
action: "workspace.navigate.relative",
payload: { delta: -1 },
preventDefault: true,
stopPropagation: true,
},
{
name: "matches Alt+] to next workspace on macOS web when Option substitutes event.key",
@@ -336,6 +338,8 @@ describe("keyboard-shortcuts", () => {
context: { isMac: true, isDesktop: false },
action: "workspace.navigate.relative",
payload: { delta: 1 },
preventDefault: true,
stopPropagation: true,
},
{
name: "matches Alt+Shift+W to close current tab on macOS web when Option substitutes event.key",
@@ -134,6 +134,13 @@ describe("routeKeyboardShortcut — workspace.navigate.index", () => {
});
describe("routeKeyboardShortcut — workspace.navigate.relative", () => {
const STATUS_VISUAL_TARGETS = [
{ serverId: "srv", workspaceId: "needs-input" },
{ serverId: "srv", workspaceId: "running-new" },
{ serverId: "srv", workspaceId: "running-old" },
{ serverId: "srv", workspaceId: "done" },
] as const;
it("uses the retained navigation workspace selection over a stale pathname", () => {
expect(
routeKeyboardShortcut(
@@ -150,6 +157,57 @@ describe("routeKeyboardShortcut — workspace.navigate.relative", () => {
});
});
it("moves from status row 2 to row 3 using status visual target order", () => {
expect(
routeKeyboardShortcut(
{ action: "workspace.navigate.relative", payload: { delta: 1 } },
makeCtx({
pathname: "/h/srv/workspace/ui-expose-archive-worktrees-on-merge",
sidebarShortcutTargets: STATUS_VISUAL_TARGETS,
navigationActiveWorkspace: { serverId: "srv", workspaceId: "running-new" },
}),
),
).toEqual<ShortcutAction>({
kind: "navigate-workspace",
serverId: "srv",
workspaceId: "running-old",
});
});
it("moves backward from status row 2 to row 1 using status visual target order", () => {
expect(
routeKeyboardShortcut(
{ action: "workspace.navigate.relative", payload: { delta: -1 } },
makeCtx({
pathname: "/h/srv/workspace/ui-expose-archive-worktrees-on-merge",
sidebarShortcutTargets: STATUS_VISUAL_TARGETS,
navigationActiveWorkspace: { serverId: "srv", workspaceId: "running-new" },
}),
),
).toEqual<ShortcutAction>({
kind: "navigate-workspace",
serverId: "srv",
workspaceId: "needs-input",
});
});
it("moves backward from status row 3 to row 2 using status visual target order", () => {
expect(
routeKeyboardShortcut(
{ action: "workspace.navigate.relative", payload: { delta: -1 } },
makeCtx({
pathname: "/h/srv/workspace/running-old",
sidebarShortcutTargets: STATUS_VISUAL_TARGETS,
navigationActiveWorkspace: { serverId: "srv", workspaceId: "running-old" },
}),
),
).toEqual<ShortcutAction>({
kind: "navigate-workspace",
serverId: "srv",
workspaceId: "running-new",
});
});
it("falls back to the pathname workspace when no retained selection exists", () => {
expect(
routeKeyboardShortcut(
@@ -16,6 +16,7 @@ function createWorkspaceDescriptor(): WorkspaceDescriptor {
diffStat: null,
scripts: [],
archivingAt: null,
statusEnteredAt: null,
};
}
@@ -25,6 +25,7 @@ function createWorkspaceDescriptor(input: Partial<WorkspaceDescriptor> = {}): Wo
status: "running",
diffStat: null,
scripts: [],
statusEnteredAt: null,
...input,
archivingAt: input.archivingAt ?? null,
};
@@ -35,6 +35,7 @@ function createWorkspace(
name: input.name ?? "main",
status: input.status ?? "done",
archivingAt: input.archivingAt ?? null,
statusEnteredAt: null,
diffStat: input.diffStat ?? null,
scripts: input.scripts ?? [],
};
@@ -23,6 +23,7 @@ function createWorkspace(
workspaceKind: input.workspaceKind ?? "local_checkout",
name: input.name ?? "main",
status: input.status ?? "done",
statusEnteredAt: input.statusEnteredAt ?? null,
archivingAt: input.archivingAt ?? null,
diffStat: input.diffStat ?? null,
scripts: input.scripts ?? [],
@@ -76,6 +77,7 @@ describe("normalizeWorkspaceDescriptor", () => {
name: "main",
archivingAt: null,
status: "running",
statusEnteredAt: null,
activityAt: "not-a-date",
diffStat: null,
scripts,
@@ -109,6 +111,7 @@ describe("normalizeWorkspaceDescriptor", () => {
name: "main",
archivingAt: null,
status: "done",
statusEnteredAt: null,
activityAt: null,
diffStat: null,
scripts: [],
@@ -140,6 +143,42 @@ describe("normalizeWorkspaceDescriptor", () => {
expect(workspace.archivingAt).toBeNull();
});
it("normalizes statusEnteredAt strings to Date and missing or null values to null", () => {
const basePayload = {
id: "1",
projectId: "1",
projectDisplayName: "Project 1",
projectRootPath: "/repo",
workspaceDirectory: "/repo",
projectKind: "git",
workspaceKind: "checkout",
name: "main",
status: "running",
activityAt: null,
diffStat: null,
scripts: [],
} satisfies Omit<WorkspaceDescriptorPayload, "statusEnteredAt" | "archivingAt">;
const withString = normalizeWorkspaceDescriptor({
...basePayload,
archivingAt: null,
statusEnteredAt: "2026-05-12T09:30:00.000Z",
});
const withNull = normalizeWorkspaceDescriptor({
...basePayload,
archivingAt: null,
statusEnteredAt: null,
});
const missing = normalizeWorkspaceDescriptor({
...basePayload,
archivingAt: null,
} as unknown as WorkspaceDescriptorPayload);
expect(withString.statusEnteredAt).toEqual(new Date("2026-05-12T09:30:00.000Z"));
expect(withNull.statusEnteredAt).toBeNull();
expect(missing.statusEnteredAt).toBeNull();
});
it("preserves project placement from workspace descriptor payloads", () => {
const workspace = normalizeWorkspaceDescriptor({
id: "1",
@@ -152,6 +191,7 @@ describe("normalizeWorkspaceDescriptor", () => {
name: "main",
archivingAt: null,
status: "done",
statusEnteredAt: null,
activityAt: null,
diffStat: null,
scripts: [],
+7
View File
@@ -126,6 +126,7 @@ export interface WorkspaceDescriptor {
workspaceKind: WorkspaceDescriptorPayload["workspaceKind"];
name: string;
status: WorkspaceDescriptorPayload["status"];
statusEnteredAt: Date | null;
archivingAt: string | null;
diffStat: { additions: number; deletions: number } | null;
scripts: WorkspaceDescriptorPayload["scripts"];
@@ -137,6 +138,11 @@ export interface WorkspaceDescriptor {
export function normalizeWorkspaceDescriptor(
payload: WorkspaceDescriptorPayload,
): WorkspaceDescriptor {
const statusEnteredAtRaw = payload.statusEnteredAt;
const statusEnteredAt: Date | null =
typeof statusEnteredAtRaw === "string" && statusEnteredAtRaw.length > 0
? new Date(statusEnteredAtRaw)
: null;
return {
id: normalizeWorkspaceOpaqueId(payload.id) ?? payload.id,
projectId: payload.projectId,
@@ -148,6 +154,7 @@ export function normalizeWorkspaceDescriptor(
workspaceKind: payload.workspaceKind,
name: payload.name,
status: payload.status,
statusEnteredAt,
archivingAt: payload.archivingAt ?? null,
diffStat: payload.diffStat ?? null,
scripts: (payload.scripts ?? []).map((s) => Object.assign({}, s)),
@@ -7,21 +7,26 @@ import {
serializeCollapsedProjects,
setProjectCollapsed,
toggleProjectCollapsed,
toggleStatusGroupCollapsed,
} from "./state";
interface SidebarCollapsedSectionsState extends CollapsedProjectsState {
toggleProjectCollapsed: (projectKey: string) => void;
setProjectCollapsed: (projectKey: string, collapsed: boolean) => void;
toggleStatusGroupCollapsed: (statusGroupKey: string) => void;
}
export const useSidebarCollapsedSectionsStore = create<SidebarCollapsedSectionsState>()(
persist(
(set) => ({
collapsedProjectKeys: new Set(),
collapsedStatusGroupKeys: new Set(),
toggleProjectCollapsed: (projectKey) =>
set((state) => toggleProjectCollapsed(state, projectKey)),
setProjectCollapsed: (projectKey, collapsed) =>
set((state) => setProjectCollapsed(state, projectKey, collapsed)),
toggleStatusGroupCollapsed: (statusGroupKey) =>
set((state) => toggleStatusGroupCollapsed(state, statusGroupKey)),
}),
{
name: "sidebar-collapsed-sections",
@@ -5,10 +5,11 @@ import {
serializeCollapsedProjects,
setProjectCollapsed,
toggleProjectCollapsed,
toggleStatusGroupCollapsed,
} from "@/stores/sidebar-collapsed-sections-store/state";
function emptyState(): CollapsedProjectsState {
return { collapsedProjectKeys: new Set() };
return { collapsedProjectKeys: new Set(), collapsedStatusGroupKeys: new Set() };
}
describe("sidebar collapsed projects transitions", () => {
@@ -18,17 +19,21 @@ describe("sidebar collapsed projects transitions", () => {
state = setProjectCollapsed(state, "project-a", true);
state = toggleProjectCollapsed(state, "project-b");
state = toggleProjectCollapsed(state, "project-a");
state = toggleStatusGroupCollapsed(state, "running");
expect(Array.from(state.collapsedProjectKeys)).toEqual(["project-b"]);
expect(Array.from(state.collapsedStatusGroupKeys)).toEqual(["running"]);
});
it("serializes collapsed project keys for preference storage", () => {
const state: CollapsedProjectsState = {
collapsedProjectKeys: new Set(["project-a", "project-b"]),
collapsedStatusGroupKeys: new Set(["running"]),
};
expect(serializeCollapsedProjects(state)).toEqual({
collapsedProjectKeys: ["project-a", "project-b"],
collapsedStatusGroupKeys: ["running"],
});
});
@@ -39,6 +44,7 @@ describe("sidebar collapsed projects transitions", () => {
);
expect(Array.from(restored.collapsedProjectKeys)).toEqual(["project-a", "project-b"]);
expect(Array.from(restored.collapsedStatusGroupKeys)).toEqual([]);
});
it("keeps the existing state object when persisted preferences do not change collapsed keys", () => {
@@ -1,9 +1,11 @@
export interface CollapsedProjectsState {
collapsedProjectKeys: Set<string>;
collapsedStatusGroupKeys: Set<string>;
}
export interface PersistedCollapsedProjects {
collapsedProjectKeys?: unknown;
collapsedStatusGroupKeys?: unknown;
}
export function toggleProjectCollapsed(
@@ -16,7 +18,20 @@ export function toggleProjectCollapsed(
} else {
next.add(projectKey);
}
return { collapsedProjectKeys: next };
return { ...state, collapsedProjectKeys: next };
}
export function toggleStatusGroupCollapsed(
state: CollapsedProjectsState,
statusGroupKey: string,
): CollapsedProjectsState {
const next = new Set(state.collapsedStatusGroupKeys);
if (next.has(statusGroupKey)) {
next.delete(statusGroupKey);
} else {
next.add(statusGroupKey);
}
return { ...state, collapsedStatusGroupKeys: next };
}
export function setProjectCollapsed(
@@ -30,13 +45,17 @@ export function setProjectCollapsed(
} else {
next.delete(projectKey);
}
return { collapsedProjectKeys: next };
return { ...state, collapsedProjectKeys: next };
}
export function serializeCollapsedProjects(state: CollapsedProjectsState): {
collapsedProjectKeys: string[];
collapsedStatusGroupKeys: string[];
} {
return { collapsedProjectKeys: Array.from(state.collapsedProjectKeys) };
return {
collapsedProjectKeys: Array.from(state.collapsedProjectKeys),
collapsedStatusGroupKeys: Array.from(state.collapsedStatusGroupKeys),
};
}
export function mergePersistedCollapsedProjects<S extends CollapsedProjectsState>(
@@ -44,16 +63,24 @@ export function mergePersistedCollapsedProjects<S extends CollapsedProjectsState
current: S,
): S {
if (!persisted?.collapsedProjectKeys) {
if (!persisted?.collapsedStatusGroupKeys) return current;
}
const restoredProjects = deserializeCollapsedKeys(persisted.collapsedProjectKeys);
const restoredStatusGroups = deserializeCollapsedKeys(persisted.collapsedStatusGroupKeys);
if (
areSetsEqual(current.collapsedProjectKeys, restoredProjects) &&
areSetsEqual(current.collapsedStatusGroupKeys, restoredStatusGroups)
) {
return current;
}
const restored = deserializeCollapsedProjectKeys(persisted.collapsedProjectKeys);
if (areSetsEqual(current.collapsedProjectKeys, restored)) {
return current;
}
return { ...current, collapsedProjectKeys: restored };
return {
...current,
collapsedProjectKeys: restoredProjects,
collapsedStatusGroupKeys: restoredStatusGroups,
};
}
function deserializeCollapsedProjectKeys(value: unknown): Set<string> {
function deserializeCollapsedKeys(value: unknown): Set<string> {
if (!Array.isArray(value)) {
return new Set();
}
@@ -0,0 +1,41 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
export type SidebarGroupMode = "project" | "status";
interface SidebarViewStoreState {
groupModeByServerId: Record<string, SidebarGroupMode>;
getGroupMode: (serverId: string) => SidebarGroupMode;
setGroupMode: (serverId: string, mode: SidebarGroupMode) => void;
}
export const useSidebarViewStore = create<SidebarViewStoreState>()(
persist(
(set, get) => ({
groupModeByServerId: {},
getGroupMode: (serverId) => {
const key = serverId.trim();
if (!key) return "project";
return get().groupModeByServerId[key] ?? "project";
},
setGroupMode: (serverId, mode) => {
const key = serverId.trim();
if (!key) return;
set((state) => ({
groupModeByServerId: {
...state.groupModeByServerId,
[key]: mode,
},
}));
},
}),
{
name: "sidebar-group-mode",
storage: createJSONStorage(() => AsyncStorage),
partialize: (state) => ({
groupModeByServerId: state.groupModeByServerId,
}),
},
),
);
@@ -23,6 +23,7 @@ function createWorkspace(): WorkspaceDescriptor {
name: "worktree",
status: "done",
archivingAt: null,
statusEnteredAt: null,
diffStat: null,
scripts: [],
};
+1
View File
@@ -44,6 +44,7 @@ function workspace(input: {
name: input.id,
status: "done",
archivingAt: null,
statusEnteredAt: null,
diffStat: null,
scripts: [],
gitRuntime: {
@@ -35,7 +35,7 @@ describe("deriveSidebarStateBucket", () => {
).toBe("attention");
});
it("treats initializing agents as done", () => {
it("treats initializing agents as running", () => {
expect(
deriveSidebarStateBucket({
status: "initializing",
@@ -43,6 +43,6 @@ describe("deriveSidebarStateBucket", () => {
requiresAttention: false,
attentionReason: null,
}),
).toBe("done");
).toBe("running");
});
});
@@ -25,6 +25,7 @@ function workspace(overrides: Partial<SidebarWorkspaceEntry> = {}): SidebarWorks
archiveUnpushedCommitCount: null,
scripts: [],
hasRunningScripts: false,
statusEnteredAt: null,
...overrides,
archivingAt: overrides.archivingAt ?? null,
};
@@ -4,7 +4,11 @@ import type {
SidebarWorkspaceEntry,
} from "@/hooks/use-sidebar-workspaces-list";
import { buildSidebarShortcutModel, getRelativeSidebarShortcutTarget } from "./sidebar-shortcuts";
import {
buildSidebarShortcutModel,
buildStatusSidebarShortcutModel,
getRelativeSidebarShortcutTarget,
} from "./sidebar-shortcuts";
function workspace(input: {
serverId: string;
@@ -12,6 +16,8 @@ function workspace(input: {
workspaceDirectory: string;
name: string;
projectKey?: string;
statusBucket?: SidebarWorkspaceEntry["statusBucket"];
statusEnteredAt?: Date | null;
}): SidebarWorkspaceEntry {
return {
workspaceKey: `${input.serverId}:${input.workspaceId}`,
@@ -22,8 +28,9 @@ function workspace(input: {
projectKind: "git",
workspaceKind: "checkout",
name: input.name,
statusBucket: "done",
statusBucket: input.statusBucket ?? "done",
archivingAt: null,
statusEnteredAt: input.statusEnteredAt ?? null,
diffStat: null,
prHint: null,
archiveHasUncommittedChanges: null,
@@ -132,6 +139,68 @@ describe("buildSidebarShortcutModel", () => {
});
});
describe("buildStatusSidebarShortcutModel", () => {
it("builds shortcut targets in status visual order", () => {
const workspaces = [
workspace({
serverId: "s1",
workspaceId: "done-old",
workspaceDirectory: "/repo/done-old",
name: "done old",
projectKey: "p1",
statusBucket: "done",
statusEnteredAt: new Date("2026-01-01T00:00:00.000Z"),
}),
workspace({
serverId: "s1",
workspaceId: "running-new",
workspaceDirectory: "/repo/running-new",
name: "running new",
projectKey: "p2",
statusBucket: "running",
statusEnteredAt: new Date("2026-03-01T00:00:00.000Z"),
}),
workspace({
serverId: "s1",
workspaceId: "needs-input",
workspaceDirectory: "/repo/needs-input",
name: "needs input",
projectKey: "p1",
statusBucket: "needs_input",
statusEnteredAt: new Date("2026-02-01T00:00:00.000Z"),
}),
workspace({
serverId: "s1",
workspaceId: "running-old",
workspaceDirectory: "/repo/running-old",
name: "running old",
projectKey: "p2",
statusBucket: "running",
statusEnteredAt: new Date("2026-01-15T00:00:00.000Z"),
}),
];
const model = buildStatusSidebarShortcutModel({
workspaces,
projectNamesByKey: new Map([
["p1", "Project 1"],
["p2", "Project 2"],
]),
});
expect(model.shortcutTargets).toEqual([
{ serverId: "s1", workspaceId: "needs-input" },
{ serverId: "s1", workspaceId: "running-new" },
{ serverId: "s1", workspaceId: "running-old" },
{ serverId: "s1", workspaceId: "done-old" },
]);
expect(model.shortcutIndexByWorkspaceKey.get("s1:needs-input")).toBe(1);
expect(model.shortcutIndexByWorkspaceKey.get("s1:running-new")).toBe(2);
expect(model.shortcutIndexByWorkspaceKey.get("s1:running-old")).toBe(3);
expect(model.shortcutIndexByWorkspaceKey.get("s1:done-old")).toBe(4);
});
});
describe("getRelativeSidebarShortcutTarget", () => {
const targets = [
{ serverId: "s1", workspaceId: "ws-1" },
+27 -1
View File
@@ -2,6 +2,7 @@ import type {
SidebarProjectEntry,
SidebarWorkspaceEntry,
} from "@/hooks/use-sidebar-workspaces-list";
import { buildStatusGroups } from "@/hooks/sidebar-status-view-model";
import { isSidebarProjectFlattened } from "./sidebar-project-row-model";
export interface SidebarShortcutWorkspaceTarget {
@@ -37,7 +38,32 @@ export function buildSidebarShortcutModel(input: {
for (const workspace of project.workspaces) {
if (shortcutTargets.length >= maxShortcuts) {
continue;
break;
}
const shortcutNumber = shortcutTargets.length + 1;
shortcutTargets.push(createShortcutTarget(workspace));
shortcutIndexByWorkspaceKey.set(workspace.workspaceKey, shortcutNumber);
}
}
return { shortcutTargets, shortcutIndexByWorkspaceKey };
}
export function buildStatusSidebarShortcutModel(input: {
workspaces: SidebarWorkspaceEntry[];
projectNamesByKey: Map<string, string>;
shortcutLimit?: number;
}): SidebarShortcutModel {
const maxShortcuts = Math.max(0, Math.floor(input.shortcutLimit ?? 9));
const groups = buildStatusGroups(input.workspaces, input.projectNamesByKey);
const shortcutTargets: SidebarShortcutWorkspaceTarget[] = [];
const shortcutIndexByWorkspaceKey = new Map<string, number>();
for (const group of groups) {
for (const workspace of group.rows) {
if (shortcutTargets.length >= maxShortcuts) {
break;
}
const shortcutNumber = shortcutTargets.length + 1;
@@ -21,6 +21,7 @@ function workspace(
name: input.name ?? input.id,
status: input.status ?? "done",
archivingAt: input.archivingAt ?? null,
statusEnteredAt: null,
diffStat: input.diffStat ?? null,
scripts: input.scripts ?? [],
};
@@ -22,6 +22,7 @@ function createWorkspace(
name: input.name ?? "main",
status: input.status ?? "running",
archivingAt: input.archivingAt ?? null,
statusEnteredAt: null,
diffStat: input.diffStat ?? null,
scripts: input.scripts ?? [],
};
@@ -39,6 +39,7 @@ function workspace(input?: Partial<WorkspaceDescriptor>): WorkspaceDescriptor {
name: "workspace-1",
status: "done",
archivingAt: null,
statusEnteredAt: null,
diffStat: null,
scripts: [],
...input,