From 4f047832accee477965b00bab51eaf6c27faa80b Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 8 May 2026 20:45:18 +0700 Subject: [PATCH] sidebar history: collapsible project sections, indented children MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The grouped Sessions view now matches the workspace tree's project-row UX instead of forking it. Both consumers share one composable. New: SidebarProjectHeaderRow (memoized, slot-driven) — chevron, hover/press/selected backgrounds, leading visual override, trailing slot for kebab/shortcut/new-worktree, optional PressableComponent for ContextMenuTrigger. Plus sidebarProjectChildIndentStyles for indenting children under the header. Workspace tree refactored to consume it: drag handle, status icon, shortcut badge, kebab menu, and ContextMenuTrigger all flow through slots. Behavior preserved. Grouped Sessions: - Header now collapses on press, with the same chevron-on-hover affordance the workspace tree has. - Section collapse uses the same persistent useSidebarCollapsedSections store the workspace tree uses, so collapsing Project A in either view collapses it in both. - Children rows get indented: SidebarSessionRow accepts an `indented` prop wired to the shared style. Footer matches. - Show-more vs section-collapse are now distinct concepts: controller's expandedProjects renamed to previewExpandedProjects, toggleProjectExpanded -> toggleProjectPreviewExpanded. Collapse beats preview-expansion. deriveGroupedSidebarSessions takes both preview-expanded and collapsed sets. Collapsed groups return empty visibleIds and the flattener emits header-only for them. Tests: 41 passing across 7 files. New cases cover collapsed-hides-rows, collapsed-wins-over-preview-expanded, and the section-only flatten emission. --- packages/app/src/components/left-sidebar.tsx | 32 +-- .../src/components/sidebar-workspace-list.tsx | 153 ++++--------- .../sidebar-collapsible-project-section.tsx | 213 ++++++++++++++++++ .../src/sidebar-sessions/controller.test.ts | 40 ++-- .../app/src/sidebar-sessions/controller.ts | 18 +- .../app/src/sidebar-sessions/grouped-view.tsx | 83 ++++--- .../render-boundaries.test.tsx | 44 +++- .../session-filtering.test.ts | 55 ++++- .../src/sidebar-sessions/session-filtering.ts | 44 +++- .../sidebar-session-row-navigation.test.tsx | 22 +- packages/app/src/sidebar-sessions/view.tsx | 48 ++-- 11 files changed, 524 insertions(+), 228 deletions(-) create mode 100644 packages/app/src/components/sidebar/sidebar-collapsible-project-section.tsx diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index 4c8c612712..d030d9b975 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -91,9 +91,9 @@ interface SidebarSharedProps { sidebarSessionFilter: SidebarSessionFilter; setSidebarSessionFilter: (filter: SidebarSessionFilter) => void; groupByProject: boolean; - expandedProjects: ReadonlySet; + previewExpandedProjects: ReadonlySet; setGroupByProject: (next: boolean) => void; - toggleProjectExpanded: (projectKey: string) => void; + toggleProjectPreviewExpanded: (projectKey: string) => void; hostOptions: ComboboxOption[]; hostTriggerRef: RefObject; isHostPickerOpen: boolean; @@ -202,9 +202,9 @@ export const LeftSidebar = memo(function LeftSidebar({ sidebarSessionFilter, setSidebarSessionFilter, groupByProject, - expandedProjects, + previewExpandedProjects, setGroupByProject, - toggleProjectExpanded, + toggleProjectPreviewExpanded, } = useSidebarSessionsController({ serverId: activeServerId }); const { projects, isInitialLoad, isRevalidating, refreshAll } = useSidebarWorkspacesList({ @@ -276,9 +276,9 @@ export const LeftSidebar = memo(function LeftSidebar({ sidebarSessionFilter, setSidebarSessionFilter, groupByProject, - expandedProjects, + previewExpandedProjects, setGroupByProject, - toggleProjectExpanded, + toggleProjectPreviewExpanded, hostOptions, hostTriggerRef, isHostPickerOpen, @@ -522,9 +522,9 @@ function MobileSidebar({ sidebarSessionFilter, setSidebarSessionFilter, groupByProject, - expandedProjects, + previewExpandedProjects, setGroupByProject, - toggleProjectExpanded, + toggleProjectPreviewExpanded, hostOptions, hostTriggerRef, isHostPickerOpen, @@ -758,8 +758,10 @@ function MobileSidebar({ projects={projects} filter={sidebarSessionFilter} groupByProject={groupByProject} - expandedProjects={expandedProjects} - onProjectExpandedToggle={toggleProjectExpanded} + previewExpandedProjects={previewExpandedProjects} + collapsedProjectKeys={collapsedProjectKeys} + onProjectPreviewExpandedToggle={toggleProjectPreviewExpanded} + onProjectCollapsedToggle={toggleProjectCollapsed} /> )} @@ -794,9 +796,9 @@ function DesktopSidebar({ sidebarSessionFilter, setSidebarSessionFilter, groupByProject, - expandedProjects, + previewExpandedProjects, setGroupByProject, - toggleProjectExpanded, + toggleProjectPreviewExpanded, hostOptions, hostTriggerRef, isHostPickerOpen, @@ -929,8 +931,10 @@ function DesktopSidebar({ projects={projects} filter={sidebarSessionFilter} groupByProject={groupByProject} - expandedProjects={expandedProjects} - onProjectExpandedToggle={toggleProjectExpanded} + previewExpandedProjects={previewExpandedProjects} + collapsedProjectKeys={collapsedProjectKeys} + onProjectPreviewExpandedToggle={toggleProjectPreviewExpanded} + onProjectCollapsedToggle={toggleProjectCollapsed} /> )} diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index a45bd51a04..ae28d9d013 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -7,6 +7,7 @@ import { StatusBar, ScrollView, type GestureResponderEvent, + type PressableProps, type PressableStateCallbackType, type ViewStyle, } from "react-native"; @@ -18,6 +19,7 @@ import { useState, useEffect, useRef, + type ComponentType, type ReactElement, type MutableRefObject, type Ref, @@ -32,8 +34,6 @@ import { DiffStat } from "@/components/diff-stat"; import { Archive, CircleAlert, - ChevronDown, - ChevronRight, Copy, ExternalLink, FolderPlus, @@ -112,10 +112,8 @@ import { import { WorkspaceHoverCard } from "@/components/workspace-hover-card"; import { GitHubIcon } from "@/components/icons/github-icon"; import { isWeb as platformIsWeb, isNative as platformIsNative } from "@/constants/platform"; -import { - SidebarProjectIcon, - SidebarProjectRowVisual, -} from "@/components/sidebar/sidebar-project-row-visual"; +import { SidebarProjectIcon } from "@/components/sidebar/sidebar-project-row-visual"; +import { SidebarProjectHeaderRow } from "@/components/sidebar/sidebar-collapsible-project-section"; function toProjectIconDataUri(icon: { mimeType: string; data: string } | null): string | null { if (!icon) { @@ -492,51 +490,6 @@ function StatusDotOverlay({ return ; } -function ProjectLeadingVisual({ - displayName, - iconDataUri, - workspace, - chevron = null, - showChevron = false, - isArchiving = false, -}: { - displayName: string; - iconDataUri: string | null; - workspace: SidebarWorkspaceEntry | null; - chevron?: "expand" | "collapse" | null; - showChevron?: boolean; - isArchiving?: boolean; -}) { - const activeWorkspace = workspace; - const shouldShowWorkspaceStatus = - activeWorkspace !== null && (isArchiving || activeWorkspace.statusBucket !== "done"); - const shouldShowSyncedLoader = activeWorkspace - ? shouldRenderSyncedStatusLoader({ bucket: activeWorkspace.statusBucket }) - : false; - - if (showChevron && chevron !== null) { - return ( - - - - ); - } - - if (!shouldShowWorkspaceStatus || !activeWorkspace) { - return ; - } - - return ( - - ); -} - function ProjectRowTrailingActions({ project, displayName, @@ -850,16 +803,6 @@ function ProjectLeadingVisualStatus({ ); } -function ProjectInlineChevron({ chevron }: { chevron: "expand" | "collapse" | null }) { - if (chevron === null) { - return null; - } - if (chevron === "collapse") { - return ; - } - return ; -} - function NewWorktreeButton({ displayName, onPress, @@ -1203,37 +1146,27 @@ function ProjectHeaderRow({ const handlePointerEnter = useCallback(() => setIsHovered(true), []); const handlePointerLeave = useCallback(() => setIsHovered(false), []); - const projectRowStyle = useCallback( - ({ pressed }: PressableStateCallbackType) => [ - styles.projectRow, - isDragging && styles.projectRowDragging, - selected && styles.sidebarRowSelected, - isHovered && styles.projectRowHovered, - pressed && styles.projectRowPressed, - ], - [isDragging, selected, isHovered], - ); - const projectLeadingVisual = useMemo( - () => ( - (() => { + if (!showsWorkspaceStatus || !workspace) { + return null; + } + return ( + - ), - [chevron, displayName, iconDataUri, isArchiving, isHovered, workspace], - ); + ); + }, [displayName, iconDataUri, isArchiving, showsWorkspaceStatus, workspace]); - const rowChildren = ( + const trailingSlot = ( <> - ); - if (menuController) { - return ( - } - onPointerEnter={handlePointerEnter} - onPointerLeave={handlePointerLeave} - > - - {rowChildren} - - - ); - } + const PressableComponent = menuController + ? (ProjectHeaderContextMenuTrigger as unknown as ComponentType) + : undefined; return ( - - {rowChildren} - + PressableComponent={PressableComponent} + /> ); } +const ProjectHeaderContextMenuTrigger = (props: PressableProps): ReactElement => ( + +); + function WorkspaceRowInner({ workspace, selected, diff --git a/packages/app/src/components/sidebar/sidebar-collapsible-project-section.tsx b/packages/app/src/components/sidebar/sidebar-collapsible-project-section.tsx new file mode 100644 index 0000000000..2a74d01ead --- /dev/null +++ b/packages/app/src/components/sidebar/sidebar-collapsible-project-section.tsx @@ -0,0 +1,213 @@ +import { + forwardRef, + memo, + useCallback, + useMemo, + type ComponentType, + type ReactElement, + type ReactNode, + type Ref, +} from "react"; +import { + Pressable, + View, + type GestureResponderEvent, + type PressableProps, + type PressableStateCallbackType, + type ViewProps, +} from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { ChevronDown, ChevronRight } from "lucide-react-native"; +import { + SidebarProjectIcon, + SidebarProjectRowVisual, +} from "@/components/sidebar/sidebar-project-row-visual"; + +export type SidebarProjectChevronDirection = "expand" | "collapse"; + +export interface SidebarProjectHeaderRowProps { + projectName: string; + iconDataUri: string | null; + onPress: () => void; + isHovered: boolean; + + /** Visual collapsed state. When set, drives the chevron direction (down=collapse / right=expand). */ + chevron?: SidebarProjectChevronDirection | null; + + selected?: boolean; + isDragging?: boolean; + + /** Replaces the chevron+icon leading visual entirely (e.g. archiving spinner, workspace status icon). */ + leadingVisualOverride?: ReactElement | null; + + /** Appended after the project name. Kebab, new-worktree button, shortcut badge. */ + trailingSlot?: ReactNode; + + /** Long-press / drag handlers. */ + onPressIn?: (event: GestureResponderEvent) => void; + onTouchMove?: (event: GestureResponderEvent) => void; + onPressOut?: (event: GestureResponderEvent) => void; + + testID?: string; + accessibilityLabel?: string; + + /** Substitute for the inner Pressable (e.g. ContextMenuTrigger). Receives the same props. */ + PressableComponent?: ComponentType }>; +} + +/** + * 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. + * + * Hover is controlled — callers wrap with their own outer View carrying onPointerEnter/Leave + * (workspace tree adds drag-handle attrs to that wrapper; sessions wraps a plain View). + */ +export const SidebarProjectHeaderRow = memo( + forwardRef(function SidebarProjectHeaderRow( + { + projectName, + iconDataUri, + onPress, + isHovered, + chevron = null, + selected = false, + isDragging = false, + leadingVisualOverride = null, + trailingSlot = null, + onPressIn, + onTouchMove, + onPressOut, + testID, + accessibilityLabel, + PressableComponent = Pressable, + }, + ref, + ): ReactElement { + const showChevron = isHovered && chevron !== null; + const leadingVisual = useMemo(() => { + if (showChevron && chevron) { + return ( + + + + ); + } + if (leadingVisualOverride) { + return leadingVisualOverride; + } + return ; + }, [chevron, iconDataUri, leadingVisualOverride, projectName, showChevron]); + + const pressableStyle = useCallback( + ({ pressed }: PressableStateCallbackType) => [ + sidebarProjectHeaderRowStyles.row, + isDragging && sidebarProjectHeaderRowStyles.dragging, + selected && sidebarProjectHeaderRowStyles.selected, + isHovered && sidebarProjectHeaderRowStyles.hovered, + pressed && sidebarProjectHeaderRowStyles.pressed, + ], + [isDragging, isHovered, selected], + ); + + return ( + + + {trailingSlot} + + ); + }), +); + +function SidebarProjectChevron({ + chevron, +}: { + chevron: SidebarProjectChevronDirection; +}): ReactElement { + if (chevron === "collapse") { + return ; + } + return ; +} + +/** + * Outer wrapper that carries hover state for a `` plus extra props + * (drag handle attrs, ref). Sessions uses the lighter ``. + */ +export const SidebarProjectHeaderHoverWrap = forwardRef< + View, + ViewProps & { + onHoverChange: (hovered: boolean) => void; + } +>(function SidebarProjectHeaderHoverWrap({ onHoverChange, children, ...rest }, ref): ReactElement { + const handleEnter = useCallback(() => onHoverChange(true), [onHoverChange]); + const handleLeave = useCallback(() => onHoverChange(false), [onHoverChange]); + return ( + + {children} + + ); +}); + +export const sidebarProjectHeaderRowStyles = StyleSheet.create((theme) => ({ + row: { + minHeight: 36, + paddingVertical: theme.spacing[2], + paddingHorizontal: theme.spacing[2], + borderRadius: theme.borderRadius.lg, + marginBottom: theme.spacing[1], + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + gap: theme.spacing[2], + userSelect: "none", + }, + hovered: { + backgroundColor: theme.colors.surfaceSidebarHover, + }, + pressed: { + backgroundColor: theme.colors.surface2, + }, + selected: { + backgroundColor: theme.colors.surfaceSidebarHover, + }, + dragging: { + backgroundColor: theme.colors.surface2, + borderWidth: 1, + borderColor: theme.colors.border, + transform: [{ scale: 1.02 }], + zIndex: 3, + ...theme.shadow.md, + }, + leadingSlot: { + position: "relative", + width: theme.iconSize.md, + height: theme.iconSize.md, + flexShrink: 0, + alignItems: "center", + justifyContent: "center", + }, +})); + +/** + * Indent style for rows nested under a ``. Workspace rows and + * grouped-session rows both apply this so children visually align across surfaces. + */ +export const sidebarProjectChildIndentStyles = StyleSheet.create((theme) => ({ + childRow: { + paddingLeft: theme.spacing[3] + theme.spacing[3], + paddingRight: theme.spacing[3], + }, +})); diff --git a/packages/app/src/sidebar-sessions/controller.test.ts b/packages/app/src/sidebar-sessions/controller.test.ts index d1eb12d14a..bf5caac175 100644 --- a/packages/app/src/sidebar-sessions/controller.test.ts +++ b/packages/app/src/sidebar-sessions/controller.test.ts @@ -10,7 +10,7 @@ describe("useSidebarSessionsController", () => { const { result } = renderHook(() => useSidebarSessionsController({ serverId: "server-1" })); expect(result.current.groupByProject).toBe(false); - expect(result.current.expandedProjects.size).toBe(0); + expect(result.current.previewExpandedProjects.size).toBe(0); }); it("enables project grouping without expanding projects", () => { @@ -21,40 +21,40 @@ describe("useSidebarSessionsController", () => { }); expect(result.current.groupByProject).toBe(true); - expect(result.current.expandedProjects.size).toBe(0); + expect(result.current.previewExpandedProjects.size).toBe(0); }); it("toggles one expanded project", () => { const { result } = renderHook(() => useSidebarSessionsController({ serverId: "server-1" })); act(() => { - result.current.toggleProjectExpanded("p1"); + result.current.toggleProjectPreviewExpanded("p1"); }); - expect(result.current.expandedProjects.has("p1")).toBe(true); + expect(result.current.previewExpandedProjects.has("p1")).toBe(true); act(() => { - result.current.toggleProjectExpanded("p1"); + result.current.toggleProjectPreviewExpanded("p1"); }); - expect(result.current.expandedProjects.size).toBe(0); + expect(result.current.previewExpandedProjects.size).toBe(0); }); it("toggles expanded projects independently", () => { const { result } = renderHook(() => useSidebarSessionsController({ serverId: "server-1" })); act(() => { - result.current.toggleProjectExpanded("p1"); - result.current.toggleProjectExpanded("p2"); + result.current.toggleProjectPreviewExpanded("p1"); + result.current.toggleProjectPreviewExpanded("p2"); }); - expect([...result.current.expandedProjects].sort()).toEqual(["p1", "p2"]); + expect([...result.current.previewExpandedProjects].sort()).toEqual(["p1", "p2"]); act(() => { - result.current.toggleProjectExpanded("p1"); + result.current.toggleProjectPreviewExpanded("p1"); }); - expect([...result.current.expandedProjects]).toEqual(["p2"]); + expect([...result.current.previewExpandedProjects]).toEqual(["p2"]); }); it("clears expanded projects when project grouping is disabled", () => { @@ -62,17 +62,17 @@ describe("useSidebarSessionsController", () => { act(() => { result.current.setGroupByProject(true); - result.current.toggleProjectExpanded("p1"); + result.current.toggleProjectPreviewExpanded("p1"); }); - expect(result.current.expandedProjects.has("p1")).toBe(true); + expect(result.current.previewExpandedProjects.has("p1")).toBe(true); act(() => { result.current.setGroupByProject(false); }); expect(result.current.groupByProject).toBe(false); - expect(result.current.expandedProjects.size).toBe(0); + expect(result.current.previewExpandedProjects.size).toBe(0); }); it("resets the session filter when the active host changes", () => { @@ -107,14 +107,14 @@ describe("useSidebarSessionsController", () => { act(() => { result.current.setGroupByProject(true); - result.current.toggleProjectExpanded("p1"); + result.current.toggleProjectPreviewExpanded("p1"); }); - expect(result.current.expandedProjects.has("p1")).toBe(true); + expect(result.current.previewExpandedProjects.has("p1")).toBe(true); rerender({ serverId: "server-2" }); - expect(result.current.expandedProjects.size).toBe(0); + expect(result.current.previewExpandedProjects.size).toBe(0); }); it("clears expanded projects when the session filter changes", () => { @@ -122,10 +122,10 @@ describe("useSidebarSessionsController", () => { act(() => { result.current.setGroupByProject(true); - result.current.toggleProjectExpanded("p1"); + result.current.toggleProjectPreviewExpanded("p1"); }); - expect(result.current.expandedProjects.has("p1")).toBe(true); + expect(result.current.previewExpandedProjects.has("p1")).toBe(true); act(() => { result.current.setSidebarSessionFilter({ @@ -138,7 +138,7 @@ describe("useSidebarSessionsController", () => { type: "project", projectKey: "project-a", }); - expect(result.current.expandedProjects.size).toBe(0); + expect(result.current.previewExpandedProjects.size).toBe(0); }); it("does not clear the session filter when project grouping is enabled", () => { diff --git a/packages/app/src/sidebar-sessions/controller.ts b/packages/app/src/sidebar-sessions/controller.ts index 53916cc0e2..107570efcd 100644 --- a/packages/app/src/sidebar-sessions/controller.ts +++ b/packages/app/src/sidebar-sessions/controller.ts @@ -8,22 +8,24 @@ export function useSidebarSessionsController({ serverId }: { serverId: string | type: "all", }); const [groupByProject, setGroupByProjectState] = useState(false); - const [expandedProjects, setExpandedProjects] = useState>(() => new Set()); + const [previewExpandedProjects, setPreviewExpandedProjects] = useState>( + () => new Set(), + ); const setGroupByProject = useCallback((next: boolean) => { setGroupByProjectState(next); if (!next) { - setExpandedProjects((previous) => (previous.size === 0 ? previous : new Set())); + setPreviewExpandedProjects((previous) => (previous.size === 0 ? previous : new Set())); } }, []); const setSidebarSessionFilterWithReset = useCallback((next: SidebarSessionFilter) => { setSidebarSessionFilter(next); - setExpandedProjects((previous) => (previous.size === 0 ? previous : new Set())); + setPreviewExpandedProjects((previous) => (previous.size === 0 ? previous : new Set())); }, []); - const toggleProjectExpanded = useCallback((projectKey: string) => { - setExpandedProjects((previous) => { + const toggleProjectPreviewExpanded = useCallback((projectKey: string) => { + setPreviewExpandedProjects((previous) => { const next = new Set(previous); if (next.has(projectKey)) { next.delete(projectKey); @@ -40,7 +42,7 @@ export function useSidebarSessionsController({ serverId }: { serverId: string | } previousServerIdRef.current = serverId; setSidebarSessionFilter({ type: "all" }); - setExpandedProjects((previous) => (previous.size === 0 ? previous : new Set())); + setPreviewExpandedProjects((previous) => (previous.size === 0 ? previous : new Set())); }, [serverId]); return { @@ -49,8 +51,8 @@ export function useSidebarSessionsController({ serverId }: { serverId: string | sidebarSessionFilter, setSidebarSessionFilter: setSidebarSessionFilterWithReset, groupByProject, - expandedProjects, + previewExpandedProjects, setGroupByProject, - toggleProjectExpanded, + toggleProjectPreviewExpanded, }; } diff --git a/packages/app/src/sidebar-sessions/grouped-view.tsx b/packages/app/src/sidebar-sessions/grouped-view.tsx index 4a671b1f56..81881a2b37 100644 --- a/packages/app/src/sidebar-sessions/grouped-view.tsx +++ b/packages/app/src/sidebar-sessions/grouped-view.tsx @@ -1,9 +1,9 @@ -import React, { memo, useCallback, useMemo, type ReactElement } from "react"; +import React, { memo, useCallback, useMemo, useState, type ReactElement } from "react"; import { Pressable, Text, View } from "react-native"; import { StyleSheet } from "react-native-unistyles"; import { shallow } from "zustand/shallow"; import { useStoreWithEqualityFn } from "zustand/traditional"; -import { SidebarProjectRowVisual } from "@/components/sidebar/sidebar-project-row-visual"; +import { SidebarProjectHeaderRow } from "@/components/sidebar/sidebar-collapsible-project-section"; import { useProjectIconQuery } from "@/hooks/use-project-icon-query"; import { useSessionStore } from "@/stores/session-store"; import { @@ -18,7 +18,13 @@ const GROUPED_SESSION_LIMIT = 6; type SessionStoreState = ReturnType; export type SidebarSessionListItem = - | { kind: "header"; projectKey: string; projectName: string; projectIconKey: string | null } + | { + kind: "header"; + projectKey: string; + projectName: string; + projectIconKey: string | null; + isCollapsed: boolean; + } | { kind: "row"; id: string; serverId: string } | { kind: "footer"; projectKey: string; hiddenCount: number; isExpanded: boolean }; @@ -82,17 +88,19 @@ export function useOrderedAgentProjectShape(input: { export function useGroupedSidebarSessionListData(input: { agentsWithProjects: readonly SidebarSessionAgentProject[]; - expandedProjects: ReadonlySet; + previewExpandedProjects: ReadonlySet; + collapsedProjectKeys: ReadonlySet; serverId: string | null; }): readonly SidebarSessionListItem[] { const groupedSessions = useMemo( () => deriveGroupedSidebarSessions({ agentsWithProjects: input.agentsWithProjects, - expandedProjects: input.expandedProjects, + previewExpandedProjects: input.previewExpandedProjects, + collapsedProjectKeys: input.collapsedProjectKeys, limit: GROUPED_SESSION_LIMIT, }), - [input.agentsWithProjects, input.expandedProjects], + [input.agentsWithProjects, input.collapsedProjectKeys, input.previewExpandedProjects], ); return useMemo( @@ -101,7 +109,7 @@ export function useGroupedSidebarSessionListData(input: { ); } -function flattenGroupedSidebarSessions( +export function flattenGroupedSidebarSessions( groupedSessions: readonly SidebarSessionGroup[], serverId: string | null, ): readonly SidebarSessionListItem[] { @@ -116,7 +124,11 @@ function flattenGroupedSidebarSessions( projectKey: group.projectKey, projectName: group.projectName, projectIconKey: group.projectIconKey, + isCollapsed: group.isCollapsed, }); + if (group.isCollapsed) { + continue; + } for (const id of group.visibleIds) { items.push({ kind: "row", id, serverId }); } @@ -134,12 +146,18 @@ function flattenGroupedSidebarSessions( export const SidebarSessionGroupHeader = memo(function SidebarSessionGroupHeader({ serverId, + projectKey, projectName, projectIconKey, + isCollapsed, + onToggleCollapsed, }: { serverId: string | null; + projectKey: string; projectName: string; projectIconKey: string | null; + isCollapsed: boolean; + onToggleCollapsed: (projectKey: string) => void; }): ReactElement { const { icon } = useProjectIconQuery({ serverId: serverId ?? "", cwd: projectIconKey ?? "" }); const dataUri = useMemo(() => { @@ -149,9 +167,24 @@ export const SidebarSessionGroupHeader = memo(function SidebarSessionGroupHeader return `data:${icon.mimeType};base64,${icon.data}`; }, [icon]); + const [isHovered, setIsHovered] = useState(false); + const handlePointerEnter = useCallback(() => setIsHovered(true), []); + const handlePointerLeave = useCallback(() => setIsHovered(false), []); + const handlePress = useCallback(() => { + onToggleCollapsed(projectKey); + }, [onToggleCollapsed, projectKey]); + return ( - - + + ); }); @@ -176,47 +209,31 @@ export const SidebarSessionGroupFooter = memo(function SidebarSessionGroupFooter accessibilityRole="button" accessibilityLabel={isExpanded ? "Show less sessions" : `Show ${hiddenCount} more sessions`} onPress={handlePress} - style={styles.footerRow} + style={footerRowStyle} testID={`sidebar-session-group-footer-${projectKey}`} > - {isExpanded ? "Show less" : `Show ${hiddenCount} more`} ); }); const styles = StyleSheet.create((theme) => ({ - projectRow: { - minHeight: 36, - paddingVertical: theme.spacing[2], - paddingHorizontal: theme.spacing[2], - borderRadius: theme.borderRadius.lg, - marginBottom: theme.spacing[1], - flexDirection: "row", - alignItems: "center", - justifyContent: "space-between", - gap: theme.spacing[2], - userSelect: "none", - }, footerRow: { - minHeight: 36, + minHeight: 32, flexDirection: "row", alignItems: "center", - gap: theme.spacing[2], - paddingVertical: theme.spacing[2], - paddingHorizontal: theme.spacing[2], + paddingVertical: theme.spacing[1], + paddingLeft: theme.spacing[3] + theme.spacing[3], + paddingRight: theme.spacing[3], borderRadius: theme.borderRadius.lg, marginBottom: theme.spacing[1], }, - footerIconColumn: { - width: theme.iconSize.md, - height: theme.iconSize.md, - flexShrink: 0, - }, footerText: { flex: 1, minWidth: 0, - fontSize: theme.fontSize.sm, + fontSize: theme.fontSize.xs, color: theme.colors.foregroundMuted, }, })); + +const footerRowStyle = styles.footerRow; diff --git a/packages/app/src/sidebar-sessions/render-boundaries.test.tsx b/packages/app/src/sidebar-sessions/render-boundaries.test.tsx index f892f6845a..6b28747ce5 100644 --- a/packages/app/src/sidebar-sessions/render-boundaries.test.tsx +++ b/packages/app/src/sidebar-sessions/render-boundaries.test.tsx @@ -24,6 +24,15 @@ vi.mock("@/hooks/use-project-icon-query", () => ({ useProjectIconQuery: () => ({ icon: null, isLoading: false, isError: false }), })); +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"), + }; +}); + const TIMESTAMP = new Date("2026-05-08T10:00:00.000Z"); const SERVER_ID = "server-1"; @@ -37,10 +46,13 @@ vi.mock("react-native-unistyles", () => ({ border: "#dddddd", foreground: "#111111", foregroundMuted: "#666666", + surface2: "#eeeeee", + surfaceSidebarHover: "#f5f5f5", }, - fontSize: { sm: 14 }, + fontSize: { sm: 14, xs: 12 }, iconSize: { md: 20 }, - spacing: { 1: 4, 2: 8 }, + shadow: { md: {} }, + spacing: { 1: 4, 2: 8, 3: 12 }, }) : styles, }, @@ -98,7 +110,7 @@ function seedAgents(agents: Agent[]) { .setAgents(SERVER_ID, new Map(agents.map((agent) => [agent.id, agent]))); } -function useGroupedBoundaryHarness() { +function useGroupedBoundaryHarness(input?: { collapsedProjectKeys?: ReadonlySet }) { const orderedIds = useStoreWithEqualityFn( useSessionStore, (state) => Array.from(state.sessions[SERVER_ID]?.agents?.keys() ?? []), @@ -115,11 +127,14 @@ function useGroupedBoundaryHarness() { serverId: SERVER_ID, resolveCwdToProject, }); - const expandedProjects = useMemo(() => new Set(), []); + const previewExpandedProjects = useMemo(() => new Set(), []); + const fallbackCollapsed = useMemo(() => new Set(), []); + const collapsedProjectKeys = input?.collapsedProjectKeys ?? fallbackCollapsed; return useGroupedSidebarSessionListData({ agentsWithProjects, - expandedProjects, + previewExpandedProjects, + collapsedProjectKeys, serverId: SERVER_ID, }); } @@ -236,6 +251,25 @@ describe("sidebar session render boundaries", () => { expect(result.current).toBe(beforeData); }); + it("emits header only (no rows or footer) for a collapsed project", () => { + const overLimit = Array.from({ length: 10 }, (_, index) => + makeAgent({ id: `agent-${index + 1}`, cwd: "/repo/a" }), + ); + seedAgents(overLimit); + + const { result } = renderHook(() => + useGroupedBoundaryHarness({ collapsedProjectKeys: new Set(["project-a"]) }), + ); + + expect(result.current).toEqual([ + expect.objectContaining({ + kind: "header", + projectKey: "project-a", + isCollapsed: true, + }), + ]); + }); + it("calls the grouped footer press handler with its project key", () => { const onPress = vi.fn(); diff --git a/packages/app/src/sidebar-sessions/session-filtering.test.ts b/packages/app/src/sidebar-sessions/session-filtering.test.ts index 185c08a4cc..bfd1080cd6 100644 --- a/packages/app/src/sidebar-sessions/session-filtering.test.ts +++ b/packages/app/src/sidebar-sessions/session-filtering.test.ts @@ -219,7 +219,7 @@ describe("deriveGroupedSidebarSessions", () => { expect( deriveGroupedSidebarSessions({ agentsWithProjects: [], - expandedProjects: new Set(), + previewExpandedProjects: new Set(), }), ).toEqual([]); }); @@ -227,7 +227,7 @@ describe("deriveGroupedSidebarSessions", () => { it("keeps a single project under the limit fully visible and collapsed", () => { const groups = deriveGroupedSidebarSessions({ agentsWithProjects: agentProjects(["a1", "a2", "a3"], testProject("project-a")), - expandedProjects: new Set(), + previewExpandedProjects: new Set(), limit: 6, }); @@ -239,6 +239,7 @@ describe("deriveGroupedSidebarSessions", () => { visibleIds: ["a1", "a2", "a3"], hiddenCount: 0, isExpanded: false, + isCollapsed: false, totalCount: 3, }, ]); @@ -249,7 +250,7 @@ describe("deriveGroupedSidebarSessions", () => { const projectA = testProject("project-a"); const groups = deriveGroupedSidebarSessions({ agentsWithProjects: agentProjects(orderedIds, projectA), - expandedProjects: new Set(), + previewExpandedProjects: new Set(), limit: 6, }); @@ -257,6 +258,7 @@ describe("deriveGroupedSidebarSessions", () => { visibleIds: ["a1", "a2", "a3", "a4", "a5", "a6"], hiddenCount: 4, isExpanded: false, + isCollapsed: false, totalCount: 10, }); }); @@ -266,7 +268,7 @@ describe("deriveGroupedSidebarSessions", () => { const projectA = testProject("project-a"); const groups = deriveGroupedSidebarSessions({ agentsWithProjects: agentProjects(orderedIds, projectA), - expandedProjects: new Set(["project-a"]), + previewExpandedProjects: new Set(["project-a"]), limit: 6, }); @@ -274,10 +276,45 @@ describe("deriveGroupedSidebarSessions", () => { visibleIds: orderedIds, hiddenCount: 0, isExpanded: true, + isCollapsed: false, totalCount: 10, }); }); + it("hides all rows when a project is collapsed", () => { + const orderedIds = Array.from({ length: 10 }, (_, index) => `a${index + 1}`); + const groups = deriveGroupedSidebarSessions({ + agentsWithProjects: agentProjects(orderedIds, testProject("project-a")), + previewExpandedProjects: new Set(), + collapsedProjectKeys: new Set(["project-a"]), + limit: 6, + }); + + expect(groups[0]).toMatchObject({ + visibleIds: [], + hiddenCount: 0, + isExpanded: false, + isCollapsed: true, + totalCount: 10, + }); + }); + + it("collapsed wins over preview-expanded", () => { + const orderedIds = Array.from({ length: 10 }, (_, index) => `a${index + 1}`); + const groups = deriveGroupedSidebarSessions({ + agentsWithProjects: agentProjects(orderedIds, testProject("project-a")), + previewExpandedProjects: new Set(["project-a"]), + collapsedProjectKeys: new Set(["project-a"]), + limit: 6, + }); + + expect(groups[0]).toMatchObject({ + visibleIds: [], + isExpanded: false, + isCollapsed: true, + }); + }); + it("orders projects by first occurrence in ordered ids", () => { const groups = deriveGroupedSidebarSessions({ agentsWithProjects: [ @@ -285,7 +322,7 @@ describe("deriveGroupedSidebarSessions", () => { agentProject("b1", testProject("project-b")), agentProject("a2", testProject("project-a")), ], - expandedProjects: new Set(), + previewExpandedProjects: new Set(), }); expect(groups.map((group) => group.projectKey)).toEqual(["project-a", "project-b"]); @@ -294,7 +331,7 @@ describe("deriveGroupedSidebarSessions", () => { it("preserves order within each project", () => { const groups = deriveGroupedSidebarSessions({ agentsWithProjects: agentProjects(["a1", "a2", "a3"], testProject("project-a")), - expandedProjects: new Set(), + previewExpandedProjects: new Set(), }); expect(groups[0]?.visibleIds).toEqual(["a1", "a2", "a3"]); @@ -307,7 +344,7 @@ describe("deriveGroupedSidebarSessions", () => { agentProject("b1", testProject("project-b")), agentProject("a2", testProject("project-a")), ], - expandedProjects: new Set(), + previewExpandedProjects: new Set(), }); expect(groups.map((group) => ({ key: group.projectKey, ids: group.visibleIds }))).toEqual([ @@ -321,7 +358,7 @@ describe("deriveGroupedSidebarSessions", () => { const projectA = testProject("project-a"); const groups = deriveGroupedSidebarSessions({ agentsWithProjects: agentProjects(orderedIds, projectA), - expandedProjects: new Set(), + previewExpandedProjects: new Set(), }); expect(groups[0]?.visibleIds).toHaveLength(6); @@ -331,7 +368,7 @@ describe("deriveGroupedSidebarSessions", () => { it("honors a custom collapsed limit", () => { const groups = deriveGroupedSidebarSessions({ agentsWithProjects: agentProjects(["a1", "a2", "a3", "a4"], testProject("project-a")), - expandedProjects: new Set(), + previewExpandedProjects: new Set(), limit: 2, }); diff --git a/packages/app/src/sidebar-sessions/session-filtering.ts b/packages/app/src/sidebar-sessions/session-filtering.ts index 8b35a7ac21..c1c7f53691 100644 --- a/packages/app/src/sidebar-sessions/session-filtering.ts +++ b/packages/app/src/sidebar-sessions/session-filtering.ts @@ -18,6 +18,7 @@ export interface SidebarSessionGroup { visibleIds: readonly string[]; hiddenCount: number; isExpanded: boolean; + isCollapsed: boolean; totalCount: number; } @@ -148,10 +149,13 @@ export function deriveSidebarSessionFilterProjects(input: { export function deriveGroupedSidebarSessions(input: { agentsWithProjects: readonly SidebarSessionAgentProject[]; - expandedProjects: ReadonlySet; + previewExpandedProjects?: ReadonlySet; + collapsedProjectKeys?: ReadonlySet; limit?: number; }): readonly SidebarSessionGroup[] { const limit = input.limit ?? DEFAULT_GROUPED_SESSION_LIMIT; + const previewExpandedProjects = input.previewExpandedProjects ?? EMPTY_KEY_SET; + const collapsedProjectKeys = input.collapsedProjectKeys ?? EMPTY_KEY_SET; const groupsByProject = new Map(); for (const agent of input.agentsWithProjects) { @@ -169,16 +173,46 @@ export function deriveGroupedSidebarSessions(input: { } return Array.from(groupsByProject.values(), (group) => { - const isExpanded = input.expandedProjects.has(group.projectKey); - const visibleIds = isExpanded ? group.ids : group.ids.slice(0, limit); + const isCollapsed = collapsedProjectKeys.has(group.projectKey); + const isExpanded = !isCollapsed && previewExpandedProjects.has(group.projectKey); return { projectKey: group.projectKey, projectName: group.projectName, projectIconKey: group.projectIconKey, - visibleIds, - hiddenCount: isExpanded ? 0 : Math.max(0, group.ids.length - limit), + visibleIds: visibleIdsFor({ ids: group.ids, isCollapsed, isExpanded, limit }), + hiddenCount: hiddenCountFor({ totalCount: group.ids.length, isCollapsed, isExpanded, limit }), isExpanded, + isCollapsed, totalCount: group.ids.length, }; }); } + +function visibleIdsFor(input: { + ids: readonly string[]; + isCollapsed: boolean; + isExpanded: boolean; + limit: number; +}): readonly string[] { + if (input.isCollapsed) { + return []; + } + if (input.isExpanded) { + return input.ids; + } + return input.ids.slice(0, input.limit); +} + +function hiddenCountFor(input: { + totalCount: number; + isCollapsed: boolean; + isExpanded: boolean; + limit: number; +}): number { + if (input.isCollapsed || input.isExpanded) { + return 0; + } + return Math.max(0, input.totalCount - input.limit); +} + +const EMPTY_KEY_SET: ReadonlySet = new Set(); diff --git a/packages/app/src/sidebar-sessions/sidebar-session-row-navigation.test.tsx b/packages/app/src/sidebar-sessions/sidebar-session-row-navigation.test.tsx index 7940413fcf..473940a584 100644 --- a/packages/app/src/sidebar-sessions/sidebar-session-row-navigation.test.tsx +++ b/packages/app/src/sidebar-sessions/sidebar-session-row-navigation.test.tsx @@ -47,6 +47,7 @@ const { fontSize: { sm: 14, xs: 12 }, fontWeight: { medium: "500" }, iconSize: { md: 20, sm: 16 }, + shadow: { md: {} }, spacing: { 1: 4, 2: 8, 3: 12, 4: 16, 6: 24 }, }, })); @@ -69,6 +70,15 @@ vi.mock("@/components/provider-icons", async () => { }; }); +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("@/screens/workspace/workspace-tab-presentation", async () => { const ReactModule = await import("react"); return { @@ -254,8 +264,10 @@ describe("SidebarSessionsView row navigation", () => { projects={projects()} filter={ALL_SESSIONS_FILTER} groupByProject={false} - expandedProjects={new Set()} - onProjectExpandedToggle={vi.fn()} + previewExpandedProjects={new Set()} + collapsedProjectKeys={new Set()} + onProjectPreviewExpandedToggle={vi.fn()} + onProjectCollapsedToggle={vi.fn()} />, ); @@ -284,8 +296,10 @@ describe("SidebarSessionsView row navigation", () => { projects={projects()} filter={ALL_SESSIONS_FILTER} groupByProject={false} - expandedProjects={new Set()} - onProjectExpandedToggle={vi.fn()} + previewExpandedProjects={new Set()} + collapsedProjectKeys={new Set()} + onProjectPreviewExpandedToggle={vi.fn()} + onProjectCollapsedToggle={vi.fn()} />, ); useSessionStore.getState().setWorkspaces(SERVER_ID, new Map()); diff --git a/packages/app/src/sidebar-sessions/view.tsx b/packages/app/src/sidebar-sessions/view.tsx index ad9c9d3728..165df94460 100644 --- a/packages/app/src/sidebar-sessions/view.tsx +++ b/packages/app/src/sidebar-sessions/view.tsx @@ -10,6 +10,7 @@ import { import { useShallow } from "zustand/shallow"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { SidebarAgentListSkeleton } from "@/components/sidebar-agent-list-skeleton"; +import { sidebarProjectChildIndentStyles } from "@/components/sidebar/sidebar-collapsible-project-section"; import { type AggregatedAgentIdEntry, useAggregatedAgentIds, @@ -46,8 +47,10 @@ interface SidebarSessionsViewProps { projects: readonly SidebarProjectEntry[]; filter: SidebarSessionFilter; groupByProject: boolean; - expandedProjects: ReadonlySet; - onProjectExpandedToggle: (projectKey: string) => void; + previewExpandedProjects: ReadonlySet; + collapsedProjectKeys: ReadonlySet; + onProjectPreviewExpandedToggle: (projectKey: string) => void; + onProjectCollapsedToggle: (projectKey: string) => void; } export function SidebarSessionsView({ @@ -55,8 +58,10 @@ export function SidebarSessionsView({ projects, filter, groupByProject, - expandedProjects, - onProjectExpandedToggle, + previewExpandedProjects, + collapsedProjectKeys, + onProjectPreviewExpandedToggle, + onProjectCollapsedToggle, }: SidebarSessionsViewProps): ReactElement { const isInitialLoad = useAggregatedAgentsInitialLoad(); const workspaces = useSidebarSessionWorkspaces({ serverId, projects }); @@ -107,8 +112,10 @@ export function SidebarSessionsView({ serverId={serverId} sessionIds={sessionIds} resolveCwdToProject={resolveCwdToProject} - expandedProjects={expandedProjects} - onProjectExpandedToggle={onProjectExpandedToggle} + previewExpandedProjects={previewExpandedProjects} + collapsedProjectKeys={collapsedProjectKeys} + onProjectPreviewExpandedToggle={onProjectPreviewExpandedToggle} + onProjectCollapsedToggle={onProjectCollapsedToggle} /> ); } @@ -147,14 +154,18 @@ const GroupedSidebarSessionsList = memo(function GroupedSidebarSessionsList({ serverId, sessionIds, resolveCwdToProject, - expandedProjects, - onProjectExpandedToggle, + previewExpandedProjects, + collapsedProjectKeys, + onProjectPreviewExpandedToggle, + onProjectCollapsedToggle, }: { serverId: string | null; sessionIds: readonly string[]; resolveCwdToProject: (cwd: string) => ResolvedSidebarSessionProject | null; - expandedProjects: ReadonlySet; - onProjectExpandedToggle: (projectKey: string) => void; + previewExpandedProjects: ReadonlySet; + collapsedProjectKeys: ReadonlySet; + onProjectPreviewExpandedToggle: (projectKey: string) => void; + onProjectCollapsedToggle: (projectKey: string) => void; }): ReactElement { const agentsWithProjects = useOrderedAgentProjectShape({ orderedIds: sessionIds, @@ -163,7 +174,8 @@ const GroupedSidebarSessionsList = memo(function GroupedSidebarSessionsList({ }); const data = useGroupedSidebarSessionListData({ agentsWithProjects, - expandedProjects, + previewExpandedProjects, + collapsedProjectKeys, serverId, }); @@ -174,24 +186,27 @@ const GroupedSidebarSessionsList = memo(function GroupedSidebarSessionsList({ return ( ); case "row": - return ; + return ; case "footer": return ( ); } }, - [onProjectExpandedToggle, serverId], + [onProjectCollapsedToggle, onProjectPreviewExpandedToggle, serverId], ); const keyExtractor = useCallback((item: SidebarSessionListItem) => { switch (item.kind) { @@ -221,9 +236,11 @@ const GroupedSidebarSessionsList = memo(function GroupedSidebarSessionsList({ const SidebarSessionRow = memo(function SidebarSessionRow({ id, serverId, + indented = false, }: { id: string; serverId: string; + indented?: boolean; }): ReactElement | null { const { theme } = useUnistyles(); const [isHovered, setIsHovered] = useState(false); @@ -269,10 +286,11 @@ const SidebarSessionRow = memo(function SidebarSessionRow({ const pressableStyle = useCallback( ({ pressed }: PressableStateCallbackType) => [ styles.row, + indented && sidebarProjectChildIndentStyles.childRow, isHovered && styles.rowHovered, pressed && styles.rowPressed, ], - [isHovered], + [indented, isHovered], ); const titleStyle = useMemo(() => [styles.title, isHovered && styles.titleHovered], [isHovered]);