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