mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-24 18:22:38 +00:00
sidebar history: parity with workspace tree (ordering, drag, kebabs, +)
Stops reinventing UX the workspace tree already solved. Five behavior-preserving extractions plus a few wires. Project ordering parity: Sessions consumes the canonical project list from useSidebarWorkspaces List instead of computing first-occurrence order from agents. The workspace tree's compareWorkspaceStructureProjects + persisted manual order via useSidebarOrderStore is the single source of truth for both views. Single non-git workspace projects flatten (no header, no chevron, rows render top-level) via the existing isSidebarProjectFlat tened gate from sidebar-project-row-model. Multi-workspace non-git projects and git projects collapse normally. Drag and drop parity: New SidebarProjectsDraggableList wraps the existing DraggableList, useDragHandle, useLongPressDragInteraction (extracted to its own file for sharing), and persists via setProjectOrder + the existing hasVisibleOrderChanged / mergeWithRemainder helpers. Both the workspace tree and grouped Sessions consume it. Workspace tree behavior unchanged; sessions's grouped view is now structured as a NestableScrollContainer / ScrollView around per-project sections. Project trailing actions parity: New SidebarProjectTrailingActions owns the project kebab (settings + remove), optional new-worktree button, and shortcut hint. Workspace tree uses it with the new-worktree button enabled; grouped Sessions uses it with kebab only. Per-agent kebab: New SidebarSessionRowKebabMenu reuses useArchiveAgent for archive, mirrors handleCloseAgentTab semantics for close, pulls the close menu entry from buildWorkspaceTabMenuEntries, and matches the workspace row kebab visibility (hover or touch). No fork. New session button: Plus button in the Sessions header reuses useOpenProjectPicker and buildHostNewWorkspaceRoute. Adds a label-only `title` query arg so the destination screen reads "New session" instead of "New workspace" when entered from this button. Tests cover ordering, flatten/collapse, drag-end persistence, kebab visibility/items, and the new-session route. Render-boundary tests extended for the section structure.
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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 (
|
||||
<ThemedMoreVertical
|
||||
size={14}
|
||||
uniProps={hovered ? foregroundColorMapping : foregroundMutedColorMapping}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function noop() {}
|
||||
|
||||
const prBadgeStyles = StyleSheet.create((theme) => ({
|
||||
@@ -490,118 +482,9 @@ function StatusDotOverlay({
|
||||
return <View style={overlayStyle} />;
|
||||
}
|
||||
|
||||
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 (
|
||||
<View style={styles.projectTrailingActions}>
|
||||
{canCreateWorktree ? (
|
||||
<NewWorktreeButton
|
||||
displayName={displayName}
|
||||
onPress={onBeginWorkspaceSetup}
|
||||
visible={actionsVisible}
|
||||
showShortcutHint={isProjectActive}
|
||||
testID={`sidebar-project-new-worktree-${project.projectKey}`}
|
||||
/>
|
||||
) : null}
|
||||
{onRemoveProject ? (
|
||||
<View
|
||||
style={!actionsVisible && styles.projectKebabButtonHidden}
|
||||
pointerEvents={actionsVisible ? "auto" : "none"}
|
||||
>
|
||||
<ProjectKebabMenu
|
||||
projectKey={project.projectKey}
|
||||
onRemoveProject={onRemoveProject}
|
||||
removeProjectStatus={removeProjectStatus}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const trash2LeadingIcon = <ThemedTrash2 size={14} uniProps={foregroundMutedColorMapping} />;
|
||||
const settingsLeadingIcon = <ThemedSettings size={14} uniProps={foregroundMutedColorMapping} />;
|
||||
const copyLeadingIcon = <ThemedCopy size={14} uniProps={foregroundMutedColorMapping} />;
|
||||
const archiveLeadingIcon = <ThemedArchive size={14} uniProps={foregroundMutedColorMapping} />;
|
||||
|
||||
function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }) {
|
||||
return (
|
||||
<ThemedMoreVertical
|
||||
size={14}
|
||||
uniProps={hovered ? foregroundColorMapping : foregroundMutedColorMapping}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
hitSlop={8}
|
||||
style={projectKebabStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Project actions"
|
||||
testID={`sidebar-project-kebab-${projectKey}`}
|
||||
>
|
||||
{renderKebabTriggerIcon}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" width={220}>
|
||||
{canOpenProjectSettings ? (
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-project-menu-open-settings-${projectKey}`}
|
||||
leading={settingsLeadingIcon}
|
||||
onSelect={handleOpenProjectSettings}
|
||||
>
|
||||
Open project settings
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-project-menu-remove-${projectKey}`}
|
||||
leading={trash2LeadingIcon}
|
||||
status={removeProjectStatus}
|
||||
pendingLabel="Removing..."
|
||||
onSelect={onRemoveProject}
|
||||
>
|
||||
Remove project
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<View style={styles.projectTrailingControlSlot} pointerEvents={visible ? "auto" : "none"}>
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger asChild disabled={!visible}>
|
||||
<Pressable
|
||||
style={pressableStyle}
|
||||
onPress={handlePress}
|
||||
disabled={loading}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Create a new workspace for ${displayName}`}
|
||||
testID={testID}
|
||||
>
|
||||
{({ hovered, pressed }) =>
|
||||
loading ? (
|
||||
<ThemedActivityIndicator size={14} uniProps={foregroundMutedColorMapping} />
|
||||
) : (
|
||||
<ThemedFolderPlus
|
||||
size={15}
|
||||
uniProps={
|
||||
hovered || pressed ? foregroundColorMapping : foregroundMutedColorMapping
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="center" offset={8}>
|
||||
<View style={styles.projectActionTooltipRow}>
|
||||
<Text style={styles.projectActionTooltipText}>New workspace</Text>
|
||||
{showShortcutHint && newWorktreeKeys ? (
|
||||
<Shortcut chord={newWorktreeKeys} style={styles.projectActionTooltipShortcut} />
|
||||
) : null}
|
||||
</View>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function useLongPressDragInteraction(input: {
|
||||
drag: () => void;
|
||||
menuController: ReturnType<typeof useContextMenu> | 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<ReturnType<typeof setTimeout> | null>(null);
|
||||
const contextMenuTimerRef = useRef<ReturnType<typeof setTimeout> | 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 = (
|
||||
<>
|
||||
<ProjectRowTrailingActions
|
||||
project={project}
|
||||
displayName={displayName}
|
||||
canCreateWorktree={canCreateWorktree}
|
||||
<SidebarProjectTrailingActions
|
||||
projectKey={project.projectKey}
|
||||
projectName={displayName}
|
||||
serverId={serverId}
|
||||
isHovered={isHovered}
|
||||
isMobileBreakpoint={isMobileBreakpoint}
|
||||
showNewWorktreeButton={canCreateWorktree}
|
||||
sourceDirectory={project.iconWorkingDir}
|
||||
isProjectActive={isProjectActive}
|
||||
onBeginWorkspaceSetup={handleBeginWorkspaceSetup}
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
onRemoveProject={onRemoveProject}
|
||||
removeProjectStatus={removeProjectStatus}
|
||||
workspaces={project.workspaces}
|
||||
/>
|
||||
{showShortcutBadge && shortcutNumber !== null ? (
|
||||
<View style={styles.shortcutBadge}>
|
||||
@@ -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<SidebarProjectEntry>) => {
|
||||
({ project, drag, isDragging, dragHandleProps }: SidebarProjectDragInfo) => {
|
||||
return (
|
||||
<ProjectBlock
|
||||
project={item}
|
||||
collapsed={collapsedProjectKeys.has(item.projectKey)}
|
||||
displayName={item.projectName}
|
||||
iconDataUri={projectIconByProjectKey.get(item.projectKey) ?? null}
|
||||
project={project}
|
||||
collapsed={collapsedProjectKeys.has(project.projectKey)}
|
||||
displayName={project.projectName}
|
||||
iconDataUri={projectIconByProjectKey.get(project.projectKey) ?? null}
|
||||
serverId={serverId}
|
||||
selectionEnabled={selectionEnabled}
|
||||
showShortcutBadges={showShortcutBadges}
|
||||
@@ -2410,7 +1965,7 @@ export function SidebarWorkspaceList({
|
||||
onWorktreeCreated={handleWorktreeCreated}
|
||||
currentPathname={pathname}
|
||||
drag={drag}
|
||||
isDragging={isActive}
|
||||
isDragging={isDragging}
|
||||
dragHandleProps={dragHandleProps}
|
||||
useNestable={platformIsNative}
|
||||
creatingWorkspaceIds={creatingWorkspaceIds}
|
||||
@@ -2445,14 +2000,11 @@ export function SidebarWorkspaceList({
|
||||
</Button>
|
||||
</View>
|
||||
) : (
|
||||
<DraggableList
|
||||
<SidebarProjectsDraggableList
|
||||
testID="sidebar-project-list"
|
||||
data={projects}
|
||||
keyExtractor={projectKeyExtractor}
|
||||
renderItem={renderProject}
|
||||
onDragEnd={handleProjectDragEnd}
|
||||
scrollEnabled={false}
|
||||
useDragHandle
|
||||
projects={projects}
|
||||
serverId={serverId}
|
||||
renderProject={renderProject}
|
||||
nestable={platformIsNative}
|
||||
simultaneousGestureRef={parentGestureRef}
|
||||
containerStyle={styles.projectListContainer}
|
||||
@@ -2562,73 +2114,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
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],
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {
|
||||
import React, {
|
||||
forwardRef,
|
||||
memo,
|
||||
useCallback,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<string, unknown>) =>
|
||||
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<Record<string, unknown>>) =>
|
||||
function ThemedComponent(props: Record<string, unknown>) {
|
||||
return <Component {...props} />;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/dropdown-menu", () => ({
|
||||
DropdownMenu: ({ children }: { children: React.ReactNode }) => <View>{children}</View>,
|
||||
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => <View>{children}</View>,
|
||||
DropdownMenuItem: ({ children, testID }: { children: React.ReactNode; testID?: string }) => (
|
||||
<View testID={testID}>{children}</View>
|
||||
),
|
||||
DropdownMenuTrigger: ({
|
||||
children,
|
||||
testID,
|
||||
}: {
|
||||
children: React.ReactNode | ((state: { hovered: boolean }) => React.ReactNode);
|
||||
testID?: string;
|
||||
}) => (
|
||||
<Pressable testID={testID}>
|
||||
{typeof children === "function" ? children({ hovered: false }) : children}
|
||||
</Pressable>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/tooltip", () => ({
|
||||
Tooltip: ({ children }: { children: React.ReactNode }) => <View>{children}</View>,
|
||||
TooltipContent: ({ children }: { children: React.ReactNode }) => <View>{children}</View>,
|
||||
TooltipTrigger: ({ children }: { children: React.ReactNode }) => children,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/shortcut", () => ({
|
||||
Shortcut: () => <View />,
|
||||
}));
|
||||
|
||||
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(
|
||||
<SidebarProjectTrailingActions
|
||||
projectKey="project-a"
|
||||
projectName="Project A"
|
||||
serverId="server-1"
|
||||
isHovered
|
||||
showNewWorktreeButton
|
||||
sourceDirectory="/repo/project-a"
|
||||
onRemoveProject={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<SidebarProjectTrailingActions
|
||||
projectKey="project-a"
|
||||
projectName="Project A"
|
||||
serverId="server-1"
|
||||
isHovered
|
||||
onRemoveProject={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(getByTestId("sidebar-project-kebab-project-a")).toBeTruthy();
|
||||
expect(queryByTestId("sidebar-project-new-worktree-project-a")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -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 = <ThemedTrash2 size={14} uniProps={foregroundMutedColorMapping} />;
|
||||
const settingsLeadingIcon = <ThemedSettings size={14} uniProps={foregroundMutedColorMapping} />;
|
||||
|
||||
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 (
|
||||
<View style={styles.projectTrailingActions}>
|
||||
{showNewWorktreeButton && serverId && sourceDirectory ? (
|
||||
<NewWorktreeButton
|
||||
displayName={projectName ?? projectKey}
|
||||
onPress={handleBeginWorkspaceSetup}
|
||||
visible={actionsVisible}
|
||||
showShortcutHint={isProjectActive}
|
||||
testID={`sidebar-project-new-worktree-${projectKey}`}
|
||||
/>
|
||||
) : null}
|
||||
<View
|
||||
style={!actionsVisible && styles.projectKebabButtonHidden}
|
||||
pointerEvents={actionsVisible ? "auto" : "none"}
|
||||
>
|
||||
<ProjectKebabMenu
|
||||
projectKey={projectKey}
|
||||
onRemoveProject={resolvedRemoveProject}
|
||||
removeProjectStatus={resolvedRemoveProjectStatus}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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<unknown>) => 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 (
|
||||
<ThemedMoreVertical
|
||||
size={14}
|
||||
uniProps={hovered ? foregroundColorMapping : foregroundMutedColorMapping}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
hitSlop={8}
|
||||
style={projectKebabStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Project actions"
|
||||
testID={`sidebar-project-kebab-${projectKey}`}
|
||||
>
|
||||
{renderKebabTriggerIcon}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" width={220}>
|
||||
{canOpenProjectSettings ? (
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-project-menu-open-settings-${projectKey}`}
|
||||
leading={settingsLeadingIcon}
|
||||
onSelect={handleOpenProjectSettings}
|
||||
>
|
||||
Open project settings
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-project-menu-remove-${projectKey}`}
|
||||
leading={trash2LeadingIcon}
|
||||
status={removeProjectStatus}
|
||||
pendingLabel="Removing..."
|
||||
onSelect={onRemoveProject}
|
||||
>
|
||||
Remove project
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<View style={styles.projectTrailingControlSlot} pointerEvents={visible ? "auto" : "none"}>
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger asChild disabled={!visible}>
|
||||
<Pressable
|
||||
style={pressableStyle}
|
||||
onPress={handlePress}
|
||||
disabled={loading}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Create a new workspace for ${displayName}`}
|
||||
testID={testID}
|
||||
>
|
||||
{({ hovered, pressed }) =>
|
||||
loading ? (
|
||||
<ThemedActivityIndicator size={14} uniProps={foregroundMutedColorMapping} />
|
||||
) : (
|
||||
<ThemedFolderPlus
|
||||
size={15}
|
||||
uniProps={
|
||||
hovered || pressed ? foregroundColorMapping : foregroundMutedColorMapping
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="center" offset={8}>
|
||||
<View style={styles.projectActionTooltipRow}>
|
||||
<Text style={styles.projectActionTooltipText}>New workspace</Text>
|
||||
{showShortcutHint && newWorktreeKeys ? (
|
||||
<Shortcut chord={newWorktreeKeys} style={styles.projectActionTooltipShortcut} />
|
||||
) : null}
|
||||
</View>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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: {},
|
||||
}));
|
||||
@@ -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(
|
||||
<SidebarProjectsDraggableList
|
||||
projects={PROJECTS_ABC}
|
||||
serverId="server-1"
|
||||
renderProject={noopRender}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<SidebarProjectsDraggableList
|
||||
projects={PROJECTS_AB}
|
||||
serverId="server-1"
|
||||
renderProject={noopRender}
|
||||
/>,
|
||||
);
|
||||
|
||||
captured.props?.onDragEnd([...PROJECTS_AB]);
|
||||
|
||||
expect(setSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does nothing when serverId is null", () => {
|
||||
render(
|
||||
<SidebarProjectsDraggableList
|
||||
projects={PROJECTS_AB}
|
||||
serverId={null}
|
||||
renderProject={noopRender}
|
||||
/>,
|
||||
);
|
||||
|
||||
captured.props?.onDragEnd([PROJECTS_AB[1], PROJECTS_AB[0]]);
|
||||
|
||||
expect(useSidebarOrderStore.getState().projectOrderByServerId).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -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<ViewStyle>;
|
||||
testID?: string;
|
||||
/** Native: gesture ref to coordinate with parent (e.g. sidebar close swipe). */
|
||||
simultaneousGestureRef?: MutableRefObject<GestureType | undefined>;
|
||||
}
|
||||
|
||||
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<SidebarProjectEntry>) =>
|
||||
renderProject({ project: item, drag, isDragging: isActive, dragHandleProps }),
|
||||
[renderProject],
|
||||
);
|
||||
|
||||
return (
|
||||
<DraggableList
|
||||
testID={testID}
|
||||
data={projects as SidebarProjectEntry[]}
|
||||
keyExtractor={projectKeyExtractor}
|
||||
renderItem={renderItem}
|
||||
onDragEnd={handleDragEnd}
|
||||
scrollEnabled={scrollEnabled}
|
||||
useDragHandle
|
||||
nestable={nestable}
|
||||
simultaneousGestureRef={simultaneousGestureRef}
|
||||
containerStyle={containerStyle}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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<typeof useContextMenu> | 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<ReturnType<typeof setTimeout> | null>(null);
|
||||
const contextMenuTimerRef = useRef<ReturnType<typeof setTimeout> | 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,
|
||||
};
|
||||
}
|
||||
@@ -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<void> {
|
||||
const OPEN_PROJECT_INTENT: ProjectPickerIntent = { kind: "open-project" };
|
||||
|
||||
export function useOpenProjectPicker(
|
||||
serverId: string | null,
|
||||
intent: ProjectPickerIntent = OPEN_PROJECT_INTENT,
|
||||
): () => Promise<void> {
|
||||
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<voi
|
||||
}
|
||||
|
||||
if (!isLocalDaemon) {
|
||||
setProjectPickerOpen(true);
|
||||
setProjectPickerOpen(true, intent);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -25,6 +35,15 @@ export function useOpenProjectPicker(serverId: string | null): () => Promise<voi
|
||||
return;
|
||||
}
|
||||
|
||||
if (intent.kind === "new-workspace") {
|
||||
router.navigate(
|
||||
buildHostNewWorkspaceRoute(normalizedServerId, path, {
|
||||
headerTitle: intent.headerTitle,
|
||||
}) as never,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await openProject(path);
|
||||
}, [isLocalDaemon, normalizedServerId, openProject, setProjectPickerOpen]);
|
||||
}, [intent, isLocalDaemon, normalizedServerId, openProject, setProjectPickerOpen]);
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ interface NewWorkspaceScreenProps {
|
||||
serverId: string;
|
||||
sourceDirectory: string;
|
||||
displayName?: string;
|
||||
headerTitle?: string;
|
||||
}
|
||||
|
||||
interface PickerOptionData {
|
||||
@@ -329,6 +330,11 @@ function computeWorkspaceTitle(
|
||||
);
|
||||
}
|
||||
|
||||
function resolveNewWorkspaceHeaderTitle(headerTitle: string | undefined): string {
|
||||
const trimmed = headerTitle?.trim();
|
||||
return trimmed ? trimmed : "New workspace";
|
||||
}
|
||||
|
||||
function submitWorkspaceDraft(input: SubmitDraftInput): void {
|
||||
const {
|
||||
serverId,
|
||||
@@ -385,6 +391,7 @@ export function NewWorkspaceScreen({
|
||||
serverId,
|
||||
sourceDirectory,
|
||||
displayName: displayNameProp,
|
||||
headerTitle: headerTitleProp,
|
||||
}: NewWorkspaceScreenProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const insets = useSafeAreaInsets();
|
||||
@@ -412,6 +419,7 @@ export function NewWorkspaceScreen({
|
||||
}, [pickerSearchQuery]);
|
||||
|
||||
const displayName = displayNameProp?.trim() ?? "";
|
||||
const headerTitle = resolveNewWorkspaceHeaderTitle(headerTitleProp);
|
||||
const workspace = createdWorkspace;
|
||||
const selectedItem = pickerSelection?.item ?? null;
|
||||
const isPending = pendingAction !== null;
|
||||
@@ -711,7 +719,7 @@ export function NewWorkspaceScreen({
|
||||
<SidebarMenuToggle />
|
||||
<View style={styles.headerTitleContainer}>
|
||||
<Text style={styles.headerTitle} numberOfLines={1}>
|
||||
New workspace
|
||||
{headerTitle}
|
||||
</Text>
|
||||
<Text style={styles.headerProjectTitle} numberOfLines={1}>
|
||||
{workspaceTitle}
|
||||
|
||||
@@ -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<typeof useSessionStore.getState>;
|
||||
|
||||
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<string>;
|
||||
collapsedProjectKeys: ReadonlySet<string>;
|
||||
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(
|
||||
() => (
|
||||
<SidebarProjectTrailingActions
|
||||
projectKey={projectKey}
|
||||
projectName={projectName}
|
||||
serverId={serverId}
|
||||
isHovered={isHovered}
|
||||
workspaces={workspaces}
|
||||
/>
|
||||
),
|
||||
[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 (
|
||||
<View onPointerEnter={handlePointerEnter} onPointerLeave={handlePointerLeave}>
|
||||
<View
|
||||
{...dragHandleProps?.attributes}
|
||||
{...dragHandleProps?.listeners}
|
||||
ref={dragHandleProps?.setActivatorNodeRef as unknown as Ref<View>}
|
||||
onPointerEnter={handlePointerEnter}
|
||||
onPointerLeave={handlePointerLeave}
|
||||
>
|
||||
<SidebarProjectHeaderRow
|
||||
projectName={projectName}
|
||||
iconDataUri={dataUri}
|
||||
chevron={isCollapsed ? "expand" : "collapse"}
|
||||
isHovered={isHovered}
|
||||
isDragging={isDragging}
|
||||
trailingSlot={trailingSlot}
|
||||
onPress={handlePress}
|
||||
onPressIn={drag ? interaction.handlePressIn : undefined}
|
||||
onTouchMove={drag ? interaction.handleTouchMove : undefined}
|
||||
onPressOut={drag ? interaction.handlePressOut : undefined}
|
||||
testID={`sidebar-session-group-header-${projectKey}`}
|
||||
accessibilityLabel={isCollapsed ? `Expand ${projectName}` : `Collapse ${projectName}`}
|
||||
/>
|
||||
@@ -189,6 +182,8 @@ export const SidebarSessionGroupHeader = memo(function SidebarSessionGroupHeader
|
||||
);
|
||||
});
|
||||
|
||||
function noopDrag() {}
|
||||
|
||||
export const SidebarSessionGroupFooter = memo(function SidebarSessionGroupFooter({
|
||||
projectKey,
|
||||
hiddenCount,
|
||||
|
||||
@@ -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 }) => <View>{children}</View>,
|
||||
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => <View>{children}</View>,
|
||||
DropdownMenuItem: ({ children, testID }: { children: React.ReactNode; testID?: string }) => (
|
||||
<View testID={testID}>{children}</View>
|
||||
),
|
||||
DropdownMenuTrigger: ({
|
||||
children,
|
||||
testID,
|
||||
}: {
|
||||
children: React.ReactNode | ((state: { hovered: boolean }) => React.ReactNode);
|
||||
testID?: string;
|
||||
}) => (
|
||||
<Pressable testID={testID}>
|
||||
{typeof children === "function" ? children({ hovered: false }) : children}
|
||||
</Pressable>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/tooltip", () => ({
|
||||
Tooltip: ({ children }: { children: React.ReactNode }) => <View>{children}</View>,
|
||||
TooltipContent: ({ children }: { children: React.ReactNode }) => <View>{children}</View>,
|
||||
TooltipTrigger: ({ children }: { children: React.ReactNode }) => children,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/shortcut", () => ({
|
||||
Shortcut: () => <View />,
|
||||
}));
|
||||
|
||||
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<Record<string, unknown>>) =>
|
||||
function ThemedComponent(props: Record<string, unknown>) {
|
||||
return <Component {...props} />;
|
||||
},
|
||||
}));
|
||||
|
||||
const AGENT_DEFAULTS: Agent = {
|
||||
@@ -131,11 +225,11 @@ function useGroupedBoundaryHarness(input?: { collapsedProjectKeys?: ReadonlySet<
|
||||
const fallbackCollapsed = useMemo(() => new Set<string>(), []);
|
||||
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(
|
||||
<SidebarSessionGroupHeader
|
||||
serverId="server-1"
|
||||
projectKey="project-a"
|
||||
projectName="Project A"
|
||||
projectIconKey="/repo/a"
|
||||
workspaces={HARNESS_PROJECTS[0].workspaces}
|
||||
isCollapsed={false}
|
||||
onToggleCollapsed={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(getByTestId("sidebar-project-kebab-project-a")).toBeTruthy();
|
||||
expect(queryByTestId("sidebar-project-new-worktree-project-a")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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<string>;
|
||||
collapsedProjectKeys?: ReadonlySet<string>;
|
||||
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<string, SidebarSessionGroupDraft>();
|
||||
|
||||
const idsByProject = new Map<string, string[]>();
|
||||
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: {
|
||||
|
||||
@@ -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 }) => <View>{children}</View>,
|
||||
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => <View>{children}</View>,
|
||||
DropdownMenuItem: ({
|
||||
children,
|
||||
onSelect,
|
||||
testID,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
onSelect?: () => void;
|
||||
testID?: string;
|
||||
}) => (
|
||||
<Pressable testID={testID} onPress={onSelect}>
|
||||
<Text>{children}</Text>
|
||||
</Pressable>
|
||||
),
|
||||
DropdownMenuTrigger: ({
|
||||
children,
|
||||
testID,
|
||||
}: {
|
||||
children: React.ReactNode | ((state: { hovered: boolean }) => React.ReactNode);
|
||||
testID?: string;
|
||||
}) => (
|
||||
<Pressable testID={testID}>
|
||||
{typeof children === "function" ? children({ hovered: false }) : children}
|
||||
</Pressable>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("lucide-react-native", () => {
|
||||
const createIcon = (name: string) =>
|
||||
function Icon({ uniProps: _uniProps, ...props }: Record<string, unknown>) {
|
||||
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<Record<string, unknown>>) =>
|
||||
function ThemedComponent(props: Record<string, unknown>) {
|
||||
return <Component {...props} />;
|
||||
},
|
||||
}));
|
||||
|
||||
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> = {}): Agent {
|
||||
return { ...AGENT_DEFAULTS, ...input };
|
||||
}
|
||||
|
||||
function workspace(input: Partial<WorkspaceDescriptor> = {}): 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(
|
||||
<SidebarSessionRowKebabMenu serverId={SERVER_ID} agentId={AGENT_ID} isHovered={false} />,
|
||||
);
|
||||
expect(hidden.queryByTestId(`sidebar-session-kebab-${SERVER_ID}-${AGENT_ID}`)).toBeNull();
|
||||
hidden.unmount();
|
||||
|
||||
const visible = render(
|
||||
<SidebarSessionRowKebabMenu serverId={SERVER_ID} agentId={AGENT_ID} isHovered />,
|
||||
);
|
||||
expect(visible.getByTestId(`sidebar-session-kebab-${SERVER_ID}-${AGENT_ID}`)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders on touch platforms without hover", () => {
|
||||
platformState.isNative = true;
|
||||
|
||||
const { getByTestId } = render(
|
||||
<SidebarSessionRowKebabMenu serverId={SERVER_ID} agentId={AGENT_ID} isHovered={false} />,
|
||||
);
|
||||
|
||||
expect(getByTestId(`sidebar-session-kebab-${SERVER_ID}-${AGENT_ID}`)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("archives the agent from the archive item", () => {
|
||||
const { getByTestId } = render(
|
||||
<SidebarSessionRowKebabMenu serverId={SERVER_ID} agentId={AGENT_ID} isHovered />,
|
||||
);
|
||||
|
||||
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(
|
||||
<SidebarSessionRowKebabMenu serverId={SERVER_ID} agentId={AGENT_ID} isHovered />,
|
||||
);
|
||||
|
||||
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}`);
|
||||
});
|
||||
});
|
||||
@@ -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 = <ThemedArchive size={14} uniProps={foregroundMutedColorMapping} />;
|
||||
|
||||
function kebabButtonStyle({ hovered = false }: PressableStateCallbackType & { hovered?: boolean }) {
|
||||
return [styles.kebabButton, hovered && styles.kebabButtonHovered];
|
||||
}
|
||||
|
||||
function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }) {
|
||||
return (
|
||||
<ThemedMoreVertical
|
||||
size={14}
|
||||
uniProps={hovered ? foregroundColorMapping : foregroundMutedColorMapping}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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<WorkspaceTabDescriptor>(
|
||||
() => ({
|
||||
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<WorkspaceTabMenuEntry, { kind: "item" }>
|
||||
| undefined,
|
||||
[agentId, handleCloseAgentTab, serverId, tab],
|
||||
);
|
||||
|
||||
if (!visible || !closeEntry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
hitSlop={8}
|
||||
style={kebabButtonStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Session actions"
|
||||
testID={`sidebar-session-kebab-${serverId}-${agentId}`}
|
||||
>
|
||||
{renderKebabTriggerIcon}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" width={220}>
|
||||
<SidebarSessionMenuItem entry={closeEntry} />
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-session-menu-${serverId}-${agentId}-archive`}
|
||||
leading={archiveLeadingIcon}
|
||||
onSelect={handleArchiveAgent}
|
||||
>
|
||||
Archive
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
});
|
||||
|
||||
function SidebarSessionMenuItem({
|
||||
entry,
|
||||
}: {
|
||||
entry: Extract<WorkspaceTabMenuEntry, { kind: "item" }>;
|
||||
}) {
|
||||
const leading = useMemo(() => {
|
||||
if (entry.icon === "x") {
|
||||
return <ThemedX size={14} uniProps={foregroundMutedColorMapping} />;
|
||||
}
|
||||
return undefined;
|
||||
}, [entry.icon]);
|
||||
const trailing = useMemo(
|
||||
() => (entry.hint ? <Text style={styles.menuItemHint}>{entry.hint}</Text> : undefined),
|
||||
[entry.hint],
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
testID={entry.testID}
|
||||
disabled={entry.disabled}
|
||||
destructive={entry.destructive}
|
||||
onSelect={entry.onSelect}
|
||||
tooltip={entry.tooltip}
|
||||
leading={leading}
|
||||
trailing={trailing}
|
||||
>
|
||||
{entry.label}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
}));
|
||||
@@ -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<string, unknown>) =>
|
||||
React.createElement("span", { ...props, "data-icon": name });
|
||||
const createIcon = (name: string) =>
|
||||
function Icon({ uniProps: _uniProps, ...props }: Record<string, unknown>) {
|
||||
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: () => ({
|
||||
|
||||
@@ -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 (
|
||||
<View style={styles.sessionActions}>
|
||||
<Pressable
|
||||
accessibilityLabel="New session"
|
||||
accessibilityRole="button"
|
||||
onPress={handleNewSessionPress}
|
||||
style={newSessionButtonStyle}
|
||||
testID="sidebar-new-session-button"
|
||||
>
|
||||
{({ hovered, pressed }) => (
|
||||
<Plus
|
||||
size={theme.iconSize.sm}
|
||||
color={hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
accessibilityLabel="Filter sessions"
|
||||
|
||||
@@ -2,15 +2,22 @@ import React, { memo, useCallback, useMemo, useState, type ReactElement } from "
|
||||
import {
|
||||
FlatList,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
Text,
|
||||
View,
|
||||
type ListRenderItem,
|
||||
type PressableStateCallbackType,
|
||||
} from "react-native";
|
||||
import { NestableScrollContainer } from "react-native-draggable-flatlist";
|
||||
import { useShallow } from "zustand/shallow";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { isNative as platformIsNative } from "@/constants/platform";
|
||||
import { SidebarAgentListSkeleton } from "@/components/sidebar-agent-list-skeleton";
|
||||
import { sidebarProjectChildIndentStyles } from "@/components/sidebar/sidebar-collapsible-project-section";
|
||||
import {
|
||||
SidebarProjectsDraggableList,
|
||||
type SidebarProjectDragInfo,
|
||||
} from "@/components/sidebar/sidebar-projects-draggable-list";
|
||||
import {
|
||||
type AggregatedAgentIdEntry,
|
||||
useAggregatedAgentIds,
|
||||
@@ -30,17 +37,19 @@ import {
|
||||
resolveSidebarSessionWorkspaceId,
|
||||
resolveSidebarSessionWorkspace,
|
||||
shouldIncludeSidebarSessionAgent,
|
||||
type SidebarSessionGroup,
|
||||
} from "./session-filtering";
|
||||
import { selectSidebarSessionSlice } from "./select-sidebar-session-slice";
|
||||
import type { ResolvedSidebarSessionProject, SidebarSessionFilter } from "./types";
|
||||
import { useSidebarSessionWorkspaces } from "./use-sidebar-session-workspaces";
|
||||
import {
|
||||
GROUPED_SESSION_LIMIT,
|
||||
SidebarSessionGroupFooter,
|
||||
SidebarSessionGroupHeader,
|
||||
type SidebarSessionListItem,
|
||||
useGroupedSidebarSessionListData,
|
||||
useGroupedSidebarSessionGroups,
|
||||
useOrderedAgentProjectShape,
|
||||
} from "./grouped-view";
|
||||
import { SidebarSessionRowKebabMenu } from "./session-row-actions";
|
||||
|
||||
interface SidebarSessionsViewProps {
|
||||
serverId: string | null;
|
||||
@@ -111,6 +120,7 @@ export function SidebarSessionsView({
|
||||
<GroupedSidebarSessionsList
|
||||
serverId={serverId}
|
||||
sessionIds={sessionIds}
|
||||
projects={projects}
|
||||
resolveCwdToProject={resolveCwdToProject}
|
||||
previewExpandedProjects={previewExpandedProjects}
|
||||
collapsedProjectKeys={collapsedProjectKeys}
|
||||
@@ -153,6 +163,7 @@ const FlatSidebarSessionsList = memo(function FlatSidebarSessionsList({
|
||||
const GroupedSidebarSessionsList = memo(function GroupedSidebarSessionsList({
|
||||
serverId,
|
||||
sessionIds,
|
||||
projects,
|
||||
resolveCwdToProject,
|
||||
previewExpandedProjects,
|
||||
collapsedProjectKeys,
|
||||
@@ -161,6 +172,7 @@ const GroupedSidebarSessionsList = memo(function GroupedSidebarSessionsList({
|
||||
}: {
|
||||
serverId: string | null;
|
||||
sessionIds: readonly string[];
|
||||
projects: readonly SidebarProjectEntry[];
|
||||
resolveCwdToProject: (cwd: string) => ResolvedSidebarSessionProject | null;
|
||||
previewExpandedProjects: ReadonlySet<string>;
|
||||
collapsedProjectKeys: ReadonlySet<string>;
|
||||
@@ -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<SidebarSessionListItem> = useCallback(
|
||||
({ item }) => {
|
||||
switch (item.kind) {
|
||||
case "header":
|
||||
return (
|
||||
<SidebarSessionGroupHeader
|
||||
serverId={serverId}
|
||||
projectKey={item.projectKey}
|
||||
projectName={item.projectName}
|
||||
projectIconKey={item.projectIconKey}
|
||||
isCollapsed={item.isCollapsed}
|
||||
onToggleCollapsed={onProjectCollapsedToggle}
|
||||
/>
|
||||
);
|
||||
case "row":
|
||||
return <SidebarSessionRow id={item.id} serverId={item.serverId} indented />;
|
||||
case "footer":
|
||||
return (
|
||||
<SidebarSessionGroupFooter
|
||||
projectKey={item.projectKey}
|
||||
hiddenCount={item.hiddenCount}
|
||||
isExpanded={item.isExpanded}
|
||||
onPress={onProjectPreviewExpandedToggle}
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
[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<string, SidebarSessionGroup>();
|
||||
for (const group of groups) {
|
||||
map.set(group.projectKey, group);
|
||||
}
|
||||
}, []);
|
||||
return map;
|
||||
}, [groups]);
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
data={data}
|
||||
const projectsWithSessions = useMemo(
|
||||
() => 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 <View />;
|
||||
}
|
||||
return (
|
||||
<SidebarSessionGroupSection
|
||||
serverId={serverId}
|
||||
project={project}
|
||||
group={group}
|
||||
drag={drag}
|
||||
isDragging={isDragging}
|
||||
dragHandleProps={dragHandleProps}
|
||||
onProjectCollapsedToggle={onProjectCollapsedToggle}
|
||||
onProjectPreviewExpandedToggle={onProjectPreviewExpandedToggle}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[groupsByKey, onProjectCollapsedToggle, onProjectPreviewExpandedToggle, serverId],
|
||||
);
|
||||
|
||||
if (projectsWithSessions.length === 0) {
|
||||
return (
|
||||
<View style={styles.list}>
|
||||
<View style={styles.listContent}>
|
||||
<EmptySessions />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const draggableList = (
|
||||
<SidebarProjectsDraggableList
|
||||
testID="sidebar-session-group-list"
|
||||
projects={projectsWithSessions}
|
||||
serverId={serverId}
|
||||
renderProject={renderProject}
|
||||
nestable={platformIsNative}
|
||||
/>
|
||||
);
|
||||
|
||||
return platformIsNative ? (
|
||||
<NestableScrollContainer
|
||||
style={styles.list}
|
||||
contentContainerStyle={styles.listContent}
|
||||
keyExtractor={keyExtractor}
|
||||
renderItem={renderItem}
|
||||
showsVerticalScrollIndicator={false}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
ListEmptyComponent={EmptySessions}
|
||||
/>
|
||||
testID="sidebar-session-group-scroll"
|
||||
>
|
||||
{draggableList}
|
||||
</NestableScrollContainer>
|
||||
) : (
|
||||
<ScrollView
|
||||
style={styles.list}
|
||||
contentContainerStyle={styles.listContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
testID="sidebar-session-group-scroll"
|
||||
>
|
||||
{draggableList}
|
||||
</ScrollView>
|
||||
);
|
||||
});
|
||||
|
||||
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 (
|
||||
<View>
|
||||
{!group.isFlattened ? (
|
||||
<SidebarSessionGroupHeader
|
||||
serverId={serverId}
|
||||
projectKey={group.projectKey}
|
||||
projectName={group.projectName}
|
||||
projectIconKey={group.projectIconKey}
|
||||
workspaces={project.workspaces}
|
||||
isCollapsed={group.isCollapsed}
|
||||
isDragging={isDragging}
|
||||
drag={drag}
|
||||
dragHandleProps={dragHandleProps}
|
||||
onToggleCollapsed={onProjectCollapsedToggle}
|
||||
/>
|
||||
) : null}
|
||||
{serverId
|
||||
? group.visibleIds.map((id) => (
|
||||
<SidebarSessionRow key={id} id={id} serverId={serverId} indented={!group.isFlattened} />
|
||||
))
|
||||
: null}
|
||||
{showFooter ? (
|
||||
<SidebarSessionGroupFooter
|
||||
projectKey={group.projectKey}
|
||||
hiddenCount={group.hiddenCount}
|
||||
isExpanded={group.isExpanded}
|
||||
onPress={onProjectPreviewExpandedToggle}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -339,6 +426,7 @@ const SidebarSessionRow = memo(function SidebarSessionRow({
|
||||
<Text style={styles.timeAgo} numberOfLines={1}>
|
||||
{formatTimeAgo(agent.lastActivityAt)}
|
||||
</Text>
|
||||
<SidebarSessionRowKebabMenu serverId={serverId} agentId={id} isHovered={isHovered} />
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<KeyboardShortcutsState>((set, get) => ({
|
||||
commandCenterOpen: false,
|
||||
projectPickerOpen: false,
|
||||
projectPickerIntent: { kind: "open-project" },
|
||||
shortcutsDialogOpen: false,
|
||||
capturingShortcut: false,
|
||||
altDown: false,
|
||||
@@ -58,7 +64,11 @@ export const useKeyboardShortcutsStore = create<KeyboardShortcutsState>((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) => {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user