From bcd32962f753f7fa0948ce89b755275a50eb4736 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 8 May 2026 23:04:27 +0700 Subject: [PATCH] sidebar history: post-merge fixes (header, kebab, +) and deeper abstractions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/components/sidebar-workspace-list.tsx | 155 ++++++++++++------ ...debar-collapsible-project-section.test.tsx | 90 ++++++++++ .../sidebar-collapsible-project-section.tsx | 64 ++++++++ .../sidebar-project-trailing-actions.test.tsx | 66 +++++++- .../sidebar-project-trailing-actions.tsx | 105 +++++++----- .../app/src/sidebar-sessions/grouped-view.tsx | 70 +++++++- .../render-boundaries.test.tsx | 37 ++++- .../session-filtering.test.ts | 28 +++- .../src/sidebar-sessions/session-filtering.ts | 6 +- .../session-row-actions.test.tsx | 129 +-------------- .../sidebar-sessions/session-row-actions.tsx | 142 +--------------- packages/app/src/sidebar-sessions/view.tsx | 56 ++++--- 12 files changed, 534 insertions(+), 414 deletions(-) create mode 100644 packages/app/src/components/sidebar/sidebar-collapsible-project-section.test.tsx diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index 394f025ca..85f332a8e 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -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(() => { + 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 ? ( @@ -774,6 +812,22 @@ function ProjectHeaderRow({ const PressableComponent = menuController ? (ProjectHeaderContextMenuTrigger as unknown as ComponentType) : undefined; + const headerProps = useMemo( + () => ({ + leadingVisualOverride, + onPressIn: interaction.handlePressIn, + onTouchMove: interaction.handleTouchMove, + onPressOut: interaction.handlePressOut, + PressableComponent, + }), + [ + interaction.handlePressIn, + interaction.handlePressOut, + interaction.handleTouchMove, + leadingVisualOverride, + PressableComponent, + ], + ); return ( - + isCollapsed={isCollapsed} + headerProps={headerProps} + > + {children} + ); } @@ -1707,44 +1761,41 @@ function ProjectBlock({ selectionEnabled={selectionEnabled} /> ) : ( - <> - + - - {!collapsed ? ( - - ) : null} - + )} ); diff --git a/packages/app/src/components/sidebar/sidebar-collapsible-project-section.test.tsx b/packages/app/src/components/sidebar/sidebar-collapsible-project-section.test.tsx new file mode 100644 index 000000000..ceeb7a1b8 --- /dev/null +++ b/packages/app/src/components/sidebar/sidebar-collapsible-project-section.test.tsx @@ -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) => + 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 }) => {projectName}, + SidebarProjectRowVisual: ({ projectName }: { projectName: string }) => {projectName}, +})); + +afterEach(cleanup); + +const collapsedFooter = Footer row; + +describe("SidebarProjectSection", () => { + it("renders the header and expanded children", () => { + const { getByTestId, getByText } = render( + + Child row + , + ); + + 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( + + Child row + , + ); + + expect(getByTestId("project-a-header")).toBeTruthy(); + expect(queryByText("Child row")).toBeNull(); + expect(queryByText("Footer row")).toBeNull(); + }); +}); diff --git a/packages/app/src/components/sidebar/sidebar-collapsible-project-section.tsx b/packages/app/src/components/sidebar/sidebar-collapsible-project-section.tsx index 7d6203fba..9d90280a4 100644 --- a/packages/app/src/components/sidebar/sidebar-collapsible-project-section.tsx +++ b/packages/app/src/components/sidebar/sidebar-collapsible-project-section.tsx @@ -55,6 +55,70 @@ export interface SidebarProjectHeaderRowProps { PressableComponent?: ComponentType }>; } +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 ( + <> + + {!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. diff --git a/packages/app/src/components/sidebar/sidebar-project-trailing-actions.test.tsx b/packages/app/src/components/sidebar/sidebar-project-trailing-actions.test.tsx index d8b1197b3..9b4ac142d 100644 --- a/packages/app/src/components/sidebar/sidebar-project-trailing-actions.test.tsx +++ b/packages/app/src/components/sidebar/sidebar-project-trailing-actions.test.tsx @@ -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( , ); @@ -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( , ); @@ -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 ( + + ); + } + + const { getByTestId } = render(); + + fireEvent.click(getByTestId("sidebar-project-project-a-new-session-button")); + + expect(createOnPress).toHaveBeenCalledOnce(); + expect(navigateMock).not.toHaveBeenCalled(); + }); }); diff --git a/packages/app/src/components/sidebar/sidebar-project-trailing-actions.tsx b/packages/app/src/components/sidebar/sidebar-project-trailing-actions.tsx index c6573f854..9c72d62d0 100644 --- a/packages/app/src/components/sidebar/sidebar-project-trailing-actions.tsx +++ b/packages/app/src/components/sidebar/sidebar-project-trailing-actions.tsx @@ -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 ( - {showNewWorktreeButton && serverId && sourceDirectory ? ( - + {createButton ? ( + ) : null} + ); +} + 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 ; + } + const uniProps = hovered || pressed ? foregroundColorMapping : foregroundMutedColorMapping; + if (icon === "plus") { + return ; + } + return ; + }, + [icon, loading], + ); + return ( @@ -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 ? ( - - ) : ( - - ) - } + {renderIcon} - New workspace + {tooltipLabel} {showShortcutHint && newWorktreeKeys ? ( ) : null} diff --git a/packages/app/src/sidebar-sessions/grouped-view.tsx b/packages/app/src/sidebar-sessions/grouped-view.tsx index 8dd214bc6..501ae0f13 100644 --- a/packages/app/src/sidebar-sessions/grouped-view.tsx +++ b/packages/app/src/sidebar-sessions/grouped-view.tsx @@ -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( + () => + 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 ( - + isCollapsed={isCollapsed} + footer={footer} + headerProps={headerProps} + > + {children} + ); }); diff --git a/packages/app/src/sidebar-sessions/render-boundaries.test.tsx b/packages/app/src/sidebar-sessions/render-boundaries.test.tsx index 3346fe9af..7cf01f3a2 100644 --- a/packages/app/src/sidebar-sessions/render-boundaries.test.tsx +++ b/packages/app/src/sidebar-sessions/render-boundaries.test.tsx @@ -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( { ); 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( + , + ); + + fireEvent.click(getByTestId("sidebar-project-project-a-new-session-button")); + + expect(routerNavigateMock).toHaveBeenCalledWith( + "/h/server-1/new?dir=%2Frepo%2Fa&title=New+session", + ); }); }); diff --git a/packages/app/src/sidebar-sessions/session-filtering.test.ts b/packages/app/src/sidebar-sessions/session-filtering.test.ts index e5c7409eb..af4998fb7 100644 --- a/packages/app/src/sidebar-sessions/session-filtering.test.ts +++ b/packages/app/src/sidebar-sessions/session-filtering.test.ts @@ -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, }); }); diff --git a/packages/app/src/sidebar-sessions/session-filtering.ts b/packages/app/src/sidebar-sessions/session-filtering.ts index f1272118b..70daff4b5 100644 --- a/packages/app/src/sidebar-sessions/session-filtering.ts +++ b/packages/app/src/sidebar-sessions/session-filtering.ts @@ -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, }); } diff --git a/packages/app/src/sidebar-sessions/session-row-actions.test.tsx b/packages/app/src/sidebar-sessions/session-row-actions.test.tsx index 3ad788639..9a8a25474 100644 --- a/packages/app/src/sidebar-sessions/session-row-actions.test.tsx +++ b/packages/app/src/sidebar-sessions/session-row-actions.test.tsx @@ -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 { - return { ...AGENT_DEFAULTS, ...input }; -} - -function workspace(input: Partial = {}): WorkspaceDescriptor { - return { - id: WORKSPACE_ID, - projectId: "project-1", - projectDisplayName: "Project", - projectRootPath: "/repo/project", - workspaceDirectory: CWD, - projectKind: "git", - workspaceKind: "worktree", - name: "workspace", - status: "done", - archivingAt: null, - diffStat: null, - scripts: [], - ...input, - }; -} - -function seedState(agent: Agent = makeAgent()) { - useSessionStore.getState().initializeSession(SERVER_ID, {} as unknown as DaemonClient); - useSessionStore.getState().setAgents(SERVER_ID, new Map([[agent.id, agent]])); - useSessionStore.getState().setWorkspaces(SERVER_ID, new Map([[WORKSPACE_ID, workspace()]])); -} beforeEach(() => { archiveAgentMock.mockReset(); archiveAgentMock.mockResolvedValue(undefined); - confirmDialogMock.mockReset(); - confirmDialogMock.mockResolvedValue(true); - closeTabMock.mockReset(); - hideAgentMock.mockReset(); - unpinAgentMock.mockReset(); platformState.isNative = false; - useWorkspaceLayoutStore.setState({ - closeTab: closeTabMock, - hideAgent: hideAgentMock, - unpinAgent: unpinAgentMock, - }); }); afterEach(() => { cleanup(); - useSessionStore.setState({ sessions: {}, agentLastActivity: new Map() }); }); describe("SidebarSessionRowKebabMenu", () => { @@ -224,35 +127,15 @@ describe("SidebarSessionRowKebabMenu", () => { }); it("archives the agent from the archive item", () => { - const { getByTestId } = render( + const { getAllByTestId, getByTestId, queryByTestId } = render( , ); + 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( - , - ); - - fireEvent.click(getByTestId(`sidebar-session-menu-${SERVER_ID}-${AGENT_ID}-close`)); - - await waitFor(() => { - expect(archiveAgentMock).toHaveBeenCalledWith({ serverId: SERVER_ID, agentId: AGENT_ID }); - }); - expect(confirmDialogMock).toHaveBeenCalledWith( - expect.objectContaining({ - title: "Archive running agent?", - confirmLabel: "Archive", - }), - ); - expect(unpinAgentMock).toHaveBeenCalledWith(`${SERVER_ID}:${WORKSPACE_ID}`, AGENT_ID); - expect(hideAgentMock).toHaveBeenCalledWith(`${SERVER_ID}:${WORKSPACE_ID}`, AGENT_ID); - expect(closeTabMock).toHaveBeenCalledWith(`${SERVER_ID}:${WORKSPACE_ID}`, `agent_${AGENT_ID}`); - }); }); diff --git a/packages/app/src/sidebar-sessions/session-row-actions.tsx b/packages/app/src/sidebar-sessions/session-row-actions.tsx index 9c243504b..c3df1ce96 100644 --- a/packages/app/src/sidebar-sessions/session-row-actions.tsx +++ b/packages/app/src/sidebar-sessions/session-row-actions.tsx @@ -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( - () => ({ - key: `agent_${agentId}`, - tabId: `agent_${agentId}`, - kind: "agent", - target: { kind: "agent", agentId }, - }), - [agentId], - ); const handleArchiveAgent = useCallback(() => { void archiveAgent({ serverId, agentId }).catch(() => {}); }, [agentId, archiveAgent, serverId]); - const handleCloseAgentTab = useCallback(async () => { - const agent = useSessionStore.getState().sessions[serverId]?.agents?.get(agentId) ?? null; - const isRunning = agent?.status === "running" || agent?.status === "initializing"; - - if (isRunning) { - const confirmed = await confirmDialog({ - title: "Archive running agent?", - message: "This agent is still running. Archiving it will stop the agent and close the tab.", - confirmLabel: "Archive", - cancelLabel: "Cancel", - destructive: true, - }); - if (!confirmed) { - return; - } - } - - const workspaceId = agent?.cwd - ? resolveSidebarSessionWorkspaceId({ - agent: { - id: agentId, - serverId, - cwd: agent.cwd, - archivedAt: null, - }, - workspaces: useSessionStore.getState().sessions[serverId]?.workspaces?.values(), - }) - : null; - const persistenceKey = workspaceId - ? buildWorkspaceTabPersistenceKey({ serverId, workspaceId }) - : null; - - if (persistenceKey) { - unpinWorkspaceAgent(persistenceKey, agentId); - hideWorkspaceAgent(persistenceKey, agentId); - closeWorkspaceTab(persistenceKey, tab.tabId); - } - - void archiveAgent({ serverId, agentId }).catch(() => {}); - }, [ - agentId, - archiveAgent, - closeWorkspaceTab, - hideWorkspaceAgent, - serverId, - tab.tabId, - unpinWorkspaceAgent, - ]); - - const closeEntry = useMemo( - () => - buildWorkspaceTabMenuEntries({ - surface: "desktop", - tab, - index: 0, - tabCount: 1, - menuTestIDBase: `sidebar-session-menu-${serverId}-${agentId}`, - onCopyResumeCommand: noop, - onCopyAgentId: noop, - onReloadAgent: noop, - onCloseTab: handleCloseAgentTab, - onCloseTabsBefore: noop, - onCloseTabsAfter: noop, - onCloseOtherTabs: noop, - }).find((entry) => entry.kind === "item" && entry.key === "close") as - | Extract - | undefined, - [agentId, handleCloseAgentTab, serverId, tab], - ); - - if (!visible || !closeEntry) { + if (!visible) { return null; } @@ -167,7 +69,6 @@ export const SidebarSessionRowKebabMenu = memo(function SidebarSessionRowKebabMe {renderKebabTriggerIcon} - ; -}) { - const leading = useMemo(() => { - if (entry.icon === "x") { - return ; - } - return undefined; - }, [entry.icon]); - const trailing = useMemo( - () => (entry.hint ? {entry.hint} : undefined), - [entry.hint], - ); - - return ( - - {entry.label} - - ); -} - const styles = StyleSheet.create((theme) => ({ kebabButton: { padding: 2, @@ -220,8 +90,4 @@ const styles = StyleSheet.create((theme) => ({ kebabButtonHovered: { backgroundColor: theme.colors.surface2, }, - menuItemHint: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.xs, - }, })); diff --git a/packages/app/src/sidebar-sessions/view.tsx b/packages/app/src/sidebar-sessions/view.tsx index 972f1efb4..7a72a9285 100644 --- a/packages/app/src/sidebar-sessions/view.tsx +++ b/packages/app/src/sidebar-sessions/view.tsx @@ -287,35 +287,45 @@ const SidebarSessionGroupSection = memo(function SidebarSessionGroupSection({ onProjectPreviewExpandedToggle: (projectKey: string) => void; }): ReactElement { const showFooter = !group.isCollapsed && group.totalCount > GROUPED_SESSION_LIMIT; - return ( - - {!group.isFlattened ? ( - - ) : null} - {serverId - ? group.visibleIds.map((id) => ( - - )) - : null} - {showFooter ? ( + const footer = useMemo( + () => + showFooter ? ( - ) : null} + ) : null, + [ + group.hiddenCount, + group.isExpanded, + group.projectKey, + onProjectPreviewExpandedToggle, + showFooter, + ], + ); + return ( + + + {serverId + ? group.visibleIds.map((id) => ( + + )) + : null} + ); });