sidebar history: collapsible project sections, indented children

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.
This commit is contained in:
Mohamed Boudra
2026-05-08 20:45:18 +07:00
parent a3c3d25592
commit 4f047832ac
11 changed files with 524 additions and 228 deletions
+18 -14
View File
@@ -91,9 +91,9 @@ interface SidebarSharedProps {
sidebarSessionFilter: SidebarSessionFilter;
setSidebarSessionFilter: (filter: SidebarSessionFilter) => void;
groupByProject: boolean;
expandedProjects: ReadonlySet<string>;
previewExpandedProjects: ReadonlySet<string>;
setGroupByProject: (next: boolean) => void;
toggleProjectExpanded: (projectKey: string) => void;
toggleProjectPreviewExpanded: (projectKey: string) => void;
hostOptions: ComboboxOption[];
hostTriggerRef: RefObject<View | null>;
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}
/>
)}
@@ -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 <View style={overlayStyle} />;
}
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 (
<View style={styles.projectLeadingVisualSlot}>
<ProjectInlineChevron chevron={chevron} />
</View>
);
}
if (!shouldShowWorkspaceStatus || !activeWorkspace) {
return <SidebarProjectIcon projectName={displayName} iconDataUri={iconDataUri} />;
}
return (
<ProjectLeadingVisualStatus
displayName={displayName}
iconDataUri={iconDataUri}
isArchiving={isArchiving}
shouldShowSyncedLoader={shouldShowSyncedLoader}
activeWorkspace={activeWorkspace}
/>
);
}
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 <ChevronDown size={14} color="#9ca3af" />;
}
return <ChevronRight size={14} color="#9ca3af" />;
}
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(
() => (
<ProjectLeadingVisual
const showsWorkspaceStatus =
workspace !== null && (isArchiving || workspace.statusBucket !== "done");
const leadingVisualOverride = useMemo<ReactElement | null>(() => {
if (!showsWorkspaceStatus || !workspace) {
return null;
}
return (
<ProjectLeadingVisualStatus
displayName={displayName}
iconDataUri={iconDataUri}
workspace={workspace}
chevron={chevron}
showChevron={isHovered && chevron !== null}
isArchiving={isArchiving}
shouldShowSyncedLoader={shouldRenderSyncedStatusLoader({
bucket: workspace.statusBucket,
})}
activeWorkspace={workspace}
/>
),
[chevron, displayName, iconDataUri, isArchiving, isHovered, workspace],
);
);
}, [displayName, iconDataUri, isArchiving, showsWorkspaceStatus, workspace]);
const rowChildren = (
const trailingSlot = (
<>
<SidebarProjectRowVisual
projectName={displayName}
iconDataUri={iconDataUri}
leadingVisual={projectLeadingVisual}
/>
<ProjectRowTrailingActions
project={project}
displayName={displayName}
@@ -1253,29 +1186,9 @@ function ProjectHeaderRow({
</>
);
if (menuController) {
return (
<View
{...dragHandleProps?.attributes}
{...dragHandleProps?.listeners}
ref={dragHandleProps?.setActivatorNodeRef as unknown as Ref<View>}
onPointerEnter={handlePointerEnter}
onPointerLeave={handlePointerLeave}
>
<ContextMenuTrigger
enabledOnMobile={false}
style={projectRowStyle}
onPressIn={interaction.handlePressIn}
onTouchMove={interaction.handleTouchMove}
onPressOut={interaction.handlePressOut}
onPress={handlePress}
testID={`sidebar-project-row-${project.projectKey}`}
>
{rowChildren}
</ContextMenuTrigger>
</View>
);
}
const PressableComponent = menuController
? (ProjectHeaderContextMenuTrigger as unknown as ComponentType<PressableProps>)
: undefined;
return (
<View
@@ -1285,20 +1198,30 @@ function ProjectHeaderRow({
onPointerEnter={handlePointerEnter}
onPointerLeave={handlePointerLeave}
>
<Pressable
style={projectRowStyle}
<SidebarProjectHeaderRow
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}
onPress={handlePress}
testID={`sidebar-project-row-${project.projectKey}`}
>
{rowChildren}
</Pressable>
PressableComponent={PressableComponent}
/>
</View>
);
}
const ProjectHeaderContextMenuTrigger = (props: PressableProps): ReactElement => (
<ContextMenuTrigger enabledOnMobile={false} {...(props as object)} />
);
function WorkspaceRowInner({
workspace,
selected,
@@ -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<PressableProps & { ref?: Ref<View> }>;
}
/**
* 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<View, SidebarProjectHeaderRowProps>(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<ReactElement | null>(() => {
if (showChevron && chevron) {
return (
<View style={sidebarProjectHeaderRowStyles.leadingSlot}>
<SidebarProjectChevron chevron={chevron} />
</View>
);
}
if (leadingVisualOverride) {
return leadingVisualOverride;
}
return <SidebarProjectIcon projectName={projectName} iconDataUri={iconDataUri} />;
}, [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 (
<PressableComponent
ref={ref}
style={pressableStyle}
onPress={onPress}
onPressIn={onPressIn}
onTouchMove={onTouchMove}
onPressOut={onPressOut}
accessibilityLabel={accessibilityLabel}
testID={testID}
>
<SidebarProjectRowVisual
projectName={projectName}
iconDataUri={iconDataUri}
leadingVisual={leadingVisual}
/>
{trailingSlot}
</PressableComponent>
);
}),
);
function SidebarProjectChevron({
chevron,
}: {
chevron: SidebarProjectChevronDirection;
}): ReactElement {
if (chevron === "collapse") {
return <ChevronDown size={14} color="#9ca3af" />;
}
return <ChevronRight size={14} color="#9ca3af" />;
}
/**
* Outer wrapper that carries hover state for a `<SidebarProjectHeaderRow>` plus extra props
* (drag handle attrs, ref). Sessions uses the lighter `<SidebarProjectHeaderHoverWrap>`.
*/
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 (
<View ref={ref} {...rest} onPointerEnter={handleEnter} onPointerLeave={handleLeave}>
{children}
</View>
);
});
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 `<SidebarProjectHeaderRow>`. 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],
},
}));
@@ -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", () => {
@@ -8,22 +8,24 @@ export function useSidebarSessionsController({ serverId }: { serverId: string |
type: "all",
});
const [groupByProject, setGroupByProjectState] = useState(false);
const [expandedProjects, setExpandedProjects] = useState<ReadonlySet<string>>(() => new Set());
const [previewExpandedProjects, setPreviewExpandedProjects] = useState<ReadonlySet<string>>(
() => 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,
};
}
@@ -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<typeof useSessionStore.getState>;
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<string>;
previewExpandedProjects: ReadonlySet<string>;
collapsedProjectKeys: ReadonlySet<string>;
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 (
<View style={styles.projectRow}>
<SidebarProjectRowVisual projectName={projectName} iconDataUri={dataUri} />
<View onPointerEnter={handlePointerEnter} onPointerLeave={handlePointerLeave}>
<SidebarProjectHeaderRow
projectName={projectName}
iconDataUri={dataUri}
chevron={isCollapsed ? "expand" : "collapse"}
isHovered={isHovered}
onPress={handlePress}
testID={`sidebar-session-group-header-${projectKey}`}
accessibilityLabel={isCollapsed ? `Expand ${projectName}` : `Collapse ${projectName}`}
/>
</View>
);
});
@@ -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}`}
>
<View style={styles.footerIconColumn} />
<Text style={styles.footerText}>{isExpanded ? "Show less" : `Show ${hiddenCount} more`}</Text>
</Pressable>
);
});
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;
@@ -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<string, unknown>) =>
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<string> }) {
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<string>(), []);
const previewExpandedProjects = useMemo(() => new Set<string>(), []);
const fallbackCollapsed = useMemo(() => new Set<string>(), []);
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();
@@ -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,
});
@@ -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<string>;
previewExpandedProjects?: ReadonlySet<string>;
collapsedProjectKeys?: ReadonlySet<string>;
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<string, SidebarSessionGroupDraft>();
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<string> = new Set();
@@ -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<string, unknown>) =>
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());
+33 -15
View File
@@ -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<string>;
onProjectExpandedToggle: (projectKey: string) => void;
previewExpandedProjects: ReadonlySet<string>;
collapsedProjectKeys: ReadonlySet<string>;
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<string>;
onProjectExpandedToggle: (projectKey: string) => void;
previewExpandedProjects: ReadonlySet<string>;
collapsedProjectKeys: ReadonlySet<string>;
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 (
<SidebarSessionGroupHeader
serverId={serverId}
projectKey={item.projectKey}
projectName={item.projectName}
projectIconKey={item.projectIconKey}
isCollapsed={item.isCollapsed}
onToggleCollapsed={onProjectCollapsedToggle}
/>
);
case "row":
return <SidebarSessionRow id={item.id} serverId={item.serverId} />;
return <SidebarSessionRow id={item.id} serverId={item.serverId} indented />;
case "footer":
return (
<SidebarSessionGroupFooter
projectKey={item.projectKey}
hiddenCount={item.hiddenCount}
isExpanded={item.isExpanded}
onPress={onProjectExpandedToggle}
onPress={onProjectPreviewExpandedToggle}
/>
);
}
},
[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]);