diff --git a/packages/app/src/components/draggable-list.native.tsx b/packages/app/src/components/draggable-list.native.tsx index 61d37ebb1..5849dcaaf 100644 --- a/packages/app/src/components/draggable-list.native.tsx +++ b/packages/app/src/components/draggable-list.native.tsx @@ -1,6 +1,7 @@ import { RefreshControl } from "react-native"; import { useCallback, useState } from "react"; import DraggableFlatList, { + NestableDraggableFlatList, type RenderItemParams, } from "react-native-draggable-flatlist"; import { useUnistyles } from "react-native-unistyles"; @@ -32,6 +33,7 @@ export function DraggableList({ simultaneousGestureRef, waitFor, onDragBegin: onDragBeginProp, + nestable = false, }: DraggableListProps) { const { theme } = useUnistyles(); const [isDragging, setIsDragging] = useState(false); @@ -75,9 +77,13 @@ export function DraggableList({ const showRefreshControl = Boolean(onRefresh) && (!isDragging || Boolean(refreshing)); const resolvedContainerStyle = containerStyle ?? (scrollEnabled ? { flex: 1 } : undefined); + const ListComponent: typeof DraggableFlatList = (nestable + ? (NestableDraggableFlatList as any) + : DraggableFlatList) as any; + const shouldShowRefreshControl = showRefreshControl && !nestable; return ( - ({ // @ts-expect-error - waitFor is supported by RNGH FlatList but not typed in DraggableFlatList waitFor={waitFor} refreshControl={ - showRefreshControl ? ( + shouldShowRefreshControl ? ( { waitFor?: MutableRefObject | MutableRefObject[]; /** Called when a drag gesture begins (before items are reordered) */ onDragBegin?: () => void; + /** + * Native-only: use the nestable draggable-flatlist variant for nested drag + * lists coordinated by a shared NestableScrollContainer. + */ + nestable?: boolean; } diff --git a/packages/app/src/components/draggable-list.web.tsx b/packages/app/src/components/draggable-list.web.tsx index 63bc3b533..7f1a0d4d8 100644 --- a/packages/app/src/components/draggable-list.web.tsx +++ b/packages/app/src/components/draggable-list.web.tsx @@ -136,6 +136,7 @@ export function DraggableList({ useDragHandle = false, // simultaneousGestureRef is native-only, ignored on web onDragBegin, + nestable: _nestable = false, }: DraggableListProps) { const [activeId, setActiveId] = useState(null); const [items, setItems] = useState(data); diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index 7010f046d..ee0dd4acc 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -1,4 +1,16 @@ -import { View, Text, Pressable, Image, Platform, ActivityIndicator, Alert } from 'react-native' +import { + View, + Text, + Pressable, + Image, + Platform, + ActivityIndicator, + Alert, + StatusBar, + ScrollView, + RefreshControl, + type GestureResponderEvent, +} from 'react-native' import { useQueries } from '@tanstack/react-query' import { useCallback, @@ -13,7 +25,9 @@ import { router, usePathname, useSegments } from 'expo-router' import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles' import { Gesture, GestureDetector, type GestureType } from 'react-native-gesture-handler' import { ChevronDown, ChevronRight } from 'lucide-react-native' +import { NestableScrollContainer } from 'react-native-draggable-flatlist' import { DraggableList, type DraggableRenderItemInfo } from './draggable-list' +import type { DraggableListDragHandleProps } from './draggable-list.types' import { getHostRuntimeStore, isHostRuntimeConnected } from '@/runtime/host-runtime' import { getIsTauri } from '@/constants/layout' import { projectIconQueryKey } from '@/hooks/use-project-icon-query' @@ -34,13 +48,16 @@ import { ContextMenuContent, ContextMenuItem, ContextMenuTrigger, + useContextMenu, } from '@/components/ui/context-menu' import { useToast } from '@/contexts/toast-context' -import { - buildSidebarWorkspaceViewModel, - type SidebarWorkspaceTreeRow, -} from '@/utils/sidebar-shortcuts' import { useCheckoutGitActionsStore } from '@/stores/checkout-git-actions-store' +import { buildSidebarShortcutModel } from '@/utils/sidebar-shortcuts' +import { hasVisibleOrderChanged, mergeWithRemainder } from '@/utils/sidebar-reorder' +import { + decideLongPressMove, + shouldOpenContextMenuOnPressOut, +} from '@/utils/sidebar-gesture-arbitration' const PASEO_WORKTREE_PATH_MARKER = '/.paseo/worktrees' @@ -63,16 +80,22 @@ interface SidebarWorkspaceListProps { parentGestureRef?: MutableRefObject } -interface ProjectRowProps { +interface ContextMenuController { + setAnchorRect: (rect: { x: number; y: number; width: number; height: number } | null) => void + setOpen: (open: boolean) => void +} + +interface ProjectHeaderRowProps { project: SidebarProjectEntry displayName: string iconDataUri: string | null collapsed: boolean onToggle: () => void - onLongPress: () => void + drag: () => void + dragHandleProps?: DraggableListDragHandleProps } -interface WorkspaceRowProps { +interface WorkspaceRowInnerProps { workspace: SidebarWorkspaceEntry selected: boolean shortcutNumber: number | null @@ -80,7 +103,8 @@ interface WorkspaceRowProps { onPress: () => void drag: () => void isArchiving: boolean - useContextMenuTrigger?: boolean + dragHandleProps?: DraggableListDragHandleProps + menuController: ContextMenuController | null } function resolveWorkspaceCreatedAtLabel(workspace: SidebarWorkspaceEntry): string | null { @@ -90,7 +114,10 @@ function resolveWorkspaceCreatedAtLabel(workspace: SidebarWorkspaceEntry): strin return formatTimeAgo(workspace.activityAt) } -function resolveStatusDotColor(input: { theme: ReturnType['theme']; bucket: SidebarStateBucket }) { +function resolveStatusDotColor(input: { + theme: ReturnType['theme'] + bucket: SidebarStateBucket +}) { const { theme, bucket } = input return bucket === 'needs_input' ? theme.colors.palette.amber[500] @@ -134,42 +161,209 @@ function WorkspaceStatusIndicator({ ) } -function ProjectRow({ +function useLongPressDragInteraction(input: { + drag: () => void + menuController: ContextMenuController | null +}) { + const didLongPressRef = useRef(false) + const didLongPressCleanupTimerRef = useRef | null>(null) + const longPressArmedRef = useRef(false) + const longPressCancelledRef = useRef(false) + const didStartDragRef = useRef(false) + const touchStartRef = useRef<{ x: number; y: number } | null>(null) + + useEffect(() => { + return () => { + if (!didLongPressCleanupTimerRef.current) { + return + } + clearTimeout(didLongPressCleanupTimerRef.current) + didLongPressCleanupTimerRef.current = null + } + }, []) + + const openContextMenuAtTouchStart = useCallback(() => { + if (!input.menuController) { + return + } + + const point = touchStartRef.current + if (!point) { + return + } + + const statusBarHeight = Platform.OS === 'android' ? (StatusBar.currentHeight ?? 0) : 0 + input.menuController.setAnchorRect({ + x: point.x, + y: point.y + statusBarHeight, + width: 0, + height: 0, + }) + input.menuController.setOpen(true) + }, [input.menuController]) + + const handleLongPress = useCallback(() => { + if (Platform.OS === 'web') { + return + } + if (longPressCancelledRef.current) { + return + } + didLongPressRef.current = true + longPressArmedRef.current = true + }, []) + + const handlePressIn = useCallback((event: GestureResponderEvent) => { + if (didLongPressCleanupTimerRef.current) { + clearTimeout(didLongPressCleanupTimerRef.current) + didLongPressCleanupTimerRef.current = null + } + + longPressCancelledRef.current = false + longPressArmedRef.current = false + didStartDragRef.current = false + touchStartRef.current = { + x: event.nativeEvent.pageX, + y: event.nativeEvent.pageY, + } + }, []) + + const handlePressOut = useCallback(() => { + if (Platform.OS === 'web') { + return + } + + if ( + !shouldOpenContextMenuOnPressOut({ + longPressArmed: longPressArmedRef.current, + didStartDrag: didStartDragRef.current, + }) + ) { + longPressCancelledRef.current = false + longPressArmedRef.current = false + didStartDragRef.current = false + touchStartRef.current = null + return + } + + openContextMenuAtTouchStart() + didLongPressCleanupTimerRef.current = setTimeout(() => { + didLongPressRef.current = false + didLongPressCleanupTimerRef.current = null + }, 0) + + longPressCancelledRef.current = false + longPressArmedRef.current = false + didStartDragRef.current = false + touchStartRef.current = null + }, [openContextMenuAtTouchStart]) + + const moveMonitorGesture = useMemo(() => { + if (Platform.OS === 'web') { + return null + } + + const CANCEL_SLOP_PX = 10 + const DRAG_SLOP_PX = 8 + + return Gesture.Pan() + .manualActivation(true) + .runOnJS(true) + .onTouchesDown((event) => { + const touch = event.changedTouches[0] + if (!touch) { + return + } + touchStartRef.current = { x: touch.absoluteX, y: touch.absoluteY } + }) + .onTouchesMove((event, stateManager) => { + const touch = event.changedTouches[0] + if (!touch || event.numberOfTouches !== 1) { + stateManager.fail() + return + } + + const start = touchStartRef.current + if (!start) { + touchStartRef.current = { x: touch.absoluteX, y: touch.absoluteY } + return + } + + const decision = decideLongPressMove({ + longPressArmed: longPressArmedRef.current, + didStartDrag: didStartDragRef.current, + startPoint: start, + currentPoint: { x: touch.absoluteX, y: touch.absoluteY }, + cancelSlopPx: CANCEL_SLOP_PX, + dragSlopPx: DRAG_SLOP_PX, + }) + + if (decision === 'cancel_long_press') { + longPressCancelledRef.current = true + stateManager.fail() + return + } + + if (decision === 'start_drag') { + didStartDragRef.current = true + input.drag() + stateManager.fail() + } + }) + }, [input]) + + return { + didLongPressRef, + handleLongPress, + handlePressIn, + handlePressOut, + moveMonitorGesture, + } +} + +function ProjectHeaderRow({ project, displayName, iconDataUri, collapsed, onToggle, - onLongPress, -}: ProjectRowProps) { - const didLongPressRef = useRef(false) + drag, + dragHandleProps, +}: ProjectHeaderRowProps) { + const interaction = useLongPressDragInteraction({ + drag, + menuController: useContextMenu(), + }) const handlePress = useCallback(() => { - if (didLongPressRef.current) { - didLongPressRef.current = false + if (interaction.didLongPressRef.current) { + interaction.didLongPressRef.current = false return } onToggle() - }, [onToggle]) + }, [interaction.didLongPressRef, onToggle]) - const handleLongPress = useCallback(() => { - didLongPressRef.current = true - onLongPress() - }, [onLongPress]) - - return ( - [ styles.projectRow, hovered && styles.projectRowHovered, pressed && styles.projectRowPressed, ]} + onPressIn={interaction.handlePressIn} + onPressOut={interaction.handlePressOut} onPress={handlePress} - onLongPress={handleLongPress} + onLongPress={interaction.handleLongPress} delayLongPress={200} testID={`sidebar-project-row-${project.projectKey}`} > - + {collapsed ? ( ) : ( @@ -180,9 +374,7 @@ function ProjectRow({ ) : ( - - {displayName.charAt(0).toUpperCase()} - + {displayName.charAt(0).toUpperCase()} )} @@ -190,11 +382,33 @@ function ProjectRow({ {displayName} - + + ) + + return interaction.moveMonitorGesture ? ( + {trigger} + ) : ( + trigger ) } -function WorkspaceRow({ +function ProjectHeaderRowWithMenu(props: ProjectHeaderRowProps) { + return ( + + + + + {props.collapsed ? 'Expand project' : 'Collapse project'} + + + + ) +} + +function WorkspaceRowInner({ workspace, selected, shortcutNumber, @@ -202,31 +416,32 @@ function WorkspaceRow({ onPress, drag, isArchiving, - useContextMenuTrigger = true, -}: WorkspaceRowProps) { + dragHandleProps, + menuController, +}: WorkspaceRowInnerProps) { const { theme } = useUnistyles() const createdAtLabel = resolveWorkspaceCreatedAtLabel(workspace) - const didLongPressRef = useRef(false) + const interaction = useLongPressDragInteraction({ + drag, + menuController, + }) const handlePress = useCallback(() => { - if (didLongPressRef.current) { - didLongPressRef.current = false + if (interaction.didLongPressRef.current) { + interaction.didLongPressRef.current = false return } onPress() - }, [onPress]) - - const handleLongPress = useCallback(() => { - didLongPressRef.current = true - }, []) - - const moveMonitorGesture = useMemo(() => { - return Platform.OS === 'web' ? null : Gesture.Pan().manualActivation(true).runOnJS(true) - }, []) + }, [interaction.didLongPressRef, onPress]) const rowChildren = ( <> - + {workspace.name} @@ -247,7 +462,7 @@ function WorkspaceRow({ ) - const trigger = useContextMenuTrigger ? ( + const trigger = menuController ? ( @@ -273,8 +490,10 @@ function WorkspaceRow({ hovered && styles.workspaceRowHovered, pressed && styles.workspaceRowPressed, ]} + onPressIn={interaction.handlePressIn} + onPressOut={interaction.handlePressOut} onPress={handlePress} - onLongPress={handleLongPress} + onLongPress={interaction.handleLongPress} delayLongPress={200} testID={`sidebar-workspace-row-${workspace.workspaceKey}`} > @@ -282,8 +501,8 @@ function WorkspaceRow({ ) - const content = moveMonitorGesture ? ( - {trigger} + const content = interaction.moveMonitorGesture ? ( + {trigger} ) : ( trigger ) @@ -308,6 +527,7 @@ function WorkspaceRowWithMenu({ showShortcutBadge, onPress, drag, + dragHandleProps, }: { workspace: SidebarWorkspaceEntry selected: boolean @@ -315,8 +535,10 @@ function WorkspaceRowWithMenu({ showShortcutBadge: boolean onPress: () => void drag: () => void + dragHandleProps?: DraggableListDragHandleProps }) { const toast = useToast() + const contextMenu = useContextMenu() const archiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree) const archiveStatus = useCheckoutGitActionsStore((state) => state.getStatus({ @@ -326,7 +548,6 @@ function WorkspaceRowWithMenu({ }) ) const isArchiving = archiveStatus === 'pending' - const isPaseoOwnedWorktree = isPaseoOwnedWorktreePath(workspace.workspaceId) const handleArchiveWorktree = useCallback(() => { if (isArchiving) { @@ -358,33 +579,11 @@ function WorkspaceRowWithMenu({ ], { cancelable: true } ) - }, [ - archiveWorktree, - isArchiving, - toast, - workspace.name, - workspace.serverId, - workspace.workspaceId, - ]) - - if (!isPaseoOwnedWorktree) { - return ( - - ) - } + }, [archiveWorktree, isArchiving, toast, workspace.name, workspace.serverId, workspace.workspaceId]) return ( - - + + + ) +} + +function WorkspaceRowPlain({ + workspace, + selected, + shortcutNumber, + showShortcutBadge, + onPress, + drag, + dragHandleProps, +}: { + workspace: SidebarWorkspaceEntry + selected: boolean + shortcutNumber: number | null + showShortcutBadge: boolean + onPress: () => void + drag: () => void + dragHandleProps?: DraggableListDragHandleProps +}) { + return ( + + ) +} + +function WorkspaceRow({ + workspace, + selected, + shortcutNumber, + showShortcutBadge, + onPress, + drag, + dragHandleProps, +}: { + workspace: SidebarWorkspaceEntry + selected: boolean + shortcutNumber: number | null + showShortcutBadge: boolean + onPress: () => void + drag: () => void + dragHandleProps?: DraggableListDragHandleProps +}) { + if (!isPaseoOwnedWorktreePath(workspace.workspaceId)) { + return ( + + ) + } + + return ( + + ) } -function mergeWithRemainder(input: { - currentOrder: string[] - reorderedVisibleKeys: string[] -}): string[] { - const reorderedSet = new Set(input.reorderedVisibleKeys) - const remainder = input.currentOrder.filter((key) => !reorderedSet.has(key)) - return [...input.reorderedVisibleKeys, ...remainder] -} +function ProjectBlock({ + project, + collapsed, + displayName, + iconDataUri, + serverId, + activeWorkspaceSelection, + shouldReplaceWorkspaceNavigation, + showShortcutBadges, + shortcutIndexByWorkspaceKey, + parentGestureRef, + onToggleCollapsed, + onWorkspacePress, + onWorkspaceReorder, + drag, + dragHandleProps, + useNestable, +}: { + project: SidebarProjectEntry + collapsed: boolean + displayName: string + iconDataUri: string | null + serverId: string | null + activeWorkspaceSelection: { serverId: string; workspaceId: string } | null + shouldReplaceWorkspaceNavigation: boolean + showShortcutBadges: boolean + shortcutIndexByWorkspaceKey: Map + parentGestureRef?: MutableRefObject + onToggleCollapsed: () => void + onWorkspacePress?: () => void + onWorkspaceReorder: (projectKey: string, workspaces: SidebarWorkspaceEntry[]) => void + drag: () => void + dragHandleProps?: DraggableListDragHandleProps + useNestable: boolean +}) { + const renderWorkspace = useCallback( + ({ item, drag: workspaceDrag, dragHandleProps: workspaceDragHandleProps }: DraggableRenderItemInfo) => { + const workspaceRoute = buildHostWorkspaceRoute(serverId ?? '', item.workspaceId) + const navigate = shouldReplaceWorkspaceNavigation ? router.replace : router.push + const isSelected = + Boolean(serverId) && + activeWorkspaceSelection?.serverId === serverId && + activeWorkspaceSelection.workspaceId === item.workspaceId -function hasVisibleOrderChanged(input: { - currentOrder: string[] - reorderedVisibleKeys: string[] -}): boolean { - const currentVisible = input.currentOrder.filter((key) => - input.reorderedVisibleKeys.includes(key) + return ( + { + if (!serverId) { + return + } + onWorkspacePress?.() + navigate(workspaceRoute as any) + }} + drag={workspaceDrag} + dragHandleProps={workspaceDragHandleProps} + /> + ) + }, + [ + activeWorkspaceSelection, + onWorkspacePress, + serverId, + shouldReplaceWorkspaceNavigation, + shortcutIndexByWorkspaceKey, + showShortcutBadges, + ] + ) + + return ( + + + + {!collapsed ? ( + workspace.workspaceKey} + renderItem={renderWorkspace} + onDragEnd={(workspaces) => onWorkspaceReorder(project.projectKey, workspaces)} + scrollEnabled={false} + useDragHandle + nestable={useNestable} + simultaneousGestureRef={parentGestureRef} + containerStyle={styles.workspaceListContainer} + /> + ) : null} + ) - if (currentVisible.length !== input.reorderedVisibleKeys.length) { - return true - } - return input.reorderedVisibleKeys.some((key, index) => currentVisible[index] !== key) } export function SidebarWorkspaceList({ @@ -441,12 +799,11 @@ export function SidebarWorkspaceList({ parentGestureRef, }: SidebarWorkspaceListProps) { const isMobile = UnistylesRuntime.breakpoint === 'xs' || UnistylesRuntime.breakpoint === 'sm' - const showDesktopWebScrollbar = Platform.OS === 'web' && !isMobile + const isNative = Platform.OS !== 'web' const segments = useSegments() const pathname = usePathname() const shouldReplaceWorkspaceNavigation = segments[0] === 'h' const [collapsedProjectKeys, setCollapsedProjectKeys] = useState>(new Set()) - const [canonicalResyncNonce, setCanonicalResyncNonce] = useState(0) const isTauri = getIsTauri() const altDown = useKeyboardShortcutsStore((state) => state.altDown) const cmdOrCtrlDown = useKeyboardShortcutsStore((state) => state.cmdOrCtrlDown) @@ -554,19 +911,18 @@ export function SidebarWorkspaceList({ return byProject }, [projectIconQueries, projectIconRequests, projects, serverId]) - const viewModel = useMemo( + const shortcutModel = useMemo( () => - buildSidebarWorkspaceViewModel({ + buildSidebarShortcutModel({ projects, collapsedProjectKeys, - getProjectDisplayName: (project) => project.projectName, }), - [canonicalResyncNonce, collapsedProjectKeys, projects] + [collapsedProjectKeys, projects] ) useEffect(() => { - setSidebarShortcutWorkspaceTargets(viewModel.shortcutTargets) - }, [setSidebarShortcutWorkspaceTargets, viewModel.shortcutTargets]) + setSidebarShortcutWorkspaceTargets(shortcutModel.shortcutTargets) + }, [setSidebarShortcutWorkspaceTargets, shortcutModel.shortcutTargets]) useEffect(() => { return () => { @@ -586,149 +942,150 @@ export function SidebarWorkspaceList({ }) }, []) - const renderRow = useCallback( - ({ item, drag }: DraggableRenderItemInfo) => { - if (item.kind === 'project') { - return ( - toggleProjectCollapsed(item.project.projectKey)} - onLongPress={drag} - /> - ) + const handleProjectDragEnd = useCallback( + (reorderedProjects: SidebarProjectEntry[]) => { + if (!serverId) { + return } - const workspaceRoute = buildHostWorkspaceRoute(serverId ?? '', item.workspace.workspaceId) - const navigate = shouldReplaceWorkspaceNavigation ? router.replace : router.push - const isSelected = - Boolean(serverId) && - activeWorkspaceSelection?.serverId === serverId && - activeWorkspaceSelection.workspaceId === item.workspace.workspaceId + 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) { + return + } + + const reorderedWorkspaceKeys = reorderedWorkspaces.map((workspace) => workspace.workspaceKey) + const currentWorkspaceOrder = getWorkspaceOrder(serverId, projectKey) + if ( + !hasVisibleOrderChanged({ + currentOrder: currentWorkspaceOrder, + reorderedVisibleKeys: reorderedWorkspaceKeys, + }) + ) { + return + } + + setWorkspaceOrder( + serverId, + projectKey, + mergeWithRemainder({ + currentOrder: currentWorkspaceOrder, + reorderedVisibleKeys: reorderedWorkspaceKeys, + }) + ) + }, + [getWorkspaceOrder, serverId, setWorkspaceOrder] + ) + + const renderProject = useCallback( + ({ item, drag, dragHandleProps }: DraggableRenderItemInfo) => { return ( - { - if (!serverId) { - return - } - onWorkspacePress?.() - navigate(workspaceRoute as any) - }} + toggleProjectCollapsed(item.projectKey)} + onWorkspacePress={onWorkspacePress} + onWorkspaceReorder={handleWorkspaceReorder} drag={drag} + dragHandleProps={dragHandleProps} + useNestable={isNative} /> ) }, [ activeWorkspaceSelection, collapsedProjectKeys, + handleWorkspaceReorder, + isNative, onWorkspacePress, + parentGestureRef, projectIconByProjectKey, serverId, - showShortcutBadges, + shortcutModel.shortcutIndexByWorkspaceKey, shouldReplaceWorkspaceNavigation, + showShortcutBadges, toggleProjectCollapsed, ] ) - const keyExtractor = useCallback((entry: SidebarWorkspaceTreeRow) => entry.rowKey, []) + const refreshControl = onRefresh ? ( + + ) : undefined - const handleDragEnd = useCallback( - (reorderedRows: SidebarWorkspaceTreeRow[]) => { - if (!serverId) { - return - } - - let didPersistOrderChange = false - const reorderedProjectKeys = reorderedRows - .filter( - (row): row is Extract => - row.kind === 'project' - ) - .map((row) => row.project.projectKey) - - const currentProjectOrder = getProjectOrder(serverId) - if ( - hasVisibleOrderChanged({ - currentOrder: currentProjectOrder, - reorderedVisibleKeys: reorderedProjectKeys, - }) - ) { - didPersistOrderChange = true - setProjectOrder( - serverId, - mergeWithRemainder({ - currentOrder: currentProjectOrder, - reorderedVisibleKeys: reorderedProjectKeys, - }) - ) - } - - const workspaceRowsByProject = new Map() - for (const row of reorderedRows) { - if (row.kind !== 'workspace') { - continue - } - const list = workspaceRowsByProject.get(row.projectKey) ?? [] - list.push(row.workspace.workspaceKey) - workspaceRowsByProject.set(row.projectKey, list) - } - - for (const [projectKey, reorderedWorkspaceKeys] of workspaceRowsByProject.entries()) { - const currentWorkspaceOrder = getWorkspaceOrder(serverId, projectKey) - if ( - !hasVisibleOrderChanged({ - currentOrder: currentWorkspaceOrder, - reorderedVisibleKeys: reorderedWorkspaceKeys, - }) - ) { - continue - } - - didPersistOrderChange = true - setWorkspaceOrder( - serverId, - projectKey, - mergeWithRemainder({ - currentOrder: currentWorkspaceOrder, - reorderedVisibleKeys: reorderedWorkspaceKeys, - }) - ) - } - - // If persisted ordering did not change, force a local resync so draggable UI state - // snaps back to canonical Project -> Workspaces grouping. - if (!didPersistOrderChange) { - setCanonicalResyncNonce((prev) => prev + 1) - } - }, - [getProjectOrder, getWorkspaceOrder, serverId, setProjectOrder, setWorkspaceOrder] + const content = ( + <> + {projects.length === 0 ? ( + No projects yet + ) : ( + project.projectKey} + renderItem={renderProject} + onDragEnd={handleProjectDragEnd} + scrollEnabled={false} + useDragHandle + nestable={isNative} + simultaneousGestureRef={parentGestureRef} + containerStyle={styles.projectListContainer} + /> + )} + {listFooterComponent} + ) return ( - No projects yet} - refreshing={isRefreshing} - onRefresh={onRefresh} - simultaneousGestureRef={parentGestureRef} - /> + {isNative ? ( + + {content} + + ) : ( + + {content} + + )} ) } @@ -745,6 +1102,15 @@ const styles = StyleSheet.create((theme) => ({ paddingTop: theme.spacing[2], paddingBottom: theme.spacing[4], }, + projectListContainer: { + width: '100%', + }, + projectBlock: { + marginBottom: theme.spacing[1], + }, + workspaceListContainer: { + marginLeft: theme.spacing[4], + }, emptyText: { color: theme.colors.foregroundMuted, textAlign: 'center', diff --git a/packages/app/src/hooks/use-command-center.ts b/packages/app/src/hooks/use-command-center.ts index 286e6917a..47a0394b7 100644 --- a/packages/app/src/hooks/use-command-center.ts +++ b/packages/app/src/hooks/use-command-center.ts @@ -18,7 +18,7 @@ import { resolveNewAgentWorkingDir, } from "@/utils/new-agent-routing"; import { - buildHostAgentDetailRoute, + buildHostWorkspaceAgentTabRoute, buildHostSettingsRoute, parseHostAgentRouteFromPathname, parseServerIdFromPathname, @@ -230,7 +230,9 @@ export function useCommandCenter() { // Don't restore focus back to the prior element after we navigate. clearCommandCenterFocusRestoreElement(); setOpen(false); - navigate(buildHostAgentDetailRoute(agent.serverId, agent.id) as any); + navigate( + buildHostWorkspaceAgentTabRoute(agent.serverId, agent.cwd, agent.id) as any + ); }, [pathname, requestMessageInputAction, setOpen] ); diff --git a/packages/app/src/screens/agent/draft-agent-screen.tsx b/packages/app/src/screens/agent/draft-agent-screen.tsx index 8ca8b40e7..a96415166 100644 --- a/packages/app/src/screens/agent/draft-agent-screen.tsx +++ b/packages/app/src/screens/agent/draft-agent-screen.tsx @@ -48,7 +48,7 @@ import type { AgentSessionConfig, } from '@server/server/agent/agent-sdk-types' import { AGENT_PROVIDER_DEFINITIONS } from '@server/server/agent/provider-manifest' -import { buildHostAgentDetailRoute } from '@/utils/host-routes' +import { buildHostWorkspaceAgentTabRoute } from '@/utils/host-routes' import { useTauriDragHandlers } from '@/utils/tauri-window' import { useKeyboardShiftStyle } from '@/hooks/use-keyboard-shift-style' import { normalizeAgentSnapshot } from '@/utils/agent-snapshots' @@ -1032,7 +1032,14 @@ function DraftAgentScreenContent({ next.set(agentId, normalizeAgentSnapshot(result, selectedServerId)) return next }) - router.replace(buildHostAgentDetailRoute(selectedServerId, agentId) as any) + const createdWorkingDir = + typeof result.cwd === 'string' ? result.cwd.trim() : '' + const configuredWorkingDir = config.cwd.trim() + const workspaceId = + createdWorkingDir.length > 0 ? createdWorkingDir : configuredWorkingDir + router.replace( + buildHostWorkspaceAgentTabRoute(selectedServerId, workspaceId, agentId) as any + ) return } diff --git a/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx b/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx index b0d9474d7..78256f573 100644 --- a/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx +++ b/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx @@ -177,7 +177,7 @@ export function WorkspaceDraftAgentTab({ pendingPermissions: [], persistence: null, runtimeInfo: { provider: selectedProvider, sessionId: null, model, modeId }, - title: "New agent", + title: "Agent", cwd: workspaceId, model, thinkingOptionId, diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index eb9f3baf4..76ed6b16b 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -16,7 +16,6 @@ import { FileText, Folder, GitBranch, - MoreVertical, PanelRight, Plus, Pencil, @@ -31,12 +30,6 @@ import { ScreenHeader } from "@/components/headers/screen-header"; import { Combobox } from "@/components/ui/combobox"; import { ClaudeIcon } from "@/components/icons/claude-icon"; import { CodexIcon } from "@/components/icons/codex-icon"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; import { Tooltip, TooltipContent, @@ -60,7 +53,6 @@ import { type WorkspaceTab, } from "@/stores/workspace-tabs-store"; import { - buildHostAgentDetailRoute, buildHostWorkspaceRoute, buildHostWorkspaceTabRoute, decodeWorkspaceIdFromPathSegment, @@ -88,7 +80,6 @@ import { } from "@/screens/workspace/workspace-header-source"; const TERMINALS_QUERY_STALE_TIME = 5_000; -const DROPDOWN_WIDTH = 220; const NEW_TAB_AGENT_OPTION_ID = "__new_tab_agent__"; const NEW_TAB_TERMINAL_OPTION_ID = "__new_tab_terminal__"; const EMPTY_OPEN_FILE_PATHS: string[] = []; @@ -166,6 +157,20 @@ function formatProviderLabel(provider: Agent["provider"]): string { return provider.charAt(0).toUpperCase() + provider.slice(1); } +function resolveWorkspaceAgentTabLabel(title: string | null | undefined): string { + if (typeof title !== "string") { + return "Agent"; + } + const normalized = title.trim(); + if (!normalized) { + return "Agent"; + } + if (normalized.toLowerCase() === "new agent") { + return "Agent"; + } + return normalized; +} + function resolveTabAvailability(input: { tab: WorkspaceTabTarget; agentsHydrated: boolean; @@ -755,7 +760,7 @@ function WorkspaceScreenContent({ tabId: tab.tabId, kind: "draft", draftId: target.draftId, - label: "New agent", + label: "Draft", subtitle: "Draft", }); continue; @@ -769,7 +774,7 @@ function WorkspaceScreenContent({ kind: "agent", agentId: target.agentId, provider, - label: agent?.title?.trim() || "Agent", + label: resolveWorkspaceAgentTabLabel(agent?.title), subtitle: `${formatProviderLabel(provider)} agent`, }); continue; @@ -868,13 +873,6 @@ function WorkspaceScreenContent({ [tabs] ); - const activeAgent = useMemo(() => { - if (activeTab?.target.kind !== "agent") { - return null; - } - return sessionAgents?.get(activeTab.target.agentId) ?? null; - }, [activeTab, sessionAgents]); - const activeTabLabel = useMemo(() => { const active = tabs.find((tab) => tab.key === activeTabKey); return active?.label ?? "Select tab"; @@ -1210,15 +1208,6 @@ function WorkspaceScreenContent({ ] ); - const handleOpenAgentChatView = useCallback(() => { - if (!activeAgent) { - return; - } - router.push( - buildHostAgentDetailRoute(normalizedServerId, activeAgent.id) as any - ); - }, [activeAgent, normalizedServerId, router]); - const renderContent = () => { if ( shouldRenderMissingWorkspaceDescriptor({ @@ -1385,32 +1374,6 @@ function WorkspaceScreenContent({ )} - {activeAgent ? ( - - - - - - - Open chat view - - - - ) : null} } /> diff --git a/packages/app/src/utils/sidebar-gesture-arbitration.test.ts b/packages/app/src/utils/sidebar-gesture-arbitration.test.ts new file mode 100644 index 000000000..603403475 --- /dev/null +++ b/packages/app/src/utils/sidebar-gesture-arbitration.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; + +import { + decideLongPressMove, + shouldOpenContextMenuOnPressOut, +} from "./sidebar-gesture-arbitration"; + +describe("decideLongPressMove", () => { + it("keeps long press pending for small movement before long-press arm", () => { + expect( + decideLongPressMove({ + longPressArmed: false, + didStartDrag: false, + startPoint: { x: 0, y: 0 }, + currentPoint: { x: 3, y: 2 }, + }) + ).toBe("none"); + }); + + it("cancels long press when movement exceeds cancel slop before arm", () => { + expect( + decideLongPressMove({ + longPressArmed: false, + didStartDrag: false, + startPoint: { x: 0, y: 0 }, + currentPoint: { x: 12, y: 0 }, + }) + ).toBe("cancel_long_press"); + }); + + it("starts drag when movement exceeds drag slop after long-press arm", () => { + expect( + decideLongPressMove({ + longPressArmed: true, + didStartDrag: false, + startPoint: { x: 0, y: 0 }, + currentPoint: { x: 0, y: 9 }, + }) + ).toBe("start_drag"); + }); + + it("does nothing when drag already started", () => { + expect( + decideLongPressMove({ + longPressArmed: true, + didStartDrag: true, + startPoint: { x: 0, y: 0 }, + currentPoint: { x: 20, y: 20 }, + }) + ).toBe("none"); + }); +}); + +describe("shouldOpenContextMenuOnPressOut", () => { + it("opens menu only when long-press is armed and drag did not start", () => { + expect( + shouldOpenContextMenuOnPressOut({ + longPressArmed: true, + didStartDrag: false, + }) + ).toBe(true); + + expect( + shouldOpenContextMenuOnPressOut({ + longPressArmed: false, + didStartDrag: false, + }) + ).toBe(false); + + expect( + shouldOpenContextMenuOnPressOut({ + longPressArmed: true, + didStartDrag: true, + }) + ).toBe(false); + }); +}); + diff --git a/packages/app/src/utils/sidebar-gesture-arbitration.ts b/packages/app/src/utils/sidebar-gesture-arbitration.ts new file mode 100644 index 000000000..2b1f73f1d --- /dev/null +++ b/packages/app/src/utils/sidebar-gesture-arbitration.ts @@ -0,0 +1,37 @@ +export type LongPressMoveDecision = "none" | "cancel_long_press" | "start_drag"; + +export function decideLongPressMove(input: { + longPressArmed: boolean; + didStartDrag: boolean; + startPoint: { x: number; y: number } | null; + currentPoint: { x: number; y: number }; + cancelSlopPx?: number; + dragSlopPx?: number; +}): LongPressMoveDecision { + const cancelSlopPx = input.cancelSlopPx ?? 10; + const dragSlopPx = input.dragSlopPx ?? 8; + + if (!input.startPoint) { + return "none"; + } + if (input.didStartDrag) { + return "none"; + } + + const dx = input.currentPoint.x - input.startPoint.x; + const dy = input.currentPoint.y - input.startPoint.y; + const distance = Math.sqrt(dx * dx + dy * dy); + + if (!input.longPressArmed) { + return distance > cancelSlopPx ? "cancel_long_press" : "none"; + } + return distance > dragSlopPx ? "start_drag" : "none"; +} + +export function shouldOpenContextMenuOnPressOut(input: { + longPressArmed: boolean; + didStartDrag: boolean; +}): boolean { + return input.longPressArmed && !input.didStartDrag; +} + diff --git a/packages/app/src/utils/sidebar-reorder.test.ts b/packages/app/src/utils/sidebar-reorder.test.ts new file mode 100644 index 000000000..046b82531 --- /dev/null +++ b/packages/app/src/utils/sidebar-reorder.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; + +import { hasVisibleOrderChanged, mergeWithRemainder } from "./sidebar-reorder"; + +describe("hasVisibleOrderChanged", () => { + it("returns false when visible order is unchanged", () => { + expect( + hasVisibleOrderChanged({ + currentOrder: ["a", "b", "c", "d"], + reorderedVisibleKeys: ["a", "b", "c"], + }) + ).toBe(false); + }); + + it("returns true when visible items are reordered", () => { + expect( + hasVisibleOrderChanged({ + currentOrder: ["a", "b", "c", "d"], + reorderedVisibleKeys: ["b", "a", "c"], + }) + ).toBe(true); + }); + + it("returns true when a visible key is missing from current order", () => { + expect( + hasVisibleOrderChanged({ + currentOrder: ["a", "b"], + reorderedVisibleKeys: ["a", "c"], + }) + ).toBe(true); + }); +}); + +describe("mergeWithRemainder", () => { + it("appends non-visible stored keys after reordered visible keys", () => { + expect( + mergeWithRemainder({ + currentOrder: ["a", "x", "b", "y"], + reorderedVisibleKeys: ["b", "a"], + }) + ).toEqual(["b", "a", "x", "y"]); + }); + + it("keeps unknown current keys when no visible keys are reordered", () => { + expect( + mergeWithRemainder({ + currentOrder: ["stale", "hidden"], + reorderedVisibleKeys: [], + }) + ).toEqual(["stale", "hidden"]); + }); +}); + diff --git a/packages/app/src/utils/sidebar-reorder.ts b/packages/app/src/utils/sidebar-reorder.ts new file mode 100644 index 000000000..9e8b3db41 --- /dev/null +++ b/packages/app/src/utils/sidebar-reorder.ts @@ -0,0 +1,21 @@ +export function mergeWithRemainder(input: { + currentOrder: string[] + reorderedVisibleKeys: string[] +}): string[] { + const reorderedSet = new Set(input.reorderedVisibleKeys) + const remainder = input.currentOrder.filter((key) => !reorderedSet.has(key)) + return [...input.reorderedVisibleKeys, ...remainder] +} + +export function hasVisibleOrderChanged(input: { + currentOrder: string[] + reorderedVisibleKeys: string[] +}): boolean { + const visibleSet = new Set(input.reorderedVisibleKeys) + const currentVisible = input.currentOrder.filter((key) => visibleSet.has(key)) + if (currentVisible.length !== input.reorderedVisibleKeys.length) { + return true + } + return input.reorderedVisibleKeys.some((key, index) => currentVisible[index] !== key) +} + diff --git a/packages/app/src/utils/sidebar-shortcuts.test.ts b/packages/app/src/utils/sidebar-shortcuts.test.ts index 6c7b566cc..19209a5e6 100644 --- a/packages/app/src/utils/sidebar-shortcuts.test.ts +++ b/packages/app/src/utils/sidebar-shortcuts.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import type { SidebarProjectEntry, SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list"; -import { buildSidebarWorkspaceViewModel } from "./sidebar-shortcuts"; +import { buildSidebarShortcutModel } from "./sidebar-shortcuts"; function workspace(serverId: string, cwd: string): SidebarWorkspaceEntry { return { @@ -27,20 +27,18 @@ function project(projectKey: string, workspaces: SidebarWorkspaceEntry[]): Sideb }; } -describe("buildSidebarWorkspaceViewModel", () => { - it("builds visible rows and shortcut targets in visual order", () => { +describe("buildSidebarShortcutModel", () => { + it("builds shortcut targets in visual order and excludes collapsed projects", () => { const projects = [ project("p1", [workspace("s1", "/repo/main"), workspace("s1", "/repo/feat-a")]), project("p2", [workspace("s1", "/repo2/main")]), ]; - const model = buildSidebarWorkspaceViewModel({ + const model = buildSidebarShortcutModel({ projects, collapsedProjectKeys: new Set(["p2"]), - getProjectDisplayName: (entry) => entry.projectName, }); - expect(model.rows.map((row) => row.kind)).toEqual(["project", "workspace", "workspace", "project"]); expect(model.shortcutTargets).toEqual([ { serverId: "s1", workspaceId: "/repo/main" }, { serverId: "s1", workspaceId: "/repo/feat-a" }, @@ -54,10 +52,9 @@ describe("buildSidebarWorkspaceViewModel", () => { const workspaces = Array.from({ length: 20 }, (_, index) => workspace("s", `/repo/w${index + 1}`)); const projects = [project("p", workspaces)]; - const model = buildSidebarWorkspaceViewModel({ + const model = buildSidebarShortcutModel({ projects, collapsedProjectKeys: new Set(), - getProjectDisplayName: (entry) => entry.projectName, }); expect(model.shortcutTargets).toHaveLength(9); diff --git a/packages/app/src/utils/sidebar-shortcuts.ts b/packages/app/src/utils/sidebar-shortcuts.ts index f721782f4..53c72f796 100644 --- a/packages/app/src/utils/sidebar-shortcuts.ts +++ b/packages/app/src/utils/sidebar-shortcuts.ts @@ -8,46 +8,21 @@ export interface SidebarShortcutWorkspaceTarget { workspaceId: string } -export type SidebarWorkspaceTreeRow = - | { - kind: 'project' - rowKey: string - project: SidebarProjectEntry - displayName: string - } - | { - kind: 'workspace' - rowKey: string - projectKey: string - workspace: SidebarWorkspaceEntry - shortcutNumber: number | null - } - -export interface SidebarWorkspaceViewModel { - rows: SidebarWorkspaceTreeRow[] +export interface SidebarShortcutModel { shortcutTargets: SidebarShortcutWorkspaceTarget[] shortcutIndexByWorkspaceKey: Map } -export function buildSidebarWorkspaceViewModel(input: { +export function buildSidebarShortcutModel(input: { projects: SidebarProjectEntry[] collapsedProjectKeys: ReadonlySet - getProjectDisplayName: (project: SidebarProjectEntry) => string shortcutLimit?: number -}): SidebarWorkspaceViewModel { +}): SidebarShortcutModel { const maxShortcuts = Math.max(0, Math.floor(input.shortcutLimit ?? 9)) - const rows: SidebarWorkspaceTreeRow[] = [] const shortcutTargets: SidebarShortcutWorkspaceTarget[] = [] const shortcutIndexByWorkspaceKey = new Map() for (const project of input.projects) { - rows.push({ - kind: 'project', - rowKey: `project:${project.projectKey}`, - project, - displayName: input.getProjectDisplayName(project), - }) - if (input.collapsedProjectKeys.has(project.projectKey)) { continue } @@ -62,16 +37,8 @@ export function buildSidebarWorkspaceViewModel(input: { }) shortcutIndexByWorkspaceKey.set(workspace.workspaceKey, shortcutNumber) } - - rows.push({ - kind: 'workspace', - rowKey: `workspace:${project.projectKey}:${workspace.workspaceKey}`, - projectKey: project.projectKey, - workspace, - shortcutNumber, - }) } } - return { rows, shortcutTargets, shortcutIndexByWorkspaceKey } + return { shortcutTargets, shortcutIndexByWorkspaceKey } }