From d00bd666a7498e2e6891e43f687deef0e341e328 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 8 May 2026 22:03:47 +0700 Subject: [PATCH] sidebar history: parity with workspace tree (ordering, drag, kebabs, +) Stops reinventing UX the workspace tree already solved. Five behavior-preserving extractions plus a few wires. Project ordering parity: Sessions consumes the canonical project list from useSidebarWorkspaces List instead of computing first-occurrence order from agents. The workspace tree's compareWorkspaceStructureProjects + persisted manual order via useSidebarOrderStore is the single source of truth for both views. Single non-git workspace projects flatten (no header, no chevron, rows render top-level) via the existing isSidebarProjectFlat tened gate from sidebar-project-row-model. Multi-workspace non-git projects and git projects collapse normally. Drag and drop parity: New SidebarProjectsDraggableList wraps the existing DraggableList, useDragHandle, useLongPressDragInteraction (extracted to its own file for sharing), and persists via setProjectOrder + the existing hasVisibleOrderChanged / mergeWithRemainder helpers. Both the workspace tree and grouped Sessions consume it. Workspace tree behavior unchanged; sessions's grouped view is now structured as a NestableScrollContainer / ScrollView around per-project sections. Project trailing actions parity: New SidebarProjectTrailingActions owns the project kebab (settings + remove), optional new-worktree button, and shortcut hint. Workspace tree uses it with the new-worktree button enabled; grouped Sessions uses it with kebab only. Per-agent kebab: New SidebarSessionRowKebabMenu reuses useArchiveAgent for archive, mirrors handleCloseAgentTab semantics for close, pulls the close menu entry from buildWorkspaceTabMenuEntries, and matches the workspace row kebab visibility (hover or touch). No fork. New session button: Plus button in the Sessions header reuses useOpenProjectPicker and buildHostNewWorkspaceRoute. Adds a label-only `title` query arg so the destination screen reads "New session" instead of "New workspace" when entered from this button. Tests cover ordering, flatten/collapse, drag-end persistence, kebab visibility/items, and the new-session route. Render-boundary tests extended for the section structure. --- packages/app/src/app/h/[serverId]/new.tsx | 9 +- .../src/components/project-picker-modal.tsx | 15 +- .../src/components/sidebar-workspace-list.tsx | 587 ++---------------- .../sidebar-collapsible-project-section.tsx | 2 +- .../sidebar/sidebar-project-row-visual.tsx | 2 +- .../sidebar-project-trailing-actions.test.tsx | 135 ++++ .../sidebar-project-trailing-actions.tsx | 426 +++++++++++++ .../sidebar-projects-draggable-list.test.tsx | 107 ++++ .../sidebar-projects-draggable-list.tsx | 92 +++ .../use-long-press-drag-interaction.ts | 229 +++++++ .../app/src/hooks/use-open-project-picker.ts | 27 +- .../app/src/screens/new-workspace-screen.tsx | 10 +- .../app/src/sidebar-sessions/grouped-view.tsx | 135 ++-- .../render-boundaries.test.tsx | 148 ++++- .../session-filtering.test.ts | 147 ++++- .../src/sidebar-sessions/session-filtering.ts | 61 +- .../session-row-actions.test.tsx | 258 ++++++++ .../sidebar-sessions/session-row-actions.tsx | 227 +++++++ .../sidebar-session-row-navigation.test.tsx | 37 +- packages/app/src/sidebar-sessions/toggle.tsx | 33 + packages/app/src/sidebar-sessions/view.tsx | 188 ++++-- .../stores/keyboard-shortcuts-store.test.ts | 20 + .../src/stores/keyboard-shortcuts-store.ts | 14 +- packages/app/src/utils/host-routes.test.ts | 12 + packages/app/src/utils/host-routes.ts | 5 +- 25 files changed, 2161 insertions(+), 765 deletions(-) create mode 100644 packages/app/src/components/sidebar/sidebar-project-trailing-actions.test.tsx create mode 100644 packages/app/src/components/sidebar/sidebar-project-trailing-actions.tsx create mode 100644 packages/app/src/components/sidebar/sidebar-projects-draggable-list.test.tsx create mode 100644 packages/app/src/components/sidebar/sidebar-projects-draggable-list.tsx create mode 100644 packages/app/src/components/sidebar/use-long-press-drag-interaction.ts create mode 100644 packages/app/src/sidebar-sessions/session-row-actions.test.tsx create mode 100644 packages/app/src/sidebar-sessions/session-row-actions.tsx 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; }