diff --git a/packages/app/src/app/h/[serverId]/new.tsx b/packages/app/src/app/h/[serverId]/new.tsx
index 051822df35..9e990f4934 100644
--- a/packages/app/src/app/h/[serverId]/new.tsx
+++ b/packages/app/src/app/h/[serverId]/new.tsx
@@ -2,10 +2,16 @@ import { useLocalSearchParams } from "expo-router";
import { NewWorkspaceScreen } from "@/screens/new-workspace-screen";
export default function HostNewWorkspaceRoute() {
- const params = useLocalSearchParams<{ serverId?: string; dir?: string; name?: string }>();
+ const params = useLocalSearchParams<{
+ serverId?: string;
+ dir?: string;
+ name?: string;
+ title?: string;
+ }>();
const serverId = typeof params.serverId === "string" ? params.serverId : "";
const sourceDirectory = typeof params.dir === "string" ? params.dir : "";
const displayName = typeof params.name === "string" ? params.name : undefined;
+ const headerTitle = typeof params.title === "string" ? params.title : undefined;
if (!sourceDirectory) {
return null;
@@ -16,6 +22,7 @@ export default function HostNewWorkspaceRoute() {
serverId={serverId}
sourceDirectory={sourceDirectory}
displayName={displayName}
+ headerTitle={headerTitle}
/>
);
}
diff --git a/packages/app/src/components/project-picker-modal.tsx b/packages/app/src/components/project-picker-modal.tsx
index d942344391..3b06f4c90d 100644
--- a/packages/app/src/components/project-picker-modal.tsx
+++ b/packages/app/src/components/project-picker-modal.tsx
@@ -9,6 +9,7 @@ import {
type PressableStateCallbackType,
} from "react-native";
import { Folder } from "lucide-react-native";
+import { router } from "expo-router";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useQuery } from "@tanstack/react-query";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
@@ -19,6 +20,7 @@ import { useOpenProject } from "@/hooks/use-open-project";
import { buildWorkingDirectorySuggestions } from "@/utils/working-directory-suggestions";
import { isNative } from "@/constants/platform";
import { useActiveServerId } from "@/hooks/use-active-server-id";
+import { buildHostNewWorkspaceRoute } from "@/utils/host-routes";
interface PathRowProps {
path: string;
@@ -63,6 +65,7 @@ export function ProjectPickerModal() {
const serverId = useActiveServerId();
const open = useKeyboardShortcutsStore((s) => s.projectPickerOpen);
+ const intent = useKeyboardShortcutsStore((s) => s.projectPickerIntent);
const setOpen = useKeyboardShortcutsStore((s) => s.setProjectPickerOpen);
const client = useHostRuntimeClient(serverId ?? "");
@@ -115,6 +118,16 @@ export function ProjectPickerModal() {
setIsSubmitting(true);
try {
+ if (intent.kind === "new-workspace") {
+ router.navigate(
+ buildHostNewWorkspaceRoute(serverId, trimmed, {
+ headerTitle: intent.headerTitle,
+ }) as never,
+ );
+ setOpen(false);
+ return;
+ }
+
const didOpenProject = await openProject(trimmed);
if (didOpenProject) {
setOpen(false);
@@ -123,7 +136,7 @@ export function ProjectPickerModal() {
setIsSubmitting(false);
}
},
- [client, openProject, serverId, setOpen],
+ [client, intent, openProject, serverId, setOpen],
);
const handleSubmitCustom = useCallback(() => {
diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx
index ae28d9d013..394f025cae 100644
--- a/packages/app/src/components/sidebar-workspace-list.tsx
+++ b/packages/app/src/components/sidebar-workspace-list.tsx
@@ -2,16 +2,13 @@ import {
View,
Text,
Pressable,
- Platform,
ActivityIndicator,
- StatusBar,
ScrollView,
type GestureResponderEvent,
type PressableProps,
type PressableStateCallbackType,
type ViewStyle,
} from "react-native";
-import * as Haptics from "expo-haptics";
import { useQueries } from "@tanstack/react-query";
import {
useCallback,
@@ -24,7 +21,7 @@ import {
type MutableRefObject,
type Ref,
} from "react";
-import { router, usePathname, type Href } from "expo-router";
+import { usePathname } from "expo-router";
import { navigateToWorkspace } from "@/hooks/use-workspace-navigation";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import type { Theme } from "@/styles/theme";
@@ -36,16 +33,13 @@ import {
CircleAlert,
Copy,
ExternalLink,
- FolderPlus,
FolderGit2,
GitPullRequest,
Globe,
- Settings,
SquareTerminal,
Monitor,
MoreVertical,
Plus,
- Trash2,
} from "lucide-react-native";
import { NestableScrollContainer } from "react-native-draggable-flatlist";
import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list";
@@ -53,11 +47,7 @@ import type { DraggableListDragHandleProps } from "./draggable-list.types";
import { getHostRuntimeStore, isHostRuntimeConnected } from "@/runtime/host-runtime";
import { useIsCompactFormFactor } from "@/constants/layout";
import { projectIconQueryKey } from "@/hooks/use-project-icon-query";
-import {
- buildHostNewWorkspaceRoute,
- buildProjectSettingsRoute,
- parseHostWorkspaceRouteFromPathname,
-} from "@/utils/host-routes";
+import { parseHostWorkspaceRouteFromPathname } from "@/utils/host-routes";
import {
createSidebarWorkspaceEntry,
type SidebarProjectEntry,
@@ -82,13 +72,16 @@ import { SyncedLoader } from "@/components/synced-loader";
import { useToast } from "@/contexts/toast-context";
import { useCheckoutGitActionsStore } from "@/stores/checkout-git-actions-store";
import { hasVisibleOrderChanged, mergeWithRemainder } from "@/utils/sidebar-reorder";
-import { decideLongPressMove } from "@/utils/sidebar-gesture-arbitration";
+import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction";
+import {
+ SidebarProjectsDraggableList,
+ type SidebarProjectDragInfo,
+} from "@/components/sidebar/sidebar-projects-draggable-list";
import { confirmDialog } from "@/utils/confirm-dialog";
import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader";
import { isEmphasizedStatusDotBucket } from "@/utils/status-dot-color";
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
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";
@@ -111,9 +104,10 @@ import {
} from "@/utils/workspace-execution";
import { WorkspaceHoverCard } from "@/components/workspace-hover-card";
import { GitHubIcon } from "@/components/icons/github-icon";
-import { isWeb as platformIsWeb, isNative as platformIsNative } from "@/constants/platform";
+import { isNative as platformIsNative } from "@/constants/platform";
import { SidebarProjectIcon } from "@/components/sidebar/sidebar-project-row-visual";
import { SidebarProjectHeaderRow } from "@/components/sidebar/sidebar-collapsible-project-section";
+import { SidebarProjectTrailingActions } from "@/components/sidebar/sidebar-project-trailing-actions";
function toProjectIconDataUri(icon: { mimeType: string; data: string } | null): string | null {
if (!icon) {
@@ -124,8 +118,6 @@ function toProjectIconDataUri(icon: { mimeType: string; data: string } | null):
const workspaceKeyExtractor = (workspace: SidebarWorkspaceEntry) => workspace.workspaceKey;
-const projectKeyExtractor = (project: SidebarProjectEntry) => project.projectKey;
-
function hideWorkspaceOptimistically(workspace: SidebarWorkspaceEntry): WorkspaceDescriptor | null {
const workspaces = useSessionStore.getState().sessions[workspace.serverId]?.workspaces;
const workspaceKey = resolveWorkspaceMapKeyByIdentity({
@@ -168,12 +160,9 @@ const ThemedCircleAlert = withUnistyles(CircleAlert);
const ThemedSyncedLoader = withUnistyles(SyncedLoader);
const ThemedMonitor = withUnistyles(Monitor);
const ThemedFolderGit2 = withUnistyles(FolderGit2);
-const ThemedFolderPlus = withUnistyles(FolderPlus);
const ThemedGlobe = withUnistyles(Globe);
const ThemedSquareTerminal = withUnistyles(SquareTerminal);
const ThemedMoreVertical = withUnistyles(MoreVertical);
-const ThemedTrash2 = withUnistyles(Trash2);
-const ThemedSettings = withUnistyles(Settings);
const ThemedCopy = withUnistyles(Copy);
const ThemedArchive = withUnistyles(Archive);
@@ -335,18 +324,21 @@ function prBadgePressableStyle({ pressed }: PressableStateCallbackType) {
return [prBadgeStyles.badge, pressed && prBadgeStyles.badgePressed];
}
-function projectKebabStyle({
- hovered = false,
-}: PressableStateCallbackType & { hovered?: boolean }) {
- return [styles.projectKebabButton, hovered && styles.projectKebabButtonHovered];
-}
-
function workspaceKebabStyle({
hovered = false,
}: PressableStateCallbackType & { hovered?: boolean }) {
return [styles.kebabButton, hovered && styles.kebabButtonHovered];
}
+function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }) {
+ return (
+
+ );
+}
+
function noop() {}
const prBadgeStyles = StyleSheet.create((theme) => ({
@@ -490,118 +482,9 @@ function StatusDotOverlay({
return ;
}
-function ProjectRowTrailingActions({
- project,
- displayName,
- canCreateWorktree,
- isHovered,
- isMobileBreakpoint,
- isProjectActive,
- onBeginWorkspaceSetup,
- onRemoveProject,
- removeProjectStatus,
-}: {
- project: SidebarProjectEntry;
- displayName: string;
- canCreateWorktree: boolean;
- isHovered: boolean;
- isMobileBreakpoint: boolean;
- isProjectActive: boolean;
- onBeginWorkspaceSetup: () => void;
- onRemoveProject?: () => void;
- removeProjectStatus: "idle" | "pending" | "success";
-}) {
- const actionsVisible = isHovered || platformIsNative || isMobileBreakpoint;
- return (
-
- {canCreateWorktree ? (
-
- ) : null}
- {onRemoveProject ? (
-
-
-
- ) : null}
-
- );
-}
-
-const trash2LeadingIcon = ;
-const settingsLeadingIcon = ;
const copyLeadingIcon = ;
const archiveLeadingIcon = ;
-function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }) {
- return (
-
- );
-}
-
-function ProjectKebabMenu({
- projectKey,
- onRemoveProject,
- removeProjectStatus,
-}: {
- projectKey: string;
- onRemoveProject: () => void;
- removeProjectStatus: "idle" | "pending" | "success";
-}) {
- const handleOpenProjectSettings = useCallback(() => {
- if (projectKey.trim().length === 0) return;
- router.navigate(buildProjectSettingsRoute(projectKey));
- }, [projectKey]);
- const canOpenProjectSettings = projectKey.trim().length > 0;
- return (
-
-
- {renderKebabTriggerIcon}
-
-
- {canOpenProjectSettings ? (
-
- Open project settings
-
- ) : null}
-
- Remove project
-
-
-
- );
-}
-
function WorkspaceRowRightGroup({
workspace,
isHovered,
@@ -803,296 +686,6 @@ function ProjectLeadingVisualStatus({
);
}
-function NewWorktreeButton({
- displayName,
- onPress,
- visible,
- loading = false,
- testID,
- showShortcutHint = false,
-}: {
- displayName: string;
- onPress: () => void;
- visible: boolean;
- loading?: boolean;
- testID: string;
- showShortcutHint?: boolean;
-}) {
- const newWorktreeKeys = useShortcutKeys("new-worktree");
-
- const pressableStyle = useCallback(
- ({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
- styles.projectIconActionButton,
- !visible && styles.projectIconActionButtonHidden,
- (Boolean(hovered) || pressed) && !loading && styles.projectIconActionButtonHovered,
- ],
- [visible, loading],
- );
-
- const handlePress = useCallback(
- (event: GestureResponderEvent) => {
- event.stopPropagation();
- onPress();
- },
- [onPress],
- );
-
- return (
-
-
-
-
- {({ hovered, pressed }) =>
- loading ? (
-
- ) : (
-
- )
- }
-
-
-
-
- New workspace
- {showShortcutHint && newWorktreeKeys ? (
-
- ) : null}
-
-
-
-
- );
-}
-
-function useLongPressDragInteraction(input: {
- drag: () => void;
- menuController: ReturnType | null;
-}) {
- const didLongPressRef = useRef(false);
- const dragArmedRef = useRef(false);
- const dragActivatedRef = useRef(false);
- const didStartDragRef = useRef(false);
- const scrollIntentRef = useRef(false);
- const menuOpenedRef = useRef(false);
- const touchStartRef = useRef<{ x: number; y: number } | null>(null);
- const touchCurrentRef = useRef<{ x: number; y: number } | null>(null);
- const dragArmTimerRef = useRef | null>(null);
- const contextMenuTimerRef = useRef | null>(null);
-
- const clearTimers = useCallback(() => {
- if (dragArmTimerRef.current) {
- clearTimeout(dragArmTimerRef.current);
- dragArmTimerRef.current = null;
- }
- if (contextMenuTimerRef.current) {
- clearTimeout(contextMenuTimerRef.current);
- contextMenuTimerRef.current = null;
- }
- }, []);
-
- const openContextMenuAtStartPoint = useCallback(() => {
- if (!input.menuController || !touchStartRef.current) {
- return;
- }
- const statusBarHeight = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0;
- input.menuController.setAnchorRect({
- x: touchStartRef.current.x,
- y: touchStartRef.current.y + statusBarHeight,
- width: 0,
- height: 0,
- });
- input.menuController.setOpen(true);
- menuOpenedRef.current = true;
- didLongPressRef.current = true;
- }, [input.menuController]);
-
- const handleLongPress = useCallback(() => {
- // Manual timers own long-press behavior on mobile.
- }, []);
-
- useEffect(() => {
- return () => {
- clearTimers();
- };
- }, [clearTimers]);
-
- const armTimers = useCallback(() => {
- clearTimers();
-
- const DRAG_ARM_DELAY_MS = 180;
- const DRAG_ARM_STATIONARY_SLOP_PX = 4;
- const CONTEXT_MENU_DELAY_MS = 450;
- const CONTEXT_MENU_STATIONARY_SLOP_PX = 6;
-
- dragArmTimerRef.current = setTimeout(() => {
- if (scrollIntentRef.current || didStartDragRef.current || menuOpenedRef.current) {
- return;
- }
- const start = touchStartRef.current;
- const current = touchCurrentRef.current ?? start;
- if (!start || !current) {
- return;
- }
- const dx = current.x - start.x;
- const dy = current.y - start.y;
- const distance = Math.sqrt(dx * dx + dy * dy);
- if (distance > DRAG_ARM_STATIONARY_SLOP_PX) {
- return;
- }
- dragArmedRef.current = true;
- dragActivatedRef.current = true;
- didLongPressRef.current = true;
- void Haptics.selectionAsync().catch(() => {});
- input.drag();
- }, DRAG_ARM_DELAY_MS);
-
- if (!input.menuController || platformIsWeb) {
- return;
- }
-
- contextMenuTimerRef.current = setTimeout(() => {
- if (scrollIntentRef.current || didStartDragRef.current || menuOpenedRef.current) {
- return;
- }
- const start = touchStartRef.current;
- const current = touchCurrentRef.current ?? start;
- if (!start || !current) {
- return;
- }
- const dx = current.x - start.x;
- const dy = current.y - start.y;
- const distance = Math.sqrt(dx * dx + dy * dy);
- if (distance > CONTEXT_MENU_STATIONARY_SLOP_PX) {
- return;
- }
- void Haptics.selectionAsync().catch(() => {});
- openContextMenuAtStartPoint();
- }, CONTEXT_MENU_DELAY_MS);
- }, [clearTimers, input, openContextMenuAtStartPoint]);
-
- const handleDragIntent = useCallback(
- (_details: { dx: number; dy: number; distance: number }) => {
- if (!dragActivatedRef.current) {
- return;
- }
- didStartDragRef.current = true;
- didLongPressRef.current = true;
- clearTimers();
- void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {});
- },
- [clearTimers],
- );
-
- const handleScrollIntent = useCallback(
- (_details: { dx: number; dy: number; distance: number }) => {
- scrollIntentRef.current = true;
- didLongPressRef.current = true;
- clearTimers();
- },
- [clearTimers],
- );
-
- const handleSwipeIntent = useCallback(
- (_details: { dx: number; dy: number; distance: number }) => {
- didLongPressRef.current = true;
- clearTimers();
- },
- [clearTimers],
- );
-
- const handlePressIn = useCallback(
- (event: GestureResponderEvent) => {
- didLongPressRef.current = false;
- dragArmedRef.current = false;
- dragActivatedRef.current = false;
- didStartDragRef.current = false;
- scrollIntentRef.current = false;
- menuOpenedRef.current = false;
- touchStartRef.current = {
- x: event.nativeEvent.pageX,
- y: event.nativeEvent.pageY,
- };
- touchCurrentRef.current = {
- x: event.nativeEvent.pageX,
- y: event.nativeEvent.pageY,
- };
- armTimers();
- },
- [armTimers],
- );
-
- const handleTouchMove = useCallback(
- (event: GestureResponderEvent) => {
- const start = touchStartRef.current;
- if (!start || didStartDragRef.current || menuOpenedRef.current) {
- return;
- }
-
- const touch = event?.nativeEvent?.touches?.[0] ?? event?.nativeEvent;
- const x = touch?.pageX;
- const y = touch?.pageY;
- if (typeof x !== "number" || typeof y !== "number") {
- return;
- }
-
- const current = { x, y };
- touchCurrentRef.current = current;
- const dx = current.x - start.x;
- const dy = current.y - start.y;
- const distance = Math.sqrt(dx * dx + dy * dy);
- const decision = decideLongPressMove({
- dragArmed: dragArmedRef.current,
- didStartDrag: didStartDragRef.current,
- startPoint: start,
- currentPoint: current,
- });
-
- if (decision === "vertical_scroll") {
- handleScrollIntent({ dx, dy, distance });
- return;
- }
-
- if (decision === "horizontal_swipe" || decision === "cancel_long_press") {
- handleSwipeIntent({ dx, dy, distance });
- return;
- }
-
- if (decision === "start_drag") {
- handleDragIntent({ dx, dy, distance });
- }
- },
- [handleDragIntent, handleScrollIntent, handleSwipeIntent],
- );
-
- const handlePressOut = useCallback(() => {
- clearTimers();
- dragArmedRef.current = false;
- dragActivatedRef.current = false;
- touchStartRef.current = null;
- touchCurrentRef.current = null;
- }, [clearTimers]);
-
- return {
- didLongPressRef,
- handleLongPress,
- handlePressIn,
- handleTouchMove,
- handlePressOut,
- };
-}
-
function ProjectHeaderRow({
project,
displayName,
@@ -1117,16 +710,6 @@ function ProjectHeaderRow({
dragHandleProps,
}: ProjectHeaderRowProps) {
const [isHovered, setIsHovered] = useState(false);
- const isMobileBreakpoint = useIsCompactFormFactor();
- const handleBeginWorkspaceSetup = useCallback(() => {
- if (!serverId) {
- return;
- }
- router.navigate(
- buildHostNewWorkspaceRoute(serverId, project.iconWorkingDir, { displayName }) as Href,
- );
- onWorkspacePress?.();
- }, [displayName, onWorkspacePress, project.iconWorkingDir, serverId]);
const _mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
const _toast = useToast();
@@ -1167,16 +750,18 @@ function ProjectHeaderRow({
const trailingSlot = (
<>
-
{showShortcutBadge && shortcutNumber !== null ? (
@@ -2185,8 +1770,6 @@ export function SidebarWorkspaceList({
);
const showShortcutBadges = useShowShortcutBadges();
- const getProjectOrder = useSidebarOrderStore((state) => state.getProjectOrder);
- const setProjectOrder = useSidebarOrderStore((state) => state.setProjectOrder);
const getWorkspaceOrder = useSidebarOrderStore((state) => state.getWorkspaceOrder);
const setWorkspaceOrder = useSidebarOrderStore((state) => state.setWorkspaceOrder);
@@ -2308,34 +1891,6 @@ export function SidebarWorkspaceList({
});
}, [creatingWorkspaceIds, projects]);
- const handleProjectDragEnd = useCallback(
- (reorderedProjects: SidebarProjectEntry[]) => {
- if (!serverId) {
- return;
- }
-
- const reorderedProjectKeys = reorderedProjects.map((project) => project.projectKey);
- const currentProjectOrder = getProjectOrder(serverId);
- if (
- !hasVisibleOrderChanged({
- currentOrder: currentProjectOrder,
- reorderedVisibleKeys: reorderedProjectKeys,
- })
- ) {
- return;
- }
-
- setProjectOrder(
- serverId,
- mergeWithRemainder({
- currentOrder: currentProjectOrder,
- reorderedVisibleKeys: reorderedProjectKeys,
- }),
- );
- },
- [getProjectOrder, serverId, setProjectOrder],
- );
-
const handleWorkspaceReorder = useCallback(
(projectKey: string, reorderedWorkspaces: SidebarWorkspaceEntry[]) => {
if (!serverId) {
@@ -2392,13 +1947,13 @@ export function SidebarWorkspaceList({
}, []);
const renderProject = useCallback(
- ({ item, drag, isActive, dragHandleProps }: DraggableRenderItemInfo) => {
+ ({ project, drag, isDragging, dragHandleProps }: SidebarProjectDragInfo) => {
return (
) : (
- ({
alignItems: "center",
justifyContent: "center",
},
- projectActionButton: {
- flexDirection: "row",
- alignItems: "center",
- gap: theme.spacing[1],
- paddingHorizontal: theme.spacing[2],
- paddingVertical: theme.spacing[1],
- borderRadius: theme.borderRadius.md,
- flexShrink: 0,
- },
- projectActionButtonHovered: {
- backgroundColor: theme.colors.surfaceSidebarHover,
- },
- projectActionButtonText: {
- color: theme.colors.foregroundMuted,
- fontSize: theme.fontSize.xs,
- },
- projectIconActionButton: {
- width: 24,
- height: 24,
- borderRadius: theme.borderRadius.md,
- alignItems: "center",
- justifyContent: "center",
- flexShrink: 0,
- },
- projectIconActionButtonHovered: {
- backgroundColor: theme.colors.surfaceSidebarHover,
- },
- projectIconActionButtonHidden: {
- opacity: 0,
- },
- projectTrailingActions: {
- flexDirection: "row",
- alignItems: "center",
- gap: 2,
- flexShrink: 0,
- },
- projectKebabButton: {
- width: 24,
- height: 24,
- borderRadius: theme.borderRadius.md,
- alignItems: "center",
- justifyContent: "center",
- flexShrink: 0,
- },
- projectKebabButtonHidden: {
- opacity: 0,
- },
- projectKebabButtonHovered: {
- backgroundColor: theme.colors.surface2,
- },
- projectTrailingControlSlot: {
- width: 24,
- height: 24,
- alignItems: "center",
- justifyContent: "center",
- flexShrink: 0,
- },
- projectActionTooltipRow: {
- flexDirection: "row",
- alignItems: "center",
- gap: theme.spacing[2],
- },
- projectActionTooltipText: {
- color: theme.colors.foreground,
- fontSize: theme.fontSize.sm,
- },
- projectActionTooltipShortcut: {},
workspaceRow: {
minHeight: 36,
marginBottom: theme.spacing[1],
diff --git a/packages/app/src/components/sidebar/sidebar-collapsible-project-section.tsx b/packages/app/src/components/sidebar/sidebar-collapsible-project-section.tsx
index 2a74d01ead..7d6203fbab 100644
--- a/packages/app/src/components/sidebar/sidebar-collapsible-project-section.tsx
+++ b/packages/app/src/components/sidebar/sidebar-collapsible-project-section.tsx
@@ -1,4 +1,4 @@
-import {
+import React, {
forwardRef,
memo,
useCallback,
diff --git a/packages/app/src/components/sidebar/sidebar-project-row-visual.tsx b/packages/app/src/components/sidebar/sidebar-project-row-visual.tsx
index 0759af32f6..4dda5182bf 100644
--- a/packages/app/src/components/sidebar/sidebar-project-row-visual.tsx
+++ b/packages/app/src/components/sidebar/sidebar-project-row-visual.tsx
@@ -1,4 +1,4 @@
-import { memo, useMemo, type ReactElement } from "react";
+import React, { memo, useMemo, type ReactElement } from "react";
import { Image, Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
diff --git a/packages/app/src/components/sidebar/sidebar-project-trailing-actions.test.tsx b/packages/app/src/components/sidebar/sidebar-project-trailing-actions.test.tsx
new file mode 100644
index 0000000000..d8b1197b38
--- /dev/null
+++ b/packages/app/src/components/sidebar/sidebar-project-trailing-actions.test.tsx
@@ -0,0 +1,135 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { cleanup, render } from "@testing-library/react";
+import React from "react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { Pressable, View } from "react-native";
+import { SidebarProjectTrailingActions } from "./sidebar-project-trailing-actions";
+
+vi.hoisted(() => {
+ Object.assign(globalThis, { __DEV__: false });
+});
+
+vi.mock("expo-router", () => ({
+ router: {
+ navigate: vi.fn(),
+ },
+}));
+
+vi.mock("lucide-react-native", () => {
+ const createIcon = (name: string) => (props: Record) =>
+ React.createElement("span", { ...props, "data-icon": name });
+ return {
+ FolderPlus: createIcon("FolderPlus"),
+ MoreVertical: createIcon("MoreVertical"),
+ Settings: createIcon("Settings"),
+ Trash2: createIcon("Trash2"),
+ };
+});
+
+vi.mock("react-native-unistyles", () => ({
+ StyleSheet: {
+ create: (styles: unknown) =>
+ typeof styles === "function"
+ ? styles({
+ borderRadius: { md: 6 },
+ colors: {
+ foreground: "#111111",
+ foregroundMuted: "#666666",
+ surface2: "#eeeeee",
+ surfaceSidebarHover: "#f5f5f5",
+ },
+ fontSize: { sm: 14 },
+ spacing: { 2: 8 },
+ })
+ : styles,
+ },
+ withUnistyles: (Component: React.ComponentType>) =>
+ function ThemedComponent(props: Record) {
+ return ;
+ },
+}));
+
+vi.mock("@/components/ui/dropdown-menu", () => ({
+ DropdownMenu: ({ children }: { children: React.ReactNode }) => {children},
+ DropdownMenuContent: ({ children }: { children: React.ReactNode }) => {children},
+ DropdownMenuItem: ({ children, testID }: { children: React.ReactNode; testID?: string }) => (
+ {children}
+ ),
+ DropdownMenuTrigger: ({
+ children,
+ testID,
+ }: {
+ children: React.ReactNode | ((state: { hovered: boolean }) => React.ReactNode);
+ testID?: string;
+ }) => (
+
+ {typeof children === "function" ? children({ hovered: false }) : children}
+
+ ),
+}));
+
+vi.mock("@/components/ui/tooltip", () => ({
+ Tooltip: ({ children }: { children: React.ReactNode }) => {children},
+ TooltipContent: ({ children }: { children: React.ReactNode }) => {children},
+ TooltipTrigger: ({ children }: { children: React.ReactNode }) => children,
+}));
+
+vi.mock("@/components/ui/shortcut", () => ({
+ Shortcut: () => ,
+}));
+
+vi.mock("@/constants/platform", () => ({
+ isNative: false,
+}));
+
+vi.mock("@/constants/layout", () => ({
+ useIsCompactFormFactor: () => false,
+}));
+
+vi.mock("@/contexts/toast-context", () => ({
+ useToast: () => ({ error: vi.fn() }),
+}));
+
+vi.mock("@/hooks/use-shortcut-keys", () => ({
+ useShortcutKeys: () => null,
+}));
+
+afterEach(() => {
+ cleanup();
+});
+
+describe("SidebarProjectTrailingActions", () => {
+ it("renders the kebab and new-worktree button when requested", () => {
+ const { getByTestId } = render(
+ ,
+ );
+
+ expect(getByTestId("sidebar-project-kebab-project-a")).toBeTruthy();
+ expect(getByTestId("sidebar-project-new-worktree-project-a")).toBeTruthy();
+ });
+
+ it("renders only the kebab when the new-worktree button is omitted", () => {
+ const { getByTestId, queryByTestId } = render(
+ ,
+ );
+
+ expect(getByTestId("sidebar-project-kebab-project-a")).toBeTruthy();
+ expect(queryByTestId("sidebar-project-new-worktree-project-a")).toBeNull();
+ });
+});
diff --git a/packages/app/src/components/sidebar/sidebar-project-trailing-actions.tsx b/packages/app/src/components/sidebar/sidebar-project-trailing-actions.tsx
new file mode 100644
index 0000000000..c6573f8541
--- /dev/null
+++ b/packages/app/src/components/sidebar/sidebar-project-trailing-actions.tsx
@@ -0,0 +1,426 @@
+import React, { useCallback, useState, type ReactElement } from "react";
+import {
+ ActivityIndicator,
+ Pressable,
+ Text,
+ View,
+ type GestureResponderEvent,
+ type PressableStateCallbackType,
+} from "react-native";
+import { router, type Href } from "expo-router";
+import { FolderPlus, MoreVertical, Settings, Trash2 } from "lucide-react-native";
+import { StyleSheet, withUnistyles } from "react-native-unistyles";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { Shortcut } from "@/components/ui/shortcut";
+import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
+import { isNative as platformIsNative } from "@/constants/platform";
+import { useIsCompactFormFactor } from "@/constants/layout";
+import { useToast } from "@/contexts/toast-context";
+import {
+ clearWorkspaceArchivePending,
+ markWorkspaceArchivePending,
+} from "@/contexts/session-workspace-upserts";
+import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
+import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
+import { getHostRuntimeStore } from "@/runtime/host-runtime";
+import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
+import type { Theme } from "@/styles/theme";
+import { confirmDialog } from "@/utils/confirm-dialog";
+import { buildHostNewWorkspaceRoute, buildProjectSettingsRoute } from "@/utils/host-routes";
+import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-execution";
+
+export interface SidebarProjectTrailingActionsProps {
+ projectKey: string;
+ serverId: string | null;
+ isHovered: boolean;
+ showNewWorktreeButton?: boolean;
+ projectName?: string;
+ sourceDirectory?: string;
+ isProjectActive?: boolean;
+ onWorkspacePress?: () => void;
+ onRemoveProject?: () => void;
+ removeProjectStatus?: "idle" | "pending" | "success";
+ workspaces?: readonly SidebarWorkspaceEntry[];
+}
+
+const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
+const ThemedFolderPlus = withUnistyles(FolderPlus);
+const ThemedMoreVertical = withUnistyles(MoreVertical);
+const ThemedSettings = withUnistyles(Settings);
+const ThemedTrash2 = withUnistyles(Trash2);
+
+const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
+const foregroundMutedColorMapping = (theme: Theme) => ({
+ color: theme.colors.foregroundMuted,
+});
+
+const trash2LeadingIcon = ;
+const settingsLeadingIcon = ;
+
+export function SidebarProjectTrailingActions({
+ projectKey,
+ serverId,
+ isHovered,
+ showNewWorktreeButton = false,
+ projectName,
+ sourceDirectory,
+ isProjectActive = false,
+ onWorkspacePress,
+ onRemoveProject,
+ removeProjectStatus,
+ workspaces = [],
+}: SidebarProjectTrailingActionsProps): ReactElement {
+ const isMobileBreakpoint = useIsCompactFormFactor();
+ const actionsVisible = isHovered || platformIsNative || isMobileBreakpoint;
+ const fallbackRemoval = useSidebarProjectRemoval({
+ projectKey,
+ projectName,
+ serverId,
+ workspaces,
+ });
+ const resolvedRemoveProject = onRemoveProject ?? fallbackRemoval.onRemoveProject;
+ const resolvedRemoveProjectStatus = removeProjectStatus ?? fallbackRemoval.removeProjectStatus;
+
+ const handleBeginWorkspaceSetup = useCallback(() => {
+ if (!serverId || !sourceDirectory) {
+ return;
+ }
+ router.navigate(
+ buildHostNewWorkspaceRoute(serverId, sourceDirectory, { displayName: projectName }) as Href,
+ );
+ onWorkspacePress?.();
+ }, [onWorkspacePress, projectName, serverId, sourceDirectory]);
+
+ return (
+
+ {showNewWorktreeButton && serverId && sourceDirectory ? (
+
+ ) : null}
+
+
+
+
+ );
+}
+
+function useSidebarProjectRemoval({
+ projectKey,
+ projectName,
+ serverId,
+ workspaces,
+}: {
+ projectKey: string;
+ projectName?: string;
+ serverId: string | null;
+ workspaces: readonly SidebarWorkspaceEntry[];
+}): {
+ onRemoveProject: () => void;
+ removeProjectStatus: "idle" | "pending";
+} {
+ const toast = useToast();
+ const [isRemovingProject, setIsRemovingProject] = useState(false);
+ const displayName = projectName ?? projectKey;
+
+ const handleRemoveProject = useCallback(() => {
+ if (isRemovingProject || !serverId) {
+ return;
+ }
+
+ void (async () => {
+ const confirmed = await confirmDialog({
+ title: "Remove project?",
+ message: `Remove "${displayName}" from the sidebar?\n\nFiles on disk will not be changed.`,
+ confirmLabel: "Remove",
+ cancelLabel: "Cancel",
+ destructive: true,
+ });
+ if (!confirmed) {
+ return;
+ }
+
+ const client = getHostRuntimeStore().getClient(serverId);
+ if (!client) {
+ toast.error("Host is not connected");
+ return;
+ }
+
+ setIsRemovingProject(true);
+ const snapshots = new Map(
+ workspaces.map((workspace) => [
+ workspace.workspaceId,
+ hideWorkspaceOptimistically(workspace),
+ ]),
+ );
+
+ const isRejected = (r: PromiseSettledResult) => r.status === "rejected";
+ void Promise.allSettled(
+ workspaces.map(async (ws) => {
+ try {
+ const payload = await client.archiveWorkspace(ws.workspaceId);
+ if (payload.error) {
+ throw new Error(payload.error);
+ }
+ } catch (error) {
+ restoreOptimisticallyHiddenWorkspace({
+ serverId,
+ workspaceId: ws.workspaceId,
+ snapshot: snapshots.get(ws.workspaceId) ?? null,
+ });
+ throw error;
+ }
+ }),
+ ).then((results) => {
+ const failed = results.filter(isRejected);
+ if (failed.length > 0) {
+ toast.error("Failed to remove some workspaces");
+ }
+ setIsRemovingProject(false);
+ return;
+ });
+ })();
+ }, [displayName, isRemovingProject, serverId, toast, workspaces]);
+
+ return {
+ onRemoveProject: handleRemoveProject,
+ removeProjectStatus: isRemovingProject ? "pending" : "idle",
+ };
+}
+
+function hideWorkspaceOptimistically(workspace: SidebarWorkspaceEntry): WorkspaceDescriptor | null {
+ const workspaces = useSessionStore.getState().sessions[workspace.serverId]?.workspaces;
+ const workspaceKey = resolveWorkspaceMapKeyByIdentity({
+ workspaces,
+ workspaceId: workspace.workspaceId,
+ });
+ const snapshot = workspaceKey ? (workspaces?.get(workspaceKey) ?? null) : null;
+ markWorkspaceArchivePending({
+ serverId: workspace.serverId,
+ workspaceId: workspace.workspaceId,
+ workspaceDirectory: workspace.workspaceDirectory,
+ });
+ useSessionStore.getState().removeWorkspace(workspace.serverId, workspace.workspaceId);
+ return snapshot;
+}
+
+function restoreOptimisticallyHiddenWorkspace(input: {
+ serverId: string;
+ workspaceId: string;
+ snapshot: WorkspaceDescriptor | null;
+}): void {
+ clearWorkspaceArchivePending({
+ serverId: input.serverId,
+ workspaceId: input.workspaceId,
+ });
+ if (input.snapshot) {
+ useSessionStore.getState().mergeWorkspaces(input.serverId, [input.snapshot]);
+ }
+}
+
+function projectKebabStyle({
+ hovered = false,
+}: PressableStateCallbackType & { hovered?: boolean }) {
+ return [styles.projectKebabButton, hovered && styles.projectKebabButtonHovered];
+}
+
+function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }) {
+ return (
+
+ );
+}
+
+function ProjectKebabMenu({
+ projectKey,
+ onRemoveProject,
+ removeProjectStatus,
+}: {
+ projectKey: string;
+ onRemoveProject: () => void;
+ removeProjectStatus: "idle" | "pending" | "success";
+}) {
+ const handleOpenProjectSettings = useCallback(() => {
+ if (projectKey.trim().length === 0) return;
+ router.navigate(buildProjectSettingsRoute(projectKey));
+ }, [projectKey]);
+ const canOpenProjectSettings = projectKey.trim().length > 0;
+ return (
+
+
+ {renderKebabTriggerIcon}
+
+
+ {canOpenProjectSettings ? (
+
+ Open project settings
+
+ ) : null}
+
+ Remove project
+
+
+
+ );
+}
+
+function NewWorktreeButton({
+ displayName,
+ onPress,
+ visible,
+ loading = false,
+ testID,
+ showShortcutHint = false,
+}: {
+ displayName: string;
+ onPress: () => void;
+ visible: boolean;
+ loading?: boolean;
+ testID: string;
+ showShortcutHint?: boolean;
+}) {
+ const newWorktreeKeys = useShortcutKeys("new-worktree");
+
+ const pressableStyle = useCallback(
+ ({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
+ styles.projectIconActionButton,
+ !visible && styles.projectIconActionButtonHidden,
+ (Boolean(hovered) || pressed) && !loading && styles.projectIconActionButtonHovered,
+ ],
+ [visible, loading],
+ );
+
+ const handlePress = useCallback(
+ (event: GestureResponderEvent) => {
+ event.stopPropagation();
+ onPress();
+ },
+ [onPress],
+ );
+
+ return (
+
+
+
+
+ {({ hovered, pressed }) =>
+ loading ? (
+
+ ) : (
+
+ )
+ }
+
+
+
+
+ New workspace
+ {showShortcutHint && newWorktreeKeys ? (
+
+ ) : null}
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create((theme) => ({
+ projectIconActionButton: {
+ width: 24,
+ height: 24,
+ borderRadius: theme.borderRadius.md,
+ alignItems: "center",
+ justifyContent: "center",
+ flexShrink: 0,
+ },
+ projectIconActionButtonHovered: {
+ backgroundColor: theme.colors.surfaceSidebarHover,
+ },
+ projectIconActionButtonHidden: {
+ opacity: 0,
+ },
+ projectTrailingActions: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 2,
+ flexShrink: 0,
+ },
+ projectKebabButton: {
+ width: 24,
+ height: 24,
+ borderRadius: theme.borderRadius.md,
+ alignItems: "center",
+ justifyContent: "center",
+ flexShrink: 0,
+ },
+ projectKebabButtonHidden: {
+ opacity: 0,
+ },
+ projectKebabButtonHovered: {
+ backgroundColor: theme.colors.surface2,
+ },
+ projectTrailingControlSlot: {
+ width: 24,
+ height: 24,
+ alignItems: "center",
+ justifyContent: "center",
+ flexShrink: 0,
+ },
+ projectActionTooltipRow: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: theme.spacing[2],
+ },
+ projectActionTooltipText: {
+ color: theme.colors.foreground,
+ fontSize: theme.fontSize.sm,
+ },
+ projectActionTooltipShortcut: {},
+}));
diff --git a/packages/app/src/components/sidebar/sidebar-projects-draggable-list.test.tsx b/packages/app/src/components/sidebar/sidebar-projects-draggable-list.test.tsx
new file mode 100644
index 0000000000..4a2a5d4b51
--- /dev/null
+++ b/packages/app/src/components/sidebar/sidebar-projects-draggable-list.test.tsx
@@ -0,0 +1,107 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { render } from "@testing-library/react";
+import React from "react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import type { SidebarProjectEntry } from "@/hooks/use-sidebar-workspaces-list";
+import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
+import {
+ SidebarProjectsDraggableList,
+ type SidebarProjectDragInfo,
+} from "./sidebar-projects-draggable-list";
+
+interface CapturedDraggableProps {
+ data: SidebarProjectEntry[];
+ onDragEnd: (next: SidebarProjectEntry[]) => void;
+}
+
+const captured: { props: CapturedDraggableProps | null } = { props: null };
+
+vi.mock("@/components/draggable-list", () => ({
+ DraggableList: (props: CapturedDraggableProps) => {
+ captured.props = props;
+ return null;
+ },
+}));
+
+function makeProject(key: string): SidebarProjectEntry {
+ return {
+ projectKey: key,
+ projectName: key,
+ projectKind: "git",
+ iconWorkingDir: `/repo/${key}`,
+ workspaces: [],
+ };
+}
+
+const PROJECTS_ABC: readonly SidebarProjectEntry[] = [
+ makeProject("a"),
+ makeProject("b"),
+ makeProject("c"),
+];
+
+const PROJECTS_AB: readonly SidebarProjectEntry[] = [makeProject("a"), makeProject("b")];
+
+function renderEmpty(): React.ReactElement {
+ return React.createElement(React.Fragment);
+}
+
+afterEach(() => {
+ captured.props = null;
+ useSidebarOrderStore.setState({
+ projectOrderByServerId: {},
+ workspaceOrderByServerAndProject: {},
+ });
+});
+
+const noopRender = (_info: SidebarProjectDragInfo): React.ReactElement => renderEmpty();
+
+describe("SidebarProjectsDraggableList", () => {
+ it("persists the reordered project keys to the order store on drag end", () => {
+ render(
+ ,
+ );
+
+ expect(captured.props).not.toBeNull();
+ const reordered = [PROJECTS_ABC[1], PROJECTS_ABC[0], PROJECTS_ABC[2]];
+ captured.props?.onDragEnd(reordered);
+
+ expect(useSidebarOrderStore.getState().getProjectOrder("server-1")).toEqual(["b", "a", "c"]);
+ });
+
+ it("does not persist when the reordered visible keys match the current order", () => {
+ useSidebarOrderStore.getState().setProjectOrder("server-1", ["a", "b"]);
+ const setSpy = vi.spyOn(useSidebarOrderStore.getState(), "setProjectOrder");
+
+ render(
+ ,
+ );
+
+ captured.props?.onDragEnd([...PROJECTS_AB]);
+
+ expect(setSpy).not.toHaveBeenCalled();
+ });
+
+ it("does nothing when serverId is null", () => {
+ render(
+ ,
+ );
+
+ captured.props?.onDragEnd([PROJECTS_AB[1], PROJECTS_AB[0]]);
+
+ expect(useSidebarOrderStore.getState().projectOrderByServerId).toEqual({});
+ });
+});
diff --git a/packages/app/src/components/sidebar/sidebar-projects-draggable-list.tsx b/packages/app/src/components/sidebar/sidebar-projects-draggable-list.tsx
new file mode 100644
index 0000000000..834ea9b8f8
--- /dev/null
+++ b/packages/app/src/components/sidebar/sidebar-projects-draggable-list.tsx
@@ -0,0 +1,92 @@
+import React, { useCallback, type MutableRefObject, type ReactElement } from "react";
+import type { StyleProp, ViewStyle } from "react-native";
+import type { GestureType } from "react-native-gesture-handler";
+import { DraggableList, type DraggableRenderItemInfo } from "@/components/draggable-list";
+import type { DraggableListDragHandleProps } from "@/components/draggable-list.types";
+import type { SidebarProjectEntry } from "@/hooks/use-sidebar-workspaces-list";
+import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
+import { hasVisibleOrderChanged, mergeWithRemainder } from "@/utils/sidebar-reorder";
+
+export interface SidebarProjectDragInfo {
+ project: SidebarProjectEntry;
+ drag: () => void;
+ isDragging: boolean;
+ dragHandleProps?: DraggableListDragHandleProps;
+}
+
+interface SidebarProjectsDraggableListProps {
+ projects: readonly SidebarProjectEntry[];
+ serverId: string | null;
+ renderProject: (info: SidebarProjectDragInfo) => ReactElement;
+ /** Native: use the nestable variant when wrapped in a NestableScrollContainer. */
+ nestable?: boolean;
+ /** When false, the outer scroll container owns scrolling. */
+ scrollEnabled?: boolean;
+ containerStyle?: StyleProp;
+ testID?: string;
+ /** Native: gesture ref to coordinate with parent (e.g. sidebar close swipe). */
+ simultaneousGestureRef?: MutableRefObject;
+}
+
+const projectKeyExtractor = (project: SidebarProjectEntry) => project.projectKey;
+
+export function SidebarProjectsDraggableList({
+ projects,
+ serverId,
+ renderProject,
+ nestable = false,
+ scrollEnabled = false,
+ containerStyle,
+ testID,
+ simultaneousGestureRef,
+}: SidebarProjectsDraggableListProps): ReactElement {
+ const getProjectOrder = useSidebarOrderStore((state) => state.getProjectOrder);
+ const setProjectOrder = useSidebarOrderStore((state) => state.setProjectOrder);
+
+ const handleDragEnd = useCallback(
+ (reorderedProjects: SidebarProjectEntry[]) => {
+ if (!serverId) {
+ return;
+ }
+ const reorderedProjectKeys = reorderedProjects.map((project) => project.projectKey);
+ const currentProjectOrder = getProjectOrder(serverId);
+ if (
+ !hasVisibleOrderChanged({
+ currentOrder: currentProjectOrder,
+ reorderedVisibleKeys: reorderedProjectKeys,
+ })
+ ) {
+ return;
+ }
+ setProjectOrder(
+ serverId,
+ mergeWithRemainder({
+ currentOrder: currentProjectOrder,
+ reorderedVisibleKeys: reorderedProjectKeys,
+ }),
+ );
+ },
+ [getProjectOrder, serverId, setProjectOrder],
+ );
+
+ const renderItem = useCallback(
+ ({ item, drag, isActive, dragHandleProps }: DraggableRenderItemInfo) =>
+ renderProject({ project: item, drag, isDragging: isActive, dragHandleProps }),
+ [renderProject],
+ );
+
+ return (
+
+ );
+}
diff --git a/packages/app/src/components/sidebar/use-long-press-drag-interaction.ts b/packages/app/src/components/sidebar/use-long-press-drag-interaction.ts
new file mode 100644
index 0000000000..4ccf1a3838
--- /dev/null
+++ b/packages/app/src/components/sidebar/use-long-press-drag-interaction.ts
@@ -0,0 +1,229 @@
+import { useCallback, useEffect, useRef } from "react";
+import { Platform, StatusBar, type GestureResponderEvent } from "react-native";
+import * as Haptics from "expo-haptics";
+import { isWeb as platformIsWeb } from "@/constants/platform";
+import { useContextMenu } from "@/components/ui/context-menu";
+import { decideLongPressMove } from "@/utils/sidebar-gesture-arbitration";
+
+/**
+ * Shared mobile drag affordance: long-press arms a draggable-flatlist drag with haptics,
+ * arbitrating against vertical scroll, horizontal swipe, and an optional context-menu open.
+ *
+ * Web does not exercise this — `dragHandleProps` from `DraggableList` covers web DnD.
+ */
+export function useLongPressDragInteraction(input: {
+ drag: () => void;
+ menuController: ReturnType | null;
+}) {
+ const didLongPressRef = useRef(false);
+ const dragArmedRef = useRef(false);
+ const dragActivatedRef = useRef(false);
+ const didStartDragRef = useRef(false);
+ const scrollIntentRef = useRef(false);
+ const menuOpenedRef = useRef(false);
+ const touchStartRef = useRef<{ x: number; y: number } | null>(null);
+ const touchCurrentRef = useRef<{ x: number; y: number } | null>(null);
+ const dragArmTimerRef = useRef | null>(null);
+ const contextMenuTimerRef = useRef | null>(null);
+
+ const clearTimers = useCallback(() => {
+ if (dragArmTimerRef.current) {
+ clearTimeout(dragArmTimerRef.current);
+ dragArmTimerRef.current = null;
+ }
+ if (contextMenuTimerRef.current) {
+ clearTimeout(contextMenuTimerRef.current);
+ contextMenuTimerRef.current = null;
+ }
+ }, []);
+
+ const openContextMenuAtStartPoint = useCallback(() => {
+ if (!input.menuController || !touchStartRef.current) {
+ return;
+ }
+ const statusBarHeight = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0;
+ input.menuController.setAnchorRect({
+ x: touchStartRef.current.x,
+ y: touchStartRef.current.y + statusBarHeight,
+ width: 0,
+ height: 0,
+ });
+ input.menuController.setOpen(true);
+ menuOpenedRef.current = true;
+ didLongPressRef.current = true;
+ }, [input.menuController]);
+
+ const handleLongPress = useCallback(() => {
+ // Manual timers own long-press behavior on mobile.
+ }, []);
+
+ useEffect(() => {
+ return () => {
+ clearTimers();
+ };
+ }, [clearTimers]);
+
+ const armTimers = useCallback(() => {
+ clearTimers();
+
+ const DRAG_ARM_DELAY_MS = 180;
+ const DRAG_ARM_STATIONARY_SLOP_PX = 4;
+ const CONTEXT_MENU_DELAY_MS = 450;
+ const CONTEXT_MENU_STATIONARY_SLOP_PX = 6;
+
+ dragArmTimerRef.current = setTimeout(() => {
+ if (scrollIntentRef.current || didStartDragRef.current || menuOpenedRef.current) {
+ return;
+ }
+ const start = touchStartRef.current;
+ const current = touchCurrentRef.current ?? start;
+ if (!start || !current) {
+ return;
+ }
+ const dx = current.x - start.x;
+ const dy = current.y - start.y;
+ const distance = Math.sqrt(dx * dx + dy * dy);
+ if (distance > DRAG_ARM_STATIONARY_SLOP_PX) {
+ return;
+ }
+ dragArmedRef.current = true;
+ dragActivatedRef.current = true;
+ didLongPressRef.current = true;
+ void Haptics.selectionAsync().catch(() => {});
+ input.drag();
+ }, DRAG_ARM_DELAY_MS);
+
+ if (!input.menuController || platformIsWeb) {
+ return;
+ }
+
+ contextMenuTimerRef.current = setTimeout(() => {
+ if (scrollIntentRef.current || didStartDragRef.current || menuOpenedRef.current) {
+ return;
+ }
+ const start = touchStartRef.current;
+ const current = touchCurrentRef.current ?? start;
+ if (!start || !current) {
+ return;
+ }
+ const dx = current.x - start.x;
+ const dy = current.y - start.y;
+ const distance = Math.sqrt(dx * dx + dy * dy);
+ if (distance > CONTEXT_MENU_STATIONARY_SLOP_PX) {
+ return;
+ }
+ void Haptics.selectionAsync().catch(() => {});
+ openContextMenuAtStartPoint();
+ }, CONTEXT_MENU_DELAY_MS);
+ }, [clearTimers, input, openContextMenuAtStartPoint]);
+
+ const handleDragIntent = useCallback(
+ (_details: { dx: number; dy: number; distance: number }) => {
+ if (!dragActivatedRef.current) {
+ return;
+ }
+ didStartDragRef.current = true;
+ didLongPressRef.current = true;
+ clearTimers();
+ void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {});
+ },
+ [clearTimers],
+ );
+
+ const handleScrollIntent = useCallback(
+ (_details: { dx: number; dy: number; distance: number }) => {
+ scrollIntentRef.current = true;
+ didLongPressRef.current = true;
+ clearTimers();
+ },
+ [clearTimers],
+ );
+
+ const handleSwipeIntent = useCallback(
+ (_details: { dx: number; dy: number; distance: number }) => {
+ didLongPressRef.current = true;
+ clearTimers();
+ },
+ [clearTimers],
+ );
+
+ const handlePressIn = useCallback(
+ (event: GestureResponderEvent) => {
+ didLongPressRef.current = false;
+ dragArmedRef.current = false;
+ dragActivatedRef.current = false;
+ didStartDragRef.current = false;
+ scrollIntentRef.current = false;
+ menuOpenedRef.current = false;
+ touchStartRef.current = {
+ x: event.nativeEvent.pageX,
+ y: event.nativeEvent.pageY,
+ };
+ touchCurrentRef.current = {
+ x: event.nativeEvent.pageX,
+ y: event.nativeEvent.pageY,
+ };
+ armTimers();
+ },
+ [armTimers],
+ );
+
+ const handleTouchMove = useCallback(
+ (event: GestureResponderEvent) => {
+ const start = touchStartRef.current;
+ if (!start || didStartDragRef.current || menuOpenedRef.current) {
+ return;
+ }
+
+ const touch = event?.nativeEvent?.touches?.[0] ?? event?.nativeEvent;
+ const x = touch?.pageX;
+ const y = touch?.pageY;
+ if (typeof x !== "number" || typeof y !== "number") {
+ return;
+ }
+
+ const current = { x, y };
+ touchCurrentRef.current = current;
+ const dx = current.x - start.x;
+ const dy = current.y - start.y;
+ const distance = Math.sqrt(dx * dx + dy * dy);
+ const decision = decideLongPressMove({
+ dragArmed: dragArmedRef.current,
+ didStartDrag: didStartDragRef.current,
+ startPoint: start,
+ currentPoint: current,
+ });
+
+ if (decision === "vertical_scroll") {
+ handleScrollIntent({ dx, dy, distance });
+ return;
+ }
+
+ if (decision === "horizontal_swipe" || decision === "cancel_long_press") {
+ handleSwipeIntent({ dx, dy, distance });
+ return;
+ }
+
+ if (decision === "start_drag") {
+ handleDragIntent({ dx, dy, distance });
+ }
+ },
+ [handleDragIntent, handleScrollIntent, handleSwipeIntent],
+ );
+
+ const handlePressOut = useCallback(() => {
+ clearTimers();
+ dragArmedRef.current = false;
+ dragActivatedRef.current = false;
+ touchStartRef.current = null;
+ touchCurrentRef.current = null;
+ }, [clearTimers]);
+
+ return {
+ didLongPressRef,
+ handleLongPress,
+ handlePressIn,
+ handleTouchMove,
+ handlePressOut,
+ };
+}
diff --git a/packages/app/src/hooks/use-open-project-picker.ts b/packages/app/src/hooks/use-open-project-picker.ts
index 9a3193a414..9d90604232 100644
--- a/packages/app/src/hooks/use-open-project-picker.ts
+++ b/packages/app/src/hooks/use-open-project-picker.ts
@@ -1,10 +1,20 @@
import { useCallback } from "react";
+import { router } from "expo-router";
import { pickDirectory } from "@/desktop/pick-directory";
-import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
+import {
+ useKeyboardShortcutsStore,
+ type ProjectPickerIntent,
+} from "@/stores/keyboard-shortcuts-store";
+import { buildHostNewWorkspaceRoute } from "@/utils/host-routes";
import { useIsLocalDaemon } from "./use-is-local-daemon";
import { useOpenProject } from "./use-open-project";
-export function useOpenProjectPicker(serverId: string | null): () => Promise {
+const OPEN_PROJECT_INTENT: ProjectPickerIntent = { kind: "open-project" };
+
+export function useOpenProjectPicker(
+ serverId: string | null,
+ intent: ProjectPickerIntent = OPEN_PROJECT_INTENT,
+): () => Promise {
const normalizedServerId = serverId?.trim() ?? "";
const isLocalDaemon = useIsLocalDaemon(normalizedServerId);
const setProjectPickerOpen = useKeyboardShortcutsStore((state) => state.setProjectPickerOpen);
@@ -16,7 +26,7 @@ export function useOpenProjectPicker(serverId: string | null): () => Promise Promise
- New workspace
+ {headerTitle}
{workspaceTitle}
diff --git a/packages/app/src/sidebar-sessions/grouped-view.tsx b/packages/app/src/sidebar-sessions/grouped-view.tsx
index 81881a2b37..8dd214bc60 100644
--- a/packages/app/src/sidebar-sessions/grouped-view.tsx
+++ b/packages/app/src/sidebar-sessions/grouped-view.tsx
@@ -1,10 +1,17 @@
-import React, { memo, useCallback, useMemo, useState, type ReactElement } from "react";
+import React, { memo, useCallback, useMemo, useState, type ReactElement, type Ref } from "react";
import { Pressable, Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { shallow } from "zustand/shallow";
import { useStoreWithEqualityFn } from "zustand/traditional";
import { SidebarProjectHeaderRow } from "@/components/sidebar/sidebar-collapsible-project-section";
+import { SidebarProjectTrailingActions } from "@/components/sidebar/sidebar-project-trailing-actions";
+import type { DraggableListDragHandleProps } from "@/components/draggable-list.types";
+import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction";
import { useProjectIconQuery } from "@/hooks/use-project-icon-query";
+import type {
+ SidebarProjectEntry,
+ SidebarWorkspaceEntry,
+} from "@/hooks/use-sidebar-workspaces-list";
import { useSessionStore } from "@/stores/session-store";
import {
deriveGroupedSidebarSessions,
@@ -13,21 +20,10 @@ import {
} from "./session-filtering";
import type { ResolvedSidebarSessionProject } from "./types";
-const GROUPED_SESSION_LIMIT = 6;
+export const GROUPED_SESSION_LIMIT = 6;
type SessionStoreState = ReturnType;
-export type SidebarSessionListItem =
- | {
- kind: "header";
- projectKey: string;
- projectName: string;
- projectIconKey: string | null;
- isCollapsed: boolean;
- }
- | { kind: "row"; id: string; serverId: string }
- | { kind: "footer"; projectKey: string; hiddenCount: number; isExpanded: boolean };
-
type ResolveCwdToProject = (cwd: string) => ResolvedSidebarSessionProject | null;
export function useOrderedAgentProjectShape(input: {
@@ -62,17 +58,9 @@ export function useOrderedAgentProjectShape(input: {
const previous = previousById.get(id);
const next =
- previous &&
- previous.projectKey === project.projectKey &&
- previous.projectIconKey === project.projectIconKey &&
- previous.projectName === project.projectName
+ previous && previous.projectKey === project.projectKey
? previous
- : {
- id,
- projectKey: project.projectKey,
- projectIconKey: project.projectIconKey,
- projectName: project.projectName,
- };
+ : { id, projectKey: project.projectKey };
nextById.set(id, next);
agentsWithProjects.push(next);
@@ -86,62 +74,28 @@ export function useOrderedAgentProjectShape(input: {
return useStoreWithEqualityFn(useSessionStore, selector, shallow);
}
-export function useGroupedSidebarSessionListData(input: {
+export function useGroupedSidebarSessionGroups(input: {
agentsWithProjects: readonly SidebarSessionAgentProject[];
+ projects: readonly SidebarProjectEntry[];
previewExpandedProjects: ReadonlySet;
collapsedProjectKeys: ReadonlySet;
- serverId: string | null;
-}): readonly SidebarSessionListItem[] {
- const groupedSessions = useMemo(
+}): readonly SidebarSessionGroup[] {
+ return useMemo(
() =>
deriveGroupedSidebarSessions({
agentsWithProjects: input.agentsWithProjects,
+ projects: input.projects,
previewExpandedProjects: input.previewExpandedProjects,
collapsedProjectKeys: input.collapsedProjectKeys,
limit: GROUPED_SESSION_LIMIT,
}),
- [input.agentsWithProjects, input.collapsedProjectKeys, input.previewExpandedProjects],
+ [
+ input.agentsWithProjects,
+ input.collapsedProjectKeys,
+ input.previewExpandedProjects,
+ input.projects,
+ ],
);
-
- return useMemo(
- () => flattenGroupedSidebarSessions(groupedSessions, input.serverId),
- [groupedSessions, input.serverId],
- );
-}
-
-export function flattenGroupedSidebarSessions(
- groupedSessions: readonly SidebarSessionGroup[],
- serverId: string | null,
-): readonly SidebarSessionListItem[] {
- if (!serverId) {
- return [];
- }
-
- const items: SidebarSessionListItem[] = [];
- for (const group of groupedSessions) {
- items.push({
- kind: "header",
- projectKey: group.projectKey,
- projectName: group.projectName,
- projectIconKey: group.projectIconKey,
- isCollapsed: group.isCollapsed,
- });
- if (group.isCollapsed) {
- continue;
- }
- for (const id of group.visibleIds) {
- items.push({ kind: "row", id, serverId });
- }
- if (group.totalCount > GROUPED_SESSION_LIMIT) {
- items.push({
- kind: "footer",
- projectKey: group.projectKey,
- hiddenCount: group.hiddenCount,
- isExpanded: group.isExpanded,
- });
- }
- }
- return items;
}
export const SidebarSessionGroupHeader = memo(function SidebarSessionGroupHeader({
@@ -149,14 +103,22 @@ export const SidebarSessionGroupHeader = memo(function SidebarSessionGroupHeader
projectKey,
projectName,
projectIconKey,
+ workspaces,
isCollapsed,
+ isDragging = false,
+ drag,
+ dragHandleProps,
onToggleCollapsed,
}: {
serverId: string | null;
projectKey: string;
projectName: string;
projectIconKey: string | null;
+ workspaces: readonly SidebarWorkspaceEntry[];
isCollapsed: boolean;
+ isDragging?: boolean;
+ drag?: () => void;
+ dragHandleProps?: DraggableListDragHandleProps;
onToggleCollapsed: (projectKey: string) => void;
}): ReactElement {
const { icon } = useProjectIconQuery({ serverId: serverId ?? "", cwd: projectIconKey ?? "" });
@@ -170,18 +132,49 @@ export const SidebarSessionGroupHeader = memo(function SidebarSessionGroupHeader
const [isHovered, setIsHovered] = useState(false);
const handlePointerEnter = useCallback(() => setIsHovered(true), []);
const handlePointerLeave = useCallback(() => setIsHovered(false), []);
+ const interaction = useLongPressDragInteraction({
+ drag: drag ?? noopDrag,
+ menuController: null,
+ });
+ const trailingSlot = useMemo(
+ () => (
+
+ ),
+ [isHovered, projectKey, projectName, serverId, workspaces],
+ );
const handlePress = useCallback(() => {
+ if (interaction.didLongPressRef.current) {
+ interaction.didLongPressRef.current = false;
+ return;
+ }
onToggleCollapsed(projectKey);
- }, [onToggleCollapsed, projectKey]);
+ }, [interaction.didLongPressRef, onToggleCollapsed, projectKey]);
return (
-
+ }
+ onPointerEnter={handlePointerEnter}
+ onPointerLeave={handlePointerLeave}
+ >
@@ -189,6 +182,8 @@ export const SidebarSessionGroupHeader = memo(function SidebarSessionGroupHeader
);
});
+function noopDrag() {}
+
export const SidebarSessionGroupFooter = memo(function SidebarSessionGroupFooter({
projectKey,
hiddenCount,
diff --git a/packages/app/src/sidebar-sessions/render-boundaries.test.tsx b/packages/app/src/sidebar-sessions/render-boundaries.test.tsx
index 6b28747ce5..3346fe9afa 100644
--- a/packages/app/src/sidebar-sessions/render-boundaries.test.tsx
+++ b/packages/app/src/sidebar-sessions/render-boundaries.test.tsx
@@ -4,18 +4,53 @@
import { act, fireEvent, render, renderHook } from "@testing-library/react";
import React from "react";
import { useCallback, useMemo } from "react";
+import { Pressable, View } from "react-native";
import { afterEach, describe, expect, it, vi } from "vitest";
import { shallow } from "zustand/shallow";
import { useStoreWithEqualityFn } from "zustand/traditional";
import type { DaemonClient } from "@server/client/daemon-client";
+import type { SidebarProjectEntry } from "@/hooks/use-sidebar-workspaces-list";
import { useSessionStore, type Agent } from "@/stores/session-store";
import {
SidebarSessionGroupFooter,
- useGroupedSidebarSessionListData,
+ SidebarSessionGroupHeader,
+ useGroupedSidebarSessionGroups,
useOrderedAgentProjectShape,
} from "./grouped-view";
import { selectSidebarSessionSlice } from "./select-sidebar-session-slice";
+function gitProject(key: string): SidebarProjectEntry {
+ return {
+ projectKey: key,
+ projectName: `Project ${key}`,
+ projectKind: "git",
+ iconWorkingDir: `/repo/${key}`,
+ workspaces: [
+ {
+ workspaceKey: `server-1:${key}-ws`,
+ serverId: "server-1",
+ workspaceId: `${key}-ws`,
+ projectKey: key,
+ projectRootPath: `/repo/${key}`,
+ workspaceDirectory: `/repo/${key}/ws`,
+ projectKind: "git",
+ workspaceKind: "checkout",
+ name: `${key}-ws`,
+ statusBucket: "done",
+ archivingAt: null,
+ diffStat: null,
+ scripts: [],
+ hasRunningScripts: false,
+ },
+ ],
+ };
+}
+
+const HARNESS_PROJECTS: readonly SidebarProjectEntry[] = [
+ gitProject("project-a"),
+ gitProject("project-b"),
+];
+
vi.hoisted(() => {
Object.assign(globalThis, { __DEV__: false });
});
@@ -30,9 +65,64 @@ vi.mock("lucide-react-native", () => {
return {
ChevronDown: createIcon("ChevronDown"),
ChevronRight: createIcon("ChevronRight"),
+ FolderPlus: createIcon("FolderPlus"),
+ MoreVertical: createIcon("MoreVertical"),
+ Settings: createIcon("Settings"),
+ Trash2: createIcon("Trash2"),
};
});
+vi.mock("expo-router", () => ({
+ router: {
+ navigate: vi.fn(),
+ },
+}));
+
+vi.mock("@/components/ui/dropdown-menu", () => ({
+ DropdownMenu: ({ children }: { children: React.ReactNode }) => {children},
+ DropdownMenuContent: ({ children }: { children: React.ReactNode }) => {children},
+ DropdownMenuItem: ({ children, testID }: { children: React.ReactNode; testID?: string }) => (
+ {children}
+ ),
+ DropdownMenuTrigger: ({
+ children,
+ testID,
+ }: {
+ children: React.ReactNode | ((state: { hovered: boolean }) => React.ReactNode);
+ testID?: string;
+ }) => (
+
+ {typeof children === "function" ? children({ hovered: false }) : children}
+
+ ),
+}));
+
+vi.mock("@/components/ui/tooltip", () => ({
+ Tooltip: ({ children }: { children: React.ReactNode }) => {children},
+ TooltipContent: ({ children }: { children: React.ReactNode }) => {children},
+ TooltipTrigger: ({ children }: { children: React.ReactNode }) => children,
+}));
+
+vi.mock("@/components/ui/shortcut", () => ({
+ Shortcut: () => ,
+}));
+
+vi.mock("@/constants/platform", () => ({
+ isNative: false,
+}));
+
+vi.mock("@/constants/layout", () => ({
+ useIsCompactFormFactor: () => false,
+}));
+
+vi.mock("@/contexts/toast-context", () => ({
+ useToast: () => ({ error: vi.fn() }),
+}));
+
+vi.mock("@/hooks/use-shortcut-keys", () => ({
+ useShortcutKeys: () => null,
+}));
+
const TIMESTAMP = new Date("2026-05-08T10:00:00.000Z");
const SERVER_ID = "server-1";
@@ -41,7 +131,7 @@ vi.mock("react-native-unistyles", () => ({
create: (styles: unknown) =>
typeof styles === "function"
? styles({
- borderRadius: { lg: 8, sm: 4 },
+ borderRadius: { lg: 8, md: 6, sm: 4 },
colors: {
border: "#dddddd",
foreground: "#111111",
@@ -56,6 +146,10 @@ vi.mock("react-native-unistyles", () => ({
})
: styles,
},
+ withUnistyles: (Component: React.ComponentType>) =>
+ function ThemedComponent(props: Record) {
+ return ;
+ },
}));
const AGENT_DEFAULTS: Agent = {
@@ -131,11 +225,11 @@ function useGroupedBoundaryHarness(input?: { collapsedProjectKeys?: ReadonlySet<
const fallbackCollapsed = useMemo(() => new Set(), []);
const collapsedProjectKeys = input?.collapsedProjectKeys ?? fallbackCollapsed;
- return useGroupedSidebarSessionListData({
+ return useGroupedSidebarSessionGroups({
agentsWithProjects,
+ projects: HARNESS_PROJECTS,
previewExpandedProjects,
collapsedProjectKeys,
- serverId: SERVER_ID,
});
}
@@ -192,11 +286,11 @@ describe("sidebar session render boundaries", () => {
expect(shallow(beforeSlice, afterSlice)).toBe(true);
});
- it("keeps grouped flattened data stable when activity changes without reordering ids", () => {
+ it("keeps grouped section data stable when activity changes without reordering ids", () => {
seedAgents([makeAgent({ id: "agent-1" }), makeAgent({ id: "agent-2", cwd: "/repo/main-2" })]);
const { result } = renderHook(() => useGroupedBoundaryHarness());
- const beforeData = result.current;
+ const beforeGroups = result.current;
act(() => {
useSessionStore.getState().setAgents(
@@ -214,14 +308,14 @@ describe("sidebar session render boundaries", () => {
);
});
- expect(result.current).toBe(beforeData);
+ expect(result.current).toBe(beforeGroups);
});
- it("changes grouped flattened data when cwd resolves to a different project", () => {
+ it("changes grouped section data when cwd resolves to a different project", () => {
seedAgents([makeAgent({ id: "agent-1", cwd: "/repo/a" })]);
const { result } = renderHook(() => useGroupedBoundaryHarness());
- const beforeData = result.current;
+ const beforeGroups = result.current;
act(() => {
useSessionStore
@@ -229,15 +323,18 @@ describe("sidebar session render boundaries", () => {
.setAgents(SERVER_ID, new Map([["agent-1", makeAgent({ id: "agent-1", cwd: "/repo/b" })]]));
});
- expect(result.current).not.toBe(beforeData);
- expect(result.current[0]).toMatchObject({ kind: "header", projectKey: "project-b" });
+ expect(result.current).not.toBe(beforeGroups);
+ expect(result.current[0]).toMatchObject({
+ projectKey: "project-b",
+ visibleIds: ["agent-1"],
+ });
});
- it("keeps grouped flattened data stable when cwd changes but resolves to the same project", () => {
+ it("keeps grouped section data stable when cwd changes but resolves to the same project", () => {
seedAgents([makeAgent({ id: "agent-1", cwd: "/repo/a" })]);
const { result } = renderHook(() => useGroupedBoundaryHarness());
- const beforeData = result.current;
+ const beforeGroups = result.current;
act(() => {
useSessionStore
@@ -248,10 +345,10 @@ describe("sidebar session render boundaries", () => {
);
});
- expect(result.current).toBe(beforeData);
+ expect(result.current).toBe(beforeGroups);
});
- it("emits header only (no rows or footer) for a collapsed project", () => {
+ it("emits a collapsed group with no visible ids and zero hidden count", () => {
const overLimit = Array.from({ length: 10 }, (_, index) =>
makeAgent({ id: `agent-${index + 1}`, cwd: "/repo/a" }),
);
@@ -263,9 +360,11 @@ describe("sidebar session render boundaries", () => {
expect(result.current).toEqual([
expect.objectContaining({
- kind: "header",
projectKey: "project-a",
isCollapsed: true,
+ visibleIds: [],
+ hiddenCount: 0,
+ totalCount: 10,
}),
]);
});
@@ -286,4 +385,21 @@ describe("sidebar session render boundaries", () => {
expect(onPress).toHaveBeenCalledWith("project-a");
});
+
+ it("renders the project kebab in the grouped header", () => {
+ const { getByTestId, queryByTestId } = render(
+ ,
+ );
+
+ expect(getByTestId("sidebar-project-kebab-project-a")).toBeTruthy();
+ expect(queryByTestId("sidebar-project-new-worktree-project-a")).toBeNull();
+ });
});
diff --git a/packages/app/src/sidebar-sessions/session-filtering.test.ts b/packages/app/src/sidebar-sessions/session-filtering.test.ts
index bfd1080cd6..e5c7409eb4 100644
--- a/packages/app/src/sidebar-sessions/session-filtering.test.ts
+++ b/packages/app/src/sidebar-sessions/session-filtering.test.ts
@@ -8,11 +8,10 @@ import {
} from "./session-filtering";
import type { SidebarSessionAgent, SidebarSessionWorkspace } from "./types";
-interface TestProject {
- projectKey: string;
- projectName: string;
- projectIconKey: string | null;
-}
+import type { SidebarProjectEntry } from "@/hooks/use-sidebar-workspaces-list";
+import type { WorkspaceDescriptor } from "@/stores/session-store";
+
+type ProjectKind = WorkspaceDescriptor["projectKind"];
const WORKSPACES: SidebarSessionWorkspace[] = [
{
@@ -71,19 +70,41 @@ function projectWorkspaceKeys(project: {
};
}
-function testProject(key: string, name = key): TestProject {
+function testProject(
+ key: string,
+ options: { name?: string; projectKind?: ProjectKind; workspaceCount?: number } = {},
+): SidebarProjectEntry {
+ const projectKind: ProjectKind = options.projectKind ?? "git";
+ const workspaceCount = options.workspaceCount ?? (projectKind === "git" ? 2 : 1);
return {
projectKey: key,
- projectName: name,
- projectIconKey: `/repo/${key}`,
+ projectName: options.name ?? key,
+ projectKind,
+ iconWorkingDir: `/repo/${key}`,
+ workspaces: Array.from({ length: workspaceCount }, (_, index) => ({
+ workspaceKey: `server-1:${key}-${index}`,
+ serverId: "server-1",
+ workspaceId: `${key}-${index}`,
+ projectKey: key,
+ projectRootPath: `/repo/${key}`,
+ workspaceDirectory: `/repo/${key}/${index}`,
+ projectKind,
+ workspaceKind: "checkout",
+ name: `${key}-${index}`,
+ statusBucket: "done",
+ archivingAt: null,
+ diffStat: null,
+ scripts: [],
+ hasRunningScripts: false,
+ })),
};
}
-function agentProject(id: string, project: TestProject) {
- return { id, ...project };
+function agentProject(id: string, project: SidebarProjectEntry) {
+ return { id, projectKey: project.projectKey };
}
-function agentProjects(ids: readonly string[], project: TestProject) {
+function agentProjects(ids: readonly string[], project: SidebarProjectEntry) {
return ids.map((id) => agentProject(id, project));
}
@@ -219,14 +240,17 @@ describe("deriveGroupedSidebarSessions", () => {
expect(
deriveGroupedSidebarSessions({
agentsWithProjects: [],
+ projects: [],
previewExpandedProjects: new Set(),
}),
).toEqual([]);
});
it("keeps a single project under the limit fully visible and collapsed", () => {
+ const projectA = testProject("project-a");
const groups = deriveGroupedSidebarSessions({
- agentsWithProjects: agentProjects(["a1", "a2", "a3"], testProject("project-a")),
+ agentsWithProjects: agentProjects(["a1", "a2", "a3"], projectA),
+ projects: [projectA],
previewExpandedProjects: new Set(),
limit: 6,
});
@@ -240,6 +264,7 @@ describe("deriveGroupedSidebarSessions", () => {
hiddenCount: 0,
isExpanded: false,
isCollapsed: false,
+ isFlattened: false,
totalCount: 3,
},
]);
@@ -250,6 +275,7 @@ describe("deriveGroupedSidebarSessions", () => {
const projectA = testProject("project-a");
const groups = deriveGroupedSidebarSessions({
agentsWithProjects: agentProjects(orderedIds, projectA),
+ projects: [projectA],
previewExpandedProjects: new Set(),
limit: 6,
});
@@ -268,6 +294,7 @@ describe("deriveGroupedSidebarSessions", () => {
const projectA = testProject("project-a");
const groups = deriveGroupedSidebarSessions({
agentsWithProjects: agentProjects(orderedIds, projectA),
+ projects: [projectA],
previewExpandedProjects: new Set(["project-a"]),
limit: 6,
});
@@ -283,8 +310,10 @@ describe("deriveGroupedSidebarSessions", () => {
it("hides all rows when a project is collapsed", () => {
const orderedIds = Array.from({ length: 10 }, (_, index) => `a${index + 1}`);
+ const projectA = testProject("project-a");
const groups = deriveGroupedSidebarSessions({
- agentsWithProjects: agentProjects(orderedIds, testProject("project-a")),
+ agentsWithProjects: agentProjects(orderedIds, projectA),
+ projects: [projectA],
previewExpandedProjects: new Set(),
collapsedProjectKeys: new Set(["project-a"]),
limit: 6,
@@ -301,8 +330,10 @@ describe("deriveGroupedSidebarSessions", () => {
it("collapsed wins over preview-expanded", () => {
const orderedIds = Array.from({ length: 10 }, (_, index) => `a${index + 1}`);
+ const projectA = testProject("project-a");
const groups = deriveGroupedSidebarSessions({
- agentsWithProjects: agentProjects(orderedIds, testProject("project-a")),
+ agentsWithProjects: agentProjects(orderedIds, projectA),
+ projects: [projectA],
previewExpandedProjects: new Set(["project-a"]),
collapsedProjectKeys: new Set(["project-a"]),
limit: 6,
@@ -315,42 +346,93 @@ describe("deriveGroupedSidebarSessions", () => {
});
});
- it("orders projects by first occurrence in ordered ids", () => {
+ it("orders projects by the canonical project list, ignoring agent first-occurrence", () => {
+ const projectA = testProject("project-a");
+ const projectB = testProject("project-b");
const groups = deriveGroupedSidebarSessions({
agentsWithProjects: [
- agentProject("a1", testProject("project-a")),
- agentProject("b1", testProject("project-b")),
- agentProject("a2", testProject("project-a")),
+ agentProject("b1", projectB),
+ agentProject("a1", projectA),
+ agentProject("b2", projectB),
+ agentProject("a2", projectA),
],
+ projects: [projectA, projectB],
previewExpandedProjects: new Set(),
});
- expect(groups.map((group) => group.projectKey)).toEqual(["project-a", "project-b"]);
+ expect(groups.map((group) => ({ key: group.projectKey, ids: group.visibleIds }))).toEqual([
+ { key: "project-a", ids: ["a1", "a2"] },
+ { key: "project-b", ids: ["b1", "b2"] },
+ ]);
});
it("preserves order within each project", () => {
+ const projectA = testProject("project-a");
const groups = deriveGroupedSidebarSessions({
- agentsWithProjects: agentProjects(["a1", "a2", "a3"], testProject("project-a")),
+ agentsWithProjects: agentProjects(["a1", "a2", "a3"], projectA),
+ projects: [projectA],
previewExpandedProjects: new Set(),
});
expect(groups[0]?.visibleIds).toEqual(["a1", "a2", "a3"]);
});
- it("preserves mapped project order after unmapped ids are filtered upstream", () => {
+ it("skips projects that have no agents", () => {
+ const projectA = testProject("project-a");
+ const projectB = testProject("project-b");
const groups = deriveGroupedSidebarSessions({
- agentsWithProjects: [
- agentProject("a1", testProject("project-a")),
- agentProject("b1", testProject("project-b")),
- agentProject("a2", testProject("project-a")),
- ],
+ agentsWithProjects: agentProjects(["a1", "a2"], projectA),
+ projects: [projectA, projectB],
previewExpandedProjects: new Set(),
});
- expect(groups.map((group) => ({ key: group.projectKey, ids: group.visibleIds }))).toEqual([
- { key: "project-a", ids: ["a1", "a2"] },
- { key: "project-b", ids: ["b1"] },
- ]);
+ expect(groups.map((group) => group.projectKey)).toEqual(["project-a"]);
+ });
+
+ it("flattens a single non-git workspace project (no header, ignores collapse)", () => {
+ const projectA = testProject("project-a", { projectKind: "directory", workspaceCount: 1 });
+ const groups = deriveGroupedSidebarSessions({
+ agentsWithProjects: agentProjects(["a1", "a2"], projectA),
+ projects: [projectA],
+ previewExpandedProjects: new Set(),
+ collapsedProjectKeys: new Set(["project-a"]),
+ });
+
+ expect(groups[0]).toMatchObject({
+ isFlattened: true,
+ isCollapsed: false,
+ visibleIds: ["a1", "a2"],
+ });
+ });
+
+ it("keeps multi-workspace non-git projects collapsible", () => {
+ const projectA = testProject("project-a", { projectKind: "directory", workspaceCount: 2 });
+ const groups = deriveGroupedSidebarSessions({
+ agentsWithProjects: agentProjects(["a1", "a2"], projectA),
+ projects: [projectA],
+ previewExpandedProjects: new Set(),
+ collapsedProjectKeys: new Set(["project-a"]),
+ });
+
+ expect(groups[0]).toMatchObject({
+ isFlattened: false,
+ isCollapsed: true,
+ });
+ });
+
+ it("keeps single-workspace git projects collapsible (not flattened)", () => {
+ const projectA = testProject("project-a", { projectKind: "git", workspaceCount: 1 });
+ const groups = deriveGroupedSidebarSessions({
+ agentsWithProjects: agentProjects(["a1", "a2"], projectA),
+ projects: [projectA],
+ previewExpandedProjects: new Set(),
+ collapsedProjectKeys: new Set(["project-a"]),
+ });
+
+ expect(groups[0]).toMatchObject({
+ isFlattened: false,
+ isCollapsed: true,
+ });
});
it("defaults the collapsed limit to 6", () => {
@@ -358,6 +440,7 @@ describe("deriveGroupedSidebarSessions", () => {
const projectA = testProject("project-a");
const groups = deriveGroupedSidebarSessions({
agentsWithProjects: agentProjects(orderedIds, projectA),
+ projects: [projectA],
previewExpandedProjects: new Set(),
});
@@ -366,8 +449,10 @@ describe("deriveGroupedSidebarSessions", () => {
});
it("honors a custom collapsed limit", () => {
+ const projectA = testProject("project-a");
const groups = deriveGroupedSidebarSessions({
- agentsWithProjects: agentProjects(["a1", "a2", "a3", "a4"], testProject("project-a")),
+ agentsWithProjects: agentProjects(["a1", "a2", "a3", "a4"], projectA),
+ projects: [projectA],
previewExpandedProjects: new Set(),
limit: 2,
});
diff --git a/packages/app/src/sidebar-sessions/session-filtering.ts b/packages/app/src/sidebar-sessions/session-filtering.ts
index c1c7f53691..f1272118b0 100644
--- a/packages/app/src/sidebar-sessions/session-filtering.ts
+++ b/packages/app/src/sidebar-sessions/session-filtering.ts
@@ -1,6 +1,7 @@
import type { SidebarSessionAgent, SidebarSessionFilter, SidebarSessionWorkspace } from "./types";
import type { SidebarProjectEntry } from "@/hooks/use-sidebar-workspaces-list";
import type { WorkspaceDescriptor } from "@/stores/session-store";
+import { isSidebarProjectFlattened } from "@/utils/sidebar-project-row-model";
import { normalizeWorkspacePath } from "@/utils/workspace-identity";
export interface SidebarSessionWorkspaceLookup {
@@ -19,21 +20,13 @@ export interface SidebarSessionGroup {
hiddenCount: number;
isExpanded: boolean;
isCollapsed: boolean;
+ isFlattened: boolean;
totalCount: number;
}
export interface SidebarSessionAgentProject {
id: string;
projectKey: string;
- projectName: string;
- projectIconKey: string | null;
-}
-
-interface SidebarSessionGroupDraft {
- projectKey: string;
- projectName: string;
- projectIconKey: string | null;
- ids: string[];
}
const DEFAULT_GROUPED_SESSION_LIMIT = 6;
@@ -149,6 +142,7 @@ export function deriveSidebarSessionFilterProjects(input: {
export function deriveGroupedSidebarSessions(input: {
agentsWithProjects: readonly SidebarSessionAgentProject[];
+ projects: readonly SidebarProjectEntry[];
previewExpandedProjects?: ReadonlySet;
collapsedProjectKeys?: ReadonlySet;
limit?: number;
@@ -156,36 +150,39 @@ export function deriveGroupedSidebarSessions(input: {
const limit = input.limit ?? DEFAULT_GROUPED_SESSION_LIMIT;
const previewExpandedProjects = input.previewExpandedProjects ?? EMPTY_KEY_SET;
const collapsedProjectKeys = input.collapsedProjectKeys ?? EMPTY_KEY_SET;
- const groupsByProject = new Map();
+ const idsByProject = new Map();
for (const agent of input.agentsWithProjects) {
- let group = groupsByProject.get(agent.projectKey);
- if (!group) {
- group = {
- projectKey: agent.projectKey,
- projectName: agent.projectName,
- projectIconKey: agent.projectIconKey,
- ids: [],
- };
- groupsByProject.set(agent.projectKey, group);
+ let ids = idsByProject.get(agent.projectKey);
+ if (!ids) {
+ ids = [];
+ idsByProject.set(agent.projectKey, ids);
}
- group.ids.push(agent.id);
+ ids.push(agent.id);
}
- return Array.from(groupsByProject.values(), (group) => {
- const isCollapsed = collapsedProjectKeys.has(group.projectKey);
- const isExpanded = !isCollapsed && previewExpandedProjects.has(group.projectKey);
- return {
- projectKey: group.projectKey,
- projectName: group.projectName,
- projectIconKey: group.projectIconKey,
- visibleIds: visibleIdsFor({ ids: group.ids, isCollapsed, isExpanded, limit }),
- hiddenCount: hiddenCountFor({ totalCount: group.ids.length, isCollapsed, isExpanded, limit }),
+ const groups: SidebarSessionGroup[] = [];
+ for (const project of input.projects) {
+ const ids = idsByProject.get(project.projectKey);
+ if (!ids || ids.length === 0) {
+ continue;
+ }
+ const isFlattened = isSidebarProjectFlattened(project);
+ const isCollapsed = !isFlattened && collapsedProjectKeys.has(project.projectKey);
+ const isExpanded = !isCollapsed && previewExpandedProjects.has(project.projectKey);
+ groups.push({
+ projectKey: project.projectKey,
+ projectName: project.projectName,
+ projectIconKey: project.iconWorkingDir || null,
+ visibleIds: visibleIdsFor({ ids, isCollapsed, isExpanded, limit }),
+ hiddenCount: hiddenCountFor({ totalCount: ids.length, isCollapsed, isExpanded, limit }),
isExpanded,
isCollapsed,
- totalCount: group.ids.length,
- };
- });
+ isFlattened,
+ totalCount: ids.length,
+ });
+ }
+ return groups;
}
function visibleIdsFor(input: {
diff --git a/packages/app/src/sidebar-sessions/session-row-actions.test.tsx b/packages/app/src/sidebar-sessions/session-row-actions.test.tsx
new file mode 100644
index 0000000000..3ad7886398
--- /dev/null
+++ b/packages/app/src/sidebar-sessions/session-row-actions.test.tsx
@@ -0,0 +1,258 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { cleanup, fireEvent, render, waitFor } from "@testing-library/react";
+import type { DaemonClient } from "@server/client/daemon-client";
+import React from "react";
+import { Pressable, Text, View } from "react-native";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { useSessionStore, type Agent, type WorkspaceDescriptor } from "@/stores/session-store";
+import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
+import { SidebarSessionRowKebabMenu } from "./session-row-actions";
+
+vi.hoisted(() => {
+ Object.assign(globalThis, { __DEV__: false });
+});
+
+const {
+ archiveAgentMock,
+ confirmDialogMock,
+ platformState,
+ closeTabMock,
+ hideAgentMock,
+ unpinAgentMock,
+} = vi.hoisted(() => ({
+ archiveAgentMock: vi.fn(),
+ confirmDialogMock: vi.fn(),
+ platformState: { isNative: false },
+ closeTabMock: vi.fn(),
+ hideAgentMock: vi.fn(),
+ unpinAgentMock: vi.fn(),
+}));
+
+vi.mock("@/hooks/use-archive-agent", () => ({
+ useArchiveAgent: () => ({
+ archiveAgent: archiveAgentMock,
+ isArchivingAgent: () => false,
+ }),
+}));
+
+vi.mock("@/utils/confirm-dialog", () => ({
+ confirmDialog: confirmDialogMock,
+}));
+
+vi.mock("@/constants/platform", () => ({
+ get isNative() {
+ return platformState.isNative;
+ },
+}));
+
+vi.mock("@/components/ui/dropdown-menu", () => ({
+ DropdownMenu: ({ children }: { children: React.ReactNode }) => {children},
+ DropdownMenuContent: ({ children }: { children: React.ReactNode }) => {children},
+ DropdownMenuItem: ({
+ children,
+ onSelect,
+ testID,
+ }: {
+ children: React.ReactNode;
+ onSelect?: () => void;
+ testID?: string;
+ }) => (
+
+ {children}
+
+ ),
+ DropdownMenuTrigger: ({
+ children,
+ testID,
+ }: {
+ children: React.ReactNode | ((state: { hovered: boolean }) => React.ReactNode);
+ testID?: string;
+ }) => (
+
+ {typeof children === "function" ? children({ hovered: false }) : children}
+
+ ),
+}));
+
+vi.mock("lucide-react-native", () => {
+ const createIcon = (name: string) =>
+ function Icon({ uniProps: _uniProps, ...props }: Record) {
+ return React.createElement("span", { ...props, "data-icon": name });
+ };
+ return {
+ Archive: createIcon("Archive"),
+ MoreVertical: createIcon("MoreVertical"),
+ X: createIcon("X"),
+ };
+});
+
+vi.mock("react-native-unistyles", () => ({
+ StyleSheet: {
+ create: (styles: unknown) =>
+ typeof styles === "function"
+ ? styles({
+ colors: {
+ foreground: "#111111",
+ foregroundMuted: "#666666",
+ surface2: "#eeeeee",
+ },
+ fontSize: { xs: 12 },
+ })
+ : styles,
+ },
+ withUnistyles: (Component: React.ComponentType>) =>
+ function ThemedComponent(props: Record) {
+ return ;
+ },
+}));
+
+const SERVER_ID = "server-1";
+const WORKSPACE_ID = "workspace-1";
+const AGENT_ID = "agent-1";
+const CWD = "/repo/project/workspace";
+const TIMESTAMP = new Date("2026-05-08T10:00:00.000Z");
+
+const AGENT_DEFAULTS: Agent = {
+ serverId: SERVER_ID,
+ id: AGENT_ID,
+ provider: "codex",
+ status: "idle",
+ createdAt: TIMESTAMP,
+ updatedAt: TIMESTAMP,
+ lastUserMessageAt: null,
+ lastActivityAt: TIMESTAMP,
+ capabilities: {
+ supportsStreaming: true,
+ supportsSessionPersistence: true,
+ supportsDynamicModes: true,
+ supportsMcpServers: true,
+ supportsReasoningStream: true,
+ supportsToolInvocations: true,
+ },
+ currentModeId: null,
+ availableModes: [],
+ pendingPermissions: [],
+ persistence: null,
+ runtimeInfo: undefined,
+ lastUsage: undefined,
+ lastError: null,
+ title: "Menu agent",
+ cwd: CWD,
+ model: null,
+ thinkingOptionId: undefined,
+ requiresAttention: false,
+ attentionReason: null,
+ attentionTimestamp: null,
+ archivedAt: null,
+ labels: {},
+ projectPlacement: null,
+};
+
+function makeAgent(input: Partial = {}): Agent {
+ return { ...AGENT_DEFAULTS, ...input };
+}
+
+function workspace(input: Partial = {}): WorkspaceDescriptor {
+ return {
+ id: WORKSPACE_ID,
+ projectId: "project-1",
+ projectDisplayName: "Project",
+ projectRootPath: "/repo/project",
+ workspaceDirectory: CWD,
+ projectKind: "git",
+ workspaceKind: "worktree",
+ name: "workspace",
+ status: "done",
+ archivingAt: null,
+ diffStat: null,
+ scripts: [],
+ ...input,
+ };
+}
+
+function seedState(agent: Agent = makeAgent()) {
+ useSessionStore.getState().initializeSession(SERVER_ID, {} as unknown as DaemonClient);
+ useSessionStore.getState().setAgents(SERVER_ID, new Map([[agent.id, agent]]));
+ useSessionStore.getState().setWorkspaces(SERVER_ID, new Map([[WORKSPACE_ID, workspace()]]));
+}
+
+beforeEach(() => {
+ archiveAgentMock.mockReset();
+ archiveAgentMock.mockResolvedValue(undefined);
+ confirmDialogMock.mockReset();
+ confirmDialogMock.mockResolvedValue(true);
+ closeTabMock.mockReset();
+ hideAgentMock.mockReset();
+ unpinAgentMock.mockReset();
+ platformState.isNative = false;
+ useWorkspaceLayoutStore.setState({
+ closeTab: closeTabMock,
+ hideAgent: hideAgentMock,
+ unpinAgent: unpinAgentMock,
+ });
+});
+
+afterEach(() => {
+ cleanup();
+ useSessionStore.setState({ sessions: {}, agentLastActivity: new Map() });
+});
+
+describe("SidebarSessionRowKebabMenu", () => {
+ it("renders when hovered and hides on non-touch idle rows", () => {
+ const hidden = render(
+ ,
+ );
+ expect(hidden.queryByTestId(`sidebar-session-kebab-${SERVER_ID}-${AGENT_ID}`)).toBeNull();
+ hidden.unmount();
+
+ const visible = render(
+ ,
+ );
+ expect(visible.getByTestId(`sidebar-session-kebab-${SERVER_ID}-${AGENT_ID}`)).toBeTruthy();
+ });
+
+ it("renders on touch platforms without hover", () => {
+ platformState.isNative = true;
+
+ const { getByTestId } = render(
+ ,
+ );
+
+ expect(getByTestId(`sidebar-session-kebab-${SERVER_ID}-${AGENT_ID}`)).toBeTruthy();
+ });
+
+ it("archives the agent from the archive item", () => {
+ const { getByTestId } = render(
+ ,
+ );
+
+ fireEvent.click(getByTestId(`sidebar-session-menu-${SERVER_ID}-${AGENT_ID}-archive`));
+
+ expect(archiveAgentMock).toHaveBeenCalledWith({ serverId: SERVER_ID, agentId: AGENT_ID });
+ });
+
+ it("uses the existing close semantics for the close item", async () => {
+ seedState(makeAgent({ status: "running" }));
+
+ const { getByTestId } = render(
+ ,
+ );
+
+ fireEvent.click(getByTestId(`sidebar-session-menu-${SERVER_ID}-${AGENT_ID}-close`));
+
+ await waitFor(() => {
+ expect(archiveAgentMock).toHaveBeenCalledWith({ serverId: SERVER_ID, agentId: AGENT_ID });
+ });
+ expect(confirmDialogMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ title: "Archive running agent?",
+ confirmLabel: "Archive",
+ }),
+ );
+ expect(unpinAgentMock).toHaveBeenCalledWith(`${SERVER_ID}:${WORKSPACE_ID}`, AGENT_ID);
+ expect(hideAgentMock).toHaveBeenCalledWith(`${SERVER_ID}:${WORKSPACE_ID}`, AGENT_ID);
+ expect(closeTabMock).toHaveBeenCalledWith(`${SERVER_ID}:${WORKSPACE_ID}`, `agent_${AGENT_ID}`);
+ });
+});
diff --git a/packages/app/src/sidebar-sessions/session-row-actions.tsx b/packages/app/src/sidebar-sessions/session-row-actions.tsx
new file mode 100644
index 0000000000..9c243504b2
--- /dev/null
+++ b/packages/app/src/sidebar-sessions/session-row-actions.tsx
@@ -0,0 +1,227 @@
+import React, { memo, useCallback, useMemo, type ReactElement } from "react";
+import { Text, type PressableStateCallbackType } from "react-native";
+import { Archive, MoreVertical, X } from "lucide-react-native";
+import { StyleSheet, withUnistyles } from "react-native-unistyles";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { isNative as isTouchPlatform } from "@/constants/platform";
+import { useArchiveAgent } from "@/hooks/use-archive-agent";
+import {
+ buildWorkspaceTabMenuEntries,
+ type WorkspaceTabMenuEntry,
+} from "@/screens/workspace/workspace-tab-menu";
+import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
+import { useSessionStore } from "@/stores/session-store";
+import {
+ buildWorkspaceTabPersistenceKey,
+ useWorkspaceLayoutStore,
+} from "@/stores/workspace-layout-store";
+import type { Theme } from "@/styles/theme";
+import { confirmDialog } from "@/utils/confirm-dialog";
+import { resolveSidebarSessionWorkspaceId } from "./session-filtering";
+
+export interface SidebarSessionRowKebabMenuProps {
+ serverId: string;
+ agentId: string;
+ isHovered: boolean;
+}
+
+const ThemedArchive = withUnistyles(Archive);
+const ThemedMoreVertical = withUnistyles(MoreVertical);
+const ThemedX = withUnistyles(X);
+
+const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
+const foregroundMutedColorMapping = (theme: Theme) => ({
+ color: theme.colors.foregroundMuted,
+});
+
+const archiveLeadingIcon = ;
+
+function kebabButtonStyle({ hovered = false }: PressableStateCallbackType & { hovered?: boolean }) {
+ return [styles.kebabButton, hovered && styles.kebabButtonHovered];
+}
+
+function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }) {
+ return (
+
+ );
+}
+
+function noop() {}
+
+export const SidebarSessionRowKebabMenu = memo(function SidebarSessionRowKebabMenu({
+ serverId,
+ agentId,
+ isHovered,
+}: SidebarSessionRowKebabMenuProps): ReactElement | null {
+ const visible = isHovered || isTouchPlatform;
+ const { archiveAgent } = useArchiveAgent();
+ const closeWorkspaceTab = useWorkspaceLayoutStore((state) => state.closeTab);
+ const unpinWorkspaceAgent = useWorkspaceLayoutStore((state) => state.unpinAgent);
+ const hideWorkspaceAgent = useWorkspaceLayoutStore((state) => state.hideAgent);
+
+ const tab = useMemo(
+ () => ({
+ key: `agent_${agentId}`,
+ tabId: `agent_${agentId}`,
+ kind: "agent",
+ target: { kind: "agent", agentId },
+ }),
+ [agentId],
+ );
+
+ const handleArchiveAgent = useCallback(() => {
+ void archiveAgent({ serverId, agentId }).catch(() => {});
+ }, [agentId, archiveAgent, serverId]);
+
+ const handleCloseAgentTab = useCallback(async () => {
+ const agent = useSessionStore.getState().sessions[serverId]?.agents?.get(agentId) ?? null;
+ const isRunning = agent?.status === "running" || agent?.status === "initializing";
+
+ if (isRunning) {
+ const confirmed = await confirmDialog({
+ title: "Archive running agent?",
+ message: "This agent is still running. Archiving it will stop the agent and close the tab.",
+ confirmLabel: "Archive",
+ cancelLabel: "Cancel",
+ destructive: true,
+ });
+ if (!confirmed) {
+ return;
+ }
+ }
+
+ const workspaceId = agent?.cwd
+ ? resolveSidebarSessionWorkspaceId({
+ agent: {
+ id: agentId,
+ serverId,
+ cwd: agent.cwd,
+ archivedAt: null,
+ },
+ workspaces: useSessionStore.getState().sessions[serverId]?.workspaces?.values(),
+ })
+ : null;
+ const persistenceKey = workspaceId
+ ? buildWorkspaceTabPersistenceKey({ serverId, workspaceId })
+ : null;
+
+ if (persistenceKey) {
+ unpinWorkspaceAgent(persistenceKey, agentId);
+ hideWorkspaceAgent(persistenceKey, agentId);
+ closeWorkspaceTab(persistenceKey, tab.tabId);
+ }
+
+ void archiveAgent({ serverId, agentId }).catch(() => {});
+ }, [
+ agentId,
+ archiveAgent,
+ closeWorkspaceTab,
+ hideWorkspaceAgent,
+ serverId,
+ tab.tabId,
+ unpinWorkspaceAgent,
+ ]);
+
+ const closeEntry = useMemo(
+ () =>
+ buildWorkspaceTabMenuEntries({
+ surface: "desktop",
+ tab,
+ index: 0,
+ tabCount: 1,
+ menuTestIDBase: `sidebar-session-menu-${serverId}-${agentId}`,
+ onCopyResumeCommand: noop,
+ onCopyAgentId: noop,
+ onReloadAgent: noop,
+ onCloseTab: handleCloseAgentTab,
+ onCloseTabsBefore: noop,
+ onCloseTabsAfter: noop,
+ onCloseOtherTabs: noop,
+ }).find((entry) => entry.kind === "item" && entry.key === "close") as
+ | Extract
+ | undefined,
+ [agentId, handleCloseAgentTab, serverId, tab],
+ );
+
+ if (!visible || !closeEntry) {
+ return null;
+ }
+
+ return (
+
+
+ {renderKebabTriggerIcon}
+
+
+
+
+ Archive
+
+
+
+ );
+});
+
+function SidebarSessionMenuItem({
+ entry,
+}: {
+ entry: Extract;
+}) {
+ const leading = useMemo(() => {
+ if (entry.icon === "x") {
+ return ;
+ }
+ return undefined;
+ }, [entry.icon]);
+ const trailing = useMemo(
+ () => (entry.hint ? {entry.hint} : undefined),
+ [entry.hint],
+ );
+
+ return (
+
+ {entry.label}
+
+ );
+}
+
+const styles = StyleSheet.create((theme) => ({
+ kebabButton: {
+ padding: 2,
+ borderRadius: 4,
+ marginLeft: 2,
+ },
+ kebabButtonHovered: {
+ backgroundColor: theme.colors.surface2,
+ },
+ menuItemHint: {
+ color: theme.colors.foregroundMuted,
+ fontSize: theme.fontSize.xs,
+ },
+}));
diff --git a/packages/app/src/sidebar-sessions/sidebar-session-row-navigation.test.tsx b/packages/app/src/sidebar-sessions/sidebar-session-row-navigation.test.tsx
index 473940a584..c1a0d8b2a5 100644
--- a/packages/app/src/sidebar-sessions/sidebar-session-row-navigation.test.tsx
+++ b/packages/app/src/sidebar-sessions/sidebar-session-row-navigation.test.tsx
@@ -70,12 +70,34 @@ vi.mock("@/components/provider-icons", async () => {
};
});
+vi.mock("./session-row-actions", () => ({
+ SidebarSessionRowKebabMenu: () => null,
+}));
+
+vi.mock("@/components/sidebar/sidebar-project-trailing-actions", () => ({
+ SidebarProjectTrailingActions: () => null,
+}));
+
+vi.mock("expo-router", () => ({
+ router: {
+ navigate: vi.fn(),
+ },
+}));
+
vi.mock("lucide-react-native", () => {
- const createIcon = (name: string) => (props: Record) =>
- React.createElement("span", { ...props, "data-icon": name });
+ const createIcon = (name: string) =>
+ function Icon({ uniProps: _uniProps, ...props }: Record) {
+ return React.createElement("span", { ...props, "data-icon": name });
+ };
return {
+ Archive: createIcon("Archive"),
ChevronDown: createIcon("ChevronDown"),
ChevronRight: createIcon("ChevronRight"),
+ FolderPlus: createIcon("FolderPlus"),
+ MoreVertical: createIcon("MoreVertical"),
+ Settings: createIcon("Settings"),
+ Trash2: createIcon("Trash2"),
+ X: createIcon("X"),
};
});
@@ -89,6 +111,15 @@ vi.mock("@/screens/workspace/workspace-tab-presentation", async () => {
};
});
+vi.mock("react-native-draggable-flatlist", async () => {
+ const ReactModule = await import("react");
+ return {
+ NestableScrollContainer: ({ children }: { children?: React.ReactNode }) =>
+ ReactModule.createElement("div", null, children),
+ default: () => null,
+ };
+});
+
vi.mock("react-native-reanimated", () => ({
default: {
View: "div",
@@ -104,7 +135,7 @@ vi.mock("react-native-reanimated", () => ({
vi.mock("react-native-unistyles", () => ({
StyleSheet: {
- create: (styles: unknown) => (typeof styles === "function" ? styles(testTheme) : styles),
+ create: (styles: (theme: unknown) => unknown) => styles(testTheme),
},
withUnistyles: (Component: unknown) => Component,
useUnistyles: () => ({
diff --git a/packages/app/src/sidebar-sessions/toggle.tsx b/packages/app/src/sidebar-sessions/toggle.tsx
index a46e326b08..c96863c58e 100644
--- a/packages/app/src/sidebar-sessions/toggle.tsx
+++ b/packages/app/src/sidebar-sessions/toggle.tsx
@@ -4,6 +4,7 @@ import {
LayoutList,
ListFilter,
MessagesSquare,
+ Plus,
type LucideIcon,
} from "lucide-react-native";
import { memo, useCallback, useMemo, type ReactElement } from "react";
@@ -17,6 +18,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
+import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
import { useProjectIconQuery } from "@/hooks/use-project-icon-query";
import type { SidebarProjectEntry } from "@/hooks/use-sidebar-workspaces-list";
import type { SidebarSessionFilter, SidebarSessionViewMode } from "./types";
@@ -105,11 +107,28 @@ function SidebarSessionsFilterMenu({
const { theme } = useUnistyles();
const filterActive = filter.type !== "all";
const visibleFilterProjects = useVisibleSidebarSessionFilterProjects({ serverId, projects });
+ const newSessionIntent = useMemo(
+ () => ({ kind: "new-workspace", headerTitle: "New session" }) as const,
+ [],
+ );
+ const openNewSessionPicker = useOpenProjectPicker(serverId, newSessionIntent);
const handleAllSelect = useCallback(() => {
onFilterChange({ type: "all" });
}, [onFilterChange]);
+ const handleNewSessionPress = useCallback(() => {
+ void openNewSessionPicker();
+ }, [openNewSessionPicker]);
+
+ const newSessionButtonStyle = useCallback(
+ ({ hovered = false, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
+ styles.filterButton,
+ (hovered || pressed) && styles.filterButtonHovered,
+ ],
+ [],
+ );
+
const filterTriggerStyle = useCallback(
({ hovered = false, open = false }: PressableStateCallbackType & { open?: boolean }) => [
styles.filterButton,
@@ -149,6 +168,20 @@ function SidebarSessionsFilterMenu({
return (
+
+ {({ hovered, pressed }) => (
+
+ )}
+
ResolvedSidebarSessionProject | null;
previewExpandedProjects: ReadonlySet;
collapsedProjectKeys: ReadonlySet;
@@ -172,64 +184,139 @@ const GroupedSidebarSessionsList = memo(function GroupedSidebarSessionsList({
serverId: serverId ?? "",
resolveCwdToProject,
});
- const data = useGroupedSidebarSessionListData({
+ const groups = useGroupedSidebarSessionGroups({
agentsWithProjects,
+ projects,
previewExpandedProjects,
collapsedProjectKeys,
- serverId,
});
- const renderItem: ListRenderItem = useCallback(
- ({ item }) => {
- switch (item.kind) {
- case "header":
- return (
-
- );
- case "row":
- return ;
- case "footer":
- return (
-
- );
- }
- },
- [onProjectCollapsedToggle, onProjectPreviewExpandedToggle, serverId],
- );
- const keyExtractor = useCallback((item: SidebarSessionListItem) => {
- switch (item.kind) {
- case "header":
- return `header:${item.projectKey}`;
- case "row":
- return `row:${item.id}`;
- case "footer":
- return `footer:${item.projectKey}`;
+ const groupsByKey = useMemo(() => {
+ const map = new Map();
+ for (const group of groups) {
+ map.set(group.projectKey, group);
}
- }, []);
+ return map;
+ }, [groups]);
- return (
- projects.filter((project) => groupsByKey.has(project.projectKey)),
+ [groupsByKey, projects],
+ );
+
+ const renderProject = useCallback(
+ ({ project, drag, isDragging, dragHandleProps }: SidebarProjectDragInfo) => {
+ const group = groupsByKey.get(project.projectKey);
+ if (!group) {
+ return ;
+ }
+ return (
+
+ );
+ },
+ [groupsByKey, onProjectCollapsedToggle, onProjectPreviewExpandedToggle, serverId],
+ );
+
+ if (projectsWithSessions.length === 0) {
+ return (
+
+
+
+
+
+ );
+ }
+
+ const draggableList = (
+
+ );
+
+ return platformIsNative ? (
+
+ testID="sidebar-session-group-scroll"
+ >
+ {draggableList}
+
+ ) : (
+
+ {draggableList}
+
+ );
+});
+
+const SidebarSessionGroupSection = memo(function SidebarSessionGroupSection({
+ serverId,
+ project,
+ group,
+ drag,
+ isDragging,
+ dragHandleProps,
+ onProjectCollapsedToggle,
+ onProjectPreviewExpandedToggle,
+}: {
+ serverId: string | null;
+ project: SidebarProjectEntry;
+ group: SidebarSessionGroup;
+ drag: () => void;
+ isDragging: boolean;
+ dragHandleProps?: SidebarProjectDragInfo["dragHandleProps"];
+ onProjectCollapsedToggle: (projectKey: string) => void;
+ onProjectPreviewExpandedToggle: (projectKey: string) => void;
+}): ReactElement {
+ const showFooter = !group.isCollapsed && group.totalCount > GROUPED_SESSION_LIMIT;
+ return (
+
+ {!group.isFlattened ? (
+
+ ) : null}
+ {serverId
+ ? group.visibleIds.map((id) => (
+
+ ))
+ : null}
+ {showFooter ? (
+
+ ) : null}
+
);
});
@@ -339,6 +426,7 @@ const SidebarSessionRow = memo(function SidebarSessionRow({
{formatTimeAgo(agent.lastActivityAt)}
+
);
diff --git a/packages/app/src/stores/keyboard-shortcuts-store.test.ts b/packages/app/src/stores/keyboard-shortcuts-store.test.ts
index d0291dad5a..862f32cd7f 100644
--- a/packages/app/src/stores/keyboard-shortcuts-store.test.ts
+++ b/packages/app/src/stores/keyboard-shortcuts-store.test.ts
@@ -5,6 +5,7 @@ beforeEach(() => {
useKeyboardShortcutsStore.setState({
commandCenterOpen: false,
projectPickerOpen: false,
+ projectPickerIntent: { kind: "open-project" },
shortcutsDialogOpen: false,
capturingShortcut: false,
altDown: false,
@@ -25,4 +26,23 @@ describe("keyboard-shortcuts-store", () => {
useKeyboardShortcutsStore.getState().setCapturingShortcut(true);
expect(useKeyboardShortcutsStore.getState().capturingShortcut).toBe(true);
});
+
+ it("stores and resets the project picker intent", () => {
+ useKeyboardShortcutsStore
+ .getState()
+ .setProjectPickerOpen(true, { kind: "new-workspace", headerTitle: "New session" });
+
+ expect(useKeyboardShortcutsStore.getState().projectPickerOpen).toBe(true);
+ expect(useKeyboardShortcutsStore.getState().projectPickerIntent).toEqual({
+ kind: "new-workspace",
+ headerTitle: "New session",
+ });
+
+ useKeyboardShortcutsStore.getState().setProjectPickerOpen(false);
+
+ expect(useKeyboardShortcutsStore.getState().projectPickerOpen).toBe(false);
+ expect(useKeyboardShortcutsStore.getState().projectPickerIntent).toEqual({
+ kind: "open-project",
+ });
+ });
});
diff --git a/packages/app/src/stores/keyboard-shortcuts-store.ts b/packages/app/src/stores/keyboard-shortcuts-store.ts
index d47084a58a..ea13ea1749 100644
--- a/packages/app/src/stores/keyboard-shortcuts-store.ts
+++ b/packages/app/src/stores/keyboard-shortcuts-store.ts
@@ -3,9 +3,14 @@ import type { SidebarShortcutWorkspaceTarget } from "@/utils/sidebar-shortcuts";
const SHORTCUT_BADGE_DELAY_MS = 150;
+export type ProjectPickerIntent =
+ | { kind: "open-project" }
+ | { kind: "new-workspace"; headerTitle: string };
+
interface KeyboardShortcutsState {
commandCenterOpen: boolean;
projectPickerOpen: boolean;
+ projectPickerIntent: ProjectPickerIntent;
shortcutsDialogOpen: boolean;
capturingShortcut: boolean;
altDown: boolean;
@@ -15,7 +20,7 @@ interface KeyboardShortcutsState {
sidebarShortcutWorkspaceTargets: SidebarShortcutWorkspaceTarget[];
setCommandCenterOpen: (open: boolean) => void;
- setProjectPickerOpen: (open: boolean) => void;
+ setProjectPickerOpen: (open: boolean, intent?: ProjectPickerIntent) => void;
setShortcutsDialogOpen: (open: boolean) => void;
setCapturingShortcut: (capturing: boolean) => void;
setAltDown: (down: boolean) => void;
@@ -50,6 +55,7 @@ function updateBadgeTimer(
export const useKeyboardShortcutsStore = create((set, get) => ({
commandCenterOpen: false,
projectPickerOpen: false,
+ projectPickerIntent: { kind: "open-project" },
shortcutsDialogOpen: false,
capturingShortcut: false,
altDown: false,
@@ -58,7 +64,11 @@ export const useKeyboardShortcutsStore = create((set, ge
sidebarShortcutWorkspaceTargets: [],
setCommandCenterOpen: (open) => set({ commandCenterOpen: open }),
- setProjectPickerOpen: (open) => set({ projectPickerOpen: open }),
+ setProjectPickerOpen: (open, intent) =>
+ set({
+ projectPickerOpen: open,
+ projectPickerIntent: open ? (intent ?? { kind: "open-project" }) : { kind: "open-project" },
+ }),
setShortcutsDialogOpen: (open) => set({ shortcutsDialogOpen: open }),
setCapturingShortcut: (capturing) => set({ capturingShortcut: capturing }),
setAltDown: (down) => {
diff --git a/packages/app/src/utils/host-routes.test.ts b/packages/app/src/utils/host-routes.test.ts
index 02712d2f43..e4de8658e2 100644
--- a/packages/app/src/utils/host-routes.test.ts
+++ b/packages/app/src/utils/host-routes.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
buildHostAgentDetailRoute,
+ buildHostNewWorkspaceRoute,
buildHostRootRoute,
buildHostWorkspaceOpenRoute,
buildHostWorkspaceRoute,
@@ -160,3 +161,14 @@ describe("projects settings routes", () => {
expect(decodeURIComponent(segment)).toBe(projectKey);
});
});
+
+describe("new workspace route", () => {
+ it("can carry a header title separately from the project display name", () => {
+ expect(
+ buildHostNewWorkspaceRoute("server-1", "/repo", {
+ displayName: "Project",
+ headerTitle: "New session",
+ }),
+ ).toBe("/h/server-1/new?dir=%2Frepo&name=Project&title=New+session");
+ });
+});
diff --git a/packages/app/src/utils/host-routes.ts b/packages/app/src/utils/host-routes.ts
index e3af3a51cf..77a8b44f1f 100644
--- a/packages/app/src/utils/host-routes.ts
+++ b/packages/app/src/utils/host-routes.ts
@@ -358,7 +358,7 @@ export function buildHostOpenProjectRoute(serverId: string) {
export function buildHostNewWorkspaceRoute(
serverId: string,
sourceDirectory: string,
- options?: { displayName?: string },
+ options?: { displayName?: string; headerTitle?: string },
) {
const base = buildHostRootRoute(serverId);
if (base === "/") {
@@ -369,6 +369,9 @@ export function buildHostNewWorkspaceRoute(
if (options?.displayName) {
params.set("name", options.displayName);
}
+ if (options?.headerTitle) {
+ params.set("title", options.headerTitle);
+ }
return `${base}/new?${params.toString()}` as const;
}