mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-28 04:03:05 +00:00
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:
@@ -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());
|
||||
|
||||
@@ -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]);
|
||||
|
||||
Reference in New Issue
Block a user