mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
sidebar history: post-merge fixes (header, kebab, +) and deeper abstractions
Three behavior fixes from user feedback after the parity pass landed. R1 — Non-git project headers were missing in grouped Sessions while their child rows still rendered. Sessions had inherited the workspace tree's isSidebarProjectFlattened branch, but only half of it: the header was suppressed and the children continued. Sessions doesn't share the workspace tree's flatten semantics — workspaces flatten because "1 workspace = the project IS the workspace, no children to expand"; in Sessions, even a single-workspace non-git project can still have multiple agents worth grouping. Drop the flatten branch from Sessions entirely. SidebarSessionGroup loses isFlattened. Workspace tree's FlattenedProjectRow stays untouched. R2 — The agent kebab had Close + Archive. Sessions agent rows aren't tabs; there's no open tab to close. Drop Close and the orphan chain that came with it (workspace-tab menu imports, session store reach, confirm dialog wiring, resolveSidebarSessionWorkspaceId, etc.). The menu is now Archive-only; useArchiveAgent is the only handler. R3 — Each project header in grouped Sessions now renders a "+" that opens the new-workspace flow scoped to that project's directory. The shared SidebarProjectTrailingActions is the home — Sessions wires its own createButton config (Plus icon, "New session", route via buildHostNewWorkspaceRoute(serverId, sourceDirectory, headerTitle)). R4 — Meta-audit on the shared sidebar/* abstractions found two depth issues that let R1 and R3 silently slip through: 1. SidebarProjectHeaderRow was a chrome primitive. Consumers could render children without rendering a header (R1 bug). Added SidebarProjectSection — an opinionated wrapper that owns "header + children/footer when expanded" so the silent failure mode no longer compiles. Both consumers refactored. 2. SidebarProjectTrailingActions had showNewWorktreeButton: boolean defaulting false. Sessions silently lost the affordance with no warning (R3 bug). Replaced the boolean with a required createButton prop (literal null to opt out). Each consumer must say what it wants — no default suppression. Surfaced for future iterations (not fixed here): hand-wired hover state in both consumers, chevron native visibility, missing project/agent selection state in Sessions, zero-agent-project filter divergence. Tests cover the new wrapper, the trailing-actions API change, the header-always-renders behavior, and the per-project + button navigation. Workspace tree behavior unchanged.
This commit is contained in:
@@ -18,10 +18,11 @@ import {
|
||||
useRef,
|
||||
type ComponentType,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
type MutableRefObject,
|
||||
type Ref,
|
||||
} from "react";
|
||||
import { usePathname } from "expo-router";
|
||||
import { router, usePathname, type Href } from "expo-router";
|
||||
import { navigateToWorkspace } from "@/hooks/use-workspace-navigation";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
@@ -47,7 +48,10 @@ 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 { parseHostWorkspaceRouteFromPathname } from "@/utils/host-routes";
|
||||
import {
|
||||
buildHostNewWorkspaceRoute,
|
||||
parseHostWorkspaceRouteFromPathname,
|
||||
} from "@/utils/host-routes";
|
||||
import {
|
||||
createSidebarWorkspaceEntry,
|
||||
type SidebarProjectEntry,
|
||||
@@ -106,8 +110,11 @@ import { WorkspaceHoverCard } from "@/components/workspace-hover-card";
|
||||
import { GitHubIcon } from "@/components/icons/github-icon";
|
||||
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";
|
||||
import { SidebarProjectSection } from "@/components/sidebar/sidebar-collapsible-project-section";
|
||||
import {
|
||||
SidebarProjectTrailingActions,
|
||||
type SidebarProjectCreateButtonConfig,
|
||||
} from "@/components/sidebar/sidebar-project-trailing-actions";
|
||||
|
||||
function toProjectIconDataUri(icon: { mimeType: string; data: string } | null): string | null {
|
||||
if (!icon) {
|
||||
@@ -230,6 +237,8 @@ interface ProjectHeaderRowProps {
|
||||
onRemoveProject?: () => void;
|
||||
removeProjectStatus?: "idle" | "pending";
|
||||
dragHandleProps?: DraggableListDragHandleProps;
|
||||
isCollapsed?: boolean;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
interface WorkspaceRowInnerProps {
|
||||
@@ -708,6 +717,8 @@ function ProjectHeaderRow({
|
||||
onRemoveProject,
|
||||
removeProjectStatus = "idle",
|
||||
dragHandleProps,
|
||||
isCollapsed = true,
|
||||
children,
|
||||
}: ProjectHeaderRowProps) {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const _mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
|
||||
@@ -728,6 +739,36 @@ function ProjectHeaderRow({
|
||||
|
||||
const handlePointerEnter = useCallback(() => setIsHovered(true), []);
|
||||
const handlePointerLeave = useCallback(() => setIsHovered(false), []);
|
||||
const handleBeginWorkspaceSetup = useCallback(() => {
|
||||
if (!serverId || !project.iconWorkingDir) {
|
||||
return;
|
||||
}
|
||||
router.navigate(
|
||||
buildHostNewWorkspaceRoute(serverId, project.iconWorkingDir, {
|
||||
displayName,
|
||||
}) as Href,
|
||||
);
|
||||
onWorkspacePress?.();
|
||||
}, [displayName, onWorkspacePress, project.iconWorkingDir, serverId]);
|
||||
const createButton = useMemo<SidebarProjectCreateButtonConfig | null>(() => {
|
||||
if (!canCreateWorktree) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
onPress: handleBeginWorkspaceSetup,
|
||||
accessibilityLabel: `Create a new workspace for ${displayName}`,
|
||||
testID: `sidebar-project-new-worktree-${project.projectKey}`,
|
||||
tooltipLabel: "New workspace",
|
||||
icon: "folder-plus",
|
||||
showShortcutHint: isProjectActive,
|
||||
};
|
||||
}, [
|
||||
canCreateWorktree,
|
||||
displayName,
|
||||
handleBeginWorkspaceSetup,
|
||||
isProjectActive,
|
||||
project.projectKey,
|
||||
]);
|
||||
|
||||
const showsWorkspaceStatus =
|
||||
workspace !== null && (isArchiving || workspace.statusBucket !== "done");
|
||||
@@ -755,13 +796,10 @@ function ProjectHeaderRow({
|
||||
projectName={displayName}
|
||||
serverId={serverId}
|
||||
isHovered={isHovered}
|
||||
showNewWorktreeButton={canCreateWorktree}
|
||||
sourceDirectory={project.iconWorkingDir}
|
||||
isProjectActive={isProjectActive}
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
onRemoveProject={onRemoveProject}
|
||||
removeProjectStatus={removeProjectStatus}
|
||||
workspaces={project.workspaces}
|
||||
createButton={createButton}
|
||||
/>
|
||||
{showShortcutBadge && shortcutNumber !== null ? (
|
||||
<View style={styles.shortcutBadge}>
|
||||
@@ -774,6 +812,22 @@ function ProjectHeaderRow({
|
||||
const PressableComponent = menuController
|
||||
? (ProjectHeaderContextMenuTrigger as unknown as ComponentType<PressableProps>)
|
||||
: undefined;
|
||||
const headerProps = useMemo(
|
||||
() => ({
|
||||
leadingVisualOverride,
|
||||
onPressIn: interaction.handlePressIn,
|
||||
onTouchMove: interaction.handleTouchMove,
|
||||
onPressOut: interaction.handlePressOut,
|
||||
PressableComponent,
|
||||
}),
|
||||
[
|
||||
interaction.handlePressIn,
|
||||
interaction.handlePressOut,
|
||||
interaction.handleTouchMove,
|
||||
leadingVisualOverride,
|
||||
PressableComponent,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<View
|
||||
@@ -783,22 +837,22 @@ function ProjectHeaderRow({
|
||||
onPointerEnter={handlePointerEnter}
|
||||
onPointerLeave={handlePointerLeave}
|
||||
>
|
||||
<SidebarProjectHeaderRow
|
||||
<SidebarProjectSection
|
||||
projectKey={project.projectKey}
|
||||
projectName={displayName}
|
||||
iconDataUri={iconDataUri}
|
||||
chevron={chevron}
|
||||
isHovered={isHovered}
|
||||
isDragging={isDragging}
|
||||
selected={selected}
|
||||
leadingVisualOverride={leadingVisualOverride}
|
||||
trailingSlot={trailingSlot}
|
||||
onPress={handlePress}
|
||||
onPressIn={interaction.handlePressIn}
|
||||
onTouchMove={interaction.handleTouchMove}
|
||||
onPressOut={interaction.handlePressOut}
|
||||
testID={`sidebar-project-row-${project.projectKey}`}
|
||||
PressableComponent={PressableComponent}
|
||||
/>
|
||||
isCollapsed={isCollapsed}
|
||||
headerProps={headerProps}
|
||||
>
|
||||
{children}
|
||||
</SidebarProjectSection>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1707,44 +1761,41 @@ function ProjectBlock({
|
||||
selectionEnabled={selectionEnabled}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<ProjectHeaderRow
|
||||
project={project}
|
||||
displayName={displayName}
|
||||
iconDataUri={iconDataUri}
|
||||
workspace={null}
|
||||
selected={false}
|
||||
chevron={rowModel.chevron}
|
||||
onPress={handleToggleCollapsed}
|
||||
serverId={serverId}
|
||||
canCreateWorktree={rowModel.trailingAction === "new_worktree"}
|
||||
isProjectActive={isProjectActive}
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
onWorktreeCreated={onWorktreeCreated}
|
||||
drag={drag}
|
||||
isDragging={isDragging}
|
||||
isArchiving={isRemovingProject}
|
||||
menuController={null}
|
||||
onRemoveProject={handleRemoveProject}
|
||||
removeProjectStatus={isRemovingProject ? "pending" : "idle"}
|
||||
dragHandleProps={dragHandleProps}
|
||||
<ProjectHeaderRow
|
||||
project={project}
|
||||
displayName={displayName}
|
||||
iconDataUri={iconDataUri}
|
||||
workspace={null}
|
||||
selected={false}
|
||||
chevron={rowModel.chevron}
|
||||
onPress={handleToggleCollapsed}
|
||||
serverId={serverId}
|
||||
canCreateWorktree={rowModel.trailingAction === "new_worktree"}
|
||||
isProjectActive={isProjectActive}
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
onWorktreeCreated={onWorktreeCreated}
|
||||
drag={drag}
|
||||
isDragging={isDragging}
|
||||
isArchiving={isRemovingProject}
|
||||
menuController={null}
|
||||
onRemoveProject={handleRemoveProject}
|
||||
removeProjectStatus={isRemovingProject ? "pending" : "idle"}
|
||||
dragHandleProps={dragHandleProps}
|
||||
isCollapsed={collapsed}
|
||||
>
|
||||
<DraggableList
|
||||
testID={`sidebar-workspace-list-${project.projectKey}`}
|
||||
data={project.workspaces}
|
||||
keyExtractor={workspaceKeyExtractor}
|
||||
renderItem={renderWorkspace}
|
||||
onDragEnd={handleWorkspaceDragEnd}
|
||||
scrollEnabled={false}
|
||||
useDragHandle
|
||||
nestable={useNestable}
|
||||
simultaneousGestureRef={parentGestureRef}
|
||||
containerStyle={styles.workspaceListContainer}
|
||||
/>
|
||||
|
||||
{!collapsed ? (
|
||||
<DraggableList
|
||||
testID={`sidebar-workspace-list-${project.projectKey}`}
|
||||
data={project.workspaces}
|
||||
keyExtractor={workspaceKeyExtractor}
|
||||
renderItem={renderWorkspace}
|
||||
onDragEnd={handleWorkspaceDragEnd}
|
||||
scrollEnabled={false}
|
||||
useDragHandle
|
||||
nestable={useNestable}
|
||||
simultaneousGestureRef={parentGestureRef}
|
||||
containerStyle={styles.workspaceListContainer}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
</ProjectHeaderRow>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { cleanup, render } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import { Text } from "react-native";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { SidebarProjectSection } from "./sidebar-collapsible-project-section";
|
||||
|
||||
vi.mock("lucide-react-native", () => {
|
||||
const createIcon = (name: string) => (props: Record<string, unknown>) =>
|
||||
React.createElement("span", { ...props, "data-icon": name });
|
||||
return {
|
||||
ChevronDown: createIcon("ChevronDown"),
|
||||
ChevronRight: createIcon("ChevronRight"),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("react-native-unistyles", () => ({
|
||||
StyleSheet: {
|
||||
create: (styles: unknown) =>
|
||||
typeof styles === "function"
|
||||
? styles({
|
||||
borderRadius: { lg: 8 },
|
||||
colors: {
|
||||
border: "#dddddd",
|
||||
surface2: "#eeeeee",
|
||||
surfaceSidebarHover: "#f5f5f5",
|
||||
},
|
||||
iconSize: { md: 16 },
|
||||
shadow: { md: {} },
|
||||
spacing: { 1: 4, 2: 8, 3: 12 },
|
||||
})
|
||||
: styles,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/components/sidebar/sidebar-project-row-visual", () => ({
|
||||
SidebarProjectIcon: ({ projectName }: { projectName: string }) => <Text>{projectName}</Text>,
|
||||
SidebarProjectRowVisual: ({ projectName }: { projectName: string }) => <Text>{projectName}</Text>,
|
||||
}));
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const collapsedFooter = <Text>Footer row</Text>;
|
||||
|
||||
describe("SidebarProjectSection", () => {
|
||||
it("renders the header and expanded children", () => {
|
||||
const { getByTestId, getByText } = render(
|
||||
<SidebarProjectSection
|
||||
projectKey="project-a"
|
||||
projectName="Project A"
|
||||
iconDataUri={null}
|
||||
onPress={vi.fn()}
|
||||
isHovered={false}
|
||||
chevron="collapse"
|
||||
isCollapsed={false}
|
||||
testID="project-a-header"
|
||||
>
|
||||
<Text>Child row</Text>
|
||||
</SidebarProjectSection>,
|
||||
);
|
||||
|
||||
expect(getByTestId("project-a-header")).toBeTruthy();
|
||||
expect(getByText("Project A")).toBeTruthy();
|
||||
expect(getByText("Child row")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("keeps the header visible and hides children plus footer when collapsed", () => {
|
||||
const { getByTestId, queryByText } = render(
|
||||
<SidebarProjectSection
|
||||
projectKey="project-a"
|
||||
projectName="Project A"
|
||||
iconDataUri={null}
|
||||
onPress={vi.fn()}
|
||||
isHovered={false}
|
||||
chevron="expand"
|
||||
isCollapsed
|
||||
footer={collapsedFooter}
|
||||
testID="project-a-header"
|
||||
>
|
||||
<Text>Child row</Text>
|
||||
</SidebarProjectSection>,
|
||||
);
|
||||
|
||||
expect(getByTestId("project-a-header")).toBeTruthy();
|
||||
expect(queryByText("Child row")).toBeNull();
|
||||
expect(queryByText("Footer row")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -55,6 +55,70 @@ export interface SidebarProjectHeaderRowProps {
|
||||
PressableComponent?: ComponentType<PressableProps & { ref?: Ref<View> }>;
|
||||
}
|
||||
|
||||
export interface SidebarProjectSectionProps {
|
||||
projectKey: string;
|
||||
projectName: string;
|
||||
iconDataUri: string | null;
|
||||
onPress: () => void;
|
||||
isHovered: boolean;
|
||||
chevron: "expand" | "collapse" | null;
|
||||
selected?: boolean;
|
||||
isDragging?: boolean;
|
||||
trailingSlot?: ReactNode;
|
||||
testID?: string;
|
||||
children?: ReactNode;
|
||||
footer?: ReactNode;
|
||||
isCollapsed: boolean;
|
||||
headerProps?: Pick<
|
||||
SidebarProjectHeaderRowProps,
|
||||
| "accessibilityLabel"
|
||||
| "leadingVisualOverride"
|
||||
| "onPressIn"
|
||||
| "onTouchMove"
|
||||
| "onPressOut"
|
||||
| "PressableComponent"
|
||||
>;
|
||||
}
|
||||
|
||||
export const SidebarProjectSection = memo(function SidebarProjectSection({
|
||||
projectName,
|
||||
iconDataUri,
|
||||
onPress,
|
||||
isHovered,
|
||||
chevron,
|
||||
selected = false,
|
||||
isDragging = false,
|
||||
trailingSlot = null,
|
||||
testID,
|
||||
children = null,
|
||||
footer = null,
|
||||
isCollapsed,
|
||||
headerProps,
|
||||
}: SidebarProjectSectionProps): ReactElement {
|
||||
return (
|
||||
<>
|
||||
<SidebarProjectHeaderRow
|
||||
projectName={projectName}
|
||||
iconDataUri={iconDataUri}
|
||||
chevron={chevron}
|
||||
isHovered={isHovered}
|
||||
isDragging={isDragging}
|
||||
selected={selected}
|
||||
trailingSlot={trailingSlot}
|
||||
onPress={onPress}
|
||||
testID={testID}
|
||||
{...headerProps}
|
||||
/>
|
||||
{!isCollapsed ? (
|
||||
<>
|
||||
{children}
|
||||
{footer}
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Shared chrome for a project row that names a section: pressable, hover/press/selected
|
||||
* styles, on-hover chevron leading visual, and a trailing actions slot.
|
||||
|
||||
@@ -1,19 +1,26 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { cleanup, render } from "@testing-library/react";
|
||||
import { cleanup, fireEvent, 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";
|
||||
import {
|
||||
SidebarProjectTrailingActions,
|
||||
type SidebarProjectCreateButtonConfig,
|
||||
} from "./sidebar-project-trailing-actions";
|
||||
|
||||
vi.hoisted(() => {
|
||||
Object.assign(globalThis, { __DEV__: false });
|
||||
});
|
||||
|
||||
const { navigateMock } = vi.hoisted(() => ({
|
||||
navigateMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("expo-router", () => ({
|
||||
router: {
|
||||
navigate: vi.fn(),
|
||||
navigate: navigateMock,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -23,6 +30,7 @@ vi.mock("lucide-react-native", () => {
|
||||
return {
|
||||
FolderPlus: createIcon("FolderPlus"),
|
||||
MoreVertical: createIcon("MoreVertical"),
|
||||
Plus: createIcon("Plus"),
|
||||
Settings: createIcon("Settings"),
|
||||
Trash2: createIcon("Trash2"),
|
||||
};
|
||||
@@ -98,18 +106,26 @@ vi.mock("@/hooks/use-shortcut-keys", () => ({
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
navigateMock.mockClear();
|
||||
});
|
||||
|
||||
const newWorkspaceCreateButton: SidebarProjectCreateButtonConfig = {
|
||||
onPress: () => {},
|
||||
accessibilityLabel: "New workspace",
|
||||
testID: "sidebar-project-new-worktree-project-a",
|
||||
tooltipLabel: "New workspace",
|
||||
icon: "folder-plus",
|
||||
};
|
||||
|
||||
describe("SidebarProjectTrailingActions", () => {
|
||||
it("renders the kebab and new-worktree button when requested", () => {
|
||||
it("renders the kebab and create button when configured", () => {
|
||||
const { getByTestId } = render(
|
||||
<SidebarProjectTrailingActions
|
||||
projectKey="project-a"
|
||||
projectName="Project A"
|
||||
serverId="server-1"
|
||||
isHovered
|
||||
showNewWorktreeButton
|
||||
sourceDirectory="/repo/project-a"
|
||||
createButton={newWorkspaceCreateButton}
|
||||
onRemoveProject={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
@@ -118,13 +134,14 @@ describe("SidebarProjectTrailingActions", () => {
|
||||
expect(getByTestId("sidebar-project-new-worktree-project-a")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders only the kebab when the new-worktree button is omitted", () => {
|
||||
it("renders only the kebab when createButton is null", () => {
|
||||
const { getByTestId, queryByTestId } = render(
|
||||
<SidebarProjectTrailingActions
|
||||
projectKey="project-a"
|
||||
projectName="Project A"
|
||||
serverId="server-1"
|
||||
isHovered
|
||||
createButton={null}
|
||||
onRemoveProject={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
@@ -132,4 +149,39 @@ describe("SidebarProjectTrailingActions", () => {
|
||||
expect(getByTestId("sidebar-project-kebab-project-a")).toBeTruthy();
|
||||
expect(queryByTestId("sidebar-project-new-worktree-project-a")).toBeNull();
|
||||
});
|
||||
|
||||
it("uses the create button override instead of the default route", () => {
|
||||
const createOnPress = vi.fn();
|
||||
|
||||
function Harness() {
|
||||
const createButton = React.useMemo(
|
||||
() => ({
|
||||
onPress: createOnPress,
|
||||
accessibilityLabel: "New session",
|
||||
testID: "sidebar-project-project-a-new-session-button",
|
||||
tooltipLabel: "New session",
|
||||
icon: "plus" as const,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<SidebarProjectTrailingActions
|
||||
projectKey="project-a"
|
||||
projectName="Project A"
|
||||
serverId="server-1"
|
||||
isHovered
|
||||
createButton={createButton}
|
||||
onRemoveProject={vi.fn()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const { getByTestId } = render(<Harness />);
|
||||
|
||||
fireEvent.click(getByTestId("sidebar-project-project-a-new-session-button"));
|
||||
|
||||
expect(createOnPress).toHaveBeenCalledOnce();
|
||||
expect(navigateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,8 +7,8 @@ import {
|
||||
type GestureResponderEvent,
|
||||
type PressableStateCallbackType,
|
||||
} from "react-native";
|
||||
import { router, type Href } from "expo-router";
|
||||
import { FolderPlus, MoreVertical, Settings, Trash2 } from "lucide-react-native";
|
||||
import { router } from "expo-router";
|
||||
import { FolderPlus, MoreVertical, Plus, Settings, Trash2 } from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -31,26 +31,33 @@ 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 { buildProjectSettingsRoute } from "@/utils/host-routes";
|
||||
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-execution";
|
||||
|
||||
export interface SidebarProjectCreateButtonConfig {
|
||||
onPress: () => void;
|
||||
accessibilityLabel: string;
|
||||
testID: string;
|
||||
tooltipLabel: string;
|
||||
icon: "folder-plus" | "plus";
|
||||
showShortcutHint?: boolean;
|
||||
}
|
||||
|
||||
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[];
|
||||
createButton: SidebarProjectCreateButtonConfig | null;
|
||||
}
|
||||
|
||||
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
|
||||
const ThemedFolderPlus = withUnistyles(FolderPlus);
|
||||
const ThemedMoreVertical = withUnistyles(MoreVertical);
|
||||
const ThemedPlus = withUnistyles(Plus);
|
||||
const ThemedSettings = withUnistyles(Settings);
|
||||
const ThemedTrash2 = withUnistyles(Trash2);
|
||||
|
||||
@@ -66,14 +73,11 @@ export function SidebarProjectTrailingActions({
|
||||
projectKey,
|
||||
serverId,
|
||||
isHovered,
|
||||
showNewWorktreeButton = false,
|
||||
projectName,
|
||||
sourceDirectory,
|
||||
isProjectActive = false,
|
||||
onWorkspacePress,
|
||||
onRemoveProject,
|
||||
removeProjectStatus,
|
||||
workspaces = [],
|
||||
createButton,
|
||||
}: SidebarProjectTrailingActionsProps): ReactElement {
|
||||
const isMobileBreakpoint = useIsCompactFormFactor();
|
||||
const actionsVisible = isHovered || platformIsNative || isMobileBreakpoint;
|
||||
@@ -86,26 +90,10 @@ export function SidebarProjectTrailingActions({
|
||||
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}`}
|
||||
/>
|
||||
{createButton ? (
|
||||
<ProjectCreateButton actionsVisible={actionsVisible} createButton={createButton} />
|
||||
) : null}
|
||||
<View
|
||||
style={!actionsVisible && styles.projectKebabButtonHidden}
|
||||
@@ -121,6 +109,26 @@ export function SidebarProjectTrailingActions({
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectCreateButton({
|
||||
actionsVisible,
|
||||
createButton,
|
||||
}: {
|
||||
actionsVisible: boolean;
|
||||
createButton: SidebarProjectCreateButtonConfig;
|
||||
}): ReactElement {
|
||||
return (
|
||||
<NewWorktreeButton
|
||||
accessibilityLabel={createButton.accessibilityLabel}
|
||||
icon={createButton.icon}
|
||||
onPress={createButton.onPress}
|
||||
visible={actionsVisible}
|
||||
showShortcutHint={createButton.showShortcutHint ?? false}
|
||||
testID={createButton.testID}
|
||||
tooltipLabel={createButton.tooltipLabel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function useSidebarProjectRemoval({
|
||||
projectKey,
|
||||
projectName,
|
||||
@@ -299,19 +307,23 @@ function ProjectKebabMenu({
|
||||
}
|
||||
|
||||
function NewWorktreeButton({
|
||||
displayName,
|
||||
accessibilityLabel,
|
||||
icon,
|
||||
onPress,
|
||||
visible,
|
||||
loading = false,
|
||||
testID,
|
||||
showShortcutHint = false,
|
||||
tooltipLabel,
|
||||
}: {
|
||||
displayName: string;
|
||||
accessibilityLabel: string;
|
||||
icon: "folder-plus" | "plus";
|
||||
onPress: () => void;
|
||||
visible: boolean;
|
||||
loading?: boolean;
|
||||
testID: string;
|
||||
showShortcutHint?: boolean;
|
||||
tooltipLabel: string;
|
||||
}) {
|
||||
const newWorktreeKeys = useShortcutKeys("new-worktree");
|
||||
|
||||
@@ -332,6 +344,20 @@ function NewWorktreeButton({
|
||||
[onPress],
|
||||
);
|
||||
|
||||
const renderIcon = useCallback(
|
||||
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => {
|
||||
if (loading) {
|
||||
return <ThemedActivityIndicator size={14} uniProps={foregroundMutedColorMapping} />;
|
||||
}
|
||||
const uniProps = hovered || pressed ? foregroundColorMapping : foregroundMutedColorMapping;
|
||||
if (icon === "plus") {
|
||||
return <ThemedPlus size={15} uniProps={uniProps} />;
|
||||
}
|
||||
return <ThemedFolderPlus size={15} uniProps={uniProps} />;
|
||||
},
|
||||
[icon, loading],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.projectTrailingControlSlot} pointerEvents={visible ? "auto" : "none"}>
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
@@ -341,26 +367,15 @@ function NewWorktreeButton({
|
||||
onPress={handlePress}
|
||||
disabled={loading}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Create a new workspace for ${displayName}`}
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
testID={testID}
|
||||
>
|
||||
{({ hovered, pressed }) =>
|
||||
loading ? (
|
||||
<ThemedActivityIndicator size={14} uniProps={foregroundMutedColorMapping} />
|
||||
) : (
|
||||
<ThemedFolderPlus
|
||||
size={15}
|
||||
uniProps={
|
||||
hovered || pressed ? foregroundColorMapping : foregroundMutedColorMapping
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{renderIcon}
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="center" offset={8}>
|
||||
<View style={styles.projectActionTooltipRow}>
|
||||
<Text style={styles.projectActionTooltipText}>New workspace</Text>
|
||||
<Text style={styles.projectActionTooltipText}>{tooltipLabel}</Text>
|
||||
{showShortcutHint && newWorktreeKeys ? (
|
||||
<Shortcut chord={newWorktreeKeys} style={styles.projectActionTooltipShortcut} />
|
||||
) : null}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import React, { memo, useCallback, useMemo, useState, type ReactElement, type Ref } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { router, type Href } from "expo-router";
|
||||
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 { SidebarProjectSection } from "@/components/sidebar/sidebar-collapsible-project-section";
|
||||
import {
|
||||
SidebarProjectTrailingActions,
|
||||
type SidebarProjectCreateButtonConfig,
|
||||
} 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";
|
||||
@@ -13,6 +17,7 @@ import type {
|
||||
SidebarWorkspaceEntry,
|
||||
} from "@/hooks/use-sidebar-workspaces-list";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { buildHostNewWorkspaceRoute } from "@/utils/host-routes";
|
||||
import {
|
||||
deriveGroupedSidebarSessions,
|
||||
type SidebarSessionAgentProject,
|
||||
@@ -109,6 +114,8 @@ export const SidebarSessionGroupHeader = memo(function SidebarSessionGroupHeader
|
||||
drag,
|
||||
dragHandleProps,
|
||||
onToggleCollapsed,
|
||||
children,
|
||||
footer = null,
|
||||
}: {
|
||||
serverId: string | null;
|
||||
projectKey: string;
|
||||
@@ -120,6 +127,8 @@ export const SidebarSessionGroupHeader = memo(function SidebarSessionGroupHeader
|
||||
drag?: () => void;
|
||||
dragHandleProps?: DraggableListDragHandleProps;
|
||||
onToggleCollapsed: (projectKey: string) => void;
|
||||
children?: React.ReactNode;
|
||||
footer?: React.ReactNode;
|
||||
}): ReactElement {
|
||||
const { icon } = useProjectIconQuery({ serverId: serverId ?? "", cwd: projectIconKey ?? "" });
|
||||
const dataUri = useMemo(() => {
|
||||
@@ -132,6 +141,30 @@ export const SidebarSessionGroupHeader = memo(function SidebarSessionGroupHeader
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const handlePointerEnter = useCallback(() => setIsHovered(true), []);
|
||||
const handlePointerLeave = useCallback(() => setIsHovered(false), []);
|
||||
const sourceDirectory = projectIconKey ?? undefined;
|
||||
const handleCreateSessionPress = useCallback(() => {
|
||||
if (!serverId || !sourceDirectory) {
|
||||
return;
|
||||
}
|
||||
router.navigate(
|
||||
buildHostNewWorkspaceRoute(serverId, sourceDirectory, {
|
||||
headerTitle: "New session",
|
||||
}) as Href,
|
||||
);
|
||||
}, [serverId, sourceDirectory]);
|
||||
const createButton = useMemo<SidebarProjectCreateButtonConfig | null>(
|
||||
() =>
|
||||
serverId && sourceDirectory
|
||||
? {
|
||||
onPress: handleCreateSessionPress,
|
||||
accessibilityLabel: "New session",
|
||||
testID: `sidebar-project-${projectKey}-new-session-button`,
|
||||
tooltipLabel: "New session",
|
||||
icon: "plus" as const,
|
||||
}
|
||||
: null,
|
||||
[handleCreateSessionPress, projectKey, serverId, sourceDirectory],
|
||||
);
|
||||
const interaction = useLongPressDragInteraction({
|
||||
drag: drag ?? noopDrag,
|
||||
menuController: null,
|
||||
@@ -144,9 +177,10 @@ export const SidebarSessionGroupHeader = memo(function SidebarSessionGroupHeader
|
||||
serverId={serverId}
|
||||
isHovered={isHovered}
|
||||
workspaces={workspaces}
|
||||
createButton={createButton}
|
||||
/>
|
||||
),
|
||||
[isHovered, projectKey, projectName, serverId, workspaces],
|
||||
[createButton, isHovered, projectKey, projectName, serverId, workspaces],
|
||||
);
|
||||
const handlePress = useCallback(() => {
|
||||
if (interaction.didLongPressRef.current) {
|
||||
@@ -155,6 +189,22 @@ export const SidebarSessionGroupHeader = memo(function SidebarSessionGroupHeader
|
||||
}
|
||||
onToggleCollapsed(projectKey);
|
||||
}, [interaction.didLongPressRef, onToggleCollapsed, projectKey]);
|
||||
const headerProps = useMemo(
|
||||
() => ({
|
||||
accessibilityLabel: isCollapsed ? `Expand ${projectName}` : `Collapse ${projectName}`,
|
||||
onPressIn: drag ? interaction.handlePressIn : undefined,
|
||||
onTouchMove: drag ? interaction.handleTouchMove : undefined,
|
||||
onPressOut: drag ? interaction.handlePressOut : undefined,
|
||||
}),
|
||||
[
|
||||
drag,
|
||||
interaction.handlePressIn,
|
||||
interaction.handlePressOut,
|
||||
interaction.handleTouchMove,
|
||||
isCollapsed,
|
||||
projectName,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<View
|
||||
@@ -164,7 +214,8 @@ export const SidebarSessionGroupHeader = memo(function SidebarSessionGroupHeader
|
||||
onPointerEnter={handlePointerEnter}
|
||||
onPointerLeave={handlePointerLeave}
|
||||
>
|
||||
<SidebarProjectHeaderRow
|
||||
<SidebarProjectSection
|
||||
projectKey={projectKey}
|
||||
projectName={projectName}
|
||||
iconDataUri={dataUri}
|
||||
chevron={isCollapsed ? "expand" : "collapse"}
|
||||
@@ -172,12 +223,13 @@ export const SidebarSessionGroupHeader = memo(function SidebarSessionGroupHeader
|
||||
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}`}
|
||||
/>
|
||||
isCollapsed={isCollapsed}
|
||||
footer={footer}
|
||||
headerProps={headerProps}
|
||||
>
|
||||
{children}
|
||||
</SidebarProjectSection>
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { act, fireEvent, render, renderHook } from "@testing-library/react";
|
||||
import { act, cleanup, fireEvent, render, renderHook } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { Pressable, View } from "react-native";
|
||||
@@ -55,6 +55,10 @@ vi.hoisted(() => {
|
||||
Object.assign(globalThis, { __DEV__: false });
|
||||
});
|
||||
|
||||
const { routerNavigateMock } = vi.hoisted(() => ({
|
||||
routerNavigateMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-project-icon-query", () => ({
|
||||
useProjectIconQuery: () => ({ icon: null, isLoading: false, isError: false }),
|
||||
}));
|
||||
@@ -67,6 +71,7 @@ vi.mock("lucide-react-native", () => {
|
||||
ChevronRight: createIcon("ChevronRight"),
|
||||
FolderPlus: createIcon("FolderPlus"),
|
||||
MoreVertical: createIcon("MoreVertical"),
|
||||
Plus: createIcon("Plus"),
|
||||
Settings: createIcon("Settings"),
|
||||
Trash2: createIcon("Trash2"),
|
||||
};
|
||||
@@ -74,7 +79,7 @@ vi.mock("lucide-react-native", () => {
|
||||
|
||||
vi.mock("expo-router", () => ({
|
||||
router: {
|
||||
navigate: vi.fn(),
|
||||
navigate: routerNavigateMock,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -234,7 +239,9 @@ function useGroupedBoundaryHarness(input?: { collapsedProjectKeys?: ReadonlySet<
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
useSessionStore.setState({ sessions: {}, agentLastActivity: new Map() });
|
||||
routerNavigateMock.mockClear();
|
||||
});
|
||||
|
||||
describe("sidebar session render boundaries", () => {
|
||||
@@ -386,8 +393,8 @@ describe("sidebar session render boundaries", () => {
|
||||
expect(onPress).toHaveBeenCalledWith("project-a");
|
||||
});
|
||||
|
||||
it("renders the project kebab in the grouped header", () => {
|
||||
const { getByTestId, queryByTestId } = render(
|
||||
it("renders the project kebab and new-session button in the grouped header", () => {
|
||||
const { getByTestId } = render(
|
||||
<SidebarSessionGroupHeader
|
||||
serverId="server-1"
|
||||
projectKey="project-a"
|
||||
@@ -400,6 +407,26 @@ describe("sidebar session render boundaries", () => {
|
||||
);
|
||||
|
||||
expect(getByTestId("sidebar-project-kebab-project-a")).toBeTruthy();
|
||||
expect(queryByTestId("sidebar-project-new-worktree-project-a")).toBeNull();
|
||||
expect(getByTestId("sidebar-project-project-a-new-session-button")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("opens a new session scoped to the grouped project root", () => {
|
||||
const { getByTestId } = render(
|
||||
<SidebarSessionGroupHeader
|
||||
serverId="server-1"
|
||||
projectKey="project-a"
|
||||
projectName="Project A"
|
||||
projectIconKey="/repo/a"
|
||||
workspaces={HARNESS_PROJECTS[0].workspaces}
|
||||
isCollapsed={false}
|
||||
onToggleCollapsed={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(getByTestId("sidebar-project-project-a-new-session-button"));
|
||||
|
||||
expect(routerNavigateMock).toHaveBeenCalledWith(
|
||||
"/h/server-1/new?dir=%2Frepo%2Fa&title=New+session",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -264,7 +264,6 @@ describe("deriveGroupedSidebarSessions", () => {
|
||||
hiddenCount: 0,
|
||||
isExpanded: false,
|
||||
isCollapsed: false,
|
||||
isFlattened: false,
|
||||
totalCount: 3,
|
||||
},
|
||||
]);
|
||||
@@ -389,7 +388,7 @@ describe("deriveGroupedSidebarSessions", () => {
|
||||
expect(groups.map((group) => group.projectKey)).toEqual(["project-a"]);
|
||||
});
|
||||
|
||||
it("flattens a single non-git workspace project (no header, ignores collapse)", () => {
|
||||
it("keeps a single-workspace non-git project grouped and collapsible", () => {
|
||||
const projectA = testProject("project-a", { projectKind: "directory", workspaceCount: 1 });
|
||||
const groups = deriveGroupedSidebarSessions({
|
||||
agentsWithProjects: agentProjects(["a1", "a2"], projectA),
|
||||
@@ -399,13 +398,29 @@ describe("deriveGroupedSidebarSessions", () => {
|
||||
});
|
||||
|
||||
expect(groups[0]).toMatchObject({
|
||||
isFlattened: true,
|
||||
projectKey: "project-a",
|
||||
isCollapsed: true,
|
||||
visibleIds: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("renders a single-workspace non-git project group when expanded", () => {
|
||||
const projectA = testProject("project-a", { projectKind: "directory", workspaceCount: 1 });
|
||||
const groups = deriveGroupedSidebarSessions({
|
||||
agentsWithProjects: agentProjects(["a1", "a2"], projectA),
|
||||
projects: [projectA],
|
||||
previewExpandedProjects: new Set(),
|
||||
});
|
||||
|
||||
expect(groups[0]).toMatchObject({
|
||||
projectKey: "project-a",
|
||||
projectName: "project-a",
|
||||
isCollapsed: false,
|
||||
visibleIds: ["a1", "a2"],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps multi-workspace non-git projects collapsible", () => {
|
||||
it("renders multi-workspace non-git project groups too", () => {
|
||||
const projectA = testProject("project-a", { projectKind: "directory", workspaceCount: 2 });
|
||||
const groups = deriveGroupedSidebarSessions({
|
||||
agentsWithProjects: agentProjects(["a1", "a2"], projectA),
|
||||
@@ -415,12 +430,12 @@ describe("deriveGroupedSidebarSessions", () => {
|
||||
});
|
||||
|
||||
expect(groups[0]).toMatchObject({
|
||||
isFlattened: false,
|
||||
isCollapsed: true,
|
||||
visibleIds: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps single-workspace git projects collapsible (not flattened)", () => {
|
||||
it("keeps single-workspace git projects collapsible", () => {
|
||||
const projectA = testProject("project-a", { projectKind: "git", workspaceCount: 1 });
|
||||
const groups = deriveGroupedSidebarSessions({
|
||||
agentsWithProjects: agentProjects(["a1", "a2"], projectA),
|
||||
@@ -430,7 +445,6 @@ describe("deriveGroupedSidebarSessions", () => {
|
||||
});
|
||||
|
||||
expect(groups[0]).toMatchObject({
|
||||
isFlattened: false,
|
||||
isCollapsed: true,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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 {
|
||||
@@ -20,7 +19,6 @@ export interface SidebarSessionGroup {
|
||||
hiddenCount: number;
|
||||
isExpanded: boolean;
|
||||
isCollapsed: boolean;
|
||||
isFlattened: boolean;
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
@@ -167,8 +165,7 @@ export function deriveGroupedSidebarSessions(input: {
|
||||
if (!ids || ids.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const isFlattened = isSidebarProjectFlattened(project);
|
||||
const isCollapsed = !isFlattened && collapsedProjectKeys.has(project.projectKey);
|
||||
const isCollapsed = collapsedProjectKeys.has(project.projectKey);
|
||||
const isExpanded = !isCollapsed && previewExpandedProjects.has(project.projectKey);
|
||||
groups.push({
|
||||
projectKey: project.projectKey,
|
||||
@@ -178,7 +175,6 @@ export function deriveGroupedSidebarSessions(input: {
|
||||
hiddenCount: hiddenCountFor({ totalCount: ids.length, isCollapsed, isExpanded, limit }),
|
||||
isExpanded,
|
||||
isCollapsed,
|
||||
isFlattened,
|
||||
totalCount: ids.length,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,33 +1,19 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { cleanup, fireEvent, render, waitFor } from "@testing-library/react";
|
||||
import type { DaemonClient } from "@server/client/daemon-client";
|
||||
import { cleanup, fireEvent, render } from "@testing-library/react";
|
||||
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(() => ({
|
||||
const { archiveAgentMock, platformState } = 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", () => ({
|
||||
@@ -37,10 +23,6 @@ vi.mock("@/hooks/use-archive-agent", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/utils/confirm-dialog", () => ({
|
||||
confirmDialog: confirmDialogMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/constants/platform", () => ({
|
||||
get isNative() {
|
||||
return platformState.isNative;
|
||||
@@ -84,7 +66,6 @@ vi.mock("lucide-react-native", () => {
|
||||
return {
|
||||
Archive: createIcon("Archive"),
|
||||
MoreVertical: createIcon("MoreVertical"),
|
||||
X: createIcon("X"),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -109,94 +90,16 @@ vi.mock("react-native-unistyles", () => ({
|
||||
}));
|
||||
|
||||
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", () => {
|
||||
@@ -224,35 +127,15 @@ describe("SidebarSessionRowKebabMenu", () => {
|
||||
});
|
||||
|
||||
it("archives the agent from the archive item", () => {
|
||||
const { getByTestId } = render(
|
||||
const { getAllByTestId, getByTestId, queryByTestId } = render(
|
||||
<SidebarSessionRowKebabMenu serverId={SERVER_ID} agentId={AGENT_ID} isHovered />,
|
||||
);
|
||||
|
||||
expect(getAllByTestId(/^sidebar-session-menu-server-1-agent-1-/)).toHaveLength(1);
|
||||
expect(queryByTestId(`sidebar-session-menu-${SERVER_ID}-${AGENT_ID}-close`)).toBeNull();
|
||||
|
||||
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}`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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 React, { memo, useCallback, type ReactElement } from "react";
|
||||
import { type PressableStateCallbackType } from "react-native";
|
||||
import { Archive, MoreVertical } from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -10,19 +10,7 @@ import {
|
||||
} 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;
|
||||
@@ -32,7 +20,6 @@ export interface SidebarSessionRowKebabMenuProps {
|
||||
|
||||
const ThemedArchive = withUnistyles(Archive);
|
||||
const ThemedMoreVertical = withUnistyles(MoreVertical);
|
||||
const ThemedX = withUnistyles(X);
|
||||
|
||||
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||
@@ -54,8 +41,6 @@ function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }) {
|
||||
);
|
||||
}
|
||||
|
||||
function noop() {}
|
||||
|
||||
export const SidebarSessionRowKebabMenu = memo(function SidebarSessionRowKebabMenu({
|
||||
serverId,
|
||||
agentId,
|
||||
@@ -63,95 +48,12 @@ export const SidebarSessionRowKebabMenu = memo(function SidebarSessionRowKebabMe
|
||||
}: 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) {
|
||||
if (!visible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -167,7 +69,6 @@ export const SidebarSessionRowKebabMenu = memo(function SidebarSessionRowKebabMe
|
||||
{renderKebabTriggerIcon}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" width={220}>
|
||||
<SidebarSessionMenuItem entry={closeEntry} />
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-session-menu-${serverId}-${agentId}-archive`}
|
||||
leading={archiveLeadingIcon}
|
||||
@@ -180,37 +81,6 @@ export const SidebarSessionRowKebabMenu = memo(function SidebarSessionRowKebabMe
|
||||
);
|
||||
});
|
||||
|
||||
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,
|
||||
@@ -220,8 +90,4 @@ const styles = StyleSheet.create((theme) => ({
|
||||
kebabButtonHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
menuItemHint: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -287,35 +287,45 @@ const SidebarSessionGroupSection = memo(function SidebarSessionGroupSection({
|
||||
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 ? (
|
||||
const footer = useMemo(
|
||||
() =>
|
||||
showFooter ? (
|
||||
<SidebarSessionGroupFooter
|
||||
projectKey={group.projectKey}
|
||||
hiddenCount={group.hiddenCount}
|
||||
isExpanded={group.isExpanded}
|
||||
onPress={onProjectPreviewExpandedToggle}
|
||||
/>
|
||||
) : null}
|
||||
) : null,
|
||||
[
|
||||
group.hiddenCount,
|
||||
group.isExpanded,
|
||||
group.projectKey,
|
||||
onProjectPreviewExpandedToggle,
|
||||
showFooter,
|
||||
],
|
||||
);
|
||||
return (
|
||||
<View>
|
||||
<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}
|
||||
footer={footer}
|
||||
>
|
||||
{serverId
|
||||
? group.visibleIds.map((id) => (
|
||||
<SidebarSessionRow key={id} id={id} serverId={serverId} indented />
|
||||
))
|
||||
: null}
|
||||
</SidebarSessionGroupHeader>
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user