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:
Mohamed Boudra
2026-05-08 22:03:47 +07:00
parent 4f047832ac
commit d00bd666a7
25 changed files with 2161 additions and 765 deletions
+8 -1
View File
@@ -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"
+138 -50
View File
@@ -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");
});
});
+4 -1
View File
@@ -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;
}